mirror of
https://github.com/unshackle-dl/unshackle.git
synced 2026-11-14 21:15:06 +00:00
Compare commits
1
Commits
dev
...
feat/user-themes
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6029acd40b |
@@ -1,6 +1,7 @@
|
|||||||
# unshackle
|
# unshackle
|
||||||
unshackle.yaml
|
unshackle.yaml
|
||||||
unshackle.yml
|
unshackle.yml
|
||||||
|
!unshackle/themes/unshackle.yaml
|
||||||
update_check.json
|
update_check.json
|
||||||
*.mkv
|
*.mkv
|
||||||
*.mp4
|
*.mp4
|
||||||
@@ -27,6 +28,7 @@ unshackle/binaries/*
|
|||||||
# test fixtures (binary subtitle samples) must be tracked despite the *.mp4 rule above
|
# test fixtures (binary subtitle samples) must be tracked despite the *.mp4 rule above
|
||||||
!tests/tracks/fixtures/*.mp4
|
!tests/tracks/fixtures/*.mp4
|
||||||
unshackle/cache/
|
unshackle/cache/
|
||||||
|
unshackle/themes/custom/
|
||||||
unshackle/cookies/
|
unshackle/cookies/
|
||||||
unshackle/certs/
|
unshackle/certs/
|
||||||
unshackle/WVDs/
|
unshackle/WVDs/
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ The directories unshackle uses:
|
|||||||
| `wvds` | Widevine devices (`.wvd` files) | Yes |
|
| `wvds` | Widevine devices (`.wvd` files) | Yes |
|
||||||
| `prds` | PlayReady devices (`.prd` files) | Yes |
|
| `prds` | PlayReady devices (`.prd` files) | Yes |
|
||||||
| `dcsl` | DCSL data | Yes |
|
| `dcsl` | DCSL data | Yes |
|
||||||
|
| `themes` | Your [theme files](../guide/themes.md) | Yes |
|
||||||
| `services` | Search paths for service code (see below) | Yes |
|
| `services` | Search paths for service code (see below) | Yes |
|
||||||
| `commands` | CLI command modules | Yes |
|
| `commands` | CLI command modules | Yes |
|
||||||
| `vaults` | Vault modules | Yes |
|
| `vaults` | Vault modules | Yes |
|
||||||
|
|||||||
@@ -391,10 +391,15 @@ Shows the location unshackle loaded the configuration file from. If it found no
|
|||||||
### `env theme`
|
### `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`
|
### `env clear`
|
||||||
|
|
||||||
|
|||||||
@@ -431,6 +431,7 @@ directories:
|
|||||||
| `exports` | Export JSONs. |
|
| `exports` | Export JSONs. |
|
||||||
| `services`, `vaults`, `fonts`, `commands` | Search paths for services, key-vault backends, bundled fonts, and CLI commands. |
|
| `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. |
|
| `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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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`). |
|
| `wvds` | path | `<data>/WVDs` | Yes | Widevine device files (`.wvd`). |
|
||||||
| `prds` | path | `<data>/PRDs` | Yes | PlayReady device files (`.prd`). |
|
| `prds` | path | `<data>/PRDs` | Yes | PlayReady device files (`.prd`). |
|
||||||
| `dcsl` | path | `<data>/DCSL` | Yes | DCSL data. |
|
| `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. |
|
| `commands` | path | `unshackle/commands` | Yes | CLI command modules. |
|
||||||
| `services` | list \| path | `[unshackle/services]` | Yes | Service search paths and/or remote repo specs (see below). |
|
| `services` | list \| path | `[unshackle/services]` | Yes | Service search paths and/or remote repo specs (see below). |
|
||||||
| `vaults` | path | `unshackle/vaults` | Yes | Vault backend modules. |
|
| `vaults` | path | `unshackle/vaults` | Yes | Vault backend modules. |
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ calls.
|
|||||||
|
|
||||||
| Key | Type | Default | Description |
|
| 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). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ nav:
|
|||||||
- Key Vaults: guide/vaults.md
|
- Key Vaults: guide/vaults.md
|
||||||
- Output & Naming: guide/output-and-naming.md
|
- Output & Naming: guide/output-and-naming.md
|
||||||
- Subtitles: guide/subtitles.md
|
- Subtitles: guide/subtitles.md
|
||||||
|
- Themes: guide/themes.md
|
||||||
- Troubleshooting: guide/troubleshooting.md
|
- Troubleshooting: guide/troubleshooting.md
|
||||||
- Configuration:
|
- Configuration:
|
||||||
- Overview: reference/configuration/index.md
|
- Overview: reference/configuration/index.md
|
||||||
|
|||||||
+1
-1
@@ -46,7 +46,7 @@ dependencies = [
|
|||||||
"pywidevine[serve]>=1.8.0,<2",
|
"pywidevine[serve]>=1.8.0,<2",
|
||||||
"PyYAML>=6.0.1,<7",
|
"PyYAML>=6.0.1,<7",
|
||||||
"requests[socks]>=2.32.5,<3",
|
"requests[socks]>=2.32.5,<3",
|
||||||
"rich>=13.7.1,<15",
|
"rich>=14,<15",
|
||||||
"rlaphoenix.m3u8>=3.4.0,<4",
|
"rlaphoenix.m3u8>=3.4.0,<4",
|
||||||
"ruamel.yaml>=0.18.6,<0.19",
|
"ruamel.yaml>=0.18.6,<0.19",
|
||||||
"sortedcontainers>=2.4.0,<3",
|
"sortedcontainers>=2.4.0,<3",
|
||||||
|
|||||||
@@ -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
|
||||||
+138
-65
@@ -3,10 +3,12 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
from rich.console import RenderableType
|
||||||
from rich.padding import Padding
|
from rich.padding import Padding
|
||||||
|
from rich.text import Text
|
||||||
from rich.tree import Tree
|
from rich.tree import Tree
|
||||||
|
|
||||||
from unshackle.core import binaries
|
from unshackle.core import binaries
|
||||||
@@ -243,59 +245,118 @@ def check() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@env.command()
|
@env.command()
|
||||||
def theme() -> None:
|
@click.argument("name", required=False)
|
||||||
"""Preview the available CLI themes."""
|
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
|
import math
|
||||||
|
|
||||||
from rich import box
|
|
||||||
from rich.color import Color, blend_rgb
|
|
||||||
from rich.console import Group
|
from rich.console import Group
|
||||||
from rich.panel import Panel
|
from rich.progress_bar import ProgressBar
|
||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
from rich.style import Style
|
from rich.segment import SegmentLines
|
||||||
from rich.text import Text
|
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}")
|
|
||||||
|
|
||||||
swatch = Text()
|
|
||||||
for role in ("red", "green", "yellow", "blue", "pink", "cyan", "text", "gray"):
|
|
||||||
swatch.append("██ ", style=palette[role])
|
|
||||||
|
|
||||||
docstring = Text()
|
docstring = Text()
|
||||||
docstring.append("Service docstrings render like this, ", style=text)
|
docstring.append("Service docstrings render like this, ", style=text)
|
||||||
docstring.append("with secondary detail lines like this.", style=text2)
|
docstring.append("with secondary detail lines like this.", style=text2)
|
||||||
|
|
||||||
separator = Text("─" * 60, style=palette["dark_gray"])
|
# the help screen's own styles
|
||||||
|
separator = Text("─" * 60, style=help_config.style_options_table_border_style or "")
|
||||||
options: list[Text] = []
|
options: list[Text] = []
|
||||||
rows = (
|
rows = (
|
||||||
("-q, ", "--quality ", "TEXT ", "Video quality to download", " [default: best]", f"dim {gray}"),
|
("-q, ", "--quality ", "TEXT ", "Video quality to download", " [default: best]", "default"),
|
||||||
(
|
("-r, ", "--range ", "[SDR|HDR10|DV] ", "Video color range", " [env: RANGE]", "envvar"),
|
||||||
"-r, ",
|
|
||||||
"--range ",
|
|
||||||
"[SDR|HDR10|DV] ",
|
|
||||||
"Video color range",
|
|
||||||
" [env: RANGE]",
|
|
||||||
f"dim {palette['yellow']}",
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
for short, long_opt, metavar, help_text, extra, extra_style in rows:
|
for short, long_opt, metavar, help_text, extra, extra_kind in rows:
|
||||||
row = Text()
|
row = Text()
|
||||||
row.append(short, style=palette["green"])
|
row.append(short, style=help_config.style_switch)
|
||||||
row.append(long_opt, style=text)
|
row.append(long_opt, style=help_config.style_option)
|
||||||
row.append(metavar, style=palette["yellow"])
|
row.append(metavar, style=help_config.style_metavar)
|
||||||
row.append(f" {help_text}", style=text)
|
row.append(f" {help_text}", style=help_config.style_option_help)
|
||||||
row.append(extra, style=extra_style)
|
row.append(extra, style=getattr(help_config, f"style_option_{extra_kind}"))
|
||||||
options.extend((row, separator))
|
options.extend((row, separator))
|
||||||
|
|
||||||
listing = Tree(Text("1 season, S1(1)", style=text), guide_style=guide)
|
listing = Tree(Text("1 season, S1(1)", style=text), guide_style=guide)
|
||||||
@@ -307,44 +368,56 @@ def theme() -> None:
|
|||||||
)
|
)
|
||||||
for kind, desc in track_rows:
|
for kind, desc in track_rows:
|
||||||
episode.add(Text.assemble((kind, palette["pink"]), (" | ", guide), (desc, text)), guide_style=guide)
|
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)
|
tracks = listing_panel(listing, "Available Tracks")
|
||||||
|
|
||||||
logs = []
|
logs = []
|
||||||
log_rows = (
|
log_rows = (
|
||||||
("INFO", palette["green"], "Downloading 3 tracks"),
|
("INFO", "Downloading 3 tracks"),
|
||||||
("WARNING", palette["yellow"], "Subtitle cues overlapped, fixed 2"),
|
("WARNING", "Subtitle cues overlapped, fixed 2"),
|
||||||
("ERROR", palette["red"], "License request denied (403)"),
|
("ERROR", "License request denied (403)"),
|
||||||
)
|
)
|
||||||
for level, level_style, message in log_rows:
|
for level, message in log_rows:
|
||||||
line = Text()
|
line = Text()
|
||||||
line.append("18:00:01 ", style=gray)
|
line.append("18:00:01 ", style=gray)
|
||||||
line.append(f"{level:<8} ", style=level_style)
|
line.append(f"{level:<8} ", style=f"logging.level.{level.lower()}")
|
||||||
line.append(message, style=text if level != "ERROR" else palette["red"])
|
line.append(message, style=text if level != "ERROR" else palette["red"])
|
||||||
logs.append(line)
|
logs.append(line)
|
||||||
|
|
||||||
progress = Text()
|
seconds = 5.0
|
||||||
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
|
def bars(t: Optional[float]) -> RenderableType:
|
||||||
lo = Color.parse(palette["dark_gray"]).triplet
|
"""The bars *t* seconds into the sample. The download finishes at *seconds*, or at None."""
|
||||||
hi = Color.parse(palette["pink"]).triplet
|
now = seconds if t is None else t
|
||||||
assert lo and hi
|
fill: RenderableType = (
|
||||||
lut = [Style(color=Color.from_triplet(blend_rgb(lo, hi, i / 31))) for i in range(32)]
|
_GradientFill(fill_lut, now / seconds, now * PULSE_SPEED if scroll else None, 39, "bar.back")
|
||||||
pulse = Text("Muxing ", style=text)
|
if fill_lut and now < seconds
|
||||||
for i in range(39):
|
else ProgressBar(total=seconds, completed=now, width=39)
|
||||||
fade = (math.cos((i - 8) * math.tau / 40.0) + 1) / 2
|
)
|
||||||
pulse.append("━", style=lut[int(fade * 31)])
|
# 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))
|
||||||
|
|
||||||
block = Group(header, swatch, Text(), docstring, *options, tracks, *logs, Text(), progress, pulse)
|
block = Group(swatch(palette), Text(), docstring, *options, tracks, *logs)
|
||||||
print_wide(block, (1, 2, 1, 2))
|
with console.use_theme(build_console_theme(palette, styles, config.set_terminal_bg)), use_gradients(gradients):
|
||||||
print_wide(Rule(style=guide, characters="─"), (0, 0))
|
scroll, fill_lut, pulse_lut = bar_gradients(palette)
|
||||||
console.print()
|
header = Padding(Rule(f"[rule.text]{name}"), (0, 2))
|
||||||
|
still = console.render_lines(Group(header, banner(), Padding(block, (0, 2))), pad=False)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
# 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()
|
@env.command()
|
||||||
|
|||||||
@@ -136,6 +136,7 @@ DATA_DIRS = {
|
|||||||
for key in ("cookies", "wvds", "prds", "cache", "logs", "exports", "dcsl", "temp")
|
for key in ("cookies", "wvds", "prds", "cache", "logs", "exports", "dcsl", "temp")
|
||||||
}
|
}
|
||||||
DATA_DIRS["services"] = Config._Directories.services[0].name
|
DATA_DIRS["services"] = Config._Directories.services[0].name
|
||||||
|
DATA_DIRS["themes"] = "themes"
|
||||||
|
|
||||||
say = partial(console.print, soft_wrap=True)
|
say = partial(console.print, soft_wrap=True)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import atexit
|
import atexit
|
||||||
import logging
|
import logging
|
||||||
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import urllib3
|
import urllib3
|
||||||
@@ -12,19 +14,20 @@ from rich.text import Text
|
|||||||
from urllib3.exceptions import InsecureRequestWarning
|
from urllib3.exceptions import InsecureRequestWarning
|
||||||
|
|
||||||
from unshackle.core import __code_hash__, __version__
|
from unshackle.core import __code_hash__, __version__
|
||||||
|
from unshackle.core import console as console_module
|
||||||
from unshackle.core.commands import Commands
|
from unshackle.core.commands import Commands
|
||||||
from unshackle.core.config import config
|
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.constants import context_settings
|
||||||
from unshackle.core.update_checker import UpdateChecker
|
from unshackle.core.update_checker import UpdateChecker
|
||||||
from unshackle.core.utilities import close_debug_logger, init_debug_logger
|
from unshackle.core.utilities import close_debug_logger, init_debug_logger
|
||||||
|
|
||||||
|
|
||||||
def print_banner() -> None:
|
def banner() -> Padding:
|
||||||
"""Print the ASCII banner with the version line."""
|
"""The ASCII banner with the version line."""
|
||||||
console.print(
|
return Padding(
|
||||||
Padding(
|
|
||||||
Group(
|
Group(
|
||||||
|
Gradient(
|
||||||
Text(
|
Text(
|
||||||
r"▄• ▄▌ ▐ ▄ .▄▄ · ▄ .▄ ▄▄▄· ▄▄· ▄ •▄ ▄▄▌ ▄▄▄ ." + "\n"
|
r"▄• ▄▌ ▐ ▄ .▄▄ · ▄ .▄ ▄▄▄· ▄▄· ▄ •▄ ▄▄▌ ▄▄▄ ." + "\n"
|
||||||
r"█▪██▌•█▌▐█▐█ ▀. ██▪▐█▐█ ▀█ ▐█ ▌▪█▌▄▌▪██• ▀▄.▀·" + "\n"
|
r"█▪██▌•█▌▐█▐█ ▀. ██▪▐█▐█ ▀█ ▐█ ▌▪█▌▄▌▪██• ▀▄.▀·" + "\n"
|
||||||
@@ -32,17 +35,39 @@ def print_banner() -> None:
|
|||||||
r"▐█▄█▌██▐█▌▐█▄▪▐███▌▐▀▐█ ▪▐▌▐███▌▐█.█▌▐█▌▐▌▐█▄▄▌" + "\n"
|
r"▐█▄█▌██▐█▌▐█▄▪▐███▌▐▀▐█ ▪▐▌▐███▌▐█.█▌▐█▌▐▌▐█▄▄▌" + "\n"
|
||||||
r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ ",
|
r" ▀▀▀ ▀▀ █▪ ▀▀▀▀ ▀▀▀ · ▀ ▀ ·▀▀▀ ·▀ ▀.▀▀▀ ▀▀▀ ",
|
||||||
style="ascii.art",
|
style="ascii.art",
|
||||||
|
justify="center",
|
||||||
),
|
),
|
||||||
|
"banner",
|
||||||
|
"ascii.art",
|
||||||
|
),
|
||||||
|
Text.from_markup(
|
||||||
f"v [repr.number]{__version__}[/]{f' ({__code_hash__})' if __code_hash__ else ''}"
|
f"v [repr.number]{__version__}[/]{f' ({__code_hash__})' if __code_hash__ else ''}"
|
||||||
f" - © 2025-{datetime.now().year} github.com/unshackle-dl/unshackle",
|
f" - © 2025-{datetime.now().year} github.com/unshackle-dl/unshackle",
|
||||||
|
justify="center",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
(1, 11, 1, 10),
|
(1, 11, 1, 10),
|
||||||
expand=True,
|
expand=True,
|
||||||
),
|
|
||||||
justify="center",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
class Main(Commands):
|
||||||
"""The top-level group. Its help shows the banner, because its callback does not run for --help."""
|
"""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.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("-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.")
|
@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."""
|
"""unshackle: Modular Movie, TV, and Music Archival Software."""
|
||||||
debug_logging_enabled = debug or config.debug
|
debug_logging_enabled = debug or config.debug
|
||||||
|
|
||||||
@@ -89,6 +115,13 @@ def main(version: bool, debug: bool) -> None:
|
|||||||
if "--quiet" in serve_args or "-q" in serve_args:
|
if "--quiet" in serve_args or "-q" in serve_args:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
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()
|
print_banner()
|
||||||
|
|
||||||
if version:
|
if version:
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class Config:
|
|||||||
wvds = data / "WVDs"
|
wvds = data / "WVDs"
|
||||||
prds = data / "PRDs"
|
prds = data / "PRDs"
|
||||||
dcsl = data / "DCSL"
|
dcsl = data / "DCSL"
|
||||||
|
themes = data / "themes" / "custom"
|
||||||
|
|
||||||
class _Filenames:
|
class _Filenames:
|
||||||
# default filenames, do not modify here, set via config
|
# default filenames, do not modify here, set via config
|
||||||
|
|||||||
+374
-68
@@ -4,17 +4,29 @@ import logging
|
|||||||
import math
|
import math
|
||||||
import shutil
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import unicodedata
|
import unicodedata
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from types import ModuleType
|
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
|
import rich.cells
|
||||||
from rich import box
|
from rich import box
|
||||||
from rich._log_render import FormatTimeCallable, LogRender
|
from rich._log_render import FormatTimeCallable, LogRender
|
||||||
|
from rich.cells import cell_len
|
||||||
from rich.color import Color, blend_rgb
|
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.emoji import EmojiVariant
|
||||||
from rich.highlighter import Highlighter, ReprHighlighter
|
from rich.highlighter import Highlighter, ReprHighlighter
|
||||||
from rich.live import Live
|
from rich.live import Live
|
||||||
@@ -23,6 +35,8 @@ from rich.measure import Measurement
|
|||||||
from rich.padding import Padding, PaddingDimensions
|
from rich.padding import Padding, PaddingDimensions
|
||||||
from rich.panel import Panel
|
from rich.panel import Panel
|
||||||
from rich.progress import BarColumn, Task
|
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.status import Status
|
||||||
from rich.style import Style, StyleType
|
from rich.style import Style, StyleType
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
@@ -30,7 +44,7 @@ from rich.text import Text, TextType
|
|||||||
from rich.theme import Theme
|
from rich.theme import Theme
|
||||||
|
|
||||||
from unshackle.core.config import config
|
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):
|
class ComfyLogRenderer(LogRender):
|
||||||
@@ -311,7 +325,8 @@ class ComfyConsole(Console):
|
|||||||
# We copy the list because stop() will modify console._live_stack.
|
# We copy the list because stop() will modify console._live_stack.
|
||||||
active_lives = list(self._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()
|
live.stop()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -323,72 +338,379 @@ class ComfyConsole(Console):
|
|||||||
stream=stream,
|
stream=stream,
|
||||||
)
|
)
|
||||||
finally:
|
finally:
|
||||||
for live in reversed(active_lives):
|
for live in active_lives:
|
||||||
live.start()
|
live.start()
|
||||||
|
|
||||||
|
|
||||||
primary_scheme = resolve_palette(config.theme)
|
active_theme, (primary_scheme, theme_styles, theme_gradients) = load_active_theme(config.theme)
|
||||||
if primary_scheme is None:
|
|
||||||
logging.getLogger("console").warning(
|
|
||||||
"Unknown theme %r, falling back to %s (available: %s)",
|
|
||||||
config.theme,
|
|
||||||
DEFAULT_THEME,
|
|
||||||
", ".join(PALETTES),
|
|
||||||
)
|
|
||||||
primary_scheme = dict(PALETTES[DEFAULT_THEME])
|
|
||||||
apply_help_theme(primary_scheme)
|
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:
|
@contextmanager
|
||||||
custom_colors["ascii.art"] += f" on {primary_scheme['bg']}"
|
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):
|
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:
|
def process_renderables(self, renderables: list[ConsoleRenderable]) -> list[ConsoleRenderable]:
|
||||||
if not self.console.is_terminal:
|
return self.synced(super().process_renderables(renderables))
|
||||||
super().refresh()
|
|
||||||
return
|
def synced(self, renderables: list[ConsoleRenderable]) -> list[ConsoleRenderable]:
|
||||||
self.console.file.write("\x1b[?2026h")
|
if not self.console.is_interactive or self.console.legacy_windows:
|
||||||
try:
|
return renderables
|
||||||
super().refresh()
|
return [_SYNC_BEGIN, *renderables, _SYNC_END]
|
||||||
finally:
|
|
||||||
self.console.file.write("\x1b[?2026l")
|
|
||||||
self.console.file.flush()
|
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):
|
class GradientPulseBarColumn(BarColumn):
|
||||||
LUT_SIZE = 32
|
def __init__(
|
||||||
|
self, *args: Any, pulse_period: float = PULSE_PERIOD, pulse_speed: float = PULSE_SPEED, **kwargs: Any
|
||||||
def __init__(self, *args: Any, pulse_period: float = 40.0, pulse_speed: float = 15.0, **kwargs: Any) -> None:
|
) -> None:
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.pulse_period = pulse_period
|
self.pulse_period = pulse_period
|
||||||
self.pulse_speed = pulse_speed
|
self.pulse_speed = pulse_speed
|
||||||
lo = Color.parse(primary_scheme["dark_gray"]).triplet
|
self.scroll, self._fill_lut, self._lut = bar_gradients(primary_scheme)
|
||||||
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)
|
|
||||||
]
|
|
||||||
|
|
||||||
def render(self, task: Task) -> RenderableType: # type: ignore[override]
|
def render(self, task: Task) -> RenderableType: # type: ignore[override]
|
||||||
if task.started and task.total is not None:
|
if task.started and task.total is not None:
|
||||||
|
if self._fill_lut is None or task.completed >= task.total:
|
||||||
return super().render(task)
|
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)
|
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:
|
class _GradientPulse:
|
||||||
def __init__(self, lut: list[Style], offset: float, period: float, width: Optional[int]) -> None:
|
def __init__(self, lut: list[Style], offset: float, period: float, width: Optional[int]) -> None:
|
||||||
self.lut = lut
|
self.lut = lut
|
||||||
@@ -433,26 +755,7 @@ console = ComfyConsole(
|
|||||||
log_time=False,
|
log_time=False,
|
||||||
log_path=False,
|
log_path=False,
|
||||||
width=80,
|
width=80,
|
||||||
theme=Theme(
|
theme=build_console_theme(primary_scheme, theme_styles, config.set_terminal_bg),
|
||||||
{
|
|
||||||
"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,
|
|
||||||
}
|
|
||||||
),
|
|
||||||
log_renderer=ComfyLogRenderer(show_time=False, show_path=False),
|
log_renderer=ComfyLogRenderer(show_time=False, show_path=False),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -524,7 +827,9 @@ __all__ = (
|
|||||||
"ComfyLogRenderer",
|
"ComfyLogRenderer",
|
||||||
"ComfyRichHandler",
|
"ComfyRichHandler",
|
||||||
"ComfyConsole",
|
"ComfyConsole",
|
||||||
|
"Gradient",
|
||||||
"GradientPulseBarColumn",
|
"GradientPulseBarColumn",
|
||||||
|
"PinnedLive",
|
||||||
"SyncLive",
|
"SyncLive",
|
||||||
"console",
|
"console",
|
||||||
"listing_panel",
|
"listing_panel",
|
||||||
@@ -534,4 +839,5 @@ __all__ = (
|
|||||||
"set_notice_handler",
|
"set_notice_handler",
|
||||||
"set_prompt_handler",
|
"set_prompt_handler",
|
||||||
"show_notice",
|
"show_notice",
|
||||||
|
"use_gradients",
|
||||||
)
|
)
|
||||||
|
|||||||
+235
-174
@@ -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
|
A theme sets the colours of the `ROLES`, can override any Rich style under `styles:`, and can draw the
|
||||||
brand accent, the six ANSI hues, and the grey ramp used for rules and dim details. The active
|
`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
|
||||||
palette drives both the rich Console theme (banner, logs, progress) and the click help screens,
|
theme with the same name. The active theme drives both the rich Console theme (banner, logs,
|
||||||
so a single `theme` config value restyles the whole CLI.
|
progress) and the click help screens.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
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
|
from rich_click import RichHelpConfiguration
|
||||||
|
|
||||||
DEFAULT_THEME = "catppuccin-mocha"
|
from unshackle.core.config import config
|
||||||
|
|
||||||
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",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
DEFAULT_THEME = "unshackle"
|
||||||
ALIASES = {
|
ALIASES = {
|
||||||
"default": DEFAULT_THEME,
|
"default": DEFAULT_THEME,
|
||||||
"catppuccin": DEFAULT_THEME,
|
"catppuccin": "catppuccin-mocha",
|
||||||
"mocha": DEFAULT_THEME,
|
"mocha": "catppuccin-mocha",
|
||||||
"onedark": "one-dark",
|
"onedark": "one-dark",
|
||||||
"tokyonight": "tokyo-night",
|
|
||||||
"solarized": "solarized-dark",
|
"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:
|
ThemeData = tuple[dict[str, str], dict[str, str], dict[str, Any]]
|
||||||
"""Look up a palette by theme name or alias, case-insensitively.
|
|
||||||
|
|
||||||
Takes any YAML scalar, because unshackle does not validate config values. A non-string
|
|
||||||
never matches a palette name.
|
def normalize_name(name: object) -> str:
|
||||||
Returns a copy: console.py extends the active palette in place, and that must
|
"""Normalise a theme name or file stem: lowercase, with `_` read as `-`."""
|
||||||
not pollute the master definitions.
|
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("_", "-")
|
key = canonical_name(name)
|
||||||
palette = PALETTES.get(ALIASES.get(key, key))
|
shipped = shipped_themes()
|
||||||
return dict(palette) if palette else None
|
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:
|
def build_help_config(palette: dict[str, str]) -> RichHelpConfiguration:
|
||||||
"""Assemble the rich-click help configuration for a palette."""
|
"""Assemble the rich-click help configuration for a palette."""
|
||||||
return RichHelpConfiguration(
|
return RichHelpConfiguration(
|
||||||
|
enable_theme_env_var=False,
|
||||||
style_option=palette["text"],
|
style_option=palette["text"],
|
||||||
style_switch=palette["green"],
|
style_switch=palette["green"],
|
||||||
style_argument=palette["cyan"],
|
style_argument=palette["cyan"],
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -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
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -2268,7 +2268,7 @@ requires-dist = [
|
|||||||
{ name = "pywidevine", extras = ["serve"], specifier = ">=1.8.0,<2" },
|
{ name = "pywidevine", extras = ["serve"], specifier = ">=1.8.0,<2" },
|
||||||
{ name = "pyyaml", specifier = ">=6.0.1,<7" },
|
{ name = "pyyaml", specifier = ">=6.0.1,<7" },
|
||||||
{ name = "requests", extras = ["socks"], specifier = ">=2.32.5,<3" },
|
{ 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 = "rich-click", specifier = ">=1.9.8" },
|
||||||
{ name = "rlaphoenix-m3u8", specifier = ">=3.4.0,<4" },
|
{ name = "rlaphoenix-m3u8", specifier = ">=3.4.0,<4" },
|
||||||
{ name = "rnet", specifier = ">=2.4.2" },
|
{ name = "rnet", specifier = ">=2.4.2" },
|
||||||
|
|||||||
Reference in New Issue
Block a user