chore: python code formatted with ruff, ruff formatter + linter, pyproject.toml now in root
C++ / fmt (pull_request) Successful in 4s
JS/TS / fmt (pull_request) Successful in 15s
JS/TS / lint (pull_request) Successful in 18s
Python / lint (pull_request) Failing after 32s
Python / fmt (pull_request) Failing after 37s
Python / test (pull_request) Successful in 52s
C++ / build (pull_request) Successful in 1m56s
Rust / fmt (pull_request) Successful in 1m22s
Rust / build (pull_request) Successful in 2m5s
Python / buildcheck (pull_request) Successful in 2m39s
Rust / clippy (pull_request) Successful in 2m1s
C++ / clang-tidy (pull_request) Successful in 3m46s

This commit is contained in:
2026-07-08 16:54:41 +02:00
parent 298cbbf424
commit 9300da258e
10 changed files with 1505 additions and 1315 deletions
-37
View File
@@ -1,37 +0,0 @@
[build-system]
requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build"
[project]
name = "zshell"
requires-python = ">=3.13"
version = "0.1.0"
dependencies = [
"typer",
"pillow",
"jinja2",
"materialyoucolor"
]
[project.scripts]
zshell-cli = "zshell:main"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build]
include = [
"src/zshell/assets/**",
]
[tool.hatch.build.targets.sdist]
only-include = [
"src",
]
[tool.ruff]
line-length = 120
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
+51 -33
View File
@@ -1,12 +1,14 @@
from __future__ import annotations
import os
import sys
from pathlib import Path
import typer
from typer._completion_shared import install, _get_shell_name
from typer._completion_classes import completion_init
from zshell.subcommands import shell, scheme, screenshot, wallpaper, record
from typer._completion_shared import _get_shell_name, install
from zshell.subcommands import record, scheme, screenshot, shell, wallpaper
app = typer.Typer(name="zshell-cli", add_completion=False)
@@ -18,40 +20,56 @@ app.add_typer(record.app, name="record")
def _completion_installed() -> bool:
shell = _get_shell_name()
match shell:
case "zsh":
return (Path.home() / ".zfunc" / "_zshell-cli").exists()
case "bash":
return (Path.home() / ".bash_completions" / "zshell-cli.sh").exists()
case "fish":
return (Path.home() / ".config" / "fish" / "completions" / "zshell-cli.fish").exists()
return False
shell = _get_shell_name()
match shell:
case "zsh":
return (Path.home() / ".zfunc" / "_zshell-cli").exists()
case "bash":
return (
Path.home() / ".bash_completions" / "zshell-cli.sh"
).exists()
case "fish":
return (
Path.home()
/ ".config"
/ "fish"
/ "completions"
/ "zshell-cli.fish"
).exists()
return False
def _install_completion() -> None:
if _completion_installed():
print("zshell-cli: Shell completion already installed.")
sys.exit(0)
shell = _get_shell_name()
if shell is None:
print("zshell-cli: Unable to detect shell type.", file=sys.stderr)
sys.exit(1)
try:
_, path = install(prog_name="zshell-cli")
print(f"zshell-cli: Shell completion installed ({shell}: {path})")
print("zshell-cli: Restart your shell or source the file to enable tab-completion.")
except Exception as e:
print(f"zshell-cli: Failed to install shell completion: {e}", file=sys.stderr)
raise typer.Exit(code=1)
if _completion_installed():
print("zshell-cli: Shell completion already installed.")
sys.exit(0)
shell = _get_shell_name()
if shell is None:
print("zshell-cli: Unable to detect shell type.", file=sys.stderr)
sys.exit(1)
try:
_, path = install(prog_name="zshell-cli")
print(f"zshell-cli: Shell completion installed ({shell}: {path})")
print(
"zshell-cli: Restart your shell or source the file to enable tab-completion."
)
except Exception as e:
print(
f"zshell-cli: Failed to install shell completion: {e}",
file=sys.stderr,
)
raise typer.Exit(code=1) from None
def main() -> None:
if "--install-autocomplete" in sys.argv:
_install_completion()
return
if "_ZSHELL_CLI_COMPLETE" in os.environ:
completion_init()
if sys.stdout.isatty() and not _completion_installed():
print("zshell-cli: Tip: run with --install-autocomplete for tab completion.", file=sys.stderr)
app()
if "--install-autocomplete" in sys.argv:
_install_completion()
return
if "_ZSHELL_CLI_COMPLETE" in os.environ:
completion_init()
if sys.stdout.isatty() and not _completion_installed():
print(
"zshell-cli: Tip: run with --install-autocomplete for tab completion.",
file=sys.stderr,
)
app()
+1 -1
View File
@@ -1,4 +1,4 @@
from zshell import main
if __name__ == "__main__":
main()
main()
+195 -135
View File
@@ -1,9 +1,9 @@
import os
import contextlib
import json
import os
import subprocess
import time
from pathlib import Path
from typing import Optional
import typer
@@ -18,193 +18,253 @@ TEMP_RECORDING = STATE_DIR / "recording.mp4"
REPLAY_RECORDING = STATE_DIR / "replay.mp4"
NOTIF_ID_FILE = STATE_DIR / "notifid.txt"
RECORDINGS_DIR = os.getenv("ZSHELL_RECORDINGS_DIR", str(Path(HOME) / "Videos/Recordings"))
RECORDINGS_DIR = os.getenv(
"ZSHELL_RECORDINGS_DIR", str(Path(HOME) / "Videos/Recordings")
)
def _read_extra_args() -> list[str]:
try:
if CONFIG.is_file():
data = json.loads(CONFIG.read_text())
return data.get("record", {}).get("extraArgs", [])
except Exception:
pass
return []
try:
if CONFIG.is_file():
data = json.loads(CONFIG.read_text())
return data.get("record", {}).get("extraArgs", [])
except Exception:
pass
return []
def _is_recording() -> bool:
return subprocess.run(["pidof", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0
return (
subprocess.run(
["pidof", RECORDER],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
).returncode
== 0
)
def _notify(summary: str, body: str = "", actions: list | None = None, timeout: int = 5000) -> Optional[int]:
args = ["notify-send", summary, body, "-t", str(timeout), "-p"]
if actions:
for action in actions:
args.extend(["-A", action])
try:
proc = subprocess.run(args, capture_output=True, text=True)
return int(proc.stdout.strip()) if proc.stdout.strip().isdigit() else None
except Exception:
return None
def _notify(
summary: str,
body: str = "",
actions: list | None = None,
timeout: int = 5000,
) -> int | None:
args = ["notify-send", summary, body, "-t", str(timeout), "-p"]
if actions:
for action in actions:
args.extend(["-A", action])
try:
proc = subprocess.run(args, capture_output=True, text=True)
return (
int(proc.stdout.strip()) if proc.stdout.strip().isdigit() else None
)
except Exception:
return None
def _close_notification(notif_id: int):
subprocess.run(["notify-send", "--close", str(notif_id)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(
["notify-send", "--close", str(notif_id)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def _get_monitors() -> list[dict]:
try:
res = subprocess.run(["hyprctl", "monitors", "-j"], capture_output=True, text=True)
return json.loads(res.stdout)
except Exception:
return []
try:
res = subprocess.run(
["hyprctl", "monitors", "-j"], capture_output=True, text=True
)
return json.loads(res.stdout)
except Exception:
return []
def _focused_monitor_name() -> Optional[str]:
for m in _get_monitors():
if m.get("focused"):
return m["name"]
return None
def _focused_monitor_name() -> str | None:
for m in _get_monitors():
if m.get("focused"):
return m["name"]
return None
def _monitors_intersecting_region(x: int, y: int, w: int, h: int) -> list[dict]:
region = (x, y, x + w, y + h)
intersecting = []
for m in _get_monitors():
mx, my, mw, mh = m["x"], m["y"], m["width"], m["height"]
if not (region[2] <= mx or region[0] >= mx + mw or region[3] <= my or region[1] >= my + mh):
intersecting.append(m)
return intersecting
region = (x, y, x + w, y + h)
intersecting = []
for m in _get_monitors():
mx, my, mw, mh = m["x"], m["y"], m["width"], m["height"]
if not (
region[2] <= mx
or region[0] >= mx + mw
or region[3] <= my
or region[1] >= my + mh
):
intersecting.append(m)
return intersecting
def _highest_refresh(monitors: list[dict]) -> float:
return max((m["refreshRate"] for m in monitors), default=60.0)
return max((m["refreshRate"] for m in monitors), default=60.0)
def _slurp_region() -> Optional[str]:
try:
return subprocess.check_output(["slurp", "-f", "%wx%h+%x+%y"], text=True).strip()
except subprocess.CalledProcessError:
return None
def _slurp_region() -> str | None:
try:
return subprocess.check_output(
["slurp", "-f", "%wx%h+%x+%y"], text=True
).strip()
except subprocess.CalledProcessError:
return None
def _parse_geometry(geometry: str) -> Optional[tuple[int, int, int, int]]:
import re
def _parse_geometry(geometry: str) -> tuple[int, int, int, int] | None:
import re
match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry)
if match:
return int(match.group(3)), int(match.group(4)), int(match.group(1)), int(match.group(2))
return None
match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry)
if match:
return (
int(match.group(3)),
int(match.group(4)),
int(match.group(1)),
int(match.group(2)),
)
return None
def start_recording(region: Optional[str], sound: bool):
STATE_DIR.mkdir(parents=True, exist_ok=True)
cmd = [RECORDER]
extra_args = _read_extra_args()
def start_recording(region: str | None, sound: bool):
STATE_DIR.mkdir(parents=True, exist_ok=True)
cmd = [RECORDER]
extra_args = _read_extra_args()
if region:
if region.lower() == "slurp" or not region:
geometry = _slurp_region()
if not geometry:
typer.echo("Region selection cancelled.")
raise typer.Abort()
else:
geometry = region
if region:
if region.lower() == "slurp" or not region:
geometry = _slurp_region()
if not geometry:
typer.echo("Region selection cancelled.")
raise typer.Abort()
else:
geometry = region
parsed = _parse_geometry(geometry)
if not parsed:
typer.echo("Invalid geometry format.")
raise typer.Abort()
x, y, w, h = parsed
parsed = _parse_geometry(geometry)
if not parsed:
typer.echo("Invalid geometry format.")
raise typer.Abort()
x, y, w, h = parsed
monitors = _monitors_intersecting_region(x, y, w, h)
framerate = _highest_refresh(monitors)
cmd.extend(["-w", "region", "-region", geometry, "-f", str(int(framerate))])
monitors = _monitors_intersecting_region(x, y, w, h)
framerate = _highest_refresh(monitors)
cmd.extend(
["-w", "region", "-region", geometry, "-f", str(int(framerate))]
)
else:
monitor_name = _focused_monitor_name()
if not monitor_name:
typer.echo("No focused monitor found.")
raise typer.Abort()
else:
monitor_name = _focused_monitor_name()
if not monitor_name:
typer.echo("No focused monitor found.")
raise typer.Abort()
monitors = _get_monitors()
mon = next((m for m in monitors if m["name"] == monitor_name), None)
rate = int(mon["refreshRate"]) if mon else 60
cmd.extend(["-w", monitor_name, "-f", str(rate)])
monitors = _get_monitors()
mon = next((m for m in monitors if m["name"] == monitor_name), None)
rate = int(mon["refreshRate"]) if mon else 60
cmd.extend(["-w", monitor_name, "-f", str(rate)])
if sound:
cmd.extend(["-a", "default_output"])
if sound:
cmd.extend(["-a", "default_output"])
cmd.extend(extra_args)
cmd.extend(["-o", str(TEMP_RECORDING)])
cmd.extend(extra_args)
cmd.extend(["-o", str(TEMP_RECORDING)])
subprocess.Popen(cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.Popen(
cmd,
start_new_session=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
notif_id = _notify("Recording started", f"Saving to {TEMP_RECORDING}")
if notif_id is not None:
NOTIF_ID_FILE.write_text(str(notif_id))
notif_id = _notify("Recording started", f"Saving to {TEMP_RECORDING}")
if notif_id is not None:
NOTIF_ID_FILE.write_text(str(notif_id))
time.sleep(1)
if not _is_recording():
_notify("Recording failed", "Check gpu-screen-recorder output.", timeout=5000)
raise typer.Exit(code=1)
time.sleep(1)
if not _is_recording():
_notify(
"Recording failed",
"Check gpu-screen-recorder output.",
timeout=5000,
)
raise typer.Exit(code=1)
def stop_recording(clipboard: bool):
subprocess.run(["pkill", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
subprocess.run(
["pkill", "-f", RECORDER],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
for _ in range(50):
if not _is_recording():
break
time.sleep(0.1)
for _ in range(50):
if not _is_recording():
break
time.sleep(0.1)
dest_dir = Path(RECORDINGS_DIR)
dest_dir.mkdir(parents=True, exist_ok=True)
timestamp = time.strftime("%Y-%m-%d_%H-%M-%S")
final_path = dest_dir / f"recording_{timestamp}.mp4"
dest_dir = Path(RECORDINGS_DIR)
dest_dir.mkdir(parents=True, exist_ok=True)
timestamp = time.strftime("%Y-%m-%d_%H-%M-%S")
final_path = dest_dir / f"recording_{timestamp}.mp4"
if TEMP_RECORDING.exists():
TEMP_RECORDING.rename(final_path)
if TEMP_RECORDING.exists():
TEMP_RECORDING.rename(final_path)
if NOTIF_ID_FILE.is_file():
try:
_close_notification(int(NOTIF_ID_FILE.read_text().strip()))
except Exception:
pass
NOTIF_ID_FILE.unlink()
if NOTIF_ID_FILE.is_file():
with contextlib.suppress(Exception):
_close_notification(int(NOTIF_ID_FILE.read_text().strip()))
NOTIF_ID_FILE.unlink()
if clipboard:
subprocess.run(
["wl-copy", "--type", "text/uri-list", f"file://{final_path}"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
if clipboard:
subprocess.run(
["wl-copy", "--type", "text/uri-list", f"file://{final_path}"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
_notify("Recording stopped", f"Saved to {final_path}", timeout=5000)
_notify("Recording stopped", f"Saved to {final_path}", timeout=5000)
def toggle_pause():
subprocess.run(["pkill", "-USR2", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
typer.echo("Toggled pause.")
subprocess.run(
["pkill", "-USR2", "-f", RECORDER],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
typer.echo("Toggled pause.")
@app.command()
def record(
region: Optional[str] = typer.Option(
None,
"--region",
"-r",
help="Record a region. Use 'slurp' (or omit value) to select interactively, or give 'WxH+X+Y'.",
),
sound: bool = typer.Option(False, "--sound", "-s", help="Record audio from default output."),
pause: bool = typer.Option(False, "--pause", "-p", help="Toggle pause/resume."),
clipboard: bool = typer.Option(False, "--clipboard", "-c", help="Copy the final recording path to clipboard."),
region: str | None = typer.Option(
None,
"--region",
"-r",
help="Record a region. Use 'slurp' (or omit value) to select interactively, or give 'WxH+X+Y'.",
),
sound: bool = typer.Option(
False, "--sound", "-s", help="Record audio from default output."
),
pause: bool = typer.Option(
False, "--pause", "-p", help="Toggle pause/resume."
),
clipboard: bool = typer.Option(
False,
"--clipboard",
"-c",
help="Copy the final recording path to clipboard.",
),
):
"""Start or stop a screen recording with gpu-screen-recorder."""
if pause:
toggle_pause()
raise typer.Exit()
"""Start or stop a screen recording with gpu-screen-recorder."""
if pause:
toggle_pause()
raise typer.Exit()
if _is_recording():
stop_recording(clipboard)
else:
start_recording(region, sound)
if _is_recording():
stop_recording(clipboard)
else:
start_recording(region, sound)
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -1,4 +1,5 @@
import subprocess
import typer
args = ["qs", "-c", "zshell"]
@@ -8,9 +9,9 @@ app = typer.Typer()
@app.command()
def start():
subprocess.run(args + ["ipc"] + ["call"] + ["picker"] + ["open"], check=True)
subprocess.run([*args, "ipc", "call", "picker", "open"], check=True)
@app.command()
def start_freeze():
subprocess.run(args + ["ipc"] + ["call"] + ["picker"] + ["openFreeze"], check=True)
subprocess.run([*args, "ipc", "call", "picker", "openFreeze"], check=True)
+55 -47
View File
@@ -11,76 +11,84 @@ app = typer.Typer()
@app.command()
def kill():
result = subprocess.run(args + ["kill"], capture_output=True)
if result.returncode != 0:
sys.stderr.write("No running instance to kill.\n")
sys.exit(1)
sys.stderr.write(result.stderr.decode())
result = subprocess.run([*args, "kill"], capture_output=True)
if result.returncode != 0:
sys.stderr.write("No running instance to kill.\n")
sys.exit(1)
sys.stderr.write(result.stderr.decode())
def start_instance(no_daemon: bool = False) -> None:
result = subprocess.run(args + ["-n"] + ([] if no_daemon else ["-d"]), capture_output=True)
stdout = result.stdout.decode().strip()
if stdout:
if "already running" in stdout.lower():
sys.stderr.write(stdout + "\n")
sys.exit(1)
if result.returncode != 0:
stderr = result.stderr.decode().strip()
sys.stderr.write(stderr + "\n")
sys.exit(1)
result = subprocess.run(
args + ["-n"] + ([] if no_daemon else ["-d"]), capture_output=True
)
stdout = result.stdout.decode().strip()
if stdout and "already running" in stdout.lower():
sys.stderr.write(stdout + "\n")
sys.exit(1)
if result.returncode != 0:
stderr = result.stderr.decode().strip()
sys.stderr.write(stderr + "\n")
sys.exit(1)
@app.command()
def start(no_daemon: bool = False):
start_instance(no_daemon)
start_instance(no_daemon)
@app.command()
def restart(no_daemon: bool = False):
subprocess.run(args + ["kill"], capture_output=True)
deadline = time.monotonic() + 2.5
while time.monotonic() < deadline:
result = subprocess.run(args + ["kill"], capture_output=True)
if result.returncode == 255:
break
time.sleep(0.25)
start_instance(no_daemon=no_daemon)
subprocess.run([*args, "kill"], capture_output=True)
deadline = time.monotonic() + 2.5
while time.monotonic() < deadline:
result = subprocess.run([*args, "kill"], capture_output=True)
if result.returncode == 255:
break
time.sleep(0.25)
start_instance(no_daemon=no_daemon)
@app.command()
def show():
result = subprocess.run(args + ["ipc"] + ["show"], capture_output=True)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
sys.stdout.write(result.stdout.decode())
sys.stderr.write(result.stderr.decode())
result = subprocess.run([*args, "ipc", "show"], capture_output=True)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
sys.stdout.write(result.stdout.decode())
sys.stderr.write(result.stderr.decode())
@app.command()
def log():
result = subprocess.run(args + ["log"], capture_output=True)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
sys.stdout.write(result.stdout.decode())
sys.stderr.write(result.stderr.decode())
result = subprocess.run([*args, "log"], capture_output=True)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
sys.stdout.write(result.stdout.decode())
sys.stderr.write(result.stderr.decode())
@app.command()
def lock():
result = subprocess.run(args + ["ipc"] + ["call"] + ["lock"] + ["lock"], capture_output=True)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
sys.stderr.write(result.stderr.decode())
result = subprocess.run(
[*args, "ipc", "call", "lock", "lock"], capture_output=True
)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
sys.stderr.write(result.stderr.decode())
@app.command()
def call(target: str, method: str, method_args: list[str] = typer.Argument(None)):
result = subprocess.run(args + ["ipc"] + ["call"] + [target] + [method] + (method_args or []), capture_output=True)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
sys.stderr.write(result.stderr.decode())
def call(
target: str, method: str, method_args: list[str] = typer.Argument(None)
):
result = subprocess.run(
args + ["ipc"] + ["call"] + [target] + [method] + (method_args or []),
capture_output=True,
)
if result.returncode != 0:
sys.stderr.write(result.stderr.decode())
sys.exit(1)
sys.stderr.write(result.stderr.decode())
+28 -25
View File
@@ -1,9 +1,9 @@
import subprocess
import typer
from typing import Annotated
from PIL import Image, ImageFilter
from pathlib import Path
from typing import Annotated
import typer
from PIL import Image, ImageFilter
args = ["qs", "-c", "zshell"]
@@ -12,32 +12,35 @@ app = typer.Typer()
@app.command()
def set(wallpaper: Path):
subprocess.run(args + ["ipc"] + ["call"] + ["wallpaper"] + ["set"] + [wallpaper], check=True)
subprocess.run(
[*args, "ipc", "call", "wallpaper", "set", wallpaper],
check=True,
)
@app.command()
def lockscreen(
input_image: Annotated[
Path,
typer.Option(),
],
output_path: Annotated[
Path,
typer.Option(),
],
blur_amount: int = 20,
input_image: Annotated[
Path,
typer.Option(),
],
output_path: Annotated[
Path,
typer.Option(),
],
blur_amount: int = 20,
):
img = Image.open(input_image)
size = img.size
if blur_amount == 0:
img.save(output_path, "PNG")
return
img = Image.open(input_image)
size = img.size
if blur_amount == 0:
img.save(output_path, "PNG")
return
if size[0] < 3840 or size[1] < 2160:
img = img.resize((size[0] // 2, size[1] // 2), Image.Resampling.NEAREST)
else:
img = img.resize((size[0] // 4, size[1] // 4), Image.Resampling.NEAREST)
if size[0] < 3840 or size[1] < 2160:
img = img.resize((size[0] // 2, size[1] // 2), Image.Resampling.NEAREST)
else:
img = img.resize((size[0] // 4, size[1] // 4), Image.Resampling.NEAREST)
img = img.filter(ImageFilter.GaussianBlur(blur_amount))
img = img.filter(ImageFilter.GaussianBlur(blur_amount))
img.save(output_path, "PNG")
img.save(output_path, "PNG")
+98 -95
View File
@@ -10,137 +10,140 @@ ASSETS: Traversable = files("zshell") / "assets" / "schemes"
@dataclass(frozen=True)
class SchemeVariant:
id: str
name: str
modes: frozenset[str]
accents: tuple[str, ...] = ()
id: str
name: str
modes: frozenset[str]
accents: tuple[str, ...] = ()
@dataclass(frozen=True)
class SchemeMeta:
id: str
name: str
variants: tuple[SchemeVariant, ...]
id: str
name: str
variants: tuple[SchemeVariant, ...]
@dataclass
class Palette:
colors: dict[str, str]
mode: str
scheme: str
variant: str
accent: str | None = None
colors: dict[str, str]
mode: str
scheme: str
variant: str
accent: str | None = None
def _parse_txt(path: Traversable) -> dict[str, str]:
colors: dict[str, str] = {}
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
if len(parts) == 2:
key, val = parts
colors[key] = f"#{val}" if not val.startswith("#") else val
return colors
colors: dict[str, str] = {}
for line in path.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
parts = line.split(None, 1)
if len(parts) == 2:
key, val = parts
colors[key] = f"#{val}" if not val.startswith("#") else val
return colors
def _discover_schemes() -> dict[str, SchemeMeta]:
schemes: dict[str, SchemeMeta] = {}
schemes: dict[str, SchemeMeta] = {}
for scheme_dir in sorted(ASSETS.iterdir(), key=lambda p: p.name):
if not scheme_dir.is_dir() or scheme_dir.name.startswith("."):
continue
for scheme_dir in sorted(ASSETS.iterdir(), key=lambda p: p.name):
if not scheme_dir.is_dir() or scheme_dir.name.startswith("."):
continue
sid = scheme_dir.name
display_name = sid.capitalize()
sid = scheme_dir.name
display_name = sid.capitalize()
variants: list[SchemeVariant] = []
for var_dir in sorted(scheme_dir.iterdir(), key=lambda p: p.name):
if not var_dir.is_dir() or var_dir.name.startswith("."):
continue
variants: list[SchemeVariant] = []
for var_dir in sorted(scheme_dir.iterdir(), key=lambda p: p.name):
if not var_dir.is_dir() or var_dir.name.startswith("."):
continue
modes: set[str] = set()
accents: set[str] = set()
modes: set[str] = set()
accents: set[str] = set()
for f in var_dir.iterdir():
name = PurePosixPath(f.name)
if name.suffix != ".txt":
continue
stem = name.stem
if "-" in stem:
maybe_accent, maybe_mode = stem.rsplit("-", 1)
if maybe_mode in ("dark", "light"):
modes.add(maybe_mode)
accents.add(maybe_accent)
else:
modes.add(stem)
else:
if stem in ("dark", "light"):
modes.add(stem)
for f in var_dir.iterdir():
name = PurePosixPath(f.name)
if name.suffix != ".txt":
continue
stem = name.stem
if "-" in stem:
maybe_accent, maybe_mode = stem.rsplit("-", 1)
if maybe_mode in ("dark", "light"):
modes.add(maybe_mode)
accents.add(maybe_accent)
else:
modes.add(stem)
else:
if stem in ("dark", "light"):
modes.add(stem)
if modes:
vname = var_dir.name.capitalize()
variants.append(
SchemeVariant(
id=var_dir.name,
name=vname,
modes=frozenset(modes),
accents=tuple(sorted(accents)),
)
)
if modes:
vname = var_dir.name.capitalize()
variants.append(
SchemeVariant(
id=var_dir.name,
name=vname,
modes=frozenset(modes),
accents=tuple(sorted(accents)),
)
)
schemes[sid] = SchemeMeta(
id=sid,
name=display_name,
variants=tuple(variants),
)
schemes[sid] = SchemeMeta(
id=sid,
name=display_name,
variants=tuple(variants),
)
return schemes
return schemes
SCHEMES: dict[str, SchemeMeta] = _discover_schemes()
def get_palette(scheme: str, variant: str, mode: str, accent: str | None = None) -> Palette:
if scheme not in SCHEMES:
raise KeyError(
f"Unknown scheme '{scheme}'. Available: {', '.join(SCHEMES)}")
def get_palette(
scheme: str, variant: str, mode: str, accent: str | None = None
) -> Palette:
if scheme not in SCHEMES:
raise KeyError(
f"Unknown scheme '{scheme}'. Available: {', '.join(SCHEMES)}"
)
meta = SCHEMES[scheme]
var_ids = {v.id for v in meta.variants}
if variant not in var_ids:
raise KeyError(
f"Unknown variant '{variant}' for scheme '{scheme}'. Available: {', '.join(sorted(var_ids))}")
meta = SCHEMES[scheme]
var_ids = {v.id for v in meta.variants}
if variant not in var_ids:
raise KeyError(
f"Unknown variant '{variant}' for scheme '{scheme}'. Available: {', '.join(sorted(var_ids))}"
)
if accent:
filename = f"{accent}-{mode}.txt"
else:
filename = f"{mode}.txt"
filename = f"{accent}-{mode}.txt" if accent else f"{mode}.txt"
txt_path = ASSETS / scheme / variant / filename
if not txt_path.is_file():
txt_path = ASSETS / scheme / variant / f"{mode}.txt"
txt_path = ASSETS / scheme / variant / filename
if not txt_path.is_file():
txt_path = ASSETS / scheme / variant / f"{mode}.txt"
if not txt_path.is_file():
var_info = next(v for v in meta.variants if v.id == variant)
raise FileNotFoundError(
f"No {mode} palette for '{scheme}:{variant}'. Available modes: {sorted(var_info.modes)}"
)
if not txt_path.is_file():
var_info = next(v for v in meta.variants if v.id == variant)
raise FileNotFoundError(
f"No {mode} palette for '{scheme}:{variant}'. Available modes: {sorted(var_info.modes)}"
)
colors = _parse_txt(txt_path)
colors = _parse_txt(txt_path)
return Palette(colors=colors, mode=mode, scheme=scheme, variant=variant, accent=accent)
return Palette(
colors=colors, mode=mode, scheme=scheme, variant=variant, accent=accent
)
def list_schemes() -> dict[str, SchemeMeta]:
return dict(SCHEMES)
return dict(SCHEMES)
def resolve_preset(spec: str) -> tuple[str, str]:
parts = spec.split(":")
if len(parts) == 2:
return parts[0], parts[1]
if len(parts) == 1:
return parts[0], "default"
raise ValueError(f"Invalid preset spec '{spec}'. Use <scheme>:<variant>")
parts = spec.split(":")
if len(parts) == 2:
return parts[0], parts[1]
if len(parts) == 1:
return parts[0], "default"
raise ValueError(f"Invalid preset spec '{spec}'. Use <scheme>:<variant>")