diff --git a/cli/src/zshell/__init__.py b/cli/src/zshell/__init__.py index 9c8c6b8..613ee3e 100644 --- a/cli/src/zshell/__init__.py +++ b/cli/src/zshell/__init__.py @@ -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() diff --git a/cli/src/zshell/__main__.py b/cli/src/zshell/__main__.py index 1520f59..fd4e8bd 100644 --- a/cli/src/zshell/__main__.py +++ b/cli/src/zshell/__main__.py @@ -1,4 +1,4 @@ from zshell import main if __name__ == "__main__": - main() + main() diff --git a/cli/src/zshell/subcommands/record.py b/cli/src/zshell/subcommands/record.py index 00a9c07..1d25c66 100644 --- a/cli/src/zshell/subcommands/record.py +++ b/cli/src/zshell/subcommands/record.py @@ -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) diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index 9792a85..7ba1dd1 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -1,699 +1,729 @@ -import typer +import contextlib import json -import shutil import os -import sys import re +import shutil import subprocess - -from jinja2 import Environment, FileSystemLoader, StrictUndefined, Undefined -from typing import Any, Optional, Tuple -from zshell.utils.schemepalettes import get_palette, list_schemes, resolve_preset +import sys from pathlib import Path -from PIL import Image +from typing import Any + +import typer +from jinja2 import Environment, FileSystemLoader, StrictUndefined, Undefined +from materialyoucolor.dynamiccolor.material_dynamic_colors import ( + MaterialDynamicColors, +) +from materialyoucolor.hct.hct import Hct from materialyoucolor.quantize import QuantizeCelebi 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, + difference_degrees, + rotation_direction, + sanitize_degrees_double, +) +from PIL import Image +from zshell.utils.schemepalettes import ( + get_palette, + list_schemes, + resolve_preset, ) 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] + 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 + 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] + 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] + 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"), + json_format: bool = typer.Option( + False, "--json", help="Output in JSON format" + ), ): - schemes = list_schemes() - if json_format: - out = {} - for sid, meta in sorted(schemes.items()): - variants = {} - for v in meta.variants: - entry: dict[str, Any] = {"modes": sorted(v.modes)} - if v.accents: - entry["accents"] = sorted(v.accents) - entry["default_accent"] = sorted(v.accents)[0] - variants[v.id] = entry - out[meta.name] = { - "id": sid, - "variants": variants, - } - print(json.dumps({"presets": out}, indent=2)) - else: - for sid, meta in sorted(schemes.items()): - var_list = [] - for v in meta.variants: - parts = [f"{v.id} ({', '.join(sorted(v.modes))})"] - if v.accents: - parts.append(f"accents: {', '.join(v.accents)}") - var_list.append(" | ".join(parts)) - print(f"{meta.name} ({sid})") - print(f" Variants: {', '.join(var_list)}") - print() + schemes = list_schemes() + if json_format: + out = {} + for sid, meta in sorted(schemes.items()): + variants = {} + for v in meta.variants: + entry: dict[str, Any] = {"modes": sorted(v.modes)} + if v.accents: + entry["accents"] = sorted(v.accents) + entry["default_accent"] = sorted(v.accents)[0] + variants[v.id] = entry + out[meta.name] = { + "id": sid, + "variants": variants, + } + print(json.dumps({"presets": out}, indent=2)) + else: + for sid, meta in sorted(schemes.items()): + var_list = [] + for v in meta.variants: + parts = [f"{v.id} ({', '.join(sorted(v.modes))})"] + if v.accents: + parts.append(f"accents: {', '.join(v.accents)}") + var_list.append(" | ".join(parts)) + print(f"{meta.name} ({sid})") + print(f" Variants: {', '.join(var_list)}") + print() @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.", - autocompletion=_complete_scheme_name, - ), - preset: Optional[str] = typer.Option( - None, - help="Name of a premade scheme in this format: :", - 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, - ), + image_path: Path | None = typer.Option( + None, help="Path to source image. Required for image mode." + ), + scheme: str | None = typer.Option( + None, + help="Color scheme algorithm to use for image mode. Ignored in preset mode.", + autocompletion=_complete_scheme_name, + ), + preset: str | None = typer.Option( + None, + help="Name of a premade scheme in this format: :", + autocompletion=_complete_preset, + ), + mode: str | None = typer.Option( + None, + help="Mode of the preset scheme (dark or light).", + autocompletion=_complete_mode, + ), + accent: str | None = typer.Option( + None, + help="Accent for schemes that support it (e.g. mauve).", + autocompletion=_complete_accent, + ), ): - if not any([image_path, scheme, preset, mode, accent]): - print( - "Hint: use --preset : or --image-path ", - file=sys.stderr, - ) - - HOME = str(os.getenv("HOME")) - OUTPUT = Path(HOME + "/.local/state/zshell/scheme.json") - SEQ_STATE = Path(HOME + "/.local/state/zshell/sequences.txt") - THUMB_DIR = Path(HOME + "/.cache/zshell/imagecache/thumbnails") - WALL_DIR_PATH = Path(HOME + "/.local/state/zshell/wallpaper_path.json") - - TEMPLATE_DIR = Path(HOME + "/.config/zshell/templates") - WALL_PATH = Path() - CONFIG = Path(HOME + "/.config/zshell/config.json") - - if preset is not None and image_path is not None: - raise typer.BadParameter( - "Use either --image-path or --preset, not both.") - - def get_scheme_class(scheme_name: str): - match scheme_name: - case "fruit-salad": - from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad - - return SchemeFruitSalad - case "expressive": - from materialyoucolor.scheme.scheme_expressive import SchemeExpressive - - return SchemeExpressive - case "monochrome": - from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome - - return SchemeMonochrome - case "rainbow": - from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow - - return SchemeRainbow - case "tonal-spot": - from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot - - return SchemeTonalSpot - case "neutral": - from materialyoucolor.scheme.scheme_neutral import SchemeNeutral - - return SchemeNeutral - case "fidelity": - from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity - - return SchemeFidelity - case "content": - from materialyoucolor.scheme.scheme_content import SchemeContent - - return SchemeContent - case "vibrant": - from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant - - return SchemeVibrant - case _: - from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad - - return SchemeFruitSalad - - def hex_to_hct(hex_color: str) -> Hct: - s = hex_color.strip() - if s.startswith("#"): - s = s[1:] - if len(s) != 6: - raise ValueError(f"Expected 6-digit hex color, got: {hex_color!r}") - return Hct.from_int(int("0xFF" + s, 16)) - - LIGHT_GRUVBOX = list( - map( - hex_to_hct, - [ - "FDF9F3", - "FF6188", - "A9DC76", - "FC9867", - "FFD866", - "F47FD4", - "78DCE8", - "333034", - "121212", - "FF6188", - "A9DC76", - "FC9867", - "FFD866", - "F47FD4", - "78DCE8", - "333034", - ], - ) - ) - - DARK_GRUVBOX = list( - map( - hex_to_hct, - [ - "282828", - "CC241D", - "98971A", - "D79921", - "458588", - "B16286", - "689D6A", - "A89984", - "928374", - "FB4934", - "B8BB26", - "FABD2F", - "83A598", - "D3869B", - "8EC07C", - "EBDBB2", - ], - ) - ) - - with WALL_DIR_PATH.open() as f: - path = json.load(f)["currentWallpaperPath"] - WALL_PATH = path - - def lighten(color: Hct, amount: float) -> Hct: - diff = (100 - color.tone) * amount - tone = max(0.0, min(100.0, color.tone + diff)) - chroma = max(0.0, color.chroma + diff / 5) - return Hct.from_hct(color.hue, chroma, tone) - - def darken(color: Hct, amount: float) -> Hct: - diff = color.tone * amount - tone = max(0.0, min(100.0, color.tone - diff)) - chroma = max(0.0, color.chroma - diff / 5) - return Hct.from_hct(color.hue, chroma, tone) - - def grayscale(color: Hct, light: bool) -> Hct: - color = darken(color, 0.35) if light else lighten(color, 0.65) - color.chroma = 0 - return color - - 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) - ) - 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]: - light = mode.lower() == "light" - - key_hex = ( - colors.get("primary_paletteKeyColor") - or colors.get("primaryPaletteKeyColor") - or colors.get("primary") - or int_to_hex(seed.to_int()) - ) - key_hct = hex_to_hct(key_hex) - - base = LIGHT_GRUVBOX if light else DARK_GRUVBOX - out: dict[str, str] = {} - - is_mono = variant.lower() == "monochrome" - - for i, base_hct in enumerate(base): - if is_mono: - h = grayscale(base_hct, light) - else: - tone_boost = (0.35 if i < 8 else 0.2) * (-1 if light else 1) - h = harmonize(base_hct, key_hct, tone_boost) - - out[f"term{i}"] = int_to_hex(h.to_int()) - - return out - - def thumbnail_cache_path(image_path: Path, thumb_dir: Path) -> Path: - stat = image_path.stat() - key = f"{image_path.stem}_{stat.st_size}_{int(stat.st_mtime)}" - safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key) - return thumb_dir / f"{safe_key}_thumbnail.jpg" - - def generate_thumbnail(image_path: Path, thumb_dir: Path, size=(128, 128)) -> Path: - thumb_dir.mkdir(parents=True, exist_ok=True) - cache_path = thumbnail_cache_path(image_path, thumb_dir) - - if cache_path.exists(): - return cache_path - - image = Image.open(image_path) - image.draft("RGB", size) - image = image.convert("RGB") - image.thumbnail(size, Image.Resampling.NEAREST) - image.save(cache_path, "JPEG") - - return cache_path - - def apply_terms(sequences: str, sequences_tmux: str, state_path: Path) -> None: - state_path.parent.mkdir(parents=True, exist_ok=True) - state_path.write_text(sequences, encoding="utf-8") - - pts_path = Path("/dev/pts") - if not pts_path.exists(): - return - - O_NOCTTY = getattr(os, "O_NOCTTY", 0) - - for pt in pts_path.iterdir(): - if not pt.name.isdigit(): - continue - try: - fd = os.open(str(pt), os.O_WRONLY | os.O_NONBLOCK | O_NOCTTY) - try: - os.write(fd, sequences_tmux.encode()) - os.write(fd, sequences.encode()) - finally: - os.close(fd) - except (PermissionError, OSError, BlockingIOError): - pass - - def smart_mode(image_path: Path) -> str: - is_dark = "" - - with Image.open(image_path) as img: - 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 - - def apply_gtk_mode(mode: str) -> None: - mode = mode.lower() - preference = "prefer-dark" if mode == "dark" else "prefer-light" - - try: - subprocess.run( - [ - "gsettings", - "set", - "org.gnome.desktop.interface", - "color-scheme", - preference, - ], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - except FileNotFoundError: - pass - - def apply_qt_mode(mode: str, home: str) -> None: - mode = mode.lower() - qt_conf = Path(home) / ".config/qt6ct/qt6ct.conf" - - if not qt_conf.exists(): - return - - try: - text = qt_conf.read_text(encoding="utf-8") - except OSError: - return - - target = "Dark.colors" if mode == "dark" else "Light.colors" - - new_text, count = re.subn( - r"^(color_scheme_path=.*?)(?:Light|Dark)\.colors\s*$", - rf"\1{target}", - text, - flags=re.MULTILINE, - ) - - if count > 0 and new_text != text: - try: - qt_conf.write_text(new_text, encoding="utf-8") - except OSError: - pass - - def build_template_context( - *, - colors: dict[str, str], - seed: Hct, - mode: str, - wallpaper_path: str, - name: str, - flavor: str, - variant: str, - ) -> dict[str, Any]: - ctx: dict[str, Any] = { - "mode": mode, - "wallpaper_path": wallpaper_path, - "source_color": int_to_hex(seed.to_int()), - "name": name, - "seed": seed.to_int(), - "flavor": flavor, - "variant": variant, - "colors": colors, - } - - for k, v in colors.items(): - ctx[k] = v - ctx[f"m3{k}"] = v - - term = terminal_palette(colors, mode, variant) - ctx.update(term) - ctx["term"] = [term[f"term{i}"] for i in range(16)] - - seq = make_sequences( - term=term, - foreground=ctx["m3onSurface"], - background=ctx["m3surface"], - ) - ctx["sequences"] = seq - ctx["sequences_tmux"] = tmux_wrap_sequences(seq) - - return ctx - - def make_sequences( - *, - term: dict[str, str], - foreground: str, - background: str, - ) -> str: - ESC = "\x1b" - ST = ESC + "\\" - - parts: list[str] = [] - - for i in range(16): - parts.append(f"{ESC}]4;{i};{term[f'term{i}']}{ST}") - - parts.append(f"{ESC}]10;{foreground}{ST}") - parts.append(f"{ESC}]11;{background}{ST}") - - return "".join(parts) - - def tmux_wrap_sequences(seq: str) -> str: - ESC = "\x1b" - return f"{ESC}Ptmux;{seq.replace(ESC, ESC + ESC)}{ESC}\\" - - def parse_output_directive(first_line: str) -> Optional[Path]: - s = first_line.strip() - if not s.startswith("#") or s.startswith("#!"): - return None - - target = s[1:].strip() - if not target: - return None - - expanded = os.path.expandvars(os.path.expanduser(target)) - return Path(expanded) - - def split_directive_and_body(text: str) -> Tuple[Optional[Path], str]: - lines = text.splitlines(keepends=True) - if not lines: - return None, "" - - out_path = parse_output_directive(lines[0]) - if out_path is None: - return None, text - - body = "".join(lines[1:]) - return out_path, body - - def render_all_templates( - templates_dir: Path, - context: dict[str, object], - *, - strict: bool = True, - ) -> list[Path]: - undefined_cls = StrictUndefined if strict else Undefined - env = Environment( - loader=FileSystemLoader(str(templates_dir)), - autoescape=False, - keep_trailing_newline=True, - undefined=undefined_cls, - ) - - rendered_outputs: list[Path] = [] - - for tpl_path in sorted(p for p in templates_dir.rglob("*") if p.is_file()): - rel = tpl_path.relative_to(templates_dir) - - if any(part.startswith(".") for part in rel.parts): - continue - - 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) - - try: - template = env.from_string(body) - text = template.render(**context) - except Exception as e: - raise RuntimeError( - f"Template render failed for '{rel}': {e}") from e - - out_path.write_text(text, encoding="utf-8") - - try: - shutil.copymode(tpl_path, out_path) - except OSError: - pass - - rendered_outputs.append(out_path) - - return rendered_outputs - - def seed_from_image(image_path: Path) -> Hct: - image = Image.open(image_path) - pixel_len = image.width * image.height - image_data = image.getdata() - - quality = 1 - pixel_array = [image_data[_] for _ in range(0, pixel_len, quality)] - - result = QuantizeCelebi(pixel_array, 128) - return Hct.from_int(Score.score(result)[0]) - - def generate_color_scheme(seed: Hct, mode: str, scheme_class) -> dict[str, str]: - - is_dark = mode.lower() == "dark" - - scheme = scheme_class(seed, is_dark, 0.0) - - color_dict = {} - for color in vars(MaterialDynamicColors).keys(): - color_name = getattr(MaterialDynamicColors, color) - if hasattr(color_name, "get_hct"): - color_int = color_name.get_hct(scheme).to_int() - color_dict[color] = int_to_hex(color_int) - - return color_dict - - def int_to_hex(argb_int): - return "#{:06X}".format(argb_int & 0xFFFFFF) - - try: - with CONFIG.open() as f: - config = json.load(f) - - 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), () - ) - 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}" - ) - - requested_mode = mode or config_mode - resolved_mode = requested_mode - if p_scheme in schemes: - meta = schemes[p_scheme] - variant = next( - (vari for vari in meta.variants if vari.id == p_variant), None - ) - if variant and requested_mode not in variant.modes and variant.modes: - resolved_mode = sorted(variant.modes)[0] - - palette_obj = get_palette( - p_scheme, p_variant, resolved_mode, accent=accent - ) - colors = palette_obj.colors - effective_mode = palette_obj.mode - name = palette_obj.scheme - flavor = palette_obj.variant - - display_name = schemes[p_scheme].name - config["colors"]["presets"] = { - "name": display_name, - "variant": p_variant, - "accent": accent or "", - } - tmp = CONFIG.with_suffix(".json.tmp") - with tmp.open("w") as f: - json.dump(config, f, indent=4) - os.replace(tmp, CONFIG) - - seed = hex_to_hct(colors.get("primary", "#000000").lstrip("#")) - else: - image_path = image_path or Path(WALL_PATH) - thumb_path = generate_thumbnail(image_path, THUMB_DIR) - seed = seed_from_image(thumb_path) - name = "dynamic" - flavor = "default" - - if smart: - effective_mode = smart_mode(thumb_path) - elif mode is not None: - effective_mode = mode - else: - effective_mode = config_mode - - colors = generate_color_scheme(seed, effective_mode, scheme_class) - - variant_val = scheme if not preset else p_variant - - if smart and not preset: - apply_gtk_mode(effective_mode) - apply_qt_mode(effective_mode, HOME) - - output_dict = { - "name": name, - "flavor": flavor, - "mode": effective_mode, - "variant": variant_val, - "colors": colors, - "seed": seed.to_int(), - } - - if TEMPLATE_DIR is not None: - wp = str(WALL_PATH) - ctx = build_template_context( - colors=colors, - seed=seed, - mode=effective_mode, - wallpaper_path=wp, - name=name, - flavor=flavor, - variant=variant_val, - ) - - rendered = render_all_templates( - templates_dir=TEMPLATE_DIR, - context=ctx, - ) - - apply_terms(ctx["sequences"], ctx["sequences_tmux"], SEQ_STATE) - - for p in rendered: - print(f"rendered: {p}") - - OUTPUT.parent.mkdir(parents=True, exist_ok=True) - tmp_output = OUTPUT.with_suffix(".json.tmp") - with open(tmp_output, "w") as f: - json.dump(output_dict, f, indent=4) - os.replace(tmp_output, OUTPUT) - except Exception as e: - print(f"Error: {e}") + if not any([image_path, scheme, preset, mode, accent]): + print( + "Hint: use --preset : or --image-path ", + file=sys.stderr, + ) + + HOME = str(os.getenv("HOME")) + OUTPUT = Path(HOME + "/.local/state/zshell/scheme.json") + SEQ_STATE = Path(HOME + "/.local/state/zshell/sequences.txt") + THUMB_DIR = Path(HOME + "/.cache/zshell/imagecache/thumbnails") + WALL_DIR_PATH = Path(HOME + "/.local/state/zshell/wallpaper_path.json") + + TEMPLATE_DIR = Path(HOME + "/.config/zshell/templates") + WALL_PATH = Path() + CONFIG = Path(HOME + "/.config/zshell/config.json") + + if preset is not None and image_path is not None: + raise typer.BadParameter( + "Use either --image-path or --preset, not both." + ) + + def get_scheme_class(scheme_name: str): + match scheme_name: + case "fruit-salad": + from materialyoucolor.scheme.scheme_fruit_salad import ( + SchemeFruitSalad, + ) + + return SchemeFruitSalad + case "expressive": + from materialyoucolor.scheme.scheme_expressive import ( + SchemeExpressive, + ) + + return SchemeExpressive + case "monochrome": + from materialyoucolor.scheme.scheme_monochrome import ( + SchemeMonochrome, + ) + + return SchemeMonochrome + case "rainbow": + from materialyoucolor.scheme.scheme_rainbow import SchemeRainbow + + return SchemeRainbow + case "tonal-spot": + from materialyoucolor.scheme.scheme_tonal_spot import ( + SchemeTonalSpot, + ) + + return SchemeTonalSpot + case "neutral": + from materialyoucolor.scheme.scheme_neutral import SchemeNeutral + + return SchemeNeutral + case "fidelity": + from materialyoucolor.scheme.scheme_fidelity import ( + SchemeFidelity, + ) + + return SchemeFidelity + case "content": + from materialyoucolor.scheme.scheme_content import SchemeContent + + return SchemeContent + case "vibrant": + from materialyoucolor.scheme.scheme_vibrant import SchemeVibrant + + return SchemeVibrant + case _: + from materialyoucolor.scheme.scheme_fruit_salad import ( + SchemeFruitSalad, + ) + + return SchemeFruitSalad + + def hex_to_hct(hex_color: str) -> Hct: + s = hex_color.strip() + if s.startswith("#"): + s = s[1:] + if len(s) != 6: + raise ValueError(f"Expected 6-digit hex color, got: {hex_color!r}") + return Hct.from_int(int("0xFF" + s, 16)) + + LIGHT_GRUVBOX = list( + map( + hex_to_hct, + [ + "FDF9F3", + "FF6188", + "A9DC76", + "FC9867", + "FFD866", + "F47FD4", + "78DCE8", + "333034", + "121212", + "FF6188", + "A9DC76", + "FC9867", + "FFD866", + "F47FD4", + "78DCE8", + "333034", + ], + ) + ) + + DARK_GRUVBOX = list( + map( + hex_to_hct, + [ + "282828", + "CC241D", + "98971A", + "D79921", + "458588", + "B16286", + "689D6A", + "A89984", + "928374", + "FB4934", + "B8BB26", + "FABD2F", + "83A598", + "D3869B", + "8EC07C", + "EBDBB2", + ], + ) + ) + + with WALL_DIR_PATH.open() as f: + path = json.load(f)["currentWallpaperPath"] + WALL_PATH = path + + def lighten(color: Hct, amount: float) -> Hct: + diff = (100 - color.tone) * amount + tone = max(0.0, min(100.0, color.tone + diff)) + chroma = max(0.0, color.chroma + diff / 5) + return Hct.from_hct(color.hue, chroma, tone) + + def darken(color: Hct, amount: float) -> Hct: + diff = color.tone * amount + tone = max(0.0, min(100.0, color.tone - diff)) + chroma = max(0.0, color.chroma - diff / 5) + return Hct.from_hct(color.hue, chroma, tone) + + def grayscale(color: Hct, light: bool) -> Hct: + color = darken(color, 0.35) if light else lighten(color, 0.65) + color.chroma = 0 + return color + + 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) + ) + 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]: + light = mode.lower() == "light" + + key_hex = ( + colors.get("primary_paletteKeyColor") + or colors.get("primaryPaletteKeyColor") + or colors.get("primary") + or int_to_hex(seed.to_int()) + ) + key_hct = hex_to_hct(key_hex) + + base = LIGHT_GRUVBOX if light else DARK_GRUVBOX + out: dict[str, str] = {} + + is_mono = variant.lower() == "monochrome" + + for i, base_hct in enumerate(base): + if is_mono: + h = grayscale(base_hct, light) + else: + tone_boost = (0.35 if i < 8 else 0.2) * (-1 if light else 1) + h = harmonize(base_hct, key_hct, tone_boost) + + out[f"term{i}"] = int_to_hex(h.to_int()) + + return out + + def thumbnail_cache_path(image_path: Path, thumb_dir: Path) -> Path: + stat = image_path.stat() + key = f"{image_path.stem}_{stat.st_size}_{int(stat.st_mtime)}" + safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key) + return thumb_dir / f"{safe_key}_thumbnail.jpg" + + def generate_thumbnail( + image_path: Path, thumb_dir: Path, size=(128, 128) + ) -> Path: + thumb_dir.mkdir(parents=True, exist_ok=True) + cache_path = thumbnail_cache_path(image_path, thumb_dir) + + if cache_path.exists(): + return cache_path + + image = Image.open(image_path) + image.draft("RGB", size) + image = image.convert("RGB") + image.thumbnail(size, Image.Resampling.NEAREST) + image.save(cache_path, "JPEG") + + return cache_path + + def apply_terms( + sequences: str, sequences_tmux: str, state_path: Path + ) -> None: + state_path.parent.mkdir(parents=True, exist_ok=True) + state_path.write_text(sequences, encoding="utf-8") + + pts_path = Path("/dev/pts") + if not pts_path.exists(): + return + + O_NOCTTY = getattr(os, "O_NOCTTY", 0) + + for pt in pts_path.iterdir(): + if not pt.name.isdigit(): + continue + try: + fd = os.open(str(pt), os.O_WRONLY | os.O_NONBLOCK | O_NOCTTY) + try: + os.write(fd, sequences_tmux.encode()) + os.write(fd, sequences.encode()) + finally: + os.close(fd) + except (PermissionError, OSError, BlockingIOError): + pass + + def smart_mode(image_path: Path) -> str: + is_dark = "" + + with Image.open(image_path) as img: + 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 + + def apply_gtk_mode(mode: str) -> None: + mode = mode.lower() + preference = "prefer-dark" if mode == "dark" else "prefer-light" + + with contextlib.suppress(FileNotFoundError): + subprocess.run( + [ + "gsettings", + "set", + "org.gnome.desktop.interface", + "color-scheme", + preference, + ], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + def apply_qt_mode(mode: str, home: str) -> None: + mode = mode.lower() + qt_conf = Path(home) / ".config/qt6ct/qt6ct.conf" + + if not qt_conf.exists(): + return + + try: + text = qt_conf.read_text(encoding="utf-8") + except OSError: + return + + target = "Dark.colors" if mode == "dark" else "Light.colors" + + new_text, count = re.subn( + r"^(color_scheme_path=.*?)(?:Light|Dark)\.colors\s*$", + rf"\1{target}", + text, + flags=re.MULTILINE, + ) + + if count > 0 and new_text != text: + with contextlib.suppress(OSError): + qt_conf.write_text(new_text, encoding="utf-8") + + def build_template_context( + *, + colors: dict[str, str], + seed: Hct, + mode: str, + wallpaper_path: str, + name: str, + flavor: str, + variant: str, + ) -> dict[str, Any]: + ctx: dict[str, Any] = { + "mode": mode, + "wallpaper_path": wallpaper_path, + "source_color": int_to_hex(seed.to_int()), + "name": name, + "seed": seed.to_int(), + "flavor": flavor, + "variant": variant, + "colors": colors, + } + + for k, v in colors.items(): + ctx[k] = v + ctx[f"m3{k}"] = v + + term = terminal_palette(colors, mode, variant) + ctx.update(term) + ctx["term"] = [term[f"term{i}"] for i in range(16)] + + seq = make_sequences( + term=term, + foreground=ctx["m3onSurface"], + background=ctx["m3surface"], + ) + ctx["sequences"] = seq + ctx["sequences_tmux"] = tmux_wrap_sequences(seq) + + return ctx + + def make_sequences( + *, + term: dict[str, str], + foreground: str, + background: str, + ) -> str: + ESC = "\x1b" + ST = ESC + "\\" + + parts: list[str] = [] + + for i in range(16): + parts.append(f"{ESC}]4;{i};{term[f'term{i}']}{ST}") + + parts.append(f"{ESC}]10;{foreground}{ST}") + parts.append(f"{ESC}]11;{background}{ST}") + + return "".join(parts) + + def tmux_wrap_sequences(seq: str) -> str: + ESC = "\x1b" + return f"{ESC}Ptmux;{seq.replace(ESC, ESC + ESC)}{ESC}\\" + + def parse_output_directive(first_line: str) -> Path | None: + s = first_line.strip() + if not s.startswith("#") or s.startswith("#!"): + return None + + target = s[1:].strip() + if not target: + return None + + expanded = os.path.expandvars(os.path.expanduser(target)) + return Path(expanded) + + def split_directive_and_body(text: str) -> tuple[Path | None, str]: + lines = text.splitlines(keepends=True) + if not lines: + return None, "" + + out_path = parse_output_directive(lines[0]) + if out_path is None: + return None, text + + body = "".join(lines[1:]) + return out_path, body + + def render_all_templates( + templates_dir: Path, + context: dict[str, object], + *, + strict: bool = True, + ) -> list[Path]: + undefined_cls = StrictUndefined if strict else Undefined + env = Environment( + loader=FileSystemLoader(str(templates_dir)), + autoescape=False, + keep_trailing_newline=True, + undefined=undefined_cls, + ) + + rendered_outputs: list[Path] = [] + + for tpl_path in sorted( + p for p in templates_dir.rglob("*") if p.is_file() + ): + rel = tpl_path.relative_to(templates_dir) + + if any(part.startswith(".") for part in rel.parts): + continue + + 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) + + try: + template = env.from_string(body) + text = template.render(**context) + except Exception as e: + raise RuntimeError( + f"Template render failed for '{rel}': {e}" + ) from e + + out_path.write_text(text, encoding="utf-8") + + with contextlib.suppress(OSError): + shutil.copymode(tpl_path, out_path) + + rendered_outputs.append(out_path) + + return rendered_outputs + + def seed_from_image(image_path: Path) -> Hct: + image = Image.open(image_path) + pixel_len = image.width * image.height + image_data = image.getdata() + + quality = 1 + pixel_array = [image_data[_] for _ in range(0, pixel_len, quality)] + + result = QuantizeCelebi(pixel_array, 128) + return Hct.from_int(Score.score(result)[0]) + + def generate_color_scheme( + seed: Hct, mode: str, scheme_class + ) -> dict[str, str]: + + is_dark = mode.lower() == "dark" + + scheme = scheme_class(seed, is_dark, 0.0) + + color_dict = {} + for color in vars(MaterialDynamicColors): + color_name = getattr(MaterialDynamicColors, color) + if hasattr(color_name, "get_hct"): + color_int = color_name.get_hct(scheme).to_int() + color_dict[color] = int_to_hex(color_int) + + return color_dict + + def int_to_hex(argb_int): + return f"#{argb_int & 0xFFFFFF:06X}" + + try: + with CONFIG.open() as f: + config = json.load(f) + + 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), () + ) + 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}" + ) + + requested_mode = mode or config_mode + resolved_mode = requested_mode + if p_scheme in schemes: + meta = schemes[p_scheme] + variant = next( + (vari for vari in meta.variants if vari.id == p_variant), + None, + ) + if ( + variant + and requested_mode not in variant.modes + and variant.modes + ): + resolved_mode = sorted(variant.modes)[0] + + palette_obj = get_palette( + p_scheme, p_variant, resolved_mode, accent=accent + ) + colors = palette_obj.colors + effective_mode = palette_obj.mode + name = palette_obj.scheme + flavor = palette_obj.variant + + display_name = schemes[p_scheme].name + config["colors"]["presets"] = { + "name": display_name, + "variant": p_variant, + "accent": accent or "", + } + tmp = CONFIG.with_suffix(".json.tmp") + with tmp.open("w") as f: + json.dump(config, f, indent=4) + os.replace(tmp, CONFIG) + + seed = hex_to_hct(colors.get("primary", "#000000").lstrip("#")) + else: + image_path = image_path or Path(WALL_PATH) + thumb_path = generate_thumbnail(image_path, THUMB_DIR) + seed = seed_from_image(thumb_path) + name = "dynamic" + flavor = "default" + + if smart: + effective_mode = smart_mode(thumb_path) + elif mode is not None: + effective_mode = mode + else: + effective_mode = config_mode + + colors = generate_color_scheme(seed, effective_mode, scheme_class) + + variant_val = scheme if not preset else p_variant + + if smart and not preset: + apply_gtk_mode(effective_mode) + apply_qt_mode(effective_mode, HOME) + + output_dict = { + "name": name, + "flavor": flavor, + "mode": effective_mode, + "variant": variant_val, + "colors": colors, + "seed": seed.to_int(), + } + + if TEMPLATE_DIR is not None: + wp = str(WALL_PATH) + ctx = build_template_context( + colors=colors, + seed=seed, + mode=effective_mode, + wallpaper_path=wp, + name=name, + flavor=flavor, + variant=variant_val, + ) + + rendered = render_all_templates( + templates_dir=TEMPLATE_DIR, + context=ctx, + ) + + apply_terms(ctx["sequences"], ctx["sequences_tmux"], SEQ_STATE) + + for p in rendered: + print(f"rendered: {p}") + + OUTPUT.parent.mkdir(parents=True, exist_ok=True) + tmp_output = OUTPUT.with_suffix(".json.tmp") + with open(tmp_output, "w") as f: + json.dump(output_dict, f, indent=4) + os.replace(tmp_output, OUTPUT) + except Exception as e: + print(f"Error: {e}") diff --git a/cli/src/zshell/subcommands/screenshot.py b/cli/src/zshell/subcommands/screenshot.py index 12acccf..f81b4da 100644 --- a/cli/src/zshell/subcommands/screenshot.py +++ b/cli/src/zshell/subcommands/screenshot.py @@ -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) diff --git a/cli/src/zshell/subcommands/shell.py b/cli/src/zshell/subcommands/shell.py index f0ce45f..27af89b 100644 --- a/cli/src/zshell/subcommands/shell.py +++ b/cli/src/zshell/subcommands/shell.py @@ -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()) diff --git a/cli/src/zshell/subcommands/wallpaper.py b/cli/src/zshell/subcommands/wallpaper.py index 58f6c85..9545267 100644 --- a/cli/src/zshell/subcommands/wallpaper.py +++ b/cli/src/zshell/subcommands/wallpaper.py @@ -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") diff --git a/cli/src/zshell/utils/schemepalettes.py b/cli/src/zshell/utils/schemepalettes.py index 09d5f28..1417405 100644 --- a/cli/src/zshell/utils/schemepalettes.py +++ b/cli/src/zshell/utils/schemepalettes.py @@ -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 :") + 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 :") diff --git a/cli/pyproject.toml b/pyproject.toml similarity index 50% rename from cli/pyproject.toml rename to pyproject.toml index 990ef54..d1aa9c9 100644 --- a/cli/pyproject.toml +++ b/pyproject.toml @@ -21,17 +21,39 @@ source = "vcs" [tool.hatch.build] include = [ - "src/zshell/assets/**", + "cli/src/zshell/assets/**", ] +[tool.hatch.build.targets.wheel] +packages = ["cli/src/zshell"] + [tool.hatch.build.targets.sdist] only-include = [ - "src", + "cli/src", ] [tool.ruff] -line-length = 120 +line-length = 80 + +[tool.ruff.format] +quote-style = "double" +indent-style = "tab" +line-ending = "lf" +docstring-code-format = true +docstring-code-line-length = "dynamic" + +[tool.ruff.lint] +ignore = ["E501", "B008"] +select = [ + "E", + "F", + "I", + "UP", + "B", + "SIM", + "RUF", +] [tool.pytest.ini_options] -testpaths = ["tests"] -pythonpath = ["src"] +testpaths = ["cli/tests"] +pythonpath = ["cli/src"] diff --git a/scripts/build-settings-index.py b/scripts/build-settings-index.py index a5315e1..b2e0df1 100644 --- a/scripts/build-settings-index.py +++ b/scripts/build-settings-index.py @@ -4,22 +4,24 @@ import json import re import sys from collections import defaultdict -from functools import lru_cache +from functools import cache from pathlib import Path -@lru_cache(maxsize=None) +@cache def read_lines(path: Path) -> tuple[str, ...]: - return tuple(path.read_text().splitlines()) + return tuple(path.read_text().splitlines()) ROW_RE = re.compile( - r'^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|DefaultRow)\s*\{') + r"^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|DefaultRow)\s*\{" +) LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)') ANCHOR_RE = re.compile(r'^\s*settingAnchor:\s*"([^"]+)"') -CHECKED_RE = re.compile(r'^\s*checked:\s*(?:Config)\.([\w.]+)\s*$') +CHECKED_RE = re.compile(r"^\s*checked:\s*(?:Config)\.([\w.]+)\s*$") ONTOGGLED_RE = re.compile( - r'^\s*onToggled:\s*(?:Config)\.([\w.]+)\s*=\s*checked\s*$') + r"^\s*onToggled:\s*(?:Config)\.([\w.]+)\s*=\s*checked\s*$" +) ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"') SKIP_LABELS = {"Muted", "None"} FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4} @@ -27,14 +29,14 @@ STOPWORDS = {"the", "a", "an", "of", "and", "or", "to", "on", "in", "for"} def find_pages_dir(settings: Path) -> Path: - return settings / "Pages" + return settings / "Pages" def discover_files(settings: Path) -> dict[str, Path]: - files: dict[str, Path] = {} - for p in find_pages_dir(settings).rglob("*.qml"): - files[p.stem] = p - return files + files: dict[str, Path] = {} + for p in find_pages_dir(settings).rglob("*.qml"): + files[p.stem] = p + return files PAGE_NAME_RE = re.compile(r'^\s*name:\s*qsTr\("([^"]+)"\)') @@ -42,366 +44,409 @@ PAGE_ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"') def parse_page_registry(settings: Path) -> list[tuple[str, str]]: - text = (settings / "PageRegistry.qml").read_text().splitlines() + text = (settings / "PageRegistry.qml").read_text().splitlines() - start = next( - i for i, line in enumerate(text) - if re.search(r'\bpages\s*:\s*\[', line) - ) + start = next( + i for i, line in enumerate(text) if re.search(r"\bpages\s*:\s*\[", line) + ) - out: list[tuple[str, str]] = [] - i = start + 1 + out: list[tuple[str, str]] = [] + i = start + 1 - while i < len(text): - line = text[i].strip() + while i < len(text): + line = text[i].strip() - if line.startswith("]"): - break + if line.startswith("]"): + break - if line.startswith("//") or not line: - i += 1 - continue + if line.startswith("//") or not line: + i += 1 + continue - if line.startswith("{"): - name = None - icon = None - i += 1 + if line.startswith("{"): + name = None + icon = None + i += 1 - while i < len(text): - s = text[i].strip() + while i < len(text): + s = text[i].strip() - if s.startswith("}"): - if name is not None: - out.append((icon or "tune", name)) - break + if s.startswith("}"): + if name is not None: + out.append((icon or "tune", name)) + break - if name is None: - m = PAGE_NAME_RE.match(text[i]) - if m: - name = m.group(1) + if name is None: + m = PAGE_NAME_RE.match(text[i]) + if m: + name = m.group(1) - if icon is None: - mi = PAGE_ICON_RE.match(text[i]) - if mi: - icon = mi.group(1) + if icon is None: + mi = PAGE_ICON_RE.match(text[i]) + if mi: + icon = mi.group(1) - i += 1 + i += 1 - i += 1 + i += 1 - return out + return out -BLOCK_RE = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$') +BLOCK_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$") def _strip_comment(line: str) -> str: - return line.split("//", 1)[0].rstrip() + return line.split("//", 1)[0].rstrip() -def parse_block(lines: list[str], i: int) -> tuple[str, list[tuple[str, list]], int]: - line = _strip_comment(lines[i]).strip() - m = BLOCK_RE.match(line) - if not m: - raise ValueError(f"Expected block start at line {i + 1}: {lines[i]!r}") +def parse_block( + lines: list[str], i: int +) -> tuple[str, list[tuple[str, list]], int]: + line = _strip_comment(lines[i]).strip() + m = BLOCK_RE.match(line) + if not m: + raise ValueError(f"Expected block start at line {i + 1}: {lines[i]!r}") - name = m.group(1) - i += 1 - children: list[tuple[str, list]] = [] + name = m.group(1) + i += 1 + children: list[tuple[str, list]] = [] - while i < len(lines): - s = _strip_comment(lines[i]).strip() - if not s: - i += 1 - continue + while i < len(lines): + s = _strip_comment(lines[i]).strip() + if not s: + i += 1 + continue - if s.startswith("}"): - return name, children, i + 1 + if s.startswith("}"): + return name, children, i + 1 - if BLOCK_RE.match(s): - child_name, child_children, i = parse_block(lines, i) - children.append((child_name, child_children)) - continue + if BLOCK_RE.match(s): + child_name, child_children, i = parse_block(lines, i) + children.append((child_name, child_children)) + continue - i += 1 + i += 1 - raise ValueError(f"Unterminated block: {name}") + raise ValueError(f"Unterminated block: {name}") def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]: - name, children = block + name, children = block - if name != "Component": - return [name] + if name != "Component": + return [name] - for child_name, child_children in children: - if child_name == "StackPage": - out: list[str] = [] - for grand_name, grand_children in child_children: - if grand_name == "Component": - out.extend(collect_page_names((grand_name, grand_children))) - return out + for child_name, child_children in children: + if child_name == "StackPage": + out: list[str] = [] + for grand_name, grand_children in child_children: + if grand_name == "Component": + out.extend(collect_page_names((grand_name, grand_children))) + return out - if child_name != "Component": - return [child_name] + if child_name != "Component": + return [child_name] - return [] + return [] def parse_page_comps(settings: Path) -> list[list[str]]: - text = (settings / "PageCompRegistry.qml").read_text().splitlines() + text = (settings / "PageCompRegistry.qml").read_text().splitlines() - start = next( - i for i, line in enumerate(text) - if re.search(r'\bpageComps\s*:\s*\[', _strip_comment(line)) - ) + start = next( + i + for i, line in enumerate(text) + if re.search(r"\bpageComps\s*:\s*\[", _strip_comment(line)) + ) - comps: list[list[str]] = [] - i = start + 1 + comps: list[list[str]] = [] + i = start + 1 - while i < len(text): - s = _strip_comment(text[i]).strip() - if not s: - i += 1 - continue - if s.startswith("]"): - break + while i < len(text): + s = _strip_comment(text[i]).strip() + if not s: + i += 1 + continue + if s.startswith("]"): + break - if BLOCK_RE.match(s) and BLOCK_RE.match(s).group(1) == "Component": - block = parse_block(text, i) - names = collect_page_names((block[0], block[1])) - if names: - comps.append(names) - i = block[2] - continue + if BLOCK_RE.match(s) and BLOCK_RE.match(s).group(1) == "Component": + block = parse_block(text, i) + names = collect_page_names((block[0], block[1])) + if names: + comps.append(names) + i = block[2] + continue - i += 1 + i += 1 - return comps + return comps -def dedup_crumbs(labels: list[str], icons: list[str]) -> tuple[list[str], list[str]]: - out_labels: list[str] = [] - out_icons: list[str] = [] - for lbl, ico in zip(labels, icons): - if out_labels and out_labels[-1] == lbl: - continue - out_labels.append(lbl) - out_icons.append(ico) - return out_labels, out_icons +def dedup_crumbs( + labels: list[str], icons: list[str] +) -> tuple[list[str], list[str]]: + out_labels: list[str] = [] + out_icons: list[str] = [] + for lbl, ico in zip(labels, icons, strict=False): + if out_labels and out_labels[-1] == lbl: + continue + out_labels.append(lbl) + out_icons.append(ico) + return out_labels, out_icons def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]: - comps = parse_page_comps(settings) - registry = parse_page_registry(settings) + comps = parse_page_comps(settings) + registry = parse_page_registry(settings) - top_meta: dict[int, tuple[str, str]] = {} - for i, (icon, label) in enumerate(registry): - top_meta[i] = (icon, label) + top_meta: dict[int, tuple[str, str]] = {} + for i, (icon, label) in enumerate(registry): + top_meta[i] = (icon, label) - nav_children: dict[str, dict[int, tuple[str, str, str]]] = {} - for names in comps: - for name in names: - pf = files.get(name) - if not pf: - continue - pending_icon = pending_label = None - section = "" - expect_section = False - for ln in read_lines(pf): - if SECTION_RE.match(ln): - expect_section = True - continue - ml = LABEL_RE.match(ln) - if ml: - if expect_section: - section = ml.group(1) - expect_section = False - else: - pending_label = ml.group(1) - continue - mi = ICON_RE.match(ln) - if mi: - pending_icon = mi.group(1) - mo = re.search(r"openSubPage\((\d+)\)", ln) - if mo: - pos = int(mo.group(1)) - nav_children.setdefault(name, {})[pos] = ( - pending_icon or "tune", pending_label or "", section) - pending_icon = pending_label = None + nav_children: dict[str, dict[int, tuple[str, str, str]]] = {} + for names in comps: + for name in names: + pf = files.get(name) + if not pf: + continue + pending_icon = pending_label = None + section = "" + expect_section = False + for ln in read_lines(pf): + if SECTION_RE.match(ln): + expect_section = True + continue + ml = LABEL_RE.match(ln) + if ml: + if expect_section: + section = ml.group(1) + expect_section = False + else: + pending_label = ml.group(1) + continue + mi = ICON_RE.match(ln) + if mi: + pending_icon = mi.group(1) + mo = re.search(r"openSubPage\((\d+)\)", ln) + if mo: + pos = int(mo.group(1)) + nav_children.setdefault(name, {})[pos] = ( + pending_icon or "tune", + pending_label or "", + section, + ) + pending_icon = pending_label = None - nav: dict[str, dict] = {} - for top_idx, names in enumerate(comps): - if not names: - continue - main = names[0] - main_icon, main_label = top_meta.get(top_idx, ("tune", main)) - nav[main] = {"pageIdx": top_idx, "subPath": [], - "crumbIcons": [main_icon], "crumbLabels": [main_label]} - children = dict(nav_children.get(main, {})) - opened_via_subpage = set() - for owner, kids in nav_children.items(): - owner_group = next((ns for ns in comps if owner in ns), None) - if not owner_group: - continue - for kpos in kids: - if kpos < len(owner_group): - opened_via_subpage.add(owner_group[kpos]) - for pos in range(1, len(names)): - if pos not in children and names[pos] not in opened_via_subpage: - label = re.sub(r"(Detail)?Page$", "", names[pos]) - label = re.sub(r"(?= len(names): - continue - child = names[pos] - labels = [main_label] + ([section] if section else []) + [label] - icons = [main_icon] + ([icon] if section else []) + [icon] - labels, icons = dedup_crumbs(labels, icons) - nav[child] = {"pageIdx": top_idx, "subPath": [pos], - "crumbIcons": icons, - "crumbLabels": labels} - for gpos, (gicon, glabel, gsection) in nav_children.get(child, {}).items(): - if gpos >= len(names): - continue - glabels = labels + ([gsection] if gsection else []) + [glabel] - gicons = icons + ([gicon] if gsection else []) + [gicon] - glabels, gicons = dedup_crumbs(glabels, gicons) - nav[names[gpos]] = { - "pageIdx": top_idx, "subPath": [pos, gpos], - "crumbIcons": gicons, - "crumbLabels": glabels} - return nav + nav: dict[str, dict] = {} + for top_idx, names in enumerate(comps): + if not names: + continue + main = names[0] + main_icon, main_label = top_meta.get(top_idx, ("tune", main)) + nav[main] = { + "pageIdx": top_idx, + "subPath": [], + "crumbIcons": [main_icon], + "crumbLabels": [main_label], + } + children = dict(nav_children.get(main, {})) + opened_via_subpage = set() + for owner, kids in nav_children.items(): + owner_group = next((ns for ns in comps if owner in ns), None) + if not owner_group: + continue + for kpos in kids: + if kpos < len(owner_group): + opened_via_subpage.add(owner_group[kpos]) + for pos in range(1, len(names)): + if pos not in children and names[pos] not in opened_via_subpage: + label = re.sub(r"(Detail)?Page$", "", names[pos]) + label = re.sub(r"(?= len(names): + continue + child = names[pos] + labels = [main_label] + ([section] if section else []) + [label] + icons = [main_icon] + ([icon] if section else []) + [icon] + labels, icons = dedup_crumbs(labels, icons) + nav[child] = { + "pageIdx": top_idx, + "subPath": [pos], + "crumbIcons": icons, + "crumbLabels": labels, + } + for gpos, (gicon, glabel, gsection) in nav_children.get( + child, {} + ).items(): + if gpos >= len(names): + continue + glabels = labels + ([gsection] if gsection else []) + [glabel] + gicons = icons + ([gicon] if gsection else []) + [gicon] + glabels, gicons = dedup_crumbs(glabels, gicons) + nav[names[gpos]] = { + "pageIdx": top_idx, + "subPath": [pos, gpos], + "crumbIcons": gicons, + "crumbLabels": glabels, + } + return nav def tokenize(text: str) -> list[str]: - toks: list[str] = [] - for word in text.lower().split(): - parts = [p for p in re.split(r"[^a-z0-9]+", word) if p] - for p in parts: - if p not in STOPWORDS and p not in toks: - toks.append(p) - if len(parts) > 1: - joined = "".join(parts) - if joined not in toks: - toks.append(joined) - return toks + toks: list[str] = [] + for word in text.lower().split(): + parts = [p for p in re.split(r"[^a-z0-9]+", word) if p] + for p in parts: + if p not in STOPWORDS and p not in toks: + toks.append(p) + if len(parts) > 1: + joined = "".join(parts) + if joined not in toks: + toks.append(joined) + return toks SUBTEXT_RE = re.compile(r'^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)') -SECTION_RE = re.compile(r'^\s*SectionHeader\s*\{') +SECTION_RE = re.compile(r"^\s*SectionHeader\s*\{") -def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict]: - entries: list[dict] = [] - for comp, meta in nav.items(): - pf = files.get(comp) - if not pf: - continue - lines = read_lines(pf) - section = "" - i = 0 - while i < len(lines): - if SECTION_RE.match(lines[i]): - for j in range(i + 1, min(i + 4, len(lines))): - m = LABEL_RE.match(lines[j]) - if m: - section = m.group(1) - break - row_match = ROW_RE.match(lines[i]) - if row_match: - row_type = row_match.group(1) - label = anchor = subtext = None - checked_path = toggled_path = None - for j in range(i + 1, min(i + 12, len(lines))): - if label is None: - m = LABEL_RE.match(lines[j]) - if m: - label = m.group(1) - if anchor is None: - a = ANCHOR_RE.match(lines[j]) - if a: - anchor = a.group(1) - if subtext is None: - st = SUBTEXT_RE.match(lines[j]) - if st: - subtext = st.group(1) - if checked_path is None: - ch = CHECKED_RE.match(lines[j]) - if ch: - checked_path = ch.group(1) - if toggled_path is None: - tg = ONTOGGLED_RE.match(lines[j]) - if tg: - toggled_path = tg.group(1) - toggle_path = ( - checked_path - if row_type == "ToggleRow" and checked_path and checked_path == toggled_path - else "" - ) - if label and label not in SKIP_LABELS and anchor: - extra = " ".join(meta["crumbLabels"]) + \ - " " + section + " " + (subtext or "") - entries.append({ - "pageIdx": meta["pageIdx"], "subPath": meta["subPath"], - "crumbIcons": meta["crumbIcons"], - "crumbLabels": meta["crumbLabels"], - "title": label, "anchor": anchor, - "section": section, - "subtext": subtext or "", - "togglePath": toggle_path, - "keywords": " ".join(sorted(set(tokenize(label + " " + extra)))), - }) - i += 1 - return entries +def extract_settings( + files: dict[str, Path], nav: dict[str, dict] +) -> list[dict]: + entries: list[dict] = [] + for comp, meta in nav.items(): + pf = files.get(comp) + if not pf: + continue + lines = read_lines(pf) + section = "" + i = 0 + while i < len(lines): + if SECTION_RE.match(lines[i]): + for j in range(i + 1, min(i + 4, len(lines))): + m = LABEL_RE.match(lines[j]) + if m: + section = m.group(1) + break + row_match = ROW_RE.match(lines[i]) + if row_match: + row_type = row_match.group(1) + label = anchor = subtext = None + checked_path = toggled_path = None + for j in range(i + 1, min(i + 12, len(lines))): + if label is None: + m = LABEL_RE.match(lines[j]) + if m: + label = m.group(1) + if anchor is None: + a = ANCHOR_RE.match(lines[j]) + if a: + anchor = a.group(1) + if subtext is None: + st = SUBTEXT_RE.match(lines[j]) + if st: + subtext = st.group(1) + if checked_path is None: + ch = CHECKED_RE.match(lines[j]) + if ch: + checked_path = ch.group(1) + if toggled_path is None: + tg = ONTOGGLED_RE.match(lines[j]) + if tg: + toggled_path = tg.group(1) + toggle_path = ( + checked_path + if row_type == "ToggleRow" + and checked_path + and checked_path == toggled_path + else "" + ) + if label and label not in SKIP_LABELS and anchor: + extra = ( + " ".join(meta["crumbLabels"]) + + " " + + section + + " " + + (subtext or "") + ) + entries.append( + { + "pageIdx": meta["pageIdx"], + "subPath": meta["subPath"], + "crumbIcons": meta["crumbIcons"], + "crumbLabels": meta["crumbLabels"], + "title": label, + "anchor": anchor, + "section": section, + "subtext": subtext or "", + "togglePath": toggle_path, + "keywords": " ".join( + sorted(set(tokenize(label + " " + extra))) + ), + } + ) + i += 1 + return entries def build_inverted_and_ranking(entries: list[dict]): - inverted: dict[str, list[int]] = defaultdict(list) - ranking: dict[str, dict[int, float]] = defaultdict(dict) - for idx, e in enumerate(entries): - fields = {"title": e["title"], "keywords": e["keywords"]} - seen: set[str] = set() - for field, text in fields.items(): - weight = FIELD_WEIGHT.get(field, 0.2) - for tok in tokenize(text): - if idx not in inverted[tok]: - inverted[tok].append(idx) - ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight) - seen.add(tok) - for tok, ids in inverted.items(): - ids.sort(key=lambda i: ranking[tok][i], reverse=True) - return inverted, {t: {str(k): v for k, v in d.items()} for t, d in ranking.items()} + inverted: dict[str, list[int]] = defaultdict(list) + ranking: dict[str, dict[int, float]] = defaultdict(dict) + for idx, e in enumerate(entries): + fields = {"title": e["title"], "keywords": e["keywords"]} + seen: set[str] = set() + for field, text in fields.items(): + weight = FIELD_WEIGHT.get(field, 0.2) + for tok in tokenize(text): + if idx not in inverted[tok]: + inverted[tok].append(idx) + ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight) + seen.add(tok) + for tok, ids in inverted.items(): + ids.sort(key=lambda i: ranking[tok][i], reverse=True) + return inverted, { + t: {str(k): v for k, v in d.items()} for t, d in ranking.items() + } def main() -> int: - if len(sys.argv) != 3: - print(__doc__) - return 1 - settings = Path(sys.argv[1]) - out = Path(sys.argv[2]) - files = discover_files(settings) - nav = build_nav_map(settings, files) - entries = extract_settings(files, nav) - inverted, ranking = build_inverted_and_ranking(entries) - for e in entries: - e.pop("keywords", None) - out.write_text(json.dumps({ - "version": 2, - "entries": entries, - "inverted": inverted, - "ranking": ranking, - }, ensure_ascii=False, indent=2)) - print(f"settings index: {len(entries)} entries, " - f"{len(inverted)} tokens -> {out}") - print("files:", len(files)) - print("comps:", len(parse_page_comps(settings))) - print("registry:", len(parse_page_registry(settings))) - print("nav:", len(nav)) - print("entries:", len(entries)) - return 0 + if len(sys.argv) != 3: + print(__doc__) + return 1 + settings = Path(sys.argv[1]) + out = Path(sys.argv[2]) + files = discover_files(settings) + nav = build_nav_map(settings, files) + entries = extract_settings(files, nav) + inverted, ranking = build_inverted_and_ranking(entries) + for e in entries: + e.pop("keywords", None) + out.write_text( + json.dumps( + { + "version": 2, + "entries": entries, + "inverted": inverted, + "ranking": ranking, + }, + ensure_ascii=False, + indent=2, + ) + ) + print( + f"settings index: {len(entries)} entries, " + f"{len(inverted)} tokens -> {out}" + ) + print("files:", len(files)) + print("comps:", len(parse_page_comps(settings))) + print("registry:", len(parse_page_registry(settings))) + print("nav:", len(nav)) + print("entries:", len(entries)) + return 0 if __name__ == "__main__": - sys.exit(main()) + sys.exit(main())