Merge pull request '100 shell autocomplete, type fixes, Pillow deprecation cleanup' (#101) from 100-cli-autocompletion into main

Reviewed-on: #101
Reviewed-by: zach <zach@brohn.se>
This commit was merged in pull request #101.
This commit is contained in:
2026-05-26 13:10:56 +02:00
4 changed files with 169 additions and 44 deletions
+39 -2
View File
@@ -1,16 +1,53 @@
from __future__ import annotations from __future__ import annotations
import sys
from pathlib import Path
import click
import typer import typer
from typer._completion_shared import install, _get_shell_name
from zshell.subcommands import shell, scheme, screenshot, wallpaper, record from zshell.subcommands import shell, scheme, screenshot, wallpaper, record
app = typer.Typer() app = typer.Typer(name="zshell-cli", add_completion=False)
app.add_typer(shell.app, name="shell") app.add_typer(shell.app, name="shell")
app.add_typer(scheme.app, name="scheme") app.add_typer(scheme.app, name="scheme")
app.add_typer(screenshot.app, name="screenshot") app.add_typer(screenshot.app, name="screenshot")
app.add_typer(wallpaper.app, name="wallpaper") app.add_typer(wallpaper.app, name="wallpaper")
app.add_typer(record.app, name="record") app.add_typer(record.app, name="record")
# app.add_typer(preset.app, name="preset")
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
def _install_completion() -> None:
if _completion_installed():
click.echo("zshell-cli: Shell completion already installed.")
raise typer.Exit()
shell = _get_shell_name()
if shell is None:
click.echo("zshell-cli: Unable to detect shell type.", err=True)
raise typer.Exit(code=1)
try:
_, path = install(prog_name="zshell-cli")
click.secho(f"zshell-cli: Shell completion installed ({shell}: {path})", fg="green")
click.echo("zshell-cli: Restart your shell or source the file to enable tab-completion.")
except Exception:
pass
def main() -> None: def main() -> None:
if "--install-autocomplete" in sys.argv:
_install_completion()
return
if sys.stdout.isatty() and not _completion_installed():
click.echo("zshell-cli: Tip: run with --install-autocomplete for tab completion.", err=True)
app() app()
+20 -24
View File
@@ -18,8 +18,7 @@ TEMP_RECORDING = STATE_DIR / "recording.mp4"
REPLAY_RECORDING = STATE_DIR / "replay.mp4" REPLAY_RECORDING = STATE_DIR / "replay.mp4"
NOTIF_ID_FILE = STATE_DIR / "notifid.txt" NOTIF_ID_FILE = STATE_DIR / "notifid.txt"
RECORDINGS_DIR = os.getenv("ZSHELL_RECORDINGS_DIR", RECORDINGS_DIR = os.getenv("ZSHELL_RECORDINGS_DIR", str(Path(HOME) / "Videos/Recordings"))
str(Path(HOME) / "Videos/Recordings"))
def _read_extra_args() -> list[str]: def _read_extra_args() -> list[str]:
@@ -36,7 +35,7 @@ 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, timeout: int = 5000) -> Optional[int]: def _notify(summary: str, body: str = "", actions: list | None = None, timeout: int = 5000) -> Optional[int]:
args = ["notify-send", summary, body, "-t", str(timeout), "-p"] args = ["notify-send", summary, body, "-t", str(timeout), "-p"]
if actions: if actions:
for action in actions: for action in actions:
@@ -49,14 +48,12 @@ def _notify(summary: str, body: str = "", actions: list = None, timeout: int = 5
def _close_notification(notif_id: int): def _close_notification(notif_id: int):
subprocess.run(["notify-send", "--close", str(notif_id)], subprocess.run(["notify-send", "--close", str(notif_id)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def _get_monitors() -> list[dict]: def _get_monitors() -> list[dict]:
try: try:
res = subprocess.run(["hyprctl", "monitors", "-j"], res = subprocess.run(["hyprctl", "monitors", "-j"], capture_output=True, text=True)
capture_output=True, text=True)
return json.loads(res.stdout) return json.loads(res.stdout)
except Exception: except Exception:
return [] return []
@@ -92,6 +89,7 @@ def _slurp_region() -> Optional[str]:
def _parse_geometry(geometry: str) -> Optional[tuple[int, int, int, int]]: def _parse_geometry(geometry: str) -> Optional[tuple[int, int, int, int]]:
import re import re
match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry) match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry)
if match: if match:
return int(match.group(3)), int(match.group(4)), int(match.group(1)), int(match.group(2)) return int(match.group(3)), int(match.group(4)), int(match.group(1)), int(match.group(2))
@@ -139,8 +137,7 @@ def start_recording(region: Optional[str], sound: bool):
cmd.extend(extra_args) cmd.extend(extra_args)
cmd.extend(["-o", str(TEMP_RECORDING)]) cmd.extend(["-o", str(TEMP_RECORDING)])
subprocess.Popen(cmd, start_new_session=True, subprocess.Popen(cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
notif_id = _notify("Recording started", f"Saving to {TEMP_RECORDING}") notif_id = _notify("Recording started", f"Saving to {TEMP_RECORDING}")
if notif_id is not None: if notif_id is not None:
@@ -148,14 +145,12 @@ def start_recording(region: Optional[str], sound: bool):
time.sleep(1) time.sleep(1)
if not _is_recording(): if not _is_recording():
_notify("Recording failed", _notify("Recording failed", "Check gpu-screen-recorder output.", timeout=5000)
"Check gpu-screen-recorder output.", timeout=5000)
raise typer.Exit(code=1) raise typer.Exit(code=1)
def stop_recording(clipboard: bool): def stop_recording(clipboard: bool):
subprocess.run(["pkill", "-f", RECORDER], subprocess.run(["pkill", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
for _ in range(50): for _ in range(50):
if not _is_recording(): if not _is_recording():
@@ -178,30 +173,31 @@ def stop_recording(clipboard: bool):
NOTIF_ID_FILE.unlink() NOTIF_ID_FILE.unlink()
if clipboard: if clipboard:
subprocess.run(["wl-copy", "--type", "text/uri-list", f"file://{final_path}"], subprocess.run(
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) ["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(): def toggle_pause():
subprocess.run(["pkill", "-USR2", "-f", RECORDER], subprocess.run(["pkill", "-USR2", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
typer.echo("Toggled pause.") typer.echo("Toggled pause.")
@app.command() @app.command()
def record( def record(
region: Optional[str] = typer.Option( region: Optional[str] = typer.Option(
None, "--region", "-r", None,
"--region",
"-r",
help="Record a region. Use 'slurp' (or omit value) to select interactively, or give 'WxH+X+Y'.", help="Record a region. Use 'slurp' (or omit value) to select interactively, or give 'WxH+X+Y'.",
), ),
sound: bool = typer.Option( sound: bool = typer.Option(False, "--sound", "-s", help="Record audio from default output."),
False, "--sound", "-s", help="Record audio from default output."), pause: bool = typer.Option(False, "--pause", "-p", help="Toggle pause/resume."),
pause: bool = typer.Option( clipboard: bool = typer.Option(False, "--clipboard", "-c", help="Copy the final recording path to clipboard."),
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.""" """Start or stop a screen recording with gpu-screen-recorder."""
if pause: if pause:
+108 -16
View File
@@ -2,6 +2,7 @@ import typer
import json import json
import shutil import shutil
import os import os
import sys
import re import re
import subprocess import subprocess
@@ -15,11 +16,61 @@ from materialyoucolor.score.score import Score
from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors
from materialyoucolor.hct.hct import Hct from materialyoucolor.hct.hct import Hct
from materialyoucolor.utils.color_utils import argb_from_rgb from materialyoucolor.utils.color_utils import argb_from_rgb
from materialyoucolor.utils.math_utils import difference_degrees, rotation_direction, sanitize_degrees_double from materialyoucolor.utils.math_utils import (
difference_degrees,
rotation_direction,
sanitize_degrees_double,
)
app = typer.Typer() app = typer.Typer()
def _complete_scheme_name(incomplete):
schemes = [
"fruit-salad",
"expressive",
"monochrome",
"rainbow",
"tonal-spot",
"neutral",
"fidelity",
"content",
"vibrant",
]
return [s for s in schemes if incomplete in s]
def _complete_preset(incomplete):
results = []
for sid, meta in list_schemes().items():
for v in meta.variants:
preset = f"{sid}:{v.id}"
if incomplete in preset:
results.append((preset, f"{meta.name} - {v.name}"))
return results
def _complete_mode(incomplete):
return [m for m in ("dark", "light") if incomplete in m]
def _complete_accent(ctx, incomplete):
preset_val = ctx.params.get("preset")
if preset_val:
try:
p_scheme, p_variant = resolve_preset(preset_val)
for v in list_schemes()[p_scheme].variants:
if v.id == p_variant:
return [a for a in v.accents if incomplete in a]
except (ValueError, KeyError):
pass
all_accents = set()
for meta in list_schemes().values():
for v in meta.variants:
all_accents.update(v.accents)
return [a for a in sorted(all_accents) if incomplete in a]
@app.command() @app.command()
def list_presets( def list_presets(
json_format: bool = typer.Option(False, "--json", help="Output in JSON format"), json_format: bool = typer.Option(False, "--json", help="Output in JSON format"),
@@ -30,7 +81,7 @@ def list_presets(
for sid, meta in sorted(schemes.items()): for sid, meta in sorted(schemes.items()):
variants = {} variants = {}
for v in meta.variants: for v in meta.variants:
entry = {"modes": sorted(v.modes)} entry: dict[str, Any] = {"modes": sorted(v.modes)}
if v.accents: if v.accents:
entry["accents"] = sorted(v.accents) entry["accents"] = sorted(v.accents)
entry["default_accent"] = sorted(v.accents)[0] entry["default_accent"] = sorted(v.accents)[0]
@@ -55,14 +106,35 @@ def list_presets(
@app.command() @app.command()
def generate( def generate(
image_path: Optional[Path] = typer.Option(None, help="Path to source image. Required for image mode."), image_path: Optional[Path] = typer.Option(
scheme: Optional[str] = typer.Option( None, help="Path to source image. Required for image mode."
None, help="Color scheme algorithm to use for image mode. Ignored in preset mode." ),
scheme: Optional[str] = typer.Option(
None,
help="Color scheme algorithm to use for image mode. Ignored in preset mode.",
autocompletion=_complete_scheme_name,
),
preset: Optional[str] = typer.Option(
None,
help="Name of a premade scheme in this format: <scheme>:<variant>",
autocompletion=_complete_preset,
),
mode: Optional[str] = typer.Option(
None,
help="Mode of the preset scheme (dark or light).",
autocompletion=_complete_mode,
),
accent: Optional[str] = typer.Option(
None,
help="Accent for schemes that support it (e.g. mauve).",
autocompletion=_complete_accent,
), ),
preset: Optional[str] = typer.Option(None, help="Name of a premade scheme in this format: <scheme>:<variant>"),
mode: Optional[str] = typer.Option(None, help="Mode of the preset scheme (dark or light)."),
accent: Optional[str] = typer.Option(None, help="Accent for schemes that support it (e.g. mauve)."),
): ):
if not any([image_path, scheme, preset, mode, accent]):
print(
"Hint: use --preset <scheme>:<variant> or --image-path <path>",
file=sys.stderr,
)
HOME = str(os.getenv("HOME")) HOME = str(os.getenv("HOME"))
OUTPUT = Path(HOME + "/.local/state/zshell/scheme.json") OUTPUT = Path(HOME + "/.local/state/zshell/scheme.json")
@@ -200,11 +272,15 @@ def generate(
def harmonize(from_hct: Hct, to_hct: Hct, tone_boost: float) -> Hct: def harmonize(from_hct: Hct, to_hct: Hct, tone_boost: float) -> Hct:
diff = difference_degrees(from_hct.hue, to_hct.hue) diff = difference_degrees(from_hct.hue, to_hct.hue)
rotation = min(diff * 0.8, 100) rotation = min(diff * 0.8, 100)
output_hue = sanitize_degrees_double(from_hct.hue + rotation * rotation_direction(from_hct.hue, to_hct.hue)) output_hue = sanitize_degrees_double(
from_hct.hue + rotation * rotation_direction(from_hct.hue, to_hct.hue)
)
tone = max(0.0, min(100.0, from_hct.tone * (1 + tone_boost))) tone = max(0.0, min(100.0, from_hct.tone * (1 + tone_boost)))
return Hct.from_hct(output_hue, from_hct.chroma, tone) return Hct.from_hct(output_hue, from_hct.chroma, tone)
def terminal_palette(colors: dict[str, str], mode: str, variant: str) -> dict[str, str]: def terminal_palette(
colors: dict[str, str], mode: str, variant: str
) -> dict[str, str]:
light = mode.lower() == "light" light = mode.lower() == "light"
key_hex = ( key_hex = (
@@ -236,7 +312,7 @@ def generate(
image = Image.open(image_path) image = Image.open(image_path)
image = image.convert("RGB") image = image.convert("RGB")
image.thumbnail(size, Image.NEAREST) image.thumbnail(size, Image.Resampling.NEAREST)
thumbnail_file.parent.mkdir(parents=True, exist_ok=True) thumbnail_file.parent.mkdir(parents=True, exist_ok=True)
image.save(thumbnail_path, "JPEG") image.save(thumbnail_path, "JPEG")
@@ -268,8 +344,15 @@ def generate(
is_dark = "" is_dark = ""
with Image.open(image_path) as img: with Image.open(image_path) as img:
img.thumbnail((1, 1), Image.LANCZOS) img.thumbnail((1, 1), Image.Resampling.LANCZOS)
hct = Hct.from_int(argb_from_rgb(*img.getpixel((0, 0)))) px = img.getpixel((0, 0))
if isinstance(px, (int, float)):
r = g = b = int(px)
elif px is not None:
r, g, b = int(px[0]), int(px[1]), int(px[2])
else:
r = g = b = 0
hct = Hct.from_int(argb_from_rgb(r, g, b))
is_dark = "light" if hct.tone > 50 else "dark" is_dark = "light" if hct.tone > 50 else "dark"
return is_dark return is_dark
@@ -431,6 +514,8 @@ def generate(
raw = tpl_path.read_text(encoding="utf-8") raw = tpl_path.read_text(encoding="utf-8")
out_path, body = split_directive_and_body(raw) out_path, body = split_directive_and_body(raw)
if out_path is None:
continue
out_path.parent.mkdir(parents=True, exist_ok=True) out_path.parent.mkdir(parents=True, exist_ok=True)
@@ -484,23 +569,30 @@ def generate(
with CONFIG.open() as f: with CONFIG.open() as f:
config = json.load(f) config = json.load(f)
scheme = scheme or config["colors"]["schemeType"] scheme_type = config["colors"].get("schemeType", "fruit-salad")
scheme = scheme or scheme_type
assert isinstance(scheme, str)
config_mode = config["general"]["color"]["mode"] config_mode = config["general"]["color"]["mode"]
smart = bool(config["general"]["color"].get("smart", False)) smart = bool(config["general"]["color"].get("smart", False))
scheme_class = get_scheme_class(scheme) scheme_class = get_scheme_class(scheme)
p_variant = "default"
if preset: if preset:
p_scheme, p_variant = resolve_preset(preset) p_scheme, p_variant = resolve_preset(preset)
schemes = list_schemes() schemes = list_schemes()
if accent and p_scheme in schemes: if accent and p_scheme in schemes:
meta = schemes[p_scheme] meta = schemes[p_scheme]
var_accents = next((v.accents for v in meta.variants if v.id == p_variant), ()) var_accents = next(
(v.accents for v in meta.variants if v.id == p_variant), ()
)
if accent not in var_accents: if accent not in var_accents:
available = ", ".join(var_accents) if var_accents else "none" available = ", ".join(var_accents) if var_accents else "none"
raise typer.BadParameter( raise typer.BadParameter(
f"Accent '{accent}' not available for '{p_scheme}:{p_variant}'. Available accents: {available}" f"Accent '{accent}' not available for '{p_scheme}:{p_variant}'. Available accents: {available}"
) )
palette_obj = get_palette(p_scheme, p_variant, mode or config_mode, accent=accent) palette_obj = get_palette(
p_scheme, p_variant, mode or config_mode, accent=accent
)
colors = palette_obj.colors colors = palette_obj.colors
effective_mode = palette_obj.mode effective_mode = palette_obj.mode
name = palette_obj.scheme name = palette_obj.scheme
+2 -2
View File
@@ -34,9 +34,9 @@ def lockscreen(
return return
if size[0] < 3840 or size[1] < 2160: if size[0] < 3840 or size[1] < 2160:
img = img.resize((size[0] // 2, size[1] // 2), Image.NEAREST) img = img.resize((size[0] // 2, size[1] // 2), Image.Resampling.NEAREST)
else: else:
img = img.resize((size[0] // 4, size[1] // 4), Image.NEAREST) 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))