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:
@@ -1,16 +1,53 @@
|
||||
from __future__ import annotations
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import typer
|
||||
from typer._completion_shared import install, _get_shell_name
|
||||
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(scheme.app, name="scheme")
|
||||
app.add_typer(screenshot.app, name="screenshot")
|
||||
app.add_typer(wallpaper.app, name="wallpaper")
|
||||
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:
|
||||
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()
|
||||
|
||||
@@ -18,8 +18,7 @@ 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]:
|
||||
@@ -36,7 +35,7 @@ def _is_recording() -> bool:
|
||||
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"]
|
||||
if 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):
|
||||
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)
|
||||
res = subprocess.run(["hyprctl", "monitors", "-j"], capture_output=True, text=True)
|
||||
return json.loads(res.stdout)
|
||||
except Exception:
|
||||
return []
|
||||
@@ -92,6 +89,7 @@ def _slurp_region() -> Optional[str]:
|
||||
|
||||
def _parse_geometry(geometry: str) -> Optional[tuple[int, int, int, int]]:
|
||||
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))
|
||||
@@ -139,8 +137,7 @@ def start_recording(region: Optional[str], sound: bool):
|
||||
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:
|
||||
@@ -148,14 +145,12 @@ def start_recording(region: Optional[str], sound: bool):
|
||||
|
||||
time.sleep(1)
|
||||
if not _is_recording():
|
||||
_notify("Recording failed",
|
||||
"Check gpu-screen-recorder output.", timeout=5000)
|
||||
_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():
|
||||
@@ -178,30 +173,31 @@ def stop_recording(clipboard: bool):
|
||||
NOTIF_ID_FILE.unlink()
|
||||
|
||||
if clipboard:
|
||||
subprocess.run(["wl-copy", "--type", "text/uri-list", f"file://{final_path}"],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
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)
|
||||
|
||||
|
||||
def toggle_pause():
|
||||
subprocess.run(["pkill", "-USR2", "-f", RECORDER],
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
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",
|
||||
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."),
|
||||
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:
|
||||
|
||||
@@ -2,6 +2,7 @@ import typer
|
||||
import json
|
||||
import shutil
|
||||
import os
|
||||
import sys
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
@@ -15,11 +16,61 @@ from materialyoucolor.score.score import Score
|
||||
from materialyoucolor.dynamiccolor.material_dynamic_colors import MaterialDynamicColors
|
||||
from materialyoucolor.hct.hct import Hct
|
||||
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()
|
||||
|
||||
|
||||
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()
|
||||
def list_presets(
|
||||
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()):
|
||||
variants = {}
|
||||
for v in meta.variants:
|
||||
entry = {"modes": sorted(v.modes)}
|
||||
entry: dict[str, Any] = {"modes": sorted(v.modes)}
|
||||
if v.accents:
|
||||
entry["accents"] = sorted(v.accents)
|
||||
entry["default_accent"] = sorted(v.accents)[0]
|
||||
@@ -55,14 +106,35 @@ def list_presets(
|
||||
|
||||
@app.command()
|
||||
def generate(
|
||||
image_path: Optional[Path] = typer.Option(None, help="Path to source image. Required for image mode."),
|
||||
scheme: Optional[str] = typer.Option(
|
||||
None, help="Color scheme algorithm to use for image mode. Ignored in preset mode."
|
||||
image_path: Optional[Path] = typer.Option(
|
||||
None, help="Path to source image. Required for image 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"))
|
||||
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:
|
||||
diff = difference_degrees(from_hct.hue, to_hct.hue)
|
||||
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)))
|
||||
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"
|
||||
|
||||
key_hex = (
|
||||
@@ -236,7 +312,7 @@ def generate(
|
||||
|
||||
image = Image.open(image_path)
|
||||
image = image.convert("RGB")
|
||||
image.thumbnail(size, Image.NEAREST)
|
||||
image.thumbnail(size, Image.Resampling.NEAREST)
|
||||
|
||||
thumbnail_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
image.save(thumbnail_path, "JPEG")
|
||||
@@ -268,8 +344,15 @@ def generate(
|
||||
is_dark = ""
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
img.thumbnail((1, 1), Image.LANCZOS)
|
||||
hct = Hct.from_int(argb_from_rgb(*img.getpixel((0, 0))))
|
||||
img.thumbnail((1, 1), Image.Resampling.LANCZOS)
|
||||
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"
|
||||
|
||||
return is_dark
|
||||
@@ -431,6 +514,8 @@ def generate(
|
||||
|
||||
raw = tpl_path.read_text(encoding="utf-8")
|
||||
out_path, body = split_directive_and_body(raw)
|
||||
if out_path is None:
|
||||
continue
|
||||
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -484,23 +569,30 @@ def generate(
|
||||
with CONFIG.open() as 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"]
|
||||
smart = bool(config["general"]["color"].get("smart", False))
|
||||
scheme_class = get_scheme_class(scheme)
|
||||
|
||||
p_variant = "default"
|
||||
if preset:
|
||||
p_scheme, p_variant = resolve_preset(preset)
|
||||
schemes = list_schemes()
|
||||
if accent and p_scheme in schemes:
|
||||
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:
|
||||
available = ", ".join(var_accents) if var_accents else "none"
|
||||
raise typer.BadParameter(
|
||||
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
|
||||
effective_mode = palette_obj.mode
|
||||
name = palette_obj.scheme
|
||||
|
||||
@@ -34,9 +34,9 @@ def lockscreen(
|
||||
return
|
||||
|
||||
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:
|
||||
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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user