diff --git a/CMakeLists.txt b/CMakeLists.txt index 1681ef4..1d3932b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -51,7 +51,9 @@ add_compile_options( -Wunreachable-code ) + if("shell" IN_LIST ENABLE_MODULES) + # Build settings index find_package(Python3 COMPONENTS Interpreter REQUIRED) set(SETTINGS_INDEX_JSON "${CMAKE_BINARY_DIR}/settings-index.json") execute_process( @@ -64,6 +66,49 @@ if("shell" IN_LIST ENABLE_MODULES) if(NOT SETTINGS_INDEX_RESULT EQUAL 0) message(FATAL_ERROR "Failed to build settings search index") endif() + + # Nuitka compilation + set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli") + set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist") + set(ZSHELL_CLI_SRC "${CMAKE_SOURCE_DIR}/cli/src/zshell") + + find_program(NUITKA_EXECUTABLE nuitka REQUIRED) + + file(GLOB_RECURSE ZSHELL_CLI_SOURCES CONFIGURE_DEPENDS + "${ZSHELL_CLI_SRC}/*.py" + ) + file(GLOB_RECURSE ZSHELL_CLI_ASSETS CONFIGURE_DEPENDS + "${ZSHELL_CLI_SRC}/assets/*" + ) + + add_custom_command( + OUTPUT "${ZSHELL_CLI_DIST}/zshell-cli" + COMMAND ${CMAKE_COMMAND} -E make_directory "${ZSHELL_CLI_BUILD_DIR}" + COMMAND ${CMAKE_COMMAND} -E rm -rf "${ZSHELL_CLI_DIST}" + + COMMAND + ${NUITKA_EXECUTABLE} + --standalone + --include-data-dir=${CMAKE_SOURCE_DIR}/cli/src/zshell/assets=zshell/assets + --output-dir=${ZSHELL_CLI_BUILD_DIR} + --output-filename=zshell-cli + ${CMAKE_SOURCE_DIR}/cli/src/zshell/ + + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}/cli + DEPENDS ${ZSHELL_CLI_SOURCES} ${ZSHELL_CLI_ASSETS} + ) + + add_custom_target(zshell-cli ALL DEPENDS "${ZSHELL_CLI_DIST}/zshell-cli") + + install(PROGRAMS "${ZSHELL_CLI_DIST}/zshell-cli" DESTINATION "${INSTALL_LIBDIR}/zshell-cli") + install(DIRECTORY "${ZSHELL_CLI_DIST}/" DESTINATION "${INSTALL_LIBDIR}/zshell-cli" PATTERN "zshell-cli" EXCLUDE) + + configure_file( + "${CMAKE_SOURCE_DIR}/Plugins/cmake/zshell-cli.cmake.in" + "${CMAKE_BINARY_DIR}/Plugins/cmake/zshell-cli.cmake" + @ONLY + ) + install(SCRIPT "${CMAKE_BINARY_DIR}/Plugins/cmake/zshell-cli.cmake") endif() if("plugin" IN_LIST ENABLE_MODULES) diff --git a/Plugins/cmake/zshell-cli.cmake.in b/Plugins/cmake/zshell-cli.cmake.in new file mode 100644 index 0000000..3704716 --- /dev/null +++ b/Plugins/cmake/zshell-cli.cmake.in @@ -0,0 +1,12 @@ +set(ZSHELL_CLI_BIN_DIR "$ENV{DESTDIR}${CMAKE_INSTALL_PREFIX}/usr/bin") +file(MAKE_DIRECTORY "${ZSHELL_CLI_BIN_DIR}") + +file(RELATIVE_PATH ZSHELL_CLI_TARGET + "${ZSHELL_CLI_BIN_DIR}" + "${CMAKE_INSTALL_PREFIX}/@INSTALL_LIBDIR@/zshell-cli/zshell-cli" +) + +file(CREATE_LINK + "${ZSHELL_CLI_TARGET}" + "${ZSHELL_CLI_BIN_DIR}/zshell-cli" SYMBOLIC +) diff --git a/cli/bin/zshell b/cli/bin/zshell deleted file mode 100755 index cf03348..0000000 --- a/cli/bin/zshell +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env bash - -cd "$(dirname $0)/../src" || exit - -python3 -m zshell "$@" diff --git a/cli/pyproject.toml b/cli/pyproject.toml index ae332cc..990ef54 100644 --- a/cli/pyproject.toml +++ b/cli/pyproject.toml @@ -19,6 +19,11 @@ zshell-cli = "zshell:main" [tool.hatch.version] source = "vcs" +[tool.hatch.build] +include = [ + "src/zshell/assets/**", +] + [tool.hatch.build.targets.sdist] only-include = [ "src", diff --git a/cli/src/zshell/__main__.py b/cli/src/zshell/__main__.py index 868d99e..1520f59 100644 --- a/cli/src/zshell/__main__.py +++ b/cli/src/zshell/__main__.py @@ -1,4 +1,4 @@ -from . import main +from zshell import main if __name__ == "__main__": main() diff --git a/cli/src/zshell/subcommands/scheme.py b/cli/src/zshell/subcommands/scheme.py index 70343e5..9792a85 100644 --- a/cli/src/zshell/subcommands/scheme.py +++ b/cli/src/zshell/subcommands/scheme.py @@ -73,7 +73,8 @@ def _complete_accent(ctx, incomplete): @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: @@ -139,7 +140,7 @@ def generate( HOME = str(os.getenv("HOME")) OUTPUT = Path(HOME + "/.local/state/zshell/scheme.json") SEQ_STATE = Path(HOME + "/.local/state/zshell/sequences.txt") - THUMB_PATH = Path(HOME + "/.cache/zshell/imagecache/thumbnail.jpg") + 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") @@ -147,7 +148,8 @@ def generate( 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.") + raise typer.BadParameter( + "Use either --image-path or --preset, not both.") def get_scheme_class(scheme_name: str): match scheme_name: @@ -273,7 +275,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) @@ -307,15 +310,26 @@ def generate( return out - def generate_thumbnail(image_path, thumbnail_path, size=(128, 128)): - thumbnail_file = Path(thumbnail_path) + 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") - thumbnail_file.parent.mkdir(parents=True, exist_ok=True) - image.save(thumbnail_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) @@ -523,7 +537,8 @@ def generate( template = env.from_string(body) text = template.render(**context) except Exception as e: - raise RuntimeError(f"Template render failed for '{rel}': {e}") from e + raise RuntimeError( + f"Template render failed for '{rel}': {e}") from e out_path.write_text(text, encoding="utf-8") @@ -586,7 +601,8 @@ 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}" ) @@ -623,13 +639,13 @@ def generate( seed = hex_to_hct(colors.get("primary", "#000000").lstrip("#")) else: image_path = image_path or Path(WALL_PATH) - generate_thumbnail(image_path, str(THUMB_PATH)) - seed = seed_from_image(THUMB_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) + effective_mode = smart_mode(thumb_path) elif mode is not None: effective_mode = mode else: @@ -675,7 +691,9 @@ def generate( print(f"rendered: {p}") OUTPUT.parent.mkdir(parents=True, exist_ok=True) - with open(OUTPUT, "w") as f: + 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/utils/schemepalettes.py b/cli/src/zshell/utils/schemepalettes.py index 0fc136c..09d5f28 100644 --- a/cli/src/zshell/utils/schemepalettes.py +++ b/cli/src/zshell/utils/schemepalettes.py @@ -1,9 +1,11 @@ from __future__ import annotations from dataclasses import dataclass -from pathlib import Path +from importlib.resources import files +from importlib.resources.abc import Traversable +from pathlib import PurePosixPath -ASSETS = Path(__file__).resolve().parent.parent / "assets" / "schemes" +ASSETS: Traversable = files("zshell") / "assets" / "schemes" @dataclass(frozen=True) @@ -30,7 +32,7 @@ class Palette: accent: str | None = None -def _parse_txt(path: Path) -> dict[str, str]: +def _parse_txt(path: Traversable) -> dict[str, str]: colors: dict[str, str] = {} for line in path.read_text().splitlines(): line = line.strip() @@ -46,7 +48,7 @@ def _parse_txt(path: Path) -> dict[str, str]: def _discover_schemes() -> dict[str, SchemeMeta]: schemes: dict[str, SchemeMeta] = {} - for scheme_dir in sorted(ASSETS.iterdir()): + for scheme_dir in sorted(ASSETS.iterdir(), key=lambda p: p.name): if not scheme_dir.is_dir() or scheme_dir.name.startswith("."): continue @@ -54,7 +56,7 @@ def _discover_schemes() -> dict[str, SchemeMeta]: display_name = sid.capitalize() variants: list[SchemeVariant] = [] - for var_dir in sorted(scheme_dir.iterdir()): + 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 @@ -62,9 +64,10 @@ def _discover_schemes() -> dict[str, SchemeMeta]: accents: set[str] = set() for f in var_dir.iterdir(): - if f.suffix != ".txt": + name = PurePosixPath(f.name) + if name.suffix != ".txt": continue - stem = f.stem + stem = name.stem if "-" in stem: maybe_accent, maybe_mode = stem.rsplit("-", 1) if maybe_mode in ("dark", "light"): @@ -101,12 +104,14 @@ 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)}") + 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))}") + raise KeyError( + f"Unknown variant '{variant}' for scheme '{scheme}'. Available: {', '.join(sorted(var_ids))}") if accent: filename = f"{accent}-{mode}.txt" @@ -114,10 +119,10 @@ def get_palette(scheme: str, variant: str, mode: str, accent: str | None = None) filename = f"{mode}.txt" txt_path = ASSETS / scheme / variant / filename - if not txt_path.exists(): + if not txt_path.is_file(): txt_path = ASSETS / scheme / variant / f"{mode}.txt" - if not txt_path.exists(): + 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)}"