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