From 6029acd40b952bfc1b4e220cab123bae42127d13 Mon Sep 17 00:00:00 2001 From: imSp4rky Date: Thu, 22 Oct 2026 18:57:07 -0600 Subject: [PATCH] feat(themes): load CLI themes from YAML files, with gradients Ship 11 themes as YAML and read user themes from directories.themes, with extends, styles and gradients (banner, bar, pulse, rule, panel); env theme lists themes and plays an animated sample, and dl pins a moving banner. --- .gitignore | 2 + docs/getting-started/configuration-file.md | 1 + docs/guide/cli-reference.md | 9 +- docs/guide/output-and-naming.md | 1 + docs/guide/themes.md | 190 +++++++ docs/reference/configuration/directories.md | 1 + docs/reference/configuration/misc.md | 2 +- mkdocs.yml | 1 + pyproject.toml | 2 +- tests/core/test_themes.py | 552 ++++++++++++++++++++ unshackle/commands/env.py | 251 +++++---- unshackle/commands/setup.py | 1 + unshackle/core/__main__.py | 55 +- unshackle/core/config.py | 1 + unshackle/core/console.py | 444 +++++++++++++--- unshackle/core/themes.py | 409 +++++++++------ unshackle/themes/catppuccin-mocha.yaml | 21 + unshackle/themes/dracula.yaml | 21 + unshackle/themes/flexoki.yaml | 21 + unshackle/themes/gruvbox.yaml | 21 + unshackle/themes/monokai.yaml | 21 + unshackle/themes/nord.yaml | 21 + unshackle/themes/one-dark.yaml | 21 + unshackle/themes/rainbow.yaml | 30 ++ unshackle/themes/solarized-dark.yaml | 21 + unshackle/themes/tokyo-night.yaml | 21 + unshackle/themes/unshackle.yaml | 22 + uv.lock | 2 +- 28 files changed, 1817 insertions(+), 348 deletions(-) create mode 100644 docs/guide/themes.md create mode 100644 tests/core/test_themes.py create mode 100644 unshackle/themes/catppuccin-mocha.yaml create mode 100644 unshackle/themes/dracula.yaml create mode 100644 unshackle/themes/flexoki.yaml create mode 100644 unshackle/themes/gruvbox.yaml create mode 100644 unshackle/themes/monokai.yaml create mode 100644 unshackle/themes/nord.yaml create mode 100644 unshackle/themes/one-dark.yaml create mode 100644 unshackle/themes/rainbow.yaml create mode 100644 unshackle/themes/solarized-dark.yaml create mode 100644 unshackle/themes/tokyo-night.yaml create mode 100644 unshackle/themes/unshackle.yaml diff --git a/.gitignore b/.gitignore index ee420d65..e9afb425 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # unshackle unshackle.yaml unshackle.yml +!unshackle/themes/unshackle.yaml update_check.json *.mkv *.mp4 @@ -27,6 +28,7 @@ unshackle/binaries/* # test fixtures (binary subtitle samples) must be tracked despite the *.mp4 rule above !tests/tracks/fixtures/*.mp4 unshackle/cache/ +unshackle/themes/custom/ unshackle/cookies/ unshackle/certs/ unshackle/WVDs/ diff --git a/docs/getting-started/configuration-file.md b/docs/getting-started/configuration-file.md index f04e5746..5466a359 100644 --- a/docs/getting-started/configuration-file.md +++ b/docs/getting-started/configuration-file.md @@ -159,6 +159,7 @@ The directories unshackle uses: | `wvds` | Widevine devices (`.wvd` files) | Yes | | `prds` | PlayReady devices (`.prd` files) | Yes | | `dcsl` | DCSL data | Yes | +| `themes` | Your [theme files](../guide/themes.md) | Yes | | `services` | Search paths for service code (see below) | Yes | | `commands` | CLI command modules | Yes | | `vaults` | Vault modules | Yes | diff --git a/docs/guide/cli-reference.md b/docs/guide/cli-reference.md index a2d0e9a1..9257c67e 100644 --- a/docs/guide/cli-reference.md +++ b/docs/guide/cli-reference.md @@ -391,10 +391,15 @@ Shows the location unshackle loaded the configuration file from. If it found no ### `env theme` ``` -unshackle env theme +unshackle env theme [NAME] ``` -Prints a sample of every available CLI theme. Each sample has colour swatches, help text with option rows, and a track listing with video, audio and subtitle entries. It also has log lines, a progress bar, and the gradient pulse bar. unshackle marks the active theme and shows each theme's aliases. Set your choice with the [`theme`](../reference/configuration/misc.md#appearance) config key. +Without `NAME`, lists every theme with its source (shipped or user), its colours and the active +mark. A theme file that is not valid shows with the reason. The list also shows the folder of your +theme files. With `NAME`, prints a sample of that theme: a rule with its name, the banner, colour +swatches, help text with option rows, a track listing, log lines, a progress bar and a pulse bar. +In a terminal at least 80 columns wide, the sample moves for 5 seconds. Set your choice with +the [`theme`](../reference/configuration/misc.md#appearance) config key. See [Themes](themes.md). ### `env clear` diff --git a/docs/guide/output-and-naming.md b/docs/guide/output-and-naming.md index 1d452747..a7521e0d 100644 --- a/docs/guide/output-and-naming.md +++ b/docs/guide/output-and-naming.md @@ -431,6 +431,7 @@ directories: | `exports` | Export JSONs. | | `services`, `vaults`, `fonts`, `commands` | Search paths for services, key-vault backends, bundled fonts, and CLI commands. | | `wvds`, `prds`, `dcsl` | Widevine devices, PlayReady devices, and DCSL data. | +| `themes` | Your [theme files](themes.md). | Paths can hold a `~` for expansion. unshackle protects the names `app_dirs`, `core_dir`, `namespace_dir`, `user_configs`, and `data`, so you cannot change them. diff --git a/docs/guide/themes.md b/docs/guide/themes.md new file mode 100644 index 00000000..00288b95 --- /dev/null +++ b/docs/guide/themes.md @@ -0,0 +1,190 @@ +# Themes + +A theme sets the colours of all CLI output: the banner, logs, track listings, progress bars and +help screens. Every theme is a small theme file in YAML, so you can change any colour, blend +colours into gradients, make a new theme from a shipped one, and give your theme to other people +as one file. + +unshackle ships these themes: + +| Theme | Look | +|-------|------| +| `unshackle` | The default: soft pastels on a dark blue-grey background. | +| `rainbow` | The `unshackle` colours with every [gradient](#gradients) on. | +| `nord` | Cool arctic blues with an aurora purple accent. | +| `catppuccin-mocha` | Catppuccin Mocha pastels. | +| `dracula` | Dark purple-grey with vivid pink and green. | +| `flexoki` | Warm ink-on-paper tones. | +| `gruvbox` | Retro warm browns and yellows. | +| `monokai` | High-contrast greens and pinks on charcoal. | +| `one-dark` | Muted blues and purples on slate. | +| `solarized-dark` | Teal-black background with precise accents. | +| `tokyo-night` | Deep navy with neon blues and purples. | + +Select a theme with the [`theme`](../reference/configuration/misc.md#appearance) config key: + +```yaml title="unshackle.yaml" +theme: nord +``` + +## List and preview themes + +``` +unshackle env theme # list every theme, its source and its colours +unshackle env theme nord # show a full sample of one theme +``` + +The list marks the active theme and shows the folder unshackle reads your theme files from. A +theme file that is not valid shows in the list with the reason. + +In a terminal at least 80 columns wide, the sample of one theme moves for 5 seconds: the banner, rule and panel gradients, +a progress bar and a pulse bar. The sample stops when the progress bar is full. If the sample is +taller than the terminal, only the bars move. + +## Make a theme + +1. Make a file named `.yaml` (or `.yml`) in your themes folder. The folder is + [`directories.themes`](../reference/configuration/directories.md); `unshackle env theme` shows + its path. The file name is the theme name. Case does not matter, and `_` reads as `-`. Do not + give the file one of the [other names](#when-unshackle-does-not-use-a-theme) of the shipped + themes, such as `default.yaml`: unshackle does not read it. +2. Set the colours that you want to change. Each role that you do not set comes from the + `extends` theme. +3. Set `theme: ` in `unshackle.yaml`, then preview it with `unshackle env theme `. + +!!! warning "Keep the themes folder outside the installed package" + By default, the themes folder is inside the unshackle package folder. If you installed with + `uv tool install`, a `uv tool upgrade` deletes the package folder and your themes with it. Set + [`directories.themes`](../reference/configuration/directories.md) to a folder outside the + package, for example `~/.config/unshackle/themes`. If a data folder is inside the package, + `unshackle setup` shows a warning and the `directories:` settings to add. + +```yaml title="themes/custom/my-nord.yaml" +extends: nord # start from this shipped theme +accent: "#88c0d0" # the brand accent: banner, titles, progress bars +text2: "#9aa4b8" +styles: + progress.data.speed: bold accent +author: your name # optional, unshackle does not read it +description: Nord with a frost accent. +``` + +!!! warning "Put colours in quotes" + YAML reads `#` as the start of a comment, so an unquoted `pink: #b48ead` is an empty value. + YAML also reads an unquoted `on`, `off`, `yes` or `no` as true or false. A role or a style that + YAML does not read as text makes the theme not valid, and unshackle does not use it. + +### `extends` + +`extends` names the shipped theme that the file starts from, for example `nord`. Without +`extends`, a file named like a shipped theme starts from that theme, and any other file starts +from `unshackle`. A file named `nord.yaml` replaces the shipped `nord` theme, and it can still +use `extends: nord` to start from the shipped colours. + +`extends` cannot name one of your own themes, so a theme that you give to another person always +works on their computer. + +### Roles + +A theme has 14 roles. Each role is one colour: a colour name (`magenta`), 6-digit hex (`"#88c0d0"`), +or `rgb(136,192,208)`. Rich does not accept 3-digit hex such as `#fff`. + +| Role | Used for | +|------|----------| +| `text` | Body text, option names, help text. | +| `text2` | Secondary detail lines, the second part of a docstring. | +| `pink` | The brand accent: banner, titles, panel borders on help screens, progress bars. You can also write it as `accent`; do not set both. | +| `red` | Errors and required options. | +| `green` | Success, option switches, a finished progress bar. | +| `yellow` | Warning text, metavars and usage lines. | +| `blue` | Blue text in markup and listings. | +| `cyan` | Numbers and arguments. | +| `gray` | Dim details such as option defaults. | +| `bright_gray` | Light grey details. | +| `dark_gray` | Rules, table lines, the empty part of a progress bar. | +| `black` | Very dark details, and the Rich colour `grey23`. | +| `bright_black` | Panel borders and tree guides in listings. | +| `bg` | The background, used only when [`set_terminal_bg`](../reference/configuration/misc.md#logging-privacy-debug) is on. | + +The role names are also markup names in unshackle and in services (`[text2]`, `[yellow]`), so +they do not change between versions. The terminal colour names `magenta`, `bright_red`, +`bright_green`, `bright_yellow`, `bright_blue`, `bright_magenta` and `bright_cyan` take the colour +of the matching role. To change one of them alone, set it under `styles`. + +### `styles` + +`styles` sets any [Rich style](https://rich.readthedocs.io/en/stable/style.html) by its name, +for example `progress.data.speed`, `bar.complete`, `rule.line` or `logging.level.info`. A value +is a Rich style string, and it can use role names in any case, which take the theme's colours: + +```yaml +styles: + bar.complete: bold accent + rule.line: dark_gray + logging.level.warning: bold yellow on black +``` + +A role name cannot be a style name under `styles`; set the role at the top level of the file. A +style cannot use the `link` or `conceal` attributes, so a shared theme cannot hide output or turn +text into a link. Help screens use the roles only, so `styles` does not change them. +`unshackle cfg` edits only `unshackle.yaml`, so edit the theme file to change a style. + +The log level labels (`INFO`, `WARNING`, `ERROR`) keep the terminal's own colours. To theme them, +set them under `styles`, for example `logging.level.info: blue`. + +### `gradients` + +`gradients` draws a part of the CLI in colours that blend from one to the next, for example a +rainbow banner. Give each part a list of colour stops. A stop is a role name or a colour, as for +a role. The shipped `rainbow` theme sets every part; `unshackle env theme rainbow` shows them +move: + +```yaml title="rainbow.yaml" +gradients: + banner: [red, "#fab387", yellow, green, cyan, blue, pink] + bar: [red, yellow, green, cyan, blue, pink] + pulse: [dark_gray, red, yellow, green, blue, pink] + rule: [red, yellow, green, cyan, blue, pink] + panel: [red, yellow, green, blue] + direction: diagonal + motion: scroll +``` + +| Key | What it draws | +|-----|---------------| +| `banner` | The ASCII banner on start and on help screens. During `dl`, the banner stays at the top of the screen and moves. | +| `bar` | The full part of a running progress bar. A finished bar keeps `bar.finished`. | +| `pulse` | The moving bar of a task with no known size, such as muxing. Without it, `[dark_gray, pink]`. | +| `rule` | The line of a section rule such as `Service: EXAMPLE`. A rule always blends from left to right. | +| `panel` | The border of a boxed listing such as `Available Tracks`. The title and the lines inside keep their colours. | +| `direction` | How `banner` and `panel` blend: `horizontal` (the default), `vertical` or `diagonal`. | +| `motion` | `static` (the default) keeps the colours fixed along `bar`. `scroll` moves them along the bar. | + +A part without a list keeps its normal colour. A single colour (`rule: blue`) draws the part in +that colour. A terminal with fewer colours shows the nearest colours it has. + +With a `banner` gradient, `dl` keeps the banner at the top of the screen while it runs. The newest +output shows under the banner and the progress bars show at the bottom. Older lines scroll off the +top into the scrollback. The gradients of the banner, and of the rules and panel borders on the +screen, move. This happens only in a colour terminal at least 80 columns wide, and not for the +help of `dl` (`--help`, `-h` or `-?`). To keep the banner still, remove `banner` from `gradients`. + +## Give a theme to other people + +Send the theme file. The other person puts it in their themes folder and sets `theme:` to its name. +A theme file holds only colours, style strings and gradient settings, and unshackle reads it as +data, never as code. + +## When unshackle does not use a theme + +If the theme name is unknown, or the file has an error, unshackle logs a warning with the reason +and uses the shipped `unshackle` theme. The reason names the file and the role, for example +`my-nord.yaml: pink is empty (put hex values in quotes), using the unshackle theme`. A file with an unknown key is not valid, so a misspelt key does +not go unseen. + +These names are other names for shipped themes: `default` (`unshackle`), `catppuccin` and +`mocha` (`catppuccin-mocha`), `onedark` (`one-dark`), `solarized` (`solarized-dark`) and +`tokyonight` (`tokyo-night`). unshackle does not read a theme file with one of these names, or a +file whose name has a character that the terminal does not print. + +If unshackle cannot read the themes folder, it logs a warning and uses only the shipped themes. diff --git a/docs/reference/configuration/directories.md b/docs/reference/configuration/directories.md index b6491212..839f3ea3 100644 --- a/docs/reference/configuration/directories.md +++ b/docs/reference/configuration/directories.md @@ -25,6 +25,7 @@ directories: | `wvds` | path | `/WVDs` | Yes | Widevine device files (`.wvd`). | | `prds` | path | `/PRDs` | Yes | PlayReady device files (`.prd`). | | `dcsl` | path | `/DCSL` | Yes | DCSL data. | +| `themes` | path | `/themes/custom` | Yes | Your [theme files](../../guide/themes.md) (`.yaml`). | | `commands` | path | `unshackle/commands` | Yes | CLI command modules. | | `services` | list \| path | `[unshackle/services]` | Yes | Service search paths and/or remote repo specs (see below). | | `vaults` | path | `unshackle/vaults` | Yes | Vault backend modules. | diff --git a/docs/reference/configuration/misc.md b/docs/reference/configuration/misc.md index a4481de2..a77daac8 100644 --- a/docs/reference/configuration/misc.md +++ b/docs/reference/configuration/misc.md @@ -145,7 +145,7 @@ calls. | 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`. | +| `theme` | str | `unshackle` | Colour theme for all CLI output: banner, logs, progress bars, and help screens. The shipped themes are `unshackle`, `rainbow`, `nord`, `catppuccin-mocha`, `dracula`, `flexoki`, `gruvbox`, `monokai`, `one-dark`, `solarized-dark` and `tokyo-night`. Some have other names, for example `default` for `unshackle`; see [Themes](../../guide/themes.md#when-unshackle-does-not-use-a-theme). Any other name is a theme file in [`directories.themes`](directories.md). Case-insensitive; underscores work in place of hyphens. An unknown name or a theme file that is not valid falls back to `unshackle` with a warning. List the themes with `unshackle env theme`. See [Themes](../../guide/themes.md). | --- diff --git a/mkdocs.yml b/mkdocs.yml index 38712d8e..ae9e38a0 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -120,6 +120,7 @@ nav: - Key Vaults: guide/vaults.md - Output & Naming: guide/output-and-naming.md - Subtitles: guide/subtitles.md + - Themes: guide/themes.md - Troubleshooting: guide/troubleshooting.md - Configuration: - Overview: reference/configuration/index.md diff --git a/pyproject.toml b/pyproject.toml index c967b56f..e442d8a9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dependencies = [ "pywidevine[serve]>=1.8.0,<2", "PyYAML>=6.0.1,<7", "requests[socks]>=2.32.5,<3", - "rich>=13.7.1,<15", + "rich>=14,<15", "rlaphoenix.m3u8>=3.4.0,<4", "ruamel.yaml>=0.18.6,<0.19", "sortedcontainers>=2.4.0,<3", diff --git a/tests/core/test_themes.py b/tests/core/test_themes.py new file mode 100644 index 00000000..3dc6e8c8 --- /dev/null +++ b/tests/core/test_themes.py @@ -0,0 +1,552 @@ +"""Theme files: shipped themes, user files on top of them, and the errors that reject a file.""" + +from __future__ import annotations + +import io +import logging +import subprocess +import sys +import time +from pathlib import Path + +import pytest +from click.testing import CliRunner +from rich import box +from rich.console import Console, RenderableType +from rich.panel import Panel +from rich.progress import Progress +from rich.rule import Rule +from rich.segment import Segment +from rich.style import Style +from rich.text import Text +from rich.tree import Tree + +from unshackle.commands.env import env +from unshackle.core.config import config +from unshackle.core.console import ( + ComfyConsole, + Gradient, + GradientPulseBarColumn, + PinnedLive, + SyncLive, + gradient_fill, + gradient_lut, + slide_gradients, + use_gradients, +) +from unshackle.core.themes import ( + ALIASES, + DEFAULT_THEME, + ROLES, + apply_theme, + build_console_theme, + build_help_config, + load_theme, + read_theme_file, + shipped_themes, + user_themes, +) + + +@pytest.fixture +def themes_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + monkeypatch.setattr(config.directories, "themes", tmp_path) + return tmp_path + + +def write(folder: Path, name: str, text: str) -> None: + (folder / f"{name}.yaml").write_text(text, encoding="utf-8") + + +@pytest.mark.parametrize("name", sorted(shipped_themes())) +def test_shipped_theme_sets_every_role(name: str) -> None: + assert set(read_theme_file(shipped_themes()[name])) >= set(ROLES) + palette, styles, _ = load_theme(name, user_files=False) + for set_terminal_bg in (False, True): + build_console_theme(palette, styles, set_terminal_bg) + build_help_config(palette) + + +def test_the_shipped_themes_ship() -> None: + # the root .gitignore hides unshackle.yaml; the default theme needs its exception + assert {DEFAULT_THEME, "nord", "rainbow", "dracula", "tokyo-night"} <= set(shipped_themes()) + + +@pytest.mark.parametrize("alias", sorted(ALIASES)) +def test_every_alias_names_a_shipped_theme(alias: str) -> None: + assert load_theme(alias) == load_theme(ALIASES[alias], user_files=False) + + +def test_user_theme_extends_a_shipped_theme(themes_dir: Path) -> None: + write(themes_dir, "My_Nord", 'extends: nord\naccent: "#88c0d0"\n') + nord, nord_styles, _ = load_theme("nord") + + palette, styles, _ = load_theme("my-nord") + + assert palette == {**nord, "pink": "#88c0d0"} + assert styles == nord_styles + + +def test_without_extends_a_new_theme_starts_from_the_default(themes_dir: Path) -> None: + write(themes_dir, "mine", 'text: "#ffffff"\n') + assert load_theme("mine")[0] == {**load_theme(DEFAULT_THEME)[0], "text": "#ffffff"} + + +def test_a_user_file_replaces_a_shipped_theme_that_it_extends(themes_dir: Path) -> None: + write(themes_dir, "nord", 'extends: nord\ntext: "#ffffff"\n') + assert load_theme("nord")[0]["text"] == "#ffffff" + assert load_theme("nord", user_files=False)[0]["text"] != "#ffffff" + + +def test_alias_names_are_reserved(themes_dir: Path) -> None: + write(themes_dir, "default", 'text: "#000000"\n') + assert "default" not in user_themes() + assert load_theme("default") == load_theme(DEFAULT_THEME, user_files=False) + + +def test_styles_resolve_role_names_to_the_theme_colours(themes_dir: Path) -> None: + write(themes_dir, "mine", 'extends: nord\ncyan: "#00ffff"\nstyles:\n bar.complete: bold accent on cyan\n') + palette, styles, _ = load_theme("mine") + assert styles["bar.complete"] == f"bold {palette['pink']} on #00ffff" + + +@pytest.mark.parametrize( + "text, reason", + [ + ("pink: #b48ead\n", "put hex values in quotes"), + ('pink: "#fff"\n', "pink:"), + ('pink: "#ff79c6"\naccent: "#ff79c6"\n', "pink or accent, not both"), + ('pnik: "#ff79c6"\n', "unknown key pnik"), + ('styles:\n pink: "#ff79c6"\n', "set pink at the top level"), + ("styles:\n rule.line: shiny\n", "styles: rule.line"), + ("extends: no-such-theme\n", "not a shipped theme"), + ("extends: [nord]\n", "extends must be a quoted theme name"), + ("- pink\n", "not a mapping"), + ('red: "rgb(1,,2)"\n', "is not a colour"), + ("red: off\n", "red must be a quoted colour"), # YAML reads an unquoted off as False + ("styles:\n on: bold\n", "quote .* and its value"), + ("red: " + "[" * 600 + "]" * 600 + "\n", "cannot read"), + ("styles:\n none: link https://example.com\n", "link and conceal not allowed"), + ("styles:\n logging.level.error: conceal\n", "link and conceal not allowed"), + ("gradients: [red, blue]\n", "gradients must be a mapping"), + ("gradients:\n logo: [red]\n", "gradients: unknown key 'logo'"), + ("gradients:\n direction: sideways\n", "direction must be horizontal or vertical or diagonal"), + ("gradients:\n motion: true\n", "motion must be static or scroll"), + ("gradients:\n bar: []\n", "bar must be a list of quoted colours"), + ("gradients:\n bar: [[red]]\n", "bar must be a list of quoted colours"), + ('gradients:\n bar: [red, "#12"]\n', "gradients: bar: '#12' is not a colour"), + ], +) +def test_a_bad_file_is_rejected_with_its_reason(themes_dir: Path, text: str, reason: str) -> None: + write(themes_dir, "bad", text) + with pytest.raises(ValueError, match=f"(?i)bad.yaml: .*{reason}"): + load_theme("bad") + + +def test_a_yml_file_is_a_theme_too(themes_dir: Path) -> None: + (themes_dir / "mine.yml").write_text('text: "#ffffff"\n', encoding="utf-8") + assert load_theme("mine")[0]["text"] == "#ffffff" + + +def test_role_names_in_styles_ignore_case(themes_dir: Path) -> None: + write(themes_dir, "mine", "extends: nord\nstyles:\n bar.complete: Bold RED on Accent\n") + palette, styles, _ = load_theme("mine") + assert styles["bar.complete"] == f"Bold {palette['red']} on {palette['pink']}" + + +def test_rgb_with_spaces_is_stored_without_them(themes_dir: Path) -> None: + write(themes_dir, "mine", 'cyan: "rgb(1, 2, 3)"\n') + palette, styles, _ = load_theme("mine") + assert palette["cyan"] == "rgb(1,2,3)" + build_console_theme(palette, styles, set_terminal_bg=True) + + +def test_a_file_that_is_not_utf8_is_rejected(themes_dir: Path) -> None: + (themes_dir / "bad.yaml").write_bytes(b"red: \xff\n") + with pytest.raises(ValueError, match="bad.yaml: cannot read"): + load_theme("bad") + + +def test_yaml_anchors_cannot_expand_a_role_value(themes_dir: Path) -> None: + # an alias bomb: a thousand strings from a few hundred bytes + write( + themes_dir, + "bad", + "description: &a [" + ", ".join(['"lol"'] * 10) + "]\n" + "author: &b [" + ", ".join(["*a"] * 10) + "]\n" + "red: [" + ", ".join(["*b"] * 10) + "]\n", + ) + + start = time.monotonic() + with pytest.raises(ValueError) as error: + load_theme("bad") + assert time.monotonic() - start < 1 + assert len(str(error.value)) < 200 + + +def test_an_unknown_name_is_rejected(themes_dir: Path) -> None: + with pytest.raises(ValueError, match="^unknown theme 'no-such-theme'$"): + load_theme("no-such-theme") + + +def test_console_falls_back_to_the_shipped_default(themes_dir: Path) -> None: + # console builds its theme at import, so this needs a fresh interpreter + write(themes_dir, DEFAULT_THEME, "pink: #b48ead\n") + script = ( + "import sys\n" + "from unshackle.core.config import config\n" + "config.theme = 'default'\n" + "config.directories.themes = sys.argv[1]\n" + "import unshackle.core.console as c\n" + "print(c.primary_scheme['pink'])\n" + ) + result = subprocess.run( + [sys.executable, "-c", script, str(themes_dir)], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == load_theme(DEFAULT_THEME, user_files=False)[0]["pink"] + assert "unshackle.yaml: pink is empty" in result.stderr + + +def test_env_theme_lists_themes_and_flags_a_broken_file(themes_dir: Path) -> None: + write(themes_dir, "mine", "extends: nord\n") + write(themes_dir, "bad", "pink: #b48ead\n") + result = CliRunner().invoke(env, ["theme"]) + assert result.exit_code == 0, result.output + assert "mine" in result.output and "nord" in result.output + assert "not valid" in result.output + + +def test_env_theme_marks_the_fallback_as_active(themes_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import unshackle.core.console as console_module + + monkeypatch.setattr(config, "theme", "no-such-theme") + monkeypatch.setattr(console_module, "active_theme", DEFAULT_THEME) + result = CliRunner().invoke(env, ["theme"]) + assert result.exit_code == 0, result.output + assert "(active, fallback)" in result.output + + +def test_env_theme_previews_one_theme(themes_dir: Path) -> None: + result = CliRunner().invoke(env, ["theme", "nord"]) + assert result.exit_code == 0, result.output + assert "Available Tracks" in result.output + + +def test_env_theme_names_the_error_for_a_bad_theme(themes_dir: Path) -> None: + result = CliRunner().invoke(env, ["theme", "no-such-theme"]) + assert result.exit_code == 1 + assert "unknown theme 'no-such-theme'" in result.output + + +def test_progress_bar_accepts_a_named_colour(monkeypatch: pytest.MonkeyPatch) -> None: + import unshackle.core.console as console_module + + palette = {**console_module.primary_scheme, "pink": "magenta", "dark_gray": "default"} + monkeypatch.setattr(console_module, "primary_scheme", palette) + GradientPulseBarColumn() + + +def test_help_ignores_the_rich_click_theme_env_var() -> None: + assert build_help_config(load_theme(DEFAULT_THEME, user_files=False)[0]).enable_theme_env_var is False + + +def test_gradients_resolve_role_names_and_take_one_colour(themes_dir: Path) -> None: + write( + themes_dir, + "mine", + 'gradients:\n direction: diagonal\n motion: scroll\n bar: [accent, "rgb(1, 2, 3)"]\n rule: Blue\n', + ) + palette, _, gradients = load_theme("mine") + assert gradients == { + "direction": "diagonal", + "motion": "scroll", + "bar": (palette["pink"], "rgb(1,2,3)"), + "rule": (palette["blue"],), + } + + +def test_gradients_merge_over_the_base_theme() -> None: + palette = load_theme(DEFAULT_THEME, user_files=False)[0] + _, _, gradients = apply_theme("x.yaml", {"gradients": {"rule": "#123456"}}, palette, {}, {"bar": ("#000000",)}) + assert gradients == {"bar": ("#000000",), "rule": ("#123456",)} + + +def test_gradient_lut_runs_from_the_first_stop_to_the_last() -> None: + lut = gradient_lut(("#ff0000", "#00ff00", "#0000ff")) + assert [hex_of(lut[0]), hex_of(lut[-1])] == ["#ff0000", "#0000ff"] + cyclic = gradient_lut(("#ff0000", "#0000ff"), cyclic=True) + assert cyclic[0] == cyclic[-1] + assert len(gradient_lut(("red",))) == 1 + + +def hex_of(style: Style) -> str: + assert style.color is not None + return style.color.get_truecolor().hex + + +def render(renderable: RenderableType) -> list[Segment]: + return list(Console(file=io.StringIO(), color_system="truecolor", width=40).render(renderable)) + + +def colours_of(segments: list[Segment], characters: str) -> list[str]: + """The colour of each segment whose text is one of *characters*, or is *characters*.""" + return [ + hex_of(s.style) + for s in segments + if (s.text in characters or s.text == characters) and s.text.strip() and s.style and s.style.color + ] + + +def test_panel_gradient_paints_the_frame_and_not_the_content() -> None: + tree = Tree("root", guide_style="#888888") + tree.add("leaf") + panel = Panel(tree, title="Title", border_style="#888888", box=box.SQUARE, expand=False) + with use_gradients({"panel": ("#ff0000", "#0000ff")}): + segments = render(panel) + assert colours_of(segments, "┌") == ["#ff0000"] + assert colours_of(segments, "┘") == ["#0000ff"] + assert "#888888" in colours_of(segments, "└── ") # the tree guide keeps its colour + assert any("Title" in s.text for s in segments) + + +def test_rule_gradient_paints_the_line_and_not_the_title() -> None: + with use_gradients({"rule": ("#ff0000", "#0000ff"), "direction": "vertical"}): + segments = render(Rule("[#00ff00]Title", style="#888888")) + line = colours_of(segments, "─") + assert line[0] == "#ff0000" and line[-1] == "#0000ff" # always left to right, whatever the direction + assert colours_of(segments, "Title") == ["#00ff00"] + + +def test_without_gradients_a_panel_renders_as_rich_draws_it() -> None: + with use_gradients({}): + assert set(colours_of(render(Panel("x", border_style="#888888")), "─│╭╮╰╯")) == {"#888888"} + + +def test_gradient_fill_static_and_scrolling() -> None: + bar = gradient_fill(gradient_lut(("#ff0000", "#0000ff")), 0.5, 10, None, "#333333") + assert bar.plain == "━━━━━╺━━━━" + assert hex_of(bar.get_style_at_offset(Console(), 0)) == "#ff0000" + scrolled = gradient_fill(gradient_lut(("#ff0000", "#0000ff"), cyclic=True), 0.5, 10, 3.0, "#333333") + assert hex_of(scrolled.get_style_at_offset(Console(), 3)) == "#ff0000" + + +def test_bar_column_uses_the_gradient_until_the_task_finishes(monkeypatch: pytest.MonkeyPatch) -> None: + import unshackle.core.console as console_module + + monkeypatch.setattr(console_module, "theme_gradients", {"bar": ("#ff0000", "#0000ff")}) + column = GradientPulseBarColumn() + progress = Progress(column, console=Console(file=io.StringIO())) + progress.add_task("x", total=10, completed=5) + progress.add_task("y", total=10, completed=10) + running, done = progress.tasks + assert type(column.render(running)).__name__ == "_GradientFill" + assert type(column.render(done)).__name__ == "ProgressBar" + + +def slid(renderable: RenderableType, offset: float) -> list[Segment]: + lines = Console(file=io.StringIO(), color_system="truecolor", width=40).render_lines(renderable, pad=False) + return [segment for line in slide_gradients(lines, offset) for segment in line] + + +def test_sliding_a_gradient_moves_the_colours_and_loops() -> None: + text = Text("█" * 20, style="#888888") + with use_gradients({"banner": ("#ff0000", "#0000ff")}): + frames = [colours_of(slid(Gradient(text, "banner", "#888888"), o), "█") for o in (0.0, 0.5, 1.0)] + assert frames[0] != frames[1] + assert frames[0] == frames[2] + + +def test_sliding_moves_a_nested_panel_border_and_nothing_else() -> None: + from rich.padding import Padding + + panel = Padding(Panel("x", border_style="#888888", box=box.SQUARE), (0, 3)) + with use_gradients({"panel": ("#ff0000", "#0000ff")}): + still = colours_of(render(panel), "┌") + moved = colours_of(slid(panel, 0.5), "┌") + text = colours_of(slid(Text("x", style="#888888"), 0.5), "x") + assert still == ["#ff0000"] and moved != still + assert text == ["#888888"] + + +def test_an_unreadable_themes_folder_is_skipped_with_a_warning( + themes_dir: Path, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + def refuse(self: Path) -> None: + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(Path, "iterdir", refuse) + with caplog.at_level(logging.WARNING, logger="console"): + assert user_themes() == {} + assert "cannot read the themes folder" in caplog.text + + +def test_a_file_name_with_a_control_character_is_skipped(themes_dir: Path) -> None: + write(themes_dir, "\u202emine", 'text: "#ffffff"\n') + write(themes_dir, "mine", 'text: "#ffffff"\n') + assert list(user_themes()) == ["mine"] + + +def pinned_console(height: int = 12) -> tuple[io.StringIO, Console]: + out = io.StringIO() + return out, Console( + file=out, force_terminal=True, force_interactive=True, width=60, height=height, color_system="truecolor" + ) + + +def test_pinned_live_sends_old_lines_to_the_scrollback_once_the_screen_is_full() -> None: + out, con = pinned_console() + pinned = PinnedLive(Text("HEADER"), con) + pinned.start(refresh=True) + try: + panel = Panel("first state") + con.print(panel) + panel.renderable = "CHANGED" # a kept print shows the state it had when it was printed + con.print("narrow", width=4) + assert ["".join(segment.text for segment in line) for line in pinned.kept[-2:]] == ["narr", "ow"] + for i in range(30): + con.print(f"line {i:02d}") + assert pinned.header_sent + assert len(pinned.kept) == pinned.room # exactly the lines that fit; the rest went to the scrollback + finally: + pinned.close() + assert pinned.kept == [] + plain = Text.from_ansi(out.getvalue()).plain + assert "first state" in plain and "CHANGED" not in plain + assert "line 29" in plain + + +def test_pinned_live_prints_the_header_at_close_when_nothing_scrolled() -> None: + out, con = pinned_console(height=40) + pinned = PinnedLive(Text("HEADER"), con) + pinned.start(refresh=True) + con.print("one line") + pinned.close() + assert Text.from_ansi(out.getvalue()).plain.rstrip().endswith("HEADER\none line") + + +def test_a_prompt_inside_a_spinner_keeps_the_live_areas_in_order() -> None: + con = ComfyConsole(file=io.StringIO(), force_terminal=True, force_interactive=True, width=60, height=20) + pinned = PinnedLive(Text("HEADER"), con) + pinned.start() + try: + with con.status("working"): + assert con.input("code: ", stream=io.StringIO("123\n")).strip() == "123" + assert con._live_stack[0] is pinned and not pinned._nested + assert con._live_stack[1]._nested + finally: + pinned.close() + assert con._live_stack == [] + + +def test_sync_codes_wrap_each_print_but_not_on_the_old_windows_console() -> None: + out, con = pinned_console() + with SyncLive(Text("live"), console=con, auto_refresh=False) as live: + con.print("hello") + value = out.getvalue() + assert value.index("\x1b[?2026h") < value.index("hello") < value.index("\x1b[?2026l", value.index("hello")) + con.legacy_windows = True + printed = Text("x") + assert live.synced([printed]) == [printed] + + +@pytest.mark.parametrize( + "subcommand, args, pinned", + [ + ("dl", ["dl", "EXAMPLE", "title"], True), + ("dl", ["--debug", "dl", "-q", "1080", "EXAMPLE", "title"], True), + ("dl", ["dl"], True), # Click prints the usage text after the pinned area closes + ("dl", ["dl", "EXAMPLE", "--help"], False), + ("dl", ["dl", "-?"], False), + ("env", ["env", "info"], False), + ], +) +def test_only_a_dl_run_pins_the_banner( + monkeypatch: pytest.MonkeyPatch, subcommand: str, args: list[str], pinned: bool +) -> None: + import unshackle.core.console as console_module + from unshackle.core.__main__ import pins_banner + + monkeypatch.setattr(console_module, "theme_gradients", {"banner": ("#ff0000",)}) + monkeypatch.setattr(PinnedLive, "wanted", staticmethod(lambda live_console: True)) + assert pins_banner(subcommand, args) is pinned + monkeypatch.setattr(console_module, "theme_gradients", {}) + assert pins_banner(subcommand, args) is False + + +def test_the_banner_pins_only_on_an_interactive_colour_terminal() -> None: + _, con = pinned_console() + assert PinnedLive.wanted(con) + assert not PinnedLive.wanted( + Console(file=io.StringIO(), force_terminal=True, force_interactive=True, no_color=True) + ) + assert not PinnedLive.wanted(Console(file=io.StringIO(), force_terminal=True, force_interactive=False)) + + +@pytest.mark.parametrize("height", [5, 500]) +def test_env_theme_previews_in_a_short_and_a_tall_terminal( + themes_dir: Path, monkeypatch: pytest.MonkeyPatch, height: int +) -> None: + import unshackle.core.console as console_module + + monkeypatch.setattr(console_module.console, "_height", height) + result = CliRunner().invoke(env, ["theme", "rainbow"]) + assert result.exit_code == 0, result.output + assert result.output.count("github.com/unshackle") == 1 + assert "Available Tracks" in result.output + + +def test_a_narrow_terminal_gets_no_live_animation(monkeypatch: pytest.MonkeyPatch) -> None: + from unshackle.core.console import fits_terminal + + _, con = pinned_console() + monkeypatch.setenv("COLUMNS", str(con.width - 1)) + assert not fits_terminal(con) and not PinnedLive.wanted(con) + monkeypatch.setenv("COLUMNS", str(con.width)) + assert fits_terminal(con) and PinnedLive.wanted(con) + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX signals") +def test_sigterm_during_a_pinned_run_still_prints_the_kept_lines(tmp_path: Path) -> None: + import os + import pty + import signal + + script = tmp_path / "pinned.py" + script.write_text( + "import signal, sys, time\n" + "from rich.text import Text\n" + "from unshackle.core.console import PinnedLive, console\n" + "pinned = PinnedLive(Text('HEADER'), console)\n" + "pinned.start(refresh=True)\n" + "signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(128 + signum))\n" + "try:\n" + " console.print('kept line')\n" + " print('READY', file=sys.__stderr__, flush=True)\n" + " time.sleep(30)\n" + "finally:\n" + " pinned.close()\n", + encoding="utf-8", + ) + master, slave = pty.openpty() + proc = subprocess.Popen([sys.executable, str(script)], stdin=slave, stdout=slave, stderr=subprocess.PIPE) + os.close(slave) + assert proc.stderr is not None and b"READY" in proc.stderr.readline() + proc.send_signal(signal.SIGTERM) + out = b"" + while True: + try: + chunk = os.read(master, 65536) + except OSError: + break + if not chunk: + break + out += chunk + assert proc.wait(timeout=30) == 128 + signal.SIGTERM + tail = out[out.rfind(b"\x1b[?25h") :] + assert b"HEADER" in tail and b"kept line" in tail diff --git a/unshackle/commands/env.py b/unshackle/commands/env.py index 9ed65126..73e609bc 100644 --- a/unshackle/commands/env.py +++ b/unshackle/commands/env.py @@ -3,10 +3,12 @@ import os import shutil import sys from pathlib import Path -from typing import Optional +from typing import Any, Callable, Optional import click +from rich.console import RenderableType from rich.padding import Padding +from rich.text import Text from rich.tree import Tree from unshackle.core import binaries @@ -243,108 +245,179 @@ def check() -> None: @env.command() -def theme() -> None: - """Preview the available CLI themes.""" +@click.argument("name", required=False) +def theme(name: Optional[str]) -> None: + """List the CLI themes, or preview the theme NAME in full.""" + + from unshackle.core.console import active_theme + from unshackle.core.themes import canonical_name, load_theme, shipped_themes, user_themes + + if name: + try: + palette, styles, gradients = load_theme(name) + except ValueError as e: + raise click.ClickException(str(e)) from e + preview_theme(canonical_name(name), palette, styles, gradients) + return + + shipped, user = shipped_themes(), user_themes() + mark = " (active)" if active_theme == canonical_name(config.theme) else " (active, fallback)" + table = listing_table("Themes", show_lines=True) + table.add_column("Theme", style="cyan", no_wrap=True) + table.add_column("Source", style="text2", no_wrap=True) + table.add_column("Colours", overflow="fold") + for theme_name in sorted({*shipped, *user}): + source = "shipped" if theme_name not in user else "user, replaces shipped" if theme_name in shipped else "user" + label = Text(theme_name) + try: + palette, *_ = load_theme(theme_name) + except ValueError as e: + table.add_row(label, source, Text(f"not valid: {e}", style="red")) + continue + if theme_name == active_theme: + label.append(mark, style="green") + table.add_row(label, source, swatch(palette)) + print_wide(table) + hint = Text.assemble( + ("Theme files folder: ", "text"), + (str(config.directories.themes), "text2"), + ("\nPreview one theme with ", "text"), + ("unshackle env theme NAME", "cyan"), + ) + print_wide(hint, (0, 3, 1, 3)) + + +def swatch(palette: dict[str, str]) -> Text: + """A row of colour blocks, one for each main role of a theme.""" + + blocks = Text() + for role in ("red", "green", "yellow", "blue", "pink", "cyan", "text", "gray"): + blocks.append("██ ", style=palette[role]) + return blocks + + +def play_preview(frame: Callable[[Optional[float]], RenderableType], seconds: float) -> None: + """Play *frame* on a terminal for *seconds*, then leave its still frame. *frame* takes the time in seconds, or None.""" + import time + + from unshackle.core.console import SyncLive, fits_terminal + + if not console.is_interactive or not fits_terminal(console): + console.print(frame(None)) + return + with SyncLive(frame(0.0), console=console, auto_refresh=False) as live: + start = time.monotonic() + while (elapsed := time.monotonic() - start) < seconds: + live.update(frame(elapsed), refresh=True) + time.sleep(1 / 30) + live.update(frame(None), refresh=True) + + +def preview_theme(name: str, palette: dict[str, str], styles: dict[str, str], gradients: dict[str, Any]) -> None: + """Print a sample of each kind of CLI output in one theme.""" 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.progress_bar import ProgressBar from rich.rule import Rule - from rich.style import Style - from rich.text import Text + from rich.segment import SegmentLines + from rich.table import Table - from unshackle.core.themes import ALIASES, DEFAULT_THEME, PALETTES, resolve_palette + from unshackle.core.__main__ import banner + from unshackle.core.console import ( + PULSE_PERIOD, + PULSE_SPEED, + _GradientFill, + _GradientPulse, + bar_gradients, + listing_panel, + slide_gradients, + use_gradients, + ) + from unshackle.core.themes import build_console_theme, build_help_config - 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"] + text, text2, gray, guide = palette["text"], palette["text2"], palette["gray"], palette["bright_black"] + help_config = build_help_config(palette) - 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}") + docstring = Text() + docstring.append("Service docstrings render like this, ", style=text) + docstring.append("with secondary detail lines like this.", style=text2) - swatch = Text() - for role in ("red", "green", "yellow", "blue", "pink", "cyan", "text", "gray"): - swatch.append("██ ", style=palette[role]) + # the help screen's own styles + separator = Text("─" * 60, style=help_config.style_options_table_border_style or "") + options: list[Text] = [] + rows = ( + ("-q, ", "--quality ", "TEXT ", "Video quality to download", " [default: best]", "default"), + ("-r, ", "--range ", "[SDR|HDR10|DV] ", "Video color range", " [env: RANGE]", "envvar"), + ) + for short, long_opt, metavar, help_text, extra, extra_kind in rows: + row = Text() + row.append(short, style=help_config.style_switch) + row.append(long_opt, style=help_config.style_option) + row.append(metavar, style=help_config.style_metavar) + row.append(f" {help_text}", style=help_config.style_option_help) + row.append(extra, style=getattr(help_config, f"style_option_{extra_kind}")) + options.extend((row, separator)) - docstring = Text() - docstring.append("Service docstrings render like this, ", style=text) - docstring.append("with secondary detail lines like this.", style=text2) + 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 = listing_panel(listing, "Available Tracks") - 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']}", - ), + logs = [] + log_rows = ( + ("INFO", "Downloading 3 tracks"), + ("WARNING", "Subtitle cues overlapped, fixed 2"), + ("ERROR", "License request denied (403)"), + ) + for level, message in log_rows: + line = Text() + line.append("18:00:01 ", style=gray) + line.append(f"{level:<8} ", style=f"logging.level.{level.lower()}") + line.append(message, style=text if level != "ERROR" else palette["red"]) + logs.append(line) + + seconds = 5.0 + + def bars(t: Optional[float]) -> RenderableType: + """The bars *t* seconds into the sample. The download finishes at *seconds*, or at None.""" + now = seconds if t is None else t + fill: RenderableType = ( + _GradientFill(fill_lut, now / seconds, now * PULSE_SPEED if scroll else None, 39, "bar.back") + if fill_lut and now < seconds + else ProgressBar(total=seconds, completed=now, width=39) ) - 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)) + # style names, so a theme's `styles:` overrides show + remaining = f"0:00:{math.ceil(seconds - now):02d}" + speed = Text.assemble(" • ", (remaining, "progress.remaining"), " • ", ("12.4 MB/s", "progress.data.speed")) + grid = Table.grid() + grid.add_row(Text("Pilot ", style=text), fill, speed) + grid.add_row(Text("Muxing ", style=text), _GradientPulse(pulse_lut, 8 + now * PULSE_SPEED, PULSE_PERIOD, 39)) + return Padding(grid, (1, 2, 1, 2)) - 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) + block = Group(swatch(palette), Text(), docstring, *options, tracks, *logs) + with console.use_theme(build_console_theme(palette, styles, config.set_terminal_bg)), use_gradients(gradients): + scroll, fill_lut, pulse_lut = bar_gradients(palette) + header = Padding(Rule(f"[rule.text]{name}"), (0, 2)) + still = console.render_lines(Group(header, banner(), Padding(block, (0, 2))), pad=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) + def sample(t: Optional[float]) -> RenderableType: + """The whole sample *t* seconds in. The gradient colours loop twice while the download runs.""" + lines = still if t is None else slide_gradients(still, 2 * t / seconds) + return Group(SegmentLines(lines, new_lines=True), bars(t)) - 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) - print_wide(block, (1, 2, 1, 2)) - print_wide(Rule(style=guide, characters="─"), (0, 0)) - console.print() + # a live area taller than the terminal is cropped, so only the bars move + if len(console.render_lines(sample(None), pad=False)) < console.size.height: + play_preview(sample, seconds) + else: + console.print(SegmentLines(still, new_lines=True)) + play_preview(bars, seconds) @env.command() diff --git a/unshackle/commands/setup.py b/unshackle/commands/setup.py index c88cefce..b8806f64 100644 --- a/unshackle/commands/setup.py +++ b/unshackle/commands/setup.py @@ -136,6 +136,7 @@ DATA_DIRS = { for key in ("cookies", "wvds", "prds", "cache", "logs", "exports", "dcsl", "temp") } DATA_DIRS["services"] = Config._Directories.services[0].name +DATA_DIRS["themes"] = "themes" say = partial(console.print, soft_wrap=True) diff --git a/unshackle/core/__main__.py b/unshackle/core/__main__.py index 89aaaa02..88cdd2fd 100644 --- a/unshackle/core/__main__.py +++ b/unshackle/core/__main__.py @@ -1,7 +1,9 @@ import atexit import logging +import signal import sys from datetime import datetime +from typing import Optional import click import urllib3 @@ -12,19 +14,20 @@ from rich.text import Text from urllib3.exceptions import InsecureRequestWarning from unshackle.core import __code_hash__, __version__ +from unshackle.core import console as console_module from unshackle.core.commands import Commands from unshackle.core.config import config -from unshackle.core.console import ComfyRichHandler, console +from unshackle.core.console import ComfyRichHandler, Gradient, PinnedLive, console from unshackle.core.constants import context_settings from unshackle.core.update_checker import UpdateChecker from unshackle.core.utilities import close_debug_logger, init_debug_logger -def print_banner() -> None: - """Print the ASCII banner with the version line.""" - console.print( - Padding( - Group( +def banner() -> Padding: + """The ASCII banner with the version line.""" + return Padding( + Group( + Gradient( Text( r"▄• ▄▌ ▐ ▄ .▄▄ · ▄ .▄ ▄▄▄· ▄▄· ▄ •▄ ▄▄▌ ▄▄▄ ." + "\n" r"█▪██▌•█▌▐█▐█ ▀. ██▪▐█▐█ ▀█ ▐█ ▌▪█▌▄▌▪██• ▀▄.▀·" + "\n" @@ -32,17 +35,39 @@ def print_banner() -> None: r"▐█▄█▌██▐█▌▐█▄▪▐███▌▐▀▐█ ▪▐▌▐███▌▐█.█▌▐█▌▐▌▐█▄▄▌" + "\n" r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ ", style="ascii.art", + justify="center", ), + "banner", + "ascii.art", + ), + Text.from_markup( f"v [repr.number]{__version__}[/]{f' ({__code_hash__})' if __code_hash__ else ''}" f" - © 2025-{datetime.now().year} github.com/unshackle-dl/unshackle", + justify="center", ), - (1, 11, 1, 10), - expand=True, ), - justify="center", + (1, 11, 1, 10), + expand=True, ) +def print_banner() -> None: + """Print the ASCII banner with the version line.""" + console.print(banner()) + + +def pins_banner(subcommand: Optional[str], args: list[str]) -> bool: + """Whether to pin the moving banner over this command's output: only dl, and not for its help. + + It needs a theme with a banner gradient and a colour terminal. Help prints while Click parses the arguments, + and the pinned area would wrap it again at its own width; usage errors print after the pinned area closes. + """ + if subcommand != "dl" or not console_module.theme_gradients.get("banner") or not PinnedLive.wanted(console): + return False + help_options: list[str] = context_settings["help_option_names"] # type: ignore[assignment] + return not set(args) & set(help_options) + + class Main(Commands): """The top-level group. Its help shows the banner, because its callback does not run for --help.""" @@ -54,7 +79,8 @@ class Main(Commands): @click.command(cls=Main, invoke_without_command=True, context_settings=context_settings) @click.option("-v", "--version", is_flag=True, default=False, help="Print version information.") @click.option("-d", "--debug", is_flag=True, default=False, help="Enable DEBUG level logs and JSON debug logging.") -def main(version: bool, debug: bool) -> None: +@click.pass_context +def main(ctx: click.Context, version: bool, debug: bool) -> None: """unshackle: Modular Movie, TV, and Music Archival Software.""" debug_logging_enabled = debug or config.debug @@ -89,7 +115,14 @@ def main(version: bool, debug: bool) -> None: if "--quiet" in serve_args or "-q" in serve_args: return - print_banner() + if pins_banner(ctx.invoked_subcommand, sys.argv[1:]): + pinned = PinnedLive(banner(), console) + pinned.start(refresh=True) + ctx.call_on_close(pinned.close) + if signal.getsignal(signal.SIGTERM) is signal.SIG_DFL: + signal.signal(signal.SIGTERM, lambda signum, frame: sys.exit(128 + signum)) + else: + print_banner() if version: return diff --git a/unshackle/core/config.py b/unshackle/core/config.py index 2bd70430..30fb960e 100644 --- a/unshackle/core/config.py +++ b/unshackle/core/config.py @@ -102,6 +102,7 @@ class Config: wvds = data / "WVDs" prds = data / "PRDs" dcsl = data / "DCSL" + themes = data / "themes" / "custom" class _Filenames: # default filenames, do not modify here, set via config diff --git a/unshackle/core/console.py b/unshackle/core/console.py index 843b2fb2..12978821 100644 --- a/unshackle/core/console.py +++ b/unshackle/core/console.py @@ -4,17 +4,29 @@ import logging import math import shutil import sys +import time import unicodedata +from contextlib import contextmanager from datetime import datetime from functools import lru_cache from types import ModuleType -from typing import IO, Any, Callable, Iterable, List, Literal, Mapping, Optional, TextIO, Union +from typing import IO, Any, Callable, Iterable, Iterator, List, Literal, Mapping, Optional, TextIO, Union import rich.cells from rich import box from rich._log_render import FormatTimeCallable, LogRender +from rich.cells import cell_len from rich.color import Color, blend_rgb -from rich.console import Console, ConsoleOptions, ConsoleRenderable, HighlighterType, RenderableType, RenderResult +from rich.console import ( + Console, + ConsoleOptions, + ConsoleRenderable, + Group, + HighlighterType, + RenderableType, + RenderResult, +) +from rich.control import Control from rich.emoji import EmojiVariant from rich.highlighter import Highlighter, ReprHighlighter from rich.live import Live @@ -23,6 +35,8 @@ from rich.measure import Measurement from rich.padding import Padding, PaddingDimensions from rich.panel import Panel from rich.progress import BarColumn, Task +from rich.rule import Rule +from rich.segment import ControlType, Segment, SegmentLines, Segments from rich.status import Status from rich.style import Style, StyleType from rich.table import Table @@ -30,7 +44,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 +from unshackle.core.themes import apply_help_theme, build_console_theme, load_active_theme class ComfyLogRenderer(LogRender): @@ -311,7 +325,8 @@ class ComfyConsole(Console): # We copy the list because stop() will modify console._live_stack. active_lives = list(self._live_stack) - for live in active_lives: + # newest first: Rich's stop() pops the top of the stack + for live in reversed(active_lives): live.stop() try: @@ -323,72 +338,379 @@ class ComfyConsole(Console): stream=stream, ) finally: - for live in reversed(active_lives): + for live in active_lives: live.start() -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]) +active_theme, (primary_scheme, theme_styles, theme_gradients) = load_active_theme(config.theme) apply_help_theme(primary_scheme) -primary_scheme["none"] = primary_scheme["text"] -primary_scheme["grey23"] = primary_scheme["black"] -primary_scheme["magenta"] = primary_scheme["pink"] -primary_scheme["bright_red"] = primary_scheme["red"] -primary_scheme["bright_green"] = primary_scheme["green"] -primary_scheme["bright_yellow"] = primary_scheme["yellow"] -primary_scheme["bright_blue"] = primary_scheme["blue"] -primary_scheme["bright_magenta"] = primary_scheme["pink"] -primary_scheme["bright_cyan"] = primary_scheme["cyan"] -if config.set_terminal_bg: - primary_scheme["none"] += f" on {primary_scheme['bg']}" -custom_colors = {"ascii.art": primary_scheme["pink"]} -if config.set_terminal_bg: - custom_colors["ascii.art"] += f" on {primary_scheme['bg']}" + +@contextmanager +def use_gradients(gradients: dict[str, Any]) -> Iterator[None]: + """Draw with another theme's gradients for the duration, as `env theme NAME` does.""" + global theme_gradients + saved, theme_gradients = theme_gradients, gradients + try: + yield + finally: + theme_gradients = saved + + +@lru_cache(maxsize=32) +def gradient_lut(stops: tuple[str, ...], size: int = 64, cyclic: bool = False) -> list[Style]: + """Blend colour stops into *size* evenly spaced styles. *cyclic* blends the last stop back into the first.""" + colours = [Color.parse(stop).get_truecolor() for stop in stops] + if cyclic: + colours.append(colours[0]) + if len(colours) == 1: + return [Style(color=Color.from_triplet(colours[0]))] + lut = [] + for i in range(size): + t = i / (size - 1) * (len(colours) - 1) + n = min(int(t), len(colours) - 2) + lut.append(Style(color=Color.from_triplet(blend_rgb(colours[n], colours[n + 1], t - n)))) + return lut + + +class Gradient: + """Draw *renderable*, then recolour the cells drawn in *style*'s colour along the theme's *surface* gradient. + + With *frame_only*, only the box-drawing characters on the outer edge change, so a panel's title and the lines inside + keep their colours, even a tree guide drawn in the border's colour. Each recoloured cell carries its place along the + gradient in its style's meta, so `slide_gradients` can animate the drawn lines. + """ + + def __init__( + self, + renderable: RenderableType, + surface: str, + style: StyleType, + frame_only: bool = False, + direction: Optional[str] = None, + ) -> None: + self.renderable = renderable + self.surface = surface + self.style = style + self.frame_only = frame_only + self.direction = direction + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + stops = theme_gradients.get(self.surface) + if not stops: + yield self.renderable + return + colour = console.get_style(self.style).color + lines = console.render_lines(self.renderable, options, pad=False, new_lines=True) + + def candidate(segment: Segment) -> bool: + return not segment.control and segment.style is not None and segment.style.color == colour + + def characters(line: list[Segment]) -> Iterator[tuple[int, Segment, str]]: + x = 0 + for segment in line: + for character in segment.text if candidate(segment) else [segment.text]: + yield x, segment, character + x += cell_len(character) + + marks = [ + (x, y, character) + for y, line in enumerate(lines) + for x, segment, character in characters(line) + if candidate(segment) and character.strip() + ] + cells = {(x, y) for x, y, _ in marks} + if self.frame_only: + boxes = [(x, y) for x, y, character in marks if "─" <= character <= "╿"] + ends = (min(y for _, y in boxes), max(y for _, y in boxes)) if boxes else () + left: dict[int, int] = {} + right: dict[int, int] = {} + for x, y in boxes: + left[y], right[y] = min(left.get(y, x), x), max(right.get(y, x), x) + cells = {(x, y) for x, y in boxes if y in ends or x in (left[y], right[y])} + if not cells: + yield from (segment for line in lines for segment in line) + return + x0, y0 = min(x for x, _ in cells), min(y for _, y in cells) + direction = self.direction or theme_gradients.get("direction", "horizontal") + + def distance(x: int, y: int) -> int: + return {"horizontal": x - x0, "vertical": y - y0}.get(direction, x - x0 + 2 * (y - y0)) + + span = max(distance(x, y) for x, y in cells) or 1 + lut = gradient_lut(stops) + + def recoloured() -> Iterator[Segment]: + for y, line in enumerate(lines): + for x, segment, character in characters(line): + if candidate(segment) and (x, y) in cells: + t = distance(x, y) / span + tint = lut[round(t * (len(lut) - 1))] + Style(meta={"gradient": (self.surface, t)}) + yield Segment(character, (segment.style or Style.null()) + tint) + elif candidate(segment): + yield Segment(character, segment.style) + else: + yield segment + + yield from Segment.simplify(recoloured()) + + def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement: + return Measurement.get(console, options, self.renderable) + + +def slide_gradients(lines: list[list[Segment]], offset: float) -> list[list[Segment]]: + """Slide the gradient colours that `Gradient` drew into *lines* along by *offset* gradient lengths, looping the last + stop back into the first, so a sequence of offsets animates them. Other cells keep their colours.""" + return [[_slid(segment, offset) for segment in line] for line in lines] + + +def _slid(segment: Segment, offset: float) -> Segment: + mark = _gradient_mark(segment.style) if segment.style else None + stops = theme_gradients.get(mark[0]) if mark else None + if not mark or not stops or segment.style is None: + return segment + lut = gradient_lut(stops, cyclic=True) + return Segment(segment.text, segment.style + lut[round((mark[1] - offset) % 1 * (len(lut) - 1))]) + + +@lru_cache(maxsize=4096) +def _gradient_mark(style: Style) -> Optional[tuple[str, float]]: + """The surface and the place along it of a cell that `Gradient` coloured, or None.""" + return style.meta.get("gradient") if style._meta else None + + +class _Drawn: + """A renderable that draws *target* with Rich's own, unpatched *render* method.""" + + def __init__(self, render: Callable[..., RenderResult], target: Any) -> None: + self.render = render + self.target = target + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + return self.render(self.target, console, options) + + +def _paint_with( + render: Callable[..., RenderResult], surface: str, style_attribute: str, direction: Optional[str] = None +) -> Callable[..., RenderResult]: + """Wrap a Rich class's render method so the theme's *surface* gradient colours its lines.""" + + def __rich_console__(self: Any, console: Console, options: ConsoleOptions) -> RenderResult: + if not theme_gradients.get(surface): + return render(self, console, options) + gradient = Gradient(_Drawn(render, self), surface, getattr(self, style_attribute), True, direction) + return gradient.__rich_console__(console, options) + + return __rich_console__ + + +Rule.__rich_console__ = _paint_with(Rule.__rich_console__, "rule", "style", "horizontal") # type: ignore[method-assign] +Panel.__rich_console__ = _paint_with(Panel.__rich_console__, "panel", "border_style") # type: ignore[method-assign] + +_SYNC_BEGIN = Segments([Segment("\x1b[?2026h", None, [(ControlType.BELL,)])]) +_SYNC_END = Segments([Segment("\x1b[?2026l", None, [(ControlType.BELL,)])]) class SyncLive(Live): - """Live that wraps each repaint in DEC 2026 begin/end-sync so the terminal draws it as one atomic frame.""" + """Live that wraps each repaint, and each print while it runs, in DEC 2026 begin/end-sync, so the terminal draws + the frame as one.""" - def refresh(self) -> None: - if not self.console.is_terminal: - super().refresh() - return - self.console.file.write("\x1b[?2026h") - try: - super().refresh() - finally: - self.console.file.write("\x1b[?2026l") - self.console.file.flush() + def process_renderables(self, renderables: list[ConsoleRenderable]) -> list[ConsoleRenderable]: + return self.synced(super().process_renderables(renderables)) + + def synced(self, renderables: list[ConsoleRenderable]) -> list[ConsoleRenderable]: + if not self.console.is_interactive or self.console.legacy_windows: + return renderables + return [_SYNC_BEGIN, *renderables, _SYNC_END] + + +class PinnedLive(SyncLive): + """A root live area that holds the screen for a whole command: *header* at the top, the newest output under it, + and the nested live areas (spinners, progress bars) at the bottom. + + It keeps what is printed while it runs, and sends the oldest lines to the scrollback above the live area once the + screen is full, so they scroll off the top. The header goes to the scrollback first, so the scrollback ends up as a + plain run would leave it. On screen, the gradients in the header and the kept lines move. + """ + + LOOP_SECONDS = 4.0 + + def __init__(self, header: RenderableType, live_console: Console) -> None: + super().__init__(console=live_console, transient=True, refresh_per_second=20) + self.header = header + self.header_lines: tuple[int, list[list[Segment]]] = (-1, []) + self.kept: list[list[Segment]] = [] + self.room = live_console.size.height + self.header_sent = False + self.started_at = time.monotonic() + + @staticmethod + def wanted(live_console: Console) -> bool: + """Whether the terminal can show the moving header: interactive, with colour, and wide enough.""" + colour = live_console.color_system is not None and not live_console.no_color + return live_console.is_interactive and colour and fits_terminal(live_console) + + @property + def renderable(self) -> RenderableType: + return _PinnedScreen(self) + + def process_renderables(self, renderables: list[ConsoleRenderable]) -> list[ConsoleRenderable]: + if not self.console.is_interactive: + return super().process_renderables(renderables) + with self._lock: + printed = [renderable for renderable in renderables if not isinstance(renderable, Control)] + capture = [_Capture(self, printed)] if printed else [] + return self.synced([self._live_render.position_cursor(), *capture, self._live_render]) + + def head(self, options: ConsoleOptions) -> list[list[Segment]]: + """The header's lines, drawn once for each width.""" + width, lines = self.header_lines + if width != options.max_width: + lines = self.console.render_lines(self.header, options, pad=False) + self.header_lines = (options.max_width, lines) + return lines + + def overflow(self) -> list[list[Segment]]: + """Take off the oldest kept lines that no longer fit on the screen. Call it with the lock held.""" + count = max(0, len(self.kept) - self.room) + out = self.kept[:count] + del self.kept[:count] + return out + + def still(self, lines: list[list[Segment]]) -> list[RenderableType]: + """*lines* for the scrollback, after the header if it has not gone there yet.""" + out: list[RenderableType] = [] if self.header_sent else [self.header] + self.header_sent = True + return [*out, SegmentLines(lines, new_lines=True)] + + def close(self) -> None: + """Stop the live area, then print what is still kept.""" + self.stop() + with self._lock: + kept, self.kept = self.kept, [] + for renderable in self.still(kept): + self.console.print(renderable, crop=False) + + +class _Capture: + """The output of one print while PinnedLive runs. Rendered with the print's own options, it keeps the lines in + PinnedLive and yields only the oldest kept lines that no longer fit, which land above the live area.""" + + def __init__(self, live: PinnedLive, renderables: list[ConsoleRenderable]) -> None: + self.live = live + self.renderables = renderables + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + lines = [ + line for renderable in self.renderables for line in console.render_lines(renderable, options, pad=False) + ] + with self.live._lock: + self.live.kept.extend(lines) + flushed = self.live.overflow() + if flushed: + yield from self.live.still(flushed) + + +class _PinnedScreen: + def __init__(self, live: PinnedLive) -> None: + self.live = live + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + offset = (time.monotonic() - self.live.started_at) / self.live.LOOP_SECONDS + head = self.live.head(options) + nested = [live.get_renderable() for live in console._live_stack[1:]] + foot = console.render_lines(Group(*nested), options, pad=False) if nested else [] + room = self.live.room = max(0, console.size.height - len(head) - len(foot)) + body = self.live.kept[-room:] if room else [] + for line in slide_gradients([*head, *body], offset) + foot: + yield from Segment.adjust_line_length(line, options.max_width, pad=False) + yield Segment.line() + + +PULSE_PERIOD = 40.0 +PULSE_SPEED = 15.0 # cells a second + + +def fits_terminal(live_console: Console) -> bool: + """Whether the terminal is at least as wide as *live_console*. A live area that wraps leaves old frames behind, + because Rich moves the cursor up by the lines it drew, not by the rows the terminal used.""" + return shutil.get_terminal_size((live_console.width, 0)).columns >= live_console.width + + +def bar_gradients(palette: Mapping[str, str]) -> tuple[bool, Optional[list[Style]], list[Style]]: + """The progress bar colours of the gradients in use: whether they scroll, the fill (None without a `bar` + gradient) and the pulse, which without a `pulse` gradient blends *palette*'s dark_gray into its pink.""" + scroll = theme_gradients.get("motion") == "scroll" + bar = theme_gradients.get("bar") + pulse = gradient_lut(theme_gradients.get("pulse") or (palette["dark_gray"], palette["pink"])) + return scroll, gradient_lut(bar, cyclic=scroll) if bar else None, pulse class GradientPulseBarColumn(BarColumn): - LUT_SIZE = 32 - - def __init__(self, *args: Any, pulse_period: float = 40.0, pulse_speed: float = 15.0, **kwargs: Any) -> None: + def __init__( + self, *args: Any, pulse_period: float = PULSE_PERIOD, pulse_speed: float = PULSE_SPEED, **kwargs: Any + ) -> None: super().__init__(*args, **kwargs) self.pulse_period = pulse_period self.pulse_speed = pulse_speed - lo = Color.parse(primary_scheme["dark_gray"]).triplet - hi = Color.parse(primary_scheme["pink"]).triplet - assert lo and hi - self._lut = [ - Style(color=Color.from_triplet(blend_rgb(lo, hi, i / (self.LUT_SIZE - 1)))) for i in range(self.LUT_SIZE) - ] + self.scroll, self._fill_lut, self._lut = bar_gradients(primary_scheme) def render(self, task: Task) -> RenderableType: # type: ignore[override] if task.started and task.total is not None: - return super().render(task) + if self._fill_lut is None or task.completed >= task.total: + return super().render(task) + fraction = min(1.0, max(0.0, task.completed / task.total)) if task.total else 1.0 + offset = task.get_time() * self.pulse_speed if self.scroll else None + return _GradientFill(self._fill_lut, fraction, offset, self.bar_width, self.style) return _GradientPulse(self._lut, task.get_time() * self.pulse_speed, self.pulse_period, self.bar_width) +def gradient_fill( + lut: list[Style], + fraction: float, + width: int, + offset: Optional[float], + back: Optional[StyleType], + ascii: bool = False, +) -> Text: + """Draw a progress bar *fraction* full, with the gradient fixed along the bar or, given an *offset*, slid along it.""" + full, half = divmod(int(width * 2 * fraction), 2) + bar = Text() + for i in range(full + half): + t = i / max(1, width - 1) if offset is None else (i - offset) / width % 1 + character = ("-" if ascii else "━") if i < full else (" " if ascii else "╸") + bar.append(character, lut[round(t * (len(lut) - 1))]) + remaining = width - full - half + if remaining and back is not None: + if not half and full: + bar.append(" " if ascii else "╺", back) + remaining -= 1 + bar.append(("-" if ascii else "━") * remaining, back) + return bar + + +class _GradientFill: + def __init__( + self, lut: list[Style], fraction: float, offset: Optional[float], width: Optional[int], back: StyleType + ) -> None: + self.lut = lut + self.fraction = fraction + self.offset = offset + self.width = width + self.back = back + + def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult: + width = min(self.width or options.max_width, options.max_width) + # as ProgressBar: without colour the empty part would look full + back = self.back if not console.no_color and console.color_system is not None else None + yield gradient_fill( + self.lut, self.fraction, width, self.offset, back, options.legacy_windows or options.ascii_only + ) + + def __rich_measure__(self, console: Console, options: ConsoleOptions) -> Measurement: + return Measurement(self.width, self.width) if self.width is not None else Measurement(4, options.max_width) + + class _GradientPulse: def __init__(self, lut: list[Style], offset: float, period: float, width: Optional[int]) -> None: self.lut = lut @@ -433,26 +755,7 @@ console = ComfyConsole( log_time=False, log_path=False, width=80, - theme=Theme( - { - "bar.back": primary_scheme["dark_gray"], - "bar.complete": primary_scheme["pink"], - "bar.finished": primary_scheme["green"], - "bar.pulse": primary_scheme["pink"], - "black": primary_scheme["black"], - "inspect.async_def": f"italic {primary_scheme['cyan']}", - "progress.data.speed": "dark_orange", - "repr.number": f"bold not italic {primary_scheme['cyan']}", - "repr.number_complex": f"bold not italic {primary_scheme['cyan']}", - "rule.line": primary_scheme["dark_gray"], - "rule.text": primary_scheme["pink"], - "tree.line": primary_scheme["dark_gray"], - "status.spinner": primary_scheme["pink"], - "progress.spinner": primary_scheme["pink"], - **primary_scheme, - **custom_colors, - } - ), + theme=build_console_theme(primary_scheme, theme_styles, config.set_terminal_bg), log_renderer=ComfyLogRenderer(show_time=False, show_path=False), ) @@ -524,7 +827,9 @@ __all__ = ( "ComfyLogRenderer", "ComfyRichHandler", "ComfyConsole", + "Gradient", "GradientPulseBarColumn", + "PinnedLive", "SyncLive", "console", "listing_panel", @@ -534,4 +839,5 @@ __all__ = ( "set_notice_handler", "set_prompt_handler", "show_notice", + "use_gradients", ) diff --git a/unshackle/core/themes.py b/unshackle/core/themes.py index a7b722bb..f04a00c1 100644 --- a/unshackle/core/themes.py +++ b/unshackle/core/themes.py @@ -1,200 +1,261 @@ """ -Named CLI colour themes. +CLI colour themes, read from YAML files. -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 grey 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. +A theme sets the colours of the `ROLES`, can override any Rich style under `styles:`, and can draw the +`GRADIENT_SURFACES` in blended colours under `gradients:`. The shipped themes are in `unshackle/themes/`. A file in `directories.themes` adds a theme, or replaces a shipped +theme with the same name. The active theme drives both the rich Console theme (banner, logs, +progress) and the click help screens. """ from __future__ import annotations +import logging +from pathlib import Path +from typing import Any + +import yaml +from rich.color import Color, ColorParseError +from rich.errors import StyleSyntaxError +from rich.style import Style +from rich.theme import Theme 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)", - "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", - }, -} +from unshackle.core.config import config +DEFAULT_THEME = "unshackle" ALIASES = { "default": DEFAULT_THEME, - "catppuccin": DEFAULT_THEME, - "mocha": DEFAULT_THEME, + "catppuccin": "catppuccin-mocha", + "mocha": "catppuccin-mocha", "onedark": "one-dark", - "tokyonight": "tokyo-night", "solarized": "solarized-dark", + "tokyonight": "tokyo-night", } +ROLES = ( + "bg", + "text", + "text2", + "black", + "bright_black", + "red", + "green", + "yellow", + "blue", + "pink", + "cyan", + "gray", + "bright_gray", + "dark_gray", +) +ROLE_ALIASES = {"accent": "pink"} +PRESETS_DIR = Path(__file__).resolve().parent.parent / "themes" +FILE_KEYS = {*ROLES, *ROLE_ALIASES, "extends", "styles", "gradients", "author", "description"} +GRADIENT_SURFACES = ("banner", "bar", "pulse", "rule", "panel") +GRADIENT_OPTIONS = {"direction": ("horizontal", "vertical", "diagonal"), "motion": ("static", "scroll")} -def resolve_palette(name: object) -> dict[str, str] | None: - """Look up a palette by theme name or alias, case-insensitively. +ThemeData = tuple[dict[str, str], dict[str, str], dict[str, Any]] - Takes any YAML scalar, because unshackle does not validate config values. A non-string - never matches a palette name. - Returns a copy: console.py extends the active palette in place, and that must - not pollute the master definitions. + +def normalize_name(name: object) -> str: + """Normalise a theme name or file stem: lowercase, with `_` read as `-`.""" + return str(name or "").strip().lower().replace("_", "-") + + +def canonical_name(name: object) -> str: + """Normalise a theme name and resolve an alias to the theme it names.""" + key = normalize_name(name) + return ALIASES.get(key, key) + + +def theme_files(folder: Path) -> dict[str, Path]: + """Map each theme file's name to its path. It skips alias names, which belong to the shipped themes.""" + try: + paths = sorted(path for path in folder.iterdir() if path.suffix.lower() in (".yaml", ".yml")) + except OSError as e: + if folder.exists(): + logging.getLogger("console").warning("cannot read the themes folder %s: %s", folder, e.strerror or e) + return {} + files: dict[str, Path] = {} + for path in paths: + name = normalize_name(path.stem) + if name not in ALIASES and name.isprintable(): + files.setdefault(name, path) + return files + + +def shipped_themes() -> dict[str, Path]: + return theme_files(PRESETS_DIR) + + +def user_themes() -> dict[str, Path]: + return theme_files(Path(config.directories.themes)) + + +def load_theme(name: object, user_files: bool = True) -> ThemeData: + """Resolve a theme name to its role colours and its `styles:` overrides. + + A user file starts from its `extends` theme, else the shipped theme with the same name, else the + default. That base is always a shipped file, so a theme that extends itself cannot loop. + It checks every value, so `build_console_theme` always accepts the result. Raises ValueError, + naming the file and the entry, when the theme is unknown or not valid. """ - key = str(name or "").strip().lower().replace("_", "-") - palette = PALETTES.get(ALIASES.get(key, key)) - return dict(palette) if palette else None + key = canonical_name(name) + shipped = shipped_themes() + users = user_themes() if user_files else {} + user = users.get(key) + if user is None: + if key not in shipped: + raise ValueError(f"unknown theme {str(name)!r}") + return apply_theme(shipped[key].name, read_theme_file(shipped[key]), {}, {}, {}) + + data = read_theme_file(user) + extends = data.get("extends") + if extends is not None and not isinstance(extends, str): + raise ValueError(f"{user.name}: extends must be a quoted theme name") + base = canonical_name(extends or (key if key in shipped else DEFAULT_THEME)) + if base not in shipped: + raise ValueError(f"{user.name}: extends {data['extends']!r}, not a shipped theme") + return apply_theme(user.name, data, *load_theme(base, user_files=False)) + + +def load_active_theme(name: object) -> tuple[str, ThemeData]: + """Load the configured theme, or the shipped default when it is unknown or not valid. Returns the name in use.""" + try: + return canonical_name(name), load_theme(name) + except ValueError as e: + logging.getLogger("console").warning("%s, using the %s theme", e, DEFAULT_THEME) + return DEFAULT_THEME, load_theme(DEFAULT_THEME, user_files=False) + + +def read_theme_file(path: Path) -> dict[str, Any]: + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, UnicodeDecodeError, RecursionError, yaml.YAMLError) as e: + raise ValueError(f"{path.name}: cannot read: {shorten(' '.join(str(e).split()), 80)}") from e + if not isinstance(data, dict): + raise ValueError(f"{path.name}: not a mapping of theme keys") + unknown = sorted(str(key) for key in data if key not in FILE_KEYS) + if unknown: + raise ValueError(f"{path.name}: unknown key {', '.join(unknown)}") + return data + + +def apply_theme( + file: str, data: dict[str, Any], palette: dict[str, str], styles: dict[str, str], gradients: dict[str, Any] +) -> ThemeData: + """Lay the keys of a theme file over a base theme, and return the result. The base is not changed.""" + data = dict(data) + for alias, role in ROLE_ALIASES.items(): + if alias in data: + if role in data: + raise ValueError(f"{file}: set {role} or {alias}, not both") + data[role] = data.pop(alias) + + palette = dict(palette) + for role in ROLES: + if role not in data: + continue + value = data[role] + if value is None or value == "": + raise ValueError(f"{file}: {role} is empty (put hex values in quotes)") + if not isinstance(value, str): + raise ValueError(f"{file}: {role} must be a quoted colour") + palette[role] = parse_colour(file, role, value, {}) + missing = [role for role in ROLES if role not in palette] + if missing: + raise ValueError(f"{file}: missing {', '.join(missing)}") + + overrides = data.get("styles") or {} + if not isinstance(overrides, dict): + raise ValueError(f"{file}: styles must be a mapping") + styles = dict(styles) + for style_name, value in overrides.items(): + if not isinstance(style_name, str) or not isinstance(value, str): + raise ValueError(f"{file}: styles: quote {shorten(str(style_name))} and its value") + if style_name in ROLES or style_name in ROLE_ALIASES: + raise ValueError(f"{file}: set {style_name} at the top level, not under styles") + resolved = " ".join(palette.get(ROLE_ALIASES.get(word.lower(), word.lower()), word) for word in value.split()) + try: + parsed = Style.parse(resolved) + except (StyleSyntaxError, ValueError) as e: + raise ValueError(f"{file}: styles: {style_name}: {shorten(value)} is not a Rich style") from e + if parsed.link or parsed.conceal: + raise ValueError(f"{file}: styles: {style_name}: link and conceal not allowed") + styles[style_name] = resolved + + overrides = data.get("gradients") or {} + if not isinstance(overrides, dict): + raise ValueError(f"{file}: gradients must be a mapping") + gradients = dict(gradients) + for key, value in overrides.items(): + if key in GRADIENT_OPTIONS: + if not isinstance(value, str) or value not in GRADIENT_OPTIONS[key]: + raise ValueError(f"{file}: gradients: {key} must be {' or '.join(GRADIENT_OPTIONS[key])}") + gradients[key] = value + elif key in GRADIENT_SURFACES: + stops = [value] if isinstance(value, str) else value + if not isinstance(stops, list) or not stops or not all(isinstance(stop, str) for stop in stops): + raise ValueError(f"{file}: gradients: {key} must be a list of quoted colours") + gradients[key] = tuple(parse_colour(file, f"gradients: {key}", stop, palette) for stop in stops) + else: + raise ValueError(f"{file}: gradients: unknown key {shorten(str(key))}") + return palette, styles, gradients + + +def parse_colour(file: str, label: str, value: str, palette: dict[str, str]) -> str: + """Check one colour from a theme file. A role name in *palette* becomes that role's colour.""" + value = palette.get(ROLE_ALIASES.get(value.lower(), value.lower()), value) + value = "".join(value.split()) + try: + Color.parse(value) + except (ColorParseError, ValueError) as e: + raise ValueError(f"{file}: {label}: {shorten(value)} is not a colour") from e + return value + + +def shorten(text: str, limit: int = 40) -> str: + """Quote a value from a theme file for an error message, cut to *limit* characters.""" + return repr(text if len(text) <= limit else text[:limit] + "...") + + +def build_console_theme(palette: dict[str, str], styles: dict[str, str], set_terminal_bg: bool = False) -> Theme: + """Assemble the Rich console theme: fixed styles first, then the roles and the names made from them, then `styles:`.""" + on_bg = f" on {palette['bg']}" if set_terminal_bg else "" + return Theme( + { + "bar.back": palette["dark_gray"], + "bar.complete": palette["pink"], + "bar.finished": palette["green"], + "bar.pulse": palette["pink"], + "inspect.async_def": f"italic {palette['cyan']}", + "repr.number": f"bold not italic {palette['cyan']}", + "repr.number_complex": f"bold not italic {palette['cyan']}", + "rule.line": palette["dark_gray"], + "rule.text": palette["pink"], + "tree.line": palette["dark_gray"], + "status.spinner": palette["pink"], + "progress.spinner": palette["pink"], + "ascii.art": palette["pink"] + on_bg, + "none": palette["text"] + on_bg, + "grey23": palette["black"], + "magenta": palette["pink"], + "bright_red": palette["red"], + "bright_green": palette["green"], + "bright_yellow": palette["yellow"], + "bright_blue": palette["blue"], + "bright_magenta": palette["pink"], + "bright_cyan": palette["cyan"], + **palette, + **styles, + } + ) def build_help_config(palette: dict[str, str]) -> RichHelpConfiguration: """Assemble the rich-click help configuration for a palette.""" return RichHelpConfiguration( + enable_theme_env_var=False, style_option=palette["text"], style_switch=palette["green"], style_argument=palette["cyan"], diff --git a/unshackle/themes/catppuccin-mocha.yaml b/unshackle/themes/catppuccin-mocha.yaml new file mode 100644 index 00000000..e0fc47b0 --- /dev/null +++ b/unshackle/themes/catppuccin-mocha.yaml @@ -0,0 +1,21 @@ +# Catppuccin Mocha (catppuccin.com), from the Gogh terminal themes. +author: unshackle +description: Soft pastels on a dark blue-grey background. + +bg: "#1e1e2e" +text: "#cdd6f4" +text2: "#a2a9c1" +black: "#45475a" +bright_black: "#585b70" +red: "#f38ba8" +green: "#a6e3a1" +yellow: "#f9e2af" +blue: "#89b4fa" +pink: "#f5c2e7" +cyan: "#94e2d5" +gray: "#a6adc8" +bright_gray: "#bac2de" +dark_gray: "#363654" + +styles: + progress.data.speed: dark_orange diff --git a/unshackle/themes/dracula.yaml b/unshackle/themes/dracula.yaml new file mode 100644 index 00000000..5d07d4e1 --- /dev/null +++ b/unshackle/themes/dracula.yaml @@ -0,0 +1,21 @@ +# Dracula (draculatheme.com); purple stands in for blue, the comment grey for the dim greys. +author: unshackle +description: Dark purple-grey with vivid pink and green. + +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" + +styles: + progress.data.speed: dark_orange diff --git a/unshackle/themes/flexoki.yaml b/unshackle/themes/flexoki.yaml new file mode 100644 index 00000000..1a91a994 --- /dev/null +++ b/unshackle/themes/flexoki.yaml @@ -0,0 +1,21 @@ +# Flexoki (flexoki.com) dark, with the 400-level accents. +author: unshackle +description: Warm ink-on-paper tones. + +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" + +styles: + progress.data.speed: dark_orange diff --git a/unshackle/themes/gruvbox.yaml b/unshackle/themes/gruvbox.yaml new file mode 100644 index 00000000..0c774abb --- /dev/null +++ b/unshackle/themes/gruvbox.yaml @@ -0,0 +1,21 @@ +# Gruvbox (github.com/morhetz/gruvbox) dark, bright variants. +author: unshackle +description: Retro warm browns and yellows. + +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" + +styles: + progress.data.speed: dark_orange diff --git a/unshackle/themes/monokai.yaml b/unshackle/themes/monokai.yaml new file mode 100644 index 00000000..c1d1cf4f --- /dev/null +++ b/unshackle/themes/monokai.yaml @@ -0,0 +1,21 @@ +# Monokai, classic; purple stands in for pink, and 66d9ef doubles as blue. +author: unshackle +description: High-contrast greens and pinks on charcoal. + +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" + +styles: + progress.data.speed: dark_orange diff --git a/unshackle/themes/nord.yaml b/unshackle/themes/nord.yaml new file mode 100644 index 00000000..0f3ab9ba --- /dev/null +++ b/unshackle/themes/nord.yaml @@ -0,0 +1,21 @@ +# Nord (nordtheme.com): polar night, snow storm, frost and aurora. +author: unshackle +description: Cool arctic blues; the brand accent is aurora purple. + +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" + +styles: + progress.data.speed: "#d08770" diff --git a/unshackle/themes/one-dark.yaml b/unshackle/themes/one-dark.yaml new file mode 100644 index 00000000..f38ff1a2 --- /dev/null +++ b/unshackle/themes/one-dark.yaml @@ -0,0 +1,21 @@ +# One Dark, the Atom syntax colours. +author: unshackle +description: Muted blues and purples on slate. + +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" + +styles: + progress.data.speed: dark_orange diff --git a/unshackle/themes/rainbow.yaml b/unshackle/themes/rainbow.yaml new file mode 100644 index 00000000..875adf8a --- /dev/null +++ b/unshackle/themes/rainbow.yaml @@ -0,0 +1,30 @@ +# The unshackle colours with every gradient on: an example of what `gradients` can do. +author: unshackle +description: Default colours with a rainbow banner, progress bars, rules and panel borders. + +bg: "#1e1e2e" +text: "#cdd6f4" +text2: "#a2a9c1" +black: "#45475a" +bright_black: "#585b70" +red: "#f38ba8" +green: "#a6e3a1" +yellow: "#f9e2af" +blue: "#89b4fa" +pink: "#f5c2e7" +cyan: "#94e2d5" +gray: "#9399b2" +bright_gray: "#bac2de" +dark_gray: "#313244" + +styles: + progress.data.speed: "#fab387" + +gradients: + direction: diagonal + motion: scroll + banner: [red, "#fab387", yellow, green, cyan, blue, pink] + bar: [red, yellow, green, cyan, blue, pink] + pulse: [dark_gray, red, yellow, green, blue, pink] + rule: [red, yellow, green, cyan, blue, pink] + panel: [red, yellow, green, blue] diff --git a/unshackle/themes/solarized-dark.yaml b/unshackle/themes/solarized-dark.yaml new file mode 100644 index 00000000..a68bfc4a --- /dev/null +++ b/unshackle/themes/solarized-dark.yaml @@ -0,0 +1,21 @@ +# Solarized dark (ethanschoonover.com/solarized); magenta stands in for pink. +author: unshackle +description: Teal-black background with precise accents. + +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" + +styles: + progress.data.speed: dark_orange diff --git a/unshackle/themes/tokyo-night.yaml b/unshackle/themes/tokyo-night.yaml new file mode 100644 index 00000000..e39941e4 --- /dev/null +++ b/unshackle/themes/tokyo-night.yaml @@ -0,0 +1,21 @@ +# Tokyo Night (github.com/enkia/tokyo-night-vscode-theme), night variant. +author: unshackle +description: Deep navy with neon blues and purples. + +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" + +styles: + progress.data.speed: dark_orange diff --git a/unshackle/themes/unshackle.yaml b/unshackle/themes/unshackle.yaml new file mode 100644 index 00000000..8ae29faf --- /dev/null +++ b/unshackle/themes/unshackle.yaml @@ -0,0 +1,22 @@ +# The default unshackle theme, based on Catppuccin Mocha. +# Copy this file into your themes folder to start your own; see docs/guide/themes.md. +author: unshackle +description: Default theme, soft pastels on a dark blue-grey background. + +bg: "#1e1e2e" # used only when set_terminal_bg is on +text: "#cdd6f4" +text2: "#a2a9c1" +black: "#45475a" +bright_black: "#585b70" +red: "#f38ba8" +green: "#a6e3a1" +yellow: "#f9e2af" +blue: "#89b4fa" +pink: "#f5c2e7" # the brand accent; `accent` is another name for it +cyan: "#94e2d5" +gray: "#9399b2" +bright_gray: "#bac2de" +dark_gray: "#313244" + +styles: + progress.data.speed: "#fab387" diff --git a/uv.lock b/uv.lock index f112c7a9..3c92c610 100644 --- a/uv.lock +++ b/uv.lock @@ -2268,7 +2268,7 @@ requires-dist = [ { name = "pywidevine", extras = ["serve"], specifier = ">=1.8.0,<2" }, { 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", specifier = ">=14,<15" }, { name = "rich-click", specifier = ">=1.9.8" }, { name = "rlaphoenix-m3u8", specifier = ">=3.4.0,<4" }, { name = "rnet", specifier = ">=2.4.2" },