diff --git a/.gitea/workflows/ci-image.yml b/.gitea/workflows/ci-image.yml index a2d5a7c..3ee05f3 100644 --- a/.gitea/workflows/ci-image.yml +++ b/.gitea/workflows/ci-image.yml @@ -2,7 +2,7 @@ name: Rebuild CI Image on: schedule: - - cron: '0 6 * * 1' + - cron: "0 6 * * 1" workflow_dispatch: jobs: @@ -11,7 +11,7 @@ jobs: container: image: node:26-alpine env: - IMAGE: git.aramjonghu.nl/aramjonghu/zshell-ci:latest + IMAGE: git.aramjonghu.dev/aramjonghu/zshell-ci:latest steps: - name: Checkout @@ -21,7 +21,7 @@ jobs: run: apk add --no-cache docker-cli - name: Login to registry - run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.aramjonghu.nl --username aramjonghu --password-stdin + run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.aramjonghu.dev --username aramjonghu --password-stdin - name: Build image run: docker build -t "$IMAGE" -f ci/Dockerfile . diff --git a/.gitea/workflows/cpp.yml b/.gitea/workflows/cpp.yml index 32c6a38..90eb487 100644 --- a/.gitea/workflows/cpp.yml +++ b/.gitea/workflows/cpp.yml @@ -4,10 +4,43 @@ on: pull_request: jobs: + fmt: + runs-on: alpine + container: + image: git.aramjonghu.dev/aramjonghu/zshell-ci:latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Format check + run: | + find . \( -name '*.cpp' -o -name '*.h' -o -name '*.hpp' \) \ + -not -path './build/*' \ + -exec clang-format -i --style=file {} + + git diff --exit-code && echo "clang-format: passed" + build: runs-on: alpine container: - image: git.aramjonghu.nl/aramjonghu/zshell-ci:latest + image: git.aramjonghu.dev/aramjonghu/zshell-ci:latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Configure + run: cmake -B build -G Ninja -DENABLE_MODULES=plugin -DCMAKE_BUILD_TYPE=Release + + - name: Build + run: ninja -C build + + clang-tidy: + runs-on: alpine + container: + image: git.aramjonghu.dev/aramjonghu/zshell-ci:latest steps: - name: Checkout diff --git a/.gitea/workflows/lint-js.yml b/.gitea/workflows/js-ts.yml similarity index 84% rename from .gitea/workflows/lint-js.yml rename to .gitea/workflows/js-ts.yml index f7c9829..639adaf 100644 --- a/.gitea/workflows/lint-js.yml +++ b/.gitea/workflows/js-ts.yml @@ -1,10 +1,10 @@ -name: Lint & Format (JS/TS) +name: JS/TS on: pull_request: jobs: - lint-format: + fmt: runs-on: alpine container: node:26-alpine @@ -18,7 +18,6 @@ jobs: git - name: Prettier - continue-on-error: true run: | if [ -n "$(find . \( -iname "*.js" -o -iname "*.jsx" -o -iname "*.ts" -o -iname "*.tsx" -o -iname "*.mjs" -o -iname "*.cjs" \) -print -quit)" ]; then npx --yes prettier --check "**/*.{js,jsx,ts,tsx,mjs,cjs}" --ignore-path .prettierignore @@ -26,6 +25,19 @@ jobs: echo "No JS/TS files found" fi + lint: + runs-on: alpine + container: node:26-alpine + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install tools + run: | + apk add --no-cache \ + git + - name: ESLint run: | if [ -n "$(find . \( -iname "*.js" -o -iname "*.jsx" -o -iname "*.ts" -o -iname "*.tsx" -o -iname "*.mjs" -o -iname "*.cjs" \) -print -quit)" ]; then diff --git a/.gitea/workflows/lint-rust.yml b/.gitea/workflows/lint-rust.yml deleted file mode 100644 index 49250d3..0000000 --- a/.gitea/workflows/lint-rust.yml +++ /dev/null @@ -1,85 +0,0 @@ -name: Lint & Format (Rust) - -on: - pull_request: - -jobs: - lint-format: - runs-on: alpine - container: node:26-alpine - env: - CARGO_HOME: ${{ github.workspace }}/.cargo - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Cache cargo packages - uses: actions/cache@v4 - env: - cache-name: cache-cargo-packages - with: - path: | - .cargo/registry - .cargo/git - target - key: rust-${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/Cargo.lock') }} - restore-keys: | - rust-${{ runner.os }}-build-${{ env.cache-name }}- - rust-${{ runner.os }}-build- - rust- - - - name: Install tools - run: | - apk add --no-cache \ - git \ - cargo \ - rust \ - rustfmt \ - rust-clippy - - - id: format-check - name: Format check - continue-on-error: true - run: | - if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then - status=0 - for manifest in $(find . -name "Cargo.toml"); do - cargo fmt --manifest-path "$manifest" --check && \ - echo "$manifest: formatting OK" || \ - { echo "$manifest: needs formatting"; status=1; } - done - exit $status - elif [ -n "$(find . -name "*.rs" -print -quit)" ]; then - echo "Rust files found but no Cargo.toml" - exit 1 - else - echo "No Rust project found" - fi - - - id: clippy - name: Clippy - run: | - if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then - status=0 - for manifest in $(find . -name "Cargo.toml"); do - cargo clippy --manifest-path "$manifest" --all-targets --all-features -- -D warnings && \ - echo "$manifest: Clippy passed" || \ - { echo "$manifest: Clippy failed"; status=1; } - done - exit $status - elif [ -n "$(find . -name "*.rs" -print -quit)" ]; then - echo "Rust files found but no Cargo.toml" - exit 1 - else - echo "No Rust project found" - fi - - - name: Check results - if: always() - run: | - if [ "${{ steps.format-check.outcome }}" = "failure" ] || [ "${{ steps.clippy.outcome }}" = "failure" ]; then - echo "One or more checks failed" - exit 1 - fi - echo "All checks passed" diff --git a/.gitea/workflows/lint-python.yml b/.gitea/workflows/python.yml similarity index 56% rename from .gitea/workflows/lint-python.yml rename to .gitea/workflows/python.yml index 123ebec..270c3b4 100644 --- a/.gitea/workflows/lint-python.yml +++ b/.gitea/workflows/python.yml @@ -4,7 +4,7 @@ on: pull_request: jobs: - lint-format: + fmt: runs-on: alpine container: node:26-alpine @@ -23,11 +23,28 @@ jobs: pip install --no-cache-dir ruff - name: Format check - continue-on-error: true run: | . .venv/bin/activate ruff format --check . + lint: + runs-on: alpine + container: node:26-alpine + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install tools + run: | + apk add --no-cache \ + git \ + python3 \ + py3-pip + python3 -m venv .venv + . .venv/bin/activate + pip install --no-cache-dir ruff + - name: Lint run: | . .venv/bin/activate @@ -63,3 +80,30 @@ jobs: . .venv/bin/activate cd cli python -m pytest tests/ -v + + buildcheck: + runs-on: alpine + container: node:26-alpine + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install tools + run: | + apk add --no-cache \ + git \ + python3 \ + py3-pip \ + build-base \ + python3-dev \ + gcc \ + g++ + python3 -m venv .venv + . .venv/bin/activate + pip install --no-cache-dir nuitka + + - name: Nuitka module check + run: | + . .venv/bin/activate + nuitka --module --include-package=zshell cli/src/zshell/ diff --git a/.gitea/workflows/rust.yml b/.gitea/workflows/rust.yml new file mode 100644 index 0000000..da2d4bf --- /dev/null +++ b/.gitea/workflows/rust.yml @@ -0,0 +1,152 @@ +name: Rust + +on: + pull_request: + +jobs: + build: + runs-on: alpine + container: node:26-alpine + env: + CARGO_HOME: ${{ github.workspace }}/.cargo + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache cargo packages + uses: actions/cache@v4 + env: + cache-name: cache-cargo-packages + with: + path: | + .cargo/registry + .cargo/git + target + key: rust-build-${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + rust-${{ runner.os }}-build-${{ env.cache-name }}- + rust-${{ runner.os }}-build- + rust- + + - name: Install tools + run: | + apk add --no-cache \ + git \ + cargo \ + rust + + - name: Cargo check + run: | + if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then + for manifest in $(find . -name "Cargo.toml"); do + cargo check --manifest-path "$manifest" && \ + echo "$manifest: check passed" || \ + { echo "$manifest: check failed"; exit 1; } + done + elif [ -n "$(find . -name "*.rs" -print -quit)" ]; then + echo "Rust files found but no Cargo.toml" + exit 1 + else + echo "No Rust project found" + fi + + fmt: + runs-on: alpine + container: node:26-alpine + env: + CARGO_HOME: ${{ github.workspace }}/.cargo + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache cargo packages + uses: actions/cache@v4 + env: + cache-name: cache-cargo-packages + with: + path: | + .cargo/registry + .cargo/git + target + key: rust-fmt-${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + rust-${{ runner.os }}-build-${{ env.cache-name }}- + rust-${{ runner.os }}-build- + rust- + + - name: Install tools + run: | + apk add --no-cache \ + git \ + cargo \ + rust \ + rustfmt + + - name: Format check + run: | + if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then + status=0 + for manifest in $(find . -name "Cargo.toml"); do + cargo fmt --manifest-path "$manifest" --check && \ + echo "$manifest: formatting OK" || \ + { echo "$manifest: needs formatting"; status=1; } + done + exit $status + elif [ -n "$(find . -name "*.rs" -print -quit)" ]; then + echo "Rust files found but no Cargo.toml" + exit 1 + else + echo "No Rust project found" + fi + + clippy: + runs-on: alpine + container: node:26-alpine + env: + CARGO_HOME: ${{ github.workspace }}/.cargo + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache cargo packages + uses: actions/cache@v4 + env: + cache-name: cache-cargo-packages + with: + path: | + .cargo/registry + .cargo/git + target + key: rust-clippy-${{ runner.os }}-${{ env.cache-name }}-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + rust-${{ runner.os }}-build-${{ env.cache-name }}- + rust-${{ runner.os }}-build- + rust- + + - name: Install tools + run: | + apk add --no-cache \ + git \ + cargo \ + rust \ + rust-clippy + + - name: Clippy + run: | + if [ -n "$(find . -name "Cargo.toml" -print -quit)" ]; then + status=0 + for manifest in $(find . -name "Cargo.toml"); do + cargo clippy --manifest-path "$manifest" --all-targets --all-features -- -D warnings && \ + echo "$manifest: Clippy passed" || \ + { echo "$manifest: Clippy failed"; status=1; } + done + exit $status + elif [ -n "$(find . -name "*.rs" -print -quit)" ]; then + echo "Rust files found but no Cargo.toml" + exit 1 + else + echo "No Rust project found" + fi 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/tests/test_schemepalettes.py b/cli/tests/test_schemepalettes.py index ac4fa67..7f61c30 100644 --- a/cli/tests/test_schemepalettes.py +++ b/cli/tests/test_schemepalettes.py @@ -1,167 +1,195 @@ from __future__ import annotations -import pytest from pathlib import Path + +import pytest from zshell.utils import schemepalettes as sp @pytest.fixture def tmp_schemes(tmp_path: Path) -> Path: - schemes = tmp_path / "schemes" - schemes.mkdir() + schemes = tmp_path / "schemes" + schemes.mkdir() - gmedium = schemes / "gruvbox" / "medium" - gmedium.mkdir(parents=True) - (gmedium / "dark.txt").write_text("background 101415\nonBackground e0e3e4\nprimary 81d3e0\nsurface 1c2021\n") - (gmedium / "light.txt").write_text("background fbf1c7\nonBackground 3c3836\nprimary 6b5f10\nsurface fbf1c7\n") + gmedium = schemes / "gruvbox" / "medium" + gmedium.mkdir(parents=True) + (gmedium / "dark.txt").write_text( + "background 101415\nonBackground e0e3e4\nprimary 81d3e0\nsurface 1c2021\n" + ) + (gmedium / "light.txt").write_text( + "background fbf1c7\nonBackground 3c3836\nprimary 6b5f10\nsurface fbf1c7\n" + ) - ghard = schemes / "gruvbox" / "hard" - ghard.mkdir(parents=True) - (ghard / "dark.txt").write_text("background 0b0d0e\nprimary 81d3e0\n") + ghard = schemes / "gruvbox" / "hard" + ghard.mkdir(parents=True) + (ghard / "dark.txt").write_text("background 0b0d0e\nprimary 81d3e0\n") - cmocha = schemes / "catppuccin" / "mocha" - cmocha.mkdir(parents=True) - (cmocha / "dark.txt").write_text("background 1e1e2e\nprimary cba6f7\nsecondary 756294\nsurface 313244\n") - (cmocha / "mauve-dark.txt").write_text("background 1e1e2e\nprimary cba6f7\nsecondary 756294\nsurface 313244\n") - (cmocha / "green-dark.txt").write_text("background 1e1e2e\nprimary a6e3a1\nsecondary 5b8964\nsurface 313244\n") + cmocha = schemes / "catppuccin" / "mocha" + cmocha.mkdir(parents=True) + (cmocha / "dark.txt").write_text( + "background 1e1e2e\nprimary cba6f7\nsecondary 756294\nsurface 313244\n" + ) + (cmocha / "mauve-dark.txt").write_text( + "background 1e1e2e\nprimary cba6f7\nsecondary 756294\nsurface 313244\n" + ) + (cmocha / "green-dark.txt").write_text( + "background 1e1e2e\nprimary a6e3a1\nsecondary 5b8964\nsurface 313244\n" + ) - clatte = schemes / "catppuccin" / "latte" - clatte.mkdir(parents=True) - (clatte / "light.txt").write_text("background eff1f5\nprimary 8839ef\nsecondary c2b8d0\nsurface ccd0da\n") - (clatte / "mauve-light.txt").write_text("background eff1f5\nprimary 8839ef\nsecondary c2b8d0\nsurface ccd0da\n") + clatte = schemes / "catppuccin" / "latte" + clatte.mkdir(parents=True) + (clatte / "light.txt").write_text( + "background eff1f5\nprimary 8839ef\nsecondary c2b8d0\nsurface ccd0da\n" + ) + (clatte / "mauve-light.txt").write_text( + "background eff1f5\nprimary 8839ef\nsecondary c2b8d0\nsurface ccd0da\n" + ) - cextra = schemes / "extra" / "default" - cextra.mkdir(parents=True) - (cextra / "dark.txt").write_text( - "# this is a comment\n\nbackground 000000\nprimary ffffff\n\n # indented comment \n secondary cccccc\n" - ) + cextra = schemes / "extra" / "default" + cextra.mkdir(parents=True) + (cextra / "dark.txt").write_text( + "# this is a comment\n\nbackground 000000\nprimary ffffff\n\n # indented comment \n secondary cccccc\n" + ) - return schemes + return schemes class TestParseTxt: - def test_basic(self, tmp_schemes): - path = tmp_schemes / "gruvbox" / "medium" / "dark.txt" - colors = sp._parse_txt(path) - assert colors["background"] == "#101415" - assert colors["primary"] == "#81d3e0" - assert colors["surface"] == "#1c2021" + def test_basic(self, tmp_schemes): + path = tmp_schemes / "gruvbox" / "medium" / "dark.txt" + colors = sp._parse_txt(path) + assert colors["background"] == "#101415" + assert colors["primary"] == "#81d3e0" + assert colors["surface"] == "#1c2021" - def test_adds_hash_prefix(self, tmp_schemes): - path = tmp_schemes / "gruvbox" / "medium" / "dark.txt" - colors = sp._parse_txt(path) - for v in colors.values(): - assert v.startswith("#"), f"value {v!r} missing # prefix" + def test_adds_hash_prefix(self, tmp_schemes): + path = tmp_schemes / "gruvbox" / "medium" / "dark.txt" + colors = sp._parse_txt(path) + for v in colors.values(): + assert v.startswith("#"), f"value {v!r} missing # prefix" - def test_skips_comments_and_empty_lines(self, tmp_schemes): - path = tmp_schemes / "extra" / "default" / "dark.txt" - colors = sp._parse_txt(path) - assert colors["background"] == "#000000" - assert colors["primary"] == "#ffffff" - assert colors["secondary"] == "#cccccc" - assert len(colors) == 3 + def test_skips_comments_and_empty_lines(self, tmp_schemes): + path = tmp_schemes / "extra" / "default" / "dark.txt" + colors = sp._parse_txt(path) + assert colors["background"] == "#000000" + assert colors["primary"] == "#ffffff" + assert colors["secondary"] == "#cccccc" + assert len(colors) == 3 class TestDiscoverSchemes: - def test_discovers_all_schemes(self): - schemes = sp._discover_schemes() - assert "gruvbox" in schemes - assert "catppuccin" in schemes - assert "everforest" in schemes - assert "nord" in schemes - assert len(schemes) >= 10 + def test_discovers_all_schemes(self): + schemes = sp._discover_schemes() + assert "gruvbox" in schemes + assert "catppuccin" in schemes + assert "everforest" in schemes + assert "nord" in schemes + assert len(schemes) >= 10 - def test_scheme_has_variants(self): - schemes = sp._discover_schemes() - gruvbox = schemes["gruvbox"] - var_ids = {v.id for v in gruvbox.variants} - assert "medium" in var_ids - assert "hard" in var_ids - assert "soft" in var_ids + def test_scheme_has_variants(self): + schemes = sp._discover_schemes() + gruvbox = schemes["gruvbox"] + var_ids = {v.id for v in gruvbox.variants} + assert "medium" in var_ids + assert "hard" in var_ids + assert "soft" in var_ids - def test_variant_has_modes(self): - schemes = sp._discover_schemes() - gmedium = next(v for v in schemes["gruvbox"].variants if v.id == "medium") - assert "dark" in gmedium.modes - assert "light" in gmedium.modes + def test_variant_has_modes(self): + schemes = sp._discover_schemes() + gmedium = next( + v for v in schemes["gruvbox"].variants if v.id == "medium" + ) + assert "dark" in gmedium.modes + assert "light" in gmedium.modes - def test_catppuccin_has_accents(self): - schemes = sp._discover_schemes() - mocha = next(v for v in schemes["catppuccin"].variants if v.id == "mocha") - assert "mauve" in mocha.accents - assert "green" in mocha.accents - assert "rosewater" in mocha.accents - assert len(mocha.accents) >= 14 + def test_catppuccin_has_accents(self): + schemes = sp._discover_schemes() + mocha = next( + v for v in schemes["catppuccin"].variants if v.id == "mocha" + ) + assert "mauve" in mocha.accents + assert "green" in mocha.accents + assert "rosewater" in mocha.accents + assert len(mocha.accents) >= 14 - def test_non_accent_scheme_has_no_accents(self): - schemes = sp._discover_schemes() - gmedium = next(v for v in schemes["gruvbox"].variants if v.id == "medium") - assert gmedium.accents == () + def test_non_accent_scheme_has_no_accents(self): + schemes = sp._discover_schemes() + gmedium = next( + v for v in schemes["gruvbox"].variants if v.id == "medium" + ) + assert gmedium.accents == () class TestGetPalette: - def test_loads_basic_palette(self): - pal = sp.get_palette("gruvbox", "medium", "dark") - assert pal.scheme == "gruvbox" - assert pal.variant == "medium" - assert pal.mode == "dark" - assert pal.colors["background"].startswith("#") - assert pal.colors["primary"].startswith("#") + def test_loads_basic_palette(self): + pal = sp.get_palette("gruvbox", "medium", "dark") + assert pal.scheme == "gruvbox" + assert pal.variant == "medium" + assert pal.mode == "dark" + assert pal.colors["background"].startswith("#") + assert pal.colors["primary"].startswith("#") - def test_loads_accent_palette(self): - pal = sp.get_palette("catppuccin", "mocha", "dark", accent="mauve") - assert pal.accent == "mauve" - assert pal.colors["primary"] == "#cba6f7" + def test_loads_accent_palette(self): + pal = sp.get_palette("catppuccin", "mocha", "dark", accent="mauve") + assert pal.accent == "mauve" + assert pal.colors["primary"] == "#cba6f7" - def test_different_accent_changes_colors(self): - mauve = sp.get_palette("catppuccin", "mocha", "dark", accent="mauve") - green = sp.get_palette("catppuccin", "mocha", "dark", accent="green") - assert mauve.colors["primary"] != green.colors["primary"] - assert mauve.colors["secondary"] != green.colors["secondary"] + def test_different_accent_changes_colors(self): + mauve = sp.get_palette("catppuccin", "mocha", "dark", accent="mauve") + green = sp.get_palette("catppuccin", "mocha", "dark", accent="green") + assert mauve.colors["primary"] != green.colors["primary"] + assert mauve.colors["secondary"] != green.colors["secondary"] - def test_unknown_scheme_raises(self): - with pytest.raises(KeyError, match="Unknown scheme 'nope'"): - sp.get_palette("nope", "medium", "dark") + def test_unknown_scheme_raises(self): + with pytest.raises(KeyError, match="Unknown scheme 'nope'"): + sp.get_palette("nope", "medium", "dark") - def test_unknown_variant_raises(self): - with pytest.raises(KeyError, match="Unknown variant 'bogus' for scheme 'gruvbox'"): - sp.get_palette("gruvbox", "bogus", "dark") + def test_unknown_variant_raises(self): + with pytest.raises( + KeyError, match="Unknown variant 'bogus' for scheme 'gruvbox'" + ): + sp.get_palette("gruvbox", "bogus", "dark") - def test_unknown_accent_falls_back(self): - pal = sp.get_palette("catppuccin", "mocha", "dark", accent="nonexistent") - assert pal.accent == "nonexistent" - assert pal.colors["primary"] is not None + def test_unknown_accent_falls_back(self): + pal = sp.get_palette( + "catppuccin", "mocha", "dark", accent="nonexistent" + ) + assert pal.accent == "nonexistent" + assert pal.colors["primary"] is not None - def test_accent_on_non_accent_scheme(self): - pal = sp.get_palette("gruvbox", "medium", "dark", accent="mauve") - assert pal.colors is not None + def test_accent_on_non_accent_scheme(self): + pal = sp.get_palette("gruvbox", "medium", "dark", accent="mauve") + assert pal.colors is not None - def test_non_existent_mode_raises(self): - with pytest.raises(FileNotFoundError): - sp.get_palette("catppuccin", "mocha", "light") + def test_non_existent_mode_raises(self): + with pytest.raises(FileNotFoundError): + sp.get_palette("catppuccin", "mocha", "light") class TestListSchemes: - def test_returns_dict(self): - schemes = sp.list_schemes() - assert isinstance(schemes, dict) + def test_returns_dict(self): + schemes = sp.list_schemes() + assert isinstance(schemes, dict) - def test_includes_known_schemes(self): - schemes = sp.list_schemes() - assert "catppuccin" in schemes - assert "gruvbox" in schemes + def test_includes_known_schemes(self): + schemes = sp.list_schemes() + assert "catppuccin" in schemes + assert "gruvbox" in schemes class TestResolvePreset: - def test_two_parts(self): - assert sp.resolve_preset("gruvbox:medium") == ("gruvbox", "medium") + def test_two_parts(self): + assert sp.resolve_preset("gruvbox:medium") == ("gruvbox", "medium") - def test_three_parts(self): - with pytest.raises(ValueError, match="Invalid preset spec"): - sp.resolve_preset("catppuccin:mocha:mauve") + def test_three_parts(self): + with pytest.raises(ValueError, match="Invalid preset spec"): + sp.resolve_preset("catppuccin:mocha:mauve") - def test_one_part(self): - assert sp.resolve_preset("default") == ("default", "default") + def test_one_part(self): + assert sp.resolve_preset("default") == ("default", "default") - def test_edge_spaces(self): - assert sp.resolve_preset(" catppuccin : mocha ") == (" catppuccin ", " mocha ") + def test_edge_spaces(self): + assert sp.resolve_preset(" catppuccin : mocha ") == ( + " catppuccin ", + " mocha ", + ) diff --git a/cli/tests/test_shell.py b/cli/tests/test_shell.py index 9dd8de2..1b215f4 100644 --- a/cli/tests/test_shell.py +++ b/cli/tests/test_shell.py @@ -1,7 +1,7 @@ from __future__ import annotations from subprocess import CompletedProcess -from unittest.mock import patch, call +from unittest.mock import call, patch from typer.testing import CliRunner from zshell.subcommands.shell import app @@ -10,122 +10,163 @@ runner = CliRunner() def invoke(*args: str): - result = runner.invoke(app, args) - if result.exit_code != 0: - raise RuntimeError(result.output) - return result + result = runner.invoke(app, args) + if result.exit_code != 0: + raise RuntimeError(result.output) + return result class TestKill: - @patch("zshell.subcommands.shell.subprocess.run") - def test_kill_runs_qs_kill_success(self, mock_run): - mock_run.return_value = CompletedProcess([], 0, b"", b"Killed abc\n") - invoke("kill") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "kill"], capture_output=True) + @patch("zshell.subcommands.shell.subprocess.run") + def test_kill_runs_qs_kill_success(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"", b"Killed abc\n") + invoke("kill") + mock_run.assert_called_once_with( + ["qs", "-c", "zshell", "kill"], capture_output=True + ) - @patch("zshell.subcommands.shell.subprocess.run") - def test_kill_no_instance_errors(self, mock_run): - mock_run.return_value = CompletedProcess([], 255, b"", b"No running instances\n") - result = runner.invoke(app, ["kill"]) - assert result.exit_code != 0 - assert "No running instance to kill" in result.output + @patch("zshell.subcommands.shell.subprocess.run") + def test_kill_no_instance_errors(self, mock_run): + mock_run.return_value = CompletedProcess( + [], 255, b"", b"No running instances\n" + ) + result = runner.invoke(app, ["kill"]) + assert result.exit_code != 0 + assert "No running instance to kill" in result.output class TestStart: - @patch("zshell.subcommands.shell.subprocess.run") - def test_start_default_daemon(self, mock_run): - mock_run.return_value = CompletedProcess([], 0, b"", b"Launching config\n") - invoke("start") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n", "-d"], capture_output=True) + @patch("zshell.subcommands.shell.subprocess.run") + def test_start_default_daemon(self, mock_run): + mock_run.return_value = CompletedProcess( + [], 0, b"", b"Launching config\n" + ) + invoke("start") + mock_run.assert_called_once_with( + ["qs", "-c", "zshell", "-n", "-d"], capture_output=True + ) - @patch("zshell.subcommands.shell.subprocess.run") - def test_start_no_daemon(self, mock_run): - mock_run.return_value = CompletedProcess([], 0, b"", b"Launching config\n") - invoke("start", "--no-daemon") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "-n"], capture_output=True) + @patch("zshell.subcommands.shell.subprocess.run") + def test_start_no_daemon(self, mock_run): + mock_run.return_value = CompletedProcess( + [], 0, b"", b"Launching config\n" + ) + invoke("start", "--no-daemon") + mock_run.assert_called_once_with( + ["qs", "-c", "zshell", "-n"], capture_output=True + ) - @patch("zshell.subcommands.shell.subprocess.run") - def test_start_already_running_errors(self, mock_run): - mock_run.return_value = CompletedProcess([], 0, b"An instance of this configuration is already running.\n", b"") - result = runner.invoke(app, ["start"]) - assert result.exit_code != 0 - assert "already running" in result.output + @patch("zshell.subcommands.shell.subprocess.run") + def test_start_already_running_errors(self, mock_run): + mock_run.return_value = CompletedProcess( + [], + 0, + b"An instance of this configuration is already running.\n", + b"", + ) + result = runner.invoke(app, ["start"]) + assert result.exit_code != 0 + assert "already running" in result.output - @patch("zshell.subcommands.shell.subprocess.run") - def test_start_other_failure_errors(self, mock_run): - mock_run.return_value = CompletedProcess([], 1, b"", b"Config error\n") - result = runner.invoke(app, ["start"]) - assert result.exit_code != 0 - assert "Config error" in result.output + @patch("zshell.subcommands.shell.subprocess.run") + def test_start_other_failure_errors(self, mock_run): + mock_run.return_value = CompletedProcess([], 1, b"", b"Config error\n") + result = runner.invoke(app, ["start"]) + assert result.exit_code != 0 + assert "Config error" in result.output class TestShow: - @patch("zshell.subcommands.shell.subprocess.run") - def test_show_runs_ipc_show(self, mock_run): - mock_run.return_value = CompletedProcess([], 0, b"target visibilities\n", b"") - result = invoke("show") - assert "target visibilities" in result.output - mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "show"], capture_output=True) + @patch("zshell.subcommands.shell.subprocess.run") + def test_show_runs_ipc_show(self, mock_run): + mock_run.return_value = CompletedProcess( + [], 0, b"target visibilities\n", b"" + ) + result = invoke("show") + assert "target visibilities" in result.output + mock_run.assert_called_once_with( + ["qs", "-c", "zshell", "ipc", "show"], capture_output=True + ) class TestLog: - @patch("zshell.subcommands.shell.subprocess.run") - def test_log_runs_qs_log(self, mock_run): - mock_run.return_value = CompletedProcess([], 0, b"log output\n", b"") - invoke("log") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "log"], capture_output=True) + @patch("zshell.subcommands.shell.subprocess.run") + def test_log_runs_qs_log(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"log output\n", b"") + invoke("log") + mock_run.assert_called_once_with( + ["qs", "-c", "zshell", "log"], capture_output=True + ) class TestLock: - @patch("zshell.subcommands.shell.subprocess.run") - def test_lock_runs_ipc_call_lock(self, mock_run): - mock_run.return_value = CompletedProcess([], 0, b"", b"") - invoke("lock") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "lock", "lock"], capture_output=True) + @patch("zshell.subcommands.shell.subprocess.run") + def test_lock_runs_ipc_call_lock(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"", b"") + invoke("lock") + mock_run.assert_called_once_with( + ["qs", "-c", "zshell", "ipc", "call", "lock", "lock"], + capture_output=True, + ) class TestCall: - @patch("zshell.subcommands.shell.subprocess.run") - def test_call_no_args(self, mock_run): - mock_run.return_value = CompletedProcess([], 0, b"", b"") - invoke("call", "target", "method") - mock_run.assert_called_once_with(["qs", "-c", "zshell", "ipc", "call", "target", "method"], capture_output=True) + @patch("zshell.subcommands.shell.subprocess.run") + def test_call_no_args(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"", b"") + invoke("call", "target", "method") + mock_run.assert_called_once_with( + ["qs", "-c", "zshell", "ipc", "call", "target", "method"], + capture_output=True, + ) - @patch("zshell.subcommands.shell.subprocess.run") - def test_call_with_args(self, mock_run): - mock_run.return_value = CompletedProcess([], 0, b"", b"") - invoke("call", "target", "method", "arg1", "arg2") - mock_run.assert_called_once_with( - ["qs", "-c", "zshell", "ipc", "call", "target", "method", "arg1", "arg2"], - capture_output=True, - ) + @patch("zshell.subcommands.shell.subprocess.run") + def test_call_with_args(self, mock_run): + mock_run.return_value = CompletedProcess([], 0, b"", b"") + invoke("call", "target", "method", "arg1", "arg2") + mock_run.assert_called_once_with( + [ + "qs", + "-c", + "zshell", + "ipc", + "call", + "target", + "method", + "arg1", + "arg2", + ], + capture_output=True, + ) class TestRestart: - @patch("zshell.subcommands.shell.start_instance") - @patch("zshell.subcommands.shell.subprocess.run") - def test_restart_kills_then_starts(self, mock_run, mock_start): - mock_run.side_effect = [ - CompletedProcess([], 0, b"", b"Killed abc\n"), # first kill (captured) - CompletedProcess([], 255, b"", b""), # poll → no instance - ] - invoke("restart") - assert mock_run.call_args_list == [ - call(["qs", "-c", "zshell", "kill"], capture_output=True), - call(["qs", "-c", "zshell", "kill"], capture_output=True), - ] - mock_start.assert_called_once_with(no_daemon=False) + @patch("zshell.subcommands.shell.start_instance") + @patch("zshell.subcommands.shell.subprocess.run") + def test_restart_kills_then_starts(self, mock_run, mock_start): + mock_run.side_effect = [ + CompletedProcess( + [], 0, b"", b"Killed abc\n" + ), # first kill (captured) + CompletedProcess([], 255, b"", b""), # poll → no instance + ] + invoke("restart") + assert mock_run.call_args_list == [ + call(["qs", "-c", "zshell", "kill"], capture_output=True), + call(["qs", "-c", "zshell", "kill"], capture_output=True), + ] + mock_start.assert_called_once_with(no_daemon=False) - @patch("zshell.subcommands.shell.start_instance") - @patch("zshell.subcommands.shell.subprocess.run") - def test_restart_no_daemon(self, mock_run, mock_start): - mock_run.side_effect = [ - CompletedProcess([], 0, b"", b"Killed abc\n"), - CompletedProcess([], 255, b"", b""), - ] - invoke("restart", "--no-daemon") - assert mock_run.call_args_list == [ - call(["qs", "-c", "zshell", "kill"], capture_output=True), - call(["qs", "-c", "zshell", "kill"], capture_output=True), - ] - mock_start.assert_called_once_with(no_daemon=True) + @patch("zshell.subcommands.shell.start_instance") + @patch("zshell.subcommands.shell.subprocess.run") + def test_restart_no_daemon(self, mock_run, mock_start): + mock_run.side_effect = [ + CompletedProcess([], 0, b"", b"Killed abc\n"), + CompletedProcess([], 255, b"", b""), + ] + invoke("restart", "--no-daemon") + assert mock_run.call_args_list == [ + call(["qs", "-c", "zshell", "kill"], capture_output=True), + call(["qs", "-c", "zshell", "kill"], capture_output=True), + ] + mock_start.assert_called_once_with(no_daemon=True) 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())