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

This commit is contained in:
2026-07-08 16:54:41 +02:00
parent 298cbbf424
commit 9300da258e
10 changed files with 1505 additions and 1315 deletions
+26 -8
View File
@@ -1,12 +1,14 @@
from __future__ import annotations from __future__ import annotations
import os import os
import sys import sys
from pathlib import Path from pathlib import Path
import typer import typer
from typer._completion_shared import install, _get_shell_name
from typer._completion_classes import completion_init 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) app = typer.Typer(name="zshell-cli", add_completion=False)
@@ -23,9 +25,17 @@ def _completion_installed() -> bool:
case "zsh": case "zsh":
return (Path.home() / ".zfunc" / "_zshell-cli").exists() return (Path.home() / ".zfunc" / "_zshell-cli").exists()
case "bash": case "bash":
return (Path.home() / ".bash_completions" / "zshell-cli.sh").exists() return (
Path.home() / ".bash_completions" / "zshell-cli.sh"
).exists()
case "fish": case "fish":
return (Path.home() / ".config" / "fish" / "completions" / "zshell-cli.fish").exists() return (
Path.home()
/ ".config"
/ "fish"
/ "completions"
/ "zshell-cli.fish"
).exists()
return False return False
@@ -40,10 +50,15 @@ def _install_completion() -> None:
try: try:
_, path = install(prog_name="zshell-cli") _, path = install(prog_name="zshell-cli")
print(f"zshell-cli: Shell completion installed ({shell}: {path})") print(f"zshell-cli: Shell completion installed ({shell}: {path})")
print("zshell-cli: Restart your shell or source the file to enable tab-completion.") print(
"zshell-cli: Restart your shell or source the file to enable tab-completion."
)
except Exception as e: except Exception as e:
print(f"zshell-cli: Failed to install shell completion: {e}", file=sys.stderr) print(
raise typer.Exit(code=1) f"zshell-cli: Failed to install shell completion: {e}",
file=sys.stderr,
)
raise typer.Exit(code=1) from None
def main() -> None: def main() -> None:
@@ -53,5 +68,8 @@ def main() -> None:
if "_ZSHELL_CLI_COMPLETE" in os.environ: if "_ZSHELL_CLI_COMPLETE" in os.environ:
completion_init() completion_init()
if sys.stdout.isatty() and not _completion_installed(): if sys.stdout.isatty() and not _completion_installed():
print("zshell-cli: Tip: run with --install-autocomplete for tab completion.", file=sys.stderr) print(
"zshell-cli: Tip: run with --install-autocomplete for tab completion.",
file=sys.stderr,
)
app() app()
+87 -27
View File
@@ -1,9 +1,9 @@
import os import contextlib
import json import json
import os
import subprocess import subprocess
import time import time
from pathlib import Path from pathlib import Path
from typing import Optional
import typer import typer
@@ -18,7 +18,9 @@ TEMP_RECORDING = STATE_DIR / "recording.mp4"
REPLAY_RECORDING = STATE_DIR / "replay.mp4" REPLAY_RECORDING = STATE_DIR / "replay.mp4"
NOTIF_ID_FILE = STATE_DIR / "notifid.txt" NOTIF_ID_FILE = STATE_DIR / "notifid.txt"
RECORDINGS_DIR = os.getenv("ZSHELL_RECORDINGS_DIR", str(Path(HOME) / "Videos/Recordings")) RECORDINGS_DIR = os.getenv(
"ZSHELL_RECORDINGS_DIR", str(Path(HOME) / "Videos/Recordings")
)
def _read_extra_args() -> list[str]: def _read_extra_args() -> list[str]:
@@ -32,34 +34,54 @@ def _read_extra_args() -> list[str]:
def _is_recording() -> bool: 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]: def _notify(
summary: str,
body: str = "",
actions: list | None = None,
timeout: int = 5000,
) -> int | None:
args = ["notify-send", summary, body, "-t", str(timeout), "-p"] args = ["notify-send", summary, body, "-t", str(timeout), "-p"]
if actions: if actions:
for action in actions: for action in actions:
args.extend(["-A", action]) args.extend(["-A", action])
try: try:
proc = subprocess.run(args, capture_output=True, text=True) proc = subprocess.run(args, capture_output=True, text=True)
return int(proc.stdout.strip()) if proc.stdout.strip().isdigit() else None return (
int(proc.stdout.strip()) if proc.stdout.strip().isdigit() else None
)
except Exception: except Exception:
return None return None
def _close_notification(notif_id: int): 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]: def _get_monitors() -> list[dict]:
try: try:
res = subprocess.run(["hyprctl", "monitors", "-j"], capture_output=True, text=True) res = subprocess.run(
["hyprctl", "monitors", "-j"], capture_output=True, text=True
)
return json.loads(res.stdout) return json.loads(res.stdout)
except Exception: except Exception:
return [] return []
def _focused_monitor_name() -> Optional[str]: def _focused_monitor_name() -> str | None:
for m in _get_monitors(): for m in _get_monitors():
if m.get("focused"): if m.get("focused"):
return m["name"] return m["name"]
@@ -71,7 +93,12 @@ def _monitors_intersecting_region(x: int, y: int, w: int, h: int) -> list[dict]:
intersecting = [] intersecting = []
for m in _get_monitors(): for m in _get_monitors():
mx, my, mw, mh = m["x"], m["y"], m["width"], m["height"] 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): if not (
region[2] <= mx
or region[0] >= mx + mw
or region[3] <= my
or region[1] >= my + mh
):
intersecting.append(m) intersecting.append(m)
return intersecting return intersecting
@@ -80,23 +107,30 @@ 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]: def _slurp_region() -> str | None:
try: try:
return subprocess.check_output(["slurp", "-f", "%wx%h+%x+%y"], text=True).strip() return subprocess.check_output(
["slurp", "-f", "%wx%h+%x+%y"], text=True
).strip()
except subprocess.CalledProcessError: except subprocess.CalledProcessError:
return None return None
def _parse_geometry(geometry: str) -> Optional[tuple[int, int, int, int]]: def _parse_geometry(geometry: str) -> tuple[int, int, int, int] | None:
import re import re
match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry) match = re.match(r"(\d+)x(\d+)\+(\d+)\+(\d+)", geometry)
if match: if match:
return int(match.group(3)), int(match.group(4)), int(match.group(1)), int(match.group(2)) return (
int(match.group(3)),
int(match.group(4)),
int(match.group(1)),
int(match.group(2)),
)
return None return None
def start_recording(region: Optional[str], sound: bool): def start_recording(region: str | None, sound: bool):
STATE_DIR.mkdir(parents=True, exist_ok=True) STATE_DIR.mkdir(parents=True, exist_ok=True)
cmd = [RECORDER] cmd = [RECORDER]
extra_args = _read_extra_args() extra_args = _read_extra_args()
@@ -118,7 +152,9 @@ def start_recording(region: Optional[str], sound: bool):
monitors = _monitors_intersecting_region(x, y, w, h) monitors = _monitors_intersecting_region(x, y, w, h)
framerate = _highest_refresh(monitors) framerate = _highest_refresh(monitors)
cmd.extend(["-w", "region", "-region", geometry, "-f", str(int(framerate))]) cmd.extend(
["-w", "region", "-region", geometry, "-f", str(int(framerate))]
)
else: else:
monitor_name = _focused_monitor_name() monitor_name = _focused_monitor_name()
@@ -137,7 +173,12 @@ def start_recording(region: Optional[str], sound: bool):
cmd.extend(extra_args) cmd.extend(extra_args)
cmd.extend(["-o", str(TEMP_RECORDING)]) cmd.extend(["-o", str(TEMP_RECORDING)])
subprocess.Popen(cmd, start_new_session=True, 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}") notif_id = _notify("Recording started", f"Saving to {TEMP_RECORDING}")
if notif_id is not None: if notif_id is not None:
@@ -145,12 +186,20 @@ def start_recording(region: Optional[str], sound: bool):
time.sleep(1) time.sleep(1)
if not _is_recording(): if not _is_recording():
_notify("Recording failed", "Check gpu-screen-recorder output.", timeout=5000) _notify(
"Recording failed",
"Check gpu-screen-recorder output.",
timeout=5000,
)
raise typer.Exit(code=1) raise typer.Exit(code=1)
def stop_recording(clipboard: bool): def stop_recording(clipboard: bool):
subprocess.run(["pkill", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.run(
["pkill", "-f", RECORDER],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
for _ in range(50): for _ in range(50):
if not _is_recording(): if not _is_recording():
@@ -166,10 +215,8 @@ def stop_recording(clipboard: bool):
TEMP_RECORDING.rename(final_path) TEMP_RECORDING.rename(final_path)
if NOTIF_ID_FILE.is_file(): if NOTIF_ID_FILE.is_file():
try: with contextlib.suppress(Exception):
_close_notification(int(NOTIF_ID_FILE.read_text().strip())) _close_notification(int(NOTIF_ID_FILE.read_text().strip()))
except Exception:
pass
NOTIF_ID_FILE.unlink() NOTIF_ID_FILE.unlink()
if clipboard: if clipboard:
@@ -183,21 +230,34 @@ def stop_recording(clipboard: bool):
def toggle_pause(): def toggle_pause():
subprocess.run(["pkill", "-USR2", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) subprocess.run(
["pkill", "-USR2", "-f", RECORDER],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
typer.echo("Toggled pause.") typer.echo("Toggled pause.")
@app.command() @app.command()
def record( def record(
region: Optional[str] = typer.Option( region: str | None = typer.Option(
None, None,
"--region", "--region",
"-r", "-r",
help="Record a region. Use 'slurp' (or omit value) to select interactively, or give 'WxH+X+Y'.", help="Record a region. Use 'slurp' (or omit value) to select interactively, or give 'WxH+X+Y'.",
), ),
sound: bool = typer.Option(False, "--sound", "-s", help="Record audio from default output."), sound: bool = typer.Option(
pause: bool = typer.Option(False, "--pause", "-p", help="Toggle pause/resume."), False, "--sound", "-s", help="Record audio from default output."
clipboard: bool = typer.Option(False, "--clipboard", "-c", help="Copy the final recording path to clipboard."), ),
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.""" """Start or stop a screen recording with gpu-screen-recorder."""
if pause: if pause:
+77 -47
View File
@@ -1,26 +1,33 @@
import typer import contextlib
import json import json
import shutil
import os import os
import sys
import re import re
import shutil
import subprocess import subprocess
import sys
from jinja2 import Environment, FileSystemLoader, StrictUndefined, Undefined
from typing import Any, Optional, Tuple
from zshell.utils.schemepalettes import get_palette, list_schemes, resolve_preset
from pathlib import Path 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.quantize import QuantizeCelebi
from materialyoucolor.score.score import Score 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.color_utils import argb_from_rgb
from materialyoucolor.utils.math_utils import ( from materialyoucolor.utils.math_utils import (
difference_degrees, difference_degrees,
rotation_direction, rotation_direction,
sanitize_degrees_double, sanitize_degrees_double,
) )
from PIL import Image
from zshell.utils.schemepalettes import (
get_palette,
list_schemes,
resolve_preset,
)
app = typer.Typer() app = typer.Typer()
@@ -74,7 +81,8 @@ def _complete_accent(ctx, incomplete):
@app.command() @app.command()
def list_presets( def list_presets(
json_format: bool = typer.Option( json_format: bool = typer.Option(
False, "--json", help="Output in JSON format"), False, "--json", help="Output in JSON format"
),
): ):
schemes = list_schemes() schemes = list_schemes()
if json_format: if json_format:
@@ -107,25 +115,25 @@ def list_presets(
@app.command() @app.command()
def generate( def generate(
image_path: Optional[Path] = typer.Option( image_path: Path | None = typer.Option(
None, help="Path to source image. Required for image mode." None, help="Path to source image. Required for image mode."
), ),
scheme: Optional[str] = typer.Option( scheme: str | None = typer.Option(
None, None,
help="Color scheme algorithm to use for image mode. Ignored in preset mode.", help="Color scheme algorithm to use for image mode. Ignored in preset mode.",
autocompletion=_complete_scheme_name, autocompletion=_complete_scheme_name,
), ),
preset: Optional[str] = typer.Option( preset: str | None = typer.Option(
None, None,
help="Name of a premade scheme in this format: <scheme>:<variant>", help="Name of a premade scheme in this format: <scheme>:<variant>",
autocompletion=_complete_preset, autocompletion=_complete_preset,
), ),
mode: Optional[str] = typer.Option( mode: str | None = typer.Option(
None, None,
help="Mode of the preset scheme (dark or light).", help="Mode of the preset scheme (dark or light).",
autocompletion=_complete_mode, autocompletion=_complete_mode,
), ),
accent: Optional[str] = typer.Option( accent: str | None = typer.Option(
None, None,
help="Accent for schemes that support it (e.g. mauve).", help="Accent for schemes that support it (e.g. mauve).",
autocompletion=_complete_accent, autocompletion=_complete_accent,
@@ -149,20 +157,27 @@ def generate(
if preset is not None and image_path is not None: if preset is not None and image_path is not None:
raise typer.BadParameter( raise typer.BadParameter(
"Use either --image-path or --preset, not both.") "Use either --image-path or --preset, not both."
)
def get_scheme_class(scheme_name: str): def get_scheme_class(scheme_name: str):
match scheme_name: match scheme_name:
case "fruit-salad": case "fruit-salad":
from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad from materialyoucolor.scheme.scheme_fruit_salad import (
SchemeFruitSalad,
)
return SchemeFruitSalad return SchemeFruitSalad
case "expressive": case "expressive":
from materialyoucolor.scheme.scheme_expressive import SchemeExpressive from materialyoucolor.scheme.scheme_expressive import (
SchemeExpressive,
)
return SchemeExpressive return SchemeExpressive
case "monochrome": case "monochrome":
from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome from materialyoucolor.scheme.scheme_monochrome import (
SchemeMonochrome,
)
return SchemeMonochrome return SchemeMonochrome
case "rainbow": case "rainbow":
@@ -170,7 +185,9 @@ def generate(
return SchemeRainbow return SchemeRainbow
case "tonal-spot": case "tonal-spot":
from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot from materialyoucolor.scheme.scheme_tonal_spot import (
SchemeTonalSpot,
)
return SchemeTonalSpot return SchemeTonalSpot
case "neutral": case "neutral":
@@ -178,7 +195,9 @@ def generate(
return SchemeNeutral return SchemeNeutral
case "fidelity": case "fidelity":
from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity from materialyoucolor.scheme.scheme_fidelity import (
SchemeFidelity,
)
return SchemeFidelity return SchemeFidelity
case "content": case "content":
@@ -190,7 +209,9 @@ def generate(
return SchemeVibrant return SchemeVibrant
case _: case _:
from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad from materialyoucolor.scheme.scheme_fruit_salad import (
SchemeFruitSalad,
)
return SchemeFruitSalad return SchemeFruitSalad
@@ -275,8 +296,8 @@ def generate(
diff = difference_degrees(from_hct.hue, to_hct.hue) diff = difference_degrees(from_hct.hue, to_hct.hue)
rotation = min(diff * 0.8, 100) rotation = min(diff * 0.8, 100)
output_hue = sanitize_degrees_double( output_hue = sanitize_degrees_double(
from_hct.hue + rotation * from_hct.hue
rotation_direction(from_hct.hue, to_hct.hue) + rotation * rotation_direction(from_hct.hue, to_hct.hue)
) )
tone = max(0.0, min(100.0, from_hct.tone * (1 + tone_boost))) tone = max(0.0, min(100.0, from_hct.tone * (1 + tone_boost)))
return Hct.from_hct(output_hue, from_hct.chroma, tone) return Hct.from_hct(output_hue, from_hct.chroma, tone)
@@ -316,7 +337,9 @@ def generate(
safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key) safe_key = re.sub(r"[^A-Za-z0-9._-]", "_", key)
return thumb_dir / f"{safe_key}_thumbnail.jpg" return thumb_dir / f"{safe_key}_thumbnail.jpg"
def generate_thumbnail(image_path: Path, thumb_dir: Path, size=(128, 128)) -> Path: def generate_thumbnail(
image_path: Path, thumb_dir: Path, size=(128, 128)
) -> Path:
thumb_dir.mkdir(parents=True, exist_ok=True) thumb_dir.mkdir(parents=True, exist_ok=True)
cache_path = thumbnail_cache_path(image_path, thumb_dir) cache_path = thumbnail_cache_path(image_path, thumb_dir)
@@ -331,7 +354,9 @@ def generate(
return cache_path return cache_path
def apply_terms(sequences: str, sequences_tmux: str, state_path: Path) -> None: def apply_terms(
sequences: str, sequences_tmux: str, state_path: Path
) -> None:
state_path.parent.mkdir(parents=True, exist_ok=True) state_path.parent.mkdir(parents=True, exist_ok=True)
state_path.write_text(sequences, encoding="utf-8") state_path.write_text(sequences, encoding="utf-8")
@@ -375,7 +400,7 @@ def generate(
mode = mode.lower() mode = mode.lower()
preference = "prefer-dark" if mode == "dark" else "prefer-light" preference = "prefer-dark" if mode == "dark" else "prefer-light"
try: with contextlib.suppress(FileNotFoundError):
subprocess.run( subprocess.run(
[ [
"gsettings", "gsettings",
@@ -388,8 +413,6 @@ def generate(
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
) )
except FileNotFoundError:
pass
def apply_qt_mode(mode: str, home: str) -> None: def apply_qt_mode(mode: str, home: str) -> None:
mode = mode.lower() mode = mode.lower()
@@ -413,10 +436,8 @@ def generate(
) )
if count > 0 and new_text != text: if count > 0 and new_text != text:
try: with contextlib.suppress(OSError):
qt_conf.write_text(new_text, encoding="utf-8") qt_conf.write_text(new_text, encoding="utf-8")
except OSError:
pass
def build_template_context( def build_template_context(
*, *,
@@ -480,7 +501,7 @@ def generate(
ESC = "\x1b" ESC = "\x1b"
return f"{ESC}Ptmux;{seq.replace(ESC, ESC + ESC)}{ESC}\\" return f"{ESC}Ptmux;{seq.replace(ESC, ESC + ESC)}{ESC}\\"
def parse_output_directive(first_line: str) -> Optional[Path]: def parse_output_directive(first_line: str) -> Path | None:
s = first_line.strip() s = first_line.strip()
if not s.startswith("#") or s.startswith("#!"): if not s.startswith("#") or s.startswith("#!"):
return None return None
@@ -492,7 +513,7 @@ def generate(
expanded = os.path.expandvars(os.path.expanduser(target)) expanded = os.path.expandvars(os.path.expanduser(target))
return Path(expanded) return Path(expanded)
def split_directive_and_body(text: str) -> Tuple[Optional[Path], str]: def split_directive_and_body(text: str) -> tuple[Path | None, str]:
lines = text.splitlines(keepends=True) lines = text.splitlines(keepends=True)
if not lines: if not lines:
return None, "" return None, ""
@@ -520,7 +541,9 @@ def generate(
rendered_outputs: list[Path] = [] rendered_outputs: list[Path] = []
for tpl_path in sorted(p for p in templates_dir.rglob("*") if p.is_file()): for tpl_path in sorted(
p for p in templates_dir.rglob("*") if p.is_file()
):
rel = tpl_path.relative_to(templates_dir) rel = tpl_path.relative_to(templates_dir)
if any(part.startswith(".") for part in rel.parts): if any(part.startswith(".") for part in rel.parts):
@@ -538,14 +561,13 @@ def generate(
text = template.render(**context) text = template.render(**context)
except Exception as e: except Exception as e:
raise RuntimeError( raise RuntimeError(
f"Template render failed for '{rel}': {e}") from e f"Template render failed for '{rel}': {e}"
) from e
out_path.write_text(text, encoding="utf-8") out_path.write_text(text, encoding="utf-8")
try: with contextlib.suppress(OSError):
shutil.copymode(tpl_path, out_path) shutil.copymode(tpl_path, out_path)
except OSError:
pass
rendered_outputs.append(out_path) rendered_outputs.append(out_path)
@@ -562,14 +584,16 @@ def generate(
result = QuantizeCelebi(pixel_array, 128) result = QuantizeCelebi(pixel_array, 128)
return Hct.from_int(Score.score(result)[0]) return Hct.from_int(Score.score(result)[0])
def generate_color_scheme(seed: Hct, mode: str, scheme_class) -> dict[str, str]: def generate_color_scheme(
seed: Hct, mode: str, scheme_class
) -> dict[str, str]:
is_dark = mode.lower() == "dark" is_dark = mode.lower() == "dark"
scheme = scheme_class(seed, is_dark, 0.0) scheme = scheme_class(seed, is_dark, 0.0)
color_dict = {} color_dict = {}
for color in vars(MaterialDynamicColors).keys(): for color in vars(MaterialDynamicColors):
color_name = getattr(MaterialDynamicColors, color) color_name = getattr(MaterialDynamicColors, color)
if hasattr(color_name, "get_hct"): if hasattr(color_name, "get_hct"):
color_int = color_name.get_hct(scheme).to_int() color_int = color_name.get_hct(scheme).to_int()
@@ -578,7 +602,7 @@ def generate(
return color_dict return color_dict
def int_to_hex(argb_int): def int_to_hex(argb_int):
return "#{:06X}".format(argb_int & 0xFFFFFF) return f"#{argb_int & 0xFFFFFF:06X}"
try: try:
with CONFIG.open() as f: with CONFIG.open() as f:
@@ -601,8 +625,9 @@ def generate(
(v.accents for v in meta.variants if v.id == p_variant), () (v.accents for v in meta.variants if v.id == p_variant), ()
) )
if accent not in var_accents: if accent not in var_accents:
available = ", ".join( available = (
var_accents) if var_accents else "none" ", ".join(var_accents) if var_accents else "none"
)
raise typer.BadParameter( raise typer.BadParameter(
f"Accent '{accent}' not available for '{p_scheme}:{p_variant}'. Available accents: {available}" f"Accent '{accent}' not available for '{p_scheme}:{p_variant}'. Available accents: {available}"
) )
@@ -612,9 +637,14 @@ def generate(
if p_scheme in schemes: if p_scheme in schemes:
meta = schemes[p_scheme] meta = schemes[p_scheme]
variant = next( variant = next(
(vari for vari in meta.variants if vari.id == p_variant), None (vari for vari in meta.variants if vari.id == p_variant),
None,
) )
if variant and requested_mode not in variant.modes and variant.modes: if (
variant
and requested_mode not in variant.modes
and variant.modes
):
resolved_mode = sorted(variant.modes)[0] resolved_mode = sorted(variant.modes)[0]
palette_obj = get_palette( palette_obj = get_palette(
+3 -2
View File
@@ -1,4 +1,5 @@
import subprocess import subprocess
import typer import typer
args = ["qs", "-c", "zshell"] args = ["qs", "-c", "zshell"]
@@ -8,9 +9,9 @@ app = typer.Typer()
@app.command() @app.command()
def start(): def start():
subprocess.run(args + ["ipc"] + ["call"] + ["picker"] + ["open"], check=True) subprocess.run([*args, "ipc", "call", "picker", "open"], check=True)
@app.command() @app.command()
def start_freeze(): def start_freeze():
subprocess.run(args + ["ipc"] + ["call"] + ["picker"] + ["openFreeze"], check=True) subprocess.run([*args, "ipc", "call", "picker", "openFreeze"], check=True)
+19 -11
View File
@@ -11,7 +11,7 @@ app = typer.Typer()
@app.command() @app.command()
def kill(): def kill():
result = subprocess.run(args + ["kill"], capture_output=True) result = subprocess.run([*args, "kill"], capture_output=True)
if result.returncode != 0: if result.returncode != 0:
sys.stderr.write("No running instance to kill.\n") sys.stderr.write("No running instance to kill.\n")
sys.exit(1) sys.exit(1)
@@ -19,10 +19,11 @@ def kill():
def start_instance(no_daemon: bool = False) -> None: def start_instance(no_daemon: bool = False) -> None:
result = subprocess.run(args + ["-n"] + ([] if no_daemon else ["-d"]), capture_output=True) result = subprocess.run(
args + ["-n"] + ([] if no_daemon else ["-d"]), capture_output=True
)
stdout = result.stdout.decode().strip() stdout = result.stdout.decode().strip()
if stdout: if stdout and "already running" in stdout.lower():
if "already running" in stdout.lower():
sys.stderr.write(stdout + "\n") sys.stderr.write(stdout + "\n")
sys.exit(1) sys.exit(1)
if result.returncode != 0: if result.returncode != 0:
@@ -38,10 +39,10 @@ def start(no_daemon: bool = False):
@app.command() @app.command()
def restart(no_daemon: bool = False): def restart(no_daemon: bool = False):
subprocess.run(args + ["kill"], capture_output=True) subprocess.run([*args, "kill"], capture_output=True)
deadline = time.monotonic() + 2.5 deadline = time.monotonic() + 2.5
while time.monotonic() < deadline: while time.monotonic() < deadline:
result = subprocess.run(args + ["kill"], capture_output=True) result = subprocess.run([*args, "kill"], capture_output=True)
if result.returncode == 255: if result.returncode == 255:
break break
time.sleep(0.25) time.sleep(0.25)
@@ -50,7 +51,7 @@ def restart(no_daemon: bool = False):
@app.command() @app.command()
def show(): def show():
result = subprocess.run(args + ["ipc"] + ["show"], capture_output=True) result = subprocess.run([*args, "ipc", "show"], capture_output=True)
if result.returncode != 0: if result.returncode != 0:
sys.stderr.write(result.stderr.decode()) sys.stderr.write(result.stderr.decode())
sys.exit(1) sys.exit(1)
@@ -60,7 +61,7 @@ def show():
@app.command() @app.command()
def log(): def log():
result = subprocess.run(args + ["log"], capture_output=True) result = subprocess.run([*args, "log"], capture_output=True)
if result.returncode != 0: if result.returncode != 0:
sys.stderr.write(result.stderr.decode()) sys.stderr.write(result.stderr.decode())
sys.exit(1) sys.exit(1)
@@ -70,7 +71,9 @@ def log():
@app.command() @app.command()
def lock(): def lock():
result = subprocess.run(args + ["ipc"] + ["call"] + ["lock"] + ["lock"], capture_output=True) result = subprocess.run(
[*args, "ipc", "call", "lock", "lock"], capture_output=True
)
if result.returncode != 0: if result.returncode != 0:
sys.stderr.write(result.stderr.decode()) sys.stderr.write(result.stderr.decode())
sys.exit(1) sys.exit(1)
@@ -78,8 +81,13 @@ def lock():
@app.command() @app.command()
def call(target: str, method: str, method_args: list[str] = typer.Argument(None)): def call(
result = subprocess.run(args + ["ipc"] + ["call"] + [target] + [method] + (method_args or []), capture_output=True) 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: if result.returncode != 0:
sys.stderr.write(result.stderr.decode()) sys.stderr.write(result.stderr.decode())
sys.exit(1) sys.exit(1)
+8 -5
View File
@@ -1,9 +1,9 @@
import subprocess import subprocess
import typer
from typing import Annotated
from PIL import Image, ImageFilter
from pathlib import Path from pathlib import Path
from typing import Annotated
import typer
from PIL import Image, ImageFilter
args = ["qs", "-c", "zshell"] args = ["qs", "-c", "zshell"]
@@ -12,7 +12,10 @@ app = typer.Typer()
@app.command() @app.command()
def set(wallpaper: Path): 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() @app.command()
+11 -8
View File
@@ -102,21 +102,22 @@ def _discover_schemes() -> dict[str, SchemeMeta]:
SCHEMES: dict[str, SchemeMeta] = _discover_schemes() SCHEMES: dict[str, SchemeMeta] = _discover_schemes()
def get_palette(scheme: str, variant: str, mode: str, accent: str | None = None) -> Palette: def get_palette(
scheme: str, variant: str, mode: str, accent: str | None = None
) -> Palette:
if scheme not in SCHEMES: if scheme not in SCHEMES:
raise KeyError( raise KeyError(
f"Unknown scheme '{scheme}'. Available: {', '.join(SCHEMES)}") f"Unknown scheme '{scheme}'. Available: {', '.join(SCHEMES)}"
)
meta = SCHEMES[scheme] meta = SCHEMES[scheme]
var_ids = {v.id for v in meta.variants} var_ids = {v.id for v in meta.variants}
if variant not in var_ids: if variant not in var_ids:
raise KeyError( raise KeyError(
f"Unknown variant '{variant}' for scheme '{scheme}'. Available: {', '.join(sorted(var_ids))}") f"Unknown variant '{variant}' for scheme '{scheme}'. Available: {', '.join(sorted(var_ids))}"
)
if accent: filename = f"{accent}-{mode}.txt" if accent else f"{mode}.txt"
filename = f"{accent}-{mode}.txt"
else:
filename = f"{mode}.txt"
txt_path = ASSETS / scheme / variant / filename txt_path = ASSETS / scheme / variant / filename
if not txt_path.is_file(): if not txt_path.is_file():
@@ -130,7 +131,9 @@ def get_palette(scheme: str, variant: str, mode: str, accent: str | None = None)
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]: def list_schemes() -> dict[str, SchemeMeta]:
+27 -5
View File
@@ -21,17 +21,39 @@ source = "vcs"
[tool.hatch.build] [tool.hatch.build]
include = [ include = [
"src/zshell/assets/**", "cli/src/zshell/assets/**",
] ]
[tool.hatch.build.targets.wheel]
packages = ["cli/src/zshell"]
[tool.hatch.build.targets.sdist] [tool.hatch.build.targets.sdist]
only-include = [ only-include = [
"src", "cli/src",
] ]
[tool.ruff] [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] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["cli/tests"]
pythonpath = ["src"] pythonpath = ["cli/src"]
+81 -36
View File
@@ -4,22 +4,24 @@ import json
import re import re
import sys import sys
from collections import defaultdict from collections import defaultdict
from functools import lru_cache from functools import cache
from pathlib import Path from pathlib import Path
@lru_cache(maxsize=None) @cache
def read_lines(path: Path) -> tuple[str, ...]: def read_lines(path: Path) -> tuple[str, ...]:
return tuple(path.read_text().splitlines()) return tuple(path.read_text().splitlines())
ROW_RE = re.compile( 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\("([^"]+)"\)') LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)')
ANCHOR_RE = re.compile(r'^\s*settingAnchor:\s*"([^"]+)"') 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( 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*"([^"]+)"') ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
SKIP_LABELS = {"Muted", "None"} SKIP_LABELS = {"Muted", "None"}
FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4} FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4}
@@ -45,8 +47,7 @@ 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( start = next(
i for i, line in enumerate(text) i for i, line in enumerate(text) if re.search(r"\bpages\s*:\s*\[", line)
if re.search(r'\bpages\s*:\s*\[', line)
) )
out: list[tuple[str, str]] = [] out: list[tuple[str, str]] = []
@@ -92,14 +93,16 @@ def parse_page_registry(settings: Path) -> list[tuple[str, str]]:
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: 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]: def parse_block(
lines: list[str], i: int
) -> tuple[str, list[tuple[str, list]], int]:
line = _strip_comment(lines[i]).strip() line = _strip_comment(lines[i]).strip()
m = BLOCK_RE.match(line) m = BLOCK_RE.match(line)
if not m: if not m:
@@ -152,8 +155,9 @@ 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( start = next(
i for i, line in enumerate(text) i
if re.search(r'\bpageComps\s*:\s*\[', _strip_comment(line)) for i, line in enumerate(text)
if re.search(r"\bpageComps\s*:\s*\[", _strip_comment(line))
) )
comps: list[list[str]] = [] comps: list[list[str]] = []
@@ -180,10 +184,12 @@ def parse_page_comps(settings: Path) -> list[list[str]]:
return comps return comps
def dedup_crumbs(labels: list[str], icons: list[str]) -> tuple[list[str], list[str]]: def dedup_crumbs(
labels: list[str], icons: list[str]
) -> tuple[list[str], list[str]]:
out_labels: list[str] = [] out_labels: list[str] = []
out_icons: list[str] = [] out_icons: list[str] = []
for lbl, ico in zip(labels, icons): for lbl, ico in zip(labels, icons, strict=False):
if out_labels and out_labels[-1] == lbl: if out_labels and out_labels[-1] == lbl:
continue continue
out_labels.append(lbl) out_labels.append(lbl)
@@ -227,7 +233,10 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
if mo: if mo:
pos = int(mo.group(1)) pos = int(mo.group(1))
nav_children.setdefault(name, {})[pos] = ( nav_children.setdefault(name, {})[pos] = (
pending_icon or "tune", pending_label or "", section) pending_icon or "tune",
pending_label or "",
section,
)
pending_icon = pending_label = None pending_icon = pending_label = None
nav: dict[str, dict] = {} nav: dict[str, dict] = {}
@@ -236,8 +245,12 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
continue continue
main = names[0] main = names[0]
main_icon, main_label = top_meta.get(top_idx, ("tune", main)) main_icon, main_label = top_meta.get(top_idx, ("tune", main))
nav[main] = {"pageIdx": top_idx, "subPath": [], nav[main] = {
"crumbIcons": [main_icon], "crumbLabels": [main_label]} "pageIdx": top_idx,
"subPath": [],
"crumbIcons": [main_icon],
"crumbLabels": [main_label],
}
children = dict(nav_children.get(main, {})) children = dict(nav_children.get(main, {}))
opened_via_subpage = set() opened_via_subpage = set()
for owner, kids in nav_children.items(): for owner, kids in nav_children.items():
@@ -259,19 +272,26 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
labels = [main_label] + ([section] if section else []) + [label] labels = [main_label] + ([section] if section else []) + [label]
icons = [main_icon] + ([icon] if section else []) + [icon] icons = [main_icon] + ([icon] if section else []) + [icon]
labels, icons = dedup_crumbs(labels, icons) labels, icons = dedup_crumbs(labels, icons)
nav[child] = {"pageIdx": top_idx, "subPath": [pos], nav[child] = {
"pageIdx": top_idx,
"subPath": [pos],
"crumbIcons": icons, "crumbIcons": icons,
"crumbLabels": labels} "crumbLabels": labels,
for gpos, (gicon, glabel, gsection) in nav_children.get(child, {}).items(): }
for gpos, (gicon, glabel, gsection) in nav_children.get(
child, {}
).items():
if gpos >= len(names): if gpos >= len(names):
continue continue
glabels = labels + ([gsection] if gsection else []) + [glabel] glabels = labels + ([gsection] if gsection else []) + [glabel]
gicons = icons + ([gicon] if gsection else []) + [gicon] gicons = icons + ([gicon] if gsection else []) + [gicon]
glabels, gicons = dedup_crumbs(glabels, gicons) glabels, gicons = dedup_crumbs(glabels, gicons)
nav[names[gpos]] = { nav[names[gpos]] = {
"pageIdx": top_idx, "subPath": [pos, gpos], "pageIdx": top_idx,
"subPath": [pos, gpos],
"crumbIcons": gicons, "crumbIcons": gicons,
"crumbLabels": glabels} "crumbLabels": glabels,
}
return nav return nav
@@ -290,10 +310,12 @@ def tokenize(text: str) -> list[str]:
SUBTEXT_RE = re.compile(r'^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)') 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]: def extract_settings(
files: dict[str, Path], nav: dict[str, dict]
) -> list[dict]:
entries: list[dict] = [] entries: list[dict] = []
for comp, meta in nav.items(): for comp, meta in nav.items():
pf = files.get(comp) pf = files.get(comp)
@@ -337,22 +359,35 @@ def extract_settings(files: dict[str, Path], nav: dict[str, dict]) -> list[dict]
toggled_path = tg.group(1) toggled_path = tg.group(1)
toggle_path = ( toggle_path = (
checked_path checked_path
if row_type == "ToggleRow" and checked_path and checked_path == toggled_path if row_type == "ToggleRow"
and checked_path
and checked_path == toggled_path
else "" else ""
) )
if label and label not in SKIP_LABELS and anchor: if label and label not in SKIP_LABELS and anchor:
extra = " ".join(meta["crumbLabels"]) + \ extra = (
" " + section + " " + (subtext or "") " ".join(meta["crumbLabels"])
entries.append({ + " "
"pageIdx": meta["pageIdx"], "subPath": meta["subPath"], + section
+ " "
+ (subtext or "")
)
entries.append(
{
"pageIdx": meta["pageIdx"],
"subPath": meta["subPath"],
"crumbIcons": meta["crumbIcons"], "crumbIcons": meta["crumbIcons"],
"crumbLabels": meta["crumbLabels"], "crumbLabels": meta["crumbLabels"],
"title": label, "anchor": anchor, "title": label,
"anchor": anchor,
"section": section, "section": section,
"subtext": subtext or "", "subtext": subtext or "",
"togglePath": toggle_path, "togglePath": toggle_path,
"keywords": " ".join(sorted(set(tokenize(label + " " + extra)))), "keywords": " ".join(
}) sorted(set(tokenize(label + " " + extra)))
),
}
)
i += 1 i += 1
return entries return entries
@@ -372,7 +407,9 @@ def build_inverted_and_ranking(entries: list[dict]):
seen.add(tok) seen.add(tok)
for tok, ids in inverted.items(): for tok, ids in inverted.items():
ids.sort(key=lambda i: ranking[tok][i], reverse=True) 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()} return inverted, {
t: {str(k): v for k, v in d.items()} for t, d in ranking.items()
}
def main() -> int: def main() -> int:
@@ -387,14 +424,22 @@ def main() -> int:
inverted, ranking = build_inverted_and_ranking(entries) inverted, ranking = build_inverted_and_ranking(entries)
for e in entries: for e in entries:
e.pop("keywords", None) e.pop("keywords", None)
out.write_text(json.dumps({ out.write_text(
json.dumps(
{
"version": 2, "version": 2,
"entries": entries, "entries": entries,
"inverted": inverted, "inverted": inverted,
"ranking": ranking, "ranking": ranking,
}, ensure_ascii=False, indent=2)) },
print(f"settings index: {len(entries)} entries, " ensure_ascii=False,
f"{len(inverted)} tokens -> {out}") indent=2,
)
)
print(
f"settings index: {len(entries)} entries, "
f"{len(inverted)} tokens -> {out}"
)
print("files:", len(files)) print("files:", len(files))
print("comps:", len(parse_page_comps(settings))) print("comps:", len(parse_page_comps(settings)))
print("registry:", len(parse_page_registry(settings))) print("registry:", len(parse_page_registry(settings)))