From e8c65c2098bd7c8aacb48b7c51d066a9f3e65a6b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 30 Jul 2026 22:45:57 +0200 Subject: [PATCH 01/10] Added: GitHub workflows and updated documentation --- .github/workflows/release.yml | 213 ++++++++++++++++++++++++++++++++ CHANGELOG.md | 4 +- THIRD-PARTY-NOTICES.md | 12 +- docs/index.md | 1 - src/sampletones_shared/paths.py | 1 - 5 files changed, 221 insertions(+), 10 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..8485cd30 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,213 @@ +name: Release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + target: + description: "Where to publish" + required: true + default: testpypi + type: choice + options: + - testpypi + - pypi + +jobs: + build: + name: Build distributions + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + + - name: Verify tag matches project version + if: startsWith(github.ref, 'refs/tags/v') + run: | + project_version="$(uv version --short)" + tag_version="${GITHUB_REF_NAME#v}" + if [ "$project_version" != "$tag_version" ]; then + echo "::error::Tag $GITHUB_REF_NAME does not match pyproject version $project_version" + exit 1 + fi + echo "Version $project_version matches tag $GITHUB_REF_NAME" + + - name: Build sdist and wheel + run: uv build + + - name: Check metadata renders on PyPI + run: uvx twine check --strict dist/* + + - uses: actions/upload-artifact@v4 + with: + name: dist + path: dist/ + + smoke-test: + name: Smoke test (${{ matrix.os }}, py${{ matrix.python }}) + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python: ["3.12", "3.13"] + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + - name: Install PortAudio (Linux) + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y portaudio19-dev + + - name: Install PortAudio (macOS) + if: runner.os == 'macOS' + run: brew install portaudio + + - name: Install the wheel and check the entry point + shell: bash + run: | + python -m pip install --upgrade pip + python -m pip install dist/*.whl + sampletones --version + + - name: Check the wheel carries every resource it needs at startup + shell: bash + run: sampletones --self-check + + bundle: + name: Standalone bundle (${{ matrix.platform }}) + # Only a tag push produces downloadable binaries. A manual TestPyPI run must never + # create a GitHub Release. + if: startsWith(github.ref, 'refs/tags/v') + needs: build + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + platform: linux-x86_64 + - os: windows-latest + platform: windows-x86_64 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install system libraries (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y python3-tk tk-dev libportaudio2 libasound-dev portaudio19-dev + + - name: Install SampleToNES and its runtime dependencies + run: | + python -m pip install --upgrade pip + python -m pip install . + + - name: Build the bundle (Linux) + if: runner.os == 'Linux' + run: bash scripts/linux/build/build.sh --release + + - name: Build the bundle (Windows) + if: runner.os == 'Windows' + shell: cmd + run: scripts\windows\build\build.bat --release + + - name: Check the bundle runs and carries its notices + shell: bash + run: | + test -f bin/sampletones/LICENSE + test -f bin/sampletones/THIRD-PARTY-NOTICES.md + test -f bin/sampletones/THIRD-PARTY-LICENSES.txt + if [ "$RUNNER_OS" = "Windows" ]; then + ./bin/sampletones/sampletones.exe --version + else + ./bin/sampletones/sampletones --version + fi + + - name: Zip the bundle + shell: bash + run: | + name="sampletones-${GITHUB_REF_NAME}-${{ matrix.platform }}" + mkdir -p bundles + mv bin/sampletones "bin/${name}" + python -c "import shutil, sys; shutil.make_archive(sys.argv[1], 'zip', 'bin', sys.argv[2])" \ + "bundles/${name}" "${name}" + + - uses: actions/upload-artifact@v4 + with: + name: bundle-${{ matrix.platform }} + path: bundles/*.zip + + release: + name: Attach bundles to a draft GitHub Release + if: startsWith(github.ref, 'refs/tags/v') + needs: bundle + runs-on: ubuntu-latest + permissions: + # The only job in this workflow that can write to the repository, and only to + # create the release. The release is left as a draft: nothing becomes public + # until you press Publish yourself. + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: bundle-* + merge-multiple: true + path: bundles + + - name: Create or update the draft release + env: + GH_TOKEN: ${{ github.token }} + run: | + if ! gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + gh release create "$GITHUB_REF_NAME" \ + --repo "$GITHUB_REPOSITORY" \ + --draft \ + --title "$GITHUB_REF_NAME" \ + --generate-notes + fi + gh release upload "$GITHUB_REF_NAME" bundles/*.zip \ + --repo "$GITHUB_REPOSITORY" --clobber + echo "Draft release ready. It stays invisible until you publish it." + + publish: + name: Publish to ${{ github.event_name == 'workflow_dispatch' && inputs.target || 'pypi' }} + needs: [build, smoke-test] + runs-on: ubuntu-latest + environment: + name: ${{ github.event_name == 'workflow_dispatch' && inputs.target || 'pypi' }} + permissions: + # Required for PyPI Trusted Publishing (OIDC). No API token is stored anywhere. + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: dist + path: dist + + - name: Publish to TestPyPI + if: github.event_name == 'workflow_dispatch' && inputs.target == 'testpypi' + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + - name: Publish to PyPI + if: github.event_name != 'workflow_dispatch' || inputs.target == 'pypi' + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md index d6a9c05c..1288d334 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # SampleToNES -## v0.3.0 [2026-07-25] +## v0.3.0 [2026-07-30] * Added a _Sequencer_ view with FamiTracker-style patterns. * Added project export in a FamiTracker-compatible format. -* Improved matching algorithms and extended availalbe methods (`LogFFT`, `CQT`). +* Improved matching algorithms and extended available methods (`LogFFT`, `CQT`). * Improved the general layout of the application. * Changed the internal file formats (`.stn`, `.ins`). * Switched to `uv` as the package manager. diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 14d8fb96..d1df87a1 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -12,7 +12,8 @@ Two different things are distributed, and they carry different obligations: | The standalone bundles attached to GitHub Releases | _SampleToNES_ code, the fonts, the Python runtime, and every dependency — including several native libraries | Fonts, plus the third-party terms described below | The full license text of everything in the standalone bundles is reproduced in -[`THIRD-PARTY-LICENSES.txt`](THIRD-PARTY-LICENSES.txt), which ships inside each bundle. +[`THIRD-PARTY-LICENSES.txt`](https://github.com/JakimPL/SampleToNES/blob/main/THIRD-PARTY-LICENSES.txt), +which ships inside each bundle. ## Bundled fonts @@ -23,9 +24,9 @@ them in `sampletones_assets/fonts/LICENSES/`. | Font | Copyright | License | | --- | --- | --- | -| Roboto Mono (all weights and italics, including the variable fonts) | Copyright 2015 The Roboto Mono Project Authors | [SIL Open Font License 1.1](src/sampletones_assets/fonts/LICENSES/OFL-1.1.txt) | -| Source Sans 3 (Regular, Italic, Bold) | © 2023 Adobe, with Reserved Font Name "Source" | [SIL Open Font License 1.1](src/sampletones_assets/fonts/LICENSES/OFL-1.1.txt) | -| DejaVu Sans | © 2003 Bitstream, Inc.; Arev glyphs © Tavmjong Bah; DejaVu changes in the public domain | [Bitstream Vera / Arev](src/sampletones_assets/fonts/LICENSES/DejaVu-BitstreamVera.txt) | +| Roboto Mono (all weights and italics, including the variable fonts) | Copyright 2015 The Roboto Mono Project Authors | [SIL Open Font License 1.1](https://github.com/JakimPL/SampleToNES/blob/main/src/sampletones_assets/fonts/LICENSES/OFL-1.1.txt) | +| Source Sans 3 (Regular, Italic, Bold) | © 2023 Adobe, with Reserved Font Name "Source" | [SIL Open Font License 1.1](https://github.com/JakimPL/SampleToNES/blob/main/src/sampletones_assets/fonts/LICENSES/OFL-1.1.txt) | +| DejaVu Sans | © 2003 Bitstream, Inc.; Arev glyphs © Tavmjong Bah; DejaVu changes in the public domain | [Bitstream Vera / Arev](https://github.com/JakimPL/SampleToNES/blob/main/src/sampletones_assets/fonts/LICENSES/DejaVu-BitstreamVera.txt) | The fonts are redistributed unmodified. Reserved Font Names ("Source", "Bitstream", "Vera") are not used in any SampleToNES component name. @@ -111,5 +112,4 @@ their publishers. The inventory above is a snapshot of the pinned dependency set in `pyproject.toml` and `uv.lock`. When those change, both this file and `THIRD-PARTY-LICENSES.txt` need to be -reviewed — see [`docs/development/release.md`](docs/development/release.md) for the release -checklist. +reviewed before the next release. diff --git a/docs/index.md b/docs/index.md index dba79e7b..e97a5cff 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,7 +56,6 @@ The [**development**](development/) section is for contributors. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. - [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on. -- [Releasing](development/release.md) — the release process, what the workflow does, and the license checks. - [Bugs and to-dos](development/bugs-and-todos.md) — the working ledger of known gaps. ## Glossary diff --git a/src/sampletones_shared/paths.py b/src/sampletones_shared/paths.py index 31af824e..9f41982c 100644 --- a/src/sampletones_shared/paths.py +++ b/src/sampletones_shared/paths.py @@ -5,6 +5,5 @@ import sampletones_config # Root -ROOT_DIRECTORY: Final[Path] = Path(__file__).parents[2] APPLICATION_SOURCE_DIRECTORY: Final[Path] = Path(sampletones_application.__file__).parent CONFIG_DIRECTORY: Final[Path] = Path(sampletones_config.__file__).parent From 82564845765ac91ce57a246129202b439e5a157f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 30 Jul 2026 23:52:14 +0200 Subject: [PATCH 02/10] Fixed: tests --- tests/suite/errors.py | 7 ++-- .../logic/project/test_manager.py | 5 +-- .../logic/reconstruction/test_manager.py | 5 +-- .../utils/file_dialogs/test_kdialog.py | 2 +- .../file_dialogs/test_tkinter_backend.py | 2 +- .../utils/file_dialogs/test_zenity.py | 2 +- .../sampletones_core/library/test_data.py | 3 +- .../project/test_container.py | 5 +-- .../converter/paths/test_utils.py | 24 +++++++------- .../reconstruction/test_reconstruction.py | 3 +- .../utils/system/test_filesystem.py | 22 +++++++++++++ .../utils/system/test_paths.py | 33 +++++++++++-------- 12 files changed, 74 insertions(+), 39 deletions(-) diff --git a/tests/suite/errors.py b/tests/suite/errors.py index bc54c906..9cc5f65b 100644 --- a/tests/suite/errors.py +++ b/tests/suite/errors.py @@ -1,9 +1,12 @@ import inspect from re import Pattern -from typing import Any, Callable, Optional, Tuple, Union +from typing import Any, Callable, Final, Optional, Tuple, Type, Union import pytest +# Opening a directory for reading raises IsADirectoryError on POSIX and PermissionError on Windows. +DIRECTORY_READ_ERRORS: Final[Tuple[Type[OSError], ...]] = (IsADirectoryError, PermissionError) + def _invoke_with_raises( function: Callable[..., Any], @@ -12,7 +15,7 @@ def _invoke_with_raises( match: Optional[Union[str, Pattern[str]]] = None, **kwargs: Any, ) -> None: - assert isinstance(expected, type) + assert isinstance(expected, type) or (isinstance(expected, tuple) and all(isinstance(e, type) for e in expected)) with pytest.raises(expected, match=match): function(*args, **kwargs) diff --git a/tests/unit/sampletones_application/logic/project/test_manager.py b/tests/unit/sampletones_application/logic/project/test_manager.py index 8f274a59..e0af6e43 100644 --- a/tests/unit/sampletones_application/logic/project/test_manager.py +++ b/tests/unit/sampletones_application/logic/project/test_manager.py @@ -5,6 +5,7 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_core.constants.enums import GeneratorName from sampletones_shared.exceptions import NotAValidArchiveError +from tests.suite.errors import DIRECTORY_READ_ERRORS class TestProjectManager: @@ -49,8 +50,8 @@ def test_missing_file_raises_file_not_found(self, tmp_path: Path) -> None: with pytest.raises(FileNotFoundError): ProjectManager().load(tmp_path / "nope.stp") - def test_directory_raises_is_a_directory(self, tmp_path: Path) -> None: - with pytest.raises(IsADirectoryError): + def test_directory_raises_directory_read_error(self, tmp_path: Path) -> None: + with pytest.raises(DIRECTORY_READ_ERRORS): ProjectManager().load(tmp_path) def test_invalid_archive_raises_load_project_error(self, tmp_path: Path) -> None: diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py index cbc4596d..cb0a74df 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_manager.py @@ -11,6 +11,7 @@ from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_shared.exceptions import LoadReconstructionError +from tests.suite.errors import DIRECTORY_READ_ERRORS class TestLoadReconstructionPropagatesErrors: @@ -22,8 +23,8 @@ def test_missing_file_raises_file_not_found(self, tmp_path: Path) -> None: with pytest.raises(FileNotFoundError): self._manager().load_reconstruction(tmp_path / "nope.stn") - def test_directory_raises_is_a_directory(self, tmp_path: Path) -> None: - with pytest.raises(IsADirectoryError): + def test_directory_raises_directory_read_error(self, tmp_path: Path) -> None: + with pytest.raises(DIRECTORY_READ_ERRORS): self._manager().load_reconstruction(tmp_path) def test_foreign_file_raises_load_reconstruction_error(self, tmp_path: Path) -> None: diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py b/tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py index 0b02fc51..02c1be85 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_kdialog.py @@ -28,7 +28,7 @@ def test_save_command_carries_suggested_name_and_named_filter(self) -> None: command = run.call_args.args[0] assert result == Path("/home/user/song.stp") assert "--getsavefilename" in command - assert "/home/user/song.stp" in command + assert str(Path("/home/user/song.stp")) in command assert "*.stp|Project files (*.stp)" in command assert command[command.index("--title") + 1] == "Save project" diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py b/tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py index 39cc4847..5f0c85fa 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_tkinter_backend.py @@ -24,7 +24,7 @@ def test_save_passes_filetypes_and_disposes_root(self) -> None: assert result == Path("/home/user/song.stp") assert kwargs["filetypes"] == [("Project files (*.stp)", ("*.stp",))] assert kwargs["initialfile"] == "song" - assert kwargs["initialdir"] == "/home/user" + assert kwargs["initialdir"] == str(Path("/home/user")) tk.return_value.withdraw.assert_called_once() tk.return_value.destroy.assert_called_once() diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py b/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py index 0cbbcd0f..cdbc24e7 100644 --- a/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py +++ b/tests/unit/sampletones_application/utils/file_dialogs/test_zenity.py @@ -30,7 +30,7 @@ def test_save_command_uses_named_filter_and_filename(self) -> None: assert result == Path("/home/user/song.stp") assert "--save" in command assert command[command.index("--file-filter") + 1] == "Project files (*.stp) | *.stp" - assert command[command.index("--filename") + 1] == "/home/user/song.stp" + assert command[command.index("--filename") + 1] == str(Path("/home/user/song.stp")) def test_open_command_filter_format(self) -> None: backend = ZenityBackend() diff --git a/tests/unit/sampletones_core/library/test_data.py b/tests/unit/sampletones_core/library/test_data.py index d5144233..aa518334 100644 --- a/tests/unit/sampletones_core/library/test_data.py +++ b/tests/unit/sampletones_core/library/test_data.py @@ -19,6 +19,7 @@ ) from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.errors import DIRECTORY_READ_ERRORS def _library(metadata: Optional[Metadata] = None) -> InstructionLibraryData: @@ -67,7 +68,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="directory", make_path=lambda root: root, - expected=IsADirectoryError, + expected=DIRECTORY_READ_ERRORS, ), ] diff --git a/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py index a677e170..1ede6558 100644 --- a/tests/unit/sampletones_core/project/test_container.py +++ b/tests/unit/sampletones_core/project/test_container.py @@ -25,6 +25,7 @@ UnhandledProjectError, ) from tests.conftest import ReconstructionFactory +from tests.suite.errors import DIRECTORY_READ_ERRORS _RECONSTRUCTION_VERSION_CONSTANT = ( "sampletones_core.reconstructions.reconstruction.reconstruction.SAMPLETONES_RECONSTRUCTION_DATA_VERSION" @@ -193,8 +194,8 @@ def test_missing_file_raises_file_not_found(self, tmp_path: Path) -> None: with pytest.raises(FileNotFoundError): ProjectContainer.load(tmp_path / "nope.stp") - def test_directory_raises_is_a_directory(self, tmp_path: Path) -> None: - with pytest.raises(IsADirectoryError): + def test_directory_raises_directory_read_error(self, tmp_path: Path) -> None: + with pytest.raises(DIRECTORY_READ_ERRORS): ProjectContainer.load(tmp_path) def test_non_zip_raises_not_a_valid_archive(self, tmp_path: Path) -> None: 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 8e79090f..4c38eec0 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py @@ -19,24 +19,24 @@ def config() -> Config: class TestGetRelativePath: - def test_preserves_subdirectory_structure(self) -> None: - base = Path("/base") - audio_file = Path("/base/sub/file.wav") - output = Path("/output") + def test_preserves_subdirectory_structure(self, tmp_path: Path) -> None: + base = tmp_path / "base" + audio_file = base / "sub" / "file.wav" + output = tmp_path / "output" result = get_relative_path(base, audio_file, output) - assert result == Path("/output/sub/file.stn") + assert result == output / "sub" / "file.stn" - def test_replaces_extension_with_suffix(self) -> None: - base = Path("/base") - audio_file = Path("/base/song.wav") - output = Path("/output") + def test_replaces_extension_with_suffix(self, tmp_path: Path) -> None: + base = tmp_path / "base" + audio_file = base / "song.wav" + output = tmp_path / "output" result = get_relative_path(base, audio_file, output) assert result.suffix == EXT_FILE_RECONSTRUCTION def test_result_is_absolute(self) -> None: - base = Path("/base") - audio_file = Path("/base/song.wav") - output = Path("/output") + base = Path("base") + audio_file = Path("base/song.wav") + output = Path("output") result = get_relative_path(base, audio_file, output) assert result.is_absolute() diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 289f1357..64c2d098 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -23,6 +23,7 @@ from tests.suite.arrays import assert_array_equal from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase +from tests.suite.errors import DIRECTORY_READ_ERRORS _RETUNED_FREQUENCY: Final[int] = 60 _FASTER_FREQUENCY: Final[int] = 120 @@ -103,7 +104,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="directory", make_path=lambda root: root, - expected=IsADirectoryError, + expected=DIRECTORY_READ_ERRORS, ), ] diff --git a/tests/unit/sampletones_shared/utils/system/test_filesystem.py b/tests/unit/sampletones_shared/utils/system/test_filesystem.py index 8fa32701..99dbeec5 100644 --- a/tests/unit/sampletones_shared/utils/system/test_filesystem.py +++ b/tests/unit/sampletones_shared/utils/system/test_filesystem.py @@ -1,10 +1,31 @@ +import tempfile from pathlib import Path +from typing import Final import pytest from sampletones_shared.utils.system.filesystem import remove_path +def _symlinks_are_permitted() -> bool: + """Reports whether this machine lets an unprivileged process create a symlink. + + Windows grants the privilege only under Developer Mode or elevation, so the probe + creates one in a throwaway directory and reads the answer from the attempt. + """ + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + try: + (root / "probe").symlink_to(root, target_is_directory=True) + except OSError: + return False + + return True + + +SYMLINKS_PERMITTED: Final[bool] = _symlinks_are_permitted() + + class TestRemovePath: def test_removes_file(self, tmp_path: Path) -> None: target = tmp_path / "library.stnlib" @@ -26,6 +47,7 @@ def test_removes_directory_recursively(self, tmp_path: Path) -> None: assert removed == target assert not target.exists() + @pytest.mark.skipif(not SYMLINKS_PERMITTED, reason="creating a symlink requires a privilege this machine withholds") def test_removes_directory_symlink_without_touching_target(self, tmp_path: Path) -> None: target = tmp_path / "target" target.mkdir() diff --git a/tests/unit/sampletones_shared/utils/system/test_paths.py b/tests/unit/sampletones_shared/utils/system/test_paths.py index 08b4dead..2b6f494d 100644 --- a/tests/unit/sampletones_shared/utils/system/test_paths.py +++ b/tests/unit/sampletones_shared/utils/system/test_paths.py @@ -8,7 +8,6 @@ import pytest -from sampletones_shared.types.path import GeneralPathlike from sampletones_shared.utils.system.paths import ( DEFAULT_MAX_FILENAME_DISPLAY, ensure_suffix, @@ -150,7 +149,7 @@ def test_to_path(self, test_case: TestCase) -> None: if isinstance(test_case.input_path, Path): assert result is test_case.input_path - assert str(result) == test_case.expected + assert result == Path(test_case.expected) class TestEnsureSuffix(BaseTestSuite): @@ -428,12 +427,17 @@ class TestCase(BaseRegularTestCase): ), ] - def _create_resolved_mock(self, resolved_path: GeneralPathlike) -> MagicMock: + def _create_resolved_mock(self, resolved_path: Any) -> MagicMock: + """Stands in for the resolved path, keeping the path flavour each case declares. + + Every case states its expectation as a ``PurePosixPath`` or a ``PureWindowsPath``, so + the parts come from that pure path and the case reads the same on either platform. + """ resolved_mock = MagicMock() try: - resolved_mock.parts = Path(resolved_path).parts + resolved_mock.parts = resolved_path.parts resolved_mock.__str__ = MagicMock(return_value=str(resolved_path)) # type: ignore[method-assign] - except TypeError: + except AttributeError: resolved_mock.parts = () return resolved_mock @@ -590,7 +594,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="dolphin_kde", desktop_file="org.kde.dolphin.desktop", - expected=["dolphin", "--select", "/tmp/test.txt"], + expected=["dolphin", "--select"], path="/tmp/test.txt", mime_returncode=0, command_returncode=0, @@ -599,7 +603,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="dolphin_plain", desktop_file="dolphin.desktop", - expected=["dolphin", "--select", "/tmp/test.txt"], + expected=["dolphin", "--select"], path="/tmp/test.txt", mime_returncode=0, command_returncode=0, @@ -608,7 +612,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="nautilus_gnome", desktop_file="org.gnome.Nautilus.desktop", - expected=["nautilus", "--select", "/tmp/test.txt"], + expected=["nautilus", "--select"], path="/tmp/test.txt", mime_returncode=0, command_returncode=0, @@ -617,7 +621,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="nautilus_plain", desktop_file="nautilus.desktop", - expected=["nautilus", "--select", "/tmp/test.txt"], + expected=["nautilus", "--select"], path="/tmp/test.txt", mime_returncode=0, command_returncode=0, @@ -626,7 +630,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="nemo", desktop_file="nemo.desktop", - expected=["nemo", "/tmp/test.txt"], + expected=["nemo"], path="/tmp/test.txt", mime_returncode=0, command_returncode=0, @@ -635,7 +639,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="thunar", desktop_file="thunar.desktop", - expected=["thunar", "/tmp/test.txt"], + expected=["thunar"], path="/tmp/test.txt", mime_returncode=0, command_returncode=0, @@ -644,7 +648,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="whitespace_in_path", desktop_file="org.kde.dolphin.desktop", - expected=["dolphin", "--select", "/tmp/file with spaces.txt"], + expected=["dolphin", "--select"], path="/tmp/file with spaces.txt", mime_returncode=0, command_returncode=0, @@ -671,7 +675,7 @@ class TestCase(BaseRegularTestCase): TestCase( label="command_execution_fails", desktop_file="org.kde.dolphin.desktop", - expected=["dolphin", "--select", "/tmp/test.txt"], + expected=["dolphin", "--select"], path="/tmp/test.txt", mime_returncode=0, command_returncode=1, @@ -718,8 +722,9 @@ def test_open_file_in_explorer_linux( path = Path(test_case.path) open_file_in_explorer_linux(path) + expected = test_case.expected + [str(path)] assert mock_run.call_count == 2 - assert mock_run.call_args_list[1] == call(test_case.expected, check=False, capture_output=True) + assert mock_run.call_args_list[1] == call(expected, check=False, capture_output=True) class TestOpenPathInExplorer(BaseTestSuite): From 47ead29b90a0bf2b5400216d2bc9bc91e3586b1a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 31 Jul 2026 12:15:12 +0200 Subject: [PATCH 03/10] Code reformat --- src/sampletones_application/application.py | 34 ++++- .../categories/export.py | 7 +- .../config/deployment/logs.py | 2 - .../config/managers/config.py | 29 +++-- .../config/managers/session.py | 5 +- .../config/managers/state.py | 5 +- .../coordinators/config.py | 25 +++- .../coordinators/original_audio.py | 5 +- .../coordinators/project.py | 43 +++++-- .../coordinators/reconstruction.py | 5 +- .../coordinators/tabs/reconstruction.py | 73 +++++++++-- .../coordinators/tabs/sequencer.py | 54 ++++++-- src/sampletones_application/layout/fonts.py | 12 +- src/sampletones_application/layout/loader.py | 74 +++++++++-- .../logic/history/fingerprint.py | 2 - .../logic/history/manager.py | 24 +++- .../logic/history/snapshot.py | 2 - .../logic/instruction/details.py | 5 +- .../logic/instruction/library.py | 51 ++++++-- .../logic/instruction/library_manager.py | 42 +++++-- .../logic/instruction/table.py | 5 +- .../logic/project/controller.py | 12 +- .../logic/project/title/document.py | 5 +- .../logic/reconstruction/browser_manager.py | 6 +- .../logic/reconstruction/data.py | 4 +- .../logic/reconstruction/feature.py | 5 +- .../logic/reconstruction/instruments.py | 11 +- .../logic/reconstruction/manager.py | 21 +++- .../logic/reconstruction/reconstruction.py | 62 ++++++++-- .../logic/sequencer/grid.py | 12 +- .../logic/sequencer/history_detail.py | 11 +- .../logic/sequencer/order.py | 5 +- .../logic/sequencer/playback/playhead.py | 6 +- .../logic/sequencer/playback/protocol.py | 8 +- .../logic/sequencer/playback/song_player.py | 16 ++- .../logic/sequencer/playback/synthesizer.py | 5 +- .../logic/sequencer/samples.py | 5 +- .../logic/shared/tree.py | 11 +- src/sampletones_application/services/base.py | 2 - .../services/conversion.py | 8 +- .../services/export/service.py | 29 ++++- .../services/export/truncation.py | 5 +- .../services/regeneration.py | 2 - .../services/result.py | 2 - .../services/retune/retune.py | 9 +- .../services/song_player/player.py | 9 +- src/sampletones_application/shell.py | 11 +- .../ui/elements/plus_minus_buttons.py | 60 +++++++-- .../ui/elements/status.py | 6 +- .../ui/elements/table/caret.py | 4 +- .../ui/elements/trace.py | 5 +- src/sampletones_application/ui/menu.py | 25 +++- .../ui/panels/dialogs/audio_settings.py | 12 +- .../ui/panels/dialogs/project_properties.py | 18 ++- .../ui/panels/instruction/choice.py | 69 ++++++++--- .../ui/panels/instruction/library.py | 54 ++++++-- .../ui/panels/instruction/parameters.py | 5 +- .../ui/panels/instruction/waveform.py | 5 +- .../ui/panels/main/advanced.py | 15 ++- .../ui/panels/main/converter.py | 15 ++- .../ui/panels/main/explorer.py | 75 +++++++++-- .../ui/panels/main/reconstructor.py | 15 ++- .../ui/panels/reconstruction/audio.py | 31 ++++- .../ui/panels/reconstruction/browser.py | 53 ++++++-- .../reconstruction/instruments/instruments.py | 77 +++++++++--- .../ui/panels/reconstruction/plot.py | 11 +- .../ui/panels/sequencer/browser.py | 16 ++- .../ui/panels/sequencer/channels.py | 1 - .../ui/panels/sequencer/grid.py | 32 ++++- .../ui/panels/sequencer/history.py | 50 ++++++-- .../ui/panels/sequencer/module.py | 57 +++++++-- .../ui/panels/sequencer/order.py | 50 ++++++-- .../ui/panels/sequencer/samples.py | 116 +++++++++++++++--- .../ui/themes/inline.py | 5 +- .../ui/themes/theme.py | 26 +++- .../utils/callbacks/task.py | 2 +- .../utils/file_dialogs/kdialog.py | 35 +++++- .../utils/file_dialogs/result.py | 11 +- .../utils/file_dialogs/tkinter_backend.py | 4 +- .../utils/file_dialogs/zenity.py | 29 ++++- .../utils/gui/dialogs.py | 14 ++- src/sampletones_application/utils/gui/dpg.py | 53 ++++++-- .../utils/gui/keyboard/focus/consumption.py | 5 +- .../utils/gui/keyboard/router.py | 14 ++- .../utils/gui/shortcuts/manager.py | 19 ++- src/sampletones_application/utils/palette.py | 6 +- .../utils/parallelization/thread.py | 4 +- src/sampletones_application/viewport.py | 10 +- .../project/instruments/sample.py | 4 +- .../structures/collection/bidirectional.py | 4 +- .../structures/collection/indexed.py | 2 +- .../structures/histogram/histogram.py | 3 +- src/sampletones_shared/utils/color.py | 4 +- tests/suite/dummy.py | 4 +- .../coordinators/tabs/test_instructions.py | 3 +- .../coordinators/test_config.py | 3 +- .../reconstruction/test_reconstruction.py | 6 +- .../services/song_player/test_song_player.py | 8 +- .../sampletones_core/generators/test_utils.py | 8 +- .../collection/test_bidirectional.py | 4 +- .../structures/tree/test_traversal.py | 4 +- tests/unit/scripts/test_detect_cuda.py | 8 +- 102 files changed, 1584 insertions(+), 381 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 0b90835c..0a54caf3 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -825,7 +825,10 @@ def _regenerate_instrument( feature_value, ) - def _on_reconstruction_updated(self, outcome: RegeneratedInstrument) -> None: + def _on_reconstruction_updated( + self, + outcome: RegeneratedInstrument, + ) -> None: """Records a reconstruction edit against the project when it owns the sample. Regeneration produces a fresh reconstruction. When the edited document is a @@ -924,10 +927,15 @@ def _apply_retuned_sample(self, retuned: RetunedSample) -> None: detail=self._sequencer_tab.nes_frequency_detail(nes_frequency), coalesce=(nes_frequency,), ): - self.project_controller.replace_sample_reconstruction(retuned.sample_id, retuned.reconstruction) + self.project_controller.replace_sample_reconstruction( + retuned.sample_id, + retuned.reconstruction, + ) if is_open: - self.reconstruction_manager.apply_regenerated(retuned.reconstruction) + self.reconstruction_manager.apply_regenerated( + retuned.reconstruction, + ) self._reconstructions_tab.update_reconstruction() def _open_project_properties(self) -> None: @@ -950,7 +958,12 @@ def _open_project_properties(self) -> None: ) ) - def _commit_project_properties(self, title: str, author: str, comment: str) -> None: + def _commit_project_properties( + self, + title: str, + author: str, + comment: str, + ) -> None: """Applies the properties dialog's values as one undoable gesture. Only fields that differ from the current project info reach the controller, @@ -996,7 +1009,11 @@ def content(parent: str) -> None: name_text = dpg.add_text(SAMPLETONES_NAME_VERSION, parent=parent) dpg.add_separator(parent=parent) FontRegistry.bind_to_item(name_text, Font.BOLD_LARGE) - dpg.add_text(description, parent=parent, wrap=self.dialogs.default_wrap) + dpg.add_text( + description, + parent=parent, + wrap=self.dialogs.default_wrap, + ) author_text = dpg.add_text(author_line, parent=parent) FontRegistry.bind_to_item(author_text, Font.ITALIC) @@ -1105,7 +1122,12 @@ def _update_title(self) -> None: TextType.TITLE, GlobalDialogTitleElements.MAIN_WINDOW, ] - self._viewport_manager.update_title(window_title(application_name, document)) + self._viewport_manager.update_title( + window_title( + application_name, + document, + ) + ) def _sync_reconstruction_ownership(self) -> None: """Reflects sequencer ownership in the open reconstruction view. diff --git a/src/sampletones_application/categories/export.py b/src/sampletones_application/categories/export.py index 78334b11..2f1d7e58 100644 --- a/src/sampletones_application/categories/export.py +++ b/src/sampletones_application/categories/export.py @@ -113,4 +113,9 @@ def _instruments_text( text_type: TextType, element: ReconstructionsInstrumentsElements, ) -> str: - return language_manager[Page.RECONSTRUCTIONS, Panel.INSTRUMENTS, text_type, element] + return language_manager[ + Page.RECONSTRUCTIONS, + Panel.INSTRUMENTS, + text_type, + element, + ] diff --git a/src/sampletones_application/config/deployment/logs.py b/src/sampletones_application/config/deployment/logs.py index 8a29f4ed..2e120895 100644 --- a/src/sampletones_application/config/deployment/logs.py +++ b/src/sampletones_application/config/deployment/logs.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import logging from enum import StrEnum from typing import Dict, Final diff --git a/src/sampletones_application/config/managers/config.py b/src/sampletones_application/config/managers/config.py index 5992762e..bd2fadd7 100644 --- a/src/sampletones_application/config/managers/config.py +++ b/src/sampletones_application/config/managers/config.py @@ -55,22 +55,30 @@ def initialize(self, config_path: Optional[Path] = None) -> None: except FileNotFoundError as exception: self.load_default_config() logger.error(f"Config file not found: {config_path}") - self.pending_load_outcomes.append(ConfigLoadFailure(exception, ConfigLoadFailureReason.LOAD_ERROR)) + self.pending_load_outcomes.append( + ConfigLoadFailure(exception, ConfigLoadFailureReason.LOAD_ERROR), + ) except OSError as exception: self.load_default_config() logger.error_with_traceback( exception, f"File error while loading config from {config_path}", ) - self.pending_load_outcomes.append(ConfigLoadFailure(exception, ConfigLoadFailureReason.LOAD_ERROR)) + self.pending_load_outcomes.append( + ConfigLoadFailure(exception, ConfigLoadFailureReason.LOAD_ERROR), + ) except (json.JSONDecodeError, UnicodeDecodeError, TypeError) as exception: self.load_default_config() logger.error_with_traceback(exception, f"Unreadable config file: {config_path}") - self.pending_load_outcomes.append(ConfigLoadFailure(exception, ConfigLoadFailureReason.PARSE_ERROR)) + self.pending_load_outcomes.append( + ConfigLoadFailure(exception, ConfigLoadFailureReason.PARSE_ERROR), + ) except ValidationError as exception: self.load_default_config() logger.error_with_traceback(exception, f"Invalid config file: {config_path}") - self.pending_load_outcomes.append(ConfigLoadFailure(exception, ConfigLoadFailureReason.INVALID)) + self.pending_load_outcomes.append( + ConfigLoadFailure(exception, ConfigLoadFailureReason.INVALID), + ) def save_config(self) -> None: if not self.config: @@ -102,7 +110,10 @@ def apply_library_settings(self, update: LibrarySettingsUpdate) -> None: self.window = Window.from_config(self.config) self.update_gui() - def apply_generation_settings(self, update: GenerationSettingsUpdate) -> None: + def apply_generation_settings( + self, + update: GenerationSettingsUpdate, + ) -> None: new_generation = self.config.generation.model_copy( update={ "drive": update.drive, @@ -131,7 +142,9 @@ def apply_advanced_settings(self, update: AdvancedSettingsUpdate) -> None: "transformation_gamma": update.transformation_gamma, } ) - self.config = self.config.model_copy(update={"general": new_general, "library": new_library}) + self.config = self.config.model_copy( + update={"general": new_general, "library": new_library}, + ) self.window = Window.from_config(self.config) self.library_directory = update.library_directory self.reconstructions_directory = update.reconstructions_directory @@ -171,7 +184,9 @@ def apply_library_config(self, library_key: InstructionLibraryKey) -> None: } ) - self.config = self.config.model_copy(update={"library": new_library_config}) + self.config = self.config.model_copy( + update={"library": new_library_config}, + ) self.window = Window.from_config(self.config) self.update_gui() diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index 51091048..0ce098a3 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -118,7 +118,10 @@ def set_current_project(self, path: Optional[Path]) -> None: def current_project(self) -> Optional[Path]: return self._state_manager.current_project - def set_current_audio_device(self, audio_device_manager: AudioDeviceManager) -> None: + def set_current_audio_device( + self, + audio_device_manager: AudioDeviceManager, + ) -> None: self._config_manager.set_current_audio_device(audio_device_manager) def set_master_gain(self, value: float) -> None: diff --git a/src/sampletones_application/config/managers/state.py b/src/sampletones_application/config/managers/state.py index a3394c97..8bcab291 100644 --- a/src/sampletones_application/config/managers/state.py +++ b/src/sampletones_application/config/managers/state.py @@ -38,7 +38,10 @@ def _load(self) -> ApplicationState: def save(self) -> None: try: APPLICATION_STATE_PATH.parent.mkdir(parents=True, exist_ok=True) - save_yaml_atomic(APPLICATION_STATE_PATH, self.state.model_dump(mode="json")) + save_yaml_atomic( + APPLICATION_STATE_PATH, + self.state.model_dump(mode="json"), + ) except OSError as exception: logger.error_with_traceback( exception, diff --git a/src/sampletones_application/coordinators/config.py b/src/sampletones_application/coordinators/config.py index 8ff18f67..d6db15bd 100644 --- a/src/sampletones_application/coordinators/config.py +++ b/src/sampletones_application/coordinators/config.py @@ -140,7 +140,10 @@ def _handle_load(self, filepath: Path) -> None: self._show_status_dialog(self._message(GlobalMessageElements.CONFIGURATION_LOADED_SUCCESSFULLY)) except _LOAD_EXCEPTIONS as exception: logger.error_with_traceback(exception, f"Failed to load config from {filepath}") - self._dialogs.show_error(exception, self._message(GlobalMessageElements.CONFIGURATION_LOAD_ERROR)) + self._dialogs.show_error( + exception, + self._message(GlobalMessageElements.CONFIGURATION_LOAD_ERROR), + ) self._session_manager.set_config_path(filepath) @@ -155,7 +158,13 @@ def present_pending_load_outcomes(self) -> None: for outcome in self._config_manager.pending_load_outcomes: match outcome: case ConfigRecovered(source_version=source_version, dropped=dropped): - properties = tuple(flatten_location(location) for location in sorted(dropped, key=flatten_location)) + properties = tuple( + flatten_location(location) + for location in sorted( + dropped, + key=flatten_location, + ) + ) self._dialogs.show_config_recovery( tag=TAG_GLOBAL_DIALOG_CONFIG_RECOVERY, source_version=source_version, @@ -164,12 +173,20 @@ def present_pending_load_outcomes(self) -> None: config_path=self._config_manager.config_path, ) case ConfigLoadFailure(exception=exception, reason=reason): - self._dialogs.show_error(exception, self._message(_LOAD_FAILURE_MESSAGES[reason])) + self._dialogs.show_error( + exception, + self._message(_LOAD_FAILURE_MESSAGES[reason]), + ) self._config_manager.pending_load_outcomes.clear() def _message(self, element: GlobalMessageElements) -> str: - return self._language_manager[Page.GLOBAL, Panel.DIALOG, TextType.MESSAGE, element] + return self._language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.MESSAGE, + element, + ] def _show_status_dialog(self, message: str) -> None: def content(parent: str) -> None: diff --git a/src/sampletones_application/coordinators/original_audio.py b/src/sampletones_application/coordinators/original_audio.py index 23c24d13..77a9087d 100644 --- a/src/sampletones_application/coordinators/original_audio.py +++ b/src/sampletones_application/coordinators/original_audio.py @@ -54,7 +54,10 @@ def locate(self, filepath: Path) -> None: return if not audio_filepath.exists(): - self._dialogs.show_file_not_found(audio_filepath, self._msg_locate_failed) + self._dialogs.show_file_not_found( + audio_filepath, + self._msg_locate_failed, + ) return open_path_in_explorer(audio_filepath) diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 97800125..e89d59ce 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -225,7 +225,10 @@ def _load(self, filepath: Path) -> None: try: self._project_controller.load(filepath) except (LoadProjectError, OSError) as exception: - logger.error_with_traceback(exception, f"Failed to load project from {filepath}") + logger.error_with_traceback( + exception, + f"Failed to load project from {filepath}", + ) self._dialogs.show_error(exception) return @@ -236,7 +239,10 @@ def _save(self, filepath: Path) -> bool: try: self._project_controller.save(filepath) except (SerializationError, OSError) as exception: - logger.error_with_traceback(exception, f"Failed to save project to {filepath}") + logger.error_with_traceback( + exception, + f"Failed to save project to {filepath}", + ) self._dialogs.show_error( exception, self._message(GlobalMessageElements.PROJECT_SAVE_FAILED), @@ -255,7 +261,10 @@ def _export_module(self, filepath: Path) -> None: try: self._project_controller.export_module(filepath) except (ValueError, OSError) as exception: - logger.error_with_traceback(exception, f"Failed to export FamiTracker module to {filepath}") + logger.error_with_traceback( + exception, + f"Failed to export FamiTracker module to {filepath}", + ) self._dialogs.show_error( exception, self._message(GlobalMessageElements.PROJECT_EXPORT_FAILED), @@ -299,13 +308,33 @@ def _guard_open( ) def _title(self, element: AbstractElement) -> str: - return self._language_manager[Page.GLOBAL, Panel.DIALOG, TextType.TITLE, element] + return self._language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.TITLE, + element, + ] def _message(self, element: AbstractElement) -> str: - return self._language_manager[Page.GLOBAL, Panel.DIALOG, TextType.MESSAGE, element] + return self._language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.MESSAGE, + element, + ] def _label(self, element: AbstractElement) -> str: - return self._language_manager[Page.GLOBAL, Panel.DIALOG, TextType.LABEL, element] + return self._language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.LABEL, + element, + ] def _filter_name(self, element: AbstractElement) -> str: - return self._language_manager[Page.GLOBAL, Panel.DIALOG, TextType.FILTER, element] + return self._language_manager[ + Page.GLOBAL, + Panel.DIALOG, + TextType.FILTER, + element, + ] diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index c4295f56..cc27d87b 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -171,7 +171,10 @@ def _handle_save_as(self, filepath: Path) -> None: try: self._reconstruction_manager.save_reconstruction_as(filepath) except (OSError, SampleToNESError) as exception: - logger.error_with_traceback(exception, f"Failed to save reconstruction to {filepath}") + logger.error_with_traceback( + exception, + f"Failed to save reconstruction to {filepath}", + ) self._dialogs.show_error( exception, self._language_manager[ diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index eba8fb64..a0d2f872 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -377,16 +377,32 @@ def _on_export_result(self, result: ExportResult) -> None: match result: case ExportSuccess(kind=ExportKind.WAV, filepath=fp): self._dialogs.show_message_with_path(messages.wav_title, messages.wav_success, fp) - case ExportSuccess(kind=ExportKind.INSTRUMENT, filepath=fp, truncation=truncation): + case ExportSuccess( + kind=ExportKind.INSTRUMENT, + filepath=fp, + truncation=truncation, + ): self._dialogs.show_message_with_path( messages.status_title, - self._export_message(messages.instrument_success, messages.instrument_truncated, truncation), + self._export_message( + messages.instrument_success, + messages.instrument_truncated, + truncation, + ), fp, ) - case ExportSuccess(kind=ExportKind.INSTRUMENTS, filepath=fp, truncation=truncation): + case ExportSuccess( + kind=ExportKind.INSTRUMENTS, + filepath=fp, + truncation=truncation, + ): self._dialogs.show_message_with_path( messages.status_title, - self._export_message(messages.instruments_success, messages.instruments_truncated, truncation), + self._export_message( + messages.instruments_success, + messages.instruments_truncated, + truncation, + ), fp, ) case ExportError(kind=ExportKind.WAV, exception=exception): @@ -422,12 +438,19 @@ def _export_message( ) return f"{success}\n\n{note}" - def _update_reconstruction_view(self, view_model: ReconstructionViewModel) -> None: + def _update_reconstruction_view( + self, + view_model: ReconstructionViewModel, + ) -> None: """Fans the reconstruction view model out to the audio and plot cards.""" self._reconstruction_audio_panel.update_view(view_model) self._reconstruction_plot_panel.update_view(view_model) - def _open_export_instrument_dialog(self, default_filename: str, default_path: str) -> None: + def _open_export_instrument_dialog( + self, + default_filename: str, + default_path: str, + ) -> None: filepath = save_file_dialog( title=self._ttl_export_instrument, initial_directory=default_path, @@ -510,16 +533,28 @@ def _build_reconstruction_column(self, parent: str) -> None: dpg.add_spacer(height=self._geometry.panel_gap, parent=parent) self._reconstruction_plot_panel.create_panel(parent) - def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None: + def _on_card_collapse_changed( + self, + card_tag: str, + collapsed: bool, + ) -> None: """Persists a card's collapsed state so it restores on the next launch.""" self._session_manager.set_card_collapsed(card_tag, collapsed) - def _on_browser_collapse_changed(self, card_tag: str, collapsed: bool) -> None: + def _on_browser_collapse_changed( + self, + card_tag: str, + collapsed: bool, + ) -> None: """Persists the browser panel's collapse, then docks or restores the width of the column it fills.""" self._session_manager.set_card_collapsed(card_tag, collapsed) self._sync_browser_width() - def _on_instruments_collapse_changed(self, card_tag: str, collapsed: bool) -> None: + def _on_instruments_collapse_changed( + self, + card_tag: str, + collapsed: bool, + ) -> None: """Persists the instruments panel's collapse, then docks or restores the width of the column it fills.""" self._session_manager.set_card_collapsed(card_tag, collapsed) self._sync_instruments_width() @@ -619,7 +654,10 @@ def _remove_directory(self, directory: Path) -> None: try: self._browser_logic.remove_path(directory) except OSError as exception: - logger.error_with_traceback(exception, f"Failed to remove directory: {directory}") + logger.error_with_traceback( + exception, + f"Failed to remove directory: {directory}", + ) self._dialogs.show_error(exception, self._msg_load_error) return @@ -670,7 +708,10 @@ def load_reconstruction(self, filepath: Path) -> None: self._reconstruction_manager.load_reconstruction(filepath) logger.info(f"Loaded reconstruction: {logger.format_path(filepath)}") except FileNotFoundError as exception: - logger.error_with_traceback(exception, f"Failed to load reconstruction data from {filepath}") + logger.error_with_traceback( + exception, + f"Failed to load reconstruction data from {filepath}", + ) self._dialogs.show_file_not_found(filepath, self._msg_file_not_found) except ( IOError, @@ -690,10 +731,16 @@ def load_reconstruction(self, filepath: Path) -> None: ) self._dialogs.show_error(exception, self._msg_invalid_metadata) except InvalidReconstructionValuesError as exception: - logger.error_with_traceback(exception, f"Reconstruction contains invalid values: {filepath}") + logger.error_with_traceback( + exception, + f"Reconstruction contains invalid values: {filepath}", + ) self._dialogs.show_error(exception, self._msg_invalid_values) except InvalidReconstructionError as exception: - logger.error_with_traceback(exception, f"Invalid reconstruction file: {filepath}") + logger.error_with_traceback( + exception, + f"Invalid reconstruction file: {filepath}", + ) self._dialogs.show_error(exception, self._msg_invalid_file) except IncompatibleReconstructionVersionError as exception: logger.error_with_traceback( diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 7227f675..ecda569a 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -583,7 +583,10 @@ def _stacked_card_gap(self) -> int: spacing is read from the base theme, which sets it explicitly, so the gap tracks the theme's value. """ - spacing = ThemeRegistry.get(TAG_GLOBAL_THEME_DEFAULT).get_style(dpg.mvAll, dpg.mvStyleVar_ItemSpacing) + spacing = ThemeRegistry.get(TAG_GLOBAL_THEME_DEFAULT).get_style( + dpg.mvAll, + dpg.mvStyleVar_ItemSpacing, + ) spacing_y = int(spacing[1]) if spacing is not None else 0 return self._geometry.panel_gap + 2 * spacing_y @@ -626,7 +629,10 @@ def _undoable( single entry. """ - def wrapped(*args: _UndoableParams.args, **kwargs: _UndoableParams.kwargs) -> None: + def wrapped( + *args: _UndoableParams.args, + **kwargs: _UndoableParams.kwargs, + ) -> None: description = detail(*args, **kwargs) if detail is not None else () key = coalesce(*args, **kwargs) if coalesce is not None else None with self._history.transaction(action, detail=description, coalesce=key): @@ -634,7 +640,11 @@ def wrapped(*args: _UndoableParams.args, **kwargs: _UndoableParams.kwargs) -> No return wrapped - def _cell_key(self, row_index: int, generator: Optional[GeneratorName]) -> CoalesceKey: + def _cell_key( + self, + row_index: int, + generator: Optional[GeneratorName], + ) -> CoalesceKey: """Identifies one cell of the displayed frame as a coalescing target. The sample column (``generator`` absent) is its own target, distinct from @@ -719,7 +729,11 @@ def reconstruction_edit_detail( feature_key: FeatureKey, ) -> HistoryDetail: """Describes a reconstruction edit for the project history's detail line.""" - return self._history_detail.edit_reconstruction(sample_id, generator_name, feature_key) + return self._history_detail.edit_reconstruction( + sample_id, + generator_name, + feature_key, + ) def _build_history_view_model(self) -> HistoryViewModel: cursor = self._history.cursor @@ -797,7 +811,11 @@ def _on_player_view_changed(self, view_model: SongPlayerViewModel) -> None: self._sequencer_grid_panel.set_playing_row(None) self._sequencer_order_panel.set_playing_position(None) - def _on_player_position_changed(self, order_position: int, row_index: int) -> None: + def _on_player_position_changed( + self, + order_position: int, + row_index: int, + ) -> None: self._playing_order = order_position self._sequencer_grid_panel.set_playing_row(row_index) self._sequencer_order_panel.set_playing_position(order_position) @@ -833,7 +851,10 @@ def import_reconstruction(self, filepath: Path) -> None: try: reconstruction = self._sequencer_browser_logic.load_reconstruction(filepath) except (SampleToNESError, OSError) as exception: - logger.error_with_traceback(exception, f"Failed to load reconstruction from {filepath}") + logger.error_with_traceback( + exception, + f"Failed to load reconstruction from {filepath}", + ) self._dialogs.show_error(exception) return @@ -853,7 +874,10 @@ def import_reconstruction_object(self, reconstruction: Reconstruction, name: str ) return - self._add_reconstruction_with_frequency_check(reconstruction.model_copy(deep=True), name) + self._add_reconstruction_with_frequency_check( + reconstruction.model_copy(deep=True), + name, + ) def _add_reconstruction_with_frequency_check( self, @@ -996,6 +1020,7 @@ def _commit_replace_reconstruction( ): if adopt_frequency is not None: self._sequencer_grid_logic.set_nes_frequency(adopt_frequency) + self._sequencer_samples_logic.rename_sample(sample_id, name) self._on_sample_reconstruction_replaced(sample_id, reconstruction) self._sequencer_browser_logic.replace_reconstruction(sample_id, reconstruction) @@ -1172,15 +1197,24 @@ def _remove_sample(self, sample_id: str) -> None: def _perform_remove_sample(self, sample_id: str) -> None: detail = self._history_detail.remove_sample(sample_id) - with self._history.transaction(HistoryAction.REMOVE_SAMPLE, detail=detail): + with self._history.transaction( + HistoryAction.REMOVE_SAMPLE, + detail=detail, + ): self._sequencer_samples_logic.remove_sample(sample_id) def _submit_rename(self, sample_id: str, name: str) -> None: """Applies an inline rename, ignoring a blank name so the sample keeps its current one.""" stripped = name.strip() if stripped: - detail = self._history_detail.rename_sample(self._sequencer_samples_logic.sample_name(sample_id), stripped) - with self._history.transaction(HistoryAction.RENAME_SAMPLE, detail=detail): + detail = self._history_detail.rename_sample( + self._sequencer_samples_logic.sample_name(sample_id), + stripped, + ) + with self._history.transaction( + HistoryAction.RENAME_SAMPLE, + detail=detail, + ): self._sequencer_samples_logic.rename_sample(sample_id, stripped) def _request_nes_frequency_change(self, nes_frequency: int) -> None: diff --git a/src/sampletones_application/layout/fonts.py b/src/sampletones_application/layout/fonts.py index 88b27d7a..14c90435 100644 --- a/src/sampletones_application/layout/fonts.py +++ b/src/sampletones_application/layout/fonts.py @@ -21,7 +21,11 @@ class FontScale(BaseModel, extra="forbid", frozen=True): large: int def step(self, step: Step) -> int: - return {Step.SMALL: self.small, Step.MEDIUM: self.medium, Step.LARGE: self.large}[step] + return { + Step.SMALL: self.small, + Step.MEDIUM: self.medium, + Step.LARGE: self.large, + }[step] class FontsLayout(BaseModel, extra="forbid", frozen=True): @@ -38,5 +42,9 @@ class FontsLayout(BaseModel, extra="forbid", frozen=True): icon: FontScale def size_for(self, typeface: Typeface, step: Step) -> int: - scale = {Typeface.SANS: self.sans, Typeface.MONO: self.mono, Typeface.ICON: self.icon}[typeface] + scale = { + Typeface.SANS: self.sans, + Typeface.MONO: self.mono, + Typeface.ICON: self.icon, + }[typeface] return scale.step(step) diff --git a/src/sampletones_application/layout/loader.py b/src/sampletones_application/layout/loader.py index bad7b120..4779ae9b 100644 --- a/src/sampletones_application/layout/loader.py +++ b/src/sampletones_application/layout/loader.py @@ -18,26 +18,74 @@ from sampletones_shared.utils.serialization import load_yaml_model, load_yaml_model_dir -def load_layout_config(layout_directory: Path, behavior_directory: Path, palette: Palette) -> LayoutConfig: +def load_layout_config( + layout_directory: Path, + behavior_directory: Path, + palette: Palette, +) -> LayoutConfig: context = {PALETTE_CONTEXT_KEY: palette} tabs_directory = layout_directory / "tabs" return LayoutConfig( - general=load_yaml_model_dir(layout_directory / "general", GeneralLayout, context=context), - fonts=load_yaml_model(layout_directory / "fonts.yaml", FontsLayout, context=context), - glyphs=load_yaml_model(layout_directory / "glyphs.yaml", Glyphs, context=context), - graphs=load_yaml_model_dir(layout_directory / "graphs", GraphsLayout, context=context), + general=load_yaml_model_dir( + layout_directory / "general", + GeneralLayout, + context=context, + ), + fonts=load_yaml_model( + layout_directory / "fonts.yaml", + FontsLayout, + context=context, + ), + glyphs=load_yaml_model( + layout_directory / "glyphs.yaml", + Glyphs, + context=context, + ), + graphs=load_yaml_model_dir( + layout_directory / "graphs", + GraphsLayout, + context=context, + ), tabs=TabsLayout( - main=load_yaml_model_dir(tabs_directory / "main", MainLayout, context=context), - instructions=load_yaml_model_dir(tabs_directory / "instructions", InstructionsLayout, context=context), + main=load_yaml_model_dir( + tabs_directory / "main", + MainLayout, + context=context, + ), + instructions=load_yaml_model_dir( + tabs_directory / "instructions", + InstructionsLayout, + context=context, + ), reconstruction=load_yaml_model_dir( - tabs_directory / "reconstruction", ReconstructionLayout, context=context + tabs_directory / "reconstruction", + ReconstructionLayout, + context=context, + ), + sequencer=load_yaml_model_dir( + tabs_directory / "sequencer", + SequencerLayout, + context=context, ), - sequencer=load_yaml_model_dir(tabs_directory / "sequencer", SequencerLayout, context=context), ), - player=load_yaml_model_dir(layout_directory / "player", PlayerLayout, context=context), + player=load_yaml_model_dir( + layout_directory / "player", + PlayerLayout, + context=context, + ), project_properties=load_yaml_model_dir( - layout_directory / "project_properties", ProjectPropertiesLayout, context=context + layout_directory / "project_properties", + ProjectPropertiesLayout, + context=context, + ), + settings=load_yaml_model_dir( + layout_directory / "settings", + SettingsLayout, + context=context, + ), + behavior=load_yaml_model( + behavior_directory / "general.yaml", + BehaviorConfig, + context=context, ), - settings=load_yaml_model_dir(layout_directory / "settings", SettingsLayout, context=context), - behavior=load_yaml_model(behavior_directory / "general.yaml", BehaviorConfig, context=context), ) diff --git a/src/sampletones_application/logic/history/fingerprint.py b/src/sampletones_application/logic/history/fingerprint.py index b085e04a..cea0eba0 100644 --- a/src/sampletones_application/logic/history/fingerprint.py +++ b/src/sampletones_application/logic/history/fingerprint.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import hashlib from typing import Callable, Dict, Iterable, List, Tuple diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py index 0cc5012e..236bff43 100644 --- a/src/sampletones_application/logic/history/manager.py +++ b/src/sampletones_application/logic/history/manager.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from contextlib import contextmanager from datetime import datetime from typing import Iterator, List, Optional, Tuple @@ -190,7 +188,11 @@ def _begin( coalesce: Optional[CoalesceKey], ) -> None: if self._pending is None: - self._pending = PendingTransaction(action=action, detail=detail, coalesce=coalesce) + self._pending = PendingTransaction( + action=action, + detail=detail, + coalesce=coalesce, + ) return self._pending.depth += 1 @@ -206,7 +208,11 @@ def _end(self) -> None: pending = self._pending self._pending = None if pending.mutations > 0: - self._commit(pending.action, pending.detail, coalesce=pending.coalesce) + self._commit( + pending.action, + pending.detail, + coalesce=pending.coalesce, + ) def _commit( self, @@ -267,7 +273,10 @@ def _capture( """ project = self._controller.project fingerprint = ( - fingerprint_project(project, reconstruction_hash=self._hash_cache.hash) + fingerprint_project( + project, + reconstruction_hash=self._hash_cache.hash, + ) if self._hash_cache is not None else None ) @@ -314,7 +323,10 @@ def _verify(self, entry: HistoryEntry) -> None: if entry.fingerprint is None: return - actual = fingerprint_project(self._controller.project, reconstruction_hash=hash_model) + actual = fingerprint_project( + self._controller.project, + reconstruction_hash=hash_model, + ) if actual != entry.fingerprint: raise HistoryIntegrityError( f"Restoring history entry '{entry.action}' produced a project that " diff --git a/src/sampletones_application/logic/history/snapshot.py b/src/sampletones_application/logic/history/snapshot.py index f415fccd..d3ed3c4a 100644 --- a/src/sampletones_application/logic/history/snapshot.py +++ b/src/sampletones_application/logic/history/snapshot.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import copy from dataclasses import dataclass, field from datetime import datetime diff --git a/src/sampletones_application/logic/instruction/details.py b/src/sampletones_application/logic/instruction/details.py index 200c80f5..d05e8be6 100644 --- a/src/sampletones_application/logic/instruction/details.py +++ b/src/sampletones_application/logic/instruction/details.py @@ -39,7 +39,10 @@ def clear_display(self) -> None: def get_current_instruction_data(self) -> Optional[InstructionPanelData]: return self._table_logic.current_data - def handle_instruction_parameter_changed(self, instruction: InstructionUnion) -> None: + def handle_instruction_parameter_changed( + self, + instruction: InstructionUnion, + ) -> None: current = self._table_logic.current_data if current is not None and current.instruction == instruction: return diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py index 7e5040cd..c030b03d 100644 --- a/src/sampletones_application/logic/instruction/library.py +++ b/src/sampletones_application/logic/instruction/library.py @@ -300,7 +300,10 @@ def load_library_file(self, filepath: Path) -> None: try: library_key = create_key_from_filename(filepath.name) except ValueError as exception: - logger.error_with_traceback(exception, f"Invalid library file name format: {filepath.name}") + logger.error_with_traceback( + exception, + f"Invalid library file name format: {filepath.name}", + ) self.call(self.on_load_error, exception, self._msg_load_error) return @@ -363,7 +366,11 @@ def _sync_with_config_key(self, load_if_needed: bool = True) -> None: config_key = self._config_manager.key matching_key = self._library_manager.sync_with_config_key(config_key) if matching_key: - self._set_current_library(matching_key, load_if_needed=load_if_needed, apply_config=False) + self._set_current_library( + matching_key, + load_if_needed=load_if_needed, + apply_config=False, + ) def _set_current_library( self, @@ -380,7 +387,11 @@ def _set_current_library( self.update_status() def load_library_and_set_current(self, library_key: InstructionLibraryKey) -> None: - self._set_current_library(library_key, load_if_needed=True, apply_config=True) + self._set_current_library( + library_key, + load_if_needed=True, + apply_config=True, + ) def _load_library(self, library_key: InstructionLibraryKey) -> None: if self._is_locked: @@ -391,7 +402,10 @@ def _load_library(self, library_key: InstructionLibraryKey) -> None: self._library_manager.load_library(library_key) logger.info(f"Library loaded: {library_key}") except FileNotFoundError as exception: - logger.error_with_traceback(exception, f"Library file not found for key {library_key}") + logger.error_with_traceback( + exception, + f"Library file not found for key {library_key}", + ) self.call( self.on_load_file_not_found, self._library_manager.get_path(library_key), @@ -403,14 +417,21 @@ def _load_library(self, library_key: InstructionLibraryKey) -> None: PermissionError, OSError, ) as exception: - logger.error_with_traceback(exception, f"Error loading library file for key {library_key}") + logger.error_with_traceback( + exception, + f"Error loading library file for key {library_key}", + ) self.call(self.on_load_error, exception, self._msg_file_load_error) except InvalidMetadataError as exception: logger.error_with_traceback( exception, f"Invalid metadata in library file for key {library_key}", ) - self.call(self.on_load_error, exception, self._msg_invalid_metadata_error) + self.call( + self.on_load_error, + exception, + self._msg_invalid_metadata_error, + ) except InvalidLibraryDataValuesError as exception: logger.error_with_traceback( exception, @@ -422,7 +443,10 @@ def _load_library(self, library_key: InstructionLibraryKey) -> None: self._msg_invalid_data_values_error, ) except InvalidLibraryDataError as exception: - logger.error_with_traceback(exception, f"Invalid library data file for {library_key}") + logger.error_with_traceback( + exception, + f"Invalid library data file for {library_key}", + ) self.call(self.on_load_error, exception, self._msg_invalid_data_error) except IncompatibleLibraryDataVersionError as exception: logger.error_with_traceback( @@ -443,7 +467,11 @@ def _load_library(self, library_key: InstructionLibraryKey) -> None: exception, f"Deserialization error loading library for key {library_key}", ) - self.call(self.on_load_error, exception, self._msg_deserialization_error) + self.call( + self.on_load_error, + exception, + self._msg_deserialization_error, + ) except LoadLibraryError as exception: logger.error_with_traceback(exception, f"Error loading library for key {library_key}") self.call(self.on_load_error, exception, self._msg_load_error) @@ -518,7 +546,12 @@ def _finalize_generation_error(self) -> None: self._do_unlock() self.call(self.on_generation_state_changed) - def _emit_view(self, status_text: Optional[str] = None, *, progress: float = 0.0) -> None: + def _emit_view( + self, + status_text: Optional[str] = None, + *, + progress: float = 0.0, + ) -> None: """Builds and emits the panel view model from freshly computed values. ``status_text`` of ``None`` renders the idle status derived from the manager state; diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index 654c5fdd..9633d20a 100644 --- a/src/sampletones_application/logic/instruction/library_manager.py +++ b/src/sampletones_application/logic/instruction/library_manager.py @@ -93,7 +93,10 @@ def gather_available_libraries(self) -> Dict[InstructionLibraryKey, str]: self._library_files = new_library_files return self._library_files - def get_library_key(self, library_key: Optional[InstructionLibraryKey] = None) -> Optional[InstructionLibraryKey]: + def get_library_key( + self, + library_key: Optional[InstructionLibraryKey] = None, + ) -> Optional[InstructionLibraryKey]: if library_key is None: if self._current_library_key is None: return None @@ -102,14 +105,20 @@ def get_library_key(self, library_key: Optional[InstructionLibraryKey] = None) - return library_key - def is_library_loaded(self, library_key: Optional[InstructionLibraryKey] = None) -> bool: + def is_library_loaded( + self, + library_key: Optional[InstructionLibraryKey] = None, + ) -> bool: library_key = self.get_library_key(library_key) if not self.does_library_exist(library_key): return False return library_key in self._library.data - def does_library_exist(self, library_key: Optional[InstructionLibraryKey] = None) -> bool: + def does_library_exist( + self, + library_key: Optional[InstructionLibraryKey] = None, + ) -> bool: library_key = self.get_library_key(library_key) if library_key is None: return False @@ -137,7 +146,10 @@ def load_library_file(self, path: Path) -> InstructionLibraryKey: logger.info(f"Library data: {logger.format_path(path)} loaded successfully") return library_key - def load_instruction(self, instruction: InstructionUnion) -> Optional[InstructionPanelData]: + def load_instruction( + self, + instruction: InstructionUnion, + ) -> Optional[InstructionPanelData]: if not self._current_library_key or not self.is_library_loaded(self._current_library_key): return None @@ -156,7 +168,10 @@ def load_instruction(self, instruction: InstructionUnion) -> Optional[Instructio def get_path(self, library_key: InstructionLibraryKey) -> Path: return self._library.get_path(library_key) - def sync_with_config_key(self, config_key: InstructionLibraryKey) -> Optional[InstructionLibraryKey]: + def sync_with_config_key( + self, + config_key: InstructionLibraryKey, + ) -> Optional[InstructionLibraryKey]: if self.library_exists_for_key(config_key): self._current_library_key = config_key return config_key @@ -191,7 +206,10 @@ def _on_progress(status: TaskStatus, progress: TaskProgress) -> None: self._creator.start() - def _complete_generation(self, result: Tuple[InstructionLibraryKey, InstructionLibraryData]) -> None: + def _complete_generation( + self, + result: Tuple[InstructionLibraryKey, InstructionLibraryData], + ) -> None: key, library_data = result try: self._library.save_data(key, library_data) @@ -271,9 +289,17 @@ def rebuild_tree(self) -> None: self._tree.set_root(root) - def _build_library_node(self, library_key: InstructionLibraryKey, parent: TreeNode) -> LibraryNode: + def _build_library_node( + self, + library_key: InstructionLibraryKey, + parent: TreeNode, + ) -> LibraryNode: display_name = get_display_name_from_key(library_key) - library_node = LibraryNode(display_name, library_key=library_key, parent=parent) + library_node = LibraryNode( + display_name, + library_key=library_key, + parent=parent, + ) self._build_generator_nodes(library_node) return library_node diff --git a/src/sampletones_application/logic/instruction/table.py b/src/sampletones_application/logic/instruction/table.py index 60d349c5..18976055 100644 --- a/src/sampletones_application/logic/instruction/table.py +++ b/src/sampletones_application/logic/instruction/table.py @@ -219,7 +219,10 @@ def current_data(self) -> Optional[InstructionPanelData]: return self._current_data @current_data.setter - def current_data(self, instruction_data: Optional[InstructionPanelData]) -> None: + def current_data( + self, + instruction_data: Optional[InstructionPanelData], + ) -> None: if instruction_data is None: self._current_data = None self._current_hash = "" diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index f4eba21a..5c52be87 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -218,13 +218,21 @@ def add_pattern(self, generator: GeneratorName) -> int: self.call(self.on_song_changed) return index - def duplicate_pattern(self, generator: GeneratorName, pattern_index: int) -> int: + def duplicate_pattern( + self, + generator: GeneratorName, + pattern_index: int, + ) -> int: clone_index = self.song.duplicate_pattern(generator, pattern_index) self._touch() self.call(self.on_song_changed) return clone_index - def remove_pattern(self, generator: GeneratorName, pattern_index: int) -> None: + def remove_pattern( + self, + generator: GeneratorName, + pattern_index: int, + ) -> None: self.song.remove_pattern(generator, pattern_index) self._touch() self.call(self.on_song_changed) diff --git a/src/sampletones_application/logic/project/title/document.py b/src/sampletones_application/logic/project/title/document.py index 2f28eb55..639517da 100644 --- a/src/sampletones_application/logic/project/title/document.py +++ b/src/sampletones_application/logic/project/title/document.py @@ -49,7 +49,10 @@ def document_title( if reconstruction.included: title = f"{title} [{reconstruction.name}]" else: - marked = _mark(reconstruction.name, reconstruction.unsaved_changes) + marked = _mark( + reconstruction.name, + reconstruction.unsaved_changes, + ) title = join_segments(title, marked) return title diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py index f745b17d..e9e4aa53 100644 --- a/src/sampletones_application/logic/reconstruction/browser_manager.py +++ b/src/sampletones_application/logic/reconstruction/browser_manager.py @@ -53,7 +53,11 @@ def refresh_tree(self) -> None: self._assign_directory_display_names(container_root) self.tree.set_root(container_root) - def _build_tree(self, path: Path, parent: Optional[TreeNode] = None) -> Optional[FileSystemNode]: + def _build_tree( + self, + path: Path, + parent: Optional[TreeNode] = None, + ) -> Optional[FileSystemNode]: if not path.exists(): return None diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index b37c1a4f..b7e37e0c 100644 --- a/src/sampletones_application/logic/reconstruction/data.py +++ b/src/sampletones_application/logic/reconstruction/data.py @@ -110,7 +110,9 @@ def _derive_name(reconstruction: Reconstruction, filepath: Path) -> str: return audio_filepath.stem if audio_filepath is not None else filepath.stem @staticmethod - def _load_original_audio(reconstruction: Reconstruction) -> Optional[np.ndarray]: + def _load_original_audio( + reconstruction: Reconstruction, + ) -> Optional[np.ndarray]: """Loads the source audio, yielding ``None`` when no usable original exists. A reconstruction detached from its origin (a project sample) records no source path, and a diff --git a/src/sampletones_application/logic/reconstruction/feature.py b/src/sampletones_application/logic/reconstruction/feature.py index b52cd180..430dd972 100644 --- a/src/sampletones_application/logic/reconstruction/feature.py +++ b/src/sampletones_application/logic/reconstruction/feature.py @@ -37,5 +37,8 @@ def load(cls, reconstruction: Reconstruction) -> FeatureData: return cls(generators=generators) - def get_generator_features(self, generator_name: GeneratorName) -> Optional[Features]: + def get_generator_features( + self, + generator_name: GeneratorName, + ) -> Optional[Features]: return self.generators.get(generator_name) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 783b2737..8171b68e 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -61,7 +61,11 @@ def update_display(self) -> None: ) self.call(self.on_feature_data_changed, feature_data.generators) - def handle_pitch_value_changed(self, generator_name: GeneratorName, value: int) -> None: + def handle_pitch_value_changed( + self, + generator_name: GeneratorName, + value: int, + ) -> None: self._schedule_reconstruction_update( ReconstructionUpdate( generator_name, @@ -98,7 +102,10 @@ def handle_raw_data_changed( ) ) - def _schedule_reconstruction_update(self, update: ReconstructionUpdate) -> None: + def _schedule_reconstruction_update( + self, + update: ReconstructionUpdate, + ) -> None: """Coalesces a burst of edits into the latest pending update, then hands it off promptly. The slot keeps only the newest update so events arriving within the short debounce diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py index 3ea8f76f..c91db8ae 100644 --- a/src/sampletones_application/logic/reconstruction/manager.py +++ b/src/sampletones_application/logic/reconstruction/manager.py @@ -47,7 +47,12 @@ def load_reconstruction(self, path: Path) -> None: self.call(self.on_reconstruction_loaded) logger.info(f"Reconstruction {logger.format_path(path)} loaded successfully") - def load_reconstruction_object(self, reconstruction: Reconstruction, *, name: str) -> None: + def load_reconstruction_object( + self, + reconstruction: Reconstruction, + *, + name: str, + ) -> None: """Loads an in-memory reconstruction (e.g. a project sample's) for editing. Mirrors :meth:`load_reconstruction` for an object already in memory, so edits @@ -55,7 +60,9 @@ def load_reconstruction_object(self, reconstruction: Reconstruction, *, name: st display name is supplied by the caller, since a detached reconstruction has no source path to derive it from. """ - self._adopt_reconstruction(ReconstructionData.from_reconstruction(reconstruction, name=name)) + self._adopt_reconstruction( + ReconstructionData.from_reconstruction(reconstruction, name=name), + ) self._session.mark_loaded(name) self.call(self.on_reconstruction_loaded) @@ -92,7 +99,10 @@ def save_reconstruction(self, filepath: Optional[Path] = None) -> bool: logger.warning("Reconstruction has no file path; use 'Save as' to choose one") return False - self._write_to_file(self._current_reconstruction.reconstruction, target_path) + self._write_to_file( + self._current_reconstruction.reconstruction, + target_path, + ) self._session.mark_saved(filepath.name if filepath is not None else None) return True @@ -129,7 +139,10 @@ def detach_current_reconstruction(self) -> None: reconstruction = self._current_reconstruction.reconstruction name = self._current_reconstruction.name - self._current_reconstruction = ReconstructionData.from_reconstruction(reconstruction, name=name) + self._current_reconstruction = ReconstructionData.from_reconstruction( + reconstruction, + name=name, + ) def apply_regenerated(self, reconstruction: Reconstruction) -> None: """Adopts an edited reconstruction produced by regeneration. diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index 829a3571..3c44c792 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -111,7 +111,10 @@ def close_reconstruction(self) -> None: self._selected_generators = [] self.call(self.on_audio_data_changed, None) self.call(self.on_waveform_cleared) - empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path="") + empty_path = ReconstructionPathViewModel( + state=ReconstructionPathState.EMPTY, + path="", + ) self.call( self.on_view_changed, ReconstructionViewModel( @@ -133,10 +136,17 @@ def set_selected_generators(self, generators: List[GeneratorName]) -> None: if not reconstruction_data: return - self.call(self.on_waveform_load_changed, reconstruction_data.waveform_data(), generators) + self.call( + self.on_waveform_load_changed, + reconstruction_data.waveform_data(), + generators, + ) self._emit_audio_data() - def request_export_instrument_dialog(self, generator_name: GeneratorName) -> None: + def request_export_instrument_dialog( + self, + generator_name: GeneratorName, + ) -> None: reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be loaded before exporting an instrument") @@ -149,7 +159,11 @@ def request_export_instrument_dialog(self, generator_name: GeneratorName) -> Non default_path = str(self._session_manager.get_instrument_path()) self._pending_generator_name = generator_name - self.call(self.on_open_export_instrument_dialog, instrument_name, default_path) + self.call( + self.on_open_export_instrument_dialog, + instrument_name, + default_path, + ) def request_export_instruments_dialog(self) -> None: reconstruction_data = self._reconstruction_data @@ -182,7 +196,11 @@ def handle_export_instrument_confirmed(self, filepath: Path) -> None: self._pending_generator_name = None self._session_manager.set_instrument_path(filepath.parent) - self._export_service.export_instrument(filepath, instrument_name, feature) + self._export_service.export_instrument( + filepath, + instrument_name, + feature, + ) def handle_export_instruments_confirmed(self, directory: Path) -> None: reconstruction_data = self._reconstruction_data @@ -231,7 +249,10 @@ def open_reconstruction_in_explorer(self) -> None: open_path_in_explorer(filepath) - def _get_instrument_name(self, generator_name: Optional[GeneratorName] = None) -> str: + def _get_instrument_name( + self, + generator_name: Optional[GeneratorName] = None, + ) -> str: reconstruction_data = self._reconstruction_data if not reconstruction_data: raise AssertionError("Expected reconstruction data to be present") @@ -281,11 +302,19 @@ def _build_path_view_models( return reconstruction_file, original_audio @staticmethod - def _build_file_path_view_model(filepath: Optional[Path]) -> ReconstructionPathViewModel: + def _build_file_path_view_model( + filepath: Optional[Path], + ) -> ReconstructionPathViewModel: if filepath is None: - return ReconstructionPathViewModel(state=ReconstructionPathState.NOT_APPLICABLE, path="") + return ReconstructionPathViewModel( + state=ReconstructionPathState.NOT_APPLICABLE, + path="", + ) - return ReconstructionPathViewModel(state=ReconstructionPathState.AVAILABLE, path=str(filepath)) + return ReconstructionPathViewModel( + state=ReconstructionPathState.AVAILABLE, + path=str(filepath), + ) @staticmethod def _build_audio_path_view_model( @@ -295,12 +324,21 @@ def _build_audio_path_view_model( """Reports the original-audio location, treating a recorded path with unusable content the same as a missing one, so the source toggle and waveform agree with what actually loaded.""" if audio_filepath is None: - return ReconstructionPathViewModel(state=ReconstructionPathState.NOT_APPLICABLE, path="") + return ReconstructionPathViewModel( + state=ReconstructionPathState.NOT_APPLICABLE, + path="", + ) if original_audio is None: - return ReconstructionPathViewModel(state=ReconstructionPathState.NOT_FOUND, path="") + return ReconstructionPathViewModel( + state=ReconstructionPathState.NOT_FOUND, + path="", + ) - return ReconstructionPathViewModel(state=ReconstructionPathState.AVAILABLE, path=str(audio_filepath)) + return ReconstructionPathViewModel( + state=ReconstructionPathState.AVAILABLE, + path=str(audio_filepath), + ) @property def _reconstruction_data(self) -> Optional[ReconstructionData]: diff --git a/src/sampletones_application/logic/sequencer/grid.py b/src/sampletones_application/logic/sequencer/grid.py index 6ca3eca4..fa59aa7a 100644 --- a/src/sampletones_application/logic/sequencer/grid.py +++ b/src/sampletones_application/logic/sequencer/grid.py @@ -194,7 +194,11 @@ def clear_subcolumn_all_generators( volume=volume, ) - def set_sample_instrument(self, row_index: int, sample_id: Optional[str]) -> None: + def set_sample_instrument( + self, + row_index: int, + sample_id: Optional[str], + ) -> None: """Places a sample across the channels its reconstruction uses. The sample column is authoritative: the instrument is written to every @@ -313,7 +317,11 @@ def adjust_sample_volume(self, row_index: int, delta: int) -> None: for generator in self._subcolumn_generators(row_index): self.adjust_volume(generator, row_index, delta) - def _current_row(self, generator: GeneratorName, row_index: int) -> Optional[Row]: + def _current_row( + self, + generator: GeneratorName, + row_index: int, + ) -> Optional[Row]: pattern_index = self._pattern_index_at_frame(generator) if pattern_index is None: return None diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 259431ca..68c5d30e 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -83,7 +83,10 @@ def edit_row( if transpose is not None: segments.append(self._subcolumn(SubColumn.TRANSPOSE)) segments.append( - self._segment(display_transpose(transpose), HistoryDetailRole.TRANSPOSE), + self._segment( + display_transpose(transpose), + HistoryDetailRole.TRANSPOSE, + ), ) if volume is not None: @@ -160,7 +163,11 @@ def duplicate_frame(self, position: int) -> Segments: return (self._frame(position), self._arrow(), self._frame(position + 1)) def move_frame(self, from_position: int, to_position: int) -> Segments: - return (self._frame(from_position), self._arrow(), self._frame(to_position)) + return ( + self._frame(from_position), + self._arrow(), + self._frame(to_position), + ) def set_order_entry( self, diff --git a/src/sampletones_application/logic/sequencer/order.py b/src/sampletones_application/logic/sequencer/order.py index bc0fdaa0..b1c8e4cd 100644 --- a/src/sampletones_application/logic/sequencer/order.py +++ b/src/sampletones_application/logic/sequencer/order.py @@ -27,7 +27,10 @@ def __init__(self, project_controller: ProjectController) -> None: def build_order(self) -> SequencerOrderGridViewModel: song = self._controller.project.song channels = {generator: self._build_channel_view(generator, song) for generator in GeneratorName.items()} - return SequencerOrderGridViewModel(position_count=song.order_length(), channels=channels) + return SequencerOrderGridViewModel( + position_count=song.order_length(), + channels=channels, + ) def push_order(self) -> None: self.call(self.on_order_changed, self.build_order()) diff --git a/src/sampletones_application/logic/sequencer/playback/playhead.py b/src/sampletones_application/logic/sequencer/playback/playhead.py index 72ecd04f..68dd1954 100644 --- a/src/sampletones_application/logic/sequencer/playback/playhead.py +++ b/src/sampletones_application/logic/sequencer/playback/playhead.py @@ -3,7 +3,11 @@ def remap_after_insert(playhead: int, inserted_index: int) -> int: return playhead + 1 if inserted_index <= playhead else playhead -def remap_after_remove(playhead: int, removed_index: int, new_length: int) -> int: +def remap_after_remove( + playhead: int, + removed_index: int, + new_length: int, +) -> int: """A frame removed before the playhead pulls it one position earlier. Removing the playing frame itself keeps the index (it now addresses the frame that diff --git a/src/sampletones_application/logic/sequencer/playback/protocol.py b/src/sampletones_application/logic/sequencer/playback/protocol.py index 78a0421f..71986e11 100644 --- a/src/sampletones_application/logic/sequencer/playback/protocol.py +++ b/src/sampletones_application/logic/sequencer/playback/protocol.py @@ -19,7 +19,13 @@ class ChannelGeneratorProtocol(Protocol): lies outside the static type system. """ - def __call__(self, instruction: Any, /, initials: Any = None, save: bool = False) -> np.ndarray: ... + def __call__( + self, + instruction: Any, + /, + initials: Any = None, + save: bool = False, + ) -> np.ndarray: ... def reset(self) -> None: ... diff --git a/src/sampletones_application/logic/sequencer/playback/song_player.py b/src/sampletones_application/logic/sequencer/playback/song_player.py index daf1d5aa..5a2e3663 100644 --- a/src/sampletones_application/logic/sequencer/playback/song_player.py +++ b/src/sampletones_application/logic/sequencer/playback/song_player.py @@ -199,7 +199,10 @@ def _on_service_result(self, result: SongPlayerResult) -> None: self._emit_view() def _emit_view(self) -> None: - self.call(self.on_view_changed, self._build_view_model(self.is_playing(), self.is_paused())) + self.call( + self.on_view_changed, + self._build_view_model(self.is_playing(), self.is_paused()), + ) def _emit_idle_view(self) -> None: """Pushes a definitively stopped view. @@ -209,9 +212,16 @@ def _emit_idle_view(self) -> None: itself as playing; forcing the flags off keeps the stopped view authoritative and lets the playing highlight settle correctly. """ - self.call(self.on_view_changed, self._build_view_model(is_playing=False, is_paused=False)) + self.call( + self.on_view_changed, + self._build_view_model(is_playing=False, is_paused=False), + ) - def _build_view_model(self, is_playing: bool, is_paused: bool) -> SongPlayerViewModel: + def _build_view_model( + self, + is_playing: bool, + is_paused: bool, + ) -> SongPlayerViewModel: return SongPlayerViewModel( is_loaded=self.is_loaded(), is_playing=is_playing, diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer.py index 5fbc6a40..12e1d65a 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer.py @@ -142,7 +142,10 @@ def _ensure_generators(self, nes_frequency: int) -> None: self._nes_frequency = nes_frequency config = self._playback_config(nes_frequency) for generator_name, state in self._channel_states.items(): - state.generator = GENERATOR_CLASSES[generator_name](config, generator_name.value) + state.generator = GENERATOR_CLASSES[generator_name]( + config, + generator_name.value, + ) def _playback_config(self, nes_frequency: int) -> Config: library = self._config.library.model_copy(update={"nes_frequency": nes_frequency}) diff --git a/src/sampletones_application/logic/sequencer/samples.py b/src/sampletones_application/logic/sequencer/samples.py index 48c272f5..a48edb44 100644 --- a/src/sampletones_application/logic/sequencer/samples.py +++ b/src/sampletones_application/logic/sequencer/samples.py @@ -137,5 +137,8 @@ def _play_sample(self, sample_id: str, *, priority: PlaybackPriority) -> None: priority=priority, ) except (PlaybackError, ValueError) as exception: - logger.error_with_traceback(exception, f"Failed to preview sample: {sample_id}") + logger.error_with_traceback( + exception, + f"Failed to preview sample: {sample_id}", + ) self.call(self.on_autoplay_error, exception) diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index d4e6a29f..155c548e 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -66,7 +66,11 @@ def _notify_lock_state(self, is_unlocked: bool) -> None: """ callback = self.on_lock_state_changed if callback is not None: - CallbackQueue.add(callback, is_unlocked, priority=self._scheduling.priorities.gui_action) + CallbackQueue.add( + callback, + is_unlocked, + priority=self._scheduling.priorities.gui_action, + ) @property def locked(self) -> bool: @@ -124,7 +128,10 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None: priority=priority, ) except (OSError, SampleToNESError) as exception: - logger.error_with_traceback(exception, f"Failed to play reconstruction file: {node.filepath}") + logger.error_with_traceback( + exception, + f"Failed to play reconstruction file: {node.filepath}", + ) self.call(self.on_autoplay_error, exception) case suffix if suffix in paths.EXT_FILES_AUDIO: self._audio_device_manager.play_file( diff --git a/src/sampletones_application/services/base.py b/src/sampletones_application/services/base.py index fce86612..d543d304 100644 --- a/src/sampletones_application/services/base.py +++ b/src/sampletones_application/services/base.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from abc import ABC from typing import Callable, Generic, List, TypeVar diff --git a/src/sampletones_application/services/conversion.py b/src/sampletones_application/services/conversion.py index 193dc643..d47a7322 100644 --- a/src/sampletones_application/services/conversion.py +++ b/src/sampletones_application/services/conversion.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from pathlib import Path from typing import Optional @@ -116,5 +114,9 @@ def _on_error(self, exception: Exception) -> None: def _on_cancelled(self) -> None: self._emit(ServiceCancelled()) - def forward_library_progress(self, _status: TaskStatus, progress: TaskProgress) -> None: + def forward_library_progress( + self, + _status: TaskStatus, + progress: TaskProgress, + ) -> None: self._emit(ServiceIntermediate(data=progress)) diff --git a/src/sampletones_application/services/export/service.py b/src/sampletones_application/services/export/service.py index 01f182da..e35ca182 100644 --- a/src/sampletones_application/services/export/service.py +++ b/src/sampletones_application/services/export/service.py @@ -31,10 +31,21 @@ def task() -> None: try: write_wave(filepath, sample_rate, audio) logger.info(f"Exported reconstruction to WAV: {logger.format_path(filepath)}") - self._emit(ExportSuccess(kind=ExportKind.WAV, filepath=filepath, truncation=None)) + self._emit( + ExportSuccess( + kind=ExportKind.WAV, + filepath=filepath, + truncation=None, + ) + ) except Exception as exception: # pylint: disable=broad-exception-caught logger.error_with_traceback(exception, f"Failed to export reconstruction to WAV: {filepath}") - self._emit(ExportError(kind=ExportKind.WAV, exception=exception)) + self._emit( + ExportError( + kind=ExportKind.WAV, + exception=exception, + ) + ) self._executor.execute(task, wait=False) @@ -57,7 +68,12 @@ def task() -> None: ) except Exception as exception: # pylint: disable=broad-exception-caught logger.error_with_traceback(exception, f"Failed to export instrument: {filepath}") - self._emit(ExportError(kind=ExportKind.INSTRUMENT, exception=exception)) + self._emit( + ExportError( + kind=ExportKind.INSTRUMENT, + exception=exception, + ) + ) self._executor.execute(task, wait=False) @@ -82,6 +98,11 @@ def task() -> None: ) except Exception as exception: # pylint: disable=broad-exception-caught logger.error_with_traceback(exception, f"Failed to export instruments to: {directory}") - self._emit(ExportError(kind=ExportKind.INSTRUMENTS, exception=exception)) + self._emit( + ExportError( + kind=ExportKind.INSTRUMENTS, + exception=exception, + ) + ) self._executor.execute(task, wait=False) diff --git a/src/sampletones_application/services/export/truncation.py b/src/sampletones_application/services/export/truncation.py index 71eaa295..89db73cb 100644 --- a/src/sampletones_application/services/export/truncation.py +++ b/src/sampletones_application/services/export/truncation.py @@ -21,7 +21,10 @@ class ExportTruncation: instruments: int @classmethod - def summarize(cls, truncations: Sequence[Optional[SequenceTruncation]]) -> Optional[ExportTruncation]: + def summarize( + cls, + truncations: Sequence[Optional[SequenceTruncation]], + ) -> Optional[ExportTruncation]: """Gathers the per-instrument shortenings of one export into a single report. Args: diff --git a/src/sampletones_application/services/regeneration.py b/src/sampletones_application/services/regeneration.py index 532e6378..6d2e3311 100644 --- a/src/sampletones_application/services/regeneration.py +++ b/src/sampletones_application/services/regeneration.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from dataclasses import dataclass from typing import List, cast diff --git a/src/sampletones_application/services/result.py b/src/sampletones_application/services/result.py index e8cad96d..331013ab 100644 --- a/src/sampletones_application/services/result.py +++ b/src/sampletones_application/services/result.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from dataclasses import dataclass from pathlib import Path from typing import Generic, Optional, TypeVar, Union diff --git a/src/sampletones_application/services/retune/retune.py b/src/sampletones_application/services/retune/retune.py index ee64083d..64e6d01e 100644 --- a/src/sampletones_application/services/retune/retune.py +++ b/src/sampletones_application/services/retune/retune.py @@ -39,6 +39,13 @@ def _run(self, targets: List[RetuneTarget], nes_frequency: int) -> None: try: for sample_id, reconstruction in targets: retuned = reconstruction.with_nes_frequency(nes_frequency) - self._emit(ServiceSuccess(value=RetunedSample(sample_id=sample_id, reconstruction=retuned))) + self._emit( + ServiceSuccess( + value=RetunedSample( + sample_id=sample_id, + reconstruction=retuned, + ) + ) + ) except Exception as exception: # pylint: disable=broad-exception-caught self._emit(ServiceError(exception=exception)) diff --git a/src/sampletones_application/services/song_player/player.py b/src/sampletones_application/services/song_player/player.py index 3d0f748f..27303176 100644 --- a/src/sampletones_application/services/song_player/player.py +++ b/src/sampletones_application/services/song_player/player.py @@ -1,5 +1,3 @@ -from __future__ import annotations - import threading from collections import deque from dataclasses import dataclass @@ -93,7 +91,12 @@ def start( self._synthesizer.set_position(order_position, row_index) self._synthesizer.reset() self._playback_error = None - self._prefetch_samples = max(1, round(PREFETCH_SECONDS * self._audio_device_manager.sample_rate)) + self._prefetch_samples = max( + 1, + round( + PREFETCH_SECONDS * self._audio_device_manager.sample_rate, + ), + ) self._stop_event.clear() self._resume_event.set() self._render_thread = threading.Thread( diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 78c6f312..18b5f106 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -415,7 +415,11 @@ def _register_channel_shortcuts(self, bindings: ShortcutBindings) -> None: def _setup_handlers(self) -> None: self._key_router.bind() - def _create_main_window(self, on_tab_changed: Callback, initial_menu_state: MenuBarViewModel) -> None: + def _create_main_window( + self, + on_tab_changed: Callback, + initial_menu_state: MenuBarViewModel, + ) -> None: with dpg.window( label=self._language_manager[ Page.GLOBAL, @@ -527,7 +531,10 @@ def _restore_current_project(self, on_load_project: PathCallback) -> None: on_load_project(current_project_path) - def _restore_current_reconstruction(self, on_load_reconstruction: PathCallback) -> None: + def _restore_current_reconstruction( + self, + on_load_reconstruction: PathCallback, + ) -> None: current_reconstruction_path = self._session_manager.current_reconstruction if current_reconstruction_path is None: return diff --git a/src/sampletones_application/ui/elements/plus_minus_buttons.py b/src/sampletones_application/ui/elements/plus_minus_buttons.py index 88ffcae7..3c64fab2 100644 --- a/src/sampletones_application/ui/elements/plus_minus_buttons.py +++ b/src/sampletones_application/ui/elements/plus_minus_buttons.py @@ -74,7 +74,10 @@ def __init__( self._decrement_button: Optional[GUIButton] = None self._increment_button: Optional[GUIButton] = None - self._build(increment_enabled=increment_enabled, decrement_enabled=decrement_enabled) + self._build( + increment_enabled=increment_enabled, + decrement_enabled=decrement_enabled, + ) def set_decrement_enabled(self, enabled: bool) -> None: if self._decrement_button is not None: @@ -96,8 +99,14 @@ def _build(self, *, increment_enabled: bool, decrement_enabled: bool) -> None: width=0, height=0, ): - dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.button_width) - dpg.add_table_column(width_fixed=True, init_width_or_weight=self._layout.button_width) + dpg.add_table_column( + width_fixed=True, + init_width_or_weight=self._layout.button_width, + ) + dpg.add_table_column( + width_fixed=True, + init_width_or_weight=self._layout.button_width, + ) with dpg.table_row(): with dpg.table_cell(): self._add_button( @@ -116,7 +125,13 @@ def _build(self, *, increment_enabled: bool, decrement_enabled: bool) -> None: if self._hold_repeat: self._setup_button_hold_handlers() - def _add_button(self, *, increment: bool, increment_enabled: bool, decrement_enabled: bool) -> None: + def _add_button( + self, + *, + increment: bool, + increment_enabled: bool, + decrement_enabled: bool, + ) -> None: if increment: self._increment_button = GUIButton( label=PLUS, @@ -148,8 +163,14 @@ def _clear_existing_items(self) -> None: def _setup_button_hold_handlers(self) -> None: with dpg.handler_registry(tag=self._mouse_handler_tag): - dpg.add_mouse_down_handler(button=dpg.mvMouseButton_Left, callback=self._on_mouse_down) - dpg.add_mouse_release_handler(button=dpg.mvMouseButton_Left, callback=self._on_mouse_release) + dpg.add_mouse_down_handler( + button=dpg.mvMouseButton_Left, + callback=self._on_mouse_down, + ) + dpg.add_mouse_release_handler( + button=dpg.mvMouseButton_Left, + callback=self._on_mouse_release, + ) def _step(self, direction: int) -> None: if direction > 0: @@ -163,22 +184,41 @@ def _on_increment(self, *_arguments: Any) -> None: def _on_decrement(self, *_arguments: Any) -> None: self._step(-1) - def _on_mouse_down(self, sender: Sender, app_data: Any, user_data: Any) -> None: + def _on_mouse_down( + self, + sender: Sender, + app_data: Any, + user_data: Any, + ) -> None: if not dpg.does_item_exist(self._decrement_button_tag) or not dpg.does_item_exist(self._increment_button_tag): dpg_delete_item(sender) return is_decrement = self._decrement_button is not None and bool(self._decrement_button.is_item_hovered()) is_increment = self._increment_button is not None and bool(self._increment_button.is_item_hovered()) - direction = self._update_hold_timer(is_decrement, is_increment, dpg.get_delta_time()) + direction = self._update_hold_timer( + is_decrement, + is_increment, + dpg.get_delta_time(), + ) if direction is not None: self._step(direction) - def _on_mouse_release(self, sender: Sender, app_data: Any, user_data: Any) -> None: + def _on_mouse_release( + self, + sender: Sender, + app_data: Any, + user_data: Any, + ) -> None: self._hold_timer = None self._hold_direction = None - def _update_hold_timer(self, is_decrement: bool, is_increment: bool, delta_time: float) -> Optional[int]: + def _update_hold_timer( + self, + is_decrement: bool, + is_increment: bool, + delta_time: float, + ) -> Optional[int]: """Drives click-and-hold repetition: the first frame of a press arms the timer with a longer initial delay, and each later frame repeats once the delay elapses. Returns the step direction on the frames that should advance the value, otherwise None. diff --git a/src/sampletones_application/ui/elements/status.py b/src/sampletones_application/ui/elements/status.py index 0b9f7937..52f776b0 100644 --- a/src/sampletones_application/ui/elements/status.py +++ b/src/sampletones_application/ui/elements/status.py @@ -71,7 +71,11 @@ def update( **kwargs: Any, ) -> None: if message_or_function is not None: - self.message = self.get_message(message_or_function, *args, **kwargs) + self.message = self.get_message( + message_or_function, + *args, + **kwargs, + ) self.timer = self._display_time dpg_configure_item(self.tag, label=self.message) diff --git a/src/sampletones_application/ui/elements/table/caret.py b/src/sampletones_application/ui/elements/table/caret.py index e5c68a22..87bd3c44 100644 --- a/src/sampletones_application/ui/elements/table/caret.py +++ b/src/sampletones_application/ui/elements/table/caret.py @@ -80,7 +80,7 @@ def initialize(cls, layout: CaretLayout, *, root_window_tag: str) -> None: def set_target( cls, *, - owner: object, + owner: Any, widget: Optional[Sender], caret_index: int, font: Font, @@ -102,7 +102,7 @@ def set_target( cls._clip_widget = clip_widget @classmethod - def clear(cls, owner: object) -> None: + def clear(cls, owner: Any) -> None: """Disarms the caret, but only if ``owner`` currently holds it.""" if cls._owner is not None and cls._owner != owner: return diff --git a/src/sampletones_application/ui/elements/trace.py b/src/sampletones_application/ui/elements/trace.py index d7004e07..503b5dff 100644 --- a/src/sampletones_application/ui/elements/trace.py +++ b/src/sampletones_application/ui/elements/trace.py @@ -58,7 +58,10 @@ def __init__( ] self.theme = ThemeRegistry.resolve(theme, TAG_GLOBAL_THEME_TRACEBACK) - resolved_button_theme = ThemeRegistry.resolve(button_theme, TAG_GLOBAL_THEME_DEFAULT) + resolved_button_theme = ThemeRegistry.resolve( + button_theme, + TAG_GLOBAL_THEME_DEFAULT, + ) traceback_text_tag = f"{self._tag}{SUF_TEXT}" traceback_copy_tag = f"{self._tag}{SUF_BUTTON_COPY}" diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 6561e40e..8059918a 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -526,11 +526,17 @@ def update(self, state: MenuBarViewModel) -> None: self._update_player_toolbar(state) dpg_set_value(TAG_GLOBAL_MENU_ITEM_PLAYBACK_AUTOPLAY, state.autoplay) - dpg_set_value(TAG_GLOBAL_MENU_ITEM_PLAYBACK_FOLLOW_PLAYBACK, state.follow_playback) + dpg_set_value( + TAG_GLOBAL_MENU_ITEM_PLAYBACK_FOLLOW_PLAYBACK, + state.follow_playback, + ) dpg_set_value(TAG_GLOBAL_MENU_ITEM_PLAYBACK_LOOP_SONG, state.loop_song) self._update_channels(state) dpg_set_value(TAG_GLOBAL_MENU_ITEM_VIEW_FULLSCREEN, state.fullscreen) - dpg_set_value(TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS, state.advanced_settings) + dpg_set_value( + TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS, + state.advanced_settings, + ) def _update_channels(self, state: MenuBarViewModel) -> None: """Shows the mute set the sequencer's tables show: a check on every channel that sounds.""" @@ -543,15 +549,24 @@ def _update_channels(self, state: MenuBarViewModel) -> None: ) def _update_player_toolbar(self, state: MenuBarViewModel) -> None: - dpg_configure_item(self._play_button_tag, enabled=state.play_from_start_enabled) + dpg_configure_item( + self._play_button_tag, + enabled=state.play_from_start_enabled, + ) dpg_configure_item(self._pause_button_tag, enabled=state.pause_enabled) dpg_configure_item(self._stop_button_tag, enabled=state.stop_enabled) if state.player_paused: - dpg_set_item_label(self._pause_button_tag, self._player_glyphs.resume) + dpg_set_item_label( + self._pause_button_tag, + self._player_glyphs.resume, + ) dpg_set_value(self._pause_tooltip_tag, self._lbl_resume) else: - dpg_set_item_label(self._pause_button_tag, self._player_glyphs.pause) + dpg_set_item_label( + self._pause_button_tag, + self._player_glyphs.pause, + ) dpg_set_value(self._pause_tooltip_tag, self._lbl_pause) def update_fps(self, fps: float) -> None: diff --git a/src/sampletones_application/ui/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py index 8c782940..4781f216 100644 --- a/src/sampletones_application/ui/panels/dialogs/audio_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/audio_settings.py @@ -327,9 +327,17 @@ def _update_combos(self) -> None: dpg_set_value(TAG_SETTINGS_AUDIO_COMBO_DEVICE, "") return - self._sample_rates_by_label = dict(zip(device.sample_rate_labels(self._fmt_sample_rate), device.sample_rates)) + self._sample_rates_by_label = dict( + zip( + device.sample_rate_labels(self._fmt_sample_rate), + device.sample_rates, + ) + ) sample_rate_items = list(self._sample_rates_by_label) - dpg_set_value(TAG_SETTINGS_AUDIO_COMBO_DEVICE, device.label(self._fmt_device_label)) + dpg_set_value( + TAG_SETTINGS_AUDIO_COMBO_DEVICE, + device.label(self._fmt_device_label), + ) dpg_configure_item( TAG_SETTINGS_AUDIO_COMBO_SAMPLE_RATE, items=sample_rate_items, diff --git a/src/sampletones_application/ui/panels/dialogs/project_properties.py b/src/sampletones_application/ui/panels/dialogs/project_properties.py index 11a8454b..2f6278e4 100644 --- a/src/sampletones_application/ui/panels/dialogs/project_properties.py +++ b/src/sampletones_application/ui/panels/dialogs/project_properties.py @@ -187,7 +187,11 @@ def _teardown(self) -> None: def _create_text_field(self, tag: str, label: str, value: str) -> None: with labeled_field(label, self._layout.label_width): - dpg.add_input_text(tag=tag, default_value=value, width=self._layout.input_width) + dpg.add_input_text( + tag=tag, + default_value=value, + width=self._layout.input_width, + ) def _create_comment_field(self) -> None: label_id = dpg.add_text(self._lbl_comment) @@ -235,5 +239,13 @@ def _commit(self) -> None: self.hide() @staticmethod - def _label(language_manager: LanguageManager, element: ProjectPropertiesElements) -> str: - return language_manager[Page.SETTINGS, Panel.PROPERTIES, TextType.LABEL, element] + def _label( + language_manager: LanguageManager, + element: ProjectPropertiesElements, + ) -> str: + return language_manager[ + Page.SETTINGS, + Panel.PROPERTIES, + TextType.LABEL, + element, + ] diff --git a/src/sampletones_application/ui/panels/instruction/choice.py b/src/sampletones_application/ui/panels/instruction/choice.py index cdeb9f88..1f48c46c 100644 --- a/src/sampletones_application/ui/panels/instruction/choice.py +++ b/src/sampletones_application/ui/panels/instruction/choice.py @@ -150,21 +150,39 @@ def __init__( TextType.TEMPLATE, InstructionsDetailsElements.PITCH_TOOLTIP_TEMPLATE, ] - self._pitch_tooltip = build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, tooltip_template) - self._period_tooltip = build_pitch_tooltip(language_manager, PERIOD_VALUE_KIND, tooltip_template) + self._pitch_tooltip = build_pitch_tooltip( + language_manager, + PITCH_VALUE_KIND, + tooltip_template, + ) + self._period_tooltip = build_pitch_tooltip( + language_manager, + PERIOD_VALUE_KIND, + tooltip_template, + ) super().__init__( tag=TAG_INSTRUCTIONS_DETAILS_PANEL, ) - self._enable_vertical_collapse(initial_collapsed=initial_collapsed, auto_height=True) + self._enable_vertical_collapse( + initial_collapsed=initial_collapsed, + auto_height=True, + ) def create_panel(self, parent: str) -> None: self._setup_handlers() - with self._collapsible_card(parent, self._lbl_section, glyph=self._glyphs.headers.details): + with self._collapsible_card( + parent, + self._lbl_section, + glyph=self._glyphs.headers.details, + ): self._create_instructions_choice_inputs() self._create_no_instruction_text() - def update_choice(self, instruction_data: Optional[InstructionPanelData]) -> None: + def update_choice( + self, + instruction_data: Optional[InstructionPanelData], + ) -> None: self._current_instruction_data = instruction_data dpg_configure_item(TAG_INSTRUCTIONS_DETAILS_TEXT_INFO, show=instruction_data is None) self._update_instructions_choice_panel(instruction_data) @@ -192,7 +210,10 @@ def _create_instructions_choice_inputs(self) -> None: ): pass - def _update_instructions_choice_panel(self, instruction_data: Optional[InstructionPanelData]) -> None: + def _update_instructions_choice_panel( + self, + instruction_data: Optional[InstructionPanelData], + ) -> None: dpg_delete_children(TAG_INSTRUCTIONS_DETAILS_GROUP_INSTRUCTIONS_CHOICE) if instruction_data is None: return @@ -279,7 +300,10 @@ def _create_pulse_instruction_choice_panel(self, instruction: PulseInstruction) dpg.bind_item_handler_registry(tag, self._item_handler_tag) FontRegistry.bind_to_item(tag, Font.MONO) - def _create_triangle_instruction_choice_panel(self, instruction: TriangleInstruction) -> None: + def _create_triangle_instruction_choice_panel( + self, + instruction: TriangleInstruction, + ) -> None: self._create_pitch_stepper( kind=PITCH_VALUE_KIND, initial_value=instruction.pitch, @@ -287,7 +311,10 @@ def _create_triangle_instruction_choice_panel(self, instruction: TriangleInstruc tag=TAG_INSTRUCTIONS_DETAILS_INPUT_INSTRUCTIONS_CHOICE_TRIANGLE_PITCH, ) - def _create_noise_instruction_choice_panel(self, instruction: NoiseInstruction) -> None: + def _create_noise_instruction_choice_panel( + self, + instruction: NoiseInstruction, + ) -> None: self._create_pitch_stepper( kind=PERIOD_VALUE_KIND, initial_value=instruction.period, @@ -316,14 +343,20 @@ def _create_noise_instruction_choice_panel(self, instruction: NoiseInstruction) ) self._status_bar.bind_to_item( - TAG_INSTRUCTIONS_DETAILS_INPUT_INSTRUCTIONS_CHOICE_NOISE_VOLUME, self._msg_status_input + TAG_INSTRUCTIONS_DETAILS_INPUT_INSTRUCTIONS_CHOICE_NOISE_VOLUME, + self._msg_status_input, + ) + FontRegistry.bind_to_item( + TAG_INSTRUCTIONS_DETAILS_INPUT_INSTRUCTIONS_CHOICE_NOISE_VOLUME, + Font.MONO, ) - FontRegistry.bind_to_item(TAG_INSTRUCTIONS_DETAILS_INPUT_INSTRUCTIONS_CHOICE_NOISE_VOLUME, Font.MONO) self._status_bar.bind_to_item( - TAG_INSTRUCTIONS_DETAILS_CHECKBOX_INSTRUCTIONS_CHOICE_NOISE_SHORT, self._msg_status_input + TAG_INSTRUCTIONS_DETAILS_CHECKBOX_INSTRUCTIONS_CHOICE_NOISE_SHORT, + self._msg_status_input, ) dpg.bind_item_handler_registry( - TAG_INSTRUCTIONS_DETAILS_INPUT_INSTRUCTIONS_CHOICE_NOISE_VOLUME, self._item_handler_tag + TAG_INSTRUCTIONS_DETAILS_INPUT_INSTRUCTIONS_CHOICE_NOISE_VOLUME, + self._item_handler_tag, ) def _on_instruction_changed(self, *_arguments: Any) -> None: @@ -341,7 +374,11 @@ def _on_instruction_changed(self, *_arguments: Any) -> None: case GeneratorClassName.PULSE_GENERATOR: pitch = self._pitch_stepper.value volume = int( - clamp(dpg.get_value(TAG_INSTRUCTIONS_DETAILS_INPUT_INSTRUCTIONS_CHOICE_PULSE_VOLUME), 1, MAX_VOLUME) + clamp( + dpg.get_value(TAG_INSTRUCTIONS_DETAILS_INPUT_INSTRUCTIONS_CHOICE_PULSE_VOLUME), + 1, + MAX_VOLUME, + ) ) duty_cycle = int( clamp( @@ -369,7 +406,11 @@ def _on_instruction_changed(self, *_arguments: Any) -> None: ) case GeneratorClassName.NOISE_GENERATOR: volume = int( - clamp(dpg.get_value(TAG_INSTRUCTIONS_DETAILS_INPUT_INSTRUCTIONS_CHOICE_NOISE_VOLUME), 1, MAX_VOLUME) + clamp( + dpg.get_value(TAG_INSTRUCTIONS_DETAILS_INPUT_INSTRUCTIONS_CHOICE_NOISE_VOLUME), + 1, + MAX_VOLUME, + ) ) short = bool(dpg.get_value(TAG_INSTRUCTIONS_DETAILS_CHECKBOX_INSTRUCTIONS_CHOICE_NOISE_SHORT)) instruction = NoiseInstruction( diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index 8fd56b2a..2178c644 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -288,21 +288,33 @@ def _create_library_controls(self) -> None: self._tooltip_generate_disabled, tag=TAG_INSTRUCTIONS_LIBRARY_TOOLTIP_GENERATE, ) - with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS_GENERATING, show=False): + with dpg.group( + tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS_GENERATING, + show=False, + ): dpg.add_progress_bar( tag=TAG_INSTRUCTIONS_LIBRARY_PROGRESS, width=-1, default_value=0.0, ) - FontRegistry.bind_to_item(TAG_INSTRUCTIONS_LIBRARY_PROGRESS, Font.MONO) + FontRegistry.bind_to_item( + TAG_INSTRUCTIONS_LIBRARY_PROGRESS, + Font.MONO, + ) GUIButton( tag=TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION, label=self._lbl_cancel, width=-1, callback=self._on_cancel_clicked, ) - self._status_bar.bind_to_item(TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES, self._msg_status_refresh) - self._status_bar.bind_to_item(TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, self._msg_status_generate) + self._status_bar.bind_to_item( + TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES, + self._msg_status_refresh, + ) + self._status_bar.bind_to_item( + TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, + self._msg_status_generate, + ) self._status_bar.bind_to_item( TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION, self._msg_status_cancel_generation, @@ -335,7 +347,10 @@ def _on_cancel_clicked(self) -> None: self.call(self.on_cancel_generation) def update_view(self, view_model: LibraryPanelViewModel) -> None: - dpg_set_value(TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS, view_model.status_text) + dpg_set_value( + TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS, + view_model.status_text, + ) dpg_configure_item( TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS_IDLE, show=view_model.idle_controls_visible, @@ -431,7 +446,11 @@ def _build_tree_node( def _create_status_bar_message_function_for_instructions_node( self, ) -> MessageCallback: - def message_function(*args: Any, user_data: Tuple[TreeNode, str], **kwargs: Any) -> str: + def message_function( + *args: Any, + user_data: Tuple[TreeNode, str], + **kwargs: Any, + ) -> str: node, _ = user_data match node.node_type: case NodeType.LIBRARY: @@ -499,11 +518,17 @@ def _add_context_menu_library_node(self, node: LibraryNode) -> None: dpg.add_separator() dpg.add_menu_item( label=self._lbl_ctx_load_library, - callback=lambda: self.call(self.on_library_selected, node.library_key), + callback=lambda: self.call( + self.on_library_selected, + node.library_key, + ), ) dpg.add_menu_item( label=self._lbl_ctx_remove_library, - callback=lambda: self.call(self.on_library_remove_requested, node.library_key), + callback=lambda: self.call( + self.on_library_remove_requested, + node.library_key, + ), ) def _show_generator_context_menu(self, node: GeneratorNode) -> None: @@ -528,6 +553,15 @@ def _is_current_library_node(self, node: TreeNode) -> bool: return node.library_key == self._library_logic.current_library_key - def _on_load_generator(self, sender: Sender, app_data: bool, user_data: GeneratorNode) -> None: + def _on_load_generator( + self, + sender: Sender, + app_data: bool, + user_data: GeneratorNode, + ) -> None: assert isinstance(user_data.parent, LibraryNode), "Generator node parent is not a LibraryNode" - self.call(self.on_generator_selected, user_data.parent.library_key, user_data.generator_name) + self.call( + self.on_generator_selected, + user_data.parent.library_key, + user_data.generator_name, + ) diff --git a/src/sampletones_application/ui/panels/instruction/parameters.py b/src/sampletones_application/ui/panels/instruction/parameters.py index c26895a6..4c774557 100644 --- a/src/sampletones_application/ui/panels/instruction/parameters.py +++ b/src/sampletones_application/ui/panels/instruction/parameters.py @@ -58,7 +58,10 @@ def __init__( super().__init__( tag=TAG_INSTRUCTIONS_DETAILS_WINDOW_PARAMETERS_CARD, ) - self._enable_vertical_collapse(initial_collapsed=initial_collapsed, auto_height=True) + self._enable_vertical_collapse( + initial_collapsed=initial_collapsed, + auto_height=True, + ) def create_panel(self, parent: str) -> None: with self._collapsible_card( diff --git a/src/sampletones_application/ui/panels/instruction/waveform.py b/src/sampletones_application/ui/panels/instruction/waveform.py index 0bdc626d..453f312f 100644 --- a/src/sampletones_application/ui/panels/instruction/waveform.py +++ b/src/sampletones_application/ui/panels/instruction/waveform.py @@ -64,7 +64,10 @@ def create_panel(self, parent: str) -> None: def set_display_height(self, height: int) -> None: self.display.set_height(height) - def load_library_fragment(self, fragment: InstructionLibraryFragment[Any]) -> None: + def load_library_fragment( + self, + fragment: InstructionLibraryFragment[Any], + ) -> None: self.display.load_library_fragment(fragment) def clear_layers(self) -> None: diff --git a/src/sampletones_application/ui/panels/main/advanced.py b/src/sampletones_application/ui/panels/main/advanced.py index d5171187..ec900c25 100644 --- a/src/sampletones_application/ui/panels/main/advanced.py +++ b/src/sampletones_application/ui/panels/main/advanced.py @@ -248,8 +248,14 @@ def _create_generation_method_settings(self) -> None: TAG_MAIN_ADVANCED_INPUT_TRANSFORMATION_GAMMA, self._item_handler_tag, ) - self._status_bar.bind_to_item(TAG_MAIN_ADVANCED_COMBO_SPECTRUM_METHOD, self._msg_status_combo) - self._status_bar.bind_to_item(TAG_MAIN_ADVANCED_INPUT_TRANSFORMATION_GAMMA, self._msg_status_input) + self._status_bar.bind_to_item( + TAG_MAIN_ADVANCED_COMBO_SPECTRUM_METHOD, + self._msg_status_combo, + ) + self._status_bar.bind_to_item( + TAG_MAIN_ADVANCED_INPUT_TRANSFORMATION_GAMMA, + self._msg_status_input, + ) def _create_workers_settings(self) -> None: with labeled_field(self._lbl_max_workers, self._label_width): @@ -379,7 +385,10 @@ def update_view(self, view_model: AdvancedSettingsPanelViewModel) -> None: self._output_directory = view_model.reconstructions_directory self._spectrum_method = view_model.spectrum_method self._transformation_gamma = view_model.transformation_gamma - dpg.set_value(TAG_MAIN_ADVANCED_INPUT_MAX_WORKERS, view_model.max_workers) + dpg.set_value( + TAG_MAIN_ADVANCED_INPUT_MAX_WORKERS, + view_model.max_workers, + ) dpg.set_value( TAG_MAIN_ADVANCED_COMBO_SPECTRUM_METHOD, format_spectrum_method(view_model.spectrum_method), diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 0bf7ef51..2fe9fa8e 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -224,7 +224,10 @@ def _create_action_button(self) -> None: self._tooltip_convert_disabled, tag=TAG_MAIN_CONVERTER_TOOLTIP_CONVERT, ) - self._status_bar.bind_to_item(TAG_MAIN_CONVERTER_BUTTON_ACTION, self._action_status_message) + self._status_bar.bind_to_item( + TAG_MAIN_CONVERTER_BUTTON_ACTION, + self._action_status_message, + ) def _action_status_message(self, *args: Any, **kwargs: Any) -> str: return self._status_action_message @@ -236,7 +239,10 @@ def _create_summary(self) -> None: height=-1, border=False, ): - hint = dpg.add_text(self._msg_empty_hint, tag=TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT) + hint = dpg.add_text( + self._msg_empty_hint, + tag=TAG_MAIN_CONVERTER_TEXT_SUMMARY_HINT, + ) FontRegistry.bind_to_item(hint, Font.REGULAR_SMALL) with dpg.group(tag=TAG_MAIN_CONVERTER_GROUP_SUMMARY, show=False): self.input_path_text = GUIPathText( @@ -272,7 +278,10 @@ def _create_conversion_status(self) -> None: tag=TAG_MAIN_CONVERTER_TEXT_STATUS, parent=TAG_MAIN_CONVERTER_GROUP, ) - FontRegistry.bind_to_item(TAG_MAIN_CONVERTER_TEXT_STATUS, Font.MONO_SMALL) + FontRegistry.bind_to_item( + TAG_MAIN_CONVERTER_TEXT_STATUS, + Font.MONO_SMALL, + ) dpg.add_progress_bar( tag=TAG_MAIN_CONVERTER_PROGRESS, parent=TAG_MAIN_CONVERTER_GROUP, diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index d0653aa3..8c84566b 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -248,12 +248,21 @@ def _create_buttons(self) -> None: width=-1, callback=self.collapse_all, ) - self._status_bar.bind_to_item(TAG_MAIN_EXPLORER_BUTTON_REFRESH, self._msg_status_refresh) - self._status_bar.bind_to_item(TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, self._msg_status_collapse_all) + self._status_bar.bind_to_item( + TAG_MAIN_EXPLORER_BUTTON_REFRESH, + self._msg_status_refresh, + ) + self._status_bar.bind_to_item( + TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, + self._msg_status_collapse_all, + ) def _create_tree_window(self) -> None: self.create_search(self._body_container) - with dpg.child_window(tag=TAG_MAIN_EXPLORER_WINDOW_TREE, horizontal_scrollbar=True): + with dpg.child_window( + tag=TAG_MAIN_EXPLORER_WINDOW_TREE, + horizontal_scrollbar=True, + ): with dpg.group(tag=TAG_MAIN_EXPLORER_GROUP_TREE): with dpg.tree_node( label=self._lbl_section, @@ -262,7 +271,12 @@ def _create_tree_window(self) -> None: ): pass - def collapse_all(self, sender: Sender, app_data: int, user_data: object) -> None: + def collapse_all( + self, + sender: Sender, + app_data: int, + user_data: Any, + ) -> None: self._explorer_logic.collapse_all() children = dpg.get_item_children(self.tree_tag, 1) assert children is not None, "Explorer tree has no children." @@ -281,14 +295,22 @@ def rebuild_tree(self) -> None: ) @concurrent(wait=False, method_bound=True) - def _rebuild_node_subtree(self, node: FileSystemNode, node_tag: str) -> None: + def _rebuild_node_subtree( + self, + node: FileSystemNode, + node_tag: str, + ) -> None: self._launch_rebuild( lambda: None, lambda: self._collect_subtree_specs(node, node_tag), root_tag=node_tag, ) - def _collect_subtree_specs(self, node: FileSystemNode, node_tag: str) -> List[NodeSpec]: + def _collect_subtree_specs( + self, + node: FileSystemNode, + node_tag: str, + ) -> List[NodeSpec]: self._pending_specs = [] if self._explorer_logic.is_directory_expanded(node.filepath): for child in node.children: @@ -350,16 +372,32 @@ def _create_status_bar_message_function_for_file_node( library_message_function = self._create_status_bar_message_function_for_library_node() audio_message_function = self._create_status_bar_message_function_for_audio_node() - def message_function(*args: Any, user_data: Tuple[FileSystemNode, str], **kwargs: Any) -> str: + def message_function( + *args: Any, + user_data: Tuple[FileSystemNode, str], + **kwargs: Any, + ) -> str: node, _ = user_data suffix = node.filepath.suffix.lower() match suffix: case paths.EXT_FILE_RECONSTRUCTION: - return reconstruction_message_function(*args, user_data=user_data, **kwargs) + return reconstruction_message_function( + *args, + user_data=user_data, + **kwargs, + ) case paths.EXT_FILE_LIBRARY: - return library_message_function(*args, user_data=user_data, **kwargs) + return library_message_function( + *args, + user_data=user_data, + **kwargs, + ) case suffix if suffix in paths.EXT_FILES_AUDIO: - return audio_message_function(*args, user_data=user_data, **kwargs) + return audio_message_function( + *args, + user_data=user_data, + **kwargs, + ) case _: raise ValueError(f"Unsupported file type {suffix} for status bar message function.") @@ -433,7 +471,11 @@ def message_function(*args: Any, **kwargs: Any) -> str: return self._create_status_bar_message_function(message_function) - def _directory_node_clicked(self, node: FileSystemNode, node_tag: str) -> None: + def _directory_node_clicked( + self, + node: FileSystemNode, + node_tag: str, + ) -> None: has_content = self._explorer_logic.has_relevant_content(node.filepath) if not has_content: return @@ -467,7 +509,11 @@ def _reconstruct_file(self, node: FileSystemNode) -> None: self.call(self.on_reconstruct_file, node.filepath) - def _toggle_directory_expansion(self, node: FileSystemNode, node_tag: str) -> None: + def _toggle_directory_expansion( + self, + node: FileSystemNode, + node_tag: str, + ) -> None: if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY: return @@ -513,7 +559,10 @@ def _show_file_context_menu(self, node: FileSystemNode) -> None: self._add_context_menu_path_items(node.filepath) self._add_context_menu_favorite_item(node) - def _add_context_menu_reconstruction_directory(self, node: FileSystemNode) -> None: + def _add_context_menu_reconstruction_directory( + self, + node: FileSystemNode, + ) -> None: dpg.add_separator() dpg.add_menu_item( label=self._lbl_ctx_reconstruct_directory, diff --git a/src/sampletones_application/ui/panels/main/reconstructor.py b/src/sampletones_application/ui/panels/main/reconstructor.py index 1f3638c4..bb660bd5 100644 --- a/src/sampletones_application/ui/panels/main/reconstructor.py +++ b/src/sampletones_application/ui/panels/main/reconstructor.py @@ -173,9 +173,18 @@ def _create_drive_slider(self) -> None: format=self._layout.drive_format, ) - dpg.bind_item_handler_registry(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, self._item_handler_tag) - self._status_bar.bind_to_item(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, self._msg_status_input) - FontRegistry.bind_to_item(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, Font.MONO) + dpg.bind_item_handler_registry( + TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, + self._item_handler_tag, + ) + self._status_bar.bind_to_item( + TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, + self._msg_status_input, + ) + FontRegistry.bind_to_item( + TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, + Font.MONO, + ) def _create_tooltips(self) -> None: show_tooltip(TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE, self._tooltip_drive) diff --git a/src/sampletones_application/ui/panels/reconstruction/audio.py b/src/sampletones_application/ui/panels/reconstruction/audio.py index 0eed03a0..a8f0e7ee 100644 --- a/src/sampletones_application/ui/panels/reconstruction/audio.py +++ b/src/sampletones_application/ui/panels/reconstruction/audio.py @@ -76,7 +76,10 @@ def __init__( super().__init__( tag=TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO, ) - self._enable_vertical_collapse(initial_collapsed=initial_collapsed, auto_height=True) + self._enable_vertical_collapse( + initial_collapsed=initial_collapsed, + auto_height=True, + ) def _load_path_text(self, language_manager: LanguageManager) -> None: self._lbl_reconstruction_file = language_manager[ @@ -123,7 +126,10 @@ def create_panel(self, parent: str) -> None: self._create_path_display() def update_view(self, view_model: ReconstructionViewModel) -> None: - self._render_path(self._reconstruction_file_path, view_model.reconstruction_file) + self._render_path( + self._reconstruction_file_path, + view_model.reconstruction_file, + ) self._render_path(self._original_audio_path, view_model.original_audio) dpg_configure_item( @@ -131,9 +137,16 @@ def update_view(self, view_model: ReconstructionViewModel) -> None: enabled=view_model.audio_source_enabled, ) if not view_model.audio_source_enabled: - dpg_set_value(TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, self._lbl_reconstruction_radio) + dpg_set_value( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, + self._lbl_reconstruction_radio, + ) - def _render_path(self, path_widget: GUIPathText, view_model: ReconstructionPathViewModel) -> None: + def _render_path( + self, + path_widget: GUIPathText, + view_model: ReconstructionPathViewModel, + ) -> None: match view_model.state: case ReconstructionPathState.AVAILABLE: path_widget.set_path(view_model.path) @@ -186,9 +199,15 @@ def _create_audio_source_radio_buttons(self) -> None: callback=self._on_audio_source_changed, horizontal=True, ) - FontRegistry.bind_to_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, Font.REGULAR_SMALL) + FontRegistry.bind_to_item( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_RADIO_AUDIO_SOURCE, + Font.REGULAR_SMALL, + ) - dpg_configure_item(TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE, enabled=False) + dpg_configure_item( + TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE, + enabled=False, + ) def _on_audio_source_changed(self, sender: Sender, app_data: str) -> None: if app_data == self._lbl_original_audio_radio: diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index ab854c4b..c5589d43 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -187,7 +187,10 @@ def _create_buttons(self) -> None: def _create_tree_window(self) -> None: self.create_search(self._body_container) - with dpg.child_window(tag=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, horizontal_scrollbar=True): + with dpg.child_window( + tag=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, + horizontal_scrollbar=True, + ): with dpg.group(tag=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE): with dpg.tree_node( label=self._lbl_reconstructions, @@ -250,8 +253,14 @@ def _build_tree_node( state.parent = node_tag def set_tree_enabled(self, enabled: bool) -> None: - dpg_configure_item(TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE, enabled=enabled) - dpg_configure_item(TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, enabled=enabled) + dpg_configure_item( + TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE, + enabled=enabled, + ) + dpg_configure_item( + TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, + enabled=enabled, + ) def _reconstruct_file(self) -> None: self.call(self.on_reconstruct_file) @@ -309,7 +318,11 @@ def _show_directory_context_menu(self, node: FileSystemNode) -> None: self._add_context_menu_remove_directory_item(node) self._add_context_menu_favorite_item(node) - def _show_reconstruction_context_menu(self, node: FileSystemNode, node_tag: str) -> None: + def _show_reconstruction_context_menu( + self, + node: FileSystemNode, + node_tag: str, + ) -> None: if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: return @@ -323,7 +336,10 @@ def _show_reconstruction_context_menu(self, node: FileSystemNode, node_tag: str) self._add_context_menu_locate_audio_item(node) self._add_context_menu_favorite_item(node) - def _add_context_menu_load_reconstruction_item(self, node: FileSystemNode) -> None: + def _add_context_menu_load_reconstruction_item( + self, + node: FileSystemNode, + ) -> None: dpg.add_separator() dpg.add_menu_item( label=self._lbl_context_load, @@ -331,20 +347,37 @@ def _add_context_menu_load_reconstruction_item(self, node: FileSystemNode) -> No user_data=node, ) - def _add_context_menu_remove_reconstruction_item(self, node: FileSystemNode) -> None: + def _add_context_menu_remove_reconstruction_item( + self, + node: FileSystemNode, + ) -> None: dpg.add_menu_item( label=self._lbl_context_remove_reconstruction, - callback=lambda: self.call(self.on_reconstruction_remove_requested, node.filepath), + callback=lambda: self.call( + self.on_reconstruction_remove_requested, + node.filepath, + ), ) - def _add_context_menu_remove_directory_item(self, node: FileSystemNode) -> None: + def _add_context_menu_remove_directory_item( + self, + node: FileSystemNode, + ) -> None: dpg.add_separator() dpg.add_menu_item( label=self._lbl_context_remove_directory, - callback=lambda: self.call(self.on_directory_remove_requested, node.filepath), + callback=lambda: self.call( + self.on_directory_remove_requested, + node.filepath, + ), ) - def _on_load_reconstruction(self, sender: Sender, app_data: Path, user_data: FileSystemNode) -> None: + def _on_load_reconstruction( + self, + sender: Sender, + app_data: Path, + user_data: FileSystemNode, + ) -> None: self._load_reconstruction(user_data) def _load_reconstruction(self, node: FileSystemNode) -> None: diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index b624d793..4aaa4ad4 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Any, Callable, Dict, Optional, Tuple, cast +from typing import Any, Callable, Dict, List, Optional, Tuple, cast import dearpygui.dearpygui as dpg import numpy as np @@ -205,8 +205,16 @@ def __init__( TextType.TEMPLATE, ReconstructionsInstrumentsElements.INITIAL_PITCH_TOOLTIP_TEMPLATE, ] - self._pitch_tooltip = build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, tooltip_template) - self._period_tooltip = build_pitch_tooltip(language_manager, PERIOD_VALUE_KIND, tooltip_template) + self._pitch_tooltip = build_pitch_tooltip( + language_manager, + PITCH_VALUE_KIND, + tooltip_template, + ) + self._period_tooltip = build_pitch_tooltip( + language_manager, + PERIOD_VALUE_KIND, + tooltip_template, + ) self._msg_reconstruction_no_data = language_manager[ Page.GLOBAL, Panel.DIALOG, @@ -292,23 +300,40 @@ def _get_generator_tab_tag(self, generator_name: GeneratorName) -> str: def _get_window_tag(self, tab_tag: str) -> str: return f"{tab_tag}{SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW}" - def _get_feature_group_tag(self, generator_name: GeneratorName, feature_key: FeatureKey) -> str: + def _get_feature_group_tag( + self, + generator_name: GeneratorName, + feature_key: FeatureKey, + ) -> str: return f"{self.tab_bar_tag}{TAG_SEPARATOR}{generator_name}{TAG_SEPARATOR}{feature_key}{SUF_GROUP}" - def _get_feature_text_group_tag(self, generator_name: GeneratorName, feature_key: FeatureKey) -> str: + def _get_feature_text_group_tag( + self, + generator_name: GeneratorName, + feature_key: FeatureKey, + ) -> str: return f"{self.tab_bar_tag}{TAG_SEPARATOR}{generator_name}{TAG_SEPARATOR}{feature_key}{SUF_GRAPH_RAW_DATA}" def _get_feature_text_tag(self, text_group_tag: str) -> str: return f"{text_group_tag}{SUF_TEXT}" - def _get_feature_plot_tag(self, generator_name: GeneratorName, feature_key: FeatureKey) -> str: + def _get_feature_plot_tag( + self, + generator_name: GeneratorName, + feature_key: FeatureKey, + ) -> str: return f"{self.tab_bar_tag}{TAG_SEPARATOR}{generator_name}{TAG_SEPARATOR}{feature_key}{SUF_GRAPH}" def _setup_mouse_event_handler(self) -> None: with dpg.handler_registry(tag=self.mouse_item_handler_tag): dpg.add_mouse_move_handler(callback=self._on_mouse_move) - def _handle_export_button_clicked(self, sender: Sender, app_data: Any, user_data: GeneratorName) -> None: + def _handle_export_button_clicked( + self, + sender: Sender, + app_data: Any, + user_data: GeneratorName, + ) -> None: self.call(self.on_instrument_export, user_data) def _create_tabs_for_generators(self) -> None: @@ -318,7 +343,10 @@ def _create_tabs_for_generators(self) -> None: def _generator_kind(self, generator_name: GeneratorName) -> LibraryGeneratorName: return GENERATOR_KIND[generator_name] - def _generator_features(self, generator_name: GeneratorName) -> list[FeatureKey]: + def _generator_features( + self, + generator_name: GeneratorName, + ) -> List[FeatureKey]: return supported_features(self._generator_kind(generator_name)) def _feature_plot_config(self, generator_name: GeneratorName, feature_key: FeatureKey) -> FeaturePlotConfig: @@ -360,7 +388,11 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None: ThemeRegistry.get(TAG_GLOBAL_THEME_INSTRUMENT_TABS).bind_to_item(tab_tag) - def _create_generator_content(self, generator_name: GeneratorName, window_tag: str) -> None: + def _create_generator_content( + self, + generator_name: GeneratorName, + window_tag: str, + ) -> None: initial_pitch = self._default_initial_pitch(generator_name) self._create_pitch_stepper(generator_name, initial_pitch, window_tag) self._create_generator_feature_displays(generator_name, window_tag) @@ -368,9 +400,17 @@ def _create_generator_content(self, generator_name: GeneratorName, window_tag: s def _default_initial_pitch(self, generator_name: GeneratorName) -> int: return MAX_PERIOD if generator_name == GeneratorName.NOISE else MIN_PITCH - def _create_generator_feature_displays(self, generator_name: GeneratorName, window_tag: str) -> None: + def _create_generator_feature_displays( + self, + generator_name: GeneratorName, + window_tag: str, + ) -> None: for feature_key in self._generator_features(generator_name): - self._add_generator_feature_display(generator_name, feature_key, window_tag) + self._add_generator_feature_display( + generator_name, + feature_key, + window_tag, + ) def _add_generator_feature_display( self, @@ -378,7 +418,10 @@ def _add_generator_feature_display( feature_key: FeatureKey, window_tag: str, ) -> None: - feature_group_tag = self._get_feature_group_tag(generator_name, feature_key) + feature_group_tag = self._get_feature_group_tag( + generator_name, + feature_key, + ) with dpg.group( tag=feature_group_tag, parent=window_tag, @@ -431,7 +474,10 @@ def _update_raw_data_text( feature_key: FeatureKey, data: np.ndarray, ) -> None: - text_group_tag = self._get_feature_text_group_tag(generator_name, feature_key) + text_group_tag = self._get_feature_text_group_tag( + generator_name, + feature_key, + ) raw_data_tag = self._get_feature_text_tag(text_group_tag) raw_data_text = self._format_data(data) dpg_set_value(raw_data_tag, raw_data_text) @@ -462,7 +508,10 @@ def update_feature_data( if generator_features is None: continue - self._update_generator_feature_data(generator_name, generator_features) + self._update_generator_feature_data( + generator_name, + generator_features, + ) def _update_generator_feature_data( self, diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py index 536bef06..3a3ea88e 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -164,7 +164,11 @@ def update_view(self, view_model: ReconstructionViewModel) -> None: for generator_name in GeneratorName: tag = self._get_generator_checkbox_tag(generator_name) is_available = generator_name in view_model.available_generators - dpg_configure_item(tag, enabled=is_available, default_value=is_available) + dpg_configure_item( + tag, + enabled=is_available, + default_value=is_available, + ) dpg_set_value(tag, is_available) if is_available: ThemeRegistry.get(_GENERATOR_THEME_TAGS[generator_name]).bind_to_item(tag) @@ -261,7 +265,10 @@ def _create_generator_checkboxes(self) -> None: def _create_tooltips(self) -> None: show_tooltip(self.autoscale_tag, self._tooltip_autoscale) - def _create_message_function_for_generator_checkbox(self, generator_name: GeneratorName) -> MessageCallback: + def _create_message_function_for_generator_checkbox( + self, + generator_name: GeneratorName, + ) -> MessageCallback: tag = self._get_generator_checkbox_tag(generator_name) name = generator_name.capitalized diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index 86f9c460..09348d1e 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -156,7 +156,10 @@ def _create_buttons(self) -> None: def _create_tree_window(self) -> None: self.create_search(self._body_container) - with dpg.child_window(tag=TAG_SEQUENCER_BROWSER_WINDOW_TREE, horizontal_scrollbar=True): + with dpg.child_window( + tag=TAG_SEQUENCER_BROWSER_WINDOW_TREE, + horizontal_scrollbar=True, + ): with dpg.group(tag=TAG_SEQUENCER_BROWSER_GROUP_TREE): with dpg.tree_node( label=self._lbl_reconstructions, @@ -220,7 +223,10 @@ def _build_tree_node( def set_tree_enabled(self, enabled: bool) -> None: dpg_configure_item(TAG_SEQUENCER_BROWSER_GROUP_TREE, enabled=enabled) - dpg_configure_item(TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, enabled=enabled) + dpg_configure_item( + TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, + enabled=enabled, + ) def _on_directory_node_clicked( self, @@ -271,7 +277,11 @@ def _show_directory_context_menu(self, node: FileSystemNode) -> None: self._add_context_menu_path_items(node.filepath) self._add_context_menu_favorite_item(node) - def _show_reconstruction_context_menu(self, node: FileSystemNode, node_tag: str) -> None: + def _show_reconstruction_context_menu( + self, + node: FileSystemNode, + node_tag: str, + ) -> None: if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: return diff --git a/src/sampletones_application/ui/panels/sequencer/channels.py b/src/sampletones_application/ui/panels/sequencer/channels.py index a917c544..a491c76e 100644 --- a/src/sampletones_application/ui/panels/sequencer/channels.py +++ b/src/sampletones_application/ui/panels/sequencer/channels.py @@ -19,7 +19,6 @@ OnChannelCallback = Callable[[GeneratorName], None] NOTHING_MUTED: Final[SequencerChannelsViewModel] = SequencerChannelsViewModel(muted=frozenset()) -"""The mix a channel name reads before the first model arrives: every channel audible.""" class ChannelMenuLabels(BaseModel, extra="forbid", frozen=True): diff --git a/src/sampletones_application/ui/panels/sequencer/grid.py b/src/sampletones_application/ui/panels/sequencer/grid.py index bdffa337..ba5ced92 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid.py +++ b/src/sampletones_application/ui/panels/sequencer/grid.py @@ -156,7 +156,10 @@ def __init__( self.pattern_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_PATTERN) - self._lbl_tracker = self._label(language_manager, SequencerGridElements.TRACKER_TEXT) + self._lbl_tracker = self._label( + language_manager, + SequencerGridElements.TRACKER_TEXT, + ) self._load_column_labels(language_manager) self._load_context_labels(language_manager) self._load_header_tooltips(language_manager) @@ -186,8 +189,16 @@ def _load_column_labels(self, language_manager: LanguageManager) -> None: } @staticmethod - def _label(language_manager: LanguageManager, element: SequencerGridElements) -> str: - return language_manager[Page.SEQUENCER, Panel.GRID, TextType.LABEL, element] + def _label( + language_manager: LanguageManager, + element: SequencerGridElements, + ) -> str: + return language_manager[ + Page.SEQUENCER, + Panel.GRID, + TextType.LABEL, + element, + ] def _load_context_labels(self, language_manager: LanguageManager) -> None: def label(element: SequencerGridElements) -> str: @@ -322,7 +333,11 @@ def _create_tracker_view(self, parent: str) -> None: swapped. The swap lands pattern row 0 on the same stripe it takes in every other table, and the header row's own stripe sits under an opaque header shade. """ - with self._collapsible_card(parent, self._lbl_tracker, glyph=self._glyphs.headers.tracker): + with self._collapsible_card( + parent, + self._lbl_tracker, + glyph=self._glyphs.headers.tracker, + ): dpg.add_group(tag=TAG_SEQUENCER_GRID_GROUP_TRACKER) with dpg.child_window( tag=TAG_SEQUENCER_GRID_WINDOW_TRACKER, @@ -901,7 +916,10 @@ def _on_header_right_clicked( self._show_header_context_menu(self._header_columns[clicked_item]) - def _show_header_context_menu(self, generator: Optional[GeneratorName]) -> None: + def _show_header_context_menu( + self, + generator: Optional[GeneratorName], + ) -> None: """Opens the menu behind a column header, titled with the column's own name.""" with context_menu(): header = dpg.add_text(self._column_labels[generator]) @@ -937,7 +955,9 @@ def _show_context_menu( subcolumn: SubColumn, ) -> None: with context_menu(): - header = dpg.add_text(tracker_display.indexed_label(row_index, self._column_labels[generator])) + header = dpg.add_text( + tracker_display.indexed_label(row_index, self._column_labels[generator]), + ) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() add_play_menu_item( diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py index 7a1647ee..c7432464 100644 --- a/src/sampletones_application/ui/panels/sequencer/history.py +++ b/src/sampletones_application/ui/panels/sequencer/history.py @@ -130,7 +130,10 @@ def __init__( super().__init__( tag=TAG_SEQUENCER_HISTORY_PANEL, ) - self._enable_vertical_collapse(initial_collapsed=initial_collapsed, fill=True) + self._enable_vertical_collapse( + initial_collapsed=initial_collapsed, + fill=True, + ) def create_panel(self, parent: str) -> None: with self._collapsible_card( @@ -173,8 +176,14 @@ def _create_actions(self) -> None: callback=self._on_redo_clicked, width=-1, ) - self._status_bar.bind_to_item(TAG_SEQUENCER_HISTORY_BUTTON_UNDO, self._msg_status_undo) - self._status_bar.bind_to_item(TAG_SEQUENCER_HISTORY_BUTTON_REDO, self._msg_status_redo) + self._status_bar.bind_to_item( + TAG_SEQUENCER_HISTORY_BUTTON_UNDO, + self._msg_status_undo, + ) + self._status_bar.bind_to_item( + TAG_SEQUENCER_HISTORY_BUTTON_REDO, + self._msg_status_redo, + ) def update_view(self, view_model: HistoryViewModel) -> None: self._update_actions(view_model) @@ -186,8 +195,14 @@ def update_view(self, view_model: HistoryViewModel) -> None: self._rebuild(window) def _update_actions(self, view_model: HistoryViewModel) -> None: - dpg_configure_item(TAG_SEQUENCER_HISTORY_BUTTON_UNDO, enabled=view_model.can_undo) - dpg_configure_item(TAG_SEQUENCER_HISTORY_BUTTON_REDO, enabled=view_model.can_redo) + dpg_configure_item( + TAG_SEQUENCER_HISTORY_BUTTON_UNDO, + enabled=view_model.can_undo, + ) + dpg_configure_item( + TAG_SEQUENCER_HISTORY_BUTTON_REDO, + enabled=view_model.can_redo, + ) def _window(self, view_model: HistoryViewModel) -> EntryWindow: """Selects the slice of entries rendered around the cursor. @@ -282,7 +297,13 @@ def _create_entry_list(self, window: EntryWindow) -> None: ThemeRegistry.get(TAG_SEQUENCER_HISTORY_THEME_LIST).bind_to_item(table) - def _create_entry(self, table: int, entry: HistoryEntryViewModel, *, before: int) -> None: + def _create_entry( + self, + table: int, + entry: HistoryEntryViewModel, + *, + before: int, + ) -> None: """Renders one entry as a full-width selectable with coloured text on top. A ``span_columns`` selectable backs the whole row, so clicking anywhere @@ -325,7 +346,15 @@ def _fill_entry_texts(self, group: int, entry: HistoryEntryViewModel) -> None: self._add_text(segment.text, parent=group, color=color) def _add_text(self, value: str, *, parent: int, color: Optional[RGBA]) -> None: - text = dpg.add_text(value, parent=parent) if color is None else dpg.add_text(value, parent=parent, color=color) + text = ( + dpg.add_text(value, parent=parent) + if color is None + else dpg.add_text( + value, + parent=parent, + color=color, + ) + ) FontRegistry.bind_to_item(text, Font.MONO_SMALL) def _role_color(self, role: HistoryDetailRole) -> RGBA: @@ -369,5 +398,10 @@ def _on_undo_clicked(self, sender: Sender, app_data: Any) -> None: def _on_redo_clicked(self, sender: Sender, app_data: Any) -> None: self.call(self.on_redo) - def _on_entry_clicked(self, sender: Sender, app_data: Any, user_data: int) -> None: + def _on_entry_clicked( + self, + sender: Sender, + app_data: Any, + user_data: int, + ) -> None: self.call(self.on_jump_to, user_data) diff --git a/src/sampletones_application/ui/panels/sequencer/module.py b/src/sampletones_application/ui/panels/sequencer/module.py index 0e31a51f..68e325b3 100644 --- a/src/sampletones_application/ui/panels/sequencer/module.py +++ b/src/sampletones_application/ui/panels/sequencer/module.py @@ -186,21 +186,44 @@ def _create_module_options(self) -> None: self._on_rows_per_pattern_input, ) show_tooltip(TAG_SEQUENCER_MODULE_INPUT_ROWS, self._tpl_rows_tooltip) - self._status_bar.bind_to_item(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, self._msg_status_input) - self._status_bar.bind_to_item(TAG_SEQUENCER_MODULE_INPUT_ROWS, self._msg_status_input) - self._status_bar.bind_to_item(TAG_SEQUENCER_MODULE_INPUT_TEMPO, self._msg_status_input) - self._status_bar.bind_to_item(TAG_SEQUENCER_MODULE_INPUT_SPEED, self._msg_status_input) + self._status_bar.bind_to_item( + TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, + self._msg_status_input, + ) + self._status_bar.bind_to_item( + TAG_SEQUENCER_MODULE_INPUT_ROWS, + self._msg_status_input, + ) + self._status_bar.bind_to_item( + TAG_SEQUENCER_MODULE_INPUT_TEMPO, + self._msg_status_input, + ) + self._status_bar.bind_to_item( + TAG_SEQUENCER_MODULE_INPUT_SPEED, + self._msg_status_input, + ) def update_settings(self, view_model: SequencerSettingsViewModel) -> None: - dpg.set_value(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, view_model.nes_frequency) - dpg.set_value(TAG_SEQUENCER_MODULE_INPUT_ROWS, view_model.rows_per_pattern) + dpg.set_value( + TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, + view_model.nes_frequency, + ) + dpg.set_value( + TAG_SEQUENCER_MODULE_INPUT_ROWS, + view_model.rows_per_pattern, + ) dpg.set_value(TAG_SEQUENCER_MODULE_INPUT_TEMPO, view_model.tempo) dpg.set_value(TAG_SEQUENCER_MODULE_INPUT_SPEED, view_model.speed) def set_enabled(self, enabled: bool) -> None: dpg_configure_item(TAG_SEQUENCER_MODULE_GROUP_OPTIONS, enabled=enabled) - def _commit_on_finish(self, input_tag: str, handler_tag: str, callback: Callable[[Sender, int], None]) -> None: + def _commit_on_finish( + self, + input_tag: str, + handler_tag: str, + callback: Callable[[Sender, int], None], + ) -> None: """Commits a field only when editing finishes (focus lost or Enter pressed). Used for the fields whose change rebuilds or re-times the song — NES frequency and rows @@ -214,13 +237,25 @@ def _commit_on_finish(self, input_tag: str, handler_tag: str, callback: Callable dpg.bind_item_handler_registry(input_tag, handler_tag) def _on_nes_frequency_input(self, sender: Sender, app_data: int) -> None: - self.call(self.on_nes_frequency, int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY))) + self.call( + self.on_nes_frequency, + int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY)), + ) def _on_rows_per_pattern_input(self, sender: Sender, app_data: int) -> None: - self.call(self.on_rows_per_pattern, int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_ROWS))) + self.call( + self.on_rows_per_pattern, + int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_ROWS)), + ) def _on_tempo_input(self, sender: Sender, app_data: int) -> None: - self.call(self.on_tempo, int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_TEMPO))) + self.call( + self.on_tempo, + int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_TEMPO)), + ) def _on_speed_input(self, sender: Sender, app_data: int) -> None: - self.call(self.on_speed, int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_SPEED))) + self.call( + self.on_speed, + int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_SPEED)), + ) diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index ff51b69f..cb682f66 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -256,7 +256,10 @@ def _create_entry_themes(self) -> None: colors = self._layout.colors self._entry_theme = create_selectable_text_theme(colors.text.order) self._muted_entry_theme = create_selectable_text_theme( - with_alpha_fraction(colors.text.order, self._layout.tracker.muted_text_fraction), + with_alpha_fraction( + colors.text.order, + self._layout.tracker.muted_text_fraction, + ), ) self._label_theme = create_header_selectable_theme( colors.label, @@ -330,7 +333,9 @@ def _follow_frame(self, frame: int) -> None: if cursor.position == frame: return - new_state = OrderInputState(cursor=OrderCursor(cursor.generator, frame)) + new_state = OrderInputState( + cursor=OrderCursor(cursor.generator, frame), + ) if 0 <= frame < self._position_count: self._apply_state(new_state, notify=False) else: @@ -855,12 +860,38 @@ def _show_context_menu(self, position: int) -> None: callback=lambda: self.call(self.on_remove_requested, position), ) dpg.add_separator() - self._add_move_item(self._lbl_context_move_left, self._sc_move_left, position, MoveDirection.PREVIOUS) - self._add_move_item(self._lbl_context_move_right, self._sc_move_right, position, MoveDirection.NEXT) - self._add_move_item(self._lbl_context_move_start, self._sc_move_start, position, MoveDirection.FIRST) - self._add_move_item(self._lbl_context_move_end, self._sc_move_end, position, MoveDirection.LAST) + self._add_move_item( + self._lbl_context_move_left, + self._sc_move_left, + position, + MoveDirection.PREVIOUS, + ) + self._add_move_item( + self._lbl_context_move_right, + self._sc_move_right, + position, + MoveDirection.NEXT, + ) + self._add_move_item( + self._lbl_context_move_start, + self._sc_move_start, + position, + MoveDirection.FIRST, + ) + self._add_move_item( + self._lbl_context_move_end, + self._sc_move_end, + position, + MoveDirection.LAST, + ) - def _add_move_item(self, label: str, shortcut: str, position: int, direction: MoveDirection) -> None: + def _add_move_item( + self, + label: str, + shortcut: str, + position: int, + direction: MoveDirection, + ) -> None: """Adds a move item, greyed out (disabled) when the move would have no effect.""" target = direction.target(position, self._position_count) dpg.add_menu_item( @@ -969,7 +1000,10 @@ def _alt_move_direction(self, key: int) -> Optional[MoveDirection]: def _move_position(self, delta: int) -> None: self._apply_state( - self._committed_state().navigate_position(delta, self._position_count), + self._committed_state().navigate_position( + delta, + self._position_count, + ), ) def _jump_position(self, index: int) -> None: diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index f5ba94a6..5e871545 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -251,9 +251,18 @@ def _build_sample_row(self, position: int, entry: SampleEntryViewModel) -> None: self._build_loop_cell(row_id, entry) if entry.sample_id == self._selected_sample_id: self._selected_row = position - dpg.highlight_table_row(TAG_SEQUENCER_INSTRUMENTS_TABLE, position, color=self._layout.colors.cell_cursor) + dpg.highlight_table_row( + TAG_SEQUENCER_INSTRUMENTS_TABLE, + position, + color=self._layout.colors.cell_cursor, + ) - def _build_id_cell(self, row_id: int | str, position: int, entry: SampleEntryViewModel) -> None: + def _build_id_cell( + self, + row_id: int | str, + position: int, + entry: SampleEntryViewModel, + ) -> None: id_cell = dpg.add_table_cell(parent=row_id) id_selectable = dpg.add_selectable( parent=id_cell, @@ -264,14 +273,24 @@ def _build_id_cell(self, row_id: int | str, position: int, entry: SampleEntryVie FontRegistry.bind_to_item(id_selectable, Font.MONO_SMALL) dpg.bind_item_handler_registry(id_selectable, self._row_handler_tag) - def _build_name_cell(self, row_id: int | str, position: int, entry: SampleEntryViewModel) -> None: + def _build_name_cell( + self, + row_id: int | str, + position: int, + entry: SampleEntryViewModel, + ) -> None: name_cell = dpg.add_table_cell(parent=row_id) if entry.sample_id == self._editing_sample_id: self._build_name_input(name_cell, entry) else: self._build_name_selectable(name_cell, position, entry) - def _build_name_selectable(self, name_cell: int | str, position: int, entry: SampleEntryViewModel) -> None: + def _build_name_selectable( + self, + name_cell: int | str, + position: int, + entry: SampleEntryViewModel, + ) -> None: name_selectable = dpg.add_selectable( parent=name_cell, label=entry.name, @@ -281,7 +300,11 @@ def _build_name_selectable(self, name_cell: int | str, position: int, entry: Sam FontRegistry.bind_to_item(name_selectable, Font.MONO_SMALL) dpg.bind_item_handler_registry(name_selectable, self._row_handler_tag) - def _build_name_input(self, name_cell: int | str, entry: SampleEntryViewModel) -> None: + def _build_name_input( + self, + name_cell: int | str, + entry: SampleEntryViewModel, + ) -> None: name_input = dpg.add_input_text( tag=TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME, parent=name_cell, @@ -293,7 +316,11 @@ def _build_name_input(self, name_cell: int | str, entry: SampleEntryViewModel) - FontRegistry.bind_to_item(name_input, Font.MONO_SMALL) dpg.bind_item_handler_registry(name_input, self._rename_handler_tag) - def _build_loop_cell(self, row_id: int | str, entry: SampleEntryViewModel) -> None: + def _build_loop_cell( + self, + row_id: int | str, + entry: SampleEntryViewModel, + ) -> None: loop_cell = dpg.add_table_cell(parent=row_id) loop_checkbox = dpg.add_checkbox( parent=loop_cell, @@ -303,15 +330,27 @@ def _build_loop_cell(self, row_id: int | str, entry: SampleEntryViewModel) -> No ) FontRegistry.bind_to_item(loop_checkbox, Font.REGULAR_SMALL) - def _on_sample_selected(self, sender: Sender, app_data: bool, user_data: Tuple[int, str]) -> None: + def _on_sample_selected( + self, + sender: Sender, + app_data: bool, + user_data: Tuple[int, str], + ) -> None: position, sample_id = user_data dpg.set_value(sender, False) if self._selected_row is not None: - dpg.unhighlight_table_row(TAG_SEQUENCER_INSTRUMENTS_TABLE, self._selected_row) + dpg.unhighlight_table_row( + TAG_SEQUENCER_INSTRUMENTS_TABLE, + self._selected_row, + ) self._selected_row = position self._selected_sample_id = sample_id - dpg.highlight_table_row(TAG_SEQUENCER_INSTRUMENTS_TABLE, position, color=self._layout.colors.cell_cursor) + dpg.highlight_table_row( + TAG_SEQUENCER_INSTRUMENTS_TABLE, + position, + color=self._layout.colors.cell_cursor, + ) self.call(self.on_sample_selected, sample_id) @property @@ -457,17 +496,30 @@ def _on_rename_enter(self, sender: Sender, app_data: str) -> None: def _on_rename_deactivated(self, sender: Sender, app_data: int) -> None: self._commit_rename() - def _on_loop_toggled(self, sender: Sender, app_data: bool, user_data: str) -> None: + def _on_loop_toggled( + self, + sender: Sender, + app_data: bool, + user_data: str, + ) -> None: self.call(self.on_loop_changed, user_data, app_data) - def _on_sample_double_clicked(self, sender: Sender, app_data: List[int]) -> None: + def _on_sample_double_clicked( + self, + sender: Sender, + app_data: List[int], + ) -> None: clicked_item = app_data[1] user_data = dpg.get_item_user_data(clicked_item) if user_data is not None: _, sample_id = user_data self.call(self.on_sample_edit_requested, sample_id) - def _on_sample_clicked(self, sender: Sender, app_data: Tuple[int, int]) -> None: + def _on_sample_clicked( + self, + sender: Sender, + app_data: Tuple[int, int], + ) -> None: mouse_button, clicked_item = app_data if mouse_button != dpg.mvMouseButton_Right: return @@ -491,7 +543,13 @@ def _show_context_menu(self, position: int, sample_id: str) -> None: header = dpg.add_text(display_sample_label(position, entry.name)) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() - add_play_menu_item(self._lbl_context_play, lambda: self.call(self.on_play_requested, sample_id)) + add_play_menu_item( + self._lbl_context_play, + lambda: self.call( + self.on_play_requested, + sample_id, + ), + ) dpg.add_menu_item( label=self._lbl_context_edit, callback=lambda: self.call(self.on_sample_edit_requested, sample_id), @@ -511,10 +569,34 @@ def _show_context_menu(self, position: int, sample_id: str) -> None: ) dpg.add_separator() count = len(self._entries) - self._add_move_item(self._lbl_context_move_up, sample_id, position, count, MoveDirection.PREVIOUS) - self._add_move_item(self._lbl_context_move_down, sample_id, position, count, MoveDirection.NEXT) - self._add_move_item(self._lbl_context_move_top, sample_id, position, count, MoveDirection.FIRST) - self._add_move_item(self._lbl_context_move_bottom, sample_id, position, count, MoveDirection.LAST) + self._add_move_item( + self._lbl_context_move_up, + sample_id, + position, + count, + MoveDirection.PREVIOUS, + ) + self._add_move_item( + self._lbl_context_move_down, + sample_id, + position, + count, + MoveDirection.NEXT, + ) + self._add_move_item( + self._lbl_context_move_top, + sample_id, + position, + count, + MoveDirection.FIRST, + ) + self._add_move_item( + self._lbl_context_move_bottom, + sample_id, + position, + count, + MoveDirection.LAST, + ) def _add_move_item( self, diff --git a/src/sampletones_application/ui/themes/inline.py b/src/sampletones_application/ui/themes/inline.py index a2db851e..17db911d 100644 --- a/src/sampletones_application/ui/themes/inline.py +++ b/src/sampletones_application/ui/themes/inline.py @@ -41,7 +41,10 @@ def _create_selectable_theme(colors: Dict[int, ColorRGBA]) -> int: """ with dpg.theme() as theme: for enabled_state in (True, False): - with dpg.theme_component(dpg.mvSelectable, enabled_state=enabled_state): + with dpg.theme_component( + dpg.mvSelectable, + enabled_state=enabled_state, + ): for key, color in colors.items(): dpg.add_theme_color( key, diff --git a/src/sampletones_application/ui/themes/theme.py b/src/sampletones_application/ui/themes/theme.py index dd761007..c6abddd3 100644 --- a/src/sampletones_application/ui/themes/theme.py +++ b/src/sampletones_application/ui/themes/theme.py @@ -30,7 +30,12 @@ def _index(items: ThemeItems) -> ThemeDictionary: dictionary: ThemeDictionary = {} for parameter, values in items.items.items(): for item in values: - dictionary[parameter, item.key, item.category, isinstance(item, ThemeStyle)] = item + dictionary[ + parameter, + item.key, + item.category, + isinstance(item, ThemeStyle), + ] = item return dictionary @@ -93,9 +98,16 @@ def get_color( enabled_state: bool = True, category: int = dpg.mvThemeCat_Core, ) -> Optional[Color]: - theme_item = self.get(item_type, key, enabled_state=enabled_state, category=category, is_style=False) + theme_item = self.get( + item_type, + key, + enabled_state=enabled_state, + category=category, + is_style=False, + ) if isinstance(theme_item, ThemeColor): return theme_item.color + return None def get_style( @@ -106,9 +118,16 @@ def get_style( enabled_state: bool = True, category: int = dpg.mvThemeCat_Core, ) -> Optional[Tuple[float, float]]: - theme_item = self.get(item_type, key, enabled_state=enabled_state, category=category, is_style=True) + theme_item = self.get( + item_type, + key, + enabled_state=enabled_state, + category=category, + is_style=True, + ) if isinstance(theme_item, ThemeStyle): return theme_item.x, theme_item.y + return None def get_category( @@ -129,4 +148,5 @@ def get_category( ) if theme_item is not None: return theme_item.category + return None diff --git a/src/sampletones_application/utils/callbacks/task.py b/src/sampletones_application/utils/callbacks/task.py index 74db9fb7..1776f665 100644 --- a/src/sampletones_application/utils/callbacks/task.py +++ b/src/sampletones_application/utils/callbacks/task.py @@ -13,7 +13,7 @@ class CallbackTask(NamedTuple): args: Tuple[Any, ...] kwargs: SerializedData - def __lt__(self, other: object) -> bool: + def __lt__(self, other: Any) -> bool: if not isinstance(other, CallbackTask): return NotImplemented diff --git a/src/sampletones_application/utils/file_dialogs/kdialog.py b/src/sampletones_application/utils/file_dialogs/kdialog.py index 4b0b62f7..ed9faf42 100644 --- a/src/sampletones_application/utils/file_dialogs/kdialog.py +++ b/src/sampletones_application/utils/file_dialogs/kdialog.py @@ -21,7 +21,11 @@ def open_file( initial_directory: Optional[Path], file_filter: Optional[FileFilter], ) -> Optional[Path]: - command = ["kdialog", "--getopenfilename", _start_location(initial_directory)] + command = [ + "kdialog", + "--getopenfilename", + _start_location(initial_directory), + ] command += _filter_arguments(file_filter) command += ["--title", title] return _run(command) @@ -34,7 +38,14 @@ def save_file( suggested_name: Optional[str], file_filter: Optional[FileFilter], ) -> Optional[Path]: - command = ["kdialog", "--getsavefilename", _start_location(initial_directory, suggested_name)] + command = [ + "kdialog", + "--getsavefilename", + _start_location( + initial_directory, + suggested_name, + ), + ] command += _filter_arguments(file_filter) command += ["--title", title] return _run(command) @@ -45,11 +56,20 @@ def select_directory( title: str, initial_directory: Optional[Path], ) -> Optional[Path]: - command = ["kdialog", "--getexistingdirectory", _start_location(initial_directory), "--title", title] + command = [ + "kdialog", + "--getexistingdirectory", + _start_location(initial_directory), + "--title", + title, + ] return _run(command) -def _start_location(initial_directory: Optional[Path], suggested_name: Optional[str] = None) -> str: +def _start_location( + initial_directory: Optional[Path], + suggested_name: Optional[str] = None, +) -> str: base = initial_directory if initial_directory is not None else Path.home() if suggested_name: return str(base / suggested_name) @@ -66,5 +86,10 @@ def _filter_arguments(file_filter: Optional[FileFilter]) -> List[str]: def _run(command: List[str]) -> Optional[Path]: - result = subprocess.run(command, capture_output=True, text=True, check=False) + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) return normalize_path(result.stdout.strip()) diff --git a/src/sampletones_application/utils/file_dialogs/result.py b/src/sampletones_application/utils/file_dialogs/result.py index 932cad69..590c4b21 100644 --- a/src/sampletones_application/utils/file_dialogs/result.py +++ b/src/sampletones_application/utils/file_dialogs/result.py @@ -1,6 +1,15 @@ from functools import wraps from pathlib import Path -from typing import Callable, Concatenate, Optional, ParamSpec, TypeVar, Union, cast, overload +from typing import ( + Callable, + Concatenate, + Optional, + ParamSpec, + TypeVar, + Union, + cast, + overload, +) P = ParamSpec("P") T = TypeVar("T") diff --git a/src/sampletones_application/utils/file_dialogs/tkinter_backend.py b/src/sampletones_application/utils/file_dialogs/tkinter_backend.py index ae59dfc7..f158666e 100644 --- a/src/sampletones_application/utils/file_dialogs/tkinter_backend.py +++ b/src/sampletones_application/utils/file_dialogs/tkinter_backend.py @@ -66,7 +66,9 @@ def _initial_directory(initial_directory: Optional[Path]) -> Optional[str]: return str(initial_directory) if initial_directory is not None else None -def _filetypes(file_filter: Optional[FileFilter]) -> List[Tuple[str, Tuple[str, ...]]]: +def _filetypes( + file_filter: Optional[FileFilter], +) -> List[Tuple[str, Tuple[str, ...]]]: if file_filter is None: return [] diff --git a/src/sampletones_application/utils/file_dialogs/zenity.py b/src/sampletones_application/utils/file_dialogs/zenity.py index a2e59684..80ebfa44 100644 --- a/src/sampletones_application/utils/file_dialogs/zenity.py +++ b/src/sampletones_application/utils/file_dialogs/zenity.py @@ -36,7 +36,14 @@ def save_file( suggested_name: Optional[str], file_filter: Optional[FileFilter], ) -> Optional[Path]: - command = ["zenity", "--file-selection", "--save", "--confirm-overwrite", "--title", title] + command = [ + "zenity", + "--file-selection", + "--save", + "--confirm-overwrite", + "--title", + title, + ] command += _filename_arguments(initial_directory, suggested_name) command += _filter_arguments(file_filter) return _run(command) @@ -47,12 +54,21 @@ def select_directory( title: str, initial_directory: Optional[Path], ) -> Optional[Path]: - command = ["zenity", "--file-selection", "--directory", "--title", title] + command = [ + "zenity", + "--file-selection", + "--directory", + "--title", + title, + ] command += _filename_arguments(initial_directory, None) return _run(command) -def _filename_arguments(initial_directory: Optional[Path], suggested_name: Optional[str]) -> List[str]: +def _filename_arguments( + initial_directory: Optional[Path], + suggested_name: Optional[str], +) -> List[str]: if initial_directory is None and not suggested_name: return [] @@ -72,5 +88,10 @@ def _filter_arguments(file_filter: Optional[FileFilter]) -> List[str]: def _run(command: List[str]) -> Optional[Path]: - result = subprocess.run(command, capture_output=True, text=True, check=False) + result = subprocess.run( + command, + capture_output=True, + text=True, + check=False, + ) return normalize_path(result.stdout.strip()) diff --git a/src/sampletones_application/utils/gui/dialogs.py b/src/sampletones_application/utils/gui/dialogs.py index 2b610357..55bf1d7f 100644 --- a/src/sampletones_application/utils/gui/dialogs.py +++ b/src/sampletones_application/utils/gui/dialogs.py @@ -372,7 +372,12 @@ def content(parent: str) -> None: modal=False, ) - def _render_template_bold(self, parent: str, template: str, substitutions: Dict[str, str]) -> None: + def _render_template_bold( + self, + parent: str, + template: str, + substitutions: Dict[str, str], + ) -> None: """ Renders a placeholder template on one line with the substituted values in bold. @@ -753,7 +758,12 @@ def content(parent: str) -> None: modal=False, ) - def show_message_with_path(self, title: str, message: str, path: Path) -> None: + def show_message_with_path( + self, + title: str, + message: str, + path: Path, + ) -> None: tag = get_dialog_tag(TAG_GLOBAL_DIALOG_PATH_MESSAGE) def content(parent: str) -> None: diff --git a/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py index 6c661318..a8ff4820 100644 --- a/src/sampletones_application/utils/gui/dpg.py +++ b/src/sampletones_application/utils/gui/dpg.py @@ -59,12 +59,23 @@ def dpg_delete_children(tag: Sender, /, *args: Any, **kwargs: Any) -> None: dpg_delete_item(tag, children_only=True, **kwargs) -def dpg_bind_item_theme(tag: Sender, theme_tag: Sender, /, *args: Any, **kwargs: Any) -> None: +def dpg_bind_item_theme( + tag: Sender, + theme_tag: Sender, + /, + *args: Any, + **kwargs: Any, +) -> None: if dpg.does_item_exist(tag) and dpg.does_item_exist(theme_tag): dpg.bind_item_theme(tag, theme_tag, *args, **kwargs) -def dpg_get_item_parent(tag: Sender, /, *args: Any, **kwargs: Any) -> Optional[Sender]: +def dpg_get_item_parent( + tag: Sender, + /, + *args: Any, + **kwargs: Any, +) -> Optional[Sender]: """The item's parent, or None when the item is absent. Queued callbacks mutate the item tree on the callback-queue thread, so an item read @@ -85,23 +96,46 @@ def dpg_configure_item(tag: Sender, /, *args: Any, **kwargs: Any) -> None: @dpg_wrapper(button_function=GUIButton.set_item_callback) -def dpg_set_item_callback(tag: Sender, callback: Callback, /, *args: Any, **kwargs: Any) -> None: +def dpg_set_item_callback( + tag: Sender, + callback: Callback, + /, + *args: Any, + **kwargs: Any, +) -> None: dpg.set_item_callback(tag, callback=callback, *args, **kwargs) @dpg_wrapper(button_function=GUIButton.set_item_label) -def dpg_set_item_label(tag: Sender, /, label: str, *args: Any, **kwargs: Any) -> None: +def dpg_set_item_label( + tag: Sender, + /, + label: str, + *args: Any, + **kwargs: Any, +) -> None: dpg.set_item_label(tag, label=label, *args, **kwargs) @dpg_wrapper(button_function=GUIButton.get_item_label) -def dpg_get_item_label(tag: Sender, /, *args: Any, **kwargs: Any) -> Optional[str]: +def dpg_get_item_label( + tag: Sender, + /, + *args: Any, + **kwargs: Any, +) -> Optional[str]: item_label: Optional[str] = dpg.get_item_label(tag, *args, **kwargs) return item_label @dpg_wrapper(button_function=GUIButton.set_value) -def dpg_set_value(tag: Sender, value: Any, /, *args: Any, **kwargs: Any) -> None: +def dpg_set_value( + tag: Sender, + value: Any, + /, + *args: Any, + **kwargs: Any, +) -> None: dpg.set_value(tag, value, *args, **kwargs) @@ -111,6 +145,11 @@ def dpg_get_value(tag: Sender, /, *args: Any, **kwargs: Any) -> Any: @dpg_wrapper(button_function=GUIButton.is_item_hovered) -def dpg_is_item_hovered(tag: Sender, /, *args: Any, **kwargs: Any) -> Optional[bool]: +def dpg_is_item_hovered( + tag: Sender, + /, + *args: Any, + **kwargs: Any, +) -> Optional[bool]: is_hovered: Optional[bool] = dpg.is_item_hovered(tag, *args, **kwargs) return is_hovered diff --git a/src/sampletones_application/utils/gui/keyboard/focus/consumption.py b/src/sampletones_application/utils/gui/keyboard/focus/consumption.py index be31e24b..c684429c 100644 --- a/src/sampletones_application/utils/gui/keyboard/focus/consumption.py +++ b/src/sampletones_application/utils/gui/keyboard/focus/consumption.py @@ -73,7 +73,10 @@ def field_consumes_key(kind: FieldKind, key: int, modifiers: ModifierSet) -> boo return False if Modifier.CTRL in modifiers: - return kind is FieldKind.TEXT_ENTRY and key in TEXT_EDIT_CHORDS.get(modifiers, NO_KEYS) + return kind is FieldKind.TEXT_ENTRY and key in TEXT_EDIT_CHORDS.get( + modifiers, + NO_KEYS, + ) if key in EDITING_KEYS: return True diff --git a/src/sampletones_application/utils/gui/keyboard/router.py b/src/sampletones_application/utils/gui/keyboard/router.py index 8741fce4..f9837e73 100644 --- a/src/sampletones_application/utils/gui/keyboard/router.py +++ b/src/sampletones_application/utils/gui/keyboard/router.py @@ -41,7 +41,11 @@ def __init__(self) -> None: self._scopes: List[_Scope] = [] self._modal_stack: List[ModalKeyHandler] = [] self._bound: bool = False - self.register(self._route_modal, priority=PRIORITY_MODAL, active=lambda: self.is_modal_open) + self.register( + self._route_modal, + priority=PRIORITY_MODAL, + active=lambda: self.is_modal_open, + ) def bind(self) -> None: """Installs the one global key-press handler, once, after the context exists.""" @@ -60,7 +64,13 @@ def register( active: ActivePredicate, ) -> None: """Adds a scope, keeping the scopes ordered from highest priority to lowest.""" - self._scopes.append(_Scope(priority=priority, active=active, handle=handle)) + self._scopes.append( + _Scope( + priority=priority, + active=active, + handle=handle, + ) + ) self._scopes.sort(key=lambda scope: scope.priority, reverse=True) @property diff --git a/src/sampletones_application/utils/gui/shortcuts/manager.py b/src/sampletones_application/utils/gui/shortcuts/manager.py index 94220697..c5cb4a34 100644 --- a/src/sampletones_application/utils/gui/shortcuts/manager.py +++ b/src/sampletones_application/utils/gui/shortcuts/manager.py @@ -28,7 +28,11 @@ def register( ) -> None: self._shortcuts[shortcut_id] = (shortcut, callback) - def register_alias(self, shortcut_id: ShortcutId, shortcut: Shortcut) -> None: + def register_alias( + self, + shortcut_id: ShortcutId, + shortcut: Shortcut, + ) -> None: """Binds an additional key combination to an already registered action. The primary shortcut keeps the action's display string in menus and @@ -67,7 +71,12 @@ def _add_binding(self, shortcut: Shortcut, callback: Callback) -> None: if shortcut.key is None: return - self._bindings_by_key.setdefault(shortcut.key, []).append((shortcut, callback)) + self._bindings_by_key.setdefault(shortcut.key, []).append( + ( + shortcut, + callback, + ) + ) def _dispatch(self, event: KeyEvent) -> bool: """Fires the shortcut matching the event, yielding its key to a focused field that acts on @@ -88,4 +97,8 @@ def _dispatch(self, event: KeyEvent) -> bool: @staticmethod def _field_consumes(event: KeyEvent) -> bool: - return focus.field_consumes_key(focus.focused_field_kind(), event.key, event.modifiers) + return focus.field_consumes_key( + focus.focused_field_kind(), + event.key, + event.modifiers, + ) diff --git a/src/sampletones_application/utils/palette.py b/src/sampletones_application/utils/palette.py index f3e531f7..51aad154 100644 --- a/src/sampletones_application/utils/palette.py +++ b/src/sampletones_application/utils/palette.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import Annotated, Dict, Final, Mapping, Optional, Union +from typing import Annotated, Any, Dict, Final, Mapping, Optional, Union from pydantic import BaseModel, BeforeValidator, Field, ValidationInfo, model_validator @@ -28,7 +28,7 @@ class PaletteReference(BaseModel, frozen=True): @model_validator(mode="before") @classmethod - def _from_string(cls, value: object) -> object: + def _from_string(cls, value: Any) -> object: if isinstance(value, str): return _parse_reference(value) @@ -121,7 +121,7 @@ def _palette_from_context(info: ValidationInfo) -> Palette: return palette -def _resolve_palette_color(value: object, info: ValidationInfo) -> object: +def _resolve_palette_color(value: Any, info: ValidationInfo) -> object: if isinstance(value, str): text = value.strip() if text.startswith(REFERENCE_PREFIX): diff --git a/src/sampletones_application/utils/parallelization/thread.py b/src/sampletones_application/utils/parallelization/thread.py index 84264183..1f4fe32b 100644 --- a/src/sampletones_application/utils/parallelization/thread.py +++ b/src/sampletones_application/utils/parallelization/thread.py @@ -44,7 +44,9 @@ def run_and_release() -> None: target() finally: with SingleThreadExecutor._live_threads_lock: - SingleThreadExecutor._live_threads.discard(threading.current_thread()) + SingleThreadExecutor._live_threads.discard( + threading.current_thread(), + ) with self._lock: thread = threading.Thread( diff --git a/src/sampletones_application/viewport.py b/src/sampletones_application/viewport.py index dc22b26a..7b378415 100644 --- a/src/sampletones_application/viewport.py +++ b/src/sampletones_application/viewport.py @@ -149,8 +149,14 @@ def _fit_window_to_monitor( margin_x = (screen_w - usable_w) // 2 margin_y = (screen_h - usable_h) // 2 - fitted_x = max(screen_x + margin_x, min(x, screen_x + screen_w - margin_x - fitted_width)) - fitted_y = max(screen_y + margin_y, min(y, screen_y + screen_h - margin_y - fitted_height)) + fitted_x = max( + screen_x + margin_x, + min(x, screen_x + screen_w - margin_x - fitted_width), + ) + fitted_y = max( + screen_y + margin_y, + min(y, screen_y + screen_h - margin_y - fitted_height), + ) return fitted_x, fitted_y, fitted_width, fitted_height diff --git a/src/sampletones_core/project/instruments/sample.py b/src/sampletones_core/project/instruments/sample.py index 672abb09..1243d65c 100644 --- a/src/sampletones_core/project/instruments/sample.py +++ b/src/sampletones_core/project/instruments/sample.py @@ -1,4 +1,4 @@ -from typing import Self +from typing import Any, Self from uuid import uuid4 from sampletones_core.reconstructions import Reconstruction @@ -32,7 +32,7 @@ def clone(self) -> Self: def __hash__(self) -> int: return hash(self.id) - def __eq__(self, other: object) -> bool: + def __eq__(self, other: Any) -> bool: return isinstance(other, Sample) and self.id == other.id def __repr__(self) -> str: diff --git a/src/sampletones_core/structures/collection/bidirectional.py b/src/sampletones_core/structures/collection/bidirectional.py index 50f1d145..11eb4cee 100644 --- a/src/sampletones_core/structures/collection/bidirectional.py +++ b/src/sampletones_core/structures/collection/bidirectional.py @@ -1,7 +1,7 @@ from __future__ import annotations from collections.abc import Hashable, ItemsView, KeysView, ValuesView -from typing import Dict, Generic, Iterator, Optional, TypeVar, Union, cast +from typing import Any, Dict, Generic, Iterator, Optional, TypeVar, Union, cast ValueT = TypeVar("ValueT", bound=Hashable) BidirectionalMapping = Union[ @@ -129,7 +129,7 @@ def __delitem__(self, key_or_value: Union[str, ValueT]) -> None: string = self._backward.pop(key_or_value) del self._forward[string] - def __eq__(self, other: object) -> bool: + def __eq__(self, other: Any) -> bool: """ Checks equality between this BidirectionalHashMap and another object. diff --git a/src/sampletones_core/structures/collection/indexed.py b/src/sampletones_core/structures/collection/indexed.py index 0103537d..ae29387d 100644 --- a/src/sampletones_core/structures/collection/indexed.py +++ b/src/sampletones_core/structures/collection/indexed.py @@ -214,7 +214,7 @@ def __bool__(self) -> bool: """ return len(self._order) > 0 - def __eq__(self, value: object) -> bool: + def __eq__(self, value: Any) -> bool: """ Checks equality between this collection and another object. diff --git a/src/sampletones_core/structures/histogram/histogram.py b/src/sampletones_core/structures/histogram/histogram.py index 22b78a5e..bfc0b7cf 100644 --- a/src/sampletones_core/structures/histogram/histogram.py +++ b/src/sampletones_core/structures/histogram/histogram.py @@ -4,6 +4,7 @@ from functools import cached_property, reduce from types import ModuleType from typing import ( + Any, Dict, Iterator, List, @@ -167,7 +168,7 @@ def _validate(self) -> Histogram: return self - def __eq__(self, other: object) -> bool: + def __eq__(self, other: Any) -> bool: """ Check equality with another histogram. diff --git a/src/sampletones_shared/utils/color.py b/src/sampletones_shared/utils/color.py index 0b332450..35ba9f78 100644 --- a/src/sampletones_shared/utils/color.py +++ b/src/sampletones_shared/utils/color.py @@ -1,4 +1,4 @@ -from typing import Annotated, Final +from typing import Annotated, Any, Final import numpy as np from pydantic import BeforeValidator @@ -80,7 +80,7 @@ def parse_hex_color(value: str) -> ColorRGBA: return (r, g, b, a) -def _rgba_validator(value: object) -> ColorRGBA: +def _rgba_validator(value: Any) -> ColorRGBA: if isinstance(value, str): return parse_hex_color(value) diff --git a/tests/suite/dummy.py b/tests/suite/dummy.py index 77629777..4f2d72a1 100644 --- a/tests/suite/dummy.py +++ b/tests/suite/dummy.py @@ -14,7 +14,7 @@ def __init__(self, value: int) -> None: def __hash__(self) -> int: return hash(self.value) - def __eq__(self, other: object) -> bool: + def __eq__(self, other: Any) -> bool: if not isinstance(other, ValueObject): return False @@ -32,7 +32,7 @@ def __init__(self, value: int) -> None: def __hash__(self) -> int: return 0 - def __eq__(self, other: object) -> bool: + def __eq__(self, other: Any) -> bool: if not isinstance(other, CollisionObject): return False diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py index 039893e4..d3347dab 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import Any from unittest.mock import MagicMock, patch import pytest @@ -73,7 +74,7 @@ def test_conversion_driven_generation_stays_silent(self) -> None: coordinator._dialogs.show_info.assert_not_called() -def _remove_library_coordinator(*, current_library_key: object | None) -> InstructionsTabCoordinator: +def _remove_library_coordinator(*, current_library_key: Any) -> InstructionsTabCoordinator: coordinator = InstructionsTabCoordinator.__new__(InstructionsTabCoordinator) coordinator._library_logic = MagicMock() coordinator._library_logic.current_library_key = current_library_key diff --git a/tests/unit/sampletones_application/coordinators/test_config.py b/tests/unit/sampletones_application/coordinators/test_config.py index c4cb1244..9755cd9e 100644 --- a/tests/unit/sampletones_application/coordinators/test_config.py +++ b/tests/unit/sampletones_application/coordinators/test_config.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from pathlib import Path +from typing import Any from unittest.mock import MagicMock import pytest @@ -22,7 +23,7 @@ def _coordinator(config_manager: MagicMock) -> ConfigCoordinator: ) -def _manager_with(*outcomes: object, config_path: Path = Path("config.json")) -> MagicMock: +def _manager_with(*outcomes: Any, config_path: Path = Path("config.json")) -> MagicMock: config_manager = MagicMock() config_manager.config_path = config_path config_manager.pending_load_outcomes = list(outcomes) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index de0f8e61..6bf15c98 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -237,7 +237,7 @@ def test_close_fires_on_view_changed_with_not_loaded( self, panel_logic: ReconstructionPanelLogic, ) -> None: - received: list = [] + received = [] panel_logic.on_view_changed = lambda vm: received.append(vm) panel_logic.close_reconstruction() assert len(received) == 1 @@ -247,7 +247,7 @@ def test_close_fires_on_audio_data_changed_with_none( self, panel_logic: ReconstructionPanelLogic, ) -> None: - received: list = [] + received = [] panel_logic.on_audio_data_changed = received.append panel_logic.close_reconstruction() assert received == [None] @@ -506,7 +506,7 @@ def test_set_audio_source_with_no_data_emits_none( self, panel_logic: ReconstructionPanelLogic, ) -> None: - received: list = [] + received = [] panel_logic.on_audio_data_changed = received.append panel_logic.set_audio_source(AudioSourceType.ORIGINAL) assert received == [None] diff --git a/tests/unit/sampletones_application/services/song_player/test_song_player.py b/tests/unit/sampletones_application/services/song_player/test_song_player.py index 71506b06..e01cfcbc 100644 --- a/tests/unit/sampletones_application/services/song_player/test_song_player.py +++ b/tests/unit/sampletones_application/services/song_player/test_song_player.py @@ -169,8 +169,8 @@ def test_subscribe_receives_emitted_results(self) -> None: def test_multiple_subscribers_all_receive_result(self) -> None: service = _make_service() - received_a: list = [] - received_b: list = [] + received_a = [] + received_b = [] service.subscribe(received_a.append) service.subscribe(received_b.append) @@ -276,7 +276,7 @@ def test_render_loop_ends_when_finished_without_loop(self) -> None: def test_drain_writes_buffered_rows_then_reports_stopped(self) -> None: service = _make_service() - received: list = [] + received = [] service.subscribe(received.append) service._resume_event.set() @@ -294,7 +294,7 @@ def test_drain_writes_buffered_rows_then_reports_stopped(self) -> None: def test_drain_returns_without_terminal_when_stopping(self) -> None: service = _make_service() - received: list = [] + received = [] service.subscribe(received.append) service._resume_event.set() service._stop_event.set() diff --git a/tests/unit/sampletones_core/generators/test_utils.py b/tests/unit/sampletones_core/generators/test_utils.py index 2b4eee37..c7f5610d 100644 --- a/tests/unit/sampletones_core/generators/test_utils.py +++ b/tests/unit/sampletones_core/generators/test_utils.py @@ -1,3 +1,5 @@ +from typing import Dict + import pytest from sampletones_core.configs import Config @@ -71,17 +73,17 @@ def test_maps_by_class_name(self, config: Config) -> None: class TestGetGeneratorByInstruction: - def test_pulse_instruction_returns_pulse_generator(self, all_generators: dict) -> None: + def test_pulse_instruction_returns_pulse_generator(self, all_generators: Dict) -> None: instruction = PulseInstruction(on=True, pitch=60, volume=15, duty_cycle=0) result = get_generator_by_instruction(instruction, all_generators) assert isinstance(result, PulseGenerator) - def test_noise_instruction_returns_noise_generator(self, all_generators: dict) -> None: + def test_noise_instruction_returns_noise_generator(self, all_generators: Dict) -> None: instruction = NoiseInstruction(on=True, period=3, volume=15, short=False) result = get_generator_by_instruction(instruction, all_generators) assert isinstance(result, NoiseGenerator) - def test_triangle_instruction_returns_triangle_generator(self, all_generators: dict) -> None: + def test_triangle_instruction_returns_triangle_generator(self, all_generators: Dict) -> None: instruction = TriangleInstruction(on=True, pitch=60) result = get_generator_by_instruction(instruction, all_generators) assert isinstance(result, TriangleGenerator) diff --git a/tests/unit/sampletones_core/structures/collection/test_bidirectional.py b/tests/unit/sampletones_core/structures/collection/test_bidirectional.py index 6ae5704a..664c1be2 100644 --- a/tests/unit/sampletones_core/structures/collection/test_bidirectional.py +++ b/tests/unit/sampletones_core/structures/collection/test_bidirectional.py @@ -1,4 +1,4 @@ -from typing import Tuple, Union +from typing import Any, Tuple, Union import pytest @@ -726,7 +726,7 @@ def __init__(self, value: int) -> None: def __hash__(self) -> int: return id(self) - def __eq__(self, other: object) -> bool: + def __eq__(self, other: Any) -> bool: return self is other bidirectional = BidirectionalHashMap[IdentityObject]() diff --git a/tests/unit/sampletones_core/structures/tree/test_traversal.py b/tests/unit/sampletones_core/structures/tree/test_traversal.py index 480bdeaf..735b97e2 100644 --- a/tests/unit/sampletones_core/structures/tree/test_traversal.py +++ b/tests/unit/sampletones_core/structures/tree/test_traversal.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, Optional +from typing import Any, List, Optional import pytest @@ -94,7 +94,7 @@ class TestDFS: def test_kwargs_reach_callback(self, six_node_tree: TreeNode) -> None: received: List[Optional[str]] = [] - def collect(node: TreeNode, **kwargs: object) -> None: + def collect(node: TreeNode, **kwargs: Any) -> None: received.append(kwargs.get("tag")) # type: ignore[arg-type] traverse(TreeTraversal.DFS, method=False)(collect)(six_node_tree, tag="hello") diff --git a/tests/unit/scripts/test_detect_cuda.py b/tests/unit/scripts/test_detect_cuda.py index cc78623f..454f0469 100644 --- a/tests/unit/scripts/test_detect_cuda.py +++ b/tests/unit/scripts/test_detect_cuda.py @@ -78,14 +78,14 @@ class TestCase(BaseRegularTestCase): @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_parse(self, test_case: "TestQueryDriverCudaVersion.TestCase", monkeypatch: pytest.MonkeyPatch) -> None: - def fake_run(command: Sequence[str], **_: object) -> subprocess.CompletedProcess[str]: + def fake_run(command: Sequence[str], **_: Any) -> subprocess.CompletedProcess[str]: return _completed(test_case.output) monkeypatch.setattr(detect_cuda.subprocess, "run", fake_run) assert detect_cuda.query_driver_cuda_version(Path("nvidia-smi")) == test_case.expected def test_falls_back_to_query_flag(self, monkeypatch: pytest.MonkeyPatch) -> None: - def fake_run(command: Sequence[str], **_: object) -> subprocess.CompletedProcess[str]: + def fake_run(command: Sequence[str], **_: Any) -> subprocess.CompletedProcess[str]: output = QUERY_OUTPUT_CUDA11 if "-q" in command else NO_VERSION_OUTPUT return _completed(output) @@ -93,14 +93,14 @@ def fake_run(command: Sequence[str], **_: object) -> subprocess.CompletedProcess assert detect_cuda.query_driver_cuda_version(Path("nvidia-smi")) == (11, 8) def test_missing_executable_keeps_cpu(self, monkeypatch: pytest.MonkeyPatch) -> None: - def fake_run(command: Sequence[str], **_: object) -> subprocess.CompletedProcess[str]: + def fake_run(command: Sequence[str], **_: Any) -> subprocess.CompletedProcess[str]: raise OSError("nvidia-smi is not executable") monkeypatch.setattr(detect_cuda.subprocess, "run", fake_run) assert detect_cuda.query_driver_cuda_version(Path("nvidia-smi")) is None def test_nonzero_return_code_keeps_cpu(self, monkeypatch: pytest.MonkeyPatch) -> None: - def fake_run(command: Sequence[str], **_: object) -> subprocess.CompletedProcess[str]: + def fake_run(command: Sequence[str], **_: Any) -> subprocess.CompletedProcess[str]: return _completed(TABLE_OUTPUT_CUDA12, returncode=9) monkeypatch.setattr(detect_cuda.subprocess, "run", fake_run) From eb018083c08426dadd171d82a9864b24c6025a7d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 31 Jul 2026 12:22:30 +0200 Subject: [PATCH 04/10] Fixed: Makefile for Windows --- Makefile | 72 ++++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 47 insertions(+), 25 deletions(-) diff --git a/Makefile b/Makefile index 23d68a64..3bf722c2 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,18 @@ -.PHONY: pre-commit build release system-deps setup test ftm-samples clean help +.PHONY: help setup install build release system-deps run clean pre-commit test \ + ftm-samples check-import-boundary calibration lint pylint mypy format -UNAME_S := $(shell uname -s 2>/dev/null || echo Windows) +ifeq ($(OS),Windows_NT) +ifeq ($(MSYSTEM),) +UNAME_S := Windows +else +UNAME_S := $(shell uname -s) +endif +else +UNAME_S := $(shell uname -s) +endif ifeq ($(UNAME_S),Windows) - SCRIPTS_DIR := scripts\windows + SCRIPTS_DIR := scripts/windows SCRIPT_EXT := .bat RUN_SCRIPT := BUILD_SCRIPT := install.bat @@ -18,6 +27,18 @@ else PYTHON := python3 endif +ifeq ($(UNAME_S),Windows) +script = $(subst /,\,$(SCRIPTS_DIR)/$(1)$(SCRIPT_EXT)) +else +script = $(RUN_SCRIPT) $(SCRIPTS_DIR)/$(1)$(SCRIPT_EXT) +endif + +ifeq ($(UNAME_S),Windows) +Q := +else +Q := " +endif + BUILD_COMMAND := $(RUN_SCRIPT) $(BUILD_SCRIPT) RELEASE_COMMAND := $(RUN_SCRIPT) $(BUILD_SCRIPT) --release SYSTEM_DEPS_COMMAND := bash scripts/linux/build/dependencies.sh @@ -38,26 +59,26 @@ endif endif help: - @echo "Available targets:" - @echo " make setup - Set up development environment (uv); GPU auto-detected, GPU=0 forces CPU" - @echo " make pre-commit - Install pre-commit hooks" - @echo " make system-deps - Install system packages required to build and run (Debian-based)" - @echo " make build - Compile standalone executable (respects current deployment config)" - @echo " make release - Compile standalone executable with the release deployment config" - @echo " make test - Run unit tests with coverage" - @echo " make ftm-samples - Emit example .ftm files to build/ftm via the integration suite" - @echo " make clean - Remove build artifacts and cache files" - @echo " make lint - Run linting (pylint, mypy)" - @echo " make format - Auto-format code (isort, black)" - @echo " make run - Run SampleToNES application" + @echo $(Q)Available targets:$(Q) + @echo $(Q) make setup - Set up development environment (uv); GPU auto-detected, GPU=0 forces CPU$(Q) + @echo $(Q) make pre-commit - Install pre-commit hooks$(Q) + @echo $(Q) make system-deps - Install system packages required to build and run (Debian-based)$(Q) + @echo $(Q) make build - Compile standalone executable (respects current deployment config)$(Q) + @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 clean - Remove build artifacts and cache files$(Q) + @echo $(Q) make lint - Run linting (pylint, mypy)$(Q) + @echo $(Q) make format - Auto-format code (isort, black)$(Q) + @echo $(Q) make run - Run SampleToNES application$(Q) setup: uv sync --group dev $(if $(GPU_EXTRA),--extra $(GPU_EXTRA),) uv tool install --force $(if $(GPU_EXTRA),".[$(GPU_EXTRA)]",.) install: - make setup - make build + $(MAKE) setup + $(MAKE) build build: $(BUILD_COMMAND) @@ -72,16 +93,17 @@ run: uv run sampletones clean: - $(RUN_SCRIPT) $(SCRIPTS_DIR)/build/clean$(SCRIPT_EXT) + $(call script,build/clean) pre-commit: - $(RUN_SCRIPT) $(SCRIPTS_DIR)/dev/pre_commit$(SCRIPT_EXT) + $(call script,dev/pre_commit) test: - $(RUN_SCRIPT) $(SCRIPTS_DIR)/dev/tests$(SCRIPT_EXT) + $(call script,dev/tests) +ftm-samples: export SAMPLETONES_FTM_OUTPUT_DIR := build/ftm ftm-samples: - SAMPLETONES_FTM_OUTPUT_DIR=build/ftm uv run python -m pytest tests/integration/famitracker + uv run python -m pytest tests/integration/famitracker check-import-boundary: uv run scripts/check_import_boundary.py --all @@ -90,14 +112,14 @@ calibration: uv run scripts/calibration.py --all lint: - $(RUN_SCRIPT) $(SCRIPTS_DIR)/dev/lint$(SCRIPT_EXT) + $(call script,dev/lint) pylint: - $(RUN_SCRIPT) $(SCRIPTS_DIR)/dev/pylint$(SCRIPT_EXT) + $(call script,dev/pylint) mypy: - $(RUN_SCRIPT) $(SCRIPTS_DIR)/dev/mypy$(SCRIPT_EXT) + $(call script,dev/mypy) format: - $(RUN_SCRIPT) $(SCRIPTS_DIR)/dev/format$(SCRIPT_EXT) + $(call script,dev/format) From 04f9b5ce7d139c6481830d20e40bb0ff30012a63 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 31 Jul 2026 12:23:31 +0200 Subject: [PATCH 05/10] Updated: documentation and release workflow --- .github/workflows/release.yml | 33 ++++++++++++++++++++------------- CHANGELOG.md | 2 +- THIRD-PARTY-LICENSES.txt | 3 --- THIRD-PARTY-NOTICES.md | 28 ++++++++++------------------ 4 files changed, 31 insertions(+), 35 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8485cd30..cfa843ee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,6 +15,9 @@ on: - testpypi - pypi +permissions: + contents: read + jobs: build: name: Build distributions @@ -68,13 +71,17 @@ jobs: with: python-version: ${{ matrix.python }} - - name: Install PortAudio (Linux) + - name: Install PortAudio and Tk (Linux) if: runner.os == 'Linux' - run: sudo apt-get update && sudo apt-get install -y portaudio19-dev + run: sudo apt-get update && sudo apt-get install -y portaudio19-dev python3-tk - name: Install PortAudio (macOS) if: runner.os == 'macOS' - run: brew install portaudio + run: | + brew install portaudio + prefix="$(brew --prefix portaudio)" + echo "CFLAGS=-I${prefix}/include" >> "$GITHUB_ENV" + echo "LDFLAGS=-L${prefix}/lib" >> "$GITHUB_ENV" - name: Install the wheel and check the entry point shell: bash @@ -89,8 +96,6 @@ jobs: bundle: name: Standalone bundle (${{ matrix.platform }}) - # Only a tag push produces downloadable binaries. A manual TestPyPI run must never - # create a GitHub Release. if: startsWith(github.ref, 'refs/tags/v') needs: build runs-on: ${{ matrix.os }} @@ -115,10 +120,17 @@ jobs: sudo apt-get update sudo apt-get install -y python3-tk tk-dev libportaudio2 libasound-dev portaudio19-dev - - name: Install SampleToNES and its runtime dependencies + - name: Create the build environment the bundle scripts expect + shell: bash run: | - python -m pip install --upgrade pip - python -m pip install . + python -m venv .venv-build + if [ "$RUNNER_OS" = "Windows" ]; then + venv_python=.venv-build/Scripts/python.exe + else + venv_python=.venv-build/bin/python + fi + "$venv_python" -m pip install --upgrade pip + "$venv_python" -m pip install ".[build]" - name: Build the bundle (Linux) if: runner.os == 'Linux' @@ -161,9 +173,6 @@ jobs: needs: bundle runs-on: ubuntu-latest permissions: - # The only job in this workflow that can write to the repository, and only to - # create the release. The release is left as a draft: nothing becomes public - # until you press Publish yourself. contents: write steps: - uses: actions/download-artifact@v4 @@ -185,7 +194,6 @@ jobs: fi gh release upload "$GITHUB_REF_NAME" bundles/*.zip \ --repo "$GITHUB_REPOSITORY" --clobber - echo "Draft release ready. It stays invisible until you publish it." publish: name: Publish to ${{ github.event_name == 'workflow_dispatch' && inputs.target || 'pypi' }} @@ -194,7 +202,6 @@ jobs: environment: name: ${{ github.event_name == 'workflow_dispatch' && inputs.target || 'pypi' }} permissions: - # Required for PyPI Trusted Publishing (OIDC). No API token is stored anywhere. id-token: write steps: - uses: actions/download-artifact@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 1288d334..a44206cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # SampleToNES -## v0.3.0 [2026-07-30] +## v0.3.0 [2026-07-31] * Added a _Sequencer_ view with FamiTracker-style patterns. * Added project export in a FamiTracker-compatible format. diff --git a/THIRD-PARTY-LICENSES.txt b/THIRD-PARTY-LICENSES.txt index 89d482a4..ad4a8c25 100644 --- a/THIRD-PARTY-LICENSES.txt +++ b/THIRD-PARTY-LICENSES.txt @@ -15,9 +15,6 @@ The list below covers the union of the Linux and Windows bundles. An individual bundle contains a subset of it: pywin32, pytaskbar, comtypes and colorama ship only on Windows. -This file is a snapshot of the pinned dependency set. It is regenerated by hand when -the dependencies in pyproject.toml change. - ==================================================================================================== CPython (the Python runtime frozen into the bundle) License: PSF-2.0 diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index d1df87a1..5e6f29fb 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -33,8 +33,7 @@ are not used in any SampleToNES component name. ## The PyPI package -_SampleToNES_ does not vendor any dependency source code — every dependency is installed -separately by `pip`/`uv` from PyPI and imported at runtime. +Every dependency is installed separately by `pip`/`uv` from PyPI and imported at runtime. Most dependencies are permissively licensed (MIT, BSD, Apache-2.0, ISC). Two are under the GNU Lesser General Public License — [Pebble](https://pypi.org/project/Pebble/) (LGPL-3.0, @@ -49,8 +48,8 @@ MIT License applies to the PyPI package without further obligation. The bundles attached to GitHub Releases are produced by [PyInstaller](https://pyinstaller.org/) and contain the complete dependency set, the -Python runtime, and a number of native libraries. Publishing them makes _SampleToNES_ a -redistributor of all of that material. +Python runtime, and a number of native libraries. Each component reaches you as part of +that bundle, under its own license. Every component's full license text is in `THIRD-PARTY-LICENSES.txt` at the root of each bundle. The components below are the ones whose licenses ask for more than attribution. @@ -83,8 +82,8 @@ bootloader is GPL-2.0-or-later with [an exception](https://github.com/pyinstaller/pyinstaller/blob/develop/COPYING.txt) permitting its use in bundles of software under any license. -Neither exception places any obligation on _SampleToNES_ beyond reproducing these notices. -No component of these bundles is under the plain GPL. +Both exceptions make these notices the only condition attached to the compiled output. No +component of these bundles is under the plain GPL. ### MPL-2.0 components @@ -100,16 +99,9 @@ upstream project. If you would prefer to receive it from us, open an issue at will supply the source for the exact versions contained in a given bundle, for at least three years from the date of that release. -### What the bundles do not contain +### GPU acceleration -The published bundles are **CPU-only**. They contain no CuPy, no CUDA runtime, and no -NVIDIA libraries; those are proprietary and are not redistributable under their EULA. GPU -acceleration is available only when _SampleToNES_ is installed from PyPI with the `gpu` -extra, in which case CuPy and the CUDA components are downloaded by the user directly from -their publishers. - -## Keeping this file accurate - -The inventory above is a snapshot of the pinned dependency set in `pyproject.toml` and -`uv.lock`. When those change, both this file and `THIRD-PARTY-LICENSES.txt` need to be -reviewed before the next release. +The published bundles are **CPU-only**: CuPy, the CUDA runtime and the NVIDIA libraries +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. From 83fa08399336df232658665c433c63319d26d616 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 31 Jul 2026 13:16:40 +0200 Subject: [PATCH 06/10] Enhanced: CI tools --- .github/workflows/ci.yml | 76 ++++++++++++++++++++++++++++++++++++++ .pre-commit-config.yaml | 12 +++--- .pylintrc | 2 +- Makefile | 1 - pyproject.toml | 1 - scripts/linux/dev/tests.sh | 2 +- uv.lock | 61 ------------------------------ 7 files changed, 84 insertions(+), 71 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..c9960625 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + checks: + name: Formatting, typing and linting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: "3.12" + enable-cache: true + + - name: Install system libraries + run: | + sudo apt-get update + sudo apt-get install -y portaudio19-dev python3-tk + + - name: Install the development environment + run: uv sync --group dev + + - name: Cache pre-commit environments + uses: actions/cache@v4 + with: + path: ~/.cache/pre-commit + key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }} + + - name: Run every pre-commit hook + run: uv run pre-commit run --all-files --show-diff-on-failure --color always + + tests: + name: Tests (${{ matrix.os }}, py${{ matrix.python }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python: ["3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + python-version: ${{ matrix.python }} + enable-cache: true + + - name: Install system libraries (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y portaudio19-dev python3-tk libgl1 libxrandr2 libxinerama1 libxcursor1 libxi6 + + - name: Install the development environment + run: uv sync --group dev + + - name: Run doctests + run: uv run python -m pytest src/ --doctest-modules --no-cov + + - name: Run the unit and integration suites with coverage + run: uv run python -m pytest -n auto --cov diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 155e425f..10a21d08 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,6 +6,12 @@ repos: types: [text] exclude: ^(LICENSE|THIRD-PARTY-LICENSES\.txt|src/sampletones_assets/fonts/LICENSES/) + - id: check-yaml + - id: check-toml + - id: end-of-file-fixer + types: [text] + exclude: ^(LICENSE|THIRD-PARTY-LICENSES\.txt|src/sampletones_assets/fonts/LICENSES/) + - repo: https://github.com/timothycrosley/isort rev: 9.0.0a3 hooks: @@ -43,12 +49,6 @@ repos: files: ^src/sampletones_application/tags/ verbose: true - - id: yamlfmt - name: yamlfmt - entry: uv run yamlfmt - language: system - types: [yaml] - - id: mypy name: mypy entry: uv run mypy diff --git a/.pylintrc b/.pylintrc index 7399a2d8..1a988238 100644 --- a/.pylintrc +++ b/.pylintrc @@ -10,4 +10,4 @@ disable=C0104,C0114,C0115,C0116,C0302,C0415,E0402,E1101,E1130,R0801,R0901,R0902, max-line-length=120 [TYPECHECK] -ignored-modules=pydantic \ No newline at end of file +ignored-modules=pydantic diff --git a/Makefile b/Makefile index 3bf722c2..1f3dcce4 100644 --- a/Makefile +++ b/Makefile @@ -122,4 +122,3 @@ mypy: format: $(call script,dev/format) - diff --git a/pyproject.toml b/pyproject.toml index a8ee8b9e..3ad02ec0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,7 +84,6 @@ dev = [ "pytest-cov==7.1.0", "pytest-xdist==3.8.0", "types-PyYAML==6.0.12.20260518", - "yamlfmt==1.1.1", ] [build-system] diff --git a/scripts/linux/dev/tests.sh b/scripts/linux/dev/tests.sh index 82266af2..bbd39229 100755 --- a/scripts/linux/dev/tests.sh +++ b/scripts/linux/dev/tests.sh @@ -16,4 +16,4 @@ if [[ $DOCTEST_EXIT -ne 0 ]] || [[ $PYTEST_EXIT -ne 0 ]]; then fi echo "All tests passed." -exit 0 \ No newline at end of file +exit 0 diff --git a/uv.lock b/uv.lock index 3b333578..5a68ed80 100644 --- a/uv.lock +++ b/uv.lock @@ -1706,56 +1706,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] -[[package]] -name = "ruamel-yaml" -version = "0.17.40" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ruamel-yaml-clib", marker = "(python_full_version < '3.13' and platform_python_implementation == 'CPython') or (python_full_version >= '3.13' and extra == 'extra-11-sampletones-gpu' and extra == 'extra-11-sampletones-gpu-cuda11') or (platform_python_implementation != 'CPython' and extra == 'extra-11-sampletones-gpu' and extra == 'extra-11-sampletones-gpu-cuda11')" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d1/d6/eb2833ccba5ea36f8f4de4bcfa0d1a91eb618f832d430b70e3086821f251/ruamel.yaml-0.17.40.tar.gz", hash = "sha256:6024b986f06765d482b5b07e086cc4b4cd05dd22ddcbc758fa23d54873cf313d", size = 137672, upload-time = "2023-10-20T12:53:56.073Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/35/79/5e2cffa1c77432f11cd93a5351f30732c997a239d3a3090856a72d6d8ba7/ruamel.yaml-0.17.40-py3-none-any.whl", hash = "sha256:b16b6c3816dff0a93dca12acf5e70afd089fa5acb80604afd1ffa8b465b7722c", size = 113666, upload-time = "2023-10-20T12:53:52.628Z" }, -] - -[[package]] -name = "ruamel-yaml-clib" -version = "0.2.15" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ea/97/60fda20e2fb54b83a61ae14648b0817c8f5d84a3821e40bfbdae1437026a/ruamel_yaml_clib-0.2.15.tar.gz", hash = "sha256:46e4cc8c43ef6a94885f72512094e482114a8a706d3c555a34ed4b0d20200600", size = 225794, upload-time = "2025-11-16T16:12:59.761Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/72/4b/5fde11a0722d676e469d3d6f78c6a17591b9c7e0072ca359801c4bd17eee/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cb15a2e2a90c8475df45c0949793af1ff413acfb0a716b8b94e488ea95ce7cff", size = 149088, upload-time = "2025-11-16T16:13:22.836Z" }, - { url = "https://files.pythonhosted.org/packages/85/82/4d08ac65ecf0ef3b046421985e66301a242804eb9a62c93ca3437dc94ee0/ruamel_yaml_clib-0.2.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:64da03cbe93c1e91af133f5bec37fd24d0d4ba2418eaf970d7166b0a26a148a2", size = 134553, upload-time = "2025-11-16T16:13:24.151Z" }, - { url = "https://files.pythonhosted.org/packages/b9/cb/22366d68b280e281a932403b76da7a988108287adff2bfa5ce881200107a/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f6d3655e95a80325b84c4e14c080b2470fe4f33b6846f288379ce36154993fb1", size = 737468, upload-time = "2025-11-16T20:22:47.335Z" }, - { url = "https://files.pythonhosted.org/packages/71/73/81230babf8c9e33770d43ed9056f603f6f5f9665aea4177a2c30ae48e3f3/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71845d377c7a47afc6592aacfea738cc8a7e876d586dfba814501d8c53c1ba60", size = 753349, upload-time = "2025-11-16T16:13:26.269Z" }, - { url = "https://files.pythonhosted.org/packages/61/62/150c841f24cda9e30f588ef396ed83f64cfdc13b92d2f925bb96df337ba9/ruamel_yaml_clib-0.2.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11e5499db1ccbc7f4b41f0565e4f799d863ea720e01d3e99fa0b7b5fcd7802c9", size = 788211, upload-time = "2025-11-16T16:13:27.441Z" }, - { url = "https://files.pythonhosted.org/packages/30/93/e79bd9cbecc3267499d9ead919bd61f7ddf55d793fb5ef2b1d7d92444f35/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4b293a37dc97e2b1e8a1aec62792d1e52027087c8eea4fc7b5abd2bdafdd6642", size = 743203, upload-time = "2025-11-16T16:13:28.671Z" }, - { url = "https://files.pythonhosted.org/packages/8d/06/1eb640065c3a27ce92d76157f8efddb184bd484ed2639b712396a20d6dce/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:512571ad41bba04eac7268fe33f7f4742210ca26a81fe0c75357fa682636c690", size = 747292, upload-time = "2025-11-16T20:22:48.584Z" }, - { url = "https://files.pythonhosted.org/packages/a5/21/ee353e882350beab65fcc47a91b6bdc512cace4358ee327af2962892ff16/ruamel_yaml_clib-0.2.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5e9f630c73a490b758bf14d859a39f375e6999aea5ddd2e2e9da89b9953486a", size = 771624, upload-time = "2025-11-16T16:13:29.853Z" }, - { url = "https://files.pythonhosted.org/packages/57/34/cc1b94057aa867c963ecf9ea92ac59198ec2ee3a8d22a126af0b4d4be712/ruamel_yaml_clib-0.2.15-cp312-cp312-win32.whl", hash = "sha256:f4421ab780c37210a07d138e56dd4b51f8642187cdfb433eb687fe8c11de0144", size = 100342, upload-time = "2025-11-16T16:13:31.067Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e5/8925a4208f131b218f9a7e459c0d6fcac8324ae35da269cb437894576366/ruamel_yaml_clib-0.2.15-cp312-cp312-win_amd64.whl", hash = "sha256:2b216904750889133d9222b7b873c199d48ecbb12912aca78970f84a5aa1a4bc", size = 119013, upload-time = "2025-11-16T16:13:32.164Z" }, - { url = "https://files.pythonhosted.org/packages/17/5e/2f970ce4c573dc30c2f95825f2691c96d55560268ddc67603dc6ea2dd08e/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dcec721fddbb62e60c2801ba08c87010bd6b700054a09998c4d09c08147b8fb", size = 147450, upload-time = "2025-11-16T16:13:33.542Z" }, - { url = "https://files.pythonhosted.org/packages/d6/03/a1baa5b94f71383913f21b96172fb3a2eb5576a4637729adbf7cd9f797f8/ruamel_yaml_clib-0.2.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:65f48245279f9bb301d1276f9679b82e4c080a1ae25e679f682ac62446fac471", size = 133139, upload-time = "2025-11-16T16:13:34.587Z" }, - { url = "https://files.pythonhosted.org/packages/dc/19/40d676802390f85784235a05788fd28940923382e3f8b943d25febbb98b7/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:46895c17ead5e22bea5e576f1db7e41cb273e8d062c04a6a49013d9f60996c25", size = 731474, upload-time = "2025-11-16T20:22:49.934Z" }, - { url = "https://files.pythonhosted.org/packages/ce/bb/6ef5abfa43b48dd55c30d53e997f8f978722f02add61efba31380d73e42e/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3eb199178b08956e5be6288ee0b05b2fb0b5c1f309725ad25d9c6ea7e27f962a", size = 748047, upload-time = "2025-11-16T16:13:35.633Z" }, - { url = "https://files.pythonhosted.org/packages/ff/5d/e4f84c9c448613e12bd62e90b23aa127ea4c46b697f3d760acc32cb94f25/ruamel_yaml_clib-0.2.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d1032919280ebc04a80e4fb1e93f7a738129857eaec9448310e638c8bccefcf", size = 782129, upload-time = "2025-11-16T16:13:36.781Z" }, - { url = "https://files.pythonhosted.org/packages/de/4b/e98086e88f76c00c88a6bcf15eae27a1454f661a9eb72b111e6bbb69024d/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab0df0648d86a7ecbd9c632e8f8d6b21bb21b5fc9d9e095c796cacf32a728d2d", size = 736848, upload-time = "2025-11-16T16:13:37.952Z" }, - { url = "https://files.pythonhosted.org/packages/0c/5c/5964fcd1fd9acc53b7a3a5d9a05ea4f95ead9495d980003a557deb9769c7/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:331fb180858dd8534f0e61aa243b944f25e73a4dae9962bd44c46d1761126bbf", size = 741630, upload-time = "2025-11-16T20:22:51.718Z" }, - { url = "https://files.pythonhosted.org/packages/07/1e/99660f5a30fceb58494598e7d15df883a07292346ef5696f0c0ae5dee8c6/ruamel_yaml_clib-0.2.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fd4c928ddf6bce586285daa6d90680b9c291cfd045fc40aad34e445d57b1bf51", size = 766619, upload-time = "2025-11-16T16:13:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/36/2f/fa0344a9327b58b54970e56a27b32416ffbcfe4dcc0700605516708579b2/ruamel_yaml_clib-0.2.15-cp313-cp313-win32.whl", hash = "sha256:bf0846d629e160223805db9fe8cc7aec16aaa11a07310c50c8c7164efa440aec", size = 100171, upload-time = "2025-11-16T16:13:40.456Z" }, - { url = "https://files.pythonhosted.org/packages/06/c4/c124fbcef0684fcf3c9b72374c2a8c35c94464d8694c50f37eef27f5a145/ruamel_yaml_clib-0.2.15-cp313-cp313-win_amd64.whl", hash = "sha256:45702dfbea1420ba3450bb3dd9a80b33f0badd57539c6aac09f42584303e0db6", size = 118845, upload-time = "2025-11-16T16:13:41.481Z" }, - { url = "https://files.pythonhosted.org/packages/3e/bd/ab8459c8bb759c14a146990bf07f632c1cbec0910d4853feeee4be2ab8bb/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:753faf20b3a5906faf1fc50e4ddb8c074cb9b251e00b14c18b28492f933ac8ef", size = 147248, upload-time = "2025-11-16T16:13:42.872Z" }, - { url = "https://files.pythonhosted.org/packages/69/f2/c4cec0a30f1955510fde498aac451d2e52b24afdbcb00204d3a951b772c3/ruamel_yaml_clib-0.2.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:480894aee0b29752560a9de46c0e5f84a82602f2bc5c6cde8db9a345319acfdf", size = 133764, upload-time = "2025-11-16T16:13:43.932Z" }, - { url = "https://files.pythonhosted.org/packages/82/c7/2480d062281385a2ea4f7cc9476712446e0c548cd74090bff92b4b49e898/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d3b58ab2454b4747442ac76fab66739c72b1e2bb9bd173d7694b9f9dbc9c000", size = 730537, upload-time = "2025-11-16T20:22:52.918Z" }, - { url = "https://files.pythonhosted.org/packages/75/08/e365ee305367559f57ba6179d836ecc3d31c7d3fdff2a40ebf6c32823a1f/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bfd309b316228acecfa30670c3887dcedf9b7a44ea39e2101e75d2654522acd4", size = 746944, upload-time = "2025-11-16T16:13:45.338Z" }, - { url = "https://files.pythonhosted.org/packages/a1/5c/8b56b08db91e569d0a4fbfa3e492ed2026081bdd7e892f63ba1c88a2f548/ruamel_yaml_clib-0.2.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2812ff359ec1f30129b62372e5f22a52936fac13d5d21e70373dbca5d64bb97c", size = 778249, upload-time = "2025-11-16T16:13:46.871Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1d/70dbda370bd0e1a92942754c873bd28f513da6198127d1736fa98bb2a16f/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7e74ea87307303ba91073b63e67f2c667e93f05a8c63079ee5b7a5c8d0d7b043", size = 737140, upload-time = "2025-11-16T16:13:48.349Z" }, - { url = "https://files.pythonhosted.org/packages/5b/87/822d95874216922e1120afb9d3fafa795a18fdd0c444f5c4c382f6dac761/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:713cd68af9dfbe0bb588e144a61aad8dcc00ef92a82d2e87183ca662d242f524", size = 741070, upload-time = "2025-11-16T20:22:54.151Z" }, - { url = "https://files.pythonhosted.org/packages/b9/17/4e01a602693b572149f92c983c1f25bd608df02c3f5cf50fd1f94e124a59/ruamel_yaml_clib-0.2.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:542d77b72786a35563f97069b9379ce762944e67055bea293480f7734b2c7e5e", size = 765882, upload-time = "2025-11-16T16:13:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/9f/17/7999399081d39ebb79e807314de6b611e1d1374458924eb2a489c01fc5ad/ruamel_yaml_clib-0.2.15-cp314-cp314-win32.whl", hash = "sha256:424ead8cef3939d690c4b5c85ef5b52155a231ff8b252961b6516ed7cf05f6aa", size = 102567, upload-time = "2025-11-16T16:13:50.78Z" }, - { url = "https://files.pythonhosted.org/packages/d2/67/be582a7370fdc9e6846c5be4888a530dcadd055eef5b932e0e85c33c7d73/ruamel_yaml_clib-0.2.15-cp314-cp314-win_amd64.whl", hash = "sha256:ac9b8d5fa4bb7fd2917ab5027f60d4234345fd366fe39aa711d5dca090aa1467", size = 122847, upload-time = "2025-11-16T16:13:51.807Z" }, -] - [[package]] name = "sampletones" version = "0.3.0" @@ -1804,7 +1754,6 @@ dev = [ { name = "pytest-cov" }, { name = "pytest-xdist" }, { name = "types-pyyaml" }, - { name = "yamlfmt" }, ] [package.metadata] @@ -1845,7 +1794,6 @@ dev = [ { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-xdist", specifier = "==3.8.0" }, { name = "types-pyyaml", specifier = "==6.0.12.20260518" }, - { name = "yamlfmt", specifier = "==1.1.1" }, ] [[package]] @@ -2122,12 +2070,3 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be0 wheels = [ { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, ] - -[[package]] -name = "yamlfmt" -version = "1.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "ruamel-yaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/93/eb/338ba863682a00f1ce89e8160354ce61b403c1e5f538e009baea62f2b53c/yamlfmt-1.1.1.tar.gz", hash = "sha256:5d7352cf9c779d4afd58b119571c17a6f41f3e1fd8ad5eae2e11a926ac3de60f", size = 14394, upload-time = "2023-01-05T03:42:37.35Z" } From cea5e4242b6e5592f072437ef2b74634be535f79 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 31 Jul 2026 13:46:05 +0200 Subject: [PATCH 07/10] Fixed: dependency set --- .github/workflows/ci.yml | 8 ++------ docs/development/dependencies.md | 2 +- scripts/linux/build/dependencies.sh | 25 ++++++++++++++++++++++++- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9960625..7e994651 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,7 @@ jobs: enable-cache: true - name: Install system libraries - run: | - sudo apt-get update - sudo apt-get install -y portaudio19-dev python3-tk + run: bash scripts/linux/build/dependencies.sh - name: Install the development environment run: uv sync --group dev @@ -62,9 +60,7 @@ jobs: - name: Install system libraries (Linux) if: runner.os == 'Linux' - run: | - sudo apt-get update - sudo apt-get install -y portaudio19-dev python3-tk libgl1 libxrandr2 libxinerama1 libxcursor1 libxi6 + run: bash scripts/linux/build/dependencies.sh - name: Install the development environment run: uv sync --group dev diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index d58ecb82..c0c61698 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -20,7 +20,7 @@ Instruction libraries and reconstructions are serialized with [MessagePack](http ## Linux (standalone executable) -Building a standalone executable on Linux needs the PortAudio and Tk system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. +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. PortAudio is required. Tk backs the file dialogs where `kdialog` and `zenity` are absent, and `make release` requires it so the shipped executable stays self-contained. diff --git a/scripts/linux/build/dependencies.sh b/scripts/linux/build/dependencies.sh index 60c5cedf..7b175827 100755 --- a/scripts/linux/build/dependencies.sh +++ b/scripts/linux/build/dependencies.sh @@ -2,7 +2,30 @@ set -e +PACKAGES=( + # PortAudio: audio playback through pyaudio + libportaudio2 + libasound-dev + libpulse-dev + portaudio19-dev + # Tk: file dialogs where kdialog and zenity are absent + python3-tk + tk-dev + tcl-dev + # OpenGL and X11: the window DearPyGui opens through GLFW + libgl1 + libegl1 + libx11-6 + libx11-xcb1 + libxcursor1 + libxi6 + libxinerama1 + libxrandr2 + libxrender1 + libxxf86vm1 +) + echo "Installing system dependencies (requires sudo)" sudo apt-get update -sudo apt-get install -y python3-tk tk-dev tcl-dev libportaudio2 libasound-dev libpulse-dev portaudio19-dev +sudo apt-get install -y "${PACKAGES[@]}" echo "System dependencies installed." From 6a299db35d86333bbebd99f9749b207dd370b27c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 31 Jul 2026 14:19:13 +0200 Subject: [PATCH 08/10] Updated: documentation and workflow YAML filename --- .github/workflows/{release.yml => workflow.yml} | 0 docs/development/architecture.md | 2 +- docs/index.md | 2 ++ 3 files changed, 3 insertions(+), 1 deletion(-) rename .github/workflows/{release.yml => workflow.yml} (100%) diff --git a/.github/workflows/release.yml b/.github/workflows/workflow.yml similarity index 100% rename from .github/workflows/release.yml rename to .github/workflows/workflow.yml diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 2899a31a..2a8d4e8f 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -2,7 +2,7 @@ This document describes the design of `sampletones_application` — the GUI front-end of _SampleToNES_. It is prescriptive: it states the contracts each layer must honour, in the form they are enforced, and the rationale behind them. Use it as the reference when deciding where new code belongs. -Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md § Architecture`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, and the YAML configuration package has `docs/development/config-organization.md`. +Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, and the YAML configuration package has `docs/development/config-organization.md`. --- diff --git a/docs/index.md b/docs/index.md index e97a5cff..5751f256 100644 --- a/docs/index.md +++ b/docs/index.md @@ -54,6 +54,8 @@ The [**development**](development/) section is for contributors. - [Architecture](development/architecture.md) — the application's layers and the contracts between them. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. +- [Playback](development/playback.md) — the audio transport shared by every view. +- [Configuration](development/config-organization.md) — how the YAML configuration package is laid out. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. - [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on. - [Bugs and to-dos](development/bugs-and-todos.md) — the working ledger of known gaps. From 93195e3139bdfb3cec624ef97f3745e53bfeeaea Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 31 Jul 2026 14:46:15 +0200 Subject: [PATCH 09/10] Fixed: failure if no monitors present --- src/sampletones_application/viewport.py | 15 +++++++++++-- .../sampletones_application/test_viewport.py | 21 ++++++++++++++++++- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/src/sampletones_application/viewport.py b/src/sampletones_application/viewport.py index 7b378415..3cc14810 100644 --- a/src/sampletones_application/viewport.py +++ b/src/sampletones_application/viewport.py @@ -2,13 +2,14 @@ from typing import Final, List, Optional, Tuple import dearpygui.dearpygui as dpg -from screeninfo import Monitor, get_monitors +from screeninfo import Monitor, ScreenInfoError, get_monitors from sampletones_application.config.managers.session import SessionManager from sampletones_application.ui.resources.items import IconResource from sampletones_application.ui.resources.resources import get_icon_path from sampletones_application.ui.themes.theme import Theme from sampletones_shared.application import SAMPLETONES_NAME +from sampletones_shared.logger import logger from sampletones_shared.types.callback import VoidCallback _MAX_WINDOW_MONITOR_RATIO: Final[float] = 0.9 @@ -115,7 +116,17 @@ def _get_screen_dimensions() -> Tuple[int, int]: @staticmethod def _get_monitors() -> List[Monitor]: - return get_monitors() + """Monitors reported by the platform, empty where none can be enumerated. + + A display server that exposes no enumerator — a headless session, a remote shell, + a Wayland compositor without the expected backend — makes ``screeninfo`` raise + instead of returning an empty list, so the window falls back to assumed dimensions. + """ + try: + return get_monitors() + except ScreenInfoError as exception: + logger.warning(f"No monitor information available: {exception}") + return [] def _fit_window_to_monitor( self, diff --git a/tests/unit/sampletones_application/test_viewport.py b/tests/unit/sampletones_application/test_viewport.py index 2ef08838..e1690e17 100644 --- a/tests/unit/sampletones_application/test_viewport.py +++ b/tests/unit/sampletones_application/test_viewport.py @@ -2,7 +2,7 @@ from typing import List, Tuple import pytest -from screeninfo import Monitor +from screeninfo import Monitor, ScreenInfoError from sampletones_application.viewport import _MAX_WINDOW_MONITOR_RATIO, ViewportManager @@ -120,6 +120,25 @@ def test_falls_back_to_screen_dimensions_without_monitors(self, monkeypatch: pyt assert x + width <= 1920 assert y + height <= 1080 + def test_falls_back_to_screen_dimensions_when_enumeration_fails(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A display server exposing no enumerator makes screeninfo raise, which stays recoverable.""" + + def raise_screen_info_error() -> List[Monitor]: + raise ScreenInfoError("No enumerators available") + + monkeypatch.setattr( + "sampletones_application.viewport.get_monitors", + raise_screen_info_error, + ) + manager = _manager() + manager._get_screen_dimensions = lambda: (1920, 1080) # type: ignore[method-assign] + + x, y, width, height = manager._fit_window_to_monitor(200, 200, 4000, 4000) + + assert 0 <= x and 0 <= y + assert x + width <= 1920 + assert y + height <= 1080 + @dataclass class FakeSession: From 34502de298f736085efa3f2f8f770ac79073267d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 31 Jul 2026 14:52:59 +0200 Subject: [PATCH 10/10] Rebuild