From 67cce88f3ffb3012574d2dd1b61fd6e1627bb3a6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 16:25:38 +0200 Subject: [PATCH 1/6] Merged: sampletones_core.paths into a sampletones_shared.paths --- .github/workflows/workflow.yml | 5 +- .gitignore | 3 + Makefile | 7 +- docs/development/dependencies.md | 9 + pyproject.toml | 1 + scripts/assets/icons.py | 359 ++++++++++++++++++ scripts/calibration.py | 2 +- scripts/checks/language_keys.py | 2 +- scripts/checks/palette_colors.py | 2 +- scripts/checks/unused_tags.py | 2 +- scripts/linux/build/build.sh | 1 + scripts/linux/build/icons.sh | 12 + scripts/linux/build/sampletones.sh | 2 +- scripts/windows/build/build.bat | 1 + scripts/windows/build/icons.bat | 14 + scripts/windows/build/sampletones.bat | 2 +- src/sampletones/__main__.py | 4 +- src/sampletones_application/application.py | 2 +- .../config/managers/config.py | 2 +- src/sampletones_application/config/profile.py | 2 +- .../config/session/state/paths.py | 2 +- .../coordinators/config.py | 2 +- .../coordinators/project.py | 2 +- .../coordinators/reconstruction.py | 2 +- .../coordinators/tabs/reconstruction.py | 2 +- .../logic/instruction/library_manager.py | 2 +- .../logic/main/explorer_manager.py | 10 +- .../logic/reconstruction/browser/tree/scan.py | 2 +- .../logic/shared/tree.py | 8 +- src/sampletones_application/paths.py | 4 +- .../ui/elements/tree/tree.py | 8 +- .../ui/panels/main/explorer.py | 24 +- .../ui/resources/items.py | 2 +- .../ui/resources/resources.py | 2 +- .../ui/themes/loader.py | 2 +- .../utils/gui/shortcuts/catalog.py | 2 +- .../utils/palette/catalog.py | 2 +- src/sampletones_assets/icons/sampletones.ico | Bin 57991 -> 0 bytes src/sampletones_assets/icons/sampletones.png | Bin 30500 -> 0 bytes src/sampletones_assets/icons/sampletones.svg | 12 + .../audio/writers/capability.py | 2 +- .../calibration/corpus/writer.py | 2 +- src/sampletones_core/calibration/paths.py | 2 +- src/sampletones_core/configs/config.py | 2 +- src/sampletones_core/configs/general.py | 2 +- .../library/filename/fields.py | 2 +- .../library/filename/utils.py | 2 +- src/sampletones_core/library/library.py | 2 +- src/sampletones_core/paths.py | 66 ---- src/sampletones_core/project/container.py | 2 +- .../reconstructions/converter/paths/utils.py | 8 +- .../trackers/implementation/bitphase.py | 2 +- .../trackers/implementation/famitracker.py | 2 +- .../meta/source/packages.py | 2 +- src/sampletones_shared/paths.py | 12 - src/sampletones_shared/paths/__init__.py | 0 src/sampletones_shared/paths/extensions.py | 24 ++ src/sampletones_shared/paths/resources.py | 24 ++ src/sampletones_shared/paths/source.py | 5 + src/sampletones_shared/paths/user.py | 23 ++ .../tooling/test_check_commands.py | 2 +- tests/suite/scripts.py | 2 +- .../config/test_profile.py | 2 +- .../logic/reconstruction/browser/conftest.py | 2 +- .../reconstruction/test_reconstruction.py | 8 +- .../logic/shared/test_tree.py | 6 +- .../ui/elements/tree/test_detail_items.py | 2 +- .../utils/gui/shortcuts/test_catalog.py | 2 +- .../utils/gui/shortcuts/test_scheme.py | 2 +- .../audio/writers/test_spec.py | 2 +- .../formats/bitphase/test_btp.py | 2 +- .../formats/bitphase/test_preset.py | 2 +- .../library/filename/test_fields.py | 2 +- .../converter/paths/test_utils.py | 2 +- .../trackers/test_bitphase.py | 2 +- .../trackers/test_extensions.py | 12 +- .../trackers/test_famitracker.py | 2 +- .../meta/source/test_packages.py | 2 +- .../unit/sampletones_shared/paths/__init__.py | 0 .../paths/test_resources.py | 7 + .../{test_paths.py => paths/test_source.py} | 10 +- .../sampletones_shared/paths/test_user.py | 16 + .../scripts/checks/test_palette_colors.py | 2 +- tests/unit/scripts/checks/test_tag_names.py | 2 +- tests/unit/scripts/checks/test_unused_tags.py | 2 +- uv.lock | 75 ++++ 86 files changed, 695 insertions(+), 185 deletions(-) create mode 100755 scripts/assets/icons.py create mode 100644 scripts/linux/build/icons.sh create mode 100644 scripts/windows/build/icons.bat delete mode 100644 src/sampletones_assets/icons/sampletones.ico delete mode 100644 src/sampletones_assets/icons/sampletones.png create mode 100644 src/sampletones_assets/icons/sampletones.svg delete mode 100644 src/sampletones_core/paths.py delete mode 100644 src/sampletones_shared/paths.py create mode 100644 src/sampletones_shared/paths/__init__.py create mode 100644 src/sampletones_shared/paths/extensions.py create mode 100644 src/sampletones_shared/paths/resources.py create mode 100644 src/sampletones_shared/paths/source.py create mode 100644 src/sampletones_shared/paths/user.py create mode 100644 tests/unit/sampletones_shared/paths/__init__.py create mode 100644 tests/unit/sampletones_shared/paths/test_resources.py rename tests/unit/sampletones_shared/{test_paths.py => paths/test_source.py} (63%) create mode 100644 tests/unit/sampletones_shared/paths/test_user.py diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 3cdb1383..2e5e7fb8 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -37,6 +37,9 @@ jobs: --tag "$GITHUB_REF_NAME" \ --project-version "$(uv version --short)" + - name: Generate the icon suite + run: uv run --only-group assets python scripts/assets/icons.py + - name: Build sdist and wheel run: uv build @@ -126,7 +129,7 @@ jobs: venv_python=.venv-build/bin/python fi "$venv_python" -m pip install --upgrade pip - "$venv_python" -m pip install ".[build]" + "$venv_python" -m pip install ".[build]" --group assets - name: Build the bundle (Linux) if: runner.os == 'Linux' diff --git a/.gitignore b/.gitignore index 88d53808..02a45caf 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ sampletones !src/sampletones !tests/sampletones +src/sampletones_assets/icons/sampletones.ico +src/sampletones_assets/icons/sampletones.png + **/*.idea **/*.vscode/** **/*.ipynb_checkpoints/** diff --git a/Makefile b/Makefile index 9997def7..7ab6b93d 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help setup install build release system-deps run clean pre-commit test \ - ftm-samples check-import-boundary check-tag-names check-unused-tags \ + ftm-samples icons check-import-boundary check-tag-names check-unused-tags \ check-language-keys check-palette-colors calibration lint pylint mypy format ifeq ($(OS),Windows_NT) @@ -70,6 +70,7 @@ help: @echo $(Q) make release - Compile standalone executable with the release deployment config$(Q) @echo $(Q) make test - Run unit tests with coverage$(Q) @echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q) + @echo $(Q) make icons - Generate the icon suite into src/sampletones_assets/icons$(Q) @echo $(Q) make calibration - Score the reconstruction corpus; the report lands in Documents/SampleToNES/calibration$(Q) @echo $(Q) make clean - Remove build artifacts and cache files$(Q) @echo $(Q) make lint - Run linting (pylint, mypy)$(Q) @@ -78,6 +79,7 @@ help: setup: $(SETUP_ENV) uv sync --group dev $(if $(GPU_EXTRA),--extra $(GPU_EXTRA),) + $(MAKE) icons $(SETUP_ENV) uv tool install --force $(if $(GPU_EXTRA),".[$(GPU_EXTRA)]",.) install: @@ -109,6 +111,9 @@ ftm-samples: export SAMPLETONES_FTM_OUTPUT_DIR := build/ftm ftm-samples: uv run python -m pytest tests/integration/famitracker +icons: + uv run --group assets python scripts/assets/icons.py + check-import-boundary: uv run scripts/checks/import_boundary.py --all diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index 7d553cb0..1107c943 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -51,6 +51,15 @@ Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser `jeepney` is declared for Linux alone, so the modules that speak to the portal are imported where it is installed: the application probes for it before reaching them, and the root `conftest.py` keeps them out of collection elsewhere, leaving the Linux runs of the suite to cover them. +## Application icon + +The icon suite in `src/sampletones_assets/icons` is generated: `scripts/assets/icons.py` holds the +mark's geometry and writes the vector `sampletones.svg` together with the rasters the application +ships, `sampletones.png` and the multi-resolution `sampletones.ico`. Rasterization uses Pillow, +declared in the `assets` dependency group. The SVG is committed as the design source, and the +rasters are produced where they are consumed: `make setup` writes them before packaging the wheel, +and the bundle scripts write them before PyInstaller embeds them. + ## Linux (standalone executable) Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. diff --git a/pyproject.toml b/pyproject.toml index 99ca6af6..d5d30602 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ gpu-cuda11 = [ ] [dependency-groups] +assets = ["pillow>=11,<13"] dev = [ "black==26.5.1", "isort==8.0.1", diff --git a/scripts/assets/icons.py b/scripts/assets/icons.py new file mode 100755 index 00000000..65a99a35 --- /dev/null +++ b/scripts/assets/icons.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 + +""" +Builds the application icon suite into `src/sampletones_assets/icons`. + +One geometry definition on a 64-unit grid draws the mark — a smooth sample entering as a +blue sine wave and leaving as an amber square wave, on the studio palette — and every +shipped icon derives from it: the vector `sampletones.svg`, the raster `sampletones.png`, +and the multi-resolution `sampletones.ico`. The raster filenames match the resources the +application resolves through `sampletones_shared/paths`. + +Usage: + python scripts/assets/icons.py # write the suite into src/sampletones_assets/icons +""" + +import argparse +import itertools +import sys +from pathlib import Path +from typing import Final, List, Sequence, Tuple + +from PIL import ( # TODO: update THIRD-PARTY-* files, revise LICENSE if still holds + Image, + ImageDraw, +) + +Point = Tuple[float, float] +Rectangle = Tuple[float, float, float, float] + +PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2] +ICONS_DIRECTORY: Final[Path] = PROJECT_ROOT / "src" / "sampletones_assets" / "icons" + +# TODO: take the raster filenames from sampletones_shared.paths.resources +VECTOR_FILENAME: Final[str] = "sampletones.svg" +UNIX_ICON_FILENAME: Final[str] = "sampletones.png" +WINDOWS_ICON_FILENAME: Final[str] = "sampletones.ico" + +# TODO: SVG configuration should be a YAML file based on a validated Pydantic class +# not a set hardcoded constants; I suggest a nested structure, organizing fields into +# logical units +GRID: Final[int] = 64 +CORNER_RADIUS: Final[float] = 14.0 +RIM_INSET: Final[float] = 1.0 +RIM_WIDTH: Final[float] = 2.0 +RIM_OPACITY: Final[float] = 0.14 +WAVE_WIDTH: Final[float] = 4.0 + +BACKGROUND_TOP: Final[str] = "#3a3650" +BACKGROUND_BOTTOM: Final[str] = "#211d30" +SINE_COLOR: Final[str] = "#64c8ff" +SQUARE_COLOR: Final[str] = "#ffc864" +RIM_COLOR: Final[str] = "#cdb6ff" + +SINE_START: Final[Point] = (8.0, 32.0) +SINE_CURVES: Final[Tuple[Tuple[Point, Point, Point], ...]] = ( + ((11.0, 16.0), (15.0, 16.0), (18.0, 32.0)), + ((21.0, 48.0), (25.0, 48.0), (28.0, 32.0)), +) +SQUARE_POINTS: Final[Tuple[Point, ...]] = ( + (28.0, 32.0), + (28.0, 20.0), + (38.0, 20.0), + (38.0, 44.0), + (48.0, 44.0), + (48.0, 20.0), + (56.0, 20.0), + (56.0, 32.0), +) + +SUPERSAMPLE: Final[int] = 16 +CURVE_SAMPLES: Final[int] = 96 +RASTER_SIZE: Final[int] = 256 +ICO_SIZES: Final[Tuple[int, ...]] = (256, 128, 64, 48, 32, 24, 16) + + +def _grid_number(value: float) -> str: + return f"{value:g}" + + +def _sine_path() -> str: + commands = [f"M{_grid_number(SINE_START[0])} {_grid_number(SINE_START[1])}"] + for curve in SINE_CURVES: + points = " ".join(f"{_grid_number(x)} {_grid_number(y)}" for x, y in curve) + commands.append(f"C{points}") + + return " ".join(commands) + + +def _square_path() -> str: + start_x, start_y = SQUARE_POINTS[0] + commands = [f"M{_grid_number(start_x)} {_grid_number(start_y)}"] + for (previous_x, _), (x, y) in itertools.pairwise(SQUARE_POINTS): + commands.append(f"V{_grid_number(y)}" if x == previous_x else f"H{_grid_number(x)}") + + return " ".join(commands) + + +# TODO: refactor - this should be a proper template as an asset, not hardcoded +def svg_document() -> str: + """The mark as a standalone vector, with coordinates on the even design grid. + + Grid alignment keeps the wave edges on whole pixels when the icon is rasterized + at 32 px and 16 px. + """ + rim_extent = _grid_number(GRID - 2 * RIM_INSET) + return ( + f'\n' + " \n" + ' \n' + f' \n' + f' \n' + " \n" + " \n" + f' \n' + f' \n' + f' \n' + f' \n' + "\n" + ) + + +def _background(canvas: int) -> Image.Image: + top = Image.new("RGB", (canvas, canvas), BACKGROUND_TOP) + bottom = Image.new("RGB", (canvas, canvas), BACKGROUND_BOTTOM) + blend = Image.linear_gradient("L").resize((canvas, canvas)) + shaded = Image.composite(bottom, top, blend) + + mask = Image.new("L", (canvas, canvas), 0) + ImageDraw.Draw(mask).rounded_rectangle( + (0, 0, canvas - 1, canvas - 1), + radius=CORNER_RADIUS * SUPERSAMPLE, + fill=255, + ) + + background = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) + background.paste(shaded, mask=mask) + return background + + +def _cubic_coordinate( + start: float, + control_one: float, + control_two: float, + end: float, + progress: float, +) -> float: + remainder = 1.0 - progress + return ( + remainder**3 * start + + 3 * remainder**2 * progress * control_one + + 3 * remainder * progress**2 * control_two + + progress**3 * end + ) + + +def _sine_points() -> List[Point]: + points: List[Point] = [SINE_START] + position = SINE_START + for control_one, control_two, end in SINE_CURVES: + for step in range(1, CURVE_SAMPLES + 1): + progress = step / CURVE_SAMPLES + points.append( + ( + _cubic_coordinate( + position[0], + control_one[0], + control_two[0], + end[0], + progress, + ), + _cubic_coordinate( + position[1], + control_one[1], + control_two[1], + end[1], + progress, + ), + ) + ) + position = end + + return points + + +def _draw_sine(draw: ImageDraw.ImageDraw) -> None: + """Sweeps a disk of the stroke's half width along the curve. + + The union of densely stamped disks equals a round-capped stroke of the curve and + keeps the outline smooth, where a single wide polyline call serrates its edges. + """ + radius = WAVE_WIDTH * SUPERSAMPLE / 2 + for x, y in _sine_points(): + center_x, center_y = x * SUPERSAMPLE, y * SUPERSAMPLE + draw.ellipse( + ( + center_x - radius, + center_y - radius, + center_x + radius, + center_y + radius, + ), + fill=SINE_COLOR, + ) + + +def _direction(delta: float) -> float: + if delta > 0: + return 1.0 + + if delta < 0: + return -1.0 + + return 0.0 + + +def _segment_rectangle( + start: Point, + end: Point, + *, + half_width: float, + joined_start: bool, + joined_end: bool, +) -> Rectangle: + """The stroke rectangle of one axis-aligned segment. + + A joined end reaches half the stroke width past its corner, so consecutive + rectangles fill their right-angle miter; an open end keeps a butt cap. + """ + direction_x = _direction(end[0] - start[0]) + direction_y = _direction(end[1] - start[1]) + start_reach = half_width if joined_start else 0.0 + end_reach = half_width if joined_end else 0.0 + + reached_start = ( + start[0] - direction_x * start_reach, + start[1] - direction_y * start_reach, + ) + reached_end = ( + end[0] + direction_x * end_reach, + end[1] + direction_y * end_reach, + ) + across_x = half_width * abs(direction_y) + across_y = half_width * abs(direction_x) + + return ( + min(reached_start[0], reached_end[0]) - across_x, + min(reached_start[1], reached_end[1]) - across_y, + max(reached_start[0], reached_end[0]) + across_x, + max(reached_start[1], reached_end[1]) + across_y, + ) + + +def _square_rectangles() -> List[Rectangle]: + final_segment = len(SQUARE_POINTS) - 2 + return [ + _segment_rectangle( + SQUARE_POINTS[index], + SQUARE_POINTS[index + 1], + half_width=WAVE_WIDTH / 2, + joined_start=index > 0, + joined_end=index < final_segment, + ) + for index in range(len(SQUARE_POINTS) - 1) + ] + + +def _draw_square(draw: ImageDraw.ImageDraw) -> None: + for left, top, right, bottom in _square_rectangles(): + draw.rectangle( + ( + round(left * SUPERSAMPLE), + round(top * SUPERSAMPLE), + round(right * SUPERSAMPLE) - 1, + round(bottom * SUPERSAMPLE) - 1, + ), + fill=SQUARE_COLOR, + ) + + +def _rgba(color: str, opacity: float) -> Tuple[int, int, int, int]: + red, green, blue = (int(color[start : start + 2], 16) for start in (1, 3, 5)) + return red, green, blue, round(opacity * 255) + + +def _rim_overlay(canvas: int) -> Image.Image: + overlay = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) + ImageDraw.Draw(overlay).rounded_rectangle( + (0, 0, canvas - 1, canvas - 1), + radius=CORNER_RADIUS * SUPERSAMPLE, + outline=_rgba(RIM_COLOR, RIM_OPACITY), + width=round(RIM_WIDTH * SUPERSAMPLE), + ) + return overlay + + +def render_master() -> Image.Image: + """The mark rasterized at a supersampled resolution, ready to scale down to each shipped size.""" + canvas = GRID * SUPERSAMPLE + image = _background(canvas) + draw = ImageDraw.Draw(image) + _draw_sine(draw) + _draw_square(draw) + image.alpha_composite(_rim_overlay(canvas)) + return image + + +def write_suite(directory: Path) -> List[Path]: + """Writes the vector, the raster, and the Windows icon into the directory.""" + directory.mkdir(parents=True, exist_ok=True) + master = render_master() + renders = {size: master.resize((size, size), Image.Resampling.LANCZOS) for size in ICO_SIZES} + + vector_path = directory / VECTOR_FILENAME + vector_path.write_text(svg_document(), encoding="utf-8") + + raster_path = directory / UNIX_ICON_FILENAME + renders[RASTER_SIZE].save(raster_path) + + windows_path = directory / WINDOWS_ICON_FILENAME + primary, *appended = (renders[size] for size in ICO_SIZES) + primary.save( + windows_path, + format="ICO", + sizes=[(size, size) for size in ICO_SIZES], + append_images=appended, + ) + + return [vector_path, raster_path, windows_path] + + +# TODO: this file should be only a thin layer, the rest of the code +# should belong to sampletones_assets +# Read guidelines and architecture docs, follow the current code philosophy +def main(argv: Sequence[str]) -> int: + """Writes the icon suite and reports each file it produced.""" + + parser = argparse.ArgumentParser( + description="Build the application icon suite from the mark's geometry.", + ) + parser.add_argument( + "--directory", + type=Path, + default=ICONS_DIRECTORY, + help="directory receiving the icon files", + ) + arguments = parser.parse_args(list(argv)) + + for path in write_suite(arguments.directory): + print(f"Wrote {path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/calibration.py b/scripts/calibration.py index 870735ea..c129d180 100644 --- a/scripts/calibration.py +++ b/scripts/calibration.py @@ -15,8 +15,8 @@ GeneratorName, SpectrumMethod, ) -from sampletones_core.paths import USER_PATH_DOCUMENTS from sampletones_shared.logger import logger +from sampletones_shared.paths.user import USER_PATH_DOCUMENTS DEFAULT_OUTPUT_ROOT: Final[Path] = USER_PATH_DOCUMENTS / "calibration" DEFAULT_METHODS: Final[str] = f"{SpectrumMethod.FFT.value},{SpectrumMethod.CQT.value}" diff --git a/scripts/checks/language_keys.py b/scripts/checks/language_keys.py index 89a5f9e6..d6a55601 100755 --- a/scripts/checks/language_keys.py +++ b/scripts/checks/language_keys.py @@ -39,7 +39,7 @@ from sampletones_shared.meta.source.modules import discover_modules, module_name from sampletones_shared.meta.source.packages import package_directory from sampletones_shared.meta.source.values import EnumMembers, EnumTable -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT EnumPredicate = Callable[[object], bool] diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py index 32f6f8c5..002c9bf8 100755 --- a/scripts/checks/palette_colors.py +++ b/scripts/checks/palette_colors.py @@ -28,7 +28,7 @@ from sampletones_shared.meta.source.modules import SourceModule, discover_modules from sampletones_shared.meta.source.nodes import terminal_name from sampletones_shared.meta.source.packages import package_directory -from sampletones_shared.paths import CONFIG_DIRECTORY +from sampletones_shared.paths.resources import CONFIG_DIRECTORY APPLICATION_PACKAGE: Final[Path] = package_directory("sampletones_application") diff --git a/scripts/checks/unused_tags.py b/scripts/checks/unused_tags.py index 14931ae4..c2a87b5c 100755 --- a/scripts/checks/unused_tags.py +++ b/scripts/checks/unused_tags.py @@ -22,7 +22,7 @@ from sampletones_shared.meta.source.modules import SourceModule, discover_modules from sampletones_shared.meta.source.packages import package_directory from sampletones_shared.meta.source.references import count_identifier_loads -from sampletones_shared.paths import REPOSITORY_ROOT, SOURCE_ROOT +from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT TAGS_PACKAGE: Final[Path] = package_directory("sampletones_application", "tags") REFERENCE_ROOTS: Final[Tuple[Path, ...]] = ( diff --git a/scripts/linux/build/build.sh b/scripts/linux/build/build.sh index 75629a66..1271b202 100755 --- a/scripts/linux/build/build.sh +++ b/scripts/linux/build/build.sh @@ -28,6 +28,7 @@ else fi bash "$SCRIPT_DIR/preflight.sh" "$@" +bash "$SCRIPT_DIR/icons.sh" if [[ -e "${PROJECT_DIR}/bin/sampletones" ]]; then echo "Removing the previous artifact: ./bin/sampletones" diff --git a/scripts/linux/build/icons.sh b/scripts/linux/build/icons.sh new file mode 100644 index 00000000..df7bf35b --- /dev/null +++ b/scripts/linux/build/icons.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -e + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$SCRIPT_DIR/../lib/root.sh" + +PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) +VENV_PY="$PROJECT_DIR/.venv-build/bin/python" + +echo "Generating the icon suite..." +"$VENV_PY" scripts/assets/icons.py diff --git a/scripts/linux/build/sampletones.sh b/scripts/linux/build/sampletones.sh index 7b829235..d5accc98 100755 --- a/scripts/linux/build/sampletones.sh +++ b/scripts/linux/build/sampletones.sh @@ -23,6 +23,6 @@ echo "Installing dependencies..." EXTRAS_STR=$(IFS=,; echo "${EXTRAS[*]}") echo "Installing with extras: $EXTRAS_STR" -"$VENV_PY" -m pip install ".[$EXTRAS_STR]" +"$VENV_PY" -m pip install ".[$EXTRAS_STR]" --group assets echo "sampletones Python package installed successfully." diff --git a/scripts/windows/build/build.bat b/scripts/windows/build/build.bat index 9b8137d7..bbe075f4 100644 --- a/scripts/windows/build/build.bat +++ b/scripts/windows/build/build.bat @@ -30,6 +30,7 @@ if "%RELEASE%"=="1" ( ) call "%SCRIPT_DIR%preflight.bat" %* || exit /b 1 +call "%SCRIPT_DIR%icons.bat" || exit /b 1 if exist "bin\sampletones.exe" ( echo Removing the previous artifact: bin\sampletones.exe diff --git a/scripts/windows/build/icons.bat b/scripts/windows/build/icons.bat new file mode 100644 index 00000000..d4ee7f14 --- /dev/null +++ b/scripts/windows/build/icons.bat @@ -0,0 +1,14 @@ +@echo off +setlocal EnableExtensions + +set "SCRIPT_DIR=%~dp0" +call "%SCRIPT_DIR%\..\lib\root.bat" || exit /b 1 + +set "PROJECT_DIR=%SCRIPT_DIR%..\..\.." +set "VENV_DIR=%PROJECT_DIR%\.venv-build" +set "VENV_PY=%VENV_DIR%\Scripts\python.exe" + +echo Generating the icon suite... +"%VENV_PY%" scripts\assets\icons.py || exit /b 1 + +exit /b 0 diff --git a/scripts/windows/build/sampletones.bat b/scripts/windows/build/sampletones.bat index 35fed651..902ea372 100644 --- a/scripts/windows/build/sampletones.bat +++ b/scripts/windows/build/sampletones.bat @@ -22,7 +22,7 @@ echo Installing dependencies... "%VENV_PY%" -m pip install --upgrade pip echo Installing with extras: !EXTRAS! -"%VENV_PY%" -m pip install ".[!EXTRAS!]" || exit /b 1 +"%VENV_PY%" -m pip install ".[!EXTRAS!]" --group assets || exit /b 1 echo sampletones Python package installed successfully. exit /b 0 diff --git a/src/sampletones/__main__.py b/src/sampletones/__main__.py index 9592c1e9..b7625bb2 100644 --- a/src/sampletones/__main__.py +++ b/src/sampletones/__main__.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Optional -from sampletones_core.paths import EXT_FILES_AUDIO +from sampletones_shared.paths.extensions import EXT_FILES_AUDIO if TYPE_CHECKING: from sampletones_core.configs import Config @@ -119,7 +119,7 @@ def main() -> None: config_path = Path(args.config) if args.config else None output_path = Path(args.output) if args.output else None - from sampletones_core.paths import ( + from sampletones_shared.paths.extensions import ( EXT_FILE_LIBRARY, EXT_FILE_PROJECT, EXT_FILE_RECONSTRUCTION, diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 079ae036..6a266a36 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -141,7 +141,6 @@ from sampletones_core.constants.audio import BufferSize, SampleRate from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.exporters import Features -from sampletones_core.paths import EXT_FILES_AUDIO from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode @@ -156,6 +155,7 @@ ) from sampletones_shared.exceptions import PlaybackError from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILES_AUDIO from sampletones_shared.types.application import Sender SEQUENCER_SAMPLE_TITLE_FORMAT: Final[str] = "{ordinal}: {name}" diff --git a/src/sampletones_application/config/managers/config.py b/src/sampletones_application/config/managers/config.py index bd2fadd7..cd703831 100644 --- a/src/sampletones_application/config/managers/config.py +++ b/src/sampletones_application/config/managers/config.py @@ -20,9 +20,9 @@ from sampletones_core.data.metadata import Metadata from sampletones_core.fft import Window from sampletones_core.library import InstructionLibraryKey -from sampletones_core.paths import CONFIG_PATH, LIBRARY_DIRECTORY from sampletones_shared.constants.project import RECONSTRUCTIONS_DIRECTORY from sampletones_shared.logger import logger +from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.serialization import load_json from sampletones_shared.utils.validation import validate_with_recovery diff --git a/src/sampletones_application/config/profile.py b/src/sampletones_application/config/profile.py index b6fcd9c7..239bfeaf 100644 --- a/src/sampletones_application/config/profile.py +++ b/src/sampletones_application/config/profile.py @@ -4,7 +4,7 @@ from pathlib import Path from sampletones_application.paths import APPLICATION_STATE_PATH -from sampletones_core.paths import APPLICATION_CONFIG_PATH +from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH @dataclass(frozen=True) diff --git a/src/sampletones_application/config/session/state/paths.py b/src/sampletones_application/config/session/state/paths.py index 3d8d300c..885909d6 100644 --- a/src/sampletones_application/config/session/state/paths.py +++ b/src/sampletones_application/config/session/state/paths.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field, field_serializer -from sampletones_core.paths import ( +from sampletones_shared.paths.user import ( CONFIG_PATH, LIBRARY_DIRECTORY, PROJECTS_DIRECTORY, diff --git a/src/sampletones_application/coordinators/config.py b/src/sampletones_application/coordinators/config.py index 92567fc8..282f1df8 100644 --- a/src/sampletones_application/coordinators/config.py +++ b/src/sampletones_application/coordinators/config.py @@ -26,9 +26,9 @@ from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_core.paths import EXT_FILE_JSON from sampletones_shared.application import SAMPLETONES_VERSION from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_JSON from sampletones_shared.utils.validation import flatten_location _LOAD_FAILURE_MESSAGES: Dict[ConfigLoadFailureReason, GlobalMessageElements] = { diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 295d790b..63f755e3 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -31,7 +31,6 @@ from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_core.paths import EXT_FILE_PROJECT from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.scope import ExportScope @@ -45,6 +44,7 @@ SerializationError, ) from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_PROJECT from sampletones_shared.types.callback import Callback, VoidCallback from sampletones_shared.utils.system.paths import get_directory, get_filename diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index f63b21e2..d2e5222a 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -30,10 +30,10 @@ from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.exporters import Features -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.types.feature import FeatureValue from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from sampletones_shared.types.callback import Callback, VoidCallback from sampletones_shared.utils.system.paths import get_filename diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 10f80539..bfdfd0f3 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -81,7 +81,6 @@ from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.paths import EXT_FILE_WAVE from sampletones_core.structures.tree import FileSystemNode from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat @@ -95,6 +94,7 @@ LoadReconstructionError, ) from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_WAVE from sampletones_shared.types.callback import PathCallback, VoidCallback _LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_RECONSTRUCTION, SUF_PANEL_LEFT) diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index 05ca9155..def2e397 100644 --- a/src/sampletones_application/logic/instruction/library_manager.py +++ b/src/sampletones_application/logic/instruction/library_manager.py @@ -18,7 +18,6 @@ from sampletones_core.library.creator import InstructionsLibraryCreator from sampletones_core.library.filename.fields import InstructionsFilenameFields from sampletones_core.parallelization import TaskProgress, TaskStatus -from sampletones_core.paths import EXT_FILE_LIBRARY from sampletones_core.structures.tree import ( GeneratorNode, LibraryNode, @@ -27,6 +26,7 @@ TreeNode, ) from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin from sampletones_shared.utils.system.paths import to_path diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py index 5a66fe40..178d8456 100644 --- a/src/sampletones_application/logic/main/explorer_manager.py +++ b/src/sampletones_application/logic/main/explorer_manager.py @@ -3,11 +3,6 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager -from sampletones_core.paths import ( - EXT_FILE_LIBRARY, - EXT_FILE_RECONSTRUCTION, - EXT_FILES_AUDIO, -) from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( FileSystemNode, @@ -16,6 +11,11 @@ TreeNode, create_directory_node, ) +from sampletones_shared.paths.extensions import ( + EXT_FILE_LIBRARY, + EXT_FILE_RECONSTRUCTION, + EXT_FILES_AUDIO, +) from sampletones_shared.utils.system.system import System diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py index cd34400e..56a4bb4c 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py @@ -11,8 +11,8 @@ from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, ) -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION def scan_reconstructions(directory: Path) -> ReconstructionScan: diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index e10c4b83..204c7fe9 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -5,12 +5,12 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.utils.callbacks.queue import CallbackQueue -from sampletones_core import paths from sampletones_core.audio import AudioDeviceManager from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger +from sampletones_shared.paths import extensions from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -103,7 +103,7 @@ def is_playable_file(self, node: TreeNode) -> bool: return False suffix = node.filepath.suffix.lower() - return suffix == paths.EXT_FILE_RECONSTRUCTION or suffix in paths.EXT_FILES_AUDIO + return suffix == extensions.EXT_FILE_RECONSTRUCTION or suffix in extensions.EXT_FILES_AUDIO def _execute_autoplay(self) -> None: if self._pending_autoplay_node is not None: @@ -119,7 +119,7 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None: return match node.filepath.suffix.lower(): - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: try: reconstruction = Reconstruction.load(node.filepath) self._audio_device_manager.play( @@ -133,7 +133,7 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None: f"Failed to play reconstruction file: {node.filepath}", ) self.call(self.on_autoplay_error, exception) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: self._audio_device_manager.play_file( node.filepath, update=False, diff --git a/src/sampletones_application/paths.py b/src/sampletones_application/paths.py index 4ecbdcfc..0ae44e42 100644 --- a/src/sampletones_application/paths.py +++ b/src/sampletones_application/paths.py @@ -1,8 +1,8 @@ from pathlib import Path from typing import Final -from sampletones_core.paths import USER_PATH_CONFIG -from sampletones_shared.paths import CONFIG_DIRECTORY +from sampletones_shared.paths.resources import CONFIG_DIRECTORY +from sampletones_shared.paths.user import USER_PATH_CONFIG APPLICATION_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "application" BEHAVIOR_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "behavior" diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 59730542..1c9360cc 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -65,7 +65,6 @@ BackgroundWorkCancelled, SingleThreadExecutor, ) -from sampletones_core import paths from sampletones_core.configs.display import ( format_nes_frequency, format_sample_rate, @@ -82,6 +81,7 @@ Tree, TreeNode, ) +from sampletones_shared.paths import extensions from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import ( Callback, @@ -812,11 +812,11 @@ def _resolve_file_theme_tag( return TAG_GLOBAL_THEME_FAVORITE match node.filepath.suffix.lower(): - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: return TAG_GLOBAL_THEME_FILE_RECONSTRUCTION - case paths.EXT_FILE_LIBRARY: + case extensions.EXT_FILE_LIBRARY: return TAG_GLOBAL_THEME_FILE_LIBRARY - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: return TAG_GLOBAL_THEME_FILE_WAVE case _: if has_favorite_ancestor: diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index c20fbd94..84aaa74b 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -30,7 +30,6 @@ from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.parallelization.thread import concurrent -from sampletones_core import paths from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -39,6 +38,7 @@ TreeTraversal, traverse, ) +from sampletones_shared.paths import extensions from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import MessageCallback, PathCallback @@ -300,19 +300,19 @@ def message_function( node, _ = user_data suffix = node.filepath.suffix.lower() match suffix: - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: return reconstruction_message_function( *args, user_data=user_data, **kwargs, ) - case paths.EXT_FILE_LIBRARY: + case extensions.EXT_FILE_LIBRARY: return library_message_function( *args, user_data=user_data, **kwargs, ) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: return audio_message_function( *args, user_data=user_data, @@ -333,9 +333,9 @@ def _on_file_node_clicked( node, _ = user_data if mouse_button == dpg.mvMouseButton_Left: match node.filepath.suffix.lower(): - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: return self._logic.request_autoplay(node) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: self.call(self.on_wave_file_clicked, node.filepath) return self._logic.request_autoplay(node) @@ -354,12 +354,12 @@ def _on_file_node_double_clicked( node, _ = user_data if mouse_button == dpg.mvMouseButton_Left: match node.filepath.suffix.lower(): - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: self._load_reconstruction(node) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: self._logic.cancel_autoplay() return self._reconstruct_file(node) - case paths.EXT_FILE_LIBRARY: + case extensions.EXT_FILE_LIBRARY: return self._load_library(node) return None @@ -452,17 +452,17 @@ def _add_context_menu_file_actions(self, node: FileSystemNode) -> None: dpg.add_separator() suffix = node.filepath.suffix.lower() match suffix: - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: dpg.add_menu_item( label=self._language_manager["main.explorer.label.context_load_reconstruction"], callback=lambda: self._load_reconstruction(node), ) - case paths.EXT_FILE_LIBRARY: + case extensions.EXT_FILE_LIBRARY: dpg.add_menu_item( label=self._language_manager["main.explorer.label.context_load_library"], callback=lambda: self._load_library(node), ) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: dpg.add_menu_item( label=self._language_manager["main.explorer.label.context_reconstruct_file"], callback=lambda: self._context_reconstruct_file(node), diff --git a/src/sampletones_application/ui/resources/items.py b/src/sampletones_application/ui/resources/items.py index d4a8c2ba..d85b185f 100644 --- a/src/sampletones_application/ui/resources/items.py +++ b/src/sampletones_application/ui/resources/items.py @@ -1,6 +1,6 @@ from enum import Enum -from sampletones_core.paths import ( +from sampletones_shared.paths.resources import ( FONT_ICON, FONT_MONO_BOLD, FONT_MONO_REGULAR, diff --git a/src/sampletones_application/ui/resources/resources.py b/src/sampletones_application/ui/resources/resources.py index e644d7de..b841967f 100644 --- a/src/sampletones_application/ui/resources/resources.py +++ b/src/sampletones_application/ui/resources/resources.py @@ -3,7 +3,7 @@ IconResource, ) from sampletones_application.ui.resources.loader import ResourceLoader -from sampletones_core.paths import FONT_DIRECTORY, ICON_DIRECTORY +from sampletones_shared.paths.resources import FONT_DIRECTORY, ICON_DIRECTORY icon_loader = ResourceLoader(ICON_DIRECTORY) font_loader = ResourceLoader(FONT_DIRECTORY) diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py index e24eb06e..61ed7573 100644 --- a/src/sampletones_application/ui/themes/loader.py +++ b/src/sampletones_application/ui/themes/loader.py @@ -29,7 +29,7 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY from sampletones_application.utils.palette.source import PaletteSource -from sampletones_core.paths import EXT_FILE_YAML +from sampletones_shared.paths.extensions import EXT_FILE_YAML from sampletones_shared.utils.serialization import load_yaml _BASE_THEME_NAME: Final[str] = "default" diff --git a/src/sampletones_application/utils/gui/shortcuts/catalog.py b/src/sampletones_application/utils/gui/shortcuts/catalog.py index 6018b4f3..73f503a8 100644 --- a/src/sampletones_application/utils/gui/shortcuts/catalog.py +++ b/src/sampletones_application/utils/gui/shortcuts/catalog.py @@ -6,8 +6,8 @@ from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme -from sampletones_core.paths import EXT_FILE_YAML from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_YAML @dataclass(frozen=True) diff --git a/src/sampletones_application/utils/palette/catalog.py b/src/sampletones_application/utils/palette/catalog.py index ce04fbb4..7b1d7c99 100644 --- a/src/sampletones_application/utils/palette/catalog.py +++ b/src/sampletones_application/utils/palette/catalog.py @@ -5,8 +5,8 @@ from typing import Dict, Final, Tuple from sampletones_application.utils.palette.palette import Palette -from sampletones_core.paths import EXT_FILE_YAML from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_YAML DEFAULT_PALETTE_NAME: Final[str] = "studio" diff --git a/src/sampletones_assets/icons/sampletones.ico b/src/sampletones_assets/icons/sampletones.ico deleted file mode 100644 index 7a82dacedfc1131620e8e554cc93143d3bd6ff43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 57991 zcmafaWl)^K()Hqt1QrP_?k-`0;O_1&!9BQJaCZw%aEIV7!3nZB1PSi$E?@4wf4{1C z{>=1r)lAh>T~D8Lx(5J&0U!cEAi%#t24IH;04)BqBKx1cjR*i_{M%z?{hz)30RZqt z1^|SG|7UM~1OPPg0Dypi|Je+p001}hf7bsQWB_Ux0Kmin0Ekppl=^@|fbwtigN(Ge z>c9CvH-dow&ITkTT5$pZFi$e#BI=&mc1e1+I3l3X%8HKt?gl;`0}fCSuwd)`otoi+ z45iN^(r0Ydr@cegJsI4YohJB6!!PQ3)8@7hDL#M#77zghP+X-2HY_e%*;eN;Md+v0 zS(fWb!H>TaZ&3Y~X2dzyHqtToG7{OTbk!*+T8V=g93&~uK49# zFG$2)GifX$uJ{kTNObqcK$!B8Z; zp6kCmE}guMx4Z^-p03z;t>Hr0#=7h593+o-JEcjA@3ST*?j54pKK}ZVRbppHV17S_ zc&xNPRo>*{Vab6w*0tN;o?$-JX2danI>JO7A^xEBkgk&)H>=M6)GL-bd`S8GeFlY0 z{0Z+?PZ%bp>cHe*FaTsE6vb;qje`C^Aw%}hP4?fAc>$jm0sx2={|lK^&F6TcmJhh} zvg{4#U3|~p`+qz*qyAV276>cjNW6!T{1H97zW#7LQ!!Jx`Z@TC096yQW`ZCOh}jsYz9hprc^$Qw;$Z zE&u_Lp}#9iJhc7@L6&hI%)xTlC4mZzE?K?1X43Wtw%$l1g zv=}ktj0Ks+=g1Z(KiQC)#9+?KB=Eo%oq{gel@f5ne?iWIr~^!5ih4mdA#{fl=MTL( zzsvG|F|8E$RK24W{tl=hoP@-uRcRs?{#@l0RmBYJ)Il1W(>ImVHH!xQI7SG12m=duA{ zkl+J-UQvHch*w8n3fn%Mo|C*?-0%RyH$L=!&4#dBcOA2kf-!HJsXBbuUBuyIKQCu) zZA?M$Vl}TR>=)$L7Zg^A<;Q=#-y}>YG!2^JiWym)zZ51fEyklCmGCWpstP;Zj@JvK zi{M;f&}-afP@kuvl#>l=U5l3tMgNRa>B~kOH&!W^<{jJJ-lNq*BAi1q$^Y}O0!G;d zw)W{n!nca2YIwGkdsea@`+ID6F>7b)9Yb z>3l)p5OhYMdRffG6%kDp5cj>jK6Y9~*N{&qp062dElq;chRnZM{X- z&JJN6g8CK>p-63a7P-?I?#g}_jf&Xc;QI zTpzUavk&P~>6ux0`?6(2Ir`T9)pfDyaA4*#*R+G^Bn5d0f|LB{Xxn40)UFYRQMjX~ zxtSsg#qXB2G3zqSzjxQv^RKHnVeo^|Wz$K=!~2ch-`9Fu^E@_Xy;O5sMd(v^@q?R_ z+hAS0qI!4CoA!MI&1#G?jUA>b?iNtPL1~$5mmj_T~ zQy>e|(qZnr9r|`e0QA!2J74;!3WfmX?=VQvt4;?3+lFqBKK_i!Key8O))O`TCQ*rg z=QP(#CvQ3RM^Eh>VXR>rPlAZ&K{0ps`6W+JMD0-${Bjq9(=axbsZBUf=*^22YhP)$ zMOGSHhLvMV{Z<%8QBaQukIqQnom1xLw;1YT#m(;QH2mE*s1Z3N$?0VUmfiI@G6~dO z4fea(C0p#qBKtHt$`Nkszxw-Em0mk(*sCr|^|W@ryk!mBHQ6sLW>x+Ob^*$^D|q>G z!`!WMEm@gFVbUR!C*bEu=BOH>oCh;I*W;IY)(W{8yr1f@lUU@RTRB&L?SwZp z=;rRQYG9hBkuxYVk?+h{)@QskXtPO6E5nRT;au}lnCr;NiwB)HDQd5?A9qyrZMOv_;13Li;!j6k;D% z&Q`%2W_%Pux;wu`B%9?yx#6uA&!(JXf#`w3c7rw75C-_ShpBsozxsw>zV91xu|J1S zGemw=KndWnDe!;Y7P_tIcH@Xk`r>!*N{^zM>f_wpEW=Ed+K|joN}7SPvGsfVSmJ|E zaI(m*mO{_6*)tNw(U15ZB;7^KON1%RrcK5&}ThGf&m@86J7AUptPCN`0e(stUIyU6C zSke(zXw)VfMbX~xx@5(Dc9;Pk*)7z5lQJ+=#R-ZzZ`nA7vRyi&t_p?47eae z<1|t4@34+$=vd^xtBy665m?J>))1&66J-W`J=0DOyUMFaKU?_DY@QLj78Oy9BDA$5 z3D8kULxX1sVBt{r+C;?91t@H)#V%xFFhfBpSQHH%HR4qDspfh-*rM&c<-{|oh?xqs z1c8*KuNHU4$#%E#Csc<%OI`aBWn5(M^|?H6c+p>#XAv<`cmIzR_aB_a`rj1y$H;!_ zA6m-)Uy7^N(Mcv6cB&a|f6821B1{7WvHm6_A0_`?bc!UTjUN80Mf`^FZ7Q0HQM5!% zBoQr*5&kfkBhaF48*)y*bHHZ7TW9Bt502JP;57-u6rc!6vfkDyhlZ z1{S3p=Ky~382;>LB|eXYXlgWd+3h?er#RzC1ABRp0IJmW{eO=xFKswg=<{!7fOX|? z;zS-b?-pI{Z7%Ak-nXHLo|8{N;)9I11WLv}t1&bX8r4LCm>d2p5=&UZw*n#YP(BBXh33q2c~Yfs)L>&Kf)2|AeHA=v#Sm!$&&N z3rDEX5RhtgrVDG0OA37P%QVHAqd-H)=j-U$6CLg6>2m;1RK485+2=?52L zJ)9k9U&Yw}8cQ=!8E)wV24Zw&NUsPNEz|CgQk2o+IDKD2P2oTHxE|jH3eZ`0q^&(> zwPrS$v~$fi1o=ImIA3K;WTlmP^k7rr*6S*-EOTAPP3sl}hr@C@EOP`SfBXsTMpRXET^&%~4V; zeh!svK!(m9B46vzO!bo;|;vY&=Ot z1~Hb`oYT*+DaZA&#RnM8?#c0$!x-!bpau}z4fFQu9WFa===XBeQ=+1?r*HQ;J&d$! zab9&-Ye>DnKd$%qIku`%6u4bJ74LPeAVEXP5n>bXl+f5RZt2;lB;f+$d0gdw&#?g$ zO?0$OfMGn8j+%?bDX{fgx~TLyP3Ah_YTxlbb={Se3!~C+uB$S<)I1T{87V06)HI|8 zpqG!}cGe|dVxzNKy8J#+f_6}4MG_`WEV7nsZbDrtql$=);Jhg{6LZ80$b~2Pz zQ071knrYLtZ{mIW8<7#{D5(4pmwU+29!EknOY1RNx7NahBf6u^mprd8vgMma(X{|>pGYd-ER5~{~2etqNdPJ!8l}&>=zCKQk?+ATmx7fXkMYX;Tm4jVrdkUWKRRXh-r-FXrlXCyrU|WwtRkj>5ku@)GtJ&J(UP|48 zus8Gb83X>Bh`5rKbRWA!bw%s$$X|@qoOd>m4~4AW5~uMFO+B7;6vrb&a&mrtT`!!2 zc#7ZM#2a>c9bY)2$RCh)`Q66aezsiV-50y)2f}kBGFg^4K@jLtgVIzr-6H8LEI4^O zzq8Y4JCPXBQWbmb7tCeC?Qmz~iKfr!>8YDblB8?$AJ=1%34i2~Ou$iwAO3kJ*m^yp zAj{o(Tf*(T{nePZNrv9|!WMfbRGgTbl|{HhV`Khow}9dCR|Jn`LFQ<^B0aMntXjlj zZ)Eav(YW+ydaM9$!*M*U1XLN8ZEw36Wj6BoHC%<@k<~3KqXCtXxMo#=(G@Xfbn_36FmQbsj!+S=sP?K9x9SAv_Kx`Gu177QuZ0T} zxB_7~RfaGKSs*)09mNrDxcpIXZ_m%X@zax}Yij)X)n8j?be&YKlT>BY1`;x56CYXk zQ+UKgd6jcvhG>#$O0qj`QS3(3e z(<+Ba11ukBOgtsO0wX9OjGwf_Jutn!rey&V=GSqeX(vDJ)1%JJ{5{V%y_89P-TD0=ie27rDkmq;-iD}i z61u|8hqjuW=!MeY@;@u<6Y8Plz;f+`1BKaa->3C!zMk?-!~i)1X*=OV@)fw9jpfI~ zyES6tjy8pY023(u%_)kW zzE~UDPRCOwg5OE!N^sSapvpB1rT`?X;Q*RydxsAYrP$tzE(}dkkE$M{`3+EwpvtF3A1%iH<=5eQ+BU+%jjEa)?TDMmw@)Zd zHh)!RS`ePZ|EJp&twW=fw!ys(NA-vABOhSOT%^v8&N*d+ z-m+N*5oYfs%~JgmN5LYSu^*gfo@2@}k#fUiJ~W;%;!I@2UM()~${h;I`S6#Gyz_Zk zou0UJ65qD}{Kwt_F|du7Pq6KJB|(xCG+frTXX|>1$VV9%!AVNGi-S55S~T-TR(YQ4 zrn($itiFssmBxgVo_d43`~w7J#`BnMdZ3BCbnAzm7qLdWbI>s>C6(a{0P9;#hEp6B z1hSdznpa7efSuQ|IP2(K$^*&w2gU$p)SHDhHX3u~P0J|&DjgjSdY93}k*ERy47n46 z`XS61%;$%@hs(M-g#7_DAi;r=-%Y z)kVG`1AS=0FDpAMn$MUN73eNY9NBdV50n0n&kdZ?)j9D(e$N*|LcaWaBGmaDZ002# zk9%YA1UgK}IBI9hUOnQ^W*J>5#8Aykc%DmU)CiL` z^k>&s`Xd^*DNd}m$|BzP1&vz9qLA_31T&s+5Yi$+yQ$KoH!OasiSPXI!WyJ4YZWBLHY|IyI>8(IvnAz(GU zsOe#%)UY(W5jOf^ItQE78W}l3J`ug!*6vfN`oZZ|FJA9+gOnw0!(VS7|FNQtuA8xx z+9e*)0Hbfu5w!&PZQ3%vkqlRgxSPXD6qeee;#kM}p^KJzno7ZdXKQsbc+VW%4)_f8 za`%-F0qNMhK0+*CP44sbb936IesrMyJv1G>el4vW^ndZS!c+NaCrzB8kAJN^IqP7% z?k0%hglXM<`e(i7TP`CGzSuEre!z8UX=Pz)>MadsF>iGA47K~!Aren%g7bM0fNl07 zJ%)fo82DTgf2Z$r{t@d1Igs1(p0q{l4yAa*tovyz@XPJ)rI%84b}1_-Zy7m4mJMy5 zX}JVWw#wYIJIn(fI@`6Dl5}3+SVco9_Ag775BOo2tlidOHGg-uibA&ACU~OrXKzvR zI+eZreA?eSK@krVQ>cZ_W9b4RWYXsFEEJ!|61Fz3@luQ5t~Rk;SWue6b|gt(C$S^i z9q|$grgfva^BZNznM2b}WCaH#j@h=nj(G_sGf^hFu#kvV)OprZM}a1y90KvVAvkWC6lN%;kWO{3#fr zNQJkTOHqAe7ew2r!4Ttr*xaB}ioPN+_cpm{&9{kcV2=-(?0cOPhMRl1Ke3+o*3d1f zG8G~>cPmE@2b3=g(hp}<-9>BnAxCUV_ufya+fqe5uzXlRei|%r(?QR{J`@9q`$I`g zl(n^ej5ENmI$$h&Z&f{v7@-;>@$Gt#P{Ozhwf{IZ|Ft83+zsnZs5EPPp3e|s;7@AU z`||1c(#(3h1%v7T!R&?5I)qA_0Jp<^N-L^SUz0#Q&IGS4GDZ2&IIOWCP_TTN8DXPWd@4Hoyp*&klfmamL z^;r+W)$2=^LQ+4|du)(s^=%M@^T*`gHNNS~2`{}l(D=~ez+ZtL%eh@EMHT7=G=7B1 zz6Ni%c})LJ|IQZ*!dU-DeR5e$V^j%(N>g$Of2b0eYU}CY^+hx^B@Tbi1UOFoH3#fq zH*!Bcygf(z$jc$4P$IU;BZCWN>yfzb-s|fOy%37$9$mc(J@;ltczOgOKfgT{TyNeF zoJu(4?dvzcFWRlUcvy5p~`s$D7+1F}|=fvBIiZ2(gm(4z0cc+P5cieSo zmzHV8Deag5YogSP7wlg#o1pVUm-uc`Qb2_9bs|<_0KP$uxbINjp zmw@$EUjBWof5gci=JjDrAZ5k{mHSx#%P*bDrNIYpt3{~Lb#EfPrK+PSoh63=yofN+ zveBkfg$U~=*pE$k5L{^Zbh0o*peV!?jv*R}#k9qDj5jrTs((=;xRINC8)Yu{^p`n6 znHMS%HU0T^sPv0UaH6dZ0NDJY+3#-5-&S)$=IVITFEf*zoSqaXxY{-ZzSV&aWHOVa z5o>8sCM5>@5Us`H;{J5&F)jU`8RUYO>+QXLIPXhZ5{mL`AO4WWe|iDMEi@ouuam%MR3di?+Gxk_|;t{ zLIhULiXqh@D|P>iiD(>)p-R6@w>u1==E9FE0DiRJZ(W2L#K438SNDA|jZ^43wI@z} z0F;tPE-oo^B8p-PPqw*uCIgXVPFJaVUv4RmCoRp%2i4k_ssd+r)PYgQ1#F*ygnW&+ z&%5smngZ8XLe^36jp?arViq!ydyBv87!MJGW_|BDO-iWVEqh9+OlP2rm^vcxY)K8<66i`O5nu1kOJb6J5$ zijpF(MM1KyY*t>)%=hQA#dZcBRDsx|1s>ef=^$(TMXZeAthG}l*=fD+@y)q=*C`$;q3D^Ist-4T0j!p(}Fb+^M05iGdx=`)Qv?l&iCF>Hm&ZR1{4SR(Tp|>eNRYt!11%bL zuT?r-!Xdvu`dI7`ky*zOFjRwtLo9iV!KyZTgdmYEA5s=yJ&_pluym}C3>+VK zIl@5M9}91-Y)cwFaDgYp-=iP-HywI?l#n?%SrMVn=_YC`lRdR}SE!W>&GezP)&g05 z2CUmn(+l$^ZvEcxTi1B-(E-wN9lI5(a&bTD!+hP6DP?DF>%2~PPwVeu<;cYus3=e* z3$!(2IT5+UqJ+)5o@;))xKhBbKTY>Geey%_KdpD}&9JThE{#Bkh39>G<@wx~P-r|w zYklt?FeUe?XQ-G0C(fbU&pMPbkS5UtOH_NmC<*k{&6kc=FFQ%xs`0k?vrE0>xnHX` z)405WEQCRG3g&LpJg`GATp zJGt|;D|Qro^QMCjL2voFH#~cB_iSf?M?;&^?yQI({flBV9V=vG85a-gWiXE~se^@u zPw;)WA`61i{efwu>L9F$9wsu*X*`3<66WEhpRgWWf`d;+P4e1{_j3ClgyXwWxqU+V z{yKDLQ8Qo-djn7BT7gPF=zDn-^#-beyItsUm9aO})$`zU{}st_W5xl-X+>Pb4Hl&r zQ!{t1Y1|WqEL6p?j~2~@E#`#;m{72`nn#(T74)r6poK5hO-4E>d;!~~gS38l+1#If z9kKq77jr3^__Dn3K@{3*Dj#Nzy=|oz<0g4K5+m=b0uHtvbrE2qVomHcKaJdAVnz?e zf?%$j{Bd>*6>>sM;UV?;_8quQRUPXBON<7wKbE)|i3l-*h95A^^7P__sK>hP$~dyX zlG6io*1ewhk-_GxJX{1H)iH({lli%%`Au%1B^!<6cTP3TDv>Od*T`0`>D!qiS1vQ- z!HAnGD*)fxR%-Rtv%}iw?IxkOmpfMnF7d0OTfg4K z&0I>C7&lf~V0y1KN?>5l)rKFf0BaIOV2Xo1-Z9?1Begm$3bt1u47qn-B34>-*p?>N z!sXfy^QpUjIuTt{^}H83f*o9}delyoh$0v-#-dQAmbqvEb07)olJt_(LHo1EYp#)e z*7rICZ*DiO`+iTb|2NskU!Xf>S1}yXdNLd=L>4;JweZbiTb)F?V*0c-9r|g7-BD`U z#Qi4bK3cpWHbKKVenJX%Q?LlR#KDFRU9xD^(GXo`fO9cS-j@`bvG)C1DmFc+e6EY# zREgMEC3HHNWXl@6iBTF*`g+&r)y|Wue>Gt=64jH10Dl4e0b*&0N}nH+`qqS(zd40j&v773WCzyn!Q5OJtO^UkpVpR&G0{d@ z%}s54$Ys+}Dl>B*0S8P{=S6k73Ql73FujM<6$ay@T`S)8i1d%m^MeS#O{xrMo z+MU`Z2MwDMYhepuhoKmlRlpdRs}qIHY|qQ+!3>x+%CCt5D;fJKlKJ9JCyY5pntqQm zjAxo%`EnCnb(zu^X$r8j1&!0~syZfbTSV=Qayp^3gJUEqmNH$Yo4>IVK#NiyS%2kj ziR1QbNB{P$^?w#itus08%Ud3!If?$+TqWO#K^6+o+f)r5xgw%NTBNHs_#M8qu~aQ0Akb)Uthw;EH?Q<_ zbdR+A6$Za~+)N-p6~_xg&}BE;Ucm$u%iNukHc^#S*BCS<1YS?X9MF;Ru0Tt! zMOa*iDj%z=jw_g|?M!8_51G>3qMEG<&JB37oMz^tCH`_ihf5eLlLs$1RSdXP&93XM zmo0=V7X?2*K9jbPJX~HfVM$u3iGf);1$aFj_9H$low*qL97`>}UtQ%YS$}(`83>TX zo*mjNnGtg}kq#8!r|8ihI90icbnc}mXBcCRE3pb=?0QC}HxSm5wwY*+SS!>g2x3Eg zqxIHHiHs(YRB*DL=1UHLlYJv3J^CANfC(m!>$e7C*u%d$`xaq<~*Hq(1-zL69Pgb|2PZdmNb&}zz4SY2Z zcB4ivHnT0fmytHWS#JKWiq*;JOo@L99R!k76;qQB@UBw}!e~A5;%>kg}VXyH>7pLrnr?vRFEe$rTc_!Ojn zG5%Tz|uwZUXU}%RA!g%hAnC()Ni7HwlEXj(k++z{sWuU@M6ovG(a_ z9}I9L4&K-2{)bwRwyu>vSkAUh+o&v)p4Fbp*(zvI!W5w`s|wvqt-l?v5iZ^*T6n}l zo^pPy*0Ee7!RnAAN#nt)ZY9w!Uf3_T&XkpS?vGl#D)0$h+y<#q;h_Z+Oh1xQV?nYv zNf?r~H2%gjq(EtG;bB9i@s-vTG_}6+Ae;m7rk7B&D$`hMmMaovRbYM8JT}Wu+ig58 zw=Z1i{;|qA@jgDi0yjqC2`U_Joq^T3X$9s`A}-Q%0=- z(ct@-=>^(j-;2Z|D(WAL5DED?eFMzV1XKw;6CAXt-%Rr~T*?AeV9!F}+H(>{q9 zQ=M34yLA!JW52}aXHp`*)?+JoFAY*U8GM!LTk%JX-(!h`T;Rg z`EwJL2lE=egIhbE?yt|)68mROyY|UH)Y(Nzt>WAZtJQ8q|6%$VbXjGfP2)1=p_d?u zT(6T}kzyUAuTjDle3Ft8PEHKkD09FI@6&gPF7XMLEM-DuoGhv>oVPATP=P5C=_K+Y zLo?2G$JIt$d_euL@{?CvD~VRP`Aw1sPR~*VR$vI52n=SnaW;AcNjjYP;?Z=}coZ1( zLpo?g73(*SLP|#jw*;?(OePb#O8D~7Sfxp$(`L~XnlaNvCeHmj-;!56Xu@TPY@a8Jzb6mmNf}$XL4%IE}ruwgCcUb?ZpL} zUdoZX%m&S%BDxUiU!xM0y)^r*Wh#C+T2so5T%TWIY{6|+@U z2S==W|EcZ6tjJJ-=kbMkx4L@Hwod!G9Z?%RP;df6+rp=kAQx%`#6`idMte7xhGa+? zNcx$R#g6iDU)t^cu%qSt^B&?jd^9O87hbsZX!j5sN#@ixY zT2D0+&LffZbZgX`{0b!OYobL$B3wx&0#kF_#*4aB8?N`wjX#5eZ3*|g92hIOvMR0w z)vwECHRe6fb1-^2=Qm@2q(#s8{0QKv#^#uJGi|(paxGY7YG?%K^+jNmH~I29Fj@HK zpkHWv`5l(Qps2;#FDo!7_5F`wR^f+mtV%DQP% z?Px^Y=;W?6Z>B7>XkRVa;RNs?euO?Q3;`kq`xP=$p`ldM*{mb~sA1FlBIyYob*?fx z?~gud78G9zP}&C zP&7H2e93NJFA@*X&ixp?t;cm#&upr4eu}6gf4*vutKoD(^Klu9_vRRG8kQ;#IhnC( z^Jp!z;7Spt>!sjUo+e(E2|?+d7MVTF|NaHO$Dux^(}VEi*FcT^=j!mma9gwcIIh1v zCtn-i4Au9J7C5Dz;jIP*U0(wFIgUKW{=;1nbnb@@ zrh`}#UX$HfM@ozNv_pd1l;&3z?y-{Xn*KZ=dML-h;V^j{xV4d2ieeXFE-@l+pHbi8`(^YFa)H#aNUV<4WLZuPP}?1jx>|ZL_DW@(h?Vh9q=Ix&0t#wh7<r&d?{2;4Ps7NwzYsdpnxQZ)tzP)BER?Li zVdGhH$-gYKwsR!hRy)4JCg*!I)a~1ZzxOLAAG zEZ*GO`}W8shcnz+#{cGYZRqwZmcz02Zdd=dr9f!|Pu5x2*mn$kLFcmg&P#|Azuu85 zM#1$vtij*+HmXnjPXC9(8}QE`@Lz>jUjHr|01&zSpTc|EWnF8&=tk1K&%Smz@#;-7 zWd#BtJEllSVo7{x3>%^5t4on?9(~OC9r?biWJ0S^E?aR$`MCWjZge+EEj5Z-uYoC5 zIy%J)B?y2;HiS5JowZIp**ZM8yx(1C*t1ARNQUgR2=~xSvB{t|4yA`0_9a&A%Slc!wxe8|JFb~O<2JG!1J zv*|j|Cfg+>LG|dtMg>PshtM>xToPy;Gw^@ZZf?DI^2jb#JdA( zKi|z9F3|qauO0ej5uV7&89ONSvKx;`lrkvg*?}SHg>e_~%!^NngJ?wL+tc|8E&xJ} zl<9`JSgc3>9*fSjsVL6!*SNl9h7o1qO{T*oR7490ad>jGYg3xcC7;QI^FwO_>R=3&HqX*LL<3K!j{WRP4$M z?ta|M8kRmnj8c|OHGljClc&8H3U?tkW|O2g^)@CegU@SO&%|Zn0H|tG9ewVAGgV(a zN>=(L$8nxK%E4w5!~LuANw7C2};2 zV(oNyV&-NxJOZUQbTbNgN$%eQQl!em`f8s@;DV2T)tVw*y%;o7ac@?6ao#VQ07#bP z;ZoO|Owju*@0Y*q|5(jJr($9sie=+jkt?eAXGRoWp#(L0=rz_|cST^arH>3|{5dMw zHYla8hviHUuY)HNjhXE&B2he2WN3U0@TowiE-Rbc7j%D8Q(~4W|NYkD7ooLYy4b&} zWU5H7bDn1Wi^%!QSqGg_6X&@kEcw&~5Ta&!7OXE^o;LbLaHXwwm3POde&bD`CcvKd zxJw9=ZAiBenJ2IP*D@OhqYTj>LpmIBoBo68*LT+DnfoMQA6UjZb&ZxRn=;1mm9S?# zXhg@yi2h+6G9>x??kZI2J@`FVIgIF`Jkd>Mx&0!NG+!i$ptSS-3(l0<qIp>YGj>z&l7y}mukW1lPmw1$1#oKkwAoJfi7e?`wjP0S8bG#aiL46 zad17!k3SjHP=a^9=Aj7acpK>(e)vTk;mr6X)z;Q9W=^|CwNp%k=D7SZ4l3-!S>532 z*V$rq-K^*5#q}Ymd(iG9e13T*-|N9#1_+Z=R`FgBnkZYgaaa4 zRe_Ay!;Kku%Y<~16bFdj+LARUt?b!Qz_|<=yuS@Oyd+m^G__E4?B*WC`ob$N5 zBo|+Py%^Nv{aG=YE>E-E%pDR_P|T=g)~^B{&T~+xBirjm-fH8WEoic9DOj@S9hb+s z;*MJqB>O0yP~y_nxDu%Y8g|NywX4nB#ymG*I(V>Q?If|_E`hQbY_IeOmCSJn0n)j7 zbXHcGWKyihcwN3DY;OwdrCH*Wb9~`9#0jN$*>-$hr4F{{#Me7!qKT7`(Am|A%c-f5 zCsas6FK8Tz=4Xs8_+w*gCzwGOef1S~YaAm0Lc?fdX7lv}>bAi}L)F~&{?;Xx|MA^_ z{)VI1*_#3g_`xvK&zbMZ zFsRt0V&XEU9CX`8$wlM6f-uq`T6Z61Kss7AukBX?5IMIgmAsxi5^^0bSY%S){%4`Y zf?oIYDybTa5iJt(IX7jp{`b^^FR05HIoudu<(=_?1Sq=d%W#WQ!Ym*DJO~6M7zee> zv0siSz2^|ewvc>2vQ7qo95V)o|q_7=m|LfBUamUg^kA|_*}lL^b#`b-BSTq*0c^A*Z85@ zr8b7><%(=}S-W)NJ`B!^7jTo%4Koud|GYEBVA~FA&-SPC+&B5t!s;{)b8NiNIE5A# zTHnpH3Cx?Y=!HbVi7+Vp<}lENsBjUBT5Y_Pa~A&5#XC&zQH4+T6G1j?s>EDVvy7!4l*ifAx!JEGRo4vt53V`t`)^NF$A8Jv6F=5|3)jn)L+IzmqPkyi;s~8Y2TU)UzW|P&cRLHO#Am$+Ujn*k&cpePS9w+M{ zih>H|7-;X{x~B-5wz1W*+XM#H(ZdTB1U z2Rv)*1<#;RZwXv8?2mmFx*xGmHX{%hjZ?aBq28Z%tx5}<>YPTJCD}xZ-RQpF-+6>- zy)rBDS_<72RtfA+v~=OyW@Bfwp-4}^4pz#@T2FP2t9%AiYcTsCme&0H3|~TbpL3dv zOI+4hMbQ9KH6pHaj&1=rhI|`aj0jB8+0; z#8`pGzUqHyc`dR>(*o(+RubkY9w7l(2FXkRXndc4G;==B$rG4OXU09DuV`eBW=rS0 z{NTBP5pC5gRoz(C4uIb>wn{+P>wnuK9jd(n0 zdc+Xoj9ztOIDF;;e39J{kZ~41hD+SqpTAM-JFrIx`anJM5P$b&u7UjSFYFq)fW$X; zFPJVb#0bThs2w^asX5_hs{*c7J?d0PpPh(2t-VTEEX#>nbogNZQwdh&QLs5@c)wQj zPRsu+lH06_gH;p*bZ`ElMR=Xrh)CMXtQwbmTzM3M>~~0jh;mxB+Ws)(y&2@vWZ1sv zR+Xb+Dx?>Lt8J?`-P#Ah2F&&zSWxF5-VCQWRUWO$rzx*(NUJWD{=ED0Wm<`)-MJOB z*{A!C#1w%i3;OA2*QM(73pQ}puNnp@(rIg)#Ms`jbfWRAHOafn&>nr1(y%pyC@}9Y z&dzC~r$?b+PduEkvVpCR+=&OXd<2&!b4+BF#Bz1GUOR&kZ4>9Zwz)CNY+T+v^3!S0 zpR>k7`n|81o2x&;0Lu-mvXffm_3n<)QOx{5pYSdwJ+R#U6r7{nIQu9#o|*AldB8RO zTtD_jB0zN{ps(`W@o0~vmF60s@v=TW!j^?>aL2Z;!@9Gar|2G0p)53Csb~%x(-p71 zTdNVWx&bYC+dbWTJLseq)`6k=SKT2b^RGjBVl?;gm8>-jG!wGRkA~w!w^h~F-EkAa6+N=NV8%)mEHxMmGh}5u^>CI` z0rsH}vI5K4@!|==i+HC5elJT+ET*S*<5bmU)G;M#Ry3?>K*kQ7K|BzV)lpW+VQ}e# zi)PiIL3tYGA!-7;*N?NJ#yEhww|YXg{}>3~Zy9!B6)}&eU!hTy;~-!bNfpY3J{U3tNK2=6ih1AUf%+nX{_Z2$&D%A!X?+xlC`Z#9i=s=cKZC3S{mhlBUM+A z9<5y24bfb{TGf}tE5j ze!R?chZ9qt{0QH;K4NS4a&)s14on&bzDY_1uC>A+AJL0 zT@Bv79LoK8j#tmnv*op9kz$Y5&Q&JvNk8<=Q* zd}B^BI1+ds^DxaOThBUrM1S9>gUUcfIYK~fYl(JQE5(iGL z>64He3UONqReA9Ewq-*HOh+K06a{KDAu>?j+DE<$THDh1hKghbQ>;1x1E>!M11aYS zT#hGN@GMOmqF;?K0&>5|zsJAd6Z$`CdEou@Eo<_P3vRi&DC82vhn?n=3dS6}J}hr@ zb)r!eMR!{p6Cs;=R&}2=Jk9Od8$_{M02j%1-9q_t+@C!BpQ0*hD>8yeh-}(NP{~s) zb&^6()jgXgzOUGh(5+^k-!8|t21BUCZBiVc<%GOKU95VlZW$f!|1}$P)n}+%!K26h z&4qn80nKm2h(w_F&bW*5*$IBuMW|-k)gP-Lxh`k7+&7&HYuvNG&v?J-F?jp4Uywl^ zZ$`wr3ttK)hd#DZTY354a}qsq8zsy5d9XkCl}`S3kQ3U1`IUaquKhG(sVMN=AL6v7 zLMETia((OYl5w95M(Q&wbqH`jfWSfVCAQzl7ORpGFR~ZV>Bcx=SVG^I)84B)V5FVSaM;4-51w8lX zZ8%sT6NamY0Jef?@NSafIT^89KMeie);&J)+fU?zVQ{81EJ=TmDjua7cHQQq5o;J-OsJG4tVV6 z7RIb7iQxj_(coZzZJN`7oGKh!fjE_$QwT1t@ccri2U+zX&ibsQfif+8PzuHCYK=VP za$X(ohLyx-m3tonvrjL6^s$_=#;h6Wiv*wr$(CjY%@GZB8_?ZQJ%F*}Pk|wI8->zxKUd=XCeI zU0tWo|93bZBQ*+5b6-U%(2&Eb^(yy6U+L?tdepn1Wcb^pI%JZ|e zG;N0tI&MlJaS#pRw|$J-%JFYoVm41ROC_8VZFN#cqAIDuK^?WxyoHfj9eCTG<0CK? zm)5I+1Y(JcLiV@S3}2t+2$8}d6EWSjU`w)$pC7edkLNw_lLEfGx&G15K7fDLq$&$f z!J%I`kUy4FhqwVBbUK5w1S;+3F zJ%Cp2N>)}F7?{eV<78-p9~Ps;kbzu?!GBo0x3C8x(54^>PHyr6W!UI}m zNW%Gfz`~_Fu^VX~%qI+BXcf_*Yeer%l(tF@j~W=k43qNRWl~bo_`K9sHa6>m}ZHJWbo4ekdlRsr-o9S2ME}g<_W=Pc%xVQnIK5 zBDk^7qjD;K-wH8csn%2qhnZOi_eqP{(Y^v%_bRh36z|2vgqUTtap~|c#q1)0hroRN(kL@}JZ)4X}=2tf@V z%H}p!pgZrVVk~kD68$-p-;t)Ox~QtzUR6`<+>$n=X;?KjK9(Aqf?`!rS7R+##Nk9*5#2E;G(Fa~IpWyb zsjl<&xiZ(21@7?>mt;0~7$6~`S&|X(=s^(3#b`QI6>dd)hjq)c!xb&6Znt}?;`;1$ z@B=k#`nsp8@TqVrL#sz|F__C_qe<~zZFAi1I7wIB$INXE)wX?%>t!II^#(G zbk*`f6afUoX@*7&B6_*!h*rNwb6*mFW~cc5{*uWUu<}FZ^;Q0JB>roX;X&2ud~4_J z(y*00w}X}DuyxOnKcON32IB!EdocqgvB{JPS@M@cZoEVaEQxSsY7NZ{KXkp8YI2~n z-ECyNr!yKI?_;^gRf;qFwvM#O81Gr{K#`~R9$eKGg%z~R>%!YkT*ACyorN3p1cX2h zD}W#@5j|fAJ(J#)LFFDcG5oNd$cJ&BGxNJ%sWaiC{g7}~OBmf@t=WcWA z_kuZdmSguVm06gTclr>s^wD|UhAuDbc{$%d{u(&VeY-+m0y6`O0|lMxRzChgv_j8S z5X3vxHBbfq5UB%xBthw-9F%~YrxeDjqtrp^q@t4w`mXrWh0HkUpmYzIK?oV);`Gv7 zKOkC~c^iUuGPus*F~jJP3+9EJl=5ig)dGZjwMvV{sNh~@#?TM`S#xhg3+*SMH`_WO zPM>zhcYvE=g79DA_B*d{#sKx_U1O7O=>m8iq&$ zfHDsyt7uBN6f3M~YsFzsqZ}Mz90}1XP$aQC(ND94f0%*N>Yi6Ycie|&aKLv(MY5$4 zF=H*){WJ{7j~A^h9m6AL9ksUAS;^U~r=dj!4bB3A1Xz*@o#@{ zg7ScxDoQ0ds*$e1Y2WT~^>74jK`SV3Gz@X8dg5Xof!e@{OLT`$-MyM!ZwjRWJ|7Le z6~G5LZ8*S)IKh{ps(&UyU-Lvk zb{u5|4ZtWc)~)AVviB8vJ2{L01jpqCxyfTDcZ?vJX_#dzVl@BqX7k;tTJ_l%t0i1> zYWtD&gcb*A(-!OYj2Rt^)~A0zcmLXp*B-~jb(+oBz}Hppp#Q~p_u|B;1OA}7@}32; zOE@fJqqPn?aKIu5^1@Hf8$b%XbXxNn>fW!LJ*y_!Q}8qi7vr|t;5afS6|@&RqwOA6 zyMYy!8*ZH~Ev%MWQAQU`OOl7>yzAP#2|5xe@yLi{I!A{WaSezFalAOCR_vXoxO|kl zI*Gcm%>LU3iG0?z{&CkH$6S(c!rm>RxE~$NGi==&x1Cw)8_TrhS}nbJ<=U2IVB)bRoZ(PZJAPOX!}Trxr8QX=0}z1) zu=qBNItWt1_g+Vt@Y_92E9NuMw`Mg1XHDSjIUcDQUA^|eD{Y?1EZL*NB!@a?+17ZNku#ik^&Y^_qepym{|K^GYb8-mj##XTwhzkLP>Zn z!m)Fc(|;m`GL#%zSsj;`Te9opm(;9pXa0I=O3YCcn{X! zF$BFYM3#8>M}P|;qX)qhQuYErOHKLyLVCWXm@XzB?FoZ7eAIumy zR1G5RKm_c%?J(p`$%r^IQ-lymRanH$gc43z-oTKf!tPJ-Af`NMF-HRnG)TD+$D|@M zI3^gF$fN0|mgyqxG#Wd>U>|sNs)=ua2-zbM1 zKt#s;#%2D+_J@b|=ssutcQY(-N&Bm{>Cf(h9*~vynrx>CBbBaCw(FRC9jKdTBU(uUS% z&VGC|=8@U7|5GW{vt9MJhdFP#x!Ex!gMa9BE_-+$@PG)zb$FDU{1?^nT?N6CnMrg; zifJx6l)o-S?8{3_QEN}{ zF4_%r)=O&3KM>!2or=}-wgyudn~iLB^Ka~Zk46~;{EfZ6ofQz^3pBT@ZEX<4ZA^Vx zV(uzvk1*^uJW{)Tz3G6$;q_HxtCB7C^|RX>C-zVjt+>g}UJaghpZ&F8Q_=3~t*d8a zgUae(7q}ea)@_YGex{}$w|k*F1>dHm&hmO}^5z0jQ8T(XQi19f8j7+ABIDL|(&@BSEFu%d#P zqZv1>MDOYozQGzeBN0zv9C?dd2C?qr?NERLm5_>ekmCD`7Tv_1cAglAGL^ERkVCW; zsDpYvLou#|ca%-w`+kZt2~uDUZ))yMN={)Di(=cH@ye{-5R*H0%I?#%!?3f8Lu2Y{ z`Ry`|dyM#GJ6~@sDnh!@FrfVyXj{{h^>W@)w#BIRT^Qp=c-3~Nu;0uP&A-`-a=BF& zcn^D7$(a{;=ka%YZszgK9=a)s0a0YJgiyr$JGv#07EQLf8UD;}NP2FxPam%p7P6A5 zv~P|XRk`W(zJ;%RoBl>5eiV?sq-n9L1Dm~!+FB%lc3!*(&!0KAXvaBj&yl>Q3YvsKM`A3H z`ue`UQqy7EqvbsZI0-aXD{r+jJJ`1Vn@mUbX;-o~^ryhM*vZv@m!|+8kgRzH%{@#a zi|H5_tw47>5nFK!MKw)JxoNPdHCDlTz7*mjLkd<7=87j)vN?~DtkTrM6|M2PD~rc8 z2@V{Kzun^icn|XEYGOI_U|WKa45Z?T_Sd{tm#CL{dIM(RIsek)q7`u#>8%S<@uj+d zVvOF8nMXpnWUJ2%_Zg39$3?t(LMBxuLJ`$VsBw`gERrBj3pgGnZ}i0cf)g`gPOLq9<_LTAg)MEf7c^>;m~Ts zen8A1Q4p<_oU#%z=Z#LRM6GT%OD6mFd9M>Ex})9Q7TtONa4)GY6(tTiGDj&7&}B2# z62t`4@6n`RBc?+KCi5NSNoIoi;}`=`186R^HMNA{Ku&ODY!Jq6SU0|=>gNA0z7GC< z&EOQ8trJ*_6$ZaAqpkuG4UAGSB`sivsn~bXx81pYwD-Gw`u(x>X)x%{0q_n2OfYH7 zLz2iLk#xJELCWB6=3Pb@XoGb&X6{T;n-k5YLx9B3h2e4^mI?V@I|;R=-te5Gj=+Y} z3fchOQD$x_i|zV_va%35uv>-Qyz-knxbk!LnGW+cz<$!sVB%+>p;7eJynyb`74J=K2ELmFKBg_PSg-HthLM$6YxfvzJnDW}I8n3B-GS$8N2 zV&!j8?R;^D%nddQ3Ob8>AQ;Y@PC`Z??KFcIAaqU9fMQVOVJh!7vCb$fui956MI{(= zR7f&S$z*bj!x|zf&_>ipkP6YlhllpJ;&XA=%)D>0+p$c7BuA(CQI<&hzb1lHNemQVSwUEDmC;T+`4=_cnk;7vNwrpvR zi`;faJIeb$4?Os<#Q}g(zwni%+V1&w{66?HKeg_j0~I41i1nfGUnez?f?P*DRuP6) z`fij4u;)0MJNDaMK>YXzYagHMZTn%(Q2Qu>Zc^G^%OPY-vp8l3DM?Bw2XlFF^w5uB z0@~kEGgo|1A{V*?x$DIYSdJ12C|Z_1A>Q#MTB0l3v(||WX>e+TmSO$!5|?&Jh)nyb zEvCmPF9_8`fw)%Q*3GP93JG(HFqy+Vg))NKP@nU>yqqzd?0dqYJXqxYBsFu^_gSm@ zr=7Vth;@gBV$ouqN)lSim|w3O+R_CF4*TMVRWqJ_)ue0x*V(n-+JYG0)wQ}YC%Juh zW7H9$wpDTgdr9^SpIfbhl)cYus|r8CwwL$A(y|CKD1!4n2uK=|51{BUoL+Du$R8Ld zQl|^zZw|=GbOPHOPzD5vDXP8fTq Td8hvVZnf7gj!vR1eR%_7o5&HIzlB0T2bBC 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 diff --git a/src/sampletones_assets/icons/sampletones.svg b/src/sampletones_assets/icons/sampletones.svg new file mode 100644 index 00000000..631ddf7d --- /dev/null +++ b/src/sampletones_assets/icons/sampletones.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/sampletones_core/audio/writers/capability.py b/src/sampletones_core/audio/writers/capability.py index aa8ad78f..924a95a3 100644 --- a/src/sampletones_core/audio/writers/capability.py +++ b/src/sampletones_core/audio/writers/capability.py @@ -2,7 +2,7 @@ from typing import Final, Mapping, Tuple from sampletones_core.constants.audio import SAMPLE_RATES -from sampletones_core.paths import EXT_FILE_MP3, EXT_FILE_WAVE +from sampletones_shared.paths.extensions import EXT_FILE_MP3, EXT_FILE_WAVE from .bitrate import MP3_SAMPLE_RATES from .format import AUDIO_DEPTHS, AudioDepth, AudioFormat diff --git a/src/sampletones_core/calibration/corpus/writer.py b/src/sampletones_core/calibration/corpus/writer.py index f7367a6b..43ecee39 100644 --- a/src/sampletones_core/calibration/corpus/writer.py +++ b/src/sampletones_core/calibration/corpus/writer.py @@ -2,7 +2,7 @@ from typing import Dict, List from sampletones_core.audio.io import write_wave -from sampletones_core.paths import EXT_FILE_WAVE +from sampletones_shared.paths.extensions import EXT_FILE_WAVE from sampletones_shared.utils.system.paths import get_filename from .item import CorpusItem diff --git a/src/sampletones_core/calibration/paths.py b/src/sampletones_core/calibration/paths.py index 27aed45a..751bbb53 100644 --- a/src/sampletones_core/calibration/paths.py +++ b/src/sampletones_core/calibration/paths.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Final -from sampletones_shared.paths import CONFIG_DIRECTORY +from sampletones_shared.paths.resources import CONFIG_DIRECTORY CALIBRATION_CONFIG_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "calibration" REFEREE_CONFIG_PATH: Final[Path] = CALIBRATION_CONFIG_DIRECTORY / "referee.yaml" diff --git a/src/sampletones_core/configs/config.py b/src/sampletones_core/configs/config.py index 5390791f..2dafc796 100644 --- a/src/sampletones_core/configs/config.py +++ b/src/sampletones_core/configs/config.py @@ -8,7 +8,7 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.data import DataModel from sampletones_core.data.metadata import Metadata -from sampletones_core.paths import CONFIG_PATH +from sampletones_shared.paths.user import CONFIG_PATH from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import load_json, save_json from sampletones_shared.utils.system.paths import to_path diff --git a/src/sampletones_core/configs/general.py b/src/sampletones_core/configs/general.py index 210696a6..b95c8a5f 100644 --- a/src/sampletones_core/configs/general.py +++ b/src/sampletones_core/configs/general.py @@ -10,7 +10,7 @@ ) from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH from sampletones_core.data import DataModel -from sampletones_core.paths import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY +from sampletones_shared.paths.user import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY class GeneralConfig(DataModel): diff --git a/src/sampletones_core/library/filename/fields.py b/src/sampletones_core/library/filename/fields.py index 5e050b77..0733a532 100644 --- a/src/sampletones_core/library/filename/fields.py +++ b/src/sampletones_core/library/filename/fields.py @@ -7,7 +7,7 @@ from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.constants.field_aliases import ALIASES -from sampletones_core.paths import EXT_FILE_LIBRARY +from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import HASH_PATTERN from sampletones_shared.utils.system.paths import get_filename diff --git a/src/sampletones_core/library/filename/utils.py b/src/sampletones_core/library/filename/utils.py index 26b0927b..5088d498 100644 --- a/src/sampletones_core/library/filename/utils.py +++ b/src/sampletones_core/library/filename/utils.py @@ -7,7 +7,7 @@ ) from sampletones_core.library.filename.fields import InstructionsFilenameFields from sampletones_core.library.key import InstructionLibraryKey -from sampletones_core.paths import EXT_FILE_LIBRARY +from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.system.paths import get_filename diff --git a/src/sampletones_core/library/library.py b/src/sampletones_core/library/library.py index 496d1691..76b0b5ee 100644 --- a/src/sampletones_core/library/library.py +++ b/src/sampletones_core/library/library.py @@ -7,8 +7,8 @@ from sampletones_core.configs import Config from sampletones_core.fft import Window -from sampletones_core.paths import LIBRARY_DIRECTORY from sampletones_shared.logger import logger +from sampletones_shared.paths.user import LIBRARY_DIRECTORY from .data import InstructionLibraryData from .key import InstructionLibraryKey diff --git a/src/sampletones_core/paths.py b/src/sampletones_core/paths.py deleted file mode 100644 index f0564761..00000000 --- a/src/sampletones_core/paths.py +++ /dev/null @@ -1,66 +0,0 @@ -from pathlib import Path -from typing import Final, Tuple - -from platformdirs import user_config_dir, user_data_dir, user_documents_path - -from sampletones_shared.application import ( - SAMPLETONES_GROUP, - SAMPLETONES_NAME, -) - -# User paths -USER_PATH_DOCUMENTS: Final[Path] = Path(user_documents_path()) / SAMPLETONES_NAME -USER_PATH_DATA: Final[Path] = Path(user_data_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP)) -USER_PATH_CONFIG: Final[Path] = Path(user_config_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP)) - -# Application paths -LIBRARY_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "instructions" -RECONSTRUCTIONS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "reconstructions" -PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects" -CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json" -APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml" - -# File extensions -EXT_FILE_JSON: Final[str] = ".json" -EXT_FILE_YAML: Final[str] = ".yaml" -EXT_FILE_LIBRARY: Final[str] = ".ins" -EXT_FILE_INSTRUMENT: Final[str] = ".fti" -EXT_FILE_RECONSTRUCTION: Final[str] = ".stn" -EXT_FILE_PROJECT: Final[str] = ".stp" -EXT_FILE_MODULE: Final[str] = ".ftm" -EXT_FILE_BITPHASE: Final[str] = ".btp" -EXT_FILE_WAVE: Final[str] = ".wav" -EXT_FILE_MP3: Final[str] = ".mp3" -EXT_FILE_FLAC: Final[str] = ".flac" -EXT_FILE_OGG: Final[str] = ".ogg" -EXT_FILE_AIFF: Final[str] = ".aiff" -EXT_FILE_AU: Final[str] = ".au" -EXT_FILES_AUDIO: Final[Tuple[str, ...]] = ( - EXT_FILE_WAVE, - EXT_FILE_MP3, - EXT_FILE_FLAC, - EXT_FILE_OGG, - EXT_FILE_AIFF, - EXT_FILE_AU, -) - -# Assets -ASSETS_DIRECTORY: Final[str] = "assets" - -# Icon filenames -ICON_DIRECTORY: Final[str] = "icons" -ICON_WIN_FILENAME: Final[str] = "sampletones.ico" -ICON_UNIX_FILENAME: Final[str] = "sampletones.png" - -# Font paths -FONT_DIRECTORY: Final[str] = "fonts" -FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf" -FONT_SANS_BOLD: Final[str] = "SourceSans3-Bold.ttf" -FONT_SANS_ITALIC: Final[str] = "SourceSans3-Italic.ttf" -FONT_MONO_REGULAR: Final[str] = "RobotoMono-Regular.ttf" -FONT_MONO_BOLD: Final[str] = "RobotoMono-Bold.ttf" -FONT_ICON: Final[str] = "DejaVuSans.ttf" - -PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True) -LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True) -RECONSTRUCTIONS_DIRECTORY.mkdir(parents=True, exist_ok=True) diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py index c5186a2c..375b93e4 100644 --- a/src/sampletones_core/project/container.py +++ b/src/sampletones_core/project/container.py @@ -4,7 +4,6 @@ from pydantic import ValidationError -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.project.document import ProjectDocument from sampletones_core.project.instruments.record import SampleRecord from sampletones_core.project.instruments.sample import Sample @@ -27,6 +26,7 @@ NotAValidArchiveError, UnhandledProjectError, ) +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import JSON_INDENT from sampletones_shared.utils.system.paths import get_filename diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index 481ccc32..3069f11b 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -2,13 +2,13 @@ from typing import List, Tuple from sampletones_core.configs import Config -from sampletones_core.paths import ( - EXT_FILE_RECONSTRUCTION, - EXT_FILES_AUDIO, -) from sampletones_core.reconstructions.converter.paths.fields import ( ConfigDirectoryFields, ) +from sampletones_shared.paths.extensions import ( + EXT_FILE_RECONSTRUCTION, + EXT_FILES_AUDIO, +) from sampletones_shared.utils.system.paths import to_path diff --git a/src/sampletones_core/trackers/implementation/bitphase.py b/src/sampletones_core/trackers/implementation/bitphase.py index 83e40cf5..38b3561e 100644 --- a/src/sampletones_core/trackers/implementation/bitphase.py +++ b/src/sampletones_core/trackers/implementation/bitphase.py @@ -8,11 +8,11 @@ sample_to_bitphase, ) from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset -from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON from sampletones_core.trackers.artifact import ExportArtifact from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON from sampletones_shared.utils.system.paths import get_filename DOCUMENT_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) diff --git a/src/sampletones_core/trackers/implementation/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py index ee84198e..dfe9bee0 100644 --- a/src/sampletones_core/trackers/implementation/famitracker.py +++ b/src/sampletones_core/trackers/implementation/famitracker.py @@ -11,7 +11,6 @@ from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) -from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from sampletones_core.trackers.artifact import ExportArtifact from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.request import ( @@ -20,6 +19,7 @@ SampleExport, ) from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from sampletones_shared.utils.system.paths import get_filename SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) diff --git a/src/sampletones_shared/meta/source/packages.py b/src/sampletones_shared/meta/source/packages.py index 772c40f5..f19a3899 100644 --- a/src/sampletones_shared/meta/source/packages.py +++ b/src/sampletones_shared/meta/source/packages.py @@ -1,6 +1,6 @@ from pathlib import Path -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT def package_directory(name: str, *parts: str) -> Path: diff --git a/src/sampletones_shared/paths.py b/src/sampletones_shared/paths.py deleted file mode 100644 index 53464482..00000000 --- a/src/sampletones_shared/paths.py +++ /dev/null @@ -1,12 +0,0 @@ -import sys -from importlib.resources import files -from pathlib import Path -from typing import Final, Optional - -_BUNDLE_ROOT: Final[Optional[str]] = getattr(sys, "_MEIPASS", None) - -CONFIG_DIRECTORY: Final[Path] = ( - Path(_BUNDLE_ROOT) / "config" if _BUNDLE_ROOT is not None else Path(str(files("sampletones_config"))) -) -SOURCE_ROOT: Final[Path] = Path(__file__).resolve().parents[1] -REPOSITORY_ROOT: Final[Path] = SOURCE_ROOT.parent diff --git a/src/sampletones_shared/paths/__init__.py b/src/sampletones_shared/paths/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_shared/paths/extensions.py b/src/sampletones_shared/paths/extensions.py new file mode 100644 index 00000000..857c1c91 --- /dev/null +++ b/src/sampletones_shared/paths/extensions.py @@ -0,0 +1,24 @@ +from typing import Final, Tuple + +EXT_FILE_JSON: Final[str] = ".json" +EXT_FILE_YAML: Final[str] = ".yaml" +EXT_FILE_LIBRARY: Final[str] = ".ins" +EXT_FILE_INSTRUMENT: Final[str] = ".fti" +EXT_FILE_RECONSTRUCTION: Final[str] = ".stn" +EXT_FILE_PROJECT: Final[str] = ".stp" +EXT_FILE_MODULE: Final[str] = ".ftm" +EXT_FILE_BITPHASE: Final[str] = ".btp" +EXT_FILE_WAVE: Final[str] = ".wav" +EXT_FILE_MP3: Final[str] = ".mp3" +EXT_FILE_FLAC: Final[str] = ".flac" +EXT_FILE_OGG: Final[str] = ".ogg" +EXT_FILE_AIFF: Final[str] = ".aiff" +EXT_FILE_AU: Final[str] = ".au" +EXT_FILES_AUDIO: Final[Tuple[str, ...]] = ( + EXT_FILE_WAVE, + EXT_FILE_MP3, + EXT_FILE_FLAC, + EXT_FILE_OGG, + EXT_FILE_AIFF, + EXT_FILE_AU, +) diff --git a/src/sampletones_shared/paths/resources.py b/src/sampletones_shared/paths/resources.py new file mode 100644 index 00000000..fa596d93 --- /dev/null +++ b/src/sampletones_shared/paths/resources.py @@ -0,0 +1,24 @@ +import sys +from importlib.resources import files +from pathlib import Path +from typing import Final, Optional + +_BUNDLE_ROOT: Final[Optional[str]] = getattr(sys, "_MEIPASS", None) + +CONFIG_DIRECTORY: Final[Path] = ( + Path(_BUNDLE_ROOT) / "config" if _BUNDLE_ROOT is not None else Path(str(files("sampletones_config"))) +) + +ASSETS_DIRECTORY: Final[str] = "assets" + +ICON_DIRECTORY: Final[str] = "icons" +ICON_WIN_FILENAME: Final[str] = "sampletones.ico" +ICON_UNIX_FILENAME: Final[str] = "sampletones.png" + +FONT_DIRECTORY: Final[str] = "fonts" +FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf" +FONT_SANS_BOLD: Final[str] = "SourceSans3-Bold.ttf" +FONT_SANS_ITALIC: Final[str] = "SourceSans3-Italic.ttf" +FONT_MONO_REGULAR: Final[str] = "RobotoMono-Regular.ttf" +FONT_MONO_BOLD: Final[str] = "RobotoMono-Bold.ttf" +FONT_ICON: Final[str] = "DejaVuSans.ttf" diff --git a/src/sampletones_shared/paths/source.py b/src/sampletones_shared/paths/source.py new file mode 100644 index 00000000..51dd98fa --- /dev/null +++ b/src/sampletones_shared/paths/source.py @@ -0,0 +1,5 @@ +from pathlib import Path +from typing import Final + +SOURCE_ROOT: Final[Path] = Path(__file__).resolve().parents[2] +REPOSITORY_ROOT: Final[Path] = SOURCE_ROOT.parent diff --git a/src/sampletones_shared/paths/user.py b/src/sampletones_shared/paths/user.py new file mode 100644 index 00000000..6a34673d --- /dev/null +++ b/src/sampletones_shared/paths/user.py @@ -0,0 +1,23 @@ +from pathlib import Path +from typing import Final + +from platformdirs import user_config_dir, user_data_dir, user_documents_path + +from sampletones_shared.application import ( + SAMPLETONES_GROUP, + SAMPLETONES_NAME, +) + +USER_PATH_DOCUMENTS: Final[Path] = Path(user_documents_path()) / SAMPLETONES_NAME +USER_PATH_DATA: Final[Path] = Path(user_data_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP)) +USER_PATH_CONFIG: Final[Path] = Path(user_config_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP)) + +LIBRARY_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "instructions" +RECONSTRUCTIONS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "reconstructions" +PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects" +CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json" +APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml" + +PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True) +LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True) +RECONSTRUCTIONS_DIRECTORY.mkdir(parents=True, exist_ok=True) diff --git a/tests/integration/tooling/test_check_commands.py b/tests/integration/tooling/test_check_commands.py index fee708dc..a265cea4 100644 --- a/tests/integration/tooling/test_check_commands.py +++ b/tests/integration/tooling/test_check_commands.py @@ -3,7 +3,7 @@ import yaml -from sampletones_shared.paths import REPOSITORY_ROOT +from sampletones_shared.paths.source import REPOSITORY_ROOT PRE_COMMIT_CONFIG: Final[Path] = REPOSITORY_ROOT / ".pre-commit-config.yaml" MAKEFILE: Final[Path] = REPOSITORY_ROOT / "Makefile" diff --git a/tests/suite/scripts.py b/tests/suite/scripts.py index 97a5263e..413fa9d7 100644 --- a/tests/suite/scripts.py +++ b/tests/suite/scripts.py @@ -1,7 +1,7 @@ import importlib.util from types import ModuleType -from sampletones_shared.paths import REPOSITORY_ROOT +from sampletones_shared.paths.source import REPOSITORY_ROOT def load_script(relative_path: str) -> ModuleType: diff --git a/tests/unit/sampletones_application/config/test_profile.py b/tests/unit/sampletones_application/config/test_profile.py index f1d3e5aa..4a123e29 100644 --- a/tests/unit/sampletones_application/config/test_profile.py +++ b/tests/unit/sampletones_application/config/test_profile.py @@ -2,7 +2,7 @@ from sampletones_application.config.profile import UserProfile from sampletones_application.paths import APPLICATION_STATE_PATH -from sampletones_core.paths import APPLICATION_CONFIG_PATH +from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH class TestUserProfile: diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py index 41662f90..8a1cae3e 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -16,9 +16,9 @@ ReconstructionScan, ) from sampletones_core.constants.enums import SpectrumMethod -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from tests.suite.language import FakeLanguageManager HASH_A: Final[str] = "6edf7c948606917a78b45d153c7ca7e0" diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 8c9f1c40..0d426cfb 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -19,15 +19,15 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import AudioSourceType, GeneratorName from sampletones_core.instructions import TriangleInstruction -from sampletones_core.paths import ( +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.registry import build_tracker_backends +from sampletones_shared.paths.extensions import ( EXT_FILE_BITPHASE, EXT_FILE_INSTRUMENT, EXT_FILE_JSON, EXT_FILE_MODULE, ) -from sampletones_core.reconstructions import Reconstruction -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.registry import build_tracker_backends from tests.suite.case import BaseRegularTestCase NO_EXTENSION: Final[str] = "" diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py index 3a7763e8..afc741b3 100644 --- a/tests/unit/sampletones_application/logic/shared/test_tree.py +++ b/tests/unit/sampletones_application/logic/shared/test_tree.py @@ -10,9 +10,9 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.logic.shared.tree import TreeLogic -from sampletones_core import paths from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode from sampletones_shared.exceptions import InvalidReconstructionError +from sampletones_shared.paths import extensions def _tree( @@ -360,7 +360,7 @@ def test_load_failure_reports_autoplay_error( audio_device_manager = MagicMock() tree = _tree(audio_device_manager=audio_device_manager) tree.on_autoplay_error = MagicMock() - node = _file_node(tmp_path / f"sample{paths.EXT_FILE_RECONSTRUCTION}") + node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") with patch( "sampletones_application.logic.shared.tree.Reconstruction.load", @@ -374,7 +374,7 @@ def test_load_failure_reports_autoplay_error( def test_unexpected_failure_propagates(self, tmp_path: Path) -> None: tree = _tree() tree.on_autoplay_error = MagicMock() - node = _file_node(tmp_path / f"sample{paths.EXT_FILE_RECONSTRUCTION}") + node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") with ( patch( diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py index d7461250..1c3c57ff 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py @@ -7,10 +7,10 @@ from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_core.configs import Config from sampletones_core.configs.display import format_sample_rate, short_hash -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree.node import ConfigNode, FileSystemNode, TreeNode from sampletones_core.structures.tree.type import NodeType +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from tests.suite.language import FakeLanguageManager CONFIG_FIELDS: Final[ConfigDirectoryFields] = ConfigDirectoryFields.from_config(Config()) diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py index 4c314e7e..75325efe 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py @@ -6,7 +6,7 @@ from sampletones_application.paths import KEYBINDINGS_DIRECTORY from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.paths import EXT_FILE_YAML +from sampletones_shared.paths.extensions import EXT_FILE_YAML SHIPPED_FILE = KEYBINDINGS_DIRECTORY / f"{DEFAULT_SCHEME_NAME}{EXT_FILE_YAML}" SHIPPED_SCHEME_NAMES = ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).names diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py index 4a690e0e..868689bf 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -12,7 +12,7 @@ from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme from sampletones_application.utils.gui.shortcuts.written import WrittenShortcut -from sampletones_core.paths import EXT_FILE_YAML +from sampletones_shared.paths.extensions import EXT_FILE_YAML from tests.unit.sampletones_application.utils.gui.shortcuts.conftest import ( PROBE_SCHEME_NAME, RebindScheme, diff --git a/tests/unit/sampletones_core/audio/writers/test_spec.py b/tests/unit/sampletones_core/audio/writers/test_spec.py index cf3961be..cdbc328f 100644 --- a/tests/unit/sampletones_core/audio/writers/test_spec.py +++ b/tests/unit/sampletones_core/audio/writers/test_spec.py @@ -15,7 +15,7 @@ mp3_bitrates, ) from sampletones_core.constants.audio import SAMPLE_RATES -from sampletones_core.paths import EXT_FILE_MP3, EXT_FILE_WAVE +from sampletones_shared.paths.extensions import EXT_FILE_MP3, EXT_FILE_WAVE from tests.suite.base import BaseTestSuite MPEG_1_RATE: Final[int] = 44100 diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py index 10485b18..dc7b3aba 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_btp.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py @@ -13,7 +13,7 @@ CHIP_TYPE_NES, TUNING_TABLE_LENGTH, ) -from sampletones_core.paths import EXT_FILE_BITPHASE +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE from .conftest import build_features, build_instrument, build_sample diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py index 322f0e3f..ed3b8e11 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py @@ -19,7 +19,7 @@ MIN_TONE_ADD, NO_TONE_OFFSET, ) -from sampletones_core.paths import EXT_FILE_JSON +from sampletones_shared.paths.extensions import EXT_FILE_JSON from .conftest import REFERENCE_PITCH, build_features, build_instrument diff --git a/tests/unit/sampletones_core/library/filename/test_fields.py b/tests/unit/sampletones_core/library/filename/test_fields.py index 466cfe03..1dbc7b07 100644 --- a/tests/unit/sampletones_core/library/filename/test_fields.py +++ b/tests/unit/sampletones_core/library/filename/test_fields.py @@ -7,7 +7,7 @@ FILENAME_SEPARATOR, InstructionsFilenameFields, ) -from sampletones_core.paths import EXT_FILE_LIBRARY +from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.errors import expect_error diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py index 4c38eec0..6955b77c 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py @@ -4,13 +4,13 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ( filter_files, get_audio_files, get_output_path, get_relative_path, ) +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION @pytest.fixture(scope="module") diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/trackers/test_bitphase.py index 1bbd52a3..4c9aa41f 100644 --- a/tests/unit/sampletones_core/trackers/test_bitphase.py +++ b/tests/unit/sampletones_core/trackers/test_bitphase.py @@ -9,7 +9,6 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters import Features -from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.trackers.format import TrackerFormat @@ -23,6 +22,7 @@ SampleExport, ) from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON NES_FREQUENCY: Final[int] = 60 REFERENCE_PITCH: Final[int] = 60 diff --git a/tests/unit/sampletones_core/trackers/test_extensions.py b/tests/unit/sampletones_core/trackers/test_extensions.py index 10ac1651..b9cc907d 100644 --- a/tests/unit/sampletones_core/trackers/test_extensions.py +++ b/tests/unit/sampletones_core/trackers/test_extensions.py @@ -3,17 +3,17 @@ import pytest -from sampletones_core.paths import ( - EXT_FILE_BITPHASE, - EXT_FILE_INSTRUMENT, - EXT_FILE_JSON, - EXT_FILE_MODULE, -) from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.extensions import format_for_extension from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.registry import build_tracker_backends from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, +) UNKNOWN_EXTENSION: Final[str] = ".xm" NO_EXTENSION: Final[str] = "" diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py index 5aedf2c4..3f036b02 100644 --- a/tests/unit/sampletones_core/trackers/test_famitracker.py +++ b/tests/unit/sampletones_core/trackers/test_famitracker.py @@ -10,11 +10,11 @@ from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) -from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend from sampletones_core.trackers.request import InstrumentExport, SampleExport from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE NES_FREQUENCY: Final[int] = 60 diff --git a/tests/unit/sampletones_shared/meta/source/test_packages.py b/tests/unit/sampletones_shared/meta/source/test_packages.py index bc2333ad..e18e8b85 100644 --- a/tests/unit/sampletones_shared/meta/source/test_packages.py +++ b/tests/unit/sampletones_shared/meta/source/test_packages.py @@ -1,7 +1,7 @@ import pytest from sampletones_shared.meta.source.packages import package_directory -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT SHARED_PACKAGE = "sampletones_shared" APPLICATION_PACKAGE = "sampletones_application" diff --git a/tests/unit/sampletones_shared/paths/__init__.py b/tests/unit/sampletones_shared/paths/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_shared/paths/test_resources.py b/tests/unit/sampletones_shared/paths/test_resources.py new file mode 100644 index 00000000..53ca661a --- /dev/null +++ b/tests/unit/sampletones_shared/paths/test_resources.py @@ -0,0 +1,7 @@ +from sampletones_shared.paths.resources import CONFIG_DIRECTORY + + +class TestConfigDirectory: + def test_the_configuration_directory_holds_the_shipped_files(self) -> None: + """Read as a package resource, so the bundle finds it beside the executable.""" + assert list(CONFIG_DIRECTORY.rglob("*.yaml")) diff --git a/tests/unit/sampletones_shared/test_paths.py b/tests/unit/sampletones_shared/paths/test_source.py similarity index 63% rename from tests/unit/sampletones_shared/test_paths.py rename to tests/unit/sampletones_shared/paths/test_source.py index ae1c2fe2..6d5f1ec8 100644 --- a/tests/unit/sampletones_shared/test_paths.py +++ b/tests/unit/sampletones_shared/paths/test_source.py @@ -1,4 +1,4 @@ -from sampletones_shared.paths import CONFIG_DIRECTORY, REPOSITORY_ROOT, SOURCE_ROOT +from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT PROJECT_FILE = "pyproject.toml" SHARED_PACKAGE = "sampletones_shared" @@ -10,7 +10,7 @@ def test_the_source_root_holds_the_packages(self) -> None: def test_the_source_root_is_where_this_package_lives(self) -> None: """Reading the root off the package keeps it right wherever the packages are installed.""" - assert (SOURCE_ROOT / SHARED_PACKAGE / "paths.py").is_file() + assert (SOURCE_ROOT / SHARED_PACKAGE / "paths" / "source.py").is_file() class TestRepositoryRoot: @@ -19,9 +19,3 @@ def test_the_repository_root_holds_the_project_file(self) -> None: def test_the_repository_root_holds_the_scripts_the_checks_run_from(self) -> None: assert (REPOSITORY_ROOT / "scripts" / "checks").is_dir() - - -class TestConfigDirectory: - def test_the_configuration_directory_holds_the_shipped_files(self) -> None: - """Read as a package resource, so the bundle finds it beside the executable.""" - assert list(CONFIG_DIRECTORY.rglob("*.yaml")) diff --git a/tests/unit/sampletones_shared/paths/test_user.py b/tests/unit/sampletones_shared/paths/test_user.py new file mode 100644 index 00000000..d26807b2 --- /dev/null +++ b/tests/unit/sampletones_shared/paths/test_user.py @@ -0,0 +1,16 @@ +from sampletones_shared.paths.user import ( + LIBRARY_DIRECTORY, + PROJECTS_DIRECTORY, + RECONSTRUCTIONS_DIRECTORY, +) + + +class TestUserDirectories: + def test_the_user_directories_exist_after_import(self) -> None: + """Importing the module creates the directories the application saves into.""" + for directory in ( + LIBRARY_DIRECTORY, + PROJECTS_DIRECTORY, + RECONSTRUCTIONS_DIRECTORY, + ): + assert directory.is_dir() diff --git a/tests/unit/scripts/checks/test_palette_colors.py b/tests/unit/scripts/checks/test_palette_colors.py index 0d51b8a7..2056eb0d 100644 --- a/tests/unit/scripts/checks/test_palette_colors.py +++ b/tests/unit/scripts/checks/test_palette_colors.py @@ -5,7 +5,7 @@ from sampletones_application.paths import PALETTES_DIRECTORY from sampletones_shared.meta.source.modules import SourceModule, source_paths -from sampletones_shared.paths import CONFIG_DIRECTORY +from sampletones_shared.paths.resources import CONFIG_DIRECTORY from scripts.checks.palette_colors import dpg_module_helper from tests.suite.scripts import load_script from tests.suite.source import parse_source diff --git a/tests/unit/scripts/checks/test_tag_names.py b/tests/unit/scripts/checks/test_tag_names.py index a79a294f..3b03f4fc 100644 --- a/tests/unit/scripts/checks/test_tag_names.py +++ b/tests/unit/scripts/checks/test_tag_names.py @@ -7,7 +7,7 @@ from sampletones_application.categories.hierarchy import Page, Panel, Widget from sampletones_application.categories.key.tag import TagName from sampletones_shared.meta.source.modules import SourceModule, source_paths -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.scripts import load_script diff --git a/tests/unit/scripts/checks/test_unused_tags.py b/tests/unit/scripts/checks/test_unused_tags.py index 4d8456f0..167e4a90 100644 --- a/tests/unit/scripts/checks/test_unused_tags.py +++ b/tests/unit/scripts/checks/test_unused_tags.py @@ -4,7 +4,7 @@ import pytest from sampletones_shared.meta.source.modules import SourceModule, source_paths -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT from tests.suite.scripts import load_script from tests.suite.source import parse_source diff --git a/uv.lock b/uv.lock index d8dc1be3..ac66c620 100644 --- a/uv.lock +++ b/uv.lock @@ -1236,6 +1236,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + [[package]] name = "platformdirs" version = "4.9.6" @@ -1754,6 +1825,9 @@ gpu-cuda11 = [ ] [package.dev-dependencies] +assets = [ + { name = "pillow" }, +] dev = [ { name = "black" }, { name = "isort" }, @@ -1796,6 +1870,7 @@ requires-dist = [ provides-extras = ["build", "gpu", "gpu-cuda11"] [package.metadata.requires-dev] +assets = [{ name = "pillow", specifier = ">=11,<13" }] dev = [ { name = "black", specifier = "==26.5.1" }, { name = "isort", specifier = "==8.0.1" }, From a2f2eadd5682ecd770abf63d65d6d6cfd660bb1b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 16:58:12 +0200 Subject: [PATCH 2/6] Moved: icon generation into the sampletones_assets --- .github/workflows/workflow.yml | 5 +- .gitignore | 11 +- CHANGELOG.md | 1 + docs/development/dependencies.md | 15 +- pyproject.toml | 9 + scripts/assets/icons.py | 333 +----------------- src/sampletones_assets/mark/__init__.py | 0 src/sampletones_assets/mark/geometry.py | 128 +++++++ src/sampletones_assets/mark/mark.yaml | 43 +++ src/sampletones_assets/mark/paths.py | 7 + src/sampletones_assets/mark/raster.py | 116 ++++++ .../mark/specification/__init__.py | 38 ++ .../mark/specification/colors.py | 29 ++ .../mark/specification/frame.py | 43 +++ .../mark/specification/point.py | 16 + .../mark/specification/render.py | 28 ++ .../mark/specification/waves.py | 59 ++++ src/sampletones_assets/mark/suite.py | 67 ++++ src/sampletones_assets/mark/template.svg | 12 + src/sampletones_assets/mark/vector.py | 71 ++++ src/sampletones_shared/paths/resources.py | 1 + tests/unit/sampletones_assets/__init__.py | 0 .../unit/sampletones_assets/mark/__init__.py | 0 .../sampletones_assets/mark/test_geometry.py | 69 ++++ .../sampletones_assets/mark/test_raster.py | 47 +++ .../mark/test_specification.py | 176 +++++++++ .../sampletones_assets/mark/test_suite.py | 55 +++ .../sampletones_assets/mark/test_vector.py | 47 +++ uv.lock | 2 + 29 files changed, 1090 insertions(+), 338 deletions(-) create mode 100644 src/sampletones_assets/mark/__init__.py create mode 100644 src/sampletones_assets/mark/geometry.py create mode 100644 src/sampletones_assets/mark/mark.yaml create mode 100644 src/sampletones_assets/mark/paths.py create mode 100644 src/sampletones_assets/mark/raster.py create mode 100644 src/sampletones_assets/mark/specification/__init__.py create mode 100644 src/sampletones_assets/mark/specification/colors.py create mode 100644 src/sampletones_assets/mark/specification/frame.py create mode 100644 src/sampletones_assets/mark/specification/point.py create mode 100644 src/sampletones_assets/mark/specification/render.py create mode 100644 src/sampletones_assets/mark/specification/waves.py create mode 100644 src/sampletones_assets/mark/suite.py create mode 100644 src/sampletones_assets/mark/template.svg create mode 100644 src/sampletones_assets/mark/vector.py create mode 100644 tests/unit/sampletones_assets/__init__.py create mode 100644 tests/unit/sampletones_assets/mark/__init__.py create mode 100644 tests/unit/sampletones_assets/mark/test_geometry.py create mode 100644 tests/unit/sampletones_assets/mark/test_raster.py create mode 100644 tests/unit/sampletones_assets/mark/test_specification.py create mode 100644 tests/unit/sampletones_assets/mark/test_suite.py create mode 100644 tests/unit/sampletones_assets/mark/test_vector.py diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 2e5e7fb8..8663dcc8 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -37,8 +37,11 @@ jobs: --tag "$GITHUB_REF_NAME" \ --project-version "$(uv version --short)" + - name: Install PortAudio + run: sudo apt-get update && sudo apt-get install -y portaudio19-dev + - name: Generate the icon suite - run: uv run --only-group assets python scripts/assets/icons.py + run: uv run --group assets python scripts/assets/icons.py - name: Build sdist and wheel run: uv build diff --git a/.gitignore b/.gitignore index 02a45caf..5622d69b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,15 +4,15 @@ __pycache__/ .ipynb_checkpoints/ .mypy_cache/ .pytest_cache/ +.ruff_cache/ .venv/ .venv-build/ .vscode/ -bin/ -build/ dist/ wheels/ -!scripts/**/build/ +/bin/ +/build/ sampletones !src/sampletones @@ -21,11 +21,6 @@ sampletones src/sampletones_assets/icons/sampletones.ico src/sampletones_assets/icons/sampletones.png -**/*.idea -**/*.vscode/** -**/*.ipynb_checkpoints/** -**/*__pycache__/** - *.pyc *.pyo *.coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 7114660c..70f9edbd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * Improved Sequencer module playback. * Added song export to WAV/MP3. * Added tracker selection operations. +* Added a _SampleToNES_ logo. ## v0.3.0 [2026-07-31] diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index 1107c943..d6b4035c 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -53,12 +53,15 @@ Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser ## Application icon -The icon suite in `src/sampletones_assets/icons` is generated: `scripts/assets/icons.py` holds the -mark's geometry and writes the vector `sampletones.svg` together with the rasters the application -ships, `sampletones.png` and the multi-resolution `sampletones.ico`. Rasterization uses Pillow, -declared in the `assets` dependency group. The SVG is committed as the design source, and the -rasters are produced where they are consumed: `make setup` writes them before packaging the wheel, -and the bundle scripts write them before PyInstaller embeds them. +The icon suite in `src/sampletones_assets/icons` is generated from the mark declared beside it in +`src/sampletones_assets/mark`: `mark.yaml` carries the geometry, colours and rasterization +settings, validated as a `Mark`, and `template.svg` is the vector the rendered geometry fills. The +package writes the whole suite — the vector `sampletones.svg` and the rasters the application +ships, `sampletones.png` and the multi-resolution `sampletones.ico` — and `scripts/assets/icons.py` +points it at the directory the icons are shipped from. Rasterization uses Pillow, declared in the +`assets` dependency group. The vector is committed, so the mark reads as a picture in a browser or +an editor, and the rasters are produced where they are consumed: `make setup` writes them before +packaging the wheel, and the bundle scripts write them before PyInstaller embeds them. ## Linux (standalone executable) diff --git a/pyproject.toml b/pyproject.toml index d5d30602..8e54812e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ gpu-cuda11 = [ [dependency-groups] assets = ["pillow>=11,<13"] dev = [ + { include-group = "assets" }, "black==26.5.1", "isort==8.0.1", "mypy==2.1.0", @@ -96,6 +97,12 @@ build-backend = "hatchling.build" [tool.uv] conflicts = [[{ extra = "gpu" }, { extra = "gpu-cuda11" }]] +[tool.hatch.build] +artifacts = [ + "src/sampletones_assets/icons/sampletones.png", + "src/sampletones_assets/icons/sampletones.ico", +] + [tool.hatch.build.targets.wheel] packages = [ "src/sampletones", @@ -130,6 +137,7 @@ addopts = "--import-mode=importlib" [tool.coverage.run] source = [ "sampletones_application", + "sampletones_assets", "sampletones_core", "sampletones_shared", "sampletones_synthesis", @@ -144,6 +152,7 @@ python_version = "3.12" files = [ "src/sampletones", "src/sampletones_application", + "src/sampletones_assets", "src/sampletones_core", "src/sampletones_shared", "src/sampletones_synthesis", diff --git a/scripts/assets/icons.py b/scripts/assets/icons.py index 65a99a35..c6484eb9 100755 --- a/scripts/assets/icons.py +++ b/scripts/assets/icons.py @@ -1,345 +1,32 @@ #!/usr/bin/env python3 """ -Builds the application icon suite into `src/sampletones_assets/icons`. +Writes the application icon suite from the packaged mark definition. -One geometry definition on a 64-unit grid draws the mark — a smooth sample entering as a -blue sine wave and leaving as an amber square wave, on the studio palette — and every -shipped icon derives from it: the vector `sampletones.svg`, the raster `sampletones.png`, -and the multi-resolution `sampletones.ico`. The raster filenames match the resources the -application resolves through `sampletones_shared/paths`. +The mark, its template and the code drawing them live in `sampletones_assets/mark`; this +script points them at the directory the icons are shipped from. Usage: python scripts/assets/icons.py # write the suite into src/sampletones_assets/icons """ import argparse -import itertools import sys from pathlib import Path -from typing import Final, List, Sequence, Tuple +from typing import Final, Sequence -from PIL import ( # TODO: update THIRD-PARTY-* files, revise LICENSE if still holds - Image, - ImageDraw, -) +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.suite import write_icon_suite -Point = Tuple[float, float] -Rectangle = Tuple[float, float, float, float] +REPOSITORY_ROOT: Final[Path] = Path(__file__).resolve().parents[2] +ICONS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "src" / "sampletones_assets" / "icons" -PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2] -ICONS_DIRECTORY: Final[Path] = PROJECT_ROOT / "src" / "sampletones_assets" / "icons" -# TODO: take the raster filenames from sampletones_shared.paths.resources -VECTOR_FILENAME: Final[str] = "sampletones.svg" -UNIX_ICON_FILENAME: Final[str] = "sampletones.png" -WINDOWS_ICON_FILENAME: Final[str] = "sampletones.ico" - -# TODO: SVG configuration should be a YAML file based on a validated Pydantic class -# not a set hardcoded constants; I suggest a nested structure, organizing fields into -# logical units -GRID: Final[int] = 64 -CORNER_RADIUS: Final[float] = 14.0 -RIM_INSET: Final[float] = 1.0 -RIM_WIDTH: Final[float] = 2.0 -RIM_OPACITY: Final[float] = 0.14 -WAVE_WIDTH: Final[float] = 4.0 - -BACKGROUND_TOP: Final[str] = "#3a3650" -BACKGROUND_BOTTOM: Final[str] = "#211d30" -SINE_COLOR: Final[str] = "#64c8ff" -SQUARE_COLOR: Final[str] = "#ffc864" -RIM_COLOR: Final[str] = "#cdb6ff" - -SINE_START: Final[Point] = (8.0, 32.0) -SINE_CURVES: Final[Tuple[Tuple[Point, Point, Point], ...]] = ( - ((11.0, 16.0), (15.0, 16.0), (18.0, 32.0)), - ((21.0, 48.0), (25.0, 48.0), (28.0, 32.0)), -) -SQUARE_POINTS: Final[Tuple[Point, ...]] = ( - (28.0, 32.0), - (28.0, 20.0), - (38.0, 20.0), - (38.0, 44.0), - (48.0, 44.0), - (48.0, 20.0), - (56.0, 20.0), - (56.0, 32.0), -) - -SUPERSAMPLE: Final[int] = 16 -CURVE_SAMPLES: Final[int] = 96 -RASTER_SIZE: Final[int] = 256 -ICO_SIZES: Final[Tuple[int, ...]] = (256, 128, 64, 48, 32, 24, 16) - - -def _grid_number(value: float) -> str: - return f"{value:g}" - - -def _sine_path() -> str: - commands = [f"M{_grid_number(SINE_START[0])} {_grid_number(SINE_START[1])}"] - for curve in SINE_CURVES: - points = " ".join(f"{_grid_number(x)} {_grid_number(y)}" for x, y in curve) - commands.append(f"C{points}") - - return " ".join(commands) - - -def _square_path() -> str: - start_x, start_y = SQUARE_POINTS[0] - commands = [f"M{_grid_number(start_x)} {_grid_number(start_y)}"] - for (previous_x, _), (x, y) in itertools.pairwise(SQUARE_POINTS): - commands.append(f"V{_grid_number(y)}" if x == previous_x else f"H{_grid_number(x)}") - - return " ".join(commands) - - -# TODO: refactor - this should be a proper template as an asset, not hardcoded -def svg_document() -> str: - """The mark as a standalone vector, with coordinates on the even design grid. - - Grid alignment keeps the wave edges on whole pixels when the icon is rasterized - at 32 px and 16 px. - """ - rim_extent = _grid_number(GRID - 2 * RIM_INSET) - return ( - f'\n' - " \n" - ' \n' - f' \n' - f' \n' - " \n" - " \n" - f' \n' - f' \n' - f' \n' - f' \n' - "\n" - ) - - -def _background(canvas: int) -> Image.Image: - top = Image.new("RGB", (canvas, canvas), BACKGROUND_TOP) - bottom = Image.new("RGB", (canvas, canvas), BACKGROUND_BOTTOM) - blend = Image.linear_gradient("L").resize((canvas, canvas)) - shaded = Image.composite(bottom, top, blend) - - mask = Image.new("L", (canvas, canvas), 0) - ImageDraw.Draw(mask).rounded_rectangle( - (0, 0, canvas - 1, canvas - 1), - radius=CORNER_RADIUS * SUPERSAMPLE, - fill=255, - ) - - background = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) - background.paste(shaded, mask=mask) - return background - - -def _cubic_coordinate( - start: float, - control_one: float, - control_two: float, - end: float, - progress: float, -) -> float: - remainder = 1.0 - progress - return ( - remainder**3 * start - + 3 * remainder**2 * progress * control_one - + 3 * remainder * progress**2 * control_two - + progress**3 * end - ) - - -def _sine_points() -> List[Point]: - points: List[Point] = [SINE_START] - position = SINE_START - for control_one, control_two, end in SINE_CURVES: - for step in range(1, CURVE_SAMPLES + 1): - progress = step / CURVE_SAMPLES - points.append( - ( - _cubic_coordinate( - position[0], - control_one[0], - control_two[0], - end[0], - progress, - ), - _cubic_coordinate( - position[1], - control_one[1], - control_two[1], - end[1], - progress, - ), - ) - ) - position = end - - return points - - -def _draw_sine(draw: ImageDraw.ImageDraw) -> None: - """Sweeps a disk of the stroke's half width along the curve. - - The union of densely stamped disks equals a round-capped stroke of the curve and - keeps the outline smooth, where a single wide polyline call serrates its edges. - """ - radius = WAVE_WIDTH * SUPERSAMPLE / 2 - for x, y in _sine_points(): - center_x, center_y = x * SUPERSAMPLE, y * SUPERSAMPLE - draw.ellipse( - ( - center_x - radius, - center_y - radius, - center_x + radius, - center_y + radius, - ), - fill=SINE_COLOR, - ) - - -def _direction(delta: float) -> float: - if delta > 0: - return 1.0 - - if delta < 0: - return -1.0 - - return 0.0 - - -def _segment_rectangle( - start: Point, - end: Point, - *, - half_width: float, - joined_start: bool, - joined_end: bool, -) -> Rectangle: - """The stroke rectangle of one axis-aligned segment. - - A joined end reaches half the stroke width past its corner, so consecutive - rectangles fill their right-angle miter; an open end keeps a butt cap. - """ - direction_x = _direction(end[0] - start[0]) - direction_y = _direction(end[1] - start[1]) - start_reach = half_width if joined_start else 0.0 - end_reach = half_width if joined_end else 0.0 - - reached_start = ( - start[0] - direction_x * start_reach, - start[1] - direction_y * start_reach, - ) - reached_end = ( - end[0] + direction_x * end_reach, - end[1] + direction_y * end_reach, - ) - across_x = half_width * abs(direction_y) - across_y = half_width * abs(direction_x) - - return ( - min(reached_start[0], reached_end[0]) - across_x, - min(reached_start[1], reached_end[1]) - across_y, - max(reached_start[0], reached_end[0]) + across_x, - max(reached_start[1], reached_end[1]) + across_y, - ) - - -def _square_rectangles() -> List[Rectangle]: - final_segment = len(SQUARE_POINTS) - 2 - return [ - _segment_rectangle( - SQUARE_POINTS[index], - SQUARE_POINTS[index + 1], - half_width=WAVE_WIDTH / 2, - joined_start=index > 0, - joined_end=index < final_segment, - ) - for index in range(len(SQUARE_POINTS) - 1) - ] - - -def _draw_square(draw: ImageDraw.ImageDraw) -> None: - for left, top, right, bottom in _square_rectangles(): - draw.rectangle( - ( - round(left * SUPERSAMPLE), - round(top * SUPERSAMPLE), - round(right * SUPERSAMPLE) - 1, - round(bottom * SUPERSAMPLE) - 1, - ), - fill=SQUARE_COLOR, - ) - - -def _rgba(color: str, opacity: float) -> Tuple[int, int, int, int]: - red, green, blue = (int(color[start : start + 2], 16) for start in (1, 3, 5)) - return red, green, blue, round(opacity * 255) - - -def _rim_overlay(canvas: int) -> Image.Image: - overlay = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) - ImageDraw.Draw(overlay).rounded_rectangle( - (0, 0, canvas - 1, canvas - 1), - radius=CORNER_RADIUS * SUPERSAMPLE, - outline=_rgba(RIM_COLOR, RIM_OPACITY), - width=round(RIM_WIDTH * SUPERSAMPLE), - ) - return overlay - - -def render_master() -> Image.Image: - """The mark rasterized at a supersampled resolution, ready to scale down to each shipped size.""" - canvas = GRID * SUPERSAMPLE - image = _background(canvas) - draw = ImageDraw.Draw(image) - _draw_sine(draw) - _draw_square(draw) - image.alpha_composite(_rim_overlay(canvas)) - return image - - -def write_suite(directory: Path) -> List[Path]: - """Writes the vector, the raster, and the Windows icon into the directory.""" - directory.mkdir(parents=True, exist_ok=True) - master = render_master() - renders = {size: master.resize((size, size), Image.Resampling.LANCZOS) for size in ICO_SIZES} - - vector_path = directory / VECTOR_FILENAME - vector_path.write_text(svg_document(), encoding="utf-8") - - raster_path = directory / UNIX_ICON_FILENAME - renders[RASTER_SIZE].save(raster_path) - - windows_path = directory / WINDOWS_ICON_FILENAME - primary, *appended = (renders[size] for size in ICO_SIZES) - primary.save( - windows_path, - format="ICO", - sizes=[(size, size) for size in ICO_SIZES], - append_images=appended, - ) - - return [vector_path, raster_path, windows_path] - - -# TODO: this file should be only a thin layer, the rest of the code -# should belong to sampletones_assets -# Read guidelines and architecture docs, follow the current code philosophy def main(argv: Sequence[str]) -> int: """Writes the icon suite and reports each file it produced.""" parser = argparse.ArgumentParser( - description="Build the application icon suite from the mark's geometry.", + description="Write the application icon suite from the mark definition.", ) parser.add_argument( "--directory", @@ -349,7 +36,7 @@ def main(argv: Sequence[str]) -> int: ) arguments = parser.parse_args(list(argv)) - for path in write_suite(arguments.directory): + for path in write_icon_suite(arguments.directory, Mark.load()): print(f"Wrote {path}") return 0 diff --git a/src/sampletones_assets/mark/__init__.py b/src/sampletones_assets/mark/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/sampletones_assets/mark/geometry.py b/src/sampletones_assets/mark/geometry.py new file mode 100644 index 00000000..5fd855bd --- /dev/null +++ b/src/sampletones_assets/mark/geometry.py @@ -0,0 +1,128 @@ +import itertools +from dataclasses import dataclass +from typing import List + +from sampletones_assets.mark.specification.point import CubicCurve, Point +from sampletones_assets.mark.specification.waves import MarkSine, MarkSquare + + +@dataclass(frozen=True) +class Rectangle: + """An axis-aligned box in grid units, the shape one segment of the stepped half fills.""" + + left: float + top: float + right: float + bottom: float + + +def _cubic_coordinate( + start: float, + control_start: float, + control_end: float, + end: float, + progress: float, +) -> float: + remainder = 1.0 - progress + return ( + remainder**3 * start + + 3 * remainder**2 * progress * control_start + + 3 * remainder * progress**2 * control_end + + progress**3 * end + ) + + +def _cubic_point( + start: Point, + curve: CubicCurve, + progress: float, +) -> Point: + return Point( + x=_cubic_coordinate(start.x, curve.control_start.x, curve.control_end.x, curve.end.x, progress), + y=_cubic_coordinate(start.y, curve.control_start.y, curve.control_end.y, curve.end.y, progress), + ) + + +def sine_points(sine: MarkSine, samples: int) -> List[Point]: + """The smooth half as a polyline, sampled evenly along every segment. + + Each segment contributes ``samples`` points, ending on its own end point, so the next + segment starts where the previous one arrived and the polyline runs unbroken from the + wave's start to its handover. + """ + points = [sine.start] + position = sine.start + for curve in sine.curves: + for step in range(1, samples + 1): + points.append(_cubic_point(position, curve, step / samples)) + + position = curve.end + + return points + + +def _direction(delta: float) -> float: + if delta > 0: + return 1.0 + + if delta < 0: + return -1.0 + + return 0.0 + + +def _segment_rectangle( + start: Point, + end: Point, + *, + half_width: float, + joined_start: bool, + joined_end: bool, +) -> Rectangle: + """The stroke rectangle of one axis-aligned segment. + + A joined end reaches half the stroke width past its corner, so consecutive rectangles + fill their right-angle miter; an open end keeps a butt cap. + """ + direction_x = _direction(end.x - start.x) + direction_y = _direction(end.y - start.y) + start_reach = half_width if joined_start else 0.0 + end_reach = half_width if joined_end else 0.0 + + reached_start = ( + start.x - direction_x * start_reach, + start.y - direction_y * start_reach, + ) + reached_end = ( + end.x + direction_x * end_reach, + end.y + direction_y * end_reach, + ) + across_x = half_width * abs(direction_y) + across_y = half_width * abs(direction_x) + + return Rectangle( + left=min(reached_start[0], reached_end[0]) - across_x, + top=min(reached_start[1], reached_end[1]) - across_y, + right=max(reached_start[0], reached_end[0]) + across_x, + bottom=max(reached_start[1], reached_end[1]) + across_y, + ) + + +def square_rectangles(square: MarkSquare, width: float) -> List[Rectangle]: + """The stepped half as filled rectangles, one per segment between its corners. + + The rectangles meet at every corner the wave turns at, so the sequence covers the + stroke a vector renderer draws with square joins. + """ + segments = list(itertools.pairwise(square.points)) + final_segment = len(segments) - 1 + return [ + _segment_rectangle( + start, + end, + half_width=width / 2, + joined_start=index > 0, + joined_end=index < final_segment, + ) + for index, (start, end) in enumerate(segments) + ] diff --git a/src/sampletones_assets/mark/mark.yaml b/src/sampletones_assets/mark/mark.yaml new file mode 100644 index 00000000..34c72068 --- /dev/null +++ b/src/sampletones_assets/mark/mark.yaml @@ -0,0 +1,43 @@ +frame: + grid: 64 + corner_radius: 14 + rim: + inset: 1 + width: 2 + opacity: 0.14 + +colors: + background: + top: "#3a3650" + bottom: "#211d30" + sine: "#64c8ff" + square: "#ffc864" + rim: "#cdb6ff" + +waves: + width: 4 + sine: + start: {x: 8, y: 32} + curves: + - control_start: {x: 11, y: 16} + control_end: {x: 15, y: 16} + end: {x: 18, y: 32} + - control_start: {x: 21, y: 48} + control_end: {x: 25, y: 48} + end: {x: 28, y: 32} + square: + points: + - {x: 28, y: 32} + - {x: 28, y: 20} + - {x: 38, y: 20} + - {x: 38, y: 44} + - {x: 48, y: 44} + - {x: 48, y: 20} + - {x: 56, y: 20} + - {x: 56, y: 32} + +render: + supersample: 16 + curve_samples: 96 + raster_size: 256 + windows_sizes: [256, 128, 64, 48, 32, 24, 16] diff --git a/src/sampletones_assets/mark/paths.py b/src/sampletones_assets/mark/paths.py new file mode 100644 index 00000000..4b9decd9 --- /dev/null +++ b/src/sampletones_assets/mark/paths.py @@ -0,0 +1,7 @@ +from importlib.resources import files +from pathlib import Path +from typing import Final + +MARK_DIRECTORY: Final[Path] = Path(str(files("sampletones_assets.mark"))) +MARK_PATH: Final[Path] = MARK_DIRECTORY / "mark.yaml" +TEMPLATE_PATH: Final[Path] = MARK_DIRECTORY / "template.svg" diff --git a/src/sampletones_assets/mark/raster.py b/src/sampletones_assets/mark/raster.py new file mode 100644 index 00000000..c9547af0 --- /dev/null +++ b/src/sampletones_assets/mark/raster.py @@ -0,0 +1,116 @@ +from typing import Final, Tuple + +from PIL import Image, ImageDraw # TODO: update THIRD-PARTY-* files, revise LICENSE if still holds + +from sampletones_assets.mark.geometry import sine_points, square_rectangles +from sampletones_assets.mark.specification import Mark +from sampletones_shared.types.application import ColorRGBA +from sampletones_shared.utils.color import parse_hex_color, with_alpha_fraction + +TRANSPARENT: Final[ColorRGBA] = (0, 0, 0, 0) +OPAQUE: Final[int] = 255 + + +class MarkRaster: + """Draws the mark into one supersampled image, ready to scale down to each shipped size. + + Drawing happens at the render factor times the design grid and the result is resampled + down, which is what keeps the curve edges and the rounded corners smooth at 16 px. + """ + + def __init__(self, mark: Mark) -> None: + self.mark = mark + + @property + def scale(self) -> int: + """Factor the design grid is drawn at.""" + return self.mark.render.supersample + + @property + def canvas(self) -> int: + """Edge length in pixels of the image the mark is drawn into.""" + return self.mark.frame.grid * self.scale + + @property + def corner_radius(self) -> float: + """Corner radius of the frame, in the pixels of the drawn image.""" + return self.mark.frame.corner_radius * self.scale + + @property + def frame_box(self) -> Tuple[int, int, int, int]: + """The whole image, as the box the frame and its rim are drawn in.""" + return (0, 0, self.canvas - 1, self.canvas - 1) + + def render(self) -> Image.Image: + """The mark drawn at the supersampled resolution, on a transparent ground.""" + image = self._background() + draw = ImageDraw.Draw(image) + self._draw_sine(draw) + self._draw_square(draw) + image.alpha_composite(self._rim()) + return image + + def _background(self) -> Image.Image: + """The frame: a vertical gradient between the two background colours, rounded at its corners.""" + size = (self.canvas, self.canvas) + top = Image.new("RGB", size, self.mark.colors.background.top) + bottom = Image.new("RGB", size, self.mark.colors.background.bottom) + shaded = Image.composite(bottom, top, Image.linear_gradient("L").resize(size)) + + background = Image.new("RGBA", size, TRANSPARENT) + background.paste(shaded, mask=self._frame_mask()) + return background + + def _frame_mask(self) -> Image.Image: + mask = Image.new("L", (self.canvas, self.canvas), 0) + ImageDraw.Draw(mask).rounded_rectangle( + self.frame_box, + radius=self.corner_radius, + fill=OPAQUE, + ) + return mask + + def _draw_sine(self, draw: ImageDraw.ImageDraw) -> None: + """Sweeps a disk of the stroke's half width along the curve. + + The union of densely stamped disks equals a round-capped stroke of the curve, which is + what holds the outline smooth along its whole sweep. + """ + radius = self.mark.waves.width * self.scale / 2 + for point in sine_points(self.mark.waves.sine, self.mark.render.curve_samples): + center_x, center_y = point.x * self.scale, point.y * self.scale + draw.ellipse( + ( + center_x - radius, + center_y - radius, + center_x + radius, + center_y + radius, + ), + fill=self.mark.colors.sine, + ) + + def _draw_square(self, draw: ImageDraw.ImageDraw) -> None: + for rectangle in square_rectangles(self.mark.waves.square, self.mark.waves.width): + draw.rectangle( + ( + round(rectangle.left * self.scale), + round(rectangle.top * self.scale), + round(rectangle.right * self.scale) - 1, + round(rectangle.bottom * self.scale) - 1, + ), + fill=self.mark.colors.square, + ) + + def _rim(self) -> Image.Image: + """The hairline along the frame's edge, as a layer to composite over the drawn mark.""" + overlay = Image.new("RGBA", (self.canvas, self.canvas), TRANSPARENT) + ImageDraw.Draw(overlay).rounded_rectangle( + self.frame_box, + radius=self.corner_radius, + outline=self._rim_color(), + width=round(self.mark.frame.rim.width * self.scale), + ) + return overlay + + def _rim_color(self) -> ColorRGBA: + return with_alpha_fraction(parse_hex_color(self.mark.colors.rim), self.mark.frame.rim.opacity) diff --git a/src/sampletones_assets/mark/specification/__init__.py b/src/sampletones_assets/mark/specification/__init__.py new file mode 100644 index 00000000..fa600fe1 --- /dev/null +++ b/src/sampletones_assets/mark/specification/__init__.py @@ -0,0 +1,38 @@ +from typing import Self + +from pydantic import BaseModel, Field + +from sampletones_assets.mark.paths import MARK_PATH +from sampletones_assets.mark.specification.colors import MarkColors +from sampletones_assets.mark.specification.frame import MarkFrame +from sampletones_assets.mark.specification.render import MarkRender +from sampletones_assets.mark.specification.waves import MarkWaves +from sampletones_shared.utils.serialization import load_yaml_model + + +class Mark(BaseModel, extra="forbid", frozen=True): + """The design definition of the application mark. + + Every shipped icon derives from this one definition — the vector, the raster the + application loads, and the multi-resolution Windows icon — so the mark is drawn from a + single source and stays the same shape at every size. Coordinates are written on the + frame's grid, which keeps the wave edges on whole pixels once the grid is scaled to an + icon size. + """ + + frame: MarkFrame = Field(description="The rounded square the mark sits on.") + colors: MarkColors = Field(description="The colours the mark is drawn in.") + waves: MarkWaves = Field(description="The wave crossing the frame.") + render: MarkRender = Field(description="How the mark is rasterized.") + + @classmethod + def load(cls) -> Self: + """Load the packaged mark definition. + + Returns: + The mark validated from `sampletones_assets/mark/mark.yaml`. + + Raises: + TypeError: If the definition file holds anything other than a mapping. + """ + return load_yaml_model(MARK_PATH, cls) diff --git a/src/sampletones_assets/mark/specification/colors.py b/src/sampletones_assets/mark/specification/colors.py new file mode 100644 index 00000000..1063ea2b --- /dev/null +++ b/src/sampletones_assets/mark/specification/colors.py @@ -0,0 +1,29 @@ +from typing import Annotated + +from pydantic import AfterValidator, BaseModel, Field + +from sampletones_shared.utils.color import parse_hex_color + + +def _validate_hex_color(value: str) -> str: + parse_hex_color(value) + return value + + +HexColor = Annotated[str, AfterValidator(_validate_hex_color)] + + +class MarkBackground(BaseModel, extra="forbid", frozen=True): + """The vertical gradient filling the frame.""" + + top: HexColor = Field(description="Colour at the top edge of the frame.") + bottom: HexColor = Field(description="Colour at the bottom edge of the frame.") + + +class MarkColors(BaseModel, extra="forbid", frozen=True): + """The mark's colours, written as the hex strings the vector carries.""" + + background: MarkBackground = Field(description="Gradient behind the wave.") + sine: HexColor = Field(description="Colour of the smooth half of the wave.") + square: HexColor = Field(description="Colour of the stepped half of the wave.") + rim: HexColor = Field(description="Colour of the hairline inside the frame's edge.") diff --git a/src/sampletones_assets/mark/specification/frame.py b/src/sampletones_assets/mark/specification/frame.py new file mode 100644 index 00000000..4c9111a1 --- /dev/null +++ b/src/sampletones_assets/mark/specification/frame.py @@ -0,0 +1,43 @@ +from typing import Self + +from pydantic import BaseModel, Field, PositiveFloat, PositiveInt, model_validator + + +class MarkRim(BaseModel, extra="forbid", frozen=True): + """The hairline drawn just inside the frame's edge, lifting it off a dark desktop.""" + + inset: PositiveFloat = Field(description="Distance the hairline keeps from the frame's edge.") + width: PositiveFloat = Field(description="Stroke width of the hairline.") + opacity: float = Field(gt=0.0, le=1.0, description="Share of full opacity the hairline is drawn at.") + + +class MarkFrame(BaseModel, extra="forbid", frozen=True): + """The rounded square the mark sits on. + + ``grid`` is the edge length every other coordinate is expressed in, so the whole design + follows from this one number and scales to any icon size. + """ + + grid: PositiveInt = Field(description="Edge length of the design grid.") + corner_radius: PositiveFloat = Field(description="Radius the frame's corners are rounded to.") + rim: MarkRim = Field(description="The hairline inside the frame's edge.") + + @property + def rim_radius(self) -> float: + """Corner radius the rim follows, keeping it concentric with the frame.""" + return self.corner_radius - self.rim.inset + + @property + def rim_extent(self) -> float: + """Edge length of the rim's square, inset on both sides.""" + return self.grid - 2 * self.rim.inset + + @model_validator(mode="after") + def _validate_rounding(self) -> Self: + if 2 * self.corner_radius > self.grid: + raise ValueError(f"The corner radius {self.corner_radius} must be at most half the grid {self.grid}") + + if self.rim.inset >= self.corner_radius: + raise ValueError(f"The rim inset {self.rim.inset} must stay inside the corner radius {self.corner_radius}") + + return self diff --git a/src/sampletones_assets/mark/specification/point.py b/src/sampletones_assets/mark/specification/point.py new file mode 100644 index 00000000..db8689f0 --- /dev/null +++ b/src/sampletones_assets/mark/specification/point.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, Field + + +class Point(BaseModel, extra="forbid", frozen=True): + """A position on the mark's design grid, in grid units.""" + + x: float = Field(description="Distance from the left edge of the grid.") + y: float = Field(description="Distance from the top edge of the grid.") + + +class CubicCurve(BaseModel, extra="forbid", frozen=True): + """One cubic Bézier segment, starting where the segment before it ended.""" + + control_start: Point = Field(description="Control point steering the segment away from its start.") + control_end: Point = Field(description="Control point steering the segment into its end.") + end: Point = Field(description="Point the segment reaches.") diff --git a/src/sampletones_assets/mark/specification/render.py b/src/sampletones_assets/mark/specification/render.py new file mode 100644 index 00000000..2e77b129 --- /dev/null +++ b/src/sampletones_assets/mark/specification/render.py @@ -0,0 +1,28 @@ +from typing import Tuple + +from pydantic import BaseModel, Field, PositiveInt, field_validator + + +class MarkRender(BaseModel, extra="forbid", frozen=True): + """How the mark is turned into pixels. + + Drawing happens at ``supersample`` times the design grid and the result is resampled + down to each shipped size, which is what keeps the curve edges and the rounded corners + smooth at 16 px. + """ + + supersample: PositiveInt = Field(description="Factor the design grid is drawn at before it is scaled down.") + curve_samples: PositiveInt = Field(description="Points each cubic segment of the smooth half is stamped along.") + raster_size: PositiveInt = Field(description="Edge length of the raster the application loads.") + windows_sizes: Tuple[PositiveInt, ...] = Field( + min_length=1, + description="Edge lengths the multi-resolution Windows icon carries.", + ) + + @field_validator("windows_sizes") + @classmethod + def _validate_windows_sizes(cls, windows_sizes: Tuple[int, ...]) -> Tuple[int, ...]: + if list(windows_sizes) != sorted(set(windows_sizes), reverse=True): + raise ValueError("Windows icon sizes must be listed once each, in descending order") + + return windows_sizes diff --git a/src/sampletones_assets/mark/specification/waves.py b/src/sampletones_assets/mark/specification/waves.py new file mode 100644 index 00000000..b97a1c35 --- /dev/null +++ b/src/sampletones_assets/mark/specification/waves.py @@ -0,0 +1,59 @@ +import itertools +from typing import Self, Tuple + +from pydantic import BaseModel, Field, PositiveFloat, model_validator + +from sampletones_assets.mark.specification.point import CubicCurve, Point + + +class MarkSine(BaseModel, extra="forbid", frozen=True): + """The smooth half of the wave, as cubic segments running on from the start point.""" + + start: Point = Field(description="Point the wave enters the frame at.") + curves: Tuple[CubicCurve, ...] = Field(min_length=1, description="Segments the wave follows, in drawing order.") + + @property + def end(self) -> Point: + """Point the last segment reaches, where the stepped half takes over.""" + return self.curves[-1].end + + +class MarkSquare(BaseModel, extra="forbid", frozen=True): + """The stepped half of the wave, as corners joined by axis-aligned segments.""" + + points: Tuple[Point, ...] = Field(min_length=2, description="Corners the wave turns at, in drawing order.") + + @model_validator(mode="after") + def _validate_segments_run_along_one_axis(self) -> Self: + for start, end in itertools.pairwise(self.points): + if start.x != end.x and start.y != end.y: + raise ValueError( + f"A square wave segment runs along one axis, " + f"where ({start.x}, {start.y}) to ({end.x}, {end.y}) turns on both" + ) + + return self + + +class MarkWaves(BaseModel, extra="forbid", frozen=True): + """The single wave the mark carries: one sample entering smooth and leaving stepped. + + Both halves are stroked at the same width, which is what reads them as one continuous + wave crossing the frame. + """ + + width: PositiveFloat = Field(description="Stroke width both halves of the wave are drawn at.") + sine: MarkSine = Field(description="The smooth half, entering from the left.") + square: MarkSquare = Field(description="The stepped half, leaving to the right.") + + @model_validator(mode="after") + def _validate_the_halves_meet(self) -> Self: + handover = self.square.points[0] + if handover != self.sine.end: + raise ValueError( + f"The stepped half starts where the smooth half ends, " + f"where it starts at ({handover.x}, {handover.y}) " + f"and the smooth half ends at ({self.sine.end.x}, {self.sine.end.y})" + ) + + return self diff --git a/src/sampletones_assets/mark/suite.py b/src/sampletones_assets/mark/suite.py new file mode 100644 index 00000000..a464b50a --- /dev/null +++ b/src/sampletones_assets/mark/suite.py @@ -0,0 +1,67 @@ +from pathlib import Path +from typing import List, Tuple + +from PIL import Image + +from sampletones_assets.mark.raster import MarkRaster +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.vector import render_vector +from sampletones_shared.paths.resources import ( + ICON_UNIX_FILENAME, + ICON_VECTOR_FILENAME, + ICON_WIN_FILENAME, +) + + +def _resized(master: Image.Image, size: int) -> Image.Image: + return master.resize((size, size), Image.Resampling.LANCZOS) + + +def _write_vector(path: Path, mark: Mark) -> Path: + path.write_text(render_vector(mark), encoding="utf-8") + return path + + +def _write_raster(path: Path, master: Image.Image, size: int) -> Path: + _resized(master, size).save(path) + return path + + +def _write_windows_icon( + path: Path, + master: Image.Image, + sizes: Tuple[int, ...], +) -> Path: + """Writes the multi-resolution icon, rendering one frame per declared size. + + Every frame is resampled from the supersampled master, so a 16 px frame carries the + detail the design grid puts there. + """ + primary, *appended = (_resized(master, size) for size in sizes) + primary.save( + path, + format="ICO", + sizes=[(size, size) for size in sizes], + append_images=appended, + ) + return path + + +def write_icon_suite(directory: Path, mark: Mark) -> List[Path]: + """Writes the vector, the raster and the Windows icon the application ships. + + Args: + directory (Path): Directory receiving the icon files, created where it is missing. + mark (Mark): Design definition every file is drawn from. + + Returns: + List[Path]: The files written, in the order they were produced. + """ + directory.mkdir(parents=True, exist_ok=True) + master = MarkRaster(mark).render() + + return [ + _write_vector(directory / ICON_VECTOR_FILENAME, mark), + _write_raster(directory / ICON_UNIX_FILENAME, master, mark.render.raster_size), + _write_windows_icon(directory / ICON_WIN_FILENAME, master, mark.render.windows_sizes), + ] diff --git a/src/sampletones_assets/mark/template.svg b/src/sampletones_assets/mark/template.svg new file mode 100644 index 00000000..3be72f01 --- /dev/null +++ b/src/sampletones_assets/mark/template.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/sampletones_assets/mark/vector.py b/src/sampletones_assets/mark/vector.py new file mode 100644 index 00000000..f9ba412c --- /dev/null +++ b/src/sampletones_assets/mark/vector.py @@ -0,0 +1,71 @@ +import itertools +from string import Template +from typing import Dict + +from sampletones_assets.mark.paths import TEMPLATE_PATH +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.specification.point import Point +from sampletones_assets.mark.specification.waves import MarkSine, MarkSquare + + +def _number(value: float) -> str: + return f"{value:g}" + + +def _coordinates(point: Point) -> str: + return f"{_number(point.x)} {_number(point.y)}" + + +def _sine_path(sine: MarkSine) -> str: + commands = [f"M{_coordinates(sine.start)}"] + for curve in sine.curves: + controls = f"{_coordinates(curve.control_start)} {_coordinates(curve.control_end)}" + commands.append(f"C{controls} {_coordinates(curve.end)}") + + return " ".join(commands) + + +def _square_path(square: MarkSquare) -> str: + """The stepped half as vertical and horizontal commands, one per segment. + + Each segment turns on a single axis, so it is written as the one coordinate it moves + along and the renderer holds the other. + """ + commands = [f"M{_coordinates(square.points[0])}"] + for previous, point in itertools.pairwise(square.points): + commands.append(f"V{_number(point.y)}" if point.x == previous.x else f"H{_number(point.x)}") + + return " ".join(commands) + + +def _placeholders(mark: Mark) -> Dict[str, str]: + return { + "grid": _number(mark.frame.grid), + "corner_radius": _number(mark.frame.corner_radius), + "background_top": mark.colors.background.top, + "background_bottom": mark.colors.background.bottom, + "sine_path": _sine_path(mark.waves.sine), + "sine_color": mark.colors.sine, + "square_path": _square_path(mark.waves.square), + "square_color": mark.colors.square, + "wave_width": _number(mark.waves.width), + "rim_inset": _number(mark.frame.rim.inset), + "rim_extent": _number(mark.frame.rim_extent), + "rim_radius": _number(mark.frame.rim_radius), + "rim_color": mark.colors.rim, + "rim_opacity": _number(mark.frame.rim.opacity), + "rim_width": _number(mark.frame.rim.width), + } + + +def render_vector(mark: Mark) -> str: + """The mark as a standalone vector, filling the packaged template with its own geometry. + + Coordinates stay on the design grid, which keeps the wave edges on whole pixels when the + icon is rasterized at 32 px and 16 px. + + Raises: + KeyError: If the template names a placeholder the mark leaves unfilled. + """ + template = Template(TEMPLATE_PATH.read_text(encoding="utf-8")) + return template.substitute(_placeholders(mark)) diff --git a/src/sampletones_shared/paths/resources.py b/src/sampletones_shared/paths/resources.py index fa596d93..9da9292d 100644 --- a/src/sampletones_shared/paths/resources.py +++ b/src/sampletones_shared/paths/resources.py @@ -14,6 +14,7 @@ ICON_DIRECTORY: Final[str] = "icons" ICON_WIN_FILENAME: Final[str] = "sampletones.ico" ICON_UNIX_FILENAME: Final[str] = "sampletones.png" +ICON_VECTOR_FILENAME: Final[str] = "sampletones.svg" FONT_DIRECTORY: Final[str] = "fonts" FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf" diff --git a/tests/unit/sampletones_assets/__init__.py b/tests/unit/sampletones_assets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_assets/mark/__init__.py b/tests/unit/sampletones_assets/mark/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/sampletones_assets/mark/test_geometry.py b/tests/unit/sampletones_assets/mark/test_geometry.py new file mode 100644 index 00000000..78279edb --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_geometry.py @@ -0,0 +1,69 @@ +import itertools +from typing import Final + +import pytest + +from sampletones_assets.mark.geometry import Rectangle, sine_points, square_rectangles +from sampletones_assets.mark.specification import Mark + +SAMPLES: Final[int] = 5 + + +def _overlap(first: Rectangle, second: Rectangle) -> float: + width = min(first.right, second.right) - max(first.left, second.left) + height = min(first.bottom, second.bottom) - max(first.top, second.top) + return min(width, height) + + +class TestSinePoints: + def test_the_polyline_runs_from_the_start_to_the_handover(self) -> None: + sine = Mark.load().waves.sine + points = sine_points(sine, SAMPLES) + + assert points[0] == sine.start + assert points[-1].x == pytest.approx(sine.end.x) + assert points[-1].y == pytest.approx(sine.end.y) + + def test_every_segment_contributes_its_samples(self) -> None: + sine = Mark.load().waves.sine + assert len(sine_points(sine, SAMPLES)) == len(sine.curves) * SAMPLES + 1 + + def test_the_polyline_stays_within_the_curve_the_definition_draws(self) -> None: + """The wave swings between the extremes its control points reach, keeping it inside the frame.""" + sine = Mark.load().waves.sine + controls = [sine.start] + [ + point for curve in sine.curves for point in (curve.control_start, curve.control_end, curve.end) + ] + lowest = min(point.y for point in controls) + highest = max(point.y for point in controls) + + for point in sine_points(sine, SAMPLES): + assert lowest <= point.y <= highest + + +class TestSquareRectangles: + def test_one_rectangle_covers_each_segment(self) -> None: + square = Mark.load().waves.square + assert len(square_rectangles(square, width=4.0)) == len(square.points) - 1 + + def test_every_rectangle_reads_left_to_right_and_top_to_bottom(self) -> None: + square = Mark.load().waves.square + for rectangle in square_rectangles(square, width=4.0): + assert rectangle.left < rectangle.right + assert rectangle.top < rectangle.bottom + + def test_a_segment_carries_the_stroke_width_across_its_run(self) -> None: + width = 4.0 + square = Mark.load().waves.square + for (start, end), rectangle in zip( + itertools.pairwise(square.points), + square_rectangles(square, width=width), + ): + across = rectangle.bottom - rectangle.top if start.y == end.y else rectangle.right - rectangle.left + assert across == pytest.approx(width) + + def test_consecutive_rectangles_meet_at_the_corner_they_turn_on(self) -> None: + """Overlapping rectangles fill the right-angle miter, so the stepped half draws as one stroke.""" + square = Mark.load().waves.square + for first, second in itertools.pairwise(square_rectangles(square, width=4.0)): + assert _overlap(first, second) > 0.0 diff --git a/tests/unit/sampletones_assets/mark/test_raster.py b/tests/unit/sampletones_assets/mark/test_raster.py new file mode 100644 index 00000000..5ae61ddf --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_raster.py @@ -0,0 +1,47 @@ +from typing import Final, Tuple + +import pytest + +from sampletones_assets.mark.raster import MarkRaster +from sampletones_assets.mark.specification import Mark +from sampletones_shared.utils.color import parse_hex_color + +CORNER: Final[Tuple[int, int]] = (0, 0) +ALPHA: Final[int] = 3 +CHANNELS: Final[int] = 3 + + +@pytest.fixture(name="mark", scope="module") +def mark_fixture() -> Mark: + return Mark.load() + + +class TestMarkRaster: + def test_the_image_covers_the_supersampled_grid(self, mark: Mark) -> None: + image = MarkRaster(mark).render() + edge = mark.frame.grid * mark.render.supersample + assert image.size == (edge, edge) + + def test_the_image_corner_stays_clear_of_the_rounded_frame(self, mark: Mark) -> None: + image = MarkRaster(mark).render() + assert image.getpixel(CORNER)[ALPHA] == 0 + + def test_the_frame_centre_carries_the_background(self, mark: Mark) -> None: + """The frame reaches the top edge between its rounded corners, so the ground there is opaque.""" + image = MarkRaster(mark).render() + centre = image.size[0] // 2 + assert image.getpixel((centre, 1))[ALPHA] == 255 + + def test_the_smooth_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None: + image = MarkRaster(mark).render() + scale = mark.render.supersample + start = mark.waves.sine.start + pixel = image.getpixel((round(start.x * scale), round(start.y * scale))) + assert pixel[:CHANNELS] == parse_hex_color(mark.colors.sine)[:CHANNELS] + + def test_the_stepped_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None: + image = MarkRaster(mark).render() + scale = mark.render.supersample + corner = mark.waves.square.points[1] + pixel = image.getpixel((round(corner.x * scale), round(corner.y * scale))) + assert pixel[:CHANNELS] == parse_hex_color(mark.colors.square)[:CHANNELS] diff --git a/tests/unit/sampletones_assets/mark/test_specification.py b/tests/unit/sampletones_assets/mark/test_specification.py new file mode 100644 index 00000000..a3f51d36 --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_specification.py @@ -0,0 +1,176 @@ +from dataclasses import dataclass +from typing import Any, Dict, Final + +import pytest +from pydantic import ValidationError + +from sampletones_assets.mark.specification import Mark +from tests.suite.case import BaseRegularTestCase + +VALID_FRAME: Final[Dict[str, Any]] = { + "grid": 64, + "corner_radius": 14, + "rim": {"inset": 1, "width": 2, "opacity": 0.14}, +} + +VALID_COLORS: Final[Dict[str, Any]] = { + "background": {"top": "#3a3650", "bottom": "#211d30"}, + "sine": "#64c8ff", + "square": "#ffc864", + "rim": "#cdb6ff", +} + +VALID_SINE: Final[Dict[str, Any]] = { + "start": {"x": 8, "y": 32}, + "curves": [ + { + "control_start": {"x": 11, "y": 16}, + "control_end": {"x": 15, "y": 16}, + "end": {"x": 18, "y": 32}, + }, + ], +} + +VALID_SQUARE: Final[Dict[str, Any]] = { + "points": [ + {"x": 18, "y": 32}, + {"x": 18, "y": 20}, + {"x": 28, "y": 20}, + ], +} + +VALID_WAVES: Final[Dict[str, Any]] = { + "width": 4, + "sine": VALID_SINE, + "square": VALID_SQUARE, +} + +VALID_RENDER: Final[Dict[str, Any]] = { + "supersample": 16, + "curve_samples": 96, + "raster_size": 256, + "windows_sizes": [256, 128, 64], +} + +VALID_FIELDS: Final[Dict[str, Any]] = { + "frame": VALID_FRAME, + "colors": VALID_COLORS, + "waves": VALID_WAVES, + "render": VALID_RENDER, +} + + +class TestMark: + @dataclass(frozen=True, kw_only=True) + class InvalidFieldCase(BaseRegularTestCase): + field: str + value: Any + + test_cases = ( + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "grid": 0}, + label="empty_grid", + ), + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "corner_radius": 33}, + label="corner_radius_over_half_the_grid", + ), + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "inset": 14}}, + label="rim_inset_outside_the_corner_radius", + ), + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "opacity": 1.5}}, + label="rim_opacity_over_full", + ), + InvalidFieldCase( + field="colors", + value={**VALID_COLORS, "sine": "64c8ff"}, + label="color_without_a_hash", + ), + InvalidFieldCase( + field="colors", + value={**VALID_COLORS, "sine": "#64c8"}, + label="color_of_four_hex_digits", + ), + InvalidFieldCase( + field="waves", + value={**VALID_WAVES, "width": 0}, + label="wave_without_width", + ), + InvalidFieldCase( + field="waves", + value={**VALID_WAVES, "sine": {**VALID_SINE, "curves": []}}, + label="smooth_half_without_curves", + ), + InvalidFieldCase( + field="waves", + value={**VALID_WAVES, "square": {"points": [{"x": 18, "y": 32}]}}, + label="stepped_half_without_a_segment", + ), + InvalidFieldCase( + field="waves", + value={ + **VALID_WAVES, + "square": {"points": [{"x": 18, "y": 32}, {"x": 28, "y": 20}]}, + }, + label="stepped_segment_turning_on_both_axes", + ), + InvalidFieldCase( + field="waves", + value={ + **VALID_WAVES, + "square": {"points": [{"x": 40, "y": 32}, {"x": 40, "y": 20}]}, + }, + label="halves_meeting_apart", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "supersample": 0}, + label="drawing_below_the_design_grid", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "windows_sizes": []}, + label="windows_icon_without_a_frame", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "windows_sizes": [64, 128, 256]}, + label="windows_sizes_in_ascending_order", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "windows_sizes": [256, 256, 128]}, + label="repeated_windows_size", + ), + ) + + def test_the_packaged_definition_loads(self) -> None: + mark = Mark.load() + assert isinstance(mark, Mark) + + def test_the_sample_of_the_packaged_definition_leaves_as_it_entered(self) -> None: + """The mark draws one wave, so the stepped half carries on from where the smooth half arrives.""" + mark = Mark.load() + assert mark.waves.square.points[0] == mark.waves.sine.end + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_an_invalid_field_is_rejected(self, case: InvalidFieldCase) -> None: + fields = {**VALID_FIELDS, case.field: case.value} + with pytest.raises(ValidationError): + Mark.model_validate(fields) + + @pytest.mark.parametrize("field", sorted(VALID_FIELDS)) + def test_a_missing_field_is_rejected(self, field: str) -> None: + fields = {key: value for key, value in VALID_FIELDS.items() if key != field} + with pytest.raises(ValidationError): + Mark.model_validate(fields) + + def test_an_unknown_field_is_rejected(self) -> None: + with pytest.raises(ValidationError): + Mark.model_validate({**VALID_FIELDS, "shadow": {"blur": 4}}) diff --git a/tests/unit/sampletones_assets/mark/test_suite.py b/tests/unit/sampletones_assets/mark/test_suite.py new file mode 100644 index 00000000..b0a8f3e3 --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_suite.py @@ -0,0 +1,55 @@ +from pathlib import Path + +import pytest +from PIL import Image + +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.suite import write_icon_suite +from sampletones_shared.paths.resources import ( + ICON_UNIX_FILENAME, + ICON_VECTOR_FILENAME, + ICON_WIN_FILENAME, +) + +RGBA_MODE = "RGBA" +ICO_SIZES_KEY = "sizes" + + +@pytest.fixture(name="mark", scope="module") +def mark_fixture() -> Mark: + return Mark.load() + + +class TestWriteIconSuite: + def test_the_suite_holds_every_file_the_application_ships(self, tmp_path: Path, mark: Mark) -> None: + paths = write_icon_suite(tmp_path, mark) + assert [path.name for path in paths] == [ + ICON_VECTOR_FILENAME, + ICON_UNIX_FILENAME, + ICON_WIN_FILENAME, + ] + assert all(path.is_file() for path in paths) + + def test_the_directory_is_created_where_it_is_missing(self, tmp_path: Path, mark: Mark) -> None: + directory = tmp_path / "icons" + write_icon_suite(directory, mark) + assert directory.is_dir() + + def test_the_raster_is_the_size_the_definition_declares(self, tmp_path: Path, mark: Mark) -> None: + write_icon_suite(tmp_path, mark) + with Image.open(tmp_path / ICON_UNIX_FILENAME) as image: + assert image.size == (mark.render.raster_size, mark.render.raster_size) + assert image.mode == RGBA_MODE + + def test_the_windows_icon_carries_every_declared_size(self, tmp_path: Path, mark: Mark) -> None: + write_icon_suite(tmp_path, mark) + with Image.open(tmp_path / ICON_WIN_FILENAME) as image: + carried = {width for width, _ in image.info[ICO_SIZES_KEY]} + + assert carried == set(mark.render.windows_sizes) + + def test_the_same_definition_writes_the_same_files(self, tmp_path: Path, mark: Mark) -> None: + """One definition produces one suite, so a rebuild leaves the shipped files as they were.""" + first = write_icon_suite(tmp_path / "first", mark) + second = write_icon_suite(tmp_path / "second", mark) + assert [path.read_bytes() for path in first] == [path.read_bytes() for path in second] diff --git a/tests/unit/sampletones_assets/mark/test_vector.py b/tests/unit/sampletones_assets/mark/test_vector.py new file mode 100644 index 00000000..081cb682 --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_vector.py @@ -0,0 +1,47 @@ +from importlib.resources import files +from pathlib import Path + +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.vector import render_vector +from sampletones_shared.paths.resources import ICON_VECTOR_FILENAME + +PLACEHOLDER_PREFIX = "$" +REPLACEMENT_COLOR = "#010203" + + +class TestRenderVector: + def test_the_shipped_vector_is_what_the_definition_renders(self) -> None: + """The committed vector is the mark's design source, so it stays in step with the definition.""" + shipped = Path(str(files("sampletones_assets.icons"))) / ICON_VECTOR_FILENAME + assert render_vector(Mark.load()) == shipped.read_text(encoding="utf-8") + + def test_the_template_is_filled_throughout(self) -> None: + assert PLACEHOLDER_PREFIX not in render_vector(Mark.load()) + + def test_every_colour_reaches_the_document(self) -> None: + mark = Mark.load() + document = render_vector(mark) + colors = ( + mark.colors.background.top, + mark.colors.background.bottom, + mark.colors.sine, + mark.colors.square, + mark.colors.rim, + ) + + for color in colors: + assert color in document + + def test_the_document_follows_the_definition(self) -> None: + """A colour changed in the definition is the colour the vector is drawn with.""" + mark = Mark.load() + recolored = mark.model_copy(update={"colors": mark.colors.model_copy(update={"sine": REPLACEMENT_COLOR})}) + document = render_vector(recolored) + + assert REPLACEMENT_COLOR in document + assert mark.colors.sine not in document + + def test_the_wave_starts_where_the_definition_places_it(self) -> None: + mark = Mark.load() + start = mark.waves.sine.start + assert f'd="M{start.x:g} {start.y:g}' in render_vector(mark) diff --git a/uv.lock b/uv.lock index ac66c620..4699d98d 100644 --- a/uv.lock +++ b/uv.lock @@ -1832,6 +1832,7 @@ dev = [ { name = "black" }, { name = "isort" }, { name = "mypy" }, + { name = "pillow" }, { name = "pre-commit" }, { name = "pylint" }, { name = "pylint-pydantic" }, @@ -1875,6 +1876,7 @@ dev = [ { name = "black", specifier = "==26.5.1" }, { name = "isort", specifier = "==8.0.1" }, { name = "mypy", specifier = "==2.1.0" }, + { name = "pillow", specifier = ">=11,<13" }, { name = "pre-commit", specifier = "==4.6.0" }, { name = "pylint", specifier = "==4.0.6" }, { name = "pylint-pydantic", specifier = "==0.4.1" }, From e9ef7b4804dcb7b15d90dc0e0a75898f80439123 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 17:22:11 +0200 Subject: [PATCH 3/6] Excluded: build-time Pillow from the standalone bundles --- LICENSE | 4 ++- THIRD-PARTY-NOTICES.md | 15 +++++++++++ docs/development/dependencies.md | 6 +++++ scripts/ci/checks/bundle.py | 21 ++++++++++++++- scripts/linux/build/build.sh | 1 + scripts/windows/build/build.bat | 1 + src/sampletones_assets/mark/raster.py | 2 +- tests/unit/scripts/ci/checks/test_bundle.py | 29 +++++++++++++++++++++ 8 files changed, 76 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index aa541094..5918803c 100644 --- a/LICENSE +++ b/LICENSE @@ -22,7 +22,9 @@ SOFTWARE. --- -The MIT license above covers the SampleToNES source code only. +The MIT license above covers the SampleToNES source code and the application +icons under `src/sampletones_assets/icons/`, which are drawn from the mark +declared in `src/sampletones_assets/mark/`. Font files bundled under `src/sampletones_assets/fonts/` are the work of third parties and remain under their own licenses (SIL Open Font License 1.1 and the diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 65d37c0f..99cd24b3 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -109,3 +109,18 @@ The published bundles are **CPU-only**: CuPy, the CUDA runtime and the NVIDIA li are proprietary, and their EULA reserves redistribution to NVIDIA. GPU acceleration comes from installing _SampleToNES_ from PyPI with the `gpu` extra, which fetches CuPy and the CUDA components from their publishers straight to your machine. + +## Build-time tooling + +The application icons are drawn by `sampletones_assets.mark` and rasterized with +[Pillow](https://pypi.org/project/Pillow/), which is under the +[MIT-CMU license](https://github.com/python-pillow/Pillow/blob/main/LICENSE). Pillow belongs +to the `assets` dependency group alone, so `pip`/`uv` installs it on the machine that +generates the icons: it stays out of the wheel's dependency set, and PyInstaller is told to +leave it out of the bundles. Both distributions carry the finished icon files, so Pillow's +attribution clause — a condition on redistributing Pillow itself — rests with the build +environment. + +The icons (`sampletones.svg`, `sampletones.png` and the multi-resolution `sampletones.ico`) +are original _SampleToNES_ artwork and fall under the MIT License together with the rest of +the source. diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index d6b4035c..9dd7da6a 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -63,6 +63,12 @@ points it at the directory the icons are shipped from. Rasterization uses Pillow an editor, and the rasters are produced where they are consumed: `make setup` writes them before packaging the wheel, and the bundle scripts write them before PyInstaller embeds them. +Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that: +`pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is +installed, and PyInstaller follows that import into the bundle. The application reads its icons as +files, so the exclusion spares every bundle Pillow's extension modules and the imaging libraries +that come with them. `scripts/ci/checks/bundle.py` holds the release bundles to it. + ## Linux (standalone executable) Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. diff --git a/scripts/ci/checks/bundle.py b/scripts/ci/checks/bundle.py index 843e3efd..08fd64db 100644 --- a/scripts/ci/checks/bundle.py +++ b/scripts/ci/checks/bundle.py @@ -16,6 +16,9 @@ "THIRD-PARTY-LICENSES.txt", ) +INTERNAL_DIRECTORY: Final[str] = "_internal" +BUILD_TOOLS: Final[Sequence[str]] = ("PIL",) + def launcher_path(bundle: Path, *, system: str) -> Path: """The executable a built bundle offers on the platform it was built for.""" @@ -28,8 +31,19 @@ def missing_notices(bundle: Path) -> List[str]: return [name for name in REQUIRED_NOTICES if not (bundle / name).is_file()] +def carried_build_tools(bundle: Path) -> List[str]: + """The build-time packages found in a bundle, which the notices place on the build machine. + + A bundle carries the application and its runtime dependencies. Tooling that only draws the + assets belongs to the machine that builds it, so finding it here means the notices describe + a different set of components than the bundle ships. + """ + directories = (bundle, bundle / INTERNAL_DIRECTORY) + return [name for name in BUILD_TOOLS if any((directory / name).is_dir() for directory in directories)] + + def main(argv: Sequence[str]) -> int: - """Confirm a built bundle ships its notices and that its launcher starts.""" + """Confirm a built bundle ships its notices, holds to them, and that its launcher starts.""" parser = argparse.ArgumentParser( description="Verify a built bundle before it is archived.", ) @@ -46,6 +60,11 @@ def main(argv: Sequence[str]) -> int: print(f"::error::Bundle {bundle} is missing {', '.join(absent)}") return 1 + carried = carried_build_tools(bundle) + if carried: + print(f"::error::Bundle {bundle} carries build-time tooling its notices leave out: {', '.join(carried)}") + return 1 + launcher = launcher_path(bundle, system=platform.system()) if not launcher.is_file(): print(f"::error::Bundle {bundle} offers no launcher at {launcher}") diff --git a/scripts/linux/build/build.sh b/scripts/linux/build/build.sh index 1271b202..3fd6327e 100755 --- a/scripts/linux/build/build.sh +++ b/scripts/linux/build/build.sh @@ -45,6 +45,7 @@ echo "Building executable..." --add-data "src/sampletones_assets/fonts:assets/fonts" \ --add-data "src/sampletones_config:config" \ --copy-metadata sampletones \ + --exclude-module PIL \ "${RELEASE_HOOK_ARGS[@]}" \ "src/sampletones/__main__.py" diff --git a/scripts/windows/build/build.bat b/scripts/windows/build/build.bat index bbe075f4..915dc471 100644 --- a/scripts/windows/build/build.bat +++ b/scripts/windows/build/build.bat @@ -52,6 +52,7 @@ echo Building executable... --add-data "src\sampletones_assets\fonts;assets\fonts" ^ --add-data "src\sampletones_config;config" ^ --copy-metadata sampletones ^ + --exclude-module PIL ^ %RELEASE_HOOK% ^ "src\sampletones\__main__.py" || exit /b diff --git a/src/sampletones_assets/mark/raster.py b/src/sampletones_assets/mark/raster.py index c9547af0..7c437fe0 100644 --- a/src/sampletones_assets/mark/raster.py +++ b/src/sampletones_assets/mark/raster.py @@ -1,6 +1,6 @@ from typing import Final, Tuple -from PIL import Image, ImageDraw # TODO: update THIRD-PARTY-* files, revise LICENSE if still holds +from PIL import Image, ImageDraw from sampletones_assets.mark.geometry import sine_points, square_rectangles from sampletones_assets.mark.specification import Mark diff --git a/tests/unit/scripts/ci/checks/test_bundle.py b/tests/unit/scripts/ci/checks/test_bundle.py index 28793dfa..06e339dc 100644 --- a/tests/unit/scripts/ci/checks/test_bundle.py +++ b/tests/unit/scripts/ci/checks/test_bundle.py @@ -111,6 +111,21 @@ def test_a_notice_directory_counts_as_absent(self, bundle: Path) -> None: assert check_bundle.missing_notices(bundle) == ["LICENSE"] +class TestCarriedBuildTools: + def test_an_application_bundle_holds_to_its_notices(self, bundle: Path) -> None: + assert check_bundle.carried_build_tools(bundle) == [] + + def test_a_build_tool_beside_the_application_is_reported(self, bundle: Path) -> None: + (bundle / check_bundle.INTERNAL_DIRECTORY / "PIL").mkdir(parents=True) + + assert check_bundle.carried_build_tools(bundle) == ["PIL"] + + def test_a_build_tool_beside_the_launcher_is_reported(self, bundle: Path) -> None: + (bundle / "PIL").mkdir() + + assert check_bundle.carried_build_tools(bundle) == ["PIL"] + + class TestMain: def test_a_complete_bundle_passes( self, @@ -137,6 +152,20 @@ def test_a_missing_notice_is_annotated_as_an_error( assert output.startswith("::error::") assert "THIRD-PARTY-NOTICES.md" in output + def test_bundled_build_tooling_is_annotated_as_an_error( + self, + bundle: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + _install_launcher(bundle) + (bundle / check_bundle.INTERNAL_DIRECTORY / "PIL").mkdir(parents=True) + + assert check_bundle.main([str(bundle)]) == 1 + + output = capsys.readouterr().out + assert output.startswith("::error::") + assert "PIL" in output + def test_a_missing_launcher_is_annotated_as_an_error( self, bundle: Path, From fafc6715f27f6914ebcdce5c3c7864754dd07180 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 18:21:49 +0200 Subject: [PATCH 4/6] Added: logo to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 9688052a..c088a007 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ [![Python](https://img.shields.io/pypi/pyversions/sampletones.svg)](https://pypi.org/project/sampletones/) [![License](https://img.shields.io/pypi/l/sampletones.svg)](https://github.com/JakimPL/SampleToNES/blob/main/LICENSE) +
+ SampleToNES +
+ ## Overview _SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/). From c14429b461783500aa66222aca321fd2669eb4ce Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 19:07:31 +0200 Subject: [PATCH 5/6] Tracked: the generated icon suite --- .github/workflows/ci.yml | 5 +++++ .github/workflows/workflow.yml | 6 ------ .gitignore | 3 --- README.md | 6 +++--- docs/development/dependencies.md | 9 ++++++--- pyproject.toml | 6 ------ src/sampletones_assets/icons/sampletones.ico | Bin 0 -> 32381 bytes src/sampletones_assets/icons/sampletones.png | Bin 0 -> 14609 bytes 8 files changed, 14 insertions(+), 21 deletions(-) create mode 100644 src/sampletones_assets/icons/sampletones.ico create mode 100644 src/sampletones_assets/icons/sampletones.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c8772a4..99ace251 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,11 @@ jobs: - name: Run every pre-commit hook run: uv run pre-commit run --all-files --show-diff-on-failure --color always + - name: Check the committed icons match the mark + run: | + uv run --group assets python scripts/assets/icons.py + git diff --exit-code -- src/sampletones_assets/icons + tests: name: Tests (${{ matrix.os }}, py${{ matrix.python }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 8663dcc8..f94d5553 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -37,12 +37,6 @@ jobs: --tag "$GITHUB_REF_NAME" \ --project-version "$(uv version --short)" - - name: Install PortAudio - run: sudo apt-get update && sudo apt-get install -y portaudio19-dev - - - name: Generate the icon suite - run: uv run --group assets python scripts/assets/icons.py - - name: Build sdist and wheel run: uv build diff --git a/.gitignore b/.gitignore index 5622d69b..585ea73d 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,6 @@ sampletones !src/sampletones !tests/sampletones -src/sampletones_assets/icons/sampletones.ico -src/sampletones_assets/icons/sampletones.png - *.pyc *.pyo *.coverage diff --git a/README.md b/README.md index c088a007..9d505bfb 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ [![Python](https://img.shields.io/pypi/pyversions/sampletones.svg)](https://pypi.org/project/sampletones/) [![License](https://img.shields.io/pypi/l/sampletones.svg)](https://github.com/JakimPL/SampleToNES/blob/main/LICENSE) -
- SampleToNES +
+ SampleToNES
## Overview _SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/). -SampleToNES +SampleToNES The core idea is to approximate an audio sample using only the chip's basic oscillators — two pulse channels, a triangle, and noise — **without any DPCM samples**. diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index 9dd7da6a..924d7cce 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -59,9 +59,12 @@ settings, validated as a `Mark`, and `template.svg` is the vector the rendered g package writes the whole suite — the vector `sampletones.svg` and the rasters the application ships, `sampletones.png` and the multi-resolution `sampletones.ico` — and `scripts/assets/icons.py` points it at the directory the icons are shipped from. Rasterization uses Pillow, declared in the -`assets` dependency group. The vector is committed, so the mark reads as a picture in a browser or -an editor, and the rasters are produced where they are consumed: `make setup` writes them before -packaging the wheel, and the bundle scripts write them before PyInstaller embeds them. +`assets` dependency group. + +The whole suite is committed, so a plain checkout carries the icons the application opens its window +with, and every wheel, bundle and test run finds them without a generation step. `make icons` writes +them again from the mark, and CI regenerates them on each change to confirm the committed files are +the ones the mark describes. Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that: `pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is diff --git a/pyproject.toml b/pyproject.toml index 8e54812e..ac47fab5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,12 +97,6 @@ build-backend = "hatchling.build" [tool.uv] conflicts = [[{ extra = "gpu" }, { extra = "gpu-cuda11" }]] -[tool.hatch.build] -artifacts = [ - "src/sampletones_assets/icons/sampletones.png", - "src/sampletones_assets/icons/sampletones.ico", -] - [tool.hatch.build.targets.wheel] packages = [ "src/sampletones", diff --git a/src/sampletones_assets/icons/sampletones.ico b/src/sampletones_assets/icons/sampletones.ico new file mode 100644 index 0000000000000000000000000000000000000000..5d37a4a25e8883ab82f4ac76d0ae472a7daddcbf GIT binary patch literal 32381 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

17XfK0~M zS)jtVS&!ZU;^zFc_hxPX?w&0ou~>ileAnUK$F5GwaxyYHK&JK~rto+ucI8_4oHglz zvPx#3UY>s6@U}cBhjl`OLExr`<}E9bWan$S@xSNJ)>g^IT9PzI_xu;DS;on8O*!jK z_Zet=*QipNR-Q>tRWW|yg#E{Zx6*RoSBIL_)zt@2_of5J4tBM+2imYj%^`{b3(96h zzEuS+DiI3LJUkEmm96@gJ?;-9u+BJh7neel7Nqfd!~Xg&MW00)140& zuYbr<9s4*GIdAXPS~5gl;#7V=aBUR#FjU;uW(PlE)JM>@s7CPK>zj@2_Nhku{`4j? z&kZA>s|(AgrlwXQ`g!rFWlhz(HyK~tW5S-1`ukf^9+?xPBZ+;_jAlCTNC`1P9#7tm z^v%FTQJ#^Uj24Z|-X@8LX_MH@A zaqLpF32-XDcg0WmHsGIrTaZT=v_d#&;vSv%DRZQ%CE}@c59s%uy%WN6qdt1g?+WJU z{5qYzm}ZuJBM@)7a$a|dEc3%(ufnf3eIZ-K^WLFgSq zAf?_9%C2B^1qaot=BN1;;cST=MoIkn`ooYvhY34=?@V(il2PuY))aX-#Jn#xto=_B z73d&FF?611Q@Uh~j4tSsv?nNZaU+rx9Q!qOeMWW}ZtHOK zO$h({k+5wed|4iUAjS)^1L> z4ooN~;SULHGPO2xZ;d+5FH+re8E0c z(zT_b(MZ}>5GeYPLic575^cY%d)qZ1&p$gTok%odk)jlPBvSm>>@$uYCvI*qk z#!y-Q1wno9EesyJ2jrnDPytur#%7{prWp2!`dbFNx+RBBoDt583W6;Z#Zn1#Q)yvQ zctVKta0TO%?3Yr%NtSF}?w3SGm^WCvj{dl1O{zk{Hvax0CZJL@DFAi;csTD}_>BJt zeOHXcgxLnu719B(QlhN>mW5osSb4;(%PgFU_WIXX`xKJ`E-OKe*)u3R?Ia9SGgBQ} zPu9rfC5bc0dcvN}8PiY%ol=?pX~#1dR5bVXX)Y}NO3sUI87D9^Vf1>Ji|c$=Y%D(h zIWb5eYG?Yi$yc&t*~44X$3BCx#zw>BlS^krXl-_gC_||9^{R(AtM##PW>=u2jRS57 zpOT*bwKvXmezC87ve_WXBI|6zI`)u0Did1?2FTU@DEnSk-c?kAJK#CA=|dEEKlxiH z!ty>AQ)G25Z>f~sNNw^_tECG?h5-3b;vRk=+LmCI2G#W? z>=iM3*`z~~V9BEbVbm%p@0*1pSAQm+`?pC3$22Y0eqE-@tSy~YZrLFwYLOnqALWaU zYkGYLAt0aGf>&{xY}ScbHZ|`J>p-U#)t|d<4r^OTt^e z&n-HBw}K(fwL%mOC@@#!GrnfuTe=q^DB&jn7q^Zz)4~!{%cj-qJLPhR_i8|4nj|o1 zdLao^l_*-lVT}9R+8Gv~H{_DK<)X4(&Et^4u8GK13aOn(hGw#;4;xgkhkmw<*v9l+ zm=9cR`x7V_C~oqkOt;WtuFjAUMaTEM6j<)V+H2B)sas}+mHL{*ugt^@qmz6xw*zD+lz_Xi&y_z9BKWG;%6?=;c4=dcl|z>rn>AQ zNH2vceNFVJ;Rd9GCx$%6LDRa-{mZf$Z6P`{szi!zjTC!aOm*t6g~dbH42u} z1;LiCX|`n#)Pxty_TodbWIRbtB_i3V6Cvu?Qq21+Hj9G7Ui6 zWc0R!Kiw5Y?G?&={$nrZ3gZu#U~4D9jdZ0EzZ6(9dEz}cHe_ZyUyLLXWAEUJTdX4k zGl%JDR9qdqhx#~1B5&%=pVm58nqnnvh1^MvjANiHg9sQ&&=P1-lft4@>sqcO@-v@uSP@)HY( z0?Rw?G|Q44H4YW6>(4w^{;_pB*9v|L;~H(dup@u_C;cAD`*z{XT<&V92JVfX3{9u2 zThsm^oe)qt(kJg;h&30!>#t9as{~uk*Uky@7pn*72UH)K>Vz`=91wvsou0cErKAe5 zk+;vM0)jRS7wg{*Pa6zd6%PytW3~Yj6nesDYlj&G46tCVD~BXKE=QLfUQ!NDjEp~A z>0Xsj@Ow*+VuY(^4l!T%^-}6=kfnV}hnn~=$KW*x?H)B*)E2yaV-|4~t9GYnZ1>lC7HHu^D3jW5Zd)|9wy{=89kq<4Sba>DW zBoDkK5QIF%LOouDZ5(wwE)ekDQR7PH)9K`Jn!BW-mb= z-k5dW4;Q;d7p4XTi2A6>hcTg*lO5@lE|}p7B_4tzE7^!u`5Srw9gutJQ#fQrVd%V& z3%jENCTy@Is&Ycg+{BjMh^_UXSCG`)k9S?F;ACaxT}qwX}gu0Fy* zfP3WgSB(e@&@S?dPue+@_-=D{Z7yMTlpxBBKRAA+~V5OEXt5B$D3GQ@0d&k9~;8io0Q&4 zv%E54B2d7e0JU^T{HN6hF?Eaf3q+I#u&Q^%(D3|2>9uXYYJ&WUZH&tK`;BPz8d`Ym zwwQmo3tGxUJ(9Km4fhiuAN4NfyAw%E{+*g6>sxW{wDH_XL z$@{9?Tlf(o@1KEH^2I$$h_d1v(8gL8dqf_R8#bCW0H^m`a*HbxSeQ-Zw9+P5~r6<90Wsn;FRB8T@Q{Dt;Z7d+L1aUe3(aaRfc=C5#e8F2_@56wQC6AC`P&VUi189xg%XzbS(lN` zd+ZxTU&60kte{xG<0L8>L2@Xgb9?P5x{f4xbFUFZn}>}nUnZ*{){fHrt}%-;{+Vi? z$ZX|^3^{2%F*PDV#UT1sk1Yea-)xeG3fvpSMkw5(L2FjQbYh3MlOYzw7xcNRxSn2> z0XKyo905bHK#^84Qi&!vlr7_zZ{`58?BR`h75EoHCYG3iZI;ZCe^Q89%>ZrG_a9{t z(@vJ6fK4w~B3Qx6!1$$v7$v04N3l&J9Oq+3Q9>+{B3(mNxbd$~JUlIWE>w2ems2-U zk)sCmoBla|eoK`sT3$^!*PpvX9wfWlq5fWFkMI68N84PTo z`>prs;8Kxh(I2nVU5+>JJ#A*-uo~BpDG~YIAz}|fX1d29qb_9DfQx>U{*b(@aFW9^UmQ4b_2UWPq}Q@UfV9^Yn#>?y2j zJ{o`zcOAPBA@Gvk(R2^ML4_hy_z!wafh7B`PyE0GI*wEo4EM7zyl%4_O(_0bg0zJI zYNZP@eoBWZeK{>mODy6mU~q4k5MKeuro{CIF}D2f4r>zOljpp~y-R#Qe65p2sK7qKTJVa3G@^?F&S;t#+0!3|^+_cjaamT+yH`smB6n#rY>iU!U_*-)v| zb&RDX3-O)V)e1a2S8Q}z0voSSUz_DR@Agdo%4KV1meIS@v0?&@ZnuZ_S4Z}hcwiR= zpGdyE9KWfNa_URtRjiRhAD3Tsi4DK%44qN3ry&$>r)x0qoCSV@zk+3x9OFKgT$n8nX2r^hdg+3XPgYYylC99mLWYfnVUjPPx(n!y0phAl< z0K9I?n;NuE0Fj?g1H{B5a#i-<(d*0kQmy?szykozYx423WODV4TFCsvf^+P99}TM- zcHe8OOZ3{29y_o*Z&r0AiVIj_c$^(UMRu+x!_&70DZay@SZ)#8<&TahnJ{nFmHNv)OoqS z4E~6{IH_(h1IBNVm)B>VRuT;DCyhYZzk<J!Yu6y-f04M=LDEMiDA$ zEvEqf9v<|Baag(g7f}r2s|Mc=4W&lr}@}5%sPix zZqDVTQe(#H=Ru(X-{`q3c%S3iE{FveDshZHAP^nHVp=mgclA0RWP?m(vGPxQx)*qr ztX+QmGk0OX=HfY(3q_R81-}M3Ox%`Ficq+82Y&NGi>w^n12t9&omS411dhq zkf#8%baL$zVQIgokOsG3buOuj6Ae1(&rEFMa02#cb>}*#gHsLkc)j#Og?Z<m3Z1w*7Ty#?NkZo_GiK=kZ!t{kbAi5b`A>#BLOFBtbXdDQ``*H^9X5PsR)SqR zUrNb3;F5^_?OjXp-z8zMGWYmkCg&GxrGclwM{8MZ?VVr6*uQ@lAEi!N|)D<^|QEQ07Egp zmKz>jnSazQxBHFxN7VYQH`dF84P=5^kUqWq;A__N@a)cw{6XRb0;3{%(y{v96)yIC zv70Z>d{M11==v%qg3hR%s)VKgO@jb{tF{!CqNY@T_6 zSDtEyw4VCGr%U2PptBdm)Feq(?e6hp3_Y(e;aM>d%6v5+DIHC)F*^d&-z zPUZ3bZ~Tt;Bi}Xud~%Dmo&LS>9#y~3SgE#lfy)D2sz-#iPYKZC!J!tt>SWrse=~%X z{S^Yc99WQty!mYMr@@i@f40f8*=5i)3{FyE^isS&>}(VIuKzIAasBvz$SlUT6h7EU`gBUqEQy?t(2UkR+ z$R85c6h9n-_(a2Hly28^IWwi8<72Zudq(A}e=Cc|el{`;h_q`vg1m{J?6@tDxcn0X z$0F_~beAa>PPj+JP7A7jJlXV(>yMS4GE0lU{Njxhb8HMSBM8hLga8Es% z63SsyxCGyhFrn|8TiDU5b+MV@ZO3nDm*7Fn@39*=41u;Xb7`xAGR9Ec)vcd2D*%u} z5LO}tZ~Pzwbjob!8pq}@JV{wj_4$%FQ*kGIr(V-dyRD^h3s_+dBG?)v`Ik0D zVJu7;cOr%2VqcDXb#xCP?+wAYFbUN?Vn*uPN>{|zf z>7 zmrC)5h4P*O;WBxStDg~LEd*#D1zdQgxB!;30t|_A;)fnc66M*3d;lwCmI+?t1lC862H zf&pd|{~KA2kACc~`Y$w6y6{wK3x}FvmPiU$m@Rgg)Ieb$?NzgG<03}IDgZt!9Eig2 z!`zAWA*ENKiQ}{rZo;o%{!5Ds*=hf4cgAPMkpgtfQ`xdIYbK-Lia*EI!z{suBKx5^ zTW?cn3>qg7^07IS8hHBuOAj;(*I# zh<-tm{wUyuUvCT(q`*c9;yVNy^O5~eM}8;`x&cs-g$H3w|_Z?Fk$^Y?9$Jy zLQdvpQN7og_UoPV*~G1L%ztCsnZ_~)N$(;I0Y}&!3SD;W4hf&0&QGa3Ebq_gZp?+d zmflS&(--TBW4dznY!CW&N88uMwrq5c7O8Q3oH%w^n>dzruy#vTQTmQU%4UJbIhjuD zxs0*Z8T(f7hY-;6ZScv7^sDP%Y^%pM`OE(n$0son2hbv?Y~kBqEDwN>fu{j9m?xQjL60#a(ni*|3U#7o}7mfo?KhMz%W(eKX_CGrMvhH#@d!qAltk ztiBbuU2{qh{`ZrTq8M<7(M@XDUQhqjm!quJf>F9obJV_KoxrYC54+-(ZD230u6^tt zRD}!u?7yMUQMgdKvpOtCn5rRtv`9&Udz|v*euWN;`u>s}gzXD{0)9stV|n!~C`)HJ zO)=xwePgReyrO|ThwRk6onpX~=qz9C6-%ef;rW*?%4He_*@TYK+`n;8xgdbT@`Jq1 zP9GDm8}caw$_AtE_q%@oby)AX4|GZ6FKy}hf~xj1Y(X)flMQ`0eoQD8{g(W``D)Dy z$t*@$Y_e7QqHQSy^I<#Lze8j(cB(0ZQH(tWdE@qB?nu(^u59^y;O37k=5r;1V4M>o zWa*Bs3Rl!(&t&--`$awF`_^QqB9JTTMU#qq@nNS@%VkVJSiDkB^`GgIs_0Nc**?6pxto(y*WP zW#iNi?KKjqsIUCz^#k?ja*H5Xj&fvn)324S5oWbskSRqKY zPm!}>7ec{5q7j3o3oDHsVVsVnSj(!}Xs}GWlq47dtmoV0TCRG}Qi3iX_tFTxoNwLP z!5(J&pQU0H?Jt{Vj^wka?_BvlPrCdjgCeZ1IoRM@2w5_R$qAL6$tI}5{v$@l-aff+ zv%P6&nh8Jp`sZkNq2ClB>+DfDIbNQhKeIjDBw`=)d?It;Dr9G7gQNcK?_cEyrVsC6 zOaecEox*BJJQzh@A0>B$5B$g2~0EnYhtCxV*KuT=Wfgy&O`c zi%V0zJn^d|cfNWx@j^=-! zvR!-3FZ$fjNok;5dU&_pASb@68Y($FjJx*0!Vj1%7XcnCmad%U_L#nIXvrPMsO|Rw zW|;+|1uFI{_@{F>syc0wgw^hV>+u`p@a3eZW9epNYsM^C5H`VNi2;hp_dAB$4w*Bj z8tHHI@cHAkP*2}=Fm`s5HW0cBX;5A(#>T!5`d3?hO^9RtZyFPUnr(*SAHFLIH@bXZ z)x~Q79uVGrr)YmDd1@ygPqKghxZ618xxX!NNey)>r1{4Qhy-u4xD{kQ_UC9bF@M}9G8Bg?~yM%67qO5 z5dypGUTf*NDoOA9@z+MM)0Iz`SBR=Zye;nnsJZFru>zbOmrydii;7t~Aw%!7DLL<5 z0HAkodG%#3A9%kMb3RCo(qE{U37H2`rf3Q+eqtU_Xme^ZhQgo~rarmO4t!o*bLPp=u78t;vQR*9yGery0 z@JBSx9MD5Ks-1aYaXRHCU&hQK>QRr{7Bki%R{m$ z{$hP{9N$|q{azJMUe|Ia_5aqd|3Ag*Z~wP4Ek*MG)UIWB+st}nj^F-`rZu*-N zLtqa{W6^Qpx48QIdPkVk_X)38k}M~14yuWnu6%OIpkfgX1+8_hutzKwgtd`r#Gf1< zi8^qE)t}aYm&rKHep=VZja7g+;&xiUKL?@3eg4q2tpiCQHY*RT=Md)82<}Hu~e$k=a$pN-#DyJV&GS8 zI4u4ZXk(L+uKp8f0QIJWkk0g|enoq|9c{yxMP!|E9jf{kPCW#qoQF91CyK2kJ zMw}ooQk0Z59;xFe_nMSL1#ucGIqBv~|CReA&)vI#C;HLdx z1P6!9$rp|wq90RJ=`3F)`6BTr_36V@)*#+FxS28|9Q<>v#EDQ@l{8e{D@!=04`4OH z1MOj0?A{ZrKjI4j*EN0-396p}hlpVNr0?KTJ>C7J8tsUp6!_%#mz*A1wM>NITUUno zz3g4yB8-6PI}4Y8jGYFnG>2~8&x5uU*F)n}xL!?&4eJ}=kekBXQ_EW(nL@!LAZ}V` zlfODD2HAN|V9W~uHtlNs*WdR@8nGZ??k zGR+qsaWK+uJPH}Ox~PrfF{>g~{WUNVW%dJ?pidf7BZe+&qbL5#JQmadt1L2h#JpY# zB`RcKW)=q!X>EGhc}fxQ4fgpdVpWJI-0)bElDfaDa-@bmR40T}7Cun?!S|XCNJva% z1fd;pQ1B_+vsSx`*T!0!&)r<*NgUuG`|>PjaEJ>NDM*C(T|=+(p#e25&7K)YwxGXl z8DFO>0GJx+PRRN4fjk6zfAT@z&hvwfV__+lq|ael%lz$0wQ~2d1k1U=l*Wc*x)&Jz z(6xD0hE5Wqq_?IZ!zrMTXiDq)6}u`dxaHvun=m6KuTazb+BosxqcZg8ebeXHy;H5~ zuwTJX;{tF1Y&kJs3?5#}6@@urjEnHUF#N#ZuXS7p|s*vCOplI7zYX`1tE`wj+jgb$u0z0Rc zmj8#>)b7XD)>gvv+#<143Jiq+K>R4|;5p)PhJ31myF=b*tL|lw?t+KnzCPr2{NbqW@4rCN8PE_9;4r&Vjd^<~N~Ca;UKi05R02G$9r! zj_0+`BF&Q2K$SDPJxGe4d9)_-W*#?_yeM#{8-Y_LGOv)=#S{c6LnGVT*f&W$Bu(3bReTb;$6u@Aa}w2gY`80qYh3VL@s1Q*Wa7x z)~hD0CYX*3uTpf2tiKn{-J?WQmWmE!00X_pSp!*<(lRoF|DhhEtK0VEFYaC-pM`zp z;dpu_96`N`$y@1*dc@+!op8Zh0uNQgx9rUx9b3AZYB+L% z&yuuZE$YR0y}c0F=^mMazht6Mx`174yING`^FFHTOw8G?$HDnme!V_pt8Y5ouGu@# zSv;t9;M=v|QaZb(&y_N8ZWP>ojrUDs1le@vaBbl6ng5*B@TH&p> zm&6B;8zT2+!+-_Z8K=jn54&dWZdB`{=HFc(su*DrG#E)Pv1341Ft& zsMg)j;Y(FNbG~DkPn1IHon{^^w!LniX$@+;J$TxPoIaw$NNU#Go z*}XIW9Hb+6i4(^kT`Hv#e1?S5IotvM7U26zI5&Y_hBW@718`QI+_n&U0JCCUc8ofe zwIWblXHHJqYZ9Qkbv8qEb(6`;HUYW(b^YKPs9n}ao0&*#pv{3iWZ!Sz1 zZV90BBb}rl%ZLt@XJ6jba6P&kA*HvQSLIa`2Ln&!7~qFi4X^~`oSct4phtQa;$c>= zH#Wl=&G_|9%aa)%bm=9`W1Vm*u;+E&rIkH!_o;>>8ST>ccLsZZnryNdz3j<`cs|gm zar5Qt_Z2)B?li#zJFLULb*UxkuK%h1<=O!WI^m|`FMP{*Zb_x2A0CGLdLb`OBw>_- zSon5Uz2}b1Z`Tj)sg#Xr(|9enWqOVTqw@eFVPZ5Lu`pag+ZCb7QFq>(N{K8)i?ox z?g7-=E1WLXjZ8QUxUxfOAwd3AkwskKqv!FdEwJ<)Tk!1Fi~iZdvQfQrJN~haPr& zYKnr_{QF|H>g>8*!?fr8)7+L1Ca)h82arHw3Lu?U1d);I5OJh2g`TLF5%1u#lTesl z9qiRPZ}ihZhueglLA5NQa|GJt=rzwxb29dC^A+ke^V;#N3CX%n2@H^H*-K*8xt0!d zQujATs8RlT%;CJUy2KKhH1YeZV_a8e2;cc~*bfTIv0ND`UyL$3VM#=?dlC}w%I`?v z@$lJf@-tcxywDzMrvKX?zkh0%qE7?V<9 zihZ-M>P-ytrN;hDhYYfAy0{?UWnmBp^;#E)X<{`zbu1s0JiNKY!EAB`O2(0vyK(Fz zZ&kdjsaDv0<$W--qd~f}Od?%*%JqL1PlAn9c(ls2%r(a}1fKtWXXRyGq(je+>_x*w!P(<6*AzAo*si#CU=CY{1~@?x{v@y5et z2?zYuVSA1%o9GovRfeE7$L59iH>YO9rv_s$h!J$AUtV8zNen-7UktobfDe35Ig|yE z=&!6?BM%4#(Ypk(KF Date: Mon, 17 Aug 2026 19:23:48 +0200 Subject: [PATCH 6/6] Improved: workflow --- .github/workflows/ci.yml | 4 +--- .pre-commit-config.yaml | 10 ++++++++++ docs/development/dependencies.md | 6 +++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99ace251..8a9cc876 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,9 +42,7 @@ jobs: run: uv run pre-commit run --all-files --show-diff-on-failure --color always - name: Check the committed icons match the mark - run: | - uv run --group assets python scripts/assets/icons.py - git diff --exit-code -- src/sampletones_assets/icons + run: uv run pre-commit run icons --all-files --hook-stage pre-push --color always tests: name: Tests (${{ matrix.os }}, py${{ matrix.python }}) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 570b4496..0f67e871 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -91,6 +91,16 @@ repos: require_serial: true exclude: ^(tests/) + - id: icons + name: icons + entry: uv run python scripts/assets/icons.py + language: system + files: ^src/sampletones_assets/(icons|mark)/ + pass_filenames: false + verbose: true + stages: + - pre-push + - id: pytest name: pytest entry: make test diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index 924d7cce..e0de8283 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -62,9 +62,9 @@ points it at the directory the icons are shipped from. Rasterization uses Pillow `assets` dependency group. The whole suite is committed, so a plain checkout carries the icons the application opens its window -with, and every wheel, bundle and test run finds them without a generation step. `make icons` writes -them again from the mark, and CI regenerates them on each change to confirm the committed files are -the ones the mark describes. +with, and every wheel, bundle and test run finds them where they lie. `make icons` writes them again +from the mark, and the `icons` pre-push hook writes them for a push that touches either directory, +holding the committed files to what the mark describes. CI runs that same hook. Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that: `pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is