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
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:
@@ -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)
|
||||
|
||||
@@ -23,9 +25,17 @@ def _completion_installed() -> bool:
|
||||
case "zsh":
|
||||
return (Path.home() / ".zfunc" / "_zshell-cli").exists()
|
||||
case "bash":
|
||||
return (Path.home() / ".bash_completions" / "zshell-cli.sh").exists()
|
||||
return (
|
||||
Path.home() / ".bash_completions" / "zshell-cli.sh"
|
||||
).exists()
|
||||
case "fish":
|
||||
return (Path.home() / ".config" / "fish" / "completions" / "zshell-cli.fish").exists()
|
||||
return (
|
||||
Path.home()
|
||||
/ ".config"
|
||||
/ "fish"
|
||||
/ "completions"
|
||||
/ "zshell-cli.fish"
|
||||
).exists()
|
||||
return False
|
||||
|
||||
|
||||
@@ -40,10 +50,15 @@ def _install_completion() -> None:
|
||||
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.")
|
||||
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)
|
||||
print(
|
||||
f"zshell-cli: Failed to install shell completion: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise typer.Exit(code=1) from None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -53,5 +68,8 @@ def main() -> None:
|
||||
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)
|
||||
print(
|
||||
"zshell-cli: Tip: run with --install-autocomplete for tab completion.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
app()
|
||||
|
||||
@@ -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,7 +18,9 @@ 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]:
|
||||
@@ -32,34 +34,54 @@ def _read_extra_args() -> list[str]:
|
||||
|
||||
|
||||
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"]
|
||||
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
|
||||
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)
|
||||
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]:
|
||||
def _focused_monitor_name() -> str | None:
|
||||
for m in _get_monitors():
|
||||
if m.get("focused"):
|
||||
return m["name"]
|
||||
@@ -71,7 +93,12 @@ def _monitors_intersecting_region(x: int, y: int, w: int, h: int) -> list[dict]:
|
||||
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):
|
||||
if not (
|
||||
region[2] <= mx
|
||||
or region[0] >= mx + mw
|
||||
or region[3] <= my
|
||||
or region[1] >= my + mh
|
||||
):
|
||||
intersecting.append(m)
|
||||
return intersecting
|
||||
|
||||
@@ -80,23 +107,30 @@ def _highest_refresh(monitors: list[dict]) -> float:
|
||||
return max((m["refreshRate"] for m in monitors), default=60.0)
|
||||
|
||||
|
||||
def _slurp_region() -> Optional[str]:
|
||||
def _slurp_region() -> str | None:
|
||||
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:
|
||||
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
|
||||
|
||||
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 (
|
||||
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):
|
||||
def start_recording(region: str | None, sound: bool):
|
||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [RECORDER]
|
||||
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)
|
||||
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:
|
||||
monitor_name = _focused_monitor_name()
|
||||
@@ -137,7 +173,12 @@ def start_recording(region: Optional[str], sound: bool):
|
||||
cmd.extend(extra_args)
|
||||
cmd.extend(["-o", str(TEMP_RECORDING)])
|
||||
|
||||
subprocess.Popen(cmd, start_new_session=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
start_new_session=True,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
notif_id = _notify("Recording started", f"Saving to {TEMP_RECORDING}")
|
||||
if notif_id is not None:
|
||||
@@ -145,12 +186,20 @@ def start_recording(region: Optional[str], sound: bool):
|
||||
|
||||
time.sleep(1)
|
||||
if not _is_recording():
|
||||
_notify("Recording failed", "Check gpu-screen-recorder output.", timeout=5000)
|
||||
_notify(
|
||||
"Recording failed",
|
||||
"Check gpu-screen-recorder output.",
|
||||
timeout=5000,
|
||||
)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
def stop_recording(clipboard: bool):
|
||||
subprocess.run(["pkill", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
subprocess.run(
|
||||
["pkill", "-f", RECORDER],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
for _ in range(50):
|
||||
if not _is_recording():
|
||||
@@ -166,10 +215,8 @@ def stop_recording(clipboard: bool):
|
||||
TEMP_RECORDING.rename(final_path)
|
||||
|
||||
if NOTIF_ID_FILE.is_file():
|
||||
try:
|
||||
with contextlib.suppress(Exception):
|
||||
_close_notification(int(NOTIF_ID_FILE.read_text().strip()))
|
||||
except Exception:
|
||||
pass
|
||||
NOTIF_ID_FILE.unlink()
|
||||
|
||||
if clipboard:
|
||||
@@ -183,21 +230,34 @@ def stop_recording(clipboard: bool):
|
||||
|
||||
|
||||
def toggle_pause():
|
||||
subprocess.run(["pkill", "-USR2", "-f", RECORDER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
subprocess.run(
|
||||
["pkill", "-USR2", "-f", RECORDER],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
typer.echo("Toggled pause.")
|
||||
|
||||
|
||||
@app.command()
|
||||
def record(
|
||||
region: Optional[str] = typer.Option(
|
||||
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."),
|
||||
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:
|
||||
|
||||
@@ -1,26 +1,33 @@
|
||||
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,
|
||||
)
|
||||
from PIL import Image
|
||||
from zshell.utils.schemepalettes import (
|
||||
get_palette,
|
||||
list_schemes,
|
||||
resolve_preset,
|
||||
)
|
||||
|
||||
app = typer.Typer()
|
||||
|
||||
@@ -74,7 +81,8 @@ def _complete_accent(ctx, incomplete):
|
||||
@app.command()
|
||||
def list_presets(
|
||||
json_format: bool = typer.Option(
|
||||
False, "--json", help="Output in JSON format"),
|
||||
False, "--json", help="Output in JSON format"
|
||||
),
|
||||
):
|
||||
schemes = list_schemes()
|
||||
if json_format:
|
||||
@@ -107,25 +115,25 @@ def list_presets(
|
||||
|
||||
@app.command()
|
||||
def generate(
|
||||
image_path: Optional[Path] = typer.Option(
|
||||
image_path: Path | None = typer.Option(
|
||||
None, help="Path to source image. Required for image mode."
|
||||
),
|
||||
scheme: Optional[str] = typer.Option(
|
||||
scheme: str | None = 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(
|
||||
preset: str | None = typer.Option(
|
||||
None,
|
||||
help="Name of a premade scheme in this format: <scheme>:<variant>",
|
||||
autocompletion=_complete_preset,
|
||||
),
|
||||
mode: Optional[str] = typer.Option(
|
||||
mode: str | None = typer.Option(
|
||||
None,
|
||||
help="Mode of the preset scheme (dark or light).",
|
||||
autocompletion=_complete_mode,
|
||||
),
|
||||
accent: Optional[str] = typer.Option(
|
||||
accent: str | None = typer.Option(
|
||||
None,
|
||||
help="Accent for schemes that support it (e.g. mauve).",
|
||||
autocompletion=_complete_accent,
|
||||
@@ -149,20 +157,27 @@ def generate(
|
||||
|
||||
if preset is not None and image_path is not None:
|
||||
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):
|
||||
match scheme_name:
|
||||
case "fruit-salad":
|
||||
from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad
|
||||
from materialyoucolor.scheme.scheme_fruit_salad import (
|
||||
SchemeFruitSalad,
|
||||
)
|
||||
|
||||
return SchemeFruitSalad
|
||||
case "expressive":
|
||||
from materialyoucolor.scheme.scheme_expressive import SchemeExpressive
|
||||
from materialyoucolor.scheme.scheme_expressive import (
|
||||
SchemeExpressive,
|
||||
)
|
||||
|
||||
return SchemeExpressive
|
||||
case "monochrome":
|
||||
from materialyoucolor.scheme.scheme_monochrome import SchemeMonochrome
|
||||
from materialyoucolor.scheme.scheme_monochrome import (
|
||||
SchemeMonochrome,
|
||||
)
|
||||
|
||||
return SchemeMonochrome
|
||||
case "rainbow":
|
||||
@@ -170,7 +185,9 @@ def generate(
|
||||
|
||||
return SchemeRainbow
|
||||
case "tonal-spot":
|
||||
from materialyoucolor.scheme.scheme_tonal_spot import SchemeTonalSpot
|
||||
from materialyoucolor.scheme.scheme_tonal_spot import (
|
||||
SchemeTonalSpot,
|
||||
)
|
||||
|
||||
return SchemeTonalSpot
|
||||
case "neutral":
|
||||
@@ -178,7 +195,9 @@ def generate(
|
||||
|
||||
return SchemeNeutral
|
||||
case "fidelity":
|
||||
from materialyoucolor.scheme.scheme_fidelity import SchemeFidelity
|
||||
from materialyoucolor.scheme.scheme_fidelity import (
|
||||
SchemeFidelity,
|
||||
)
|
||||
|
||||
return SchemeFidelity
|
||||
case "content":
|
||||
@@ -190,7 +209,9 @@ def generate(
|
||||
|
||||
return SchemeVibrant
|
||||
case _:
|
||||
from materialyoucolor.scheme.scheme_fruit_salad import SchemeFruitSalad
|
||||
from materialyoucolor.scheme.scheme_fruit_salad import (
|
||||
SchemeFruitSalad,
|
||||
)
|
||||
|
||||
return SchemeFruitSalad
|
||||
|
||||
@@ -275,8 +296,8 @@ def generate(
|
||||
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)
|
||||
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)
|
||||
@@ -316,7 +337,9 @@ def generate(
|
||||
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:
|
||||
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)
|
||||
|
||||
@@ -331,7 +354,9 @@ def generate(
|
||||
|
||||
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.write_text(sequences, encoding="utf-8")
|
||||
|
||||
@@ -375,7 +400,7 @@ def generate(
|
||||
mode = mode.lower()
|
||||
preference = "prefer-dark" if mode == "dark" else "prefer-light"
|
||||
|
||||
try:
|
||||
with contextlib.suppress(FileNotFoundError):
|
||||
subprocess.run(
|
||||
[
|
||||
"gsettings",
|
||||
@@ -388,8 +413,6 @@ def generate(
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
def apply_qt_mode(mode: str, home: str) -> None:
|
||||
mode = mode.lower()
|
||||
@@ -413,10 +436,8 @@ def generate(
|
||||
)
|
||||
|
||||
if count > 0 and new_text != text:
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
qt_conf.write_text(new_text, encoding="utf-8")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def build_template_context(
|
||||
*,
|
||||
@@ -480,7 +501,7 @@ def generate(
|
||||
ESC = "\x1b"
|
||||
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()
|
||||
if not s.startswith("#") or s.startswith("#!"):
|
||||
return None
|
||||
@@ -492,7 +513,7 @@ def generate(
|
||||
expanded = os.path.expandvars(os.path.expanduser(target))
|
||||
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)
|
||||
if not lines:
|
||||
return None, ""
|
||||
@@ -520,7 +541,9 @@ def generate(
|
||||
|
||||
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)
|
||||
|
||||
if any(part.startswith(".") for part in rel.parts):
|
||||
@@ -538,14 +561,13 @@ def generate(
|
||||
text = template.render(**context)
|
||||
except Exception as e:
|
||||
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")
|
||||
|
||||
try:
|
||||
with contextlib.suppress(OSError):
|
||||
shutil.copymode(tpl_path, out_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
rendered_outputs.append(out_path)
|
||||
|
||||
@@ -562,14 +584,16 @@ def generate(
|
||||
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]:
|
||||
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():
|
||||
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()
|
||||
@@ -578,7 +602,7 @@ def generate(
|
||||
return color_dict
|
||||
|
||||
def int_to_hex(argb_int):
|
||||
return "#{:06X}".format(argb_int & 0xFFFFFF)
|
||||
return f"#{argb_int & 0xFFFFFF:06X}"
|
||||
|
||||
try:
|
||||
with CONFIG.open() as f:
|
||||
@@ -601,8 +625,9 @@ def generate(
|
||||
(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"
|
||||
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}"
|
||||
)
|
||||
@@ -612,9 +637,14 @@ def generate(
|
||||
if p_scheme in schemes:
|
||||
meta = schemes[p_scheme]
|
||||
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]
|
||||
|
||||
palette_obj = get_palette(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -11,7 +11,7 @@ app = typer.Typer()
|
||||
|
||||
@app.command()
|
||||
def kill():
|
||||
result = subprocess.run(args + ["kill"], capture_output=True)
|
||||
result = subprocess.run([*args, "kill"], capture_output=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write("No running instance to kill.\n")
|
||||
sys.exit(1)
|
||||
@@ -19,10 +19,11 @@ def kill():
|
||||
|
||||
|
||||
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()
|
||||
if stdout:
|
||||
if "already running" in stdout.lower():
|
||||
if stdout and "already running" in stdout.lower():
|
||||
sys.stderr.write(stdout + "\n")
|
||||
sys.exit(1)
|
||||
if result.returncode != 0:
|
||||
@@ -38,10 +39,10 @@ def start(no_daemon: bool = False):
|
||||
|
||||
@app.command()
|
||||
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
|
||||
while time.monotonic() < deadline:
|
||||
result = subprocess.run(args + ["kill"], capture_output=True)
|
||||
result = subprocess.run([*args, "kill"], capture_output=True)
|
||||
if result.returncode == 255:
|
||||
break
|
||||
time.sleep(0.25)
|
||||
@@ -50,7 +51,7 @@ def restart(no_daemon: bool = False):
|
||||
|
||||
@app.command()
|
||||
def show():
|
||||
result = subprocess.run(args + ["ipc"] + ["show"], capture_output=True)
|
||||
result = subprocess.run([*args, "ipc", "show"], capture_output=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stderr.decode())
|
||||
sys.exit(1)
|
||||
@@ -60,7 +61,7 @@ def show():
|
||||
|
||||
@app.command()
|
||||
def log():
|
||||
result = subprocess.run(args + ["log"], capture_output=True)
|
||||
result = subprocess.run([*args, "log"], capture_output=True)
|
||||
if result.returncode != 0:
|
||||
sys.stderr.write(result.stderr.decode())
|
||||
sys.exit(1)
|
||||
@@ -70,7 +71,9 @@ def log():
|
||||
|
||||
@app.command()
|
||||
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:
|
||||
sys.stderr.write(result.stderr.decode())
|
||||
sys.exit(1)
|
||||
@@ -78,8 +81,13 @@ def lock():
|
||||
|
||||
|
||||
@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)
|
||||
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)
|
||||
|
||||
@@ -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,7 +12,10 @@ 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()
|
||||
|
||||
@@ -102,21 +102,22 @@ def _discover_schemes() -> dict[str, SchemeMeta]:
|
||||
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:
|
||||
raise KeyError(
|
||||
f"Unknown scheme '{scheme}'. Available: {', '.join(SCHEMES)}")
|
||||
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))}")
|
||||
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():
|
||||
@@ -130,7 +131,9 @@ def get_palette(scheme: str, variant: str, mode: str, accent: str | None = None)
|
||||
|
||||
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]:
|
||||
|
||||
@@ -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"]
|
||||
@@ -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())
|
||||
|
||||
|
||||
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}
|
||||
@@ -45,8 +47,7 @@ def parse_page_registry(settings: Path) -> list[tuple[str, str]]:
|
||||
text = (settings / "PageRegistry.qml").read_text().splitlines()
|
||||
|
||||
start = next(
|
||||
i for i, line in enumerate(text)
|
||||
if re.search(r'\bpages\s*:\s*\[', line)
|
||||
i for i, line in enumerate(text) if re.search(r"\bpages\s*:\s*\[", line)
|
||||
)
|
||||
|
||||
out: list[tuple[str, str]] = []
|
||||
@@ -92,14 +93,16 @@ def parse_page_registry(settings: Path) -> list[tuple[str, str]]:
|
||||
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()
|
||||
|
||||
|
||||
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()
|
||||
m = BLOCK_RE.match(line)
|
||||
if not m:
|
||||
@@ -152,8 +155,9 @@ def parse_page_comps(settings: Path) -> list[list[str]]:
|
||||
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))
|
||||
i
|
||||
for i, line in enumerate(text)
|
||||
if re.search(r"\bpageComps\s*:\s*\[", _strip_comment(line))
|
||||
)
|
||||
|
||||
comps: list[list[str]] = []
|
||||
@@ -180,10 +184,12 @@ def parse_page_comps(settings: Path) -> list[list[str]]:
|
||||
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_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:
|
||||
continue
|
||||
out_labels.append(lbl)
|
||||
@@ -227,7 +233,10 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
|
||||
if mo:
|
||||
pos = int(mo.group(1))
|
||||
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
|
||||
|
||||
nav: dict[str, dict] = {}
|
||||
@@ -236,8 +245,12 @@ def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
|
||||
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]}
|
||||
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():
|
||||
@@ -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]
|
||||
icons = [main_icon] + ([icon] if section else []) + [icon]
|
||||
labels, icons = dedup_crumbs(labels, icons)
|
||||
nav[child] = {"pageIdx": top_idx, "subPath": [pos],
|
||||
nav[child] = {
|
||||
"pageIdx": top_idx,
|
||||
"subPath": [pos],
|
||||
"crumbIcons": icons,
|
||||
"crumbLabels": labels}
|
||||
for gpos, (gicon, glabel, gsection) in nav_children.get(child, {}).items():
|
||||
"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],
|
||||
"pageIdx": top_idx,
|
||||
"subPath": [pos, gpos],
|
||||
"crumbIcons": gicons,
|
||||
"crumbLabels": glabels}
|
||||
"crumbLabels": glabels,
|
||||
}
|
||||
return nav
|
||||
|
||||
|
||||
@@ -290,10 +310,12 @@ def tokenize(text: str) -> list[str]:
|
||||
|
||||
|
||||
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] = []
|
||||
for comp, meta in nav.items():
|
||||
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)
|
||||
toggle_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 ""
|
||||
)
|
||||
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"],
|
||||
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,
|
||||
"title": label,
|
||||
"anchor": anchor,
|
||||
"section": section,
|
||||
"subtext": subtext or "",
|
||||
"togglePath": toggle_path,
|
||||
"keywords": " ".join(sorted(set(tokenize(label + " " + extra)))),
|
||||
})
|
||||
"keywords": " ".join(
|
||||
sorted(set(tokenize(label + " " + extra)))
|
||||
),
|
||||
}
|
||||
)
|
||||
i += 1
|
||||
return entries
|
||||
|
||||
@@ -372,7 +407,9 @@ def build_inverted_and_ranking(entries: list[dict]):
|
||||
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()}
|
||||
return inverted, {
|
||||
t: {str(k): v for k, v in d.items()} for t, d in ranking.items()
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -387,14 +424,22 @@ def main() -> int:
|
||||
inverted, ranking = build_inverted_and_ranking(entries)
|
||||
for e in entries:
|
||||
e.pop("keywords", None)
|
||||
out.write_text(json.dumps({
|
||||
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}")
|
||||
},
|
||||
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)))
|
||||
|
||||
Reference in New Issue
Block a user