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.
This commit is contained in:
imSp4rky
2026-10-22 18:57:07 -06:00
parent 1ec236f61b
commit 6029acd40b
28 changed files with 1817 additions and 348 deletions
+2
View File
@@ -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/
@@ -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 |
+7 -2
View File
@@ -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`
+1
View File
@@ -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.
+190
View File
@@ -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 `<name>.yaml` (or `<name>.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: <name>` in `unshackle.yaml`, then preview it with `unshackle env theme <name>`.
!!! 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.
@@ -25,6 +25,7 @@ directories:
| `wvds` | path | `<data>/WVDs` | Yes | Widevine device files (`.wvd`). |
| `prds` | path | `<data>/PRDs` | Yes | PlayReady device files (`.prd`). |
| `dcsl` | path | `<data>/DCSL` | Yes | DCSL data. |
| `themes` | path | `<data>/themes/custom` | Yes | Your [theme files](../../guide/themes.md) (`<name>.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. |
+1 -1
View File
@@ -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). |
---
+1
View File
@@ -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
+1 -1
View File
@@ -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",
+552
View File
@@ -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
+162 -89
View File
@@ -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()
+1
View File
@@ -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)
+44 -11
View File
@@ -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
+1
View File
@@ -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
+375 -69
View File
@@ -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",
)
+235 -174
View File
@@ -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"],
+21
View File
@@ -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
+21
View File
@@ -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
+21
View File
@@ -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
+21
View File
@@ -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
+21
View File
@@ -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
+21
View File
@@ -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"
+21
View File
@@ -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
+30
View File
@@ -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]
+21
View File
@@ -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
+21
View File
@@ -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
+22
View File
@@ -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"
Generated
+1 -1
View File
@@ -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" },