diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ff6e8556..8a9cc876 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,13 +41,16 @@ jobs:
- name: Run every pre-commit hook
run: uv run pre-commit run --all-files --show-diff-on-failure --color always
+ - name: Check the committed icons match the mark
+ run: uv run pre-commit run icons --all-files --hook-stage pre-push --color always
+
tests:
name: Tests (${{ matrix.os }}, py${{ matrix.python }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
- os: [ubuntu-latest, windows-latest]
+ os: [ubuntu-latest, windows-latest, macos-latest]
python: ["3.12", "3.13"]
steps:
- uses: actions/checkout@v7
@@ -62,6 +65,12 @@ jobs:
if: runner.os == 'Linux'
run: bash scripts/linux/build/dependencies.sh
+ - name: Install system libraries (macOS)
+ if: runner.os == 'macOS'
+ run: |
+ bash scripts/macos/build/dependencies.sh
+ bash scripts/macos/build/build_env.sh >> "$GITHUB_ENV"
+
- name: Install the development environment
run: uv sync --group dev
diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml
index 2d66a283..f94d5553 100644
--- a/.github/workflows/workflow.yml
+++ b/.github/workflows/workflow.yml
@@ -58,6 +58,10 @@ jobs:
os: [ubuntu-latest, windows-latest, macos-latest]
python: ["3.12", "3.13"]
steps:
+ - uses: actions/checkout@v7
+ with:
+ path: repository
+
- uses: actions/download-artifact@v8
with:
name: dist
@@ -74,10 +78,8 @@ jobs:
- name: Install PortAudio (macOS)
if: runner.os == 'macOS'
run: |
- brew install portaudio
- prefix="$(brew --prefix portaudio)"
- echo "CFLAGS=-I${prefix}/include" >> "$GITHUB_ENV"
- echo "LDFLAGS=-L${prefix}/lib" >> "$GITHUB_ENV"
+ bash repository/scripts/macos/build/dependencies.sh
+ bash repository/scripts/macos/build/build_env.sh >> "$GITHUB_ENV"
- name: Install the wheel and check the entry point
shell: bash
@@ -124,7 +126,7 @@ jobs:
venv_python=.venv-build/bin/python
fi
"$venv_python" -m pip install --upgrade pip
- "$venv_python" -m pip install ".[build]"
+ "$venv_python" -m pip install ".[build]" --group assets
- name: Build the bundle (Linux)
if: runner.os == 'Linux'
diff --git a/.gitignore b/.gitignore
index 88d53808..585ea73d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,25 +4,20 @@ __pycache__/
.ipynb_checkpoints/
.mypy_cache/
.pytest_cache/
+.ruff_cache/
.venv/
.venv-build/
.vscode/
-bin/
-build/
dist/
wheels/
-!scripts/**/build/
+/bin/
+/build/
sampletones
!src/sampletones
!tests/sampletones
-**/*.idea
-**/*.vscode/**
-**/*.ipynb_checkpoints/**
-**/*__pycache__/**
-
*.pyc
*.pyo
*.coverage
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 51492d91..0f67e871 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -51,10 +51,18 @@ repos:
- id: tag-names
name: tag names
- entry: uv run scripts/checks/tag_names.py
+ entry: uv run scripts/checks/tag_names.py --all
language: system
types: [python]
- files: ^src/sampletones_application/tags/
+ pass_filenames: false
+ verbose: true
+
+ - id: palette-colors
+ name: palette colors
+ entry: uv run scripts/checks/palette_colors.py
+ language: system
+ files: (^src/sampletones_application/.*\.py|^src/sampletones_config/.*\.yaml)$
+ pass_filenames: false
verbose: true
- id: language-keys
@@ -83,6 +91,16 @@ repos:
require_serial: true
exclude: ^(tests/)
+ - id: icons
+ name: icons
+ entry: uv run python scripts/assets/icons.py
+ language: system
+ files: ^src/sampletones_assets/(icons|mark)/
+ pass_filenames: false
+ verbose: true
+ stages:
+ - pre-push
+
- id: pytest
name: pytest
entry: make test
diff --git a/.pylintrc b/.pylintrc
deleted file mode 100644
index 1a988238..00000000
--- a/.pylintrc
+++ /dev/null
@@ -1,13 +0,0 @@
-[MAIN]
-fail-under=9.9
-ignore-paths=^tests/.*$
-load-plugins=pylint_pydantic
-
-[MESSAGES CONTROL]
-disable=C0104,C0114,C0115,C0116,C0302,C0415,E0402,E1101,E1130,R0801,R0901,R0902,R0903,R0904,R0911,R0912,R0913,R0914,R0917,W0613
-
-[FORMAT]
-max-line-length=120
-
-[TYPECHECK]
-ignored-modules=pydantic
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ef699221..70f9edbd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,15 @@
* Added support to [Bitphase](https://github.com/paator/bitphase).
* Fixed arpeggio editing shifting a sample's pitch permanently.
+* Enhanced application options:
+ * Display settings
+ * Theme selector
+ * Keybinding settings
* Bumped the reconstruction data-version to `2.1`.
+* Improved Sequencer module playback.
+* Added song export to WAV/MP3.
+* Added tracker selection operations.
+* Added a _SampleToNES_ logo.
## v0.3.0 [2026-07-31]
diff --git a/LICENSE b/LICENSE
index aa541094..5918803c 100644
--- a/LICENSE
+++ b/LICENSE
@@ -22,7 +22,9 @@ SOFTWARE.
---
-The MIT license above covers the SampleToNES source code only.
+The MIT license above covers the SampleToNES source code and the application
+icons under `src/sampletones_assets/icons/`, which are drawn from the mark
+declared in `src/sampletones_assets/mark/`.
Font files bundled under `src/sampletones_assets/fonts/` are the work of third
parties and remain under their own licenses (SIL Open Font License 1.1 and the
diff --git a/Makefile b/Makefile
index 21d2e81e..7ab6b93d 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
.PHONY: help setup install build release system-deps run clean pre-commit test \
- ftm-samples check-import-boundary check-tag-names check-unused-tags \
- check-language-keys calibration lint pylint mypy format
+ ftm-samples icons check-import-boundary check-tag-names check-unused-tags \
+ check-language-keys check-palette-colors calibration lint pylint mypy format
ifeq ($(OS),Windows_NT)
ifeq ($(MSYSTEM),)
@@ -43,12 +43,14 @@ endif
BUILD_COMMAND := $(RUN_SCRIPT) $(BUILD_SCRIPT)
RELEASE_COMMAND := $(RUN_SCRIPT) $(BUILD_SCRIPT) --release
SYSTEM_DEPS_COMMAND := bash scripts/linux/build/dependencies.sh
+SETUP_ENV :=
ifeq ($(UNAME_S),Darwin)
- MACOS_SOURCE_ONLY := bash scripts/macos/source_only.sh
- BUILD_COMMAND := $(MACOS_SOURCE_ONLY) 'make build'
- RELEASE_COMMAND := $(MACOS_SOURCE_ONLY) 'make release'
- SYSTEM_DEPS_COMMAND := $(MACOS_SOURCE_ONLY) 'make system-deps'
+ MACOS_NO_BUNDLE := bash scripts/macos/build/no_bundle.sh
+ BUILD_COMMAND := $(MACOS_NO_BUNDLE) 'make build'
+ RELEASE_COMMAND := $(MACOS_NO_BUNDLE) 'make release'
+ SYSTEM_DEPS_COMMAND := bash scripts/macos/build/dependencies.sh
+ SETUP_ENV := ARCHFLAGS="-arch $(shell uname -m)"
endif
GPU ?= auto
@@ -63,19 +65,22 @@ help:
@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 system-deps - Install system packages required to build and run (Debian-based, or Homebrew on macOS)$(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 icons - Generate the icon suite into src/sampletones_assets/icons$(Q)
+ @echo $(Q) make calibration - Score the reconstruction corpus; the report lands in Documents/SampleToNES/calibration$(Q)
@echo $(Q) make clean - Remove build artifacts and cache files$(Q)
@echo $(Q) make lint - Run linting (pylint, mypy)$(Q)
@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)]",.)
+ $(SETUP_ENV) uv sync --group dev $(if $(GPU_EXTRA),--extra $(GPU_EXTRA),)
+ $(MAKE) icons
+ $(SETUP_ENV) uv tool install --force $(if $(GPU_EXTRA),".[$(GPU_EXTRA)]",.)
install:
$(MAKE) setup
@@ -106,6 +111,9 @@ ftm-samples: export SAMPLETONES_FTM_OUTPUT_DIR := build/ftm
ftm-samples:
uv run python -m pytest tests/integration/famitracker
+icons:
+ uv run --group assets python scripts/assets/icons.py
+
check-import-boundary:
uv run scripts/checks/import_boundary.py --all
@@ -118,8 +126,11 @@ check-unused-tags:
check-language-keys:
uv run scripts/checks/language_keys.py
+check-palette-colors:
+ uv run scripts/checks/palette_colors.py
+
calibration:
- uv run scripts/calibration.py --all
+ uv run scripts/calibration.py
lint:
$(call script,dev/lint)
diff --git a/README.md b/README.md
index 9997d6ac..9d505bfb 100644
--- a/README.md
+++ b/README.md
@@ -4,10 +4,16 @@
[](https://pypi.org/project/sampletones/)
[](https://github.com/JakimPL/SampleToNES/blob/main/LICENSE)
+
+

+
+
## Overview
_SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/).
+
+
The core idea is to approximate an audio sample using only the chip's basic oscillators — two pulse channels, a triangle, and noise — **without any DPCM samples**.
A built-in sequencer lets you arrange the reconstructed samples into patterns and play them back inside the application, so you can experiment with the results before exporting the instruments into FamiTracker.
@@ -57,11 +63,12 @@ To install with GPU support, request the `gpu` extra (see [GPU acceleration](#gp
uv tool install "sampletones[gpu]"
```
-On Linux, audio playback and file dialogs rely on system libraries that cannot come from
-PyPI. Install them first:
+On Linux and macOS, audio playback and file dialogs rely on system libraries that come from
+the platform's package manager. Install them first:
```sh
sudo apt-get install libportaudio2 libasound2 python3-tk # Debian/Ubuntu
+brew install portaudio # macOS
```
### Building the executable yourself
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index 5e6f29fb..99cd24b3 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -35,12 +35,16 @@ are not used in any SampleToNES component name.
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,
-a direct dependency) and [soxr](https://pypi.org/project/soxr/) (LGPL-2.1-or-later, a
-transitive dependency of `librosa`) — and two, `certifi` and `tqdm`, are under MPL-2.0.
-
-All four are used as unmodified, separately installed libraries loaded dynamically at
+Most dependencies are permissively licensed (MIT, BSD, Apache-2.0, ISC). Three carry code
+under the GNU Lesser General Public License — [Pebble](https://pypi.org/project/Pebble/)
+(LGPL-3.0, a direct dependency), [soxr](https://pypi.org/project/soxr/)
+(LGPL-2.1-or-later, a transitive dependency of `librosa`), and
+[soundfile](https://pypi.org/project/soundfile/) (BSD-3-Clause itself, a direct
+dependency, whose wheel carries the libsndfile shared library under LGPL-2.1-or-later with
+LAME and mpg123 statically linked into it) — and two, `certifi` and `tqdm`, are under
+MPL-2.0.
+
+All of them are used as unmodified, separately installed libraries loaded dynamically at
import time. No LGPL- or MPL-licensed code is copied into the wheel or the sdist, so the
MIT License applies to the PyPI package without further obligation.
@@ -105,3 +109,18 @@ The published bundles are **CPU-only**: CuPy, the CUDA runtime and the NVIDIA li
are proprietary, and their EULA reserves redistribution to NVIDIA. GPU acceleration comes
from installing _SampleToNES_ from PyPI with the `gpu` extra, which fetches CuPy and the
CUDA components from their publishers straight to your machine.
+
+## Build-time tooling
+
+The application icons are drawn by `sampletones_assets.mark` and rasterized with
+[Pillow](https://pypi.org/project/Pillow/), which is under the
+[MIT-CMU license](https://github.com/python-pillow/Pillow/blob/main/LICENSE). Pillow belongs
+to the `assets` dependency group alone, so `pip`/`uv` installs it on the machine that
+generates the icons: it stays out of the wheel's dependency set, and PyInstaller is told to
+leave it out of the bundles. Both distributions carry the finished icon files, so Pillow's
+attribution clause — a condition on redistributing Pillow itself — rests with the build
+environment.
+
+The icons (`sampletones.svg`, `sampletones.png` and the multi-resolution `sampletones.ico`)
+are original _SampleToNES_ artwork and fall under the MIT License together with the rest of
+the source.
diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md
index a78178ff..05ebc602 100644
--- a/docs/concepts/reconstruction.md
+++ b/docs/concepts/reconstruction.md
@@ -242,12 +242,12 @@ reconstruction and the original can be shown and played on a common scale.
## Appendix — key parameters and where things live
-Default configuration (44.1 kHz, 30 Hz change rate, channels pulse 1 + triangle +
+Default configuration (44.1 kHz, 60 Hz change rate, channels pulse 1 + triangle +
noise):
| parameter | default | notes |
|--------------------------|---------|----------------------------------------------------|
-| frame length | 1470 | `sample_rate / nes_frequency`, ~33 ms |
+| frame length | 735 | `sample_rate / nes_frequency`, ~17 ms |
| spectrum method | `cqt` | `fft` / `logfft` / `cqt` |
| `transformation_gamma` | 0 | 0 = power spectrum, 100 = log |
| spectral / temporal weight | 0.8 / 0.2 | criterion blend |
diff --git a/docs/development/architecture.md b/docs/development/architecture.md
index 6e531296..fceecb7c 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`. 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`, the reconstruction browser has `docs/development/browser.md`, and the YAML configuration package has `docs/development/config-organization.md`.
---
@@ -73,6 +73,8 @@ Services execute long-running work on background threads. Their results are post
`Application.__init__` constructs the application graph — managers, controllers, shared services, coordinators, the shell — and wires their callbacks. A tab coordinator in turn constructs the panels, logic objects, and tab-scoped services it owns. Beyond these two sites, no component constructs another major component: every dependency arrives as a constructor argument, and none is obtained through a global lookup.
+**Where a run keeps its settings arrives the same way.** The application is given a `UserProfile` — the pair of files its configuration and its session state live in — and hands each path to the manager that reads and writes it. The entry point names the user's own profile through `UserProfile.user()`, which leaves one place that knows the shipped locations and lets a run be pointed at a location of its own.
+
### 8. All display text comes from `LanguageManager`
Every user-visible string is looked up on `LanguageManager` by the key the language file spells:
@@ -81,14 +83,16 @@ Every user-visible string is looked up on `LanguageManager` by the key the langu
page.panel.text_type.element
```
-The first three segments name members of `Page`, `Panel`, and `TextType` (`categories/hierarchy.py`); the element segment names a member of one of the element enums under `categories/elements/`. `en.yaml` is a flat map keyed exactly this way, so the dotted string is the lookup form — `language_manager["global.dialog.label.ok"]` — and a reader holds a key against the language file by eye. `categories/key/` owns the grammar: `validate_text_key` checks every key the file holds at load time, and a lookup that misses raises `MissingTextError` naming the key and the file. This makes the text system the single source of truth and enables future localisation. Log messages are developer-facing and exempt.
+The first three segments name members of `Page`, `Panel`, and `TextType` (`categories/hierarchy.py`); the element segment names a member of an element enum, which is any enum deriving from `AbstractElement`. An element enum is found by what it derives from, so one naming a panel's own widgets lives with the other panel vocabularies under `categories/elements/`, while one naming a domain's gestures — `HistoryAction` — lives beside that domain and serves as both the value the domain records and the element its label is looked up by. `en.yaml` is a flat map keyed exactly this way, so the dotted string is the lookup form — `language_manager["global.dialog.label.ok"]` — and a reader holds a key against the language file by eye. `categories/key/` owns the grammar: `validate_text_key` checks every key the file holds at load time, and a lookup that misses raises `MissingTextError` naming the key and the file. This makes the text system the single source of truth and enables future localisation. Log messages are developer-facing and exempt.
Text resolves where it is displayed. A class that reads text holds the manager as `self._language_manager`, assigned in its own `__init__`, and looks each string up at the point of use, so a language change takes effect on the next read. Where the same text is read at more than one site in a class, one named binding serves them all and the reads stay in step.
A key assembled at runtime passes its four members instead — `language_manager[Page.SEQUENCER, Panel.ORDER, TextType.LABEL, element]` — with the variable part annotated as the concrete element enum it carries (`SequencerOrderElements`, `DialogElements`). That annotation is what keeps the key checkable: the `language-keys` hook expands it to the enum's members and holds every key it reaches against the language file. A lookup therefore states its key as literals, as annotated members, or as a conditional between two literal keys — the three forms the hook reads values from:
```python
-language_manager["global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name"]
+language_manager[
+ "global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name"
+]
```
### 9. `tags/` holds only DPG identifiers
@@ -100,7 +104,9 @@ The `tags/` package contains only DPG widget string identifiers: `TAG_*` whole t
**A whole tag is a `TagName`**, the `str` subclass in `categories/key/tag.py` that names its four parts and composes them:
```python
-TAG_MAIN_EXPLORER_TREE = TagName(Page.MAIN, Panel.EXPLORER, Widget.TREE, "explorer") # main.explorer.tree
+TAG_MAIN_EXPLORER_TREE = TagName(
+ Page.MAIN, Panel.EXPLORER, Widget.TREE, "explorer"
+) # main.explorer.tree
```
The spelling is `page[.panel].widget[.element]` — `Panel.IMPLICIT` names a widget belonging to no panel, and an element repeating its panel's name is carried by the panel segment alone. A constant's name is its composed tag upper-cased with each separator turned into an underscore, behind the `TAG_` prefix, so reading either one states the other; the `tag-names` hook holds the two together.
@@ -133,19 +139,39 @@ Each keyboard consumer registers one scope through `register(handle, *, priority
| Priority | Scope | Active when | Behaviour |
|----------|-------|-------------|-----------|
| `MODAL` (100) | the open dialog's navigator | a modal dialog holds the keyboard | routes Tab/Enter/Escape to the dialog's focus ring and claims every press, so a dialog owns the keyboard exclusively while it is shown |
-| `PANEL` (60) | a sequencer sub-panel (grid / order / samples) | that sub-panel holds the cursor or selection | handles its tracker keys and yields the combinations it does not own so a higher-reaching shortcut still wins |
+| `PANEL` (60) | a sequencer sub-panel (grid / order / samples) | its tab is in front and that sub-panel holds the cursor or selection | handles its tracker keys and yields the combinations it does not own so a higher-reaching shortcut still wins |
| `SHORTCUT` (40) | application shortcuts (`ShortcutManager`) | always | fires the matching shortcut while no field is being edited, or whenever the shortcut is `field_transparent` |
Because the router offers a panel the key ahead of the shortcut scope, a panel returns `False` on any combination it does not own — the grid yields every `Ctrl`-modified press — so that field-transparent shortcuts such as `Ctrl+PgDn` / `Ctrl+PgUp` tab-switching reach the shortcut scope even while a grid cursor is set.
+**A panel scope answers on its own tab.** A cursor and a selection outlive a move to another tab, so a panel is given the predicate that reports whether its tab is the one in front and reads it at the moment of the press, the way focus is read. The composition root resolves the tab and the scope composes the answer into its `active`, which keeps the fact in one place and leaves the router's contract — the scope decides whether it wants the key — as it stands.
+
**Focus is pulled, not pushed.** Whether a text or value field keeps a plain key for itself is one router query, `is_field_focused`, that reads the focused item from DearPyGui at the moment of the press and counts it only while that item is actively being edited. Every input is covered by construction, and the router alone holds the rule.
The query resolves the focused item to the field behind it. A `dpg.group` reports the state of the widget inside it, and DearPyGui names the outermost such group as the focused item — the instruments panel's sequence input, laid out beside its copy button inside a card body group, reaches the keyboard as that group. An active group therefore answers with the field being edited below it, found by following the one branch that reports focus, so a panel-spanning group costs a key press only the path down to its field.
**Modal suppression lives in one place.** The router holds a LIFO stack of modal handlers; `push_modal` / `pop_modal` bracket a dialog's lifetime, and the built-in `MODAL` scope routes each press to the top of the stack. Since `MODAL` outranks the panel and shortcut scopes, the scopes beneath it carry no "a dialog is open" check of their own.
+**One vocabulary, one declaration.** The keyboard has one key table (`utils/gui/keyboard/keys.py`), which reads a key both ways — the name a file writes and the code a press carries — and one combination type, `KeyCombination`, which parses that spelling, displays it, and answers whether a press matches it. Above them a binding is declared exactly once: `ShortcutId` names every action a key reaches together with the category that answers it, and the scheme under `sampletones_config/keybindings/` is where the combination is decided. The menu printing an accelerator, the panel acting on a press, and the dispatcher firing the callback all read that one entry, so a printed key and the handler behind it stay in step by construction.
+
+The split is that **the combination is data and the category is code**: which keys reach an action is the reader's to choose, while which scope answers them follows from where the action is handled. A scheme is validated as it loads — every `ShortcutId` is answered, every key name resolves, and one combination reaches one action within a category — and a collision is a `SystemError` at startup, beside the layout and palette failures.
+
+A preference layers over the shipped scheme. `ShortcutsConfig` holds the scheme name and the per-action overrides, both written the way a keybinding file writes them, so a preference outlives the build that stored it: `ShortcutCatalog.select` answers with the default for a scheme a build stopped shipping, and an override naming an action this build has none of, a key the table has none of, or a combination its category already gives away is reported and left out, so one stale entry costs only itself. A change reaches the running application through `ShortcutSource.on_bindings_changed` — the keyboard's analogue of the palette switch (principle 13) — and the dispatcher re-reads the keys while the menus re-print their accelerators. Each registration names the action it fires, which is what leaves a rebind that little to catch up.
+
+**A scheme is edited through a draft.** `ShortcutDraft` (`utils/gui/shortcuts/draft.py`) holds the scheme being edited together with the actions the reader has touched — the combination each was given, or nothing where it was left unbound — so what reaches the preference is those actions alone while every other key follows the scheme beneath. An assignment displaces: giving an action a combination its category already answers takes the key from the holder in the same step, which is what makes every scheme a draft produces a valid one, and the dialog names the holder and asks before that step is taken. The draft is what the dialog edits, and a commit is what activates it, so a reader rebinding Escape, Tab or Enter keeps the keys the dialog is operated by until they are done.
+
+**A scheme belongs to a platform; an action does not.** `ShortcutId` and `ShortcutCategory` are the same on every platform, and `PLATFORM_SCHEME_NAMES` (`constants/keybindings.py`) states which scheme each one ships — the choice a profile makes once, at creation, after which the stored name selects. The modifier table reads every spelling on every platform while `Modifier.SUPER` displays as the name the machine is labelled with, so a scheme written for one keyboard loads, validates and reads on another, and the completeness validation holds every shipped scheme to the same action set.
+
The router is constructed at the composition root and injected into every consumer (principle 7); its one global handler is bound in `shell.py` once the DPG context exists.
+### 13. A colour is a token, resolved where it is drawn
+
+A colour is written as a palette token and stays one until it reaches DearPyGui. `BaseColor` (`utils/palette/colors/`) carries what was written, and its `rgba` property answers with the palette active at the moment of the read, so whoever holds the colour follows a palette swap. Every annotation names `BaseColor` — a dataclass field, a signature, a dictionary key — and `WrittenColor` appears only on the Pydantic field that validates a YAML entry. The read happens where the value is handed to a widget, and what a consumer keeps is the token.
+
+A shade is composed by naming its form. `utils/palette/colors/` is a flat star: `base.py` declares the abstract `rgba`, and each form is a peer module beside it (`literal`, `named`, `faded`, `grayscale`, `blended`, `layered`), answering with a `BaseColor` of its own — `FadedColor(color=GrayscaleColor(color=token), fraction=0.3)`. Every form is a module-level frozen dataclass, so two identical compositions are one value and a theme cache keyed on a shade hits.
+
+What DearPyGui has already taken a copy of is registered rather than remembered by whoever set it. `PaletteBindings` (`utils/gui/palette/`) records each `(item, argument)` a palette colour reached, and `dpg_set_palette_color` / `dpg_add_palette_theme_color` are how a colour gets there. A palette change is then one switch: `PaletteSource.activate` fires the composition root's listener, which re-applies the bindings, refreshes the viewport clear colour, and repaints the sequencer for the row and cell highlights DearPyGui holds as table state. The `palette-colors` hook holds all three rules (see Enforcement).
+
---
## Enforcement
@@ -154,15 +180,16 @@ Two mechanisms keep the codebase aligned with this document.
**Import-expressible contracts are enforced by script.** `scripts/checks/import_boundary.py` (a pre-commit hook, also run via `make check-import-boundary`) encodes one rule per layer, mirroring the **Must not import** lists in the Layer Reference; the Layer Reference is the source of truth, and a divergence between it and the script is itself a defect. Where a layer may consume another layer's data contract while its implementation stays out of reach (logic and the service result types), the rule carries an explicit contract exemption. The hook audits the entire source tree on every commit (`--all`), so strengthening a rule surfaces violations in files a commit never touched. That property sets the working idiom for structural refactors: turn the stricter rule on first, and let the failing hook enumerate the remaining work.
-**The identifier vocabularies are enforced the same way.** Three more scripts under `scripts/checks/` run whole-tree as pre-commit hooks, each also available as a `make check-*` target:
+**The identifier vocabularies are enforced the same way.** Further scripts under `scripts/checks/` run whole-tree as pre-commit hooks, each also available as a `make check-*` target:
| Hook | Script | What it holds |
|------|--------|---------------|
| `language-keys` | `language_keys.py` | Code and `en.yaml` against each other, in both directions: a literal key names an entry, every entry is reached by some lookup, and a lookup states values the check can read (principle 8) |
| `tag-names` | `tag_names.py` | A tag constant's name against the tag it composes (principle 9) |
| `unused-tags` | `unused_tags.py` | Every `TAG_*`/`SUF_*`/`PRE_*` the `tags/` package declares against the reads of it across `src/`, `tests/`, and `scripts/`, where an import alone stands at no reads |
+| `palette-colors` | `palette_colors.py` | A colour as a token up to the moment it is drawn with: an attribute assigned a resolved `rgba`, a theme colour filled outside the palette bindings, and a hex literal in the shipped configuration outside `palettes/` (principle 13) |
-All three read the source as an AST through the shared layer in `sampletones_shared/meta/source/`, which discovers modules, resolves the receiver a subscript sits on, and expands an enum-annotated key part to its members. Because the checks are global by nature — a dead entry and an unread fragment are both absences — the hooks pass whole-tree rather than filenames.
+They read the source as an AST through the shared layer in `sampletones_shared/meta/source/`, which discovers modules, resolves the receiver a subscript sits on, and expands an enum-annotated key part to its members; the palette check reads the shipped YAML beside it. That layer derives each package directory from its own location and reports a root it finds nothing at, so a check that sweeps nothing fails loudly where it would otherwise pass clean. Because the checks are global by nature — a dead entry and an unread fragment are both absences — the hooks pass whole-tree rather than filenames.
**Behavioral contracts are enforced by review.** Contracts a grep cannot see — where state lives, which methods touch DPG, how errors travel — are upheld in code review against this document. Deviations that survive review are recorded in `docs/development/bugs-and-todos.md § Architecture` until they are paid off; the ledger, not the codebase, is the memory of what is currently out of line.
@@ -195,7 +222,7 @@ All three read the source as an AST through the shared layer in `sampletones_sha
| `ui/resources/` | Icons and image resources loaded at startup |
| `ui/menu.py` | `MenuBar` — the application's top menu bar |
-**May import:** `view_model/`, `utils/`, `categories/`, `tags/`, `layout/`, `sampletones_core` types, `sampletones_shared`.
+**May import:** `view_model/`, `utils/`, `categories/`, `tags/`, `layout/`, `constants/`, `sampletones_core` types, `sampletones_shared`.
**Must not import:** `coordinators/`, `logic/`, `services/`, `config/`, `application.py`, `shell.py`, `utils/gui/dialogs` (`DialogsRenderer` is coordinator territory).
---
@@ -211,9 +238,9 @@ All three read the source as an AST through the shared layer in `sampletones_sha
- Edit payloads — frozen `*Update` models a panel emits through its `on_*_changed` hooks — also live here: they are the UI's outbound contract, the mirror of view models.
- Domain data containers (frozen dataclasses that wrap core types and are used across logic and services) belong in `logic/`. A type belongs in `view_model/` only if its purpose is to carry data across the UI boundary — a panel-feeding snapshot, an edit payload, or a projection a display renders (`WaveformData`).
-**Naming convention:** `ViewModel`, e.g. `ConverterViewModel`, `SequencerGridViewModel`.
+**Naming convention:** `ViewModel`, e.g. `ConverterViewModel`, `SequencerTrackerViewModel`.
-**May import:** `sampletones_core` types, `sampletones_shared`, Python standard library.
+**May import:** `constants/`, `sampletones_core` types, `sampletones_shared`, Python standard library.
**Must not import:** `ui/`, `coordinators/`, `logic/`, `services/`, `config/`.
---
@@ -226,12 +253,14 @@ All three read the source as an AST through the shared layer in `sampletones_sha
*Managers* own a domain object's lifecycle (load, save, close). They hold the current object, a `Session` that tracks dirty state, and fire `CallbackMixin` callbacks when the state changes.
-*Controllers* are thin mutation façades over a manager. `ProjectController` exposes named, typed mutation methods (`set_title`, `add_sample`, …) and emits a finer-grained callback per mutation kind (`on_info_changed`, `on_samples_changed`, …). This lets the UI respond precisely to what changed.
+*Controllers* are thin mutation façades over a manager. `ProjectController` exposes named, typed mutation methods (`set_title`, `add_sample`, …) and emits a finer-grained callback per mutation kind (`on_info_changed`, `on_samples_changed`, …). This lets the UI respond precisely to what changed. `ProjectController.batch()` widens that grain to a whole gesture: each mutation still applies the moment it is made, while the callbacks it raises wait for the scope to close and then arrive once each, so a gesture writing hundreds of rows rebuilds its subscribers once.
*Logic objects* (e.g. `ConverterLogic`) orchestrate multi-step workflows within a feature area. They subscribe to services and translate service results into view model updates.
`logic/history/` implements the session-scoped undo engine (`HistoryManager`); its invariants and mechanics are documented in `docs/development/undo.md`.
+`logic/reconstruction/browser/` builds the tree of reconstructions both browser tabs render (`BrowserManager`); its pipeline, node vocabulary and shaping rules are documented in `docs/development/browser.md`.
+
**Contracts:**
- Logic classes produce view models and may therefore import `view_model/`; they import neither `ui/` nor `coordinators/`.
- Logic classes never call DPG.
@@ -265,7 +294,7 @@ All three read the source as an AST through the shared layer in `sampletones_sha
There are two coordinator kinds:
-*Domain coordinators* manage a cross-cutting concern that spans the whole application lifecycle — e.g. `ProjectCoordinator` (project file I/O, save confirmations) or `PlaybackRouter` (the single transport over the shared output device, acting on the active tab's source or the engaged one — see `docs/development/playback.md`).
+*Domain coordinators* manage a cross-cutting concern that spans the whole application lifecycle — e.g. `ProjectCoordinator` (project file I/O, save confirmations), `PlaybackRouter` (the single transport over the shared output device, acting on the active tab's source or the engaged one — see `docs/development/playback.md`), or `EditRouter` (the single edit surface behind the menu bar's Edit menu, which shows the actions of the grid holding the cursor — see `docs/development/sequencer-blocks.md`).
*Tab coordinators* own everything for one tab: they instantiate its panels, logic objects, and tab-scoped services, wire their callbacks together, and provide `create_tab()` — the single method that builds the DPG widget tree for that tab. Tab coordinators present a narrow public API of intent-level methods (`set_input_path`, `display_reconstruction`, …) and keep their panels and logic objects private.
@@ -310,7 +339,8 @@ There are two coordinator kinds:
| Package | Purpose |
|---------|---------|
| `config/` | `ConfigManager` (domain generation config), `SessionManager` (runtime session: last paths, audio device, window geometry). Presentation-free: it records load outcomes (`ConfigLoadOutcome`) as domain data for `ConfigCoordinator` to present. Must not import the visual packages, `coordinators/`, or `application.py` |
-| `categories/` | `LanguageManager`, the `Page / Panel / TextType / Widget` enum hierarchy and the element enums that name lookup keys, and the key grammar under `categories/key/` |
+| `categories/` | `LanguageManager`, the `Page / Panel / TextType / Widget` enum hierarchy, the `AbstractElement` base and the panel element enums under `categories/elements/`, and the key grammar under `categories/key/` |
+| `constants/` | Application-scope facts that carry no behaviour, one module per subject — `keybindings.py` names the scheme a build ships, which both the shortcut catalog and the session config read, and `playback.py` names the follow mode, which the session config, the song player, the view models and the menu all state. A fact shared beyond the application belongs to `sampletones_shared/constants/` |
| `layout/` | Pydantic models loaded from YAML at startup; injected into coordinators and panels as `LayoutConfig` |
| `tags/` | DPG widget tags (`TAG_*`), the fragments composing into them (`SUF_*`, `PRE_*`), and `compose_tag` |
| `utils/` | dpg-free helpers usable by any layer (`utils/callbacks/`, colour, threading, and `utils/file_dialogs/` — OS-native file dialogs behind a `FileDialogBackend` Protocol, with the D-Bus desktop-portal client under `utils/file_dialogs/backends/portal/`). DPG-bound helpers live in `utils/gui/` and are off-limits to the non-visual layers |
@@ -441,6 +471,7 @@ sampletones_application/
├── services/ ← ServiceBase + one module or subpackage per background worker
├── config/ ← ConfigManager + SessionManager
├── categories/ ← LanguageManager + lookup enums, with the key grammar under key/
+├── constants/ ← application-scope constants, one module per subject
├── layout/ ← LayoutConfig (Pydantic) + YAML loaders
├── tags/ ← TAG_*, SUF_*, PRE_* identifiers and compose_tag only
└── utils/ ← dpg-free helpers; dpg-bound helpers under utils/gui/
diff --git a/docs/development/browser.md b/docs/development/browser.md
new file mode 100644
index 00000000..e764ab7b
--- /dev/null
+++ b/docs/development/browser.md
@@ -0,0 +1,275 @@
+# The Reconstruction Browser
+
+This document governs the tree of reconstructions the **Reconstructions** and **Sequencer** tabs
+share: how a reconstructions directory becomes rows, what a row stands for, and what it answers.
+Consult it when changing what the browser lists, how a row reads, or what a click on one does. It
+complements `docs/development/architecture.md` (layering and ownership) and
+`docs/development/guidelines.md` (coding rules).
+
+---
+
+## Principles
+
+1. **One reading of the disk feeds every view.** A refresh walks the reconstructions directory once
+ into a `ReconstructionScan`, and every branch is built from that record. The views therefore agree
+ about what exists by construction, and a folder name is parsed into its configuration fields once
+ per refresh.
+2. **The model carries the shape; the panel carries the widgets.** Which rows exist, what they are
+ called, which of them fold together and in what order they sit are decided on the tree. Both tabs
+ render one model, so they show one shape, and each rule is exercised without a window.
+3. **A row's identity is its path; its name is a label.** Favorites, the context menus, copy-path,
+ playback and opening a reconstruction all key on `filepath`. That is what frees a name to be
+ rewritten — a configuration directory renamed to its generator abbreviation, a chain of headings
+ joined into one row, a colliding label marked with its configuration hash.
+4. **The browser writes the headings the disk states rather than holds.** A frequency pair, a
+ transformation, a source folder, one source audio: each becomes a row that carries no path of its
+ own. What such a row offers follows from the subtree beneath it.
+5. **One thing may stand in several places.** A reconstruction is listed by the configuration that
+ produced it and again by the audio it was made from, so an action on the thing rather than on the
+ row asks for every row standing for it (`Tree.find_nodes`, `BrowserManager.nodes_at`) and hands
+ them to both tabs.
+6. **Per-row work happens off the main thread.** A rebuild resolves each row into a `NodeSpec` on the
+ background worker — tag, label, font, theme, handler, open state — and the main thread creates the
+ widgets from those specs, spread across frames.
+7. **What a browser narrows to is its own.** Both tabs render one model, so which rows a browser shows
+ is decided by the panel showing it: a search typed in one tab leaves the other reading as it was,
+ and each browser opens in the mode a session left it in.
+8. **The reader's shape is theirs to keep.** Which rows stand open is what the reader made of the
+ tree, so a browser records it and brings it back: a refresh, a change of filter and a repaint leave
+ the tree standing as it was, and so does the next run of the application. What a filter unfolds on
+ top of that shape is remembered as the filter's own, held for as long as the filter is, and handed
+ back when it goes — apart from a row the reader's own has come to stand on, which stays open so the
+ view they built stays on the screen.
+
+---
+
+## The pipeline
+
+`BrowserManager` (`logic/reconstruction/browser/manager.py`) owns the tree and runs a refresh in four
+steps: **scan** the directory, **build** each branch from that one scan, **shape** what came out, and
+**publish** it through `Tree.set_root`. `BrowserLogic` sits above it as the surface the coordinators
+drive, and `get_all_reconstruction_files` reads the scan.
+
+| Stage | Module | What it does |
+|---|---|---|
+| Scan | `tree/scan.py` | `scan_reconstructions` walks the directory once, recording each folder with the configuration its name states and each `.stn` file beneath it |
+| Records | `tree/entries/` | `DirectoryEntry`, `ReconstructionEntry`, `ReconstructionScan` — frozen, path-only, no widgets and no tree |
+| Configuration branch | `tree/configurations/` | `branch.py` lays the scanned folders out as they sit; `grouping.py` lifts a top-level configuration directory under frequency ▶ transformation groups and names it by its generators; `naming.py` gives the remaining configuration directories friendly names, unique among their siblings |
+| Sample branch | `tree/samples/` | `variants.py` regroups every top-level configuration directory's reconstructions by the audio they mirror (`SampleSource` → `SampleVariant`); `branch.py` rebuilds the mirrored folders as groups and gathers each audio's variants under one sample row, each labelled by its configuration |
+| Shaping | `tree/prune.py`, `tree/collapse.py`, `tree/order.py` | Run in that order over each branch, deepest rows first |
+| Containers | `tree/containers.py` | `find_or_create_group` and `find_or_create_sample` extend the heading of that name a parent already holds; each node type is looked up among the siblings of its own kind, so a folder and an audio sharing a name stay two rows |
+
+The policy the two branches share: a configuration directory sitting at the top level of the
+reconstructions directory is the one lifted under groups and transposed into the sample view. A
+configuration directory nested inside a plain folder keeps its friendly name where it sits, and a
+reconstruction outside every configuration directory appears in the configuration branch, that being
+the branch which follows the disk.
+
+## The node vocabulary
+
+`sampletones_core/structures/tree/` holds the nodes, all anytree-backed:
+
+* `TreeNode(name, node_type)` — a row and its kind. `NodeType.ROOT` for the container both branches
+ hang from, `GROUP` and `SAMPLE` for the headings the browser writes, `DIRECTORY` and `FILE` for what
+ the disk holds.
+* `FileSystemNode(filepath)` — a row standing for a path. Favorites, playability, themes and the path
+ items all test for this class.
+* `ConfigNode(config)` — a filesystem row belonging to a reconstruction configuration, carrying the
+ parsed `ConfigDirectoryFields`. It subclasses `FileSystemNode` so every reader of a path keeps
+ working, and the fields travel with the row, which is what lets a label, a tooltip and a font state
+ the configuration from the node already in hand.
+
+`create_directory_node` chooses between the last two from the fields the scan read. Which row carries
+the configuration follows the branch: in the configuration branch it is the directory that names it,
+and in the sample branch it is the variant leaf, since there the configuration is what distinguishes
+one row from the next.
+
+## The shaping rules
+
+* **Prune** (`prune_empty_containers`) — a heading the browser wrote that gathers nothing leaves,
+ deepest first, so a whole chain of them goes at once and a reconstructions directory with nothing to
+ show stays silent. A folder the disk holds stays, since the configuration branch mirrors the disk.
+* **Collapse** (`collapse_single_child_containers`) — a heading standing above a single row folds into
+ that row, which takes the joined name (`DISPLAY_SEPARATOR` between levels) and rises into its place.
+ The surviving row keeps its node type, path, configuration and children, so its click behaviour,
+ theme, context menu and favorite star carry over. A fold that would repeat a name already beside it
+ stays open instead, and the branch roots stay in place. With a single configuration present the
+ configuration branch reads as one row per reconstruction, and it grows back into groups as soon as a
+ second configuration arrives.
+* **Order** (`order_children`) — containers ahead of leaves, then `natural_sort_key` over the label, so
+ a row sits where its displayed name puts it and `8 kHz` precedes `44.1 kHz`. The pass runs once every
+ label is final; the branches directly under the container root keep the order the builder states them
+ in.
+* **Unique sibling labels** (`unique_display_names`, `sampletones_core/configs/display.py`) — where
+ siblings would read alike, every member of that label takes its short configuration hash. One rule
+ serves the generator directories under a transformation group, the nested configuration directories,
+ and the variants under a sample.
+
+## The panels
+
+The browsers form one line of inheritance, each level owning what it shares:
+
+* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the
+ filter they compose, the shape it holds across rebuilds — kept for it by `RowExpansionMemory`
+ (`ui/elements/tree/expansion.py`) — the rebuild handshake, spec collection, themes and fonts per row,
+ the detail tooltip, the status-bar messages, and the context-menu items every browser can offer.
+* `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the
+ controls bringing the tree up to date and folding it away, the tree window, the folder-and-file
+ handler pair, and enabling the card as the tree locks and unlocks. A subclass declares its widgets as
+ a `FileBrowserTags` class attribute and states what its card and refresh control read.
+* `GUIReconstructionBrowserPanel` (`ui/panels/shared/browser.py`) — the reconstruction browser: the
+ rows the two branches hold, the colour a group and a sample read in, and the context menus. The
+ Reconstructions and Sequencer panels below it name their widgets, their refresh control, and what
+ opening a reconstruction means in that tab.
+
+The Main tab's filesystem explorer and the Instructions tab's library catalogue sit on
+`GUIFileBrowserPanel` as well, so the card, the search and the rebuild machinery are shared with them.
+
+**A rebuild** starts on the tree worker: `_launch_rebuild` takes the tree lock, brings the model up to
+date, collects the rows into specs, and hands them to `TreeEmitter`, which clears the old rows and
+stages the new ones in budget-sized batches so interactive callbacks run between slices. The
+completion callback shows the empty state where one is called for, runs the panel's hook, and releases
+the lock. Because a browser is asked to rebuild from either tab and from several places in the
+application, exactly one rebuild is in flight at a time.
+
+**A row's tag** (`compose_node_tag`, `ui/elements/tree/tag.py`) joins the names above it, which reads
+the row back to whoever inspects the widget tree, and appends a digest over the exact path of
+`(node_type, name)` pairs. Rows the names alone spell alike — a folder and the audio beside it, two
+labels differing only in spacing or case — therefore keep tags of their own. A tag is composed rather
+than stored, so any holder of a node can address its row: this is how expanding a subtree, repainting a
+star and applying a filter reach the widgets.
+
+**What a row answers** follows its kind. A reconstruction plays on a click, opens on a double click,
+and offers its path items, the tab's own actions and the favorite mark. A directory offers its path
+items and the favorite mark. A group or a sample stands for no path, so its menu reads the subtree: how
+many reconstructions it gathers, expanding and collapsing everything below it, the label the tree shows
+it by, and — on a sample — the audio its reconstructions were made from, answered through any one of
+them.
+
+**Favorites are paths.** `TreeLogic.is_node_favorite` tests the row's path against the session's set,
+and `has_favorite_ancestor` tests the path's parents, so a reconstruction reads as part of a favorite
+folder wherever a view puts it — including the sample branch, whose headings carry no path. Since one
+path reaches the panel as several rows, `application.py` resolves the toggled path into every row
+standing for it and hands them to both tabs, and each row repaints with the ancestry its own path
+carries.
+
+## Filtering
+
+`TreeFilter` (`ui/elements/tree/filter.py`) holds what a browser is currently asked to show, and the
+panel showing it owns the filter. It is stated whole and replaced whole — `with_query`,
+`with_favorites_only` — so one place resolves what the browser shows, and `NO_FILTER` is the filter a
+browser showing its whole tree holds.
+
+The two criteria answer different questions, so each lands in a different place:
+
+| Criterion | What it decides | Where it lands | What a change costs |
+|---|---|---|---|
+| `favorites_only` | which rows the browser **draws** | `_append_spec` records the rows the mode shows, so `TreeEmitter` creates widgets for those alone | `redraw_tree` collects the rows again from the model in hand, on the tree worker |
+| `query` | which of the drawn rows are **shown** | `update_tree_visibility` flips `show` over the rows already on screen, once the typing settles | a resolution of the query, debounced |
+
+One rule serves both. `TreeVisibility` (`sampletones_core/structures/tree/visibility.py`) takes the
+rows a criterion named and answers which rows stay: a named row, a row leading down to one, and a row
+one holds. `resolve_visibility` keeps the named rows and the rows above them, so what a pass holds in
+memory follows the size of what was found, and a row beneath a match is answered from its own path
+upwards.
+
+**What a criterion names and what it keeps are two sets.** A criterion points the reader at some rows
+and brings others along with them, and only the first kind is worth unfolding to. The rows a criterion
+names are its **anchors**: for a search, the rows whose label matched; for the favorites mode, a row a
+star sits on, and — where no row stands for the starred path — the shallowest rows that path reaches.
+In the sample branch the headings carry no path, which is what makes the variants the rows a starred
+folder arrives at.
+
+**A criterion is read the way that criterion means.** A search shows what a matching row gathers, so a
+match opens along with the rows above it (`TreeVisibility.should_expand`). The favorites mode points
+the reader at a star, so what opens is the rows above it (`_way_down_to`, over the anchors' ancestors)
+while the star's own row stands where the reader left it — a starred folder is revealed. A starred
+reconstruction inside a starred folder anchors on its own, which is what opens the folder above it.
+
+**Which stars are followed is the reader's.** The mode decides what is drawn; whether it also unfolds
+is a preference stated per kind of favorite, held in `ApplicationConfig.browser` and offered as
+**View ▸ Auto-expand favorites**. A starred reconstruction reads the reconstructions answer; a starred
+folder, and everything it brings in where no row stands for it, reads the directories answer. Both are
+off by default, so turning the mode on narrows the tree and leaves every row standing as it was. The
+panel reads the pair through `TreeLogicProtocol`, once per resolution.
+
+**The way down opens on the pass the reader asked for, and stands for as long as the mode does.**
+Switching the mode on is the reader asking to be shown their favorites, so the pass that switch starts
+is the one that follows a star: `_state_favorites_only` records the request and `_resolve_filter` spends
+it, and the rows it opens are noted in the mode's own memory. Later passes read that memory, so a
+refresh, a query or a star gained meanwhile leaves the reader looking at their favorites, while the
+stars followed stay the ones the switch asked about. Every turn of the mode is a pass's to answer: the
+pass that reads the mode off lets the memory go and those rows fold back. A change of preference asks
+for nothing; it is answered the next time the reader asks for the mode, which keeps a menu click from
+moving the tree the reader is working in.
+
+**The way down becomes the reader's once their own rows stand on it.** A reader looking at their
+favorites opens rows of their own below the way the mode opened, so a row of the mode's holds theirs on
+the screen. `_release_mode_rows` therefore reads the model on the pass that finds the mode off — on the
+tree worker, beside the other walks a pass makes — and hands the memory the ways down to the reader's
+rows; `RowExpansionMemory.release` keeps the rows of the mode's among them, which writes the way down
+into the shape a session keeps. What is left held the mode's opening alone, and folds with it.
+
+**A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on
+the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so
+declining a row declines its subtree, and one decision covers it while the traversal walks on.
+
+**Two memories, each holding what one hand opened.** `RowExpansionMemory` owns both and the rules that
+join them, holding each row by the tag it is addressed under so a later pass creates it open again. The
+reader's rows hold what the reader did — a click, read a frame later once the row has answered it, and
+the expansion items and the collapse control, which record what they set — and they are what a session
+writes down. The mode's rows hold the way down it opened, and go when the mode does, so a narrowed
+browser hands the tree back the way the reader had it, keeping the rows theirs now stand on. Folding a
+row is the reader's word on it whichever hand opened it, so `remember` releases the mode's claim along
+with the reader's and the row stays folded. A pass writes from the tree worker while a click writes from
+the main thread, so one lock covers every answer the memory gives. Both sets are held to the rows the
+model states, read afresh on every pass, so a row a moved reconstructions directory left behind leaves
+them with it. Which browsers record a shape at all is `_REMEMBERS_EXPANSION`: it decides whether a click
+is followed through to the memory, and a browser that keeps none leaves it empty.
+
+A search unfolds by the same rule from the other end: its matches and the rows above them open for as
+long as the query stands, resolved afresh on each pass, and clearing the query folds them back.
+
+The shape outlives the run as well. A browser is handed the mode and the rows it opens with as it is
+built (`initial_favorites_only`, `initial_expanded_rows`). A change of mode is written where it happens,
+through `on_favorites_filter_changed`, and the shape is asked for the once, at exit:
+`_persist_application_state` takes each tab's rows into `ApplicationState.expanded_rows` under the
+panel's tag, so a pass holds what it opened in memory, on the tree worker, and the session file reads it
+from there.
+
+**The Main tab's explorer remembers folders, not rows.** Its rows are the folders on disk, read a level
+at a time as the reader opens one, so `ExplorerManager` holds two facts about a folder: whether its
+children have been read, and whether its row stands open. They part company — a folder read and then
+folded away is loaded and closed — and the open one is the shape a session writes to
+`ApplicationState.expanded_directories`. A refresh reads down to each remembered folder through
+`_expand_path_to`, reading every folder it needs once, and the folders that are no longer directories
+are dropped as the manager is built.
+
+**What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing each
+row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents` — and the
+anchors the preference follows are read out of that one answer. What it materialises is the starred rows
+and the rows above them, and what reaches DearPyGui is the drawn rows alone: on a directory holding
+hundreds of thousands of reconstructions, a favorites-only browser creates widgets for the starred ones
+and their headings. A keystroke resolves the query alone, the drawn rows being the mode's to state. A
+favorite toggled while the mode is on redraws the browser, so starring a row brings it in and unstarring
+one takes it out along with what it held.
+
+A rebuild that drew no row fills the cleared tree with the message naming the criterion that came back
+empty (`global.dialog.message.tree_no_favorites`, `global.dialog.message.tree_no_results`), so the
+filter's answer reads where the rows would be.
+
+**The control** is a checkbox under the search box carrying the favorite glyph, which reads in the
+favorite colour while the mode is on and muted while it is off. `_OFFERS_FAVORITES_FILTER` states
+which cards hold it: the reconstruction browsers, whose rows stand for the paths a session stars. It
+follows the tree's lock, a rebuild being what it asks for, and its label reads in the pair every
+checkbox reads — the text colour while it can be clicked, the muted one while a rebuild holds it — so
+the shade states whether the control is live.
+
+Each browser opens in the mode it was left in. The panel raises `on_favorites_filter_changed` with its
+own tag, and the tab coordinator writes it to `ApplicationState.favorites_filters` under that tag,
+which is how a collapsed card is remembered too.
+
+**Folding the whole tree away** is the other control every card carries. It reaches the rows through the
+model rather than the widget tree, so one pass covers a branch however deep it runs, and it records what
+it set — leaving the memory empty, which is the shape a later pass then draws. The explorer folds first
+and drops the folders it had read afterwards, so opening one lists it as it stands on disk.
diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md
index f181a0af..198ab3bc 100644
--- a/docs/development/bugs-and-todos.md
+++ b/docs/development/bugs-and-todos.md
@@ -3,42 +3,41 @@
### Navigation
* Interface scale
-* VSync/frame rate options
* Tree navigation using keys
* Waveform LOD for zooming
-* Keybindings options
-* Tracker cell shortcuts
+* Alt for scrolling graphs
* Drag and drop
* Multiple Reconstruction views
+* In-project sample selection in Reconstruction view
* Playing a fragment by clicking on a waveform
-* Transpose/note pitch display duality
+* Note pitch shown as a transpose offset rather than a note name
### Tracker
* Basic shapes as instruments
-* Selection operations on patterns and orders
-* Replace/swap sample
### Workflow
* Waveform construction preview for single-file conversion
-* Selection and trimming for a reconstruction (reconstruction editing)
+* Selection operations on a reconstruction
+* Reconstruction trimming
### Features
-* Theme selector and palette management
* In-application guide/tutorial
* Language selector
### Technical
* API documentation
-* Code documentation (docstrings)
+* Code documentation
* Backward compatibility: library/reconstruction upgrade scheme
* Respecting FamiTracker limitations
-* Carrying the project comment and tempo into a Bitphase document, once the format holds them
* Per-tab undo routing
+* In-application console
+* Improve performance of browser favorite scan of the entire tree per click
## Bugs
* No refreshing after library generation
+* Misaligned dialog boxes sizes at initialization
diff --git a/docs/development/config-organization.md b/docs/development/config-organization.md
index ce1ad1e3..fb4df14c 100644
--- a/docs/development/config-organization.md
+++ b/docs/development/config-organization.md
@@ -10,10 +10,10 @@ is read; use it as the reference when adding or moving a value. It sits alongsid
first:
- **Shipped configuration** — the `sampletones_config` YAML package: layout, theme,
- palette, language, behavior, deployment, and calibration. *(This document.)*
+ palettes, keybindings, language, behavior, deployment, and calibration. *(This document.)*
- **Runtime user preferences** — mutable state persisted to the user profile
- (`sampletones_application/config`, e.g. `PlaybackConfig`, `ApplicationState`), governed
- by that package.
+ (`sampletones_application/config`, e.g. `PlaybackConfig`, `ShortcutsConfig`,
+ `ApplicationState`), governed by that package.
- **Project generation settings** — JSON stored beside a project
(`sampletones_core/configs`, `config.json`), documented in
`docs/formats/configuration.md`.
@@ -29,8 +29,8 @@ that reads them. The dependency runs one way — a consumer imports the data pac
resolve its directory (`CONFIG_DIRECTORY`), and the package itself is pure YAML with an
empty `__init__.py`. Each schema lives with its reader:
-- `sampletones_application` owns the layout, theme, palette, language, behavior, and
- deployment schemas.
+- `sampletones_application` owns the layout, theme, palettes, keybindings, language,
+ behavior, and deployment schemas.
- `sampletones_core` owns the calibration schemas.
- `sampletones_shared` owns the loader primitives (`load_yaml_model`,
`load_yaml_model_dir`).
@@ -41,9 +41,22 @@ on their own terms.
### 2. The top level is organized by domain
`sampletones_config` has one top-level directory per schema family and its loader:
-`application`, `behavior`, `calibration`, `lang`, `layout`, `theme`. Each domain owns its
-schema and its load path (see [Domains](#domains)). A new domain is a new top-level
-directory with its own schema owner and loader.
+`application`, `behavior`, `calibration`, `keybindings`, `lang`, `layout`, `palettes`,
+`theme`. Each domain owns its schema and its load path (see [Domains](#domains)). A new domain
+is a new top-level directory with its own schema owner and loader.
+
+Palettes are a domain of their own because two other domains resolve against them: a colour
+field in `layout/` and a colour entry in `theme/` both name a palette token, and the palette
+is what turns that name into a value. A directory holds one file per palette, named after the
+palette it declares, and every palette answers the same token set — an entry names one token
+and each palette must have an answer for it.
+
+Keybindings are a domain on the same shape: a scheme is a named set a preference selects by
+name, so the directory holds one file per scheme, named after the scheme it declares, and
+every scheme answers the same action set — an entry names one `ShortcutId` and each scheme
+must have a combination for it. What the directory carries is the combinations, which are a
+reader's to choose; the actions and the category each belongs to are code, since they follow
+from the scope that handles the press.
### 3. The config tree mirrors the code
@@ -54,9 +67,9 @@ predicts its place in the code. Three conventions keep the mirror true:
- **A feature area is a directory of fragments.** Each area is a directory loaded by
`load_yaml_model_dir`; every `.yaml` supplies the model's ``, and an
- optional `root.yaml` carries the loose scalars that own no section file. Three
- cross-cutting resources — `fonts.yaml`, `glyphs.yaml`, `palette.yaml` — are single
- self-contained files at the `layout/` root, each one resource in one file.
+ optional `root.yaml` carries the loose scalars that own no section file. Two
+ cross-cutting resources — `fonts.yaml` and `glyphs.yaml` — are single self-contained
+ files at the `layout/` root, each one resource in one file.
- **File stem = field = model.** `choice.yaml` fills field `choice`, validated by
`ChoiceLayout` in `choice.py`; the three names match within a domain, so one name traces
a value from YAML through field to schema. A stem is unique within its domain: the same
@@ -118,13 +131,29 @@ each value sits in the tree stays in the factory.
| Application | `application/` | `DeploymentConfig` (`sampletones_application/config/deployment/`) | `DeploymentConfig.load()`, with `SAMPLETONES_*` env overrides |
| Behavior | `behavior/` | `BehaviorConfig` (`sampletones_application/layout/behavior.py`) | folded into `LayoutConfig.behavior` by `load_layout_config` |
| Calibration | `calibration/` | `CorpusConfig`, `RefereeConfig` (`sampletones_core/calibration/config/`) | each model's own `.load()` |
+| Keybindings | `keybindings/` | `ShortcutScheme` (`sampletones_application/utils/gui/shortcuts/`) | `ShortcutCatalog.load()`, indexed by scheme name |
| Language | `lang/` | `LanguageManager` (`sampletones_application/categories/`) | flat string map keyed `page.panel.text_type.element`, each key validated at load |
| Layout | `layout/` | `LayoutConfig` (`sampletones_application/layout/config.py`) | `load_layout_config` (`layout/loader.py`) |
+| Palettes | `palettes/` | `Palette` (`sampletones_application/utils/palette/`) | `PaletteCatalog.load()`, indexed by palette name |
| Theme | `theme/` | `ThemeSpec` (`sampletones_application/ui/themes/spec.py`) | `ThemeLoader.load_all()` → `ThemeRegistry` |
-The palette (`layout/palette.yaml` → `Palette`, `sampletones_application/utils/palette.py`)
-is a layout-domain resource loaded first and injected as validation **context**, so any
-colour field in layout or theme resolves its palette tokens against the one loaded palette.
+The palettes load first, and the source holding the active one is injected as validation
+**context**, so any colour field in layout or theme keeps the token it was written as and
+reads its value from the palette in place when it is drawn with. `PaletteCatalog` names the
+palette a preference selects and answers with the default (`studio`) for a name the build
+does not ship, so a preference outlives the build that wrote it.
+
+`ShortcutCatalog` answers the same way for a keybinding scheme, with the shipped `default` as
+its fallback. A scheme is validated as it is read: every action the application names is
+answered, every key name resolves against the key table, and one combination reaches one
+action within a category, so a scheme in use resolves any press its category owns. The user's
+own rebindings stay on the preference side (`ShortcutsConfig`) and are applied over the
+selected scheme at startup, which keeps the shipped file the statement of what a build offers.
+
+The domain holds one file per keyboard the build ships — `default.yaml` and `macos.yaml` —
+and the platform decides which one a profile starts on: `ShortcutsConfig.scheme` takes its
+default from `PLATFORM_SCHEME_NAMES`, so the choice is made when the configuration is created
+and the name stored there selects the scheme on every run after.
Layout and theme schemas are `frozen=True, extra="forbid"`, and loading is eager at the
composition root (`Application.__init__` → `load_layout_config`, wrapped as `SystemError`),
@@ -140,7 +169,7 @@ that import `SchedulingBehavior` as a type.
## Loading
-Two load mechanisms serve the two grouping schemes:
+Three load mechanisms serve the three grouping schemes:
- **Field aggregation** (layout, and every domain that mirrors the code).
`load_layout_config` builds `LayoutConfig` field by field — `load_yaml_model` for a
@@ -154,7 +183,12 @@ Two load mechanisms serve the two grouping schemes:
graph, and registers the results in the `ThemeRegistry` singleton keyed by `tag`. Here
the directory grouping serves people and the `tag` and `extends` fields carry the load
meaning; every theme extends the base `default` unless it names another parent.
+- **Name-keyed discovery** (palettes, keybindings). `PaletteCatalog.load()` reads every
+ `*.yaml` under `palettes/` and indexes it by `Palette.name`, holding each file's stem
+ against the name it declares so one name traces a palette from a stored preference to the
+ file on disk. `ShortcutCatalog.load()` reads `keybindings/` the same way, keyed by
+ `ShortcutScheme.name`.
-Palette, deployment, and calibration each load through a bespoke `.load()` classmethod over
+Deployment and calibration each load through a bespoke `.load()` classmethod over
the same low-level primitives in `sampletones_shared/utils/serialization.py` — the one
module that calls `yaml.safe_load`.
diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md
index 5c09edac..e0de8283 100644
--- a/docs/development/dependencies.md
+++ b/docs/development/dependencies.md
@@ -18,12 +18,60 @@ See [GPU acceleration](../guide/installation.md#gpu-acceleration) for enabling i
Instruction libraries and reconstructions are serialized with [MessagePack](https://msgpack.org/) (the `msgpack` package). No external compiler or system dependency is required — it is installed automatically with the package.
+## Audio playback
+
+Playback goes through PortAudio, reached with the `pyaudio` package. PyPI carries `pyaudio` wheels for Windows, so Linux and macOS compile it on install and need the PortAudio headers and library on the machine. Linux takes them from the distribution packages listed in `scripts/linux/build/dependencies.sh`; macOS takes them from Homebrew through `scripts/macos/build/dependencies.sh`.
+
+Compiling on macOS also depends on the interpreter's architecture. The python.org installer ships a universal2 build, which compiles extensions for both Apple Silicon and Intel, while Homebrew's `libportaudio` carries the machine's own architecture. Pinning `ARCHFLAGS` to `uname -m` settles it on the native one: `make setup` sets it directly, and the CI workflows take it from `scripts/macos/build/build_env.sh`, which reports it as a `KEY=VALUE` line alongside the PortAudio prefix for a Homebrew installed outside its usual place.
+
+## Audio rendering
+
+Audio files are written with libsndfile, reached with the `soundfile` package. Its wheels carry a
+prebuilt libsndfile 1.2.2 for every supported platform, so the encoders come with the package and
+need nothing installed alongside them.
+
+Which formats an installation writes is asked of the library at runtime, because libsndfile is built
+with a codec set that varies by platform and packaging — the MP3 encoder in particular arrived in
+1.2.0 and is present where it was compiled in. The chooser offers the formats the library reports,
+so what a user is shown describes the machine it is running on.
+
+| Format | Sample rates | Quality |
+| --- | --- | --- |
+| WAV | 8000, 16000, 22050, 44100, 48000, 96000, 192000 Hz | 8, 16, 24 or 32-bit PCM, or 32-bit float |
+| MP3 | 8000, 16000, 22050, 44100, 48000 Hz | a bitrate from the ladder its MPEG version defines |
+
+The bitrates on offer narrow with the sample rate: up to 320 kbps at 44100 and 48000 Hz, 160 kbps at
+16000 and 22050 Hz, and 64 kbps at 8000 Hz. libsndfile takes MP3 quality as a compression level
+between 0 and 1 and turns it into a rung on that ladder, so a bitrate is reached through the level
+its rate maps it to, measured per rate and held in `sampletones_core/audio/writers/bitrate.py`.
+
## File dialogs
Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser`), reached over D-Bus with the pure-Python `jeepney` package on Linux. The portal lists every offered file type in its selector and reports back the one the user picked, which is what lets a save settle its format from the type chosen there. Where no portal answers, `kdialog` and `zenity` take over, and Tk last.
`jeepney` is declared for Linux alone, so the modules that speak to the portal are imported where it is installed: the application probes for it before reaching them, and the root `conftest.py` keeps them out of collection elsewhere, leaving the Linux runs of the suite to cover them.
+## Application icon
+
+The icon suite in `src/sampletones_assets/icons` is generated from the mark declared beside it in
+`src/sampletones_assets/mark`: `mark.yaml` carries the geometry, colours and rasterization
+settings, validated as a `Mark`, and `template.svg` is the vector the rendered geometry fills. The
+package writes the whole suite — the vector `sampletones.svg` and the rasters the application
+ships, `sampletones.png` and the multi-resolution `sampletones.ico` — and `scripts/assets/icons.py`
+points it at the directory the icons are shipped from. Rasterization uses Pillow, declared in the
+`assets` dependency group.
+
+The whole suite is committed, so a plain checkout carries the icons the application opens its window
+with, and every wheel, bundle and test run finds them where they lie. `make icons` writes them again
+from the mark, and the `icons` pre-push hook writes them for a push that touches either directory,
+holding the committed files to what the mark describes. CI runs that same hook.
+
+Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that:
+`pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is
+installed, and PyInstaller follows that import into the bundle. The application reads its icons as
+files, so the exclusion spares every bundle Pillow's extension modules and the imaging libraries
+that come with them. `scripts/ci/checks/bundle.py` holds the release bundles to it.
+
## Linux (standalone executable)
Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list.
diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md
index b3085b39..0d2277aa 100644
--- a/docs/development/guidelines.md
+++ b/docs/development/guidelines.md
@@ -31,7 +31,7 @@ These rules govern the Python in this repository. They complement
1. An `__init__` exposes only names from within its own tree hierarchy.
1. Give each module a single area of responsibility.
1. If a module contains many class and function definitions, split into a subpackage divided by a single concern.
-1. If a private function serves only a class in the module it lives, move it to the class as a static/class method or isolate helper functions into a separate utility module.
+1. If a private function (or public that does not have any external consumers) serves only a class in the module it lives, move it to the class as a static/class method or isolate helper functions into a separate utility module.
1. Prefer subpackages over a flat directory structure.
1. Isolate platform-, desktop-, or external-tool-specific behaviour behind a `Protocol` with one implementation per target, selected by a runtime factory that probes availability and environment. Callers depend only on the `Protocol` and stay platform-agnostic.
1. Wrap a third-party library or OS tool whose behaviour differs across platforms behind our own typed interface, and encode each quirk inside the matching implementation. A comment naming the third-party behaviour is warranted there.
@@ -86,8 +86,12 @@ These rules govern the Python in this repository. They complement
1. A test file mirrors the ownership of the code it exercises.
1. When functionality moves between packages, move its direct unit tests in the same change.
1. Parametrize tests that share a body, using a test-case dataclass.
+1. Test case classes and cases themselves should be defined inside the testing class, unless these objects are shared between test classes. A suite inherits from `BaseTestSuite` and names its case class `TestCase`, which inherits from `BaseRegularTestCase`, or from `BaseAutolabelTestCase` where the case derives its own label. The parametrized argument carries the case as `test_case`.
1. For a multi-step scenario, use a test-scenario suite class — a series of functions with assertions.
1. Prefer fixtures over factories, and define shared fixtures in an appropriate place.
-1. Do not assert default values of configurations, layouts, settings, and similar. Defaults are not contracts, and pinning them overconstrains the tests. Test behavior instead: validation bounds, serialization round-trips, and invariants. The exception is when values must match by contract rather than equal a chosen constant — e.g. project metadata at creation or after a save/load round-trip should be asserted to match, never hardcoded to a version string.
+1. **A shipped value is a choice, not a contract.** Defaults, keybinding schemes, palettes, layouts, and the settings a build opens on are tuned freely, so a test that restates one turns every adjustment into a test edit. Assert behaviour instead: validation bounds, serialization round-trips, fallback and recovery paths, and the invariants a value satisfies — a default lies within the range offered, every palette declares the same tokens, every action the application names is answered.
+1. **Read a configured value; do not repeat it.** Where a case needs the keys an action answers, a palette's colour, a layout's dimension, or a default a model falls back to, it reads that value from the configuration under test and derives the rest of the case from it. A case that presses a key states which action it is pressing, resolves the combination from the scheme, and keeps passing once that action is rebound.
+1. **A literal shipped value needs a stated reason.** Write one only where the value itself is the contract — a file format's constant, a value another system reads back, an interoperability requirement — and say so in the case. Asserting against the named constant that defines the value (`DEFAULT_MAX_FPS`, `DEFAULT_SCHEME_NAME`) states where the value comes from and is welcome; a bare literal standing for the same thing is the pin this rule forbids.
+1. Values that must match by contract are asserted to match, never hardcoded — e.g. project metadata at creation or after a save/load round-trip is held against its source, never against a version string.
1. Unit tests may mock system boundaries (file I/O, external services, IPC channels), but must not mock the domain logic that is the subject of the test. Integration tests must exercise real computation pipelines against real (synthetically built) data.
1. When a test expectation diverges from the production code's actual behaviour, determine which is wrong before acting. A failing test is evidence of a potential bug in the production code unless the test itself is demonstrably incorrect (wrong imports, misread API contract, incorrect fixture). Never silently delete or weaken a test to make it pass. If uncertain, flag the divergence explicitly and ask before changing either side.
diff --git a/docs/development/playback.md b/docs/development/playback.md
index b4513237..6446cd8b 100644
--- a/docs/development/playback.md
+++ b/docs/development/playback.md
@@ -25,10 +25,17 @@ a control over what is heard. The contracts here bind every tab and every player
same behaviour.
5. **Listening choices stay out of the document.** What the user chooses to hear is session state;
what the project holds is the whole song. Saving, export, rendering, and history read the
- document, so each of them works on the full song whatever the user is listening to.
+ document, so each of them works on the full song whatever the user is listening to. A render
+ reads the document as it stood when it was asked for: every channel sounding, at unity gain,
+ played through once.
6. **Live state is pulled while sound is produced.** A player reads the settings that shape its
sound as it renders, so a change is heard as the render-ahead buffer drains. This is what lets a
listening control take effect inside the sound already playing.
+7. **A row's duration belongs to the song, not to the player.** How long a row lasts follows from
+ the project's tempo and metre together with the row's place in the pattern, so it is a function
+ of position: the same row lasts the same time however playback reached it, and a module exported
+ from the song can state the same figures. The integer tick counts the groove places *are* the
+ tempo, so a render realises them exactly at every rate it offers.
## Two kinds of sound
@@ -95,6 +102,19 @@ showing the source sounding elsewhere, and shows the local source on tabs that h
tied to one screen — playing from the shown frame — is offered on that screen with its document
open.
+The sequencer view reports the playhead too, at the reach the **follow mode** chooses: the sounding
+row, the frame that holds it, or the view the user placed. The mode is one setting with two derived
+answers — whether the tracker shows the frame being played, and whether it scrolls to keep the
+sounding row in sight — and those two are its whole contract, which every surface that follows the
+playhead reads. The song player holds the mode and emits it with every position, which is what lets
+the menu's check and the grid's scrolling settle in one step when the mode changes mid-playback.
+
+A mark belongs to what it names. The playhead's position is a frame and a row within it, so the
+order grid marks the frame under every mode, while the row's mark reads as the sounding row of the
+pattern on screen: the tracker carries it while the frame it shows is the frame that sounds, and the
+mark travels with the frame across a structural order edit. Every mode paints on this rule, and the
+mode governs where the view sits.
+
## Keyboard delivery under field focus
Playback keys arrive through the application's single key handler (architecture §12). These rules
@@ -127,7 +147,8 @@ click to silence, modified click to solo, the master name for the whole mix —
the gesture and its right-click menu to one object, so both offer the same wording and the same
behaviour. The Playback menu's **Channels** submenu carries the same set as a check per channel, plus
one item that returns the whole mix. Each of those items is registered as an action whether or not a
-key is bound to it, so the keybindings options can assign one and the menu lists it.
+key is bound to it, so the keybinding scheme can give it one and the menu prints what the scheme
+says (architecture principle 12).
The mask is pulled per rendered row, which is principle 6 for this control: a channel drops in or
out as the render-ahead buffer drains, with the immediacy every other live edit has. A silenced
@@ -141,6 +162,74 @@ sequencer distinguishes a history restore from a document transition. And the mu
listening session, so opening, creating, or closing a document starts a fresh one with every channel
audible.
+## What the channel holds
+
+A sample states every dimension of every frame, and its reconstruction names which of those
+dimensions the instrument itself wrote. The rest are the channel's: each channel carries a value per
+dimension — volume, arpeggio, timbre — and an instrument leaving one empty sounds it at the value the
+channel holds. That is what clearing an envelope in the instruments panel means once the sample is
+played in a song, and it is the same rule a FamiTracker instrument follows with a sequence left out.
+
+The value moves as the song plays. Every frame an instrument writes hands its value to the channel,
+so the channel keeps the last one written and an instrument that leaves the dimension empty picks it
+up. A silent frame states its level alone, leaving pitch and timbre where the channel holds them.
+
+A pass through the song begins on the values a channel holds from the start — full volume, no
+arpeggio offset, the first timbre — so starting the song and looping back to its first row both
+sound the same. Seeking within a running song keeps the values, since the channel has reached them.
+
+## Rendering the song to a file
+
+A render writes the whole song to an audio file through the kernel that plays it. `RowSynthesizer`
+serves both: the player drives it to feed the device, the render drives it to feed a file writer.
+The synthesis is therefore written once, and the file and the playback agree on what the song
+sounds like by construction.
+
+Two things differ between them, and each is stated by whoever asks for the audio. The **document**
+is a seam: a kernel reads its project through `ProjectSource`, which the live controller satisfies
+for playback and a frozen `ProjectSnapshot` satisfies for a render — so the player follows every
+edit as the buffer drains (principle 6), while a render describes one state of the document however
+the project moves on. The **rate** is the consumer's: the device for playback, the chosen output
+format for a render. The kernel rebuilds its generators and its tick clock when either moves, so a
+file is written at the rate its engine ran at.
+
+A rate is therefore asked for once there is audio to take it, which is the first row a kernel
+renders: a device has been chosen by the time playback starts, and a format by the time a render
+does. A session on a machine offering no output device opens on that rule, and everything that
+writes rather than sounds — editing, exporting a module, rendering to a file — works on it.
+
+The song's exact length follows from the timing model before a sample is rendered: the order's
+length in rows gives the ticks, the tick clock gives the samples those ticks span. That figure is
+what the progress bar counts against and what a finished file measures.
+
+Rendering is an exclusive operation (architecture principle 10). It occupies the application from
+the moment its dialog opens until that dialog closes, and it joins the same busy authority as
+conversion and library generation, so each of the three holds the others off and every surface
+offering one reads a single answer.
+
+The write itself takes one pass, or two where the user asks for a normalised peak: the first pass
+spills raw samples and discovers the peak, the second reads them back and encodes at the scale that
+peak sets. Each pass names itself, so the bar crosses one axis — samples — twice, holding a single
+unit across both. A cancel is honoured between rows and between encoded blocks, and a render that
+is stopped or fails clears the destination and the spill, so a result names a path where a finished
+file stands.
+
+## Teardown
+
+The device is torn down once every source holding a stream has released it. A source that streams to
+the device writes from a thread of its own, so that source alone can bring the writing to a stop and
+hand the stream back — and the hand-back is what leaves the backend safe to terminate.
+
+`PlaybackRouter.shutdown()` is the seam the application calls as it quits. It reaches every registered
+source rather than the engaged one alone, so a source holding a stream is wound down whatever the
+transport reports at that moment.
+
+The device holds a release per stream it handed out and invokes it whenever it needs the output free:
+as the backend is torn down, and on a device change, where the release stops the song so the new
+device opens cleanly. A stream that outlives its release leaves the running backend in place — the
+manager reports the failure and keeps the instance, since the source still writes to memory that
+terminating would reclaim.
+
## Who governs what
| Concern | Owner |
@@ -148,13 +237,28 @@ audible.
| The device, its stream, and arbitration between requests | `AudioDeviceManager` (`sampletones_core/audio/`) |
| The ranking that settles a contest for the device | `PlaybackPriority` (`logic/shared/`) |
| The verbs, target resolution, and the registry of sources | `coordinators/playback/router.py` |
+| Winding every source down ahead of backend teardown | `PlaybackRouter.shutdown()` (`coordinators/playback/router.py`) |
| A source's engagement reporting | the transport's player protocol, implemented per source |
| Error presentation for a source's failures | `GuardedPlayer` (`coordinators/playback/guard.py`) |
| Keyboard delivery, priority, and field focus | `utils/gui/keyboard/` (architecture §12) |
| The sequencer's mute set, its mask, and solo | `SequencerChannelsLogic` (`logic/sequencer/channels.py`) |
| A channel name's gestures and menu, in either table | `ChannelSwitch` (`ui/panels/sequencer/channels.py`) |
-| Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer.py`) |
+| The reach the sequencer view follows the playhead at | `FollowMode` (`constants/playback.py`), held by `SongPlayerLogic` (`logic/sequencer/playback/song_player.py`) |
+| Where the playhead stands, and both grids' marks for it | `SequencerTabCoordinator` (`coordinators/tabs/sequencer.py`) |
+| Marking and revealing the sounding row in the tracker | `GUISequencerTrackerPanel` (`ui/panels/sequencer/tracker.py`) |
+| Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) |
+| Filling in the dimensions a channel governs, frame by frame | `SampleVoice` (`logic/sequencer/playback/synthesizer/voice.py`) |
+| The values a channel holds between frames | `ChannelState` (`logic/sequencer/playback/synthesizer/state.py`) |
+| The channel generators and the rates they are built at | `ChannelBank` (`logic/sequencer/playback/synthesizer/bank.py`) |
+| How long each row of a pattern lasts | `Groove` (`sampletones_core/timing/`), indexed by row while rendering |
+| How many samples one of that row's ticks spans | `TickClock` (`sampletones_core/timing/`), followed by `EngineRates` |
| The song's render-ahead buffer | `services/song_player/` |
+| The document a kernel reads, live or captured | `ProjectSource` / `ProjectSnapshot` (`logic/shared/project_source.py`) |
+| The ticks the order lasts and the samples they span | `SongLength` (`logic/sequencer/playback/synthesizer/length.py`) |
+| Rendering the song to a file, its passes and its progress | `SongRenderService` (`services/render/`) |
+| Where a rendered file's samples go, normalised or direct | `RenderSink` (`services/render/sink.py`) |
+| The choices a render is made under, and the phase it is in | `SongRenderLogic` (`logic/render/`) |
+| The formats a file may be written in, and what each accepts | `sampletones_core/audio/writers/` |
The sequencer song is an ordinary intentional source alongside the reconstruction and instruction
players: it implements the same protocol and is arbitrated by the same rules.
diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md
new file mode 100644
index 00000000..142b2652
--- /dev/null
+++ b/docs/development/sequencer-blocks.md
@@ -0,0 +1,277 @@
+# Sequencer blocks
+
+A **block** is a rectangle of one sequencer grid, lifted out of the song so it can be
+written back somewhere else. Copy, cut, paste and delete are the four gestures over it,
+and both grids — the tracker's pattern rows and the order's frames — carry the same set.
+
+This document states the rules those gestures follow, how a block leaves the app as text,
+how a selection is drawn, and how a grid's actions reach the menus and the keyboard that
+fire them. The layering they sit in is
+[Architecture](architecture.md); the conventions the code is held to are the
+[coding guidelines](guidelines.md).
+
+## Three vocabularies, kept apart
+
+A gesture crosses three representations, and each has one owner:
+
+| Term | Where it lives | What it names |
+|------|----------------|---------------|
+| **Cursor** | `ui/panels/sequencer/input/` | Where the reader is typing, plus the anchor a selection was started from |
+| **Region** / **Cell** | `view_model/sequencer/region.py` | The rectangle a gesture acts on, and the single cell a paste is anchored at — grid coordinates, inclusive bounds |
+| **Block** | `logic/sequencer/tracker/`, `logic/sequencer/order/` | The values themselves, keyed by offsets from the cell they were read at |
+
+A region names *where*; a block carries *what*. A block holds offsets rather than
+coordinates, which is what lets it land anywhere it is anchored.
+
+Two axes underpin both grids:
+
+- **`constants/sequencer.py::CHANNEL_AXIS`** — `(None,) + GeneratorName.items()`. Index 0
+ is the aggregate column (the tracker's **Sample**, the order's **Master**) and 1 to 4
+ are the channels. Both grids lay out along it, so a row index means the same thing in
+ either.
+- **`view_model/sequencer/slot.py::TrackerSlot`** — a column paired with a subcolumn,
+ readable as a single flat index. Navigation and selection walk the flat index; an edit
+ addresses the pair.
+
+## A cell reaches a block in one of three states
+
+The state is carried by the block's map alone, so every consumer reads it the same way:
+
+| State | In the map | Written as |
+|-------|-----------|------------|
+| A value | Key present, holding it | That value |
+| Empty | Key present, holding `None` | Emptiness — the target is cleared |
+| Mixed | Key absent | Nothing — the target keeps what it had |
+
+Mixed is what an aggregate cell reads when the channels beneath it disagree, the same
+`?` the grid displays. Display and clipboard route through one rule,
+`sampletones_shared/utils/agreement.py::Agreement`, so a block states about a cell
+exactly what the table it was read from shows there.
+
+Absence is also what settles the order's growth (below): a column a block says nothing
+about reaches nothing.
+
+## Kind alignment is arithmetic
+
+A tracker block carries subcolumn offsets measured from `column_slot_base(column)`, and
+every base is a multiple of the subcolumn count. An offset therefore addresses the same
+kind of subcolumn at whichever column it is replayed against: an instrument value cannot
+reach a volume slot. The paste hook takes a `TrackerCell` — a row and a column, with no
+subcolumn — so the type states the rule: the anchor decides *where* a block lands and the
+block decides *which kind* goes where.
+
+## A paste is a run of the single-cell edits
+
+The writers resolve every cell to a method the grid already has:
+`SequencerTrackerLogic.place_note` / `cut_note` / `set_cell_subcolumn` /
+`clear_cell_subcolumn`, and `SequencerOrderLogic.write_entry`. Nothing about the aggregate
+column's fan-out is restated in a writer, so a pasted cell means exactly what the same
+value typed by hand means. That is why each write is explainable, and why the aggregate's
+rules have one home.
+
+Two consequences follow from the order the writes are taken in:
+
+- Within a position, the aggregate row is written before the channels beneath it, so a
+ channel cell in the same block overwrites what the aggregate settled. The more specific
+ write wins.
+- In the tracker, notes land before the transposes and volumes sharing their row, because
+ placing a sample through the **Sample** column clears the channels of that row.
+
+## The order grows to what a paste reaches
+
+A block pasted past the last frame appends frames, and the rule is stated in terms of
+writes rather than the block's shape: the order grows to the last position a write
+actually lands at. A `?`-only overrun column appends nothing; one holding an empty cell
+appends the frame it silences. Rows clipped at **Noise** take their columns' growth with
+them.
+
+Growth runs before the first write, so one history entry covers the appended frames and
+the values in them, and a single undo takes both back. Delete keeps the order's length:
+emptied trailing frames stand as silent ones.
+
+## A shift reads the columns behind a region
+
+Transpose and volume move whole cells, while a region names its edges as subcolumns. A shift
+therefore reads the columns a region covers (`TrackerRegion.columns`) and reaches each of their
+channels once, at every row the region spans. Two consequences follow: a nudge raised with the
+cursor on a volume subcolumn still moves that cell's transpose, and a region covering the sample
+column together with a channel beneath it moves that channel a single step, since the sample column
+stands for the channels a value typed in it writes to.
+
+Each cell reaches the grid through the single-cell adjustment that already governs it, the way a
+pasted cell does, so a shift lands exactly the writes the same nudge repeated by hand would make —
+the transpose and volume ranges included.
+
+## A block states itself as text
+
+A copy also writes the block to the desktop's clipboard, as the lines the grid prints — a
+tracker block:
+
+```
+SampleToNES/1 tracker rows=2 slots=3..5
+00 +05 3
+.. -02 .
+```
+
+and an order block:
+
+```
+SampleToNES/1 order rows=1 positions=0..1
+00 03
+```
+
+The form and its reading live in `logic/sequencer/clipboard/`, which deals in blocks and
+strings alone; the desktop's clipboard is reached through
+`utils/gui/clipboard.py::TextClipboard`, one more piece of external behaviour standing behind
+a protocol ([Architecture](architecture.md), principle 11). The sequencer coordinator wires
+the two.
+
+**A field prints what the grid prints in its cell**, which is what carries the three states
+across: a value reads as its value, an empty cell as the dots beneath it, and a mixed one as
+the marks filling its field. The marks fill the whole width, so every line measures the same
+and a block pasted into a message still reads as a grid; reading takes any run of them.
+
+**The header is a declaration the body is held to.** It names the grid, the count of rows, and
+the span of slots or positions the block stands on, and a body whose lines or fields disagree
+with it states no block. The span also carries the alignment a tracker block needs, since the
+first slot decides which subcolumn the block opens on.
+
+**A note names its sample by list position**, the figure the grid prints, so a block carried to
+another project plays whichever sample stands at that position there. A position the project's
+list falls short of reads as mixed, which is what the writer already makes of a sample it has
+nothing to place.
+
+A field the form has no reading for refuses the whole text, so a parse answers with a block or
+with nothing. Digits are read in either case, and transpose and volume are held to the ranges a
+row accepts, so text typed by hand lands the values the grid would.
+
+### Which block a paste writes
+
+A copy writes both clipboards, and a paste reads the desktop's text first: it stands while it
+parses as a block for *that* grid, and any other text leaves the grid's own block in hand. So a
+block copied in a second instance pastes here, and a copy taken in this one survives whatever
+else the desktop picks up afterwards. `can_paste_block` asks the same question through a
+`ParsedBlockCache`, which reparses only when the text has changed, so opening a menu costs one
+string compare.
+
+## A grid declares its actions once
+
+Where they are shown is decided by whoever asks for them. Each grid builds its whole
+action set from one **target** — the cell a gesture is aimed at, paired with the region
+that gesture acts on — and three doors resolve that target their own way:
+
+| Door | Aims at | Anchors a paste at |
+|------|---------|--------------------|
+| The keyboard | the cursor's cell | the cursor |
+| A context menu | the cell it was raised on | the clicked cell |
+| The menu bar's **Edit** menu | the cursor's cell | the cursor |
+
+The region behind a target is `region_at` on the shared input state: the selection when the
+cell falls inside it (`Region.covers`), and the cell alone otherwise. So copying one cell
+needs no selection made first, and a menu raised inside a selection acts on the whole of it.
+
+One builder means an action added to a grid appears at every door, and the accelerator
+**Edit** prints is the one that grid answers to, since a binding is declared once and every
+reader of it reads that entry ([Architecture](architecture.md), principle 12).
+
+`EditRouter` (`coordinators/edit/`) is the menu-side counterpart of the `KeyRouter` the
+keyboard runs through. Each surface states whether it owns the editing gestures at this
+moment — the same predicate its key scope answers with, so the menu offers what the next
+press would reach — and the router asks the one that does to build its items into the menu
+the bar has opened. It holds no state, resolving the surface on each call, so the menu
+states the actions of whoever holds the cursor at the moment it is opened. The bar names the
+clipboard four greyed out when no grid answers, which is how a reader working from the menus
+learns the commands exist.
+
+**`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot share
+a combination inside a shortcut category, so this branch is the route; it also matches
+tracker convention.
+
+## One gesture, one history entry
+
+Cut, delete and paste each record exactly one entry, whichever door fired them, and none of
+them coalesces: a block gesture is already a whole gesture, and folding two consecutive
+pastes would hide a repeat the reader performed on purpose. Copy runs outside a transaction,
+since it mutates nothing.
+
+A shift coalesces, because a nudge is a step of one gesture rather than a whole one. The block it
+covers is its coalescing target, so a streak over one selection leaves a single step to undo and a
+shift after the cursor moves or the selection is reached out starts the next entry. Transpose and
+volume count separately, each carrying its own action.
+
+## A shape selects to the grid's own edges
+
+`Ctrl+A` and its neighbours select a whole shape at once. Each shape is stated on the input
+state as a run of bounds along one axis — slots in the tracker, rows in the order — handed to a
+single builder that spans the other axis to the grid's full extent and lands the cursor on the
+far corner. The whole frame, a column and a subcolumn are therefore three namings of one
+rectangle, as the whole order and a channel row are of the other, and a grid laying out nothing
+keeps the selection it had.
+
+The aggregate is an ordinary member of the axis here: selecting the **Sample** column selects a
+column the way selecting a channel does, and the **Master** row a row.
+
+A press names its shape from the cell the cursor stands on, which is the cell the context menu's
+items name too, so a key and an item reach the same rectangle. In the tracker a shape ends at
+the frame's last row, so standing one carries the grid to where the cursor landed — the same
+reveal a `Shift+End` reach makes.
+
+## Dragging a range out
+
+Both grids compose one `TableSelection` (`ui/elements/table/selection.py`), which holds what
+stands painted and the drag gesture that draws it. The grid states which of its cells the
+selection covers, in its own coordinates; the repaint that follows reaches the cells whose
+membership changed, marking each through the selectable's own selected state, which the
+table's theme colours. A rebuilt table asks for a reset, since the cells a selection stood on
+belong to the body that was replaced.
+
+Both panels read the cell under a held pointer off their own geometry, because DearPyGui
+reports no hover for the cells a held pointer passes over. A drag carried past an edge
+reads as the edge, so it selects up to it.
+
+The tracker's row lookup is arithmetic: it takes the first row's top edge and divides by
+`layout.tracker.row_height`. That holds only while the rows are evenly pitched, which is
+what `CellPadding.y = 0` and `ItemSpacing.y = 0` in `theme/tables/pattern.yaml` are for.
+A vertical padding there would drift the lookup further down the grid. The order's
+position lookup is arithmetic in the same way, taking its pitch from the first two
+columns; its channel lookup walks the rows, because the master row stands apart from the
+channels beneath it.
+
+### A drag past the edge carries the view
+
+A pointer held past the cells on screen travels the grid under it, so a selection reaches
+further than the viewport holds. `grid/scroll/` states this in three pieces: a `ScrollAxis`
+naming the one DearPyGui axis a table scrolls along and the pointer coordinate that runs past
+its edges, a `TravelBand` saying where the cells stand along that axis, and the `DragTravel`
+that reads the two each frame. The tracker travels vertically and the order horizontally, both
+from the same class.
+
+Three rules make the travel feel like one gesture:
+
+- **The pointer report drives it.** A held pointer keeps reporting wherever it is carried to,
+ including past the window, so the travel runs off the same report the drag itself reads.
+- **The frame's own duration paces it**, so the same stretch of grid passes under the pointer
+ however fast the frames arrive. The pace answers how far past the edge the pointer stands,
+ rising from a floor to a ceiling over a few cells' overshoot: a nudge creeps, a reach covers
+ the grid.
+- **Each step is added to the offset last issued.** A table reports the scroll it was drawn
+ with rather than the one just set, so a travel reading it back would re-issue an offset it
+ has already reached. It rests as soon as the pointer stands within the band again, at the
+ press that opens the next gesture, and on a rebuild — and the travel that follows sets out
+ from the offset the grid is drawn with.
+
+## Accepted limitations
+
+- **A rebuilt table has no selection.** Both grids reconstruct their input state on
+ rebuild, so following playback and the rebuild after a growing paste leave the cursor
+ and drop the selection. The rows a region named belong to the body that was replaced.
+- **The selection stays put after a paste** rather than becoming the pasted footprint.
+- **A note crosses a project by whichever route it took.** The in-app slot survives a project
+ close, because it must survive `on_project_replaced`, which fires on every undo, and it names
+ its sample by id: a note whose sample the project in place lacks is left out of the write, and
+ the target keeps what it had. The clipboard's text names a list position instead, so the same
+ note pasted through it plays whichever sample stands at that position. Transpose and volume
+ are exact by either route.
+- **A drag past the edge and the followed playhead both write the scroll.** With **Follow rows**
+ on during playback, `_reveal_playing_row` carries the sounding row to the head of the band
+ while a held pointer travels the grid, so the two take turns each frame.
diff --git a/docs/development/undo.md b/docs/development/undo.md
index bf818c9f..e056846b 100644
--- a/docs/development/undo.md
+++ b/docs/development/undo.md
@@ -34,9 +34,14 @@ the regeneration worker's background thread.
consecutive commits sharing the same action and key replace the top entry
instead of appending, so a continuous interaction — a graph drag, repeated
edits of one cell — records a single entry. Any undo, redo, or jump breaks
- the run, so a state the user navigated to is always preserved.
+ the run, so a state the user navigated to is always preserved. `_undoable`
+ opens `ProjectController.batch()` inside the transaction, so one gesture is
+ one entry and one round of view notifications alike.
- **Detection — the controller.** `ProjectController._touch()` fires `on_mutation`
- on every fine-grained mutation. `HistoryManager.handle_mutation` counts those
+ on every fine-grained mutation, as it lands — a batch defers the view
+ notifications and the dirty stamp, leaving this signal immediate so the check
+ below sees each mutation inside the transaction that caused it.
+ `HistoryManager.handle_mutation` counts those
inside a transaction and rejects any that occur outside one: under strict
deployment it raises `UntrackedMutationError`; otherwise it self-heals by
recording the mutation as its own entry. This makes completeness a checkable
diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md
index 256757e9..e0693bb6 100644
--- a/docs/formats/bitphase.md
+++ b/docs/formats/bitphase.md
@@ -69,7 +69,7 @@ carries every register value the channel takes for that tick. From
| Field | Range | Runtime meaning | What the exporter writes |
| --- | --- | --- | --- |
| `pulseWidth` | 0–3 | square duty cycle; on the noise channel, any nonzero value selects the short LFSR | the duty-cycle envelope item (squares), the short/long mode (noise), a flat value (triangle) |
-| `volumeOrRate` | 0–15 | the literal channel volume while `envelope` stays off | the volume envelope item |
+| `volumeOrRate` | 0–15 | the literal channel volume while `envelope` stays off | the volume envelope item, or a full level where the slice leaves its volume to the channel |
| `envelope` | bool | reads `volumeOrRate` as a hardware decay rate | `false`, so each item is the volume itself |
| `soundLength` | 0–511 | length counter in ticks; `0` holds the note | `0`, so the volume envelope alone shapes the note |
| `toneAdd` | −4096–4095 | period offset added to the tuning-table period (squares and triangle) | `0` in a document, the pitch contour in a preset |
@@ -79,16 +79,24 @@ carries every register value the channel takes for that tick. From
**Looping.** Playback returns to the instrument's `loop` row once it runs off the end,
which is the only mode there is. A looping slice therefore sets `loop = 0` so its
-envelopes repeat from the start while the note is held; a one-shot sets
-`loop = len - 1`, and since the volume envelope ends on a note-off item, the
-instrument rests in silence once it has played through. A sample's `loop` flag drives
-this, the same flag the FamiTracker exporter reads.
+envelopes repeat from the start while the note is held; a one-shot sets `loop = len - 1`
+and rests on the level that row carries — silence where the volume envelope ends on a
+note-off item, the channel's own level where the slice holds its volume. A sample's
+`loop` flag drives this, the same flag the FamiTracker exporter reads.
+
+**A held volume.** A slice whose volume envelope carries no item leaves its level to the
+channel, so the exporter writes a full `volumeOrRate` for every frame the slice
+describes. Playback combines a row's level with the pattern's volume column through a
+PT3 volume table, where a full-level row comes out at the column's own level, so those
+rows sound at whatever level the channel carries — the same reading FamiTracker gives a
+disabled volume sequence. A slice describing no frame at all is what writes a single
+silent row, the smallest instrument Bitphase plays.
**Equal lengths.** Instrument rows and table rows advance on independent per-tick
counters, so they share a length and a loop point and stay in step for as long as the
note sounds. `equalize_lengths` in `exporters/lengths.py` supplies that shared length —
the same rule the FamiTracker exporter applies, with the item limit left unbounded
-here (section D).
+here (section F).
## C. Pitch
@@ -146,7 +154,41 @@ against the pitch the slice was reconstructed at, under the tuning a freshly cre
Bitphase document plays — NTSC at concert pitch. The noise channel takes its period
from the note, so its preset rows hold a flat offset.
-## D. What the exporter builds per scope
+## D. Tempo as a groove
+
+A Bitphase song states a **speed** — the engine ticks each row lasts — where a _SampleToNES_
+project states a tempo and a speed together. The row rate the pair asks for is fractional at
+most tempi, so the exporter carries it as a [groove](../glossary.md#groove): whole tick counts,
+one per row of a pattern, averaging out to that rate with the longer rows on the bar and the
+beat. `sampletones_core/timing/` builds them and in-app playback reads the same groove, so a
+document plays the rows the sequencer played. At 60 Hz, speed 6 and tempo 210, a 16-row
+pattern in common time comes to
+
+```
+5 4 5 4 5 4 4 4 5 4 4 4 5 4 4 4 69 ticks, a rate of 30/7 per row
+```
+
+**The groove reaches the engine as a table.** A speed effect that names a table reads one of
+its entries per pattern row, which is what carries a per-row tick count into a song:
+
+| Part | What the exporter writes |
+| --- | --- |
+| `initialSpeed` | the ticks the pattern's first row lasts |
+| The table | one entry per pattern row, `loop = 0`, taking the id above the last slice table |
+| The effect | `S` with `delay = 0` and an empty parameter, naming that table |
+| Its place | the first row of the DPCM channel, in every pattern |
+
+A speed effect applies from whichever channel carries it, so the groove rides the DPCM channel
+this exporter leaves silent and every sounding channel keeps the one effect column the chip
+gives it. The table advances an entry per row and resumes from where a trigger placed it, so
+triggering it again at each pattern start holds every row on the entry that describes it,
+however the order jumps.
+
+**A tempo the speed column states writes neither.** Where every row lasts alike — tempo 150 at
+60 Hz, where the rate is the speed itself — `initialSpeed` carries the tempo whole, and the
+document holds one table per slice with every effect column empty.
+
+## E. What the exporter builds per scope
A `.btp` holds a whole document, so every scope lands in one file; a preset holds one
instrument, so a reconstruction lands as a set of them beside the name the export was
@@ -175,34 +217,39 @@ Row cells follow from the columns: an instrument command writes the note from
`initial_pitch + transpose`, the instrument number, the table column and the row's
volume; a note-off writes note name `1`; a blank line leaves every column alone.
-## E. Bitphase capacity limits
+**The volume column names silence.** In Bitphase you type `0` to silence a channel and
+leave the cell blank to carry its level forward — and the file stores those two as `-1` and
+`0`. The volume field is declared `allowZeroValue`, so Bitphase parses a typed `0` to `-1`
+and prints a stored `-1` back as `0`, while a stored `0` shows as a blank cell; its engine
+reads `-1` as volume zero. So a row asking for silence writes `-1`, a row naming a level
+writes it verbatim, and a row with an empty volume cell writes `0` — which is the same cell
+you would see in the tracker either way.
+
+## F. Bitphase capacity limits
| Quantity | Bitphase limit | Exporter behaviour |
| --- | --- | --- |
| Items per instrument row list | unbounded | writes the envelope whole |
-| Rows per table | unbounded | writes the contour whole |
+| Rows per table | unbounded | writes the contour, or the groove, whole |
| Instruments | the instrument column holds 2 base-36 digits, so 1–1295 | raises past 1295 |
-| Tables | the table column holds 1 base-36 digit, so ids 0–34 | raises past 35 tables |
+| Tables | the table column holds 1 base-36 digit, so ids 0–34 | raises past 35 tables, one of which a groove takes |
| Note range | the 96-entry tuning table, pitch 24–119 | clamps to the nearest playable note |
+| Volume column | `-1` silences (the tracker shows `0`), `0` carries the level forward (shown blank), 1–15 set the level | writes the row's level, and `-1` where a row asks for silence |
| Pattern length (rows) | 1–256 | clamps the preview pattern; a project keeps `rows_per_pattern` |
| Order positions | unbounded | matches |
-| Speed | 1–255 | written verbatim from settings |
-| DPCM channel | present | emitted empty |
+| Speed | 1–255 | the groove's tick counts, bounded to that range |
+| DPCM channel | present | rests, apart from the groove trigger each pattern's first row carries |
Tables and instruments are numbered together — each slice takes one of each — so the
-table column is what a wide document reaches first: 35 slices fit, and the exporter
-raises rather than writing a document whose later voices cannot be named.
-
-## F. What does not cross over
+table column is what a wide document reaches first, and the exporter raises rather than
+writing a document whose later voices cannot be named. A song whose rows vary spends one
+of those ids on its groove, so the slices a document holds are those the table column can
+still name.
-Three things the SampleToNES model holds have no counterpart in a Bitphase document,
-and the exporter leaves them behind:
+## G. What does not cross over
-- **`ProjectInfo.comment`** — a Bitphase project carries a name and an author only.
-- **`ProjectSettings.tempo`** — Bitphase's engine is speed-only, so `initialSpeed`
- carries `speed` and the tempo is left to the tick rate.
-- **A volume column of `0`** — Bitphase reads it as "leave the volume alone", so a row
- that asks for silence through the volume column alone reaches playback unchanged.
+**`ProjectInfo.comment`** has no counterpart in a Bitphase document, which carries a name and
+an author only, so the exporter leaves the comment behind.
`interruptFrequency` carries the reconstruction's own tick rate. Bitphase's settings
panel offers 50 and 60 Hz, and its loader and timeline accept any value, so a rate
diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md
index ab90b250..b528b94f 100644
--- a/docs/formats/famitracker.md
+++ b/docs/formats/famitracker.md
@@ -146,19 +146,32 @@ sequence, so its envelopes repeat from the start while the note is held; a one-s
instrument leaves every loop point at `-1`. A sample's `loop` flag drives this when
the sample is exported into a module.
-**Equal lengths.** FamiTracker advances each sequence on its own per-tick counter, so
-every populated sequence of an instrument carries the same item count and the
-dimensions stay in step. The volume envelope arrives one item longer than the others,
-carrying a trailing zero that releases the note. A looping instrument therefore keeps
-the shortest length, dropping that trailing item so the loop sustains; a one-shot
-keeps the longest, each shorter dimension holding its final value through the release
-tick. That shared length stays within the 252 items a FamiTracker sequence holds, so a
-reconstruction longer than 252 frames — 8.4 s at the default 30 fps — exports its opening
-252 frames and logs the shortening. The instruments panel colours a sequence input warning
-orange once it passes that length, so the limit is visible before an export.
+**Lengths.** FamiTracker advances each sequence on its own per-tick counter. A sequence
+that reaches its last item halts and leaves the value it wrote applied, which the driver
+holds for as long as the note sounds (`CSeqInstHandler::UpdateInstrument`). A one-shot
+instrument therefore carries every dimension at the length it was written: a two-item
+volume envelope beside a one-item duty envelope plays exactly as a padded pair would, and
+costs the padding less. A looping instrument brings its populated dimensions to the
+shortest length instead, so the envelopes repeat in step and the trailing zero that
+releases the note is dropped from the cycle.
+
+Every length stays within the 252 items a FamiTracker sequence holds, so a reconstruction
+longer than 252 frames — 8.4 s at the default 30 fps — exports its opening 252 frames and
+logs the shortening. The instruments panel colours a sequence input warning orange once it
+passes that length, so the limit is visible before an export.
+
+An empty dimension is written as a disabled sequence, which is a different instrument from
+one carrying a single zero: the disabled slot leaves that dimension to the channel, while a
+one-item sequence sets the value once and holds it. A dimension arrives empty when the
+reconstruction records it as one the channel governs — the state clearing the envelope in the
+instruments panel puts it in (see [Reconstructions](reconstructions.md)).
**How _SampleToNES_ fills an instrument.** Each generator slice of a sample's
reconstruction becomes one instrument, so a sample yields one to four instruments.
+A reconstruction holds a stream for every channel, and one describing no frame is a
+channel standing by (see [Reconstructions](reconstructions.md#contents)): it takes no
+place in the instrument table, so the instruments an export writes are the channels
+that play.
The arpeggio sequence carries the reconstruction's pitch contour as signed offsets,
and triggering the instrument at `initial_pitch` replays that contour. Volume, duty
(or noise mode) and any pitch sequences carry across directly. The DPCM
@@ -194,7 +207,7 @@ checklist.
| Note range | C-0..B-7 (pitch 24–119) | `initial_pitch` 33–119 + `transpose` −24..+36 can exceed it | clamps to the nearest playable note (fidelity loss at the extremes) |
| Title / author | 32 bytes each | 64 characters | truncates to 32 bytes |
| Comment | free text (COMMENTS block) | 65536 characters | carried in full |
-| Tempo / speed | engine-dependent (split at row `speed_split_point`) | tempo 1–300, speed 1–31 | written verbatim from settings |
+| Tempo / speed | engine-dependent (split at row `speed_split_point`) | tempo 32–255, speed 1–31 | written verbatim from settings |
| DPCM samples | 64 | not modelled | always empty by design |
The exporter also reserves a per-channel empty pattern index (`max used index + 1`)
@@ -202,3 +215,50 @@ for order slots the song leaves unset; a channel that already fills indices up t
127 leaves no room for it, which the exporter reports rather than emitting a corrupt
order. When the domain model grows to enforce these limits, the editor can prevent
reaching a state the exporter would reject.
+
+## D. Driver memory footprint
+
+Compiling a module into an NSF lays each instrument out across two regions of the driver's
+data, and an instrument's sequences size both of them. `footprint.py` measures the two, and
+`specification/memory.py` names every field the measurement counts. The instruments panel and
+the samples context menu display the result, so the cost of a sample is readable before an
+export.
+
+The **instrument region** holds the instrument list — one pointer per instrument — followed by
+each instrument's body: a sequence-enable bitmask, then one pointer per populated sequence. The
+**sequence region** holds one chunk per sequence: a four-field header followed by the items.
+
+| Field | Bytes | Region |
+| --- | --- | --- |
+| instrument list entry | 2 | instrument |
+| sequence-enable bitmask | 1 | instrument |
+| sequence pointer, per populated sequence | 2 | instrument |
+| item count · loop point · release point · setting | 1 each | sequence |
+| item, per tick | 1 | sequence |
+
+An instrument with `n` populated sequences carrying `s₁ … sₙ` items therefore occupies
+`3 + 2n` bytes of the instrument region and `Σ (4 + sᵢ)` of the sequence region. A dimension the
+channel leaves unused is written as a disabled slot, and the populated sequences alone are
+charged: `n` is 3 on the pulse and noise channels (volume, arpeggio, duty) and 2 on triangle.
+Each sequence is charged at its own length (section B), so shortening any one dimension shows
+in the figure, and an instrument tops out at 777 bytes — three sequences at the 252-item limit.
+
+These two figures are the ones FamiTracker itself prints while creating an NSF —
+`Instruments used: N (X bytes)` and `Sequences used: M (Y bytes)` — which is how a measurement
+is held against the tracker.
+
+**Version.** The figures are vanilla FamiTracker 0.4.6, the target section A names. The 0CC and
+Dn-FamiTracker forks open each instrument body with a channel-type byte, so an instrument costs
+one byte more there.
+
+**Pooling narrows a module's total.** The `SEQUENCES` block stores each distinct sequence once
+(section A.2), so a module holding two instruments with the same volume envelope pays for that
+chunk once. A per-instrument or per-sample figure states that instrument's own cost, and a
+module total is therefore at most the sum of them. Within one instrument each kind appears
+once, so its own sequences are charged once each.
+
+**Looping levels the sequences.** A looping instrument brings its populated dimensions to the
+shortest length, while a one-shot keeps each dimension as written (section B), so the two forms
+of one set of envelopes cost differently. A sample carries the flag that decides which applies;
+a reconstruction standing on its own is measured as a one-shot, matching the instrument its
+**Export instrument** writes.
diff --git a/docs/formats/instruction-libraries.md b/docs/formats/instruction-libraries.md
index fbdba2e2..6cc8eb72 100644
--- a/docs/formats/instruction-libraries.md
+++ b/docs/formats/instruction-libraries.md
@@ -45,13 +45,13 @@ Libraries are stored as `.ins` files in the documents folder, with the
configuration embedded in the file name:
```
-sr_44100_nf_30_ws_13579_tg_0_sm_cqt_ch_384e710987cb958adf2b214df1267d10.ins
+sr_44100_nf_60_ws_13579_tg_0_sm_cqt_ch_384e710987cb958adf2b214df1267d10.ins
```
| Fragment | Meaning |
| --- | --- |
| `sr_44100` | sample rate 44100 Hz |
-| `nf_30` | NES frequency 30 Hz |
+| `nf_60` | NES frequency 60 Hz |
| `ws_13579` | FFT window size (samples) |
| `tg_0` | transformation gamma 0 |
| `sm_cqt` | spectrum method (`fft` / `logfft` / `cqt`) |
diff --git a/docs/formats/projects.md b/docs/formats/projects.md
index cd3b42cf..570d3275 100644
--- a/docs/formats/projects.md
+++ b/docs/formats/projects.md
@@ -25,7 +25,7 @@ while the larger audio data travels alongside it in the same archive.
| `format_version` | the project format version, checked for compatibility on load (see [Versioning](#versioning)) |
| `metadata` | the application name and version (managed automatically) |
| `info` | `title`, `author`, and `comment`, plus `created` and `modified` timestamps |
-| `settings` | the engine settings: `nes_frequency`, `sample_rate`, `tempo`, and `speed` |
+| `settings` | the engine settings: `nes_frequency`, `sample_rate`, `tempo`, `speed`, and the metric highlights `first_highlight` and `second_highlight` |
| `samples` | the song's samples — each an `id`, a `name`, and the `reconstruction_id` of its audio member |
| `song` | the arrangement (below) |
diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md
index 7b455b98..d91c4adb 100644
--- a/docs/formats/reconstructions.md
+++ b/docs/formats/reconstructions.md
@@ -26,15 +26,31 @@ A `.stn` file holds:
* **approximation** — the rendered NES audio: the sum of every channel's output,
the closest match to the original;
* **per-channel approximations** — the audio each channel contributes on its own,
- one waveform per enabled channel (`pulse1`, `pulse2`, `triangle`, `noise`);
+ one waveform per channel that sounds;
* **per-channel instructions** — the instruction stream each channel plays, one
[instruction](../glossary.md#instruction) per frame. This is the data a
- FamiTracker export is built from;
+ FamiTracker export is built from. A reconstruction holds a stream for every one
+ of the four channels (`pulse1`, `pulse2`, `triangle`, `noise`), and a stream of
+ no frames is a channel standing by: it is written by no export and costs
+ nothing, while staying open to edit, so writing an envelope into it puts the
+ channel in play and clearing every envelope takes it out again;
* **per-channel reference pitch** — the note each channel's arpeggio offsets are
measured against, chosen once when the reconstruction is built and stored with
the instructions it describes. An export reads the offsets against this pitch,
so editing an arpeggio moves the frames around a base that stays put (see
- [FamiTracker export](famitracker.md)).
+ [FamiTracker export](famitracker.md));
+* **per-channel held dimensions** — the envelopes each channel leaves to the
+ player. An instruction states a value for every dimension of its frame, so this
+ is what says which of them the instrument itself writes; the rest are the
+ channel's, and the player keeps the value it already holds for them. A channel
+ in play writes them all as it is built, and clearing an envelope in the
+ instruments panel adds that dimension here.
+
+A channel standing by rests at a reference pitch of its own, so the first envelope
+written into it sounds on a mid-range note, and it leaves every dimension it offers
+to the player, which is the record a channel edited down to empty envelopes reaches
+as well. A file naming a stream for the channels it plays alone reads as the whole
+four, with the rest coming back standing by.
## Detached reconstructions
diff --git a/docs/glossary.md b/docs/glossary.md
index d8dddcfa..8c53c90b 100644
--- a/docs/glossary.md
+++ b/docs/glossary.md
@@ -182,6 +182,21 @@ arpeggio, pitch, hi-pitch, or duty/noise mode.
A block of tracker rows spanning the channels. A song plays its patterns in an
order.
+### Metric highlight
+
+The row grouping a song is counted in. The **first highlight** is the beat — the
+rows one beat spans — and the **second highlight** is the bar that gathers beats.
+The tracker tints the row that opens each, and the beat is what a tempo counts:
+`beats_per_minute = 60 × nes_frequency / (ticks_per_row × first_highlight)`.
+
+### Groove
+
+The engine ticks each row of a pattern lasts. An engine holds a row for a whole
+number of ticks, so a tempo landing between two counts is played by varying the
+count from row to row, and the metre places the longer rows on the bar, then the
+beat, then inside the beat. Playback reads the groove by the row's position in the
+pattern, so the pattern's first row starts it afresh.
+
### Order
The list that arranges patterns into the song's timeline.
diff --git a/docs/guide/installation.md b/docs/guide/installation.md
index 5f1daec9..74efb588 100644
--- a/docs/guide/installation.md
+++ b/docs/guide/installation.md
@@ -15,8 +15,11 @@ Some setups need a little more — each is covered in the relevant section below
- **On Linux**, a few system packages are required to build or run: the Tk
file-dialog and PortAudio audio libraries. Install them with `make system-deps`.
- On Windows and macOS they come with the official Python installer and the
- packaged dependencies, so nothing extra is needed.
+- **On macOS**, audio playback is compiled against PortAudio on install, so the
+ library comes from [Homebrew](https://brew.sh): `make system-deps` installs it.
+ Tk and the graphics libraries arrive with the official Python installer.
+- **On Windows**, the official Python installer and the packaged dependencies
+ cover everything.
- **Running from source** also needs [uv](https://docs.astral.sh/uv/).
- **GPU acceleration** needs an NVIDIA GPU with a current driver. The matching CuPy
build is installed for you, so the driver is all you need — on Linux and Windows alike.
@@ -42,11 +45,12 @@ A ready-to-run executable built on your machine. You only need Python 3.12.
## Run from source
For development, and the way to run on macOS. Requires [uv](https://docs.astral.sh/uv/)
-— and, on Linux, the system packages from the Linux steps above:
+— and, on Linux and macOS, the system packages from the requirements above:
```sh
-make setup # create the environment and install the sampletones command
-make run # run the app
+make system-deps # Linux and macOS: install the system libraries
+make setup # create the environment and install the sampletones command
+make run # run the app
```
To update the global command after pulling new changes, re-run `make setup`.
diff --git a/docs/guide/interface.md b/docs/guide/interface.md
index d9fb86d3..6ff99732 100644
--- a/docs/guide/interface.md
+++ b/docs/guide/interface.md
@@ -16,43 +16,67 @@ begin.
Pick an audio file — or a whole folder — in the **Filesystem** browser on the
left, set up how the reconstruction is done in the centre, and click **Convert
-sample** (or **Convert directory** for a folder). The
+sample** (or **Convert directory** for a folder). The browser opens the folders you
+were last working in, and **Collapse all** folds them away again. The
[instruction library](../concepts/instruction-library.md) for your settings is
built automatically the first time it is needed, so you can convert straight away.
-When a single file finishes, **Load** opens the result on the **Reconstructions**
-tab; **Cancel** stops a run, and only one runs at a time.
-
-The settings worth knowing before you convert: under **Reconstructor settings**,
-the **Generators** toggles choose which channels take part (at least one must be
-on) and **Drive** sets how hard they are pushed; the analysis options — sample
-rate, NES frequency, generation method, and feature scaling — live in **General
-settings**. Less-common options, including the worker count and the output and
-library folders, sit under **Advanced settings**, which **View ▸ Show advanced
-settings** reveals. [Configuration](configuration.md) explains what each one does.
+While it runs, the panel names the file going in and where the result is going, and
+clicking either path shows it in your file manager. When a single file finishes,
+**Load** opens the result on the **Reconstructions** tab; **Cancel** stops a run, and
+only one runs at a time.
+
+A few settings are worth knowing before you convert. Under **Reconstructor
+settings**, the **Generators** toggles choose which channels take part — at least
+one must be on — and **Drive** sets how hard they are pushed. **General settings**
+holds the analysis options: sample rate, NES frequency, generation method, and
+feature scaling. The rest, including the worker count and the output and library
+folders, sit under **Advanced settings**, which **View ▸ Show advanced settings**
+reveals. [Configuration](configuration.md) explains each one.
## Reconstructions
The **Reconstructions** tab is where you audition a reconstruction against the
original, fine-tune it, and export it.
-Open a saved reconstruction from the list on the left; if the current one has
-unsaved edits, you are asked whether to save it first. You can play it back and
+Open a saved reconstruction from the **Browser** on the left, which offers the
+same files two ways: **By configuration** groups them by the settings they were
+made with, and **By sample** gathers every version of one source audio together.
+If the current reconstruction has unsaved edits, you are asked whether to save it
+first. You can play it back and
switch **Play audio source:** between **Reconstruction** and **Original audio** to
compare the two, and **Locate original audio** re-links the source file if it has
moved.
-To get your results out, **Reconstruction ▸ Export instruments** writes the
-whole reconstruction as one file per channel — `.fti` under **FamiTracker
-instruments...**, `.json` under **Bitphase presets...** — and **Reconstruction ▸
-Export to WAV...** renders the audio. **Add to Sequencer**, on a reconstruction's
-right-click menu, sends it into a song as a sample (see the
-[sequencer guide](sequencer.md)).
+To keep the reconstructions you return to within reach, right-click one — or a
+whole folder — and choose **Mark as favorite**, which highlights it in both views.
+Tick **Favorites only** under the search box to narrow the browser to your
+favorites and everything inside them. The browser keeps the folders you had open
+while it narrows, so switching the tick on and off leaves the tree as you left it.
+If you would rather it opened its way down to each favorite for you, turn that on
+under **View ▸ Auto-expand favorites**, which answers for reconstructions and for
+folders separately. It opens the way down each time you tick **Favorites only**,
+and unticking folds those rows back.
+
+**Collapse all**, beside the refresh button, folds the whole tree away in one
+click. Whatever you leave open is remembered, so the tree comes back the way you
+left it the next time you start the application.
+
+To get your results out, use the **Reconstruction** menu. **Export instruments ▸
+FamiTracker instruments...** writes one `.fti` per channel, **Bitphase
+presets...** writes the same as `.json`, and **Export to WAV...** renders the
+audio. To use the reconstruction in a song, right-click it and choose **Add to
+Sequencer** (see the [sequencer guide](sequencer.md)).
For finer control, the **Instruments** panel on the right shows each channel's
instrument — its pitch, volume, arpeggio, and duty sequences — which you can edit
-by dragging the bars or typing values. **Export instrument...** writes the channel
-on show, for whichever tracker the save dialog's file type names — see
-[where your files live](files.md#exported-files).
+by dragging the bars or typing values. Clearing a sequence hands that dimension to
+the channel, so an instrument with no volume sequence plays at whatever level its
+channel carries. Beside each channel is the room its instrument takes on the NES,
+with the whole sample's above them, so you can see what an edit costs. The figures
+are in bytes, and they count what a FamiTracker export saves, so clearing a
+sequence brings them down. **Export instrument...** writes the channel on show, for
+whichever tracker the save dialog's file type names — see [where your files
+live](files.md#exported-files).
## Instructions
@@ -74,17 +98,46 @@ instructions data** to re-read the catalogue; selecting an entry in the
The menu bar and status bar sit outside the tabs.
-The **File** menu manages projects — new, open, save, properties, close, and
-**Export FamiTracker module...**. **Edit** holds **Undo** and **Redo**.
-**Reconstruction** gathers everything for the current reconstruction: reconstruct,
-open, save, and the export actions. **Playback** controls play, pause, and stop, mutes the
-sequencer's channels under **Channels**, and opens **Audio settings...**. **View** toggles **Show advanced settings** and
-**Fullscreen**, and **Help** has **About**.
-
-**Audio settings** (**Playback ▸ Audio settings...**) choose the playback device,
-sample rate, and buffer size. These affect playback only — they are separate from
-the **Sample rate** and **NES frequency** on the **Main** tab, which govern how
-audio is reconstructed.
+Each menu covers one kind of work: **File** for projects, **Edit** for undo, redo,
+and what you can do where your cursor stands, **Reconstruction** for the current
+reconstruction and its exports, **Playback** for playing and for muting the
+sequencer's channels, **View** for settings and the window, and **Help** for
+**About**. What **Edit** offers below undo and redo follows your cursor: the block
+actions of the sequencer grid you are in, or the actions of the sample you have
+picked in the **Samples** list.
+
+Two items write audio you can play anywhere: **Reconstruction ▸ Export to WAV...**
+for the reconstruction on show, and **File ▸ Render song...** (`Ctrl+Shift+E`) for
+the sequencer's whole song, as a WAV or an MP3 —
+[rendering to audio](sequencer.md#rendering-to-audio) covers the options it offers.
+
+Two other items are easy to miss. **View ▸ Show advanced settings** reveals the extra
+options on the **Main** tab. **Playback ▸ Audio settings...** picks the playback
+device, sample rate, and buffer size; these change what you hear, while the
+**Sample rate** and **NES frequency** on the **Main** tab change how audio is
+reconstructed.
+
+`F1` to `F4` bring up the four tabs in order — **Main**, **Reconstruction**,
+**Sequencer**, and **Instructions** — and work while you are typing, so any tab is
+one key away.
+
+`1` to `4` toggle the four NES channels on the tab in front of you: the
+generators on **Main**, the channels drawn on **Reconstructions**, and the song's
+mix on the **Sequencer**. In the sequencer's grids the digits type values into the
+cell you are on, so use the channel names or the **Playback ▸ Channels** menu to
+mute there.
+
+### Keyboard shortcuts
+
+**View ▸ Keyboard shortcuts...** (`Ctrl+K`) lists everything you can do from the
+keyboard and lets you change any of it. Click an action's shortcut and press the
+keys you want, or type them into the box below the list. If another action already
+uses those keys, the app names it and asks whether to hand them over. **Reset to
+defaults** puts everything back, and your changes take effect when you press
+**OK**.
+
+On macOS the shortcuts use Command where other platforms use Control. What you
+change is saved with your settings and is there the next time you start.
Project properties belong to a project and are covered in the
[sequencer guide](sequencer.md).
diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md
index 087066fa..1f47a3fd 100644
--- a/docs/guide/sequencer.md
+++ b/docs/guide/sequencer.md
@@ -18,9 +18,13 @@ the project already has samples, _SampleToNES_ warns with **Different NES
frequency**; **Add anyway** adds it regardless.
Manage the imported samples in the **Samples** list on the right: right-click one
-to **Rename**, **Duplicate**, **Remove**, or reorder it, and toggle its **Loop**
-flag. Removing a sample that patterns still use asks **Remove sample** first,
-because it clears every row that references it.
+to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder it, and toggle its
+**Loop** flag. The **Edit** menu carries the same actions for the sample you have
+picked. The right-click menu also names how much room the sample takes on the NES —
+its total, then each channel it plays — measured as its **Loop** flag has it. The
+figures are in bytes, and they count what a FamiTracker export saves.
+Removing a sample that patterns still use asks **Remove sample** first, because it
+clears every row that references it.
## Writing a pattern
@@ -36,13 +40,85 @@ the cursor row, and **Play from this frame** to start at the top of the shown fr
A song plays a sequence of patterns, and the **Order** grid sets that sequence —
one column per position, with a row for the master and each channel. Type an entry
-to place a pattern, or right-click a frame to **Insert frame**, **Duplicate**,
-**Clear frame**, **Remove**, move it, or **Play from this frame**.
+to place a pattern, or right-click a frame for the rest: **Duplicate** repeats the
+frame with the patterns it already plays, **Clone** gives the copy patterns of its
+own so you can change it on its own, and **Insert frame**, **Clear frame**,
+**Remove**, the moves, and **Play from this frame** do what they say.
+
+## Working on a block
+
+Both grids take a **selection** — a rectangle of cells you copy, cut, paste, and
+delete in one go. Hold `Shift` and press the arrow keys to reach out from the
+cursor, or drag the pointer across the cells; `Shift`+click carries the selection to
+the cell you click. Dragging past the edge of a grid scrolls it along, so a selection
+can run further than the screen shows. Any plain move, and `Escape`, puts the
+selection away again.
+
+| Key | Action |
+|-----|--------|
+| `Shift`+arrows | Reach the selection out a cell at a time |
+| `Shift+Home` / `Shift+End` | Reach it to the first or the last row (tracker) or position (order) |
+| `Ctrl+A` | Select the whole frame, or the whole order |
+| `Ctrl+Shift+A` | Select the column you are in (tracker), or your channel's row (order) |
+| `Ctrl+Alt+A` | Select the subcolumn you are in (tracker) |
+| `Ctrl+C` | Copy |
+| `Ctrl+X` | Cut — copy, then empty what was selected |
+| `Ctrl+V` | Paste, starting at the cursor |
+| `Del` | Empty the selection |
+
+Copy, cut, paste and delete act on the cell the cursor stands on when nothing is
+selected, so copying one cell needs no selection first. All four sit on each grid's
+right-click menu: raised inside a selection they act on the whole of it, raised
+anywhere else on the cell you clicked. Each grid keeps its own copy, so a tracker
+block pastes into the tracker and an order block into the order.
+
+The **Select** keys work from the cell you are on and reach the whole length of the
+grid. They sit on the right-click menu too.
+
+A paste is anchored: the block starts at the cell you paste onto and lands the rest
+down and to the right of it.
+
+In the **Tracker**, a block keeps the kinds of the cells it came from — a transpose
+lands in a transpose, a volume in a volume, whichever column you paste onto — and
+whatever reaches past the last row or the last column is left out. A cell reading
+`?`, where the **Sample** column's channels disagree, passes over its target and
+leaves what was there; an empty cell empties it.
+
+In the **Order**, a block pasted past the last frame grows the song to hold it, and
+one reaching past the **Noise** row stops there. The **Master** row copies the index
+its channels share and reads `?` when they differ, which pasted leaves each channel
+as it was.
+
+Emptying cells keeps the rows and frames they sit in, and every block action is one
+step in the history, so a single **Undo** takes it all back.
+
+A copy also goes to your desktop's clipboard as plain text, so a block carries between
+two open windows of _SampleToNES_ — copy in one, paste in the other — and you can paste
+one into a message to show someone what you wrote. Anything else on the clipboard
+leaves you with the last block you copied here. Notes travel by their number in the
+**Samples** list, so a block pasted into another project plays whichever sample holds
+that number there.
+
+## Transposing and shading
+
+In the **Tracker**, transpose and volume move whatever the selection covers, so a
+run of rows nudges together.
+
+| Key | Action |
+|-----|--------|
+| `Ctrl+Up` / `Ctrl+Down` | Transpose a semitone |
+| `Ctrl+Shift+Up` / `Ctrl+Shift+Down` | Transpose an octave |
+| `Alt+Up` / `Alt+Down` | Volume a step |
+| `Alt+Shift+Up` / `Alt+Shift+Down` | Volume four steps |
+
+Control carries pitch, Alt carries volume, and Shift makes the step the bigger one.
+With nothing selected they act on the cell the cursor stands on, and the same
+commands sit on the right-click menu with these keys beside them.
## Playing the song
-The transport below the grid plays the song, and **Follow playback** scrolls the
-grid to keep pace. The keyboard drives playback throughout the tab:
+The transport below the grid plays the song, and the keyboard drives playback
+throughout the tab:
| Key | Action |
|-----|--------|
@@ -51,10 +127,30 @@ grid to keep pace. The keyboard drives playback throughout the tab:
| `Ctrl+Space` | Play from the frame currently shown |
| `Ctrl+Shift+Space` | Play from the cursor's row in the pattern grid |
| `Escape` | Stop |
+| `Ctrl+L` | **Loop song** — start the song over each time it reaches the end |
`Escape` silences everything, including a sample preview. The same commands sit on
the **Playback** menu and the transport buttons.
+## Following the playhead
+
+**Playback ▸ Follow playback** chooses how far the view travels with the sounding
+row. Each mode carries a key of its own, so you can change your mind while the song
+plays, and the choice is remembered for the next time you launch:
+
+| Mode | Key | Where the view goes |
+|------|-----|---------------------|
+| **Follow rows** | `Ctrl+F` | Scrolls the pattern grid to keep the sounding row on screen, and shows the frame being played |
+| **Follow patterns** | `Ctrl+Shift+F` | Shows the frame being played, and leaves the scroll where you put it |
+| **Don't follow** | `Ctrl+Alt+F` | Holds the view where you put it |
+
+The **Order** grid marks the frame being played under every mode, and the tracker
+marks the sounding row of the frame it shows — so a held view still shows the
+playhead each time the song passes through the frame you are editing.
+**Follow rows** is the one that moves the grid while you play, which is what makes
+the other two the modes to type in: they hold the view still under your cursor while
+the song runs.
+
## Listening to one channel at a time
Channel names are switches. Click **Triangle** at the top of the tracker to silence
@@ -72,7 +168,9 @@ wherever you see it.
| Right-click any name | The same actions as a menu |
The **Playback ▸ Channels** submenu carries the same mix: a check marks each channel
-that sounds, and **Unmute all channels** returns the whole set.
+that sounds, and **Unmute all channels** returns the whole set. `1` to `4` do the
+same from the keyboard, one key per channel, wherever the grids are not holding your
+cursor — inside them the digits enter values.
Muting is for listening only. The song keeps every channel, so saving, exporting a
module, and undo all work on the full arrangement, and a mute survives undo and
@@ -89,7 +187,19 @@ frequency** first (with a **Don't ask again** option).
The project's title, author, and comment — which carry into the exported module —
are set in **Project properties**, from the button or **File ▸ Project
-properties...**.
+properties...**, along with the metre the song is counted in.
+
+**First highlight** and **Second highlight** are that metre: how many rows make a
+beat, and how many make a bar. The tracker tints the row that opens each one. The
+bar divided by the beat is how many beats you hear in a bar, so the default 4 and
+16 give four beats of four rows — common time. Waltz time keeps the four-row beat
+and shortens the bar to 12, for three beats. The beat is what the tempo counts, so
+the two together say how fast the song is felt as well as how it looks.
+
+The metre also places the song's timing. Most tempos ask for a row length the engine
+can only reach on average, so the rows of a bar differ a little: the metre gives the
+extra time to the row that opens the bar, then to the row that opens each beat, which
+keeps the beat audible where you expect it.
## Undo and export
@@ -101,3 +211,29 @@ When the song is ready, **Export as FamiTracker module** (or **File ▸ Export
FamiTracker module...**) writes the `.ftm`. See
[FamiTracker export](../formats/famitracker.md) for what the module contains and
the limits it respects.
+
+## Rendering to audio
+
+A module is for a tracker. To get a file anyone can play, use **File ▸ Render
+song...** (`Ctrl+Shift+E`), which writes the whole song as audio.
+
+The dialog holds the choices:
+
+| Setting | What it does |
+|---------|--------------|
+| **Format** | **WAV** for the full-quality file, **MP3** for a smaller one |
+| **Sample rate** | How many samples a second the file holds; 44100 Hz is the usual choice |
+| **Bit depth** (WAV) | How finely each sample is stored. 16-bit PCM is the usual choice; 8-bit is there for the crunch the NES itself has |
+| **Bitrate** (MP3) | How much the file spends per second — higher sounds better and takes more room. What is on offer depends on the sample rate, so the list follows when you change it |
+| **Normalize peak** | Lifts the whole song so its loudest moment reaches full scale, keeping the balance between channels as it was |
+| **File** | Where it is written. **Browse...** opens the save dialog, clicking the path shows where the file is going in your file manager, and the folder you pick is offered again next time |
+
+**Length** tells you how long the file will be before you start. **Render** begins,
+and a bar reports how far it has got; **Cancel** stops it and leaves the file
+unwritten. When it finishes, _SampleToNES_ shows the file it wrote — click the path
+to open its folder.
+
+A render takes the song itself, once through, with every channel sounding: muting
+and **Loop song** are for listening and stay out of the file. It is one of the long
+jobs that run alone, so the item is unavailable while a conversion or a library
+generation is going, and those wait for a render in the same way.
diff --git a/docs/images/sampletones.png b/docs/images/sampletones.png
new file mode 100644
index 00000000..3a95d82e
Binary files /dev/null and b/docs/images/sampletones.png differ
diff --git a/docs/index.md b/docs/index.md
index 92a22ec4..1b02957d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -19,7 +19,7 @@ The [**guide**](guide/) walks through the application from installation onward.
- [Installation](guide/installation.md) — the standalone build, running from source, and GPU acceleration.
- [Getting started](guide/getting-started.md) — your first reconstruction and your first song.
- [The interface](guide/interface.md) — the Main, Reconstructions, and Instructions tabs, and the menus.
-- [The sequencer](guide/sequencer.md) — the tracker: arranging samples into a song and exporting a module.
+- [The sequencer](guide/sequencer.md) — the tracker: arranging samples into a song, exporting a module, and rendering it to audio.
- [Command line](guide/command-line.md) — running without the graphical interface.
- [Where your files live](guide/files.md) — the folders and file types _SampleToNES_ uses.
- [Configuration](guide/configuration.md) — the settings you can change, and where.
@@ -56,7 +56,9 @@ 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.
+- [Sequencer blocks](development/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids.
+- [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file.
+- [Reconstruction browser](development/browser.md) — how a reconstructions directory becomes the tree both browser tabs render, and what narrows it.
- [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.
diff --git a/install.sh b/install.sh
index ec1b277d..73eea2e2 100755
--- a/install.sh
+++ b/install.sh
@@ -5,7 +5,7 @@ set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if [[ "$(uname -s)" == "Darwin" ]]; then
- exec bash "${SCRIPT_DIR}/scripts/macos/source_only.sh" "./install.sh"
+ exec bash "${SCRIPT_DIR}/scripts/macos/build/no_bundle.sh" "./install.sh"
fi
source "${SCRIPT_DIR}/scripts/linux/lib/root.sh"
diff --git a/pyproject.toml b/pyproject.toml
index d1ccdc22..ac47fab5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -46,6 +46,7 @@ dependencies = [
"rich>=13.0,<16",
"scipy>=1.13,<2",
"screeninfo>=0.8,<0.9",
+ "soundfile>=0.13,<0.14",
"tqdm>=4.66,<5",
"jeepney>=0.8,<1; sys_platform == 'linux'",
"pytaskbar>=0.1.1,<0.2; platform_system == 'Windows'",
@@ -74,7 +75,9 @@ gpu-cuda11 = [
]
[dependency-groups]
+assets = ["pillow>=11,<13"]
dev = [
+ { include-group = "assets" },
"black==26.5.1",
"isort==8.0.1",
"mypy==2.1.0",
@@ -92,12 +95,7 @@ requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.uv]
-conflicts = [
- [
- { extra = "gpu" },
- { extra = "gpu-cuda11" },
- ],
-]
+conflicts = [[{ extra = "gpu" }, { extra = "gpu-cuda11" }]]
[tool.hatch.build.targets.wheel]
packages = [
@@ -133,6 +131,7 @@ addopts = "--import-mode=importlib"
[tool.coverage.run]
source = [
"sampletones_application",
+ "sampletones_assets",
"sampletones_core",
"sampletones_shared",
"sampletones_synthesis",
@@ -147,6 +146,7 @@ python_version = "3.12"
files = [
"src/sampletones",
"src/sampletones_application",
+ "src/sampletones_assets",
"src/sampletones_core",
"src/sampletones_shared",
"src/sampletones_synthesis",
@@ -155,3 +155,39 @@ exclude = ["tests"]
disallow_subclassing_any = true
ignore_missing_imports = true
strict = true
+
+[tool.pylint.main]
+fail-under = 9.9
+ignore-paths = "^tests/.*$"
+load-plugins = ["pylint_pydantic"]
+
+[tool.pylint.messages_control]
+disable = [
+ "import-outside-toplevel",
+ "missing-class-docstring",
+ "missing-function-docstring",
+ "missing-module-docstring",
+ "too-few-public-methods",
+ "too-many-ancestors",
+ "too-many-arguments",
+ "too-many-branches",
+ "too-many-instance-attributes",
+ "too-many-lines",
+ "too-many-locals",
+ "too-many-positional-arguments",
+ "too-many-public-methods",
+ "too-many-return-statements",
+ "too-many-statements",
+]
+
+[tool.pylint.basic]
+bad-names = ["foo", "baz"]
+
+[tool.pylint.format]
+max-line-length = 120
+
+[tool.pylint.similarities]
+min-similarity-lines = 5
+
+[tool.pylint.typecheck]
+ignored-modules = ["pydantic"]
diff --git a/scripts/assets/icons.py b/scripts/assets/icons.py
new file mode 100755
index 00000000..c6484eb9
--- /dev/null
+++ b/scripts/assets/icons.py
@@ -0,0 +1,46 @@
+#!/usr/bin/env python3
+
+"""
+Writes the application icon suite from the packaged mark definition.
+
+The mark, its template and the code drawing them live in `sampletones_assets/mark`; this
+script points them at the directory the icons are shipped from.
+
+Usage:
+ python scripts/assets/icons.py # write the suite into src/sampletones_assets/icons
+"""
+
+import argparse
+import sys
+from pathlib import Path
+from typing import Final, Sequence
+
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.suite import write_icon_suite
+
+REPOSITORY_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
+ICONS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "src" / "sampletones_assets" / "icons"
+
+
+def main(argv: Sequence[str]) -> int:
+ """Writes the icon suite and reports each file it produced."""
+
+ parser = argparse.ArgumentParser(
+ description="Write the application icon suite from the mark definition.",
+ )
+ parser.add_argument(
+ "--directory",
+ type=Path,
+ default=ICONS_DIRECTORY,
+ help="directory receiving the icon files",
+ )
+ arguments = parser.parse_args(list(argv))
+
+ for path in write_icon_suite(arguments.directory, Mark.load()):
+ print(f"Wrote {path}")
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/calibration.py b/scripts/calibration.py
index 83a098a4..c129d180 100644
--- a/scripts/calibration.py
+++ b/scripts/calibration.py
@@ -1,5 +1,5 @@
import argparse
-from datetime import datetime
+from datetime import UTC, datetime
from pathlib import Path
from typing import Final
@@ -10,12 +10,16 @@
from sampletones_core.calibration.report import write_csv, write_markdown
from sampletones_core.calibration.runner import build_variants, evaluate_variants
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import DEFAULT_GENERATORS, GeneratorName
-from sampletones_core.paths import USER_PATH_DOCUMENTS
+from sampletones_core.constants.enums import (
+ DEFAULT_GENERATORS,
+ GeneratorName,
+ SpectrumMethod,
+)
from sampletones_shared.logger import logger
+from sampletones_shared.paths.user import USER_PATH_DOCUMENTS
DEFAULT_OUTPUT_ROOT: Final[Path] = USER_PATH_DOCUMENTS / "calibration"
-DEFAULT_METHODS: Final[str] = "fft,cqt"
+DEFAULT_METHODS: Final[str] = f"{SpectrumMethod.FFT.value},{SpectrumMethod.CQT.value}"
DEFAULT_PERCEPTUAL_EXPONENTS: Final[str] = "1.0"
DEFAULT_GENERATOR_NAMES: Final[str] = ",".join(generator.value for generator in DEFAULT_GENERATORS)
@@ -68,12 +72,19 @@ def main() -> None:
base = Config.load(arguments.config) if arguments.config else Config.default()
base = base.model_copy(
- update={"generation": base.generation.model_copy(update={"generators": generators})},
+ update={
+ "generation": base.generation.model_copy(
+ update={"generators": generators},
+ )
+ },
)
- output = arguments.output or DEFAULT_OUTPUT_ROOT / datetime.now().strftime("run-%Y%m%d-%H%M%S")
+ output = arguments.output or DEFAULT_OUTPUT_ROOT / datetime.now(UTC).strftime("run-%Y%m%d-%H%M%S")
output.mkdir(parents=True, exist_ok=True)
- methods = [method.strip() for method in arguments.methods.split(",") if method.strip()]
+ methods = [SpectrumMethod(name.strip()) for name in arguments.methods.split(",") if name.strip()]
+ if not methods:
+ parser.error("--methods requires at least one spectrum method")
+
exponents = [float(value) for value in arguments.perceptual_exponents.split(",") if value.strip()]
temporal_weights = [float(value) for value in arguments.temporal_weights.split(",") if value.strip()]
diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py
old mode 100644
new mode 100755
index 2628130d..14c61f15
--- a/scripts/checks/import_boundary.py
+++ b/scripts/checks/import_boundary.py
@@ -18,14 +18,16 @@
python scripts/checks/import_boundary.py --all # run all rules against the source tree
"""
+import argparse
import re
import sys
from pathlib import Path
-from typing import Final, List, NamedTuple, Tuple
+from typing import Final, List, NamedTuple, Optional, Sequence, Set
-from sampletones_shared.paths import REPOSITORY_ROOT
+from sampletones_shared.meta.source.modules import source_paths
+from sampletones_shared.meta.source.packages import package_directory
-APP_ROOT: Final[Path] = REPOSITORY_ROOT / "src" / "sampletones_application"
+APP_ROOT: Final[Path] = package_directory("sampletones_application")
IMPORT_RE = re.compile(r"^\s*(import|from)\s+([\w.]+)")
@@ -37,6 +39,7 @@
SERVICE_CONTRACTS = [
"sampletones_application.services.result",
+ "sampletones_application.services.render.result",
"sampletones_application.services.song_player.result",
]
@@ -53,6 +56,13 @@ class TokenRule(NamedTuple):
message: str
+class Violation(NamedTuple):
+ """One import or token a rule forbids, and where a reader opens it."""
+
+ kind: str
+ location: str
+
+
RULES: List[BoundaryRule] = [
BoundaryRule(
"config/**/*.py",
@@ -129,8 +139,8 @@ class TokenRule(NamedTuple):
),
TokenRule(
"ui/panels/**/*.py",
- r"parent\s*=\s*TAG_SEQUENCER_GRID_PANEL\b",
- "ui/panels must not parent into another panel's container (TAG_SEQUENCER_GRID_PANEL); "
+ r"parent\s*=\s*TAG_SEQUENCER_TRACKER_PANEL\b",
+ "ui/panels must not parent into another panel's container (TAG_SEQUENCER_TRACKER_PANEL); "
"the coordinator injects the parent through create_panel(parent)",
),
TokenRule(
@@ -150,16 +160,21 @@ def _matches_prefix(module: str, prefix: str) -> bool:
def find_token_violations(
filepath: Path,
rule: TokenRule,
-) -> List[Tuple[str, str]]:
+) -> List[Violation]:
pattern = re.compile(rule.forbidden)
- violations: List[Tuple[str, str]] = []
+ violations: List[Violation] = []
for line_number, line in enumerate(
filepath.read_text(encoding="utf-8").splitlines(),
start=1,
):
if pattern.search(line):
location = f"{filepath}:{line_number}"
- violations.append((rule.message, f"{location}: {line.strip()}"))
+ violations.append(
+ Violation(
+ kind=rule.message,
+ location=f"{location}: {line.strip()}",
+ )
+ )
return violations
@@ -167,8 +182,8 @@ def find_token_violations(
def find_violations(
filepath: Path,
rule: BoundaryRule,
-) -> List[Tuple[str, str]]:
- violations: List[Tuple[str, str]] = []
+) -> List[Violation]:
+ violations: List[Violation] = []
for line_number, line in enumerate(
filepath.read_text(encoding="utf-8").splitlines(),
start=1,
@@ -184,57 +199,114 @@ def find_violations(
for prefix in rule.forbidden:
if _matches_prefix(module, prefix):
location = f"{filepath}:{line_number}"
- violations.append((prefix, f"{location}: {line.strip()}"))
+ violations.append(
+ Violation(
+ kind=prefix,
+ location=f"{location}: {line.strip()}",
+ )
+ )
break
return violations
-def run_all_rules() -> List[Tuple[str, str]]:
- all_violations: List[Tuple[str, str]] = []
- for rule in RULES:
- for filepath in sorted(APP_ROOT.glob(rule.pattern)):
- all_violations.extend(find_violations(filepath, rule))
-
- for token_rule in TOKEN_RULES:
- for filepath in sorted(APP_ROOT.glob(token_rule.pattern)):
- all_violations.extend(find_token_violations(filepath, token_rule))
-
- return all_violations
-
-
-def run_on_files(filepaths: List[Path]) -> List[Tuple[str, str]]:
- all_violations: List[Tuple[str, str]] = []
- for rule in RULES:
- matched = {path for path in filepaths if path.match(rule.pattern)}
- for filepath in sorted(matched):
- all_violations.extend(find_violations(filepath, rule))
-
- for token_rule in TOKEN_RULES:
- matched = {path for path in filepaths if path.match(token_rule.pattern)}
- for filepath in sorted(matched):
- all_violations.extend(find_token_violations(filepath, token_rule))
-
- return all_violations
-
-
-def main() -> None:
- args = sys.argv[1:]
-
- if args == ["--all"]:
- all_violations = run_all_rules()
- else:
- filepaths = [Path(argument) for argument in args]
- all_violations = run_on_files(filepaths)
+def rule_modules(
+ package: Path,
+ pattern: str,
+ swept: Set[Path],
+ selection: Optional[Set[Path]],
+) -> List[Path]:
+ """The modules a rule reaches, in path order.
+
+ A rule names its files by one glob whether the check runs over the whole package or over the
+ files a hook lists, so the two entry points read the same rule the same way.
+
+ Args:
+ package: Package the rule globs are written against.
+ pattern: Glob the rule names its files by.
+ swept: Visible modules the package holds, which the glob is held to.
+ selection: Resolved paths to narrow the rule to, or `None` to reach every module it names.
+
+ Returns:
+ List[Path]: The modules the rule applies to.
+ """
+ matched = {path.resolve() for path in package.glob(pattern)} & swept
+ if selection is not None:
+ matched &= selection
+
+ return sorted(matched)
+
+
+def check_boundaries(package: Path, selection: Optional[Set[Path]]) -> List[Violation]:
+ """Every import and token the rules forbid in the package.
+
+ The package is swept first, so the rules run over the modules it holds and a root reading as
+ empty stops the check where it would otherwise report a clean tree.
+
+ Args:
+ package: Package the rule globs are written against.
+ selection: Resolved paths to narrow the check to, or `None` to check the whole package.
+
+ Returns:
+ List[Violation]: What the rules report, boundary rules first.
+
+ Raises:
+ NotADirectoryError: If the package names no directory.
+ FileNotFoundError: If the package holds no module to read.
+ """
+ swept = {path.resolve() for path in source_paths([package])}
+ violations = [
+ violation
+ for rule in RULES
+ for filepath in rule_modules(package, rule.pattern, swept, selection)
+ for violation in find_violations(filepath, rule)
+ ]
+ violations.extend(
+ violation
+ for token_rule in TOKEN_RULES
+ for filepath in rule_modules(package, token_rule.pattern, swept, selection)
+ for violation in find_token_violations(filepath, token_rule)
+ )
+ return violations
- if all_violations:
- print("Layer boundary violation(s) found:", file=sys.stderr)
- for kind, location in all_violations:
- print(f" [forbidden: {kind}] {location}", file=sys.stderr)
- print(f"\nFound {len(all_violations)} violation(s) in total.", file=sys.stderr)
- sys.exit(1)
+def main(argv: Sequence[str]) -> int:
+ """Report every import and token the layer boundaries forbid."""
+ parser = argparse.ArgumentParser(
+ description="Check layer-boundary import rules across the application package.",
+ )
+ parser.add_argument(
+ "files",
+ nargs="*",
+ type=Path,
+ help="modules to check",
+ )
+ parser.add_argument(
+ "--all",
+ action="store_true",
+ help=f"check every module under {APP_ROOT.name}/ instead of named files",
+ )
+ parser.add_argument(
+ "--package",
+ type=Path,
+ default=APP_ROOT,
+ help="package the rule globs are written against",
+ )
+ arguments = parser.parse_args(list(argv))
+
+ files: List[Path] = arguments.files
+ selection = None if arguments.all else {path.resolve() for path in files}
+ violations = check_boundaries(arguments.package, selection)
+ if not violations:
+ return 0
+
+ print("Layer boundary violation(s) found:", file=sys.stderr)
+ for kind, location in violations:
+ print(f" [forbidden: {kind}] {location}", file=sys.stderr)
+
+ print(f"\nFound {len(violations)} violation(s) in total.", file=sys.stderr)
+ return 1
if __name__ == "__main__":
- main()
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/checks/language_keys.py b/scripts/checks/language_keys.py
old mode 100644
new mode 100755
index e58cbe62..d6a55601
--- a/scripts/checks/language_keys.py
+++ b/scripts/checks/language_keys.py
@@ -6,7 +6,8 @@
Every lookup on a `LanguageManager` states a key, so the check reads each one and holds it against
`en.yaml`. A key spelled entirely from literals must name an entry, and every entry must be reachable
from some lookup — a key part arriving in a variable stands for each member of the enum it is
-annotated with, which is why a dynamic part names a concrete element enum.
+annotated with, which is why a dynamic part names a concrete element enum. An element enum is found
+by what it derives from, so one lives wherever its domain lives and the check reads it there.
Three things are reported:
broken lookup — a literal key the language file holds no entry for
@@ -20,26 +21,29 @@
import argparse
import importlib
import inspect
-import pkgutil
import sys
-from enum import EnumMeta
+from enum import Enum, EnumMeta
from pathlib import Path
from types import ModuleType
-from typing import Dict, Final, List, Mapping, NamedTuple, Optional, Sequence
+from typing import Callable, Dict, Final, List, Mapping, NamedTuple, Optional, Sequence, Type
import yaml
-from sampletones_application.categories import elements, hierarchy
+from sampletones_application.categories import hierarchy
from sampletones_application.categories.abstract import AbstractElement
from sampletones_application.paths import LANG_EN
from sampletones_application.tags.compose import TAG_SEPARATOR
+from sampletones_shared.meta.source.classes import declared_subclasses
from sampletones_shared.meta.source.index import source_index
from sampletones_shared.meta.source.lookups import LookupSite, tree_lookups
-from sampletones_shared.meta.source.modules import discover_modules
-from sampletones_shared.meta.source.values import EnumTable
-from sampletones_shared.paths import REPOSITORY_ROOT
+from sampletones_shared.meta.source.modules import discover_modules, module_name
+from sampletones_shared.meta.source.packages import package_directory
+from sampletones_shared.meta.source.values import EnumMembers, EnumTable
+from sampletones_shared.paths.source import SOURCE_ROOT
-SOURCE_ROOT: Final[Path] = REPOSITORY_ROOT / "src"
+EnumPredicate = Callable[[object], bool]
+
+APPLICATION_PACKAGE: Final[Path] = package_directory("sampletones_application")
RECEIVER_TYPE: Final[str] = "LanguageManager"
ELEMENT_BASE: Final[str] = AbstractElement.__name__
@@ -67,27 +71,69 @@ class Finding(NamedTuple):
message: str
-def element_modules() -> List[ModuleType]:
- """Every module of the elements package, which is where the element enums live."""
+def is_enum(member: object) -> bool:
+ """States whether a module member is an enum."""
+ return isinstance(member, EnumMeta)
+
+
+def is_element_enum(member: object) -> bool:
+ """States whether a module member is an enum of the elements a key part names."""
+ return isinstance(member, EnumMeta) and issubclass(member, AbstractElement)
+
+
+def enum_members(member_type: Type[Enum]) -> EnumMembers:
+ """The value each member of an enum spells, keyed by member name."""
+ return {member.name: str(member.value) for member in member_type}
+
+
+def declared_enums(module: ModuleType, matches: EnumPredicate) -> Dict[str, EnumMembers]:
+ """The enums a module declares itself, keyed by enum name.
+
+ An enum is read from the module declaring it, so a name a module merely imports states its
+ members once, under the module that owns it.
+
+ Args:
+ module: Imported module to read.
+ matches: What makes a member one of the enums to read.
+
+ Returns:
+ Dict[str, EnumMembers]: Enum name to its member names and the values they spell.
+ """
+ return {
+ name: enum_members(member_type)
+ for name, member_type in inspect.getmembers(module, matches)
+ if member_type.__module__ == module.__name__
+ }
+
+
+def element_enum_modules() -> List[ModuleType]:
+ """Every module of the application declaring an element enum, imported for its members.
+
+ A module is found by the classes it declares, so an enum naming a domain's own elements lives
+ beside that domain and the check reads it there.
+
+ Returns:
+ List[ModuleType]: The imported modules, in path order.
+ """
return [
- importlib.import_module(f"{elements.__name__}.{module.name}")
- for module in pkgutil.iter_modules(elements.__path__)
+ importlib.import_module(module_name(module.path, SOURCE_ROOT))
+ for module in discover_modules([APPLICATION_PACKAGE])
+ if declared_subclasses(module.tree, ELEMENT_BASE)
]
def enum_table() -> EnumTable:
"""The members of every enum a key part can name, keyed by enum name.
- The hierarchy states the page, panel, and text type of a key, and the elements package states
- its element, so together they cover every part a lookup writes.
+ The hierarchy states the page, panel, and text type of a key, and an element enum states its
+ element, so together they cover every part a lookup writes.
Returns:
EnumTable: Enum name to its member names and the values they spell.
"""
- table: Dict[str, Dict[str, str]] = {}
- for module in (hierarchy, *element_modules()):
- for name, member_type in inspect.getmembers(module, lambda member: isinstance(member, EnumMeta)):
- table[name] = {member.name: str(member.value) for member in member_type}
+ table: Dict[str, EnumMembers] = declared_enums(hierarchy, is_enum)
+ for module in element_enum_modules():
+ table.update(declared_enums(module, is_element_enum))
return table
diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py
new file mode 100755
index 00000000..002c9bf8
--- /dev/null
+++ b/scripts/checks/palette_colors.py
@@ -0,0 +1,257 @@
+#!/usr/bin/env python3
+
+"""
+Checks that a colour stays a palette token until the moment it is drawn with.
+
+`BaseColor.rgba` answers with the palette active right now, so a consumer that holds the
+token follows a palette swap and one that stores the answer keeps the shade it read at
+construction. The check reports the three ways that contract is lost: an attribute assigned the
+resolved value, a theme colour filled outside the palette bindings that record it, and a colour
+written into the shipped configuration as a literal instead of a palette token.
+
+Usage:
+ python scripts/checks/palette_colors.py # check the source tree and the config package
+"""
+
+import argparse
+import ast
+import logging
+import re
+import sys
+from collections.abc import Iterator, Sequence
+from itertools import chain
+from pathlib import Path
+from typing import Final, List, NamedTuple, Tuple, Union
+
+from sampletones_application.paths import PALETTES_DIRECTORY
+from sampletones_shared.logger import logger
+from sampletones_shared.meta.source.modules import SourceModule, discover_modules
+from sampletones_shared.meta.source.nodes import terminal_name
+from sampletones_shared.meta.source.packages import package_directory
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
+
+APPLICATION_PACKAGE: Final[Path] = package_directory("sampletones_application")
+
+HEX_COLOR: Final[re.Pattern[str]] = re.compile(r"[\"']#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?[\"']")
+
+COLOR_PROPERTY: Final[str] = "rgba"
+SELF_NAMES: Final[Tuple[str, ...]] = ("self", "cls")
+
+THEME_COLOR_CALL: Final[str] = "add_theme_color"
+CONFIG_PATTERN: Final[str] = "*.yaml"
+
+
+Assignment = Union[ast.Assign, ast.AnnAssign]
+
+
+class ColorFinding(NamedTuple):
+ """One place a colour stops following the palette, and what to do about it."""
+
+ location: str
+ message: str
+
+
+def _assignments(tree: ast.Module) -> Iterator[Assignment]:
+ for node in ast.walk(tree):
+ if isinstance(node, (ast.Assign, ast.AnnAssign)):
+ yield node
+
+
+def _assigned_targets(statement: Assignment) -> Tuple[ast.expr, ...]:
+ if isinstance(statement, ast.Assign):
+ return tuple(statement.targets)
+
+ return (statement.target,)
+
+
+def _is_own_attribute(target: ast.expr) -> bool:
+ return isinstance(target, ast.Attribute) and isinstance(target.value, ast.Name) and target.value.id in SELF_NAMES
+
+
+def _resolves_a_color(value: ast.expr) -> bool:
+ return isinstance(value, ast.Attribute) and value.attr == COLOR_PROPERTY
+
+
+def stored_colors(module: SourceModule) -> Iterator[ColorFinding]:
+ """Every attribute a module assigns the resolved value of a palette colour.
+
+ Args:
+ module: Module to read.
+
+ Yields:
+ ColorFinding: One per assignment, naming the attribute that keeps the stale shade.
+ """
+ for statement in _assignments(module.tree):
+ if statement.value is None or not _resolves_a_color(statement.value):
+ continue
+
+ for target in _assigned_targets(statement):
+ if _is_own_attribute(target):
+ yield ColorFinding(
+ location=module.location(statement),
+ message=(
+ f"stores .{COLOR_PROPERTY}; hold the BaseColor and read "
+ f".{COLOR_PROPERTY} where the colour reaches DearPyGui"
+ ),
+ )
+
+
+def dpg_module_helper() -> Tuple[Path, str]:
+ """The module allowed to fill a theme colour, and the helper every other module calls.
+
+ Returns:
+ Tuple[Path, str]: The resolved path of the bindings module, and the helper's name.
+ """
+ import sampletones_application.utils.gui.palette.dpg as bindings
+
+ return Path(bindings.__file__).resolve(), bindings.dpg_add_palette_theme_color.__name__
+
+
+def unregistered_theme_colors(
+ module: SourceModule,
+ *,
+ bindings_module: Path,
+ theme_color_helper: str,
+) -> Iterator[ColorFinding]:
+ """Every theme colour a module fills without recording the token behind it.
+
+ Args:
+ module: Module to read.
+ bindings_module: Module the call belongs in, which records the token in the same breath.
+ theme_color_helper: Name of the helper a report points at.
+
+ Yields:
+ ColorFinding: One per call, naming the theme colour that stays at the shade it was
+ built with.
+ """
+ if module.path.resolve() == bindings_module:
+ return
+
+ for node in ast.walk(module.tree):
+ if isinstance(node, ast.Call) and terminal_name(node.func) == THEME_COLOR_CALL:
+ yield ColorFinding(
+ location=module.location(node),
+ message=f"fills a theme colour directly; call {theme_color_helper} so a swap repaints it",
+ )
+
+
+def literal_colors(path: Path) -> Iterator[ColorFinding]:
+ """Every hex colour a shipped configuration file writes out in place of a palette token.
+
+ Args:
+ path: Configuration file to read.
+
+ Yields:
+ ColorFinding: One per literal, naming the line that holds it.
+ """
+ for number, line in enumerate(
+ path.read_text(encoding="utf-8").splitlines(),
+ start=1,
+ ):
+ for match in HEX_COLOR.finditer(line):
+ yield ColorFinding(
+ location=f"{path}:{number}",
+ message=f"writes the colour {match.group()} directly; name a palette token instead",
+ )
+
+
+def find_detached_colors(
+ package: Path,
+ *,
+ bindings_module: Path,
+ theme_color_helper: str,
+) -> List[ColorFinding]:
+ return [
+ finding
+ for module in discover_modules([package])
+ for finding in chain(
+ stored_colors(module),
+ unregistered_theme_colors(
+ module,
+ bindings_module=bindings_module,
+ theme_color_helper=theme_color_helper,
+ ),
+ )
+ ]
+
+
+def find_literal_colors(package: Path, palettes: Path) -> List[ColorFinding]:
+ """Every hex colour the shipped configuration writes out, outside the palettes that carry values.
+
+ Args:
+ package: Configuration package to sweep.
+ palettes: Directory holding the palettes, where a colour value belongs.
+
+ Returns:
+ List[ColorFinding]: One finding per literal, in file order.
+
+ Raises:
+ FileNotFoundError: If the package holds no configuration file to read.
+ """
+ paths = sorted(package.rglob(CONFIG_PATTERN))
+ if not paths:
+ raise FileNotFoundError(f"The configuration package {package} holds no {CONFIG_PATTERN} file to read")
+
+ return [finding for path in paths if palettes not in path.parents for finding in literal_colors(path)]
+
+
+def main(argv: Sequence[str]) -> int:
+ """Report every colour the application stores resolved or the configuration writes out."""
+
+ logger.set_level(level=logging.ERROR)
+ bindings_module, theme_color_helper = dpg_module_helper()
+
+ parser = argparse.ArgumentParser(
+ description="Check that a colour stays a palette token until it is drawn with.",
+ )
+ parser.add_argument(
+ "--package",
+ type=Path,
+ default=APPLICATION_PACKAGE,
+ help="package whose colour reads to check",
+ )
+ parser.add_argument(
+ "--config",
+ type=Path,
+ default=CONFIG_DIRECTORY,
+ help="shipped configuration package whose colours must name palette tokens",
+ )
+ parser.add_argument(
+ "--palettes",
+ type=Path,
+ default=PALETTES_DIRECTORY,
+ help="directory holding the palettes, where colour values belong",
+ )
+ arguments = parser.parse_args(list(argv))
+
+ findings = find_detached_colors(
+ arguments.package,
+ bindings_module=bindings_module,
+ theme_color_helper=theme_color_helper,
+ )
+ findings.extend(
+ find_literal_colors(
+ arguments.config,
+ arguments.palettes,
+ )
+ )
+
+ if not findings:
+ return 0
+
+ print(
+ "Colour(s) that stop following the active palette:",
+ file=sys.stderr,
+ )
+ for location, message in findings:
+ print(f" {location}: {message}", file=sys.stderr)
+
+ print(
+ f"\nFound {len(findings)} colour(s) detached from the palette.",
+ file=sys.stderr,
+ )
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main(sys.argv[1:]))
diff --git a/scripts/checks/tag_names.py b/scripts/checks/tag_names.py
old mode 100644
new mode 100755
index ebd1b60e..e2757858
--- a/scripts/checks/tag_names.py
+++ b/scripts/checks/tag_names.py
@@ -26,9 +26,9 @@
from sampletones_shared.meta.source.constants import ModuleConstant, module_constants
from sampletones_shared.meta.source.modules import SourceModule, discover_modules, parse_module
from sampletones_shared.meta.source.nodes import terminal_name
-from sampletones_shared.paths import REPOSITORY_ROOT
+from sampletones_shared.meta.source.packages import package_directory
-TAGS_PACKAGE: Final[Path] = REPOSITORY_ROOT / "src" / "sampletones_application" / "tags"
+TAGS_PACKAGE: Final[Path] = package_directory("sampletones_application", "tags")
TAG_PREFIX: Final[str] = "TAG"
TAG_NAME_CLASS: Final[str] = "TagName"
@@ -37,7 +37,12 @@
PANEL_ARGUMENT: Final[str] = "panel"
WIDGET_ARGUMENT: Final[str] = "widget"
ELEMENT_ARGUMENT: Final[str] = "element"
-TAG_ARGUMENTS: Final[Tuple[str, ...]] = (PAGE_ARGUMENT, PANEL_ARGUMENT, WIDGET_ARGUMENT, ELEMENT_ARGUMENT)
+TAG_ARGUMENTS: Final[Tuple[str, ...]] = (
+ PAGE_ARGUMENT,
+ PANEL_ARGUMENT,
+ WIDGET_ARGUMENT,
+ ELEMENT_ARGUMENT,
+)
EnumMember = TypeVar("EnumMember", bound=StrEnum)
diff --git a/scripts/checks/unused_tags.py b/scripts/checks/unused_tags.py
old mode 100644
new mode 100755
index 27d2f7db..c2a87b5c
--- a/scripts/checks/unused_tags.py
+++ b/scripts/checks/unused_tags.py
@@ -20,11 +20,11 @@
from sampletones_shared.meta.source.constants import module_constants
from sampletones_shared.meta.source.modules import SourceModule, discover_modules
+from sampletones_shared.meta.source.packages import package_directory
from sampletones_shared.meta.source.references import count_identifier_loads
-from sampletones_shared.paths import REPOSITORY_ROOT
+from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT
-SOURCE_ROOT: Final[Path] = REPOSITORY_ROOT / "src"
-TAGS_PACKAGE: Final[Path] = SOURCE_ROOT / "sampletones_application" / "tags"
+TAGS_PACKAGE: Final[Path] = package_directory("sampletones_application", "tags")
REFERENCE_ROOTS: Final[Tuple[Path, ...]] = (
SOURCE_ROOT,
REPOSITORY_ROOT / "tests",
diff --git a/scripts/ci/checks/bundle.py b/scripts/ci/checks/bundle.py
index d804e773..08fd64db 100644
--- a/scripts/ci/checks/bundle.py
+++ b/scripts/ci/checks/bundle.py
@@ -16,6 +16,9 @@
"THIRD-PARTY-LICENSES.txt",
)
+INTERNAL_DIRECTORY: Final[str] = "_internal"
+BUILD_TOOLS: Final[Sequence[str]] = ("PIL",)
+
def launcher_path(bundle: Path, *, system: str) -> Path:
"""The executable a built bundle offers on the platform it was built for."""
@@ -28,10 +31,27 @@ def missing_notices(bundle: Path) -> List[str]:
return [name for name in REQUIRED_NOTICES if not (bundle / name).is_file()]
+def carried_build_tools(bundle: Path) -> List[str]:
+ """The build-time packages found in a bundle, which the notices place on the build machine.
+
+ A bundle carries the application and its runtime dependencies. Tooling that only draws the
+ assets belongs to the machine that builds it, so finding it here means the notices describe
+ a different set of components than the bundle ships.
+ """
+ directories = (bundle, bundle / INTERNAL_DIRECTORY)
+ return [name for name in BUILD_TOOLS if any((directory / name).is_dir() for directory in directories)]
+
+
def main(argv: Sequence[str]) -> int:
- """Confirm a built bundle ships its notices and that its launcher starts."""
- parser = argparse.ArgumentParser(description="Verify a built bundle before it is archived.")
- parser.add_argument("bundle", type=Path, help="the built bundle directory, such as bin/sampletones")
+ """Confirm a built bundle ships its notices, holds to them, and that its launcher starts."""
+ parser = argparse.ArgumentParser(
+ description="Verify a built bundle before it is archived.",
+ )
+ parser.add_argument(
+ "bundle",
+ type=Path,
+ help="the built bundle directory, such as bin/sampletones",
+ )
arguments = parser.parse_args(list(argv))
bundle: Path = arguments.bundle
@@ -40,6 +60,11 @@ def main(argv: Sequence[str]) -> int:
print(f"::error::Bundle {bundle} is missing {', '.join(absent)}")
return 1
+ carried = carried_build_tools(bundle)
+ if carried:
+ print(f"::error::Bundle {bundle} carries build-time tooling its notices leave out: {', '.join(carried)}")
+ return 1
+
launcher = launcher_path(bundle, system=platform.system())
if not launcher.is_file():
print(f"::error::Bundle {bundle} offers no launcher at {launcher}")
diff --git a/scripts/linux/build/build.sh b/scripts/linux/build/build.sh
index 75629a66..3fd6327e 100755
--- a/scripts/linux/build/build.sh
+++ b/scripts/linux/build/build.sh
@@ -28,6 +28,7 @@ else
fi
bash "$SCRIPT_DIR/preflight.sh" "$@"
+bash "$SCRIPT_DIR/icons.sh"
if [[ -e "${PROJECT_DIR}/bin/sampletones" ]]; then
echo "Removing the previous artifact: ./bin/sampletones"
@@ -44,6 +45,7 @@ echo "Building executable..."
--add-data "src/sampletones_assets/fonts:assets/fonts" \
--add-data "src/sampletones_config:config" \
--copy-metadata sampletones \
+ --exclude-module PIL \
"${RELEASE_HOOK_ARGS[@]}" \
"src/sampletones/__main__.py"
diff --git a/scripts/linux/build/icons.sh b/scripts/linux/build/icons.sh
new file mode 100644
index 00000000..df7bf35b
--- /dev/null
+++ b/scripts/linux/build/icons.sh
@@ -0,0 +1,12 @@
+#!/usr/bin/env bash
+
+set -e
+
+SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
+. "$SCRIPT_DIR/../lib/root.sh"
+
+PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd)
+VENV_PY="$PROJECT_DIR/.venv-build/bin/python"
+
+echo "Generating the icon suite..."
+"$VENV_PY" scripts/assets/icons.py
diff --git a/scripts/linux/build/sampletones.sh b/scripts/linux/build/sampletones.sh
index 7b829235..d5accc98 100755
--- a/scripts/linux/build/sampletones.sh
+++ b/scripts/linux/build/sampletones.sh
@@ -23,6 +23,6 @@ echo "Installing dependencies..."
EXTRAS_STR=$(IFS=,; echo "${EXTRAS[*]}")
echo "Installing with extras: $EXTRAS_STR"
-"$VENV_PY" -m pip install ".[$EXTRAS_STR]"
+"$VENV_PY" -m pip install ".[$EXTRAS_STR]" --group assets
echo "sampletones Python package installed successfully."
diff --git a/scripts/macos/build/build_env.sh b/scripts/macos/build/build_env.sh
new file mode 100755
index 00000000..f6d19582
--- /dev/null
+++ b/scripts/macos/build/build_env.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+set -e
+
+if ! command -v brew >/dev/null 2>&1; then
+ echo "ERROR: Homebrew is required to locate the PortAudio headers and library." >&2
+ echo "Run scripts/macos/build/dependencies.sh first." >&2
+ exit 1
+fi
+
+PORTAUDIO_PREFIX=$(brew --prefix portaudio)
+
+echo "CFLAGS=-I${PORTAUDIO_PREFIX}/include"
+echo "LDFLAGS=-L${PORTAUDIO_PREFIX}/lib"
+echo "ARCHFLAGS=-arch $(uname -m)"
diff --git a/scripts/macos/build/dependencies.sh b/scripts/macos/build/dependencies.sh
new file mode 100755
index 00000000..ca504908
--- /dev/null
+++ b/scripts/macos/build/dependencies.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+set -e
+
+PACKAGES=(
+ portaudio
+)
+
+if ! command -v brew >/dev/null 2>&1; then
+ echo "ERROR: Homebrew is required to install the macOS system dependencies." >&2
+ echo "Install it from https://brew.sh, then run this script again." >&2
+ exit 1
+fi
+
+echo "Installing system dependencies through Homebrew"
+brew install "${PACKAGES[@]}"
+echo "System dependencies installed."
diff --git a/scripts/macos/source_only.sh b/scripts/macos/build/no_bundle.sh
similarity index 90%
rename from scripts/macos/source_only.sh
rename to scripts/macos/build/no_bundle.sh
index 93b7543e..b6cfb4ec 100755
--- a/scripts/macos/source_only.sh
+++ b/scripts/macos/build/no_bundle.sh
@@ -7,6 +7,7 @@ OPERATION="${1:-this command}"
echo "ERROR: ${OPERATION} supports Linux and Windows." >&2
echo "On macOS, SampleToNES runs from source:" >&2
echo >&2
+echo " make system-deps" >&2
echo " make setup" >&2
echo " make run" >&2
echo >&2
diff --git a/scripts/windows/build/build.bat b/scripts/windows/build/build.bat
index 9b8137d7..915dc471 100644
--- a/scripts/windows/build/build.bat
+++ b/scripts/windows/build/build.bat
@@ -30,6 +30,7 @@ if "%RELEASE%"=="1" (
)
call "%SCRIPT_DIR%preflight.bat" %* || exit /b 1
+call "%SCRIPT_DIR%icons.bat" || exit /b 1
if exist "bin\sampletones.exe" (
echo Removing the previous artifact: bin\sampletones.exe
@@ -51,6 +52,7 @@ echo Building executable...
--add-data "src\sampletones_assets\fonts;assets\fonts" ^
--add-data "src\sampletones_config;config" ^
--copy-metadata sampletones ^
+ --exclude-module PIL ^
%RELEASE_HOOK% ^
"src\sampletones\__main__.py" || exit /b
diff --git a/scripts/windows/build/icons.bat b/scripts/windows/build/icons.bat
new file mode 100644
index 00000000..d4ee7f14
--- /dev/null
+++ b/scripts/windows/build/icons.bat
@@ -0,0 +1,14 @@
+@echo off
+setlocal EnableExtensions
+
+set "SCRIPT_DIR=%~dp0"
+call "%SCRIPT_DIR%\..\lib\root.bat" || exit /b 1
+
+set "PROJECT_DIR=%SCRIPT_DIR%..\..\.."
+set "VENV_DIR=%PROJECT_DIR%\.venv-build"
+set "VENV_PY=%VENV_DIR%\Scripts\python.exe"
+
+echo Generating the icon suite...
+"%VENV_PY%" scripts\assets\icons.py || exit /b 1
+
+exit /b 0
diff --git a/scripts/windows/build/sampletones.bat b/scripts/windows/build/sampletones.bat
index 35fed651..902ea372 100644
--- a/scripts/windows/build/sampletones.bat
+++ b/scripts/windows/build/sampletones.bat
@@ -22,7 +22,7 @@ echo Installing dependencies...
"%VENV_PY%" -m pip install --upgrade pip
echo Installing with extras: !EXTRAS!
-"%VENV_PY%" -m pip install ".[!EXTRAS!]" || exit /b 1
+"%VENV_PY%" -m pip install ".[!EXTRAS!]" --group assets || exit /b 1
echo sampletones Python package installed successfully.
exit /b 0
diff --git a/src/sampletones/__init__.py b/src/sampletones/__init__.py
index 3ce71ef4..936911a5 100644
--- a/src/sampletones/__init__.py
+++ b/src/sampletones/__init__.py
@@ -75,19 +75,19 @@ def __getattr__(name: str) -> Any:
__all__ = [
"Config",
- "Window",
+ "Generator",
+ "GeneratorName",
+ "Instruction",
"InstructionLibrary",
+ "NoiseGenerator",
+ "NoiseInstruction",
+ "PulseGenerator",
+ "PulseInstruction",
"Reconstruction",
"Reconstructor",
- "Generator",
- "PulseGenerator",
"TriangleGenerator",
- "NoiseGenerator",
- "Instruction",
- "PulseInstruction",
"TriangleInstruction",
- "NoiseInstruction",
- "GeneratorName",
+ "Window",
"__version__",
]
diff --git a/src/sampletones/__main__.py b/src/sampletones/__main__.py
index 9592c1e9..b7625bb2 100644
--- a/src/sampletones/__main__.py
+++ b/src/sampletones/__main__.py
@@ -5,7 +5,7 @@
from pathlib import Path
from typing import TYPE_CHECKING, Optional
-from sampletones_core.paths import EXT_FILES_AUDIO
+from sampletones_shared.paths.extensions import EXT_FILES_AUDIO
if TYPE_CHECKING:
from sampletones_core.configs import Config
@@ -119,7 +119,7 @@ def main() -> None:
config_path = Path(args.config) if args.config else None
output_path = Path(args.output) if args.output else None
- from sampletones_core.paths import (
+ from sampletones_shared.paths.extensions import (
EXT_FILE_LIBRARY,
EXT_FILE_PROJECT,
EXT_FILE_RECONSTRUCTION,
diff --git a/src/sampletones/run.py b/src/sampletones/run.py
index 758d65d7..a99e233b 100644
--- a/src/sampletones/run.py
+++ b/src/sampletones/run.py
@@ -2,6 +2,7 @@
from typing import Optional
from sampletones_application.application import Application
+from sampletones_application.config.profile import UserProfile
from sampletones_shared.application import SAMPLETONES_NAME_VERSION
from sampletones_shared.logger import logger
@@ -15,6 +16,7 @@ def run_application(
) -> None:
logger.info(SAMPLETONES_NAME_VERSION)
gui = Application(
+ profile=UserProfile.user(),
config_path=config_path,
library_path=library_path,
reconstruction_path=reconstruction_path,
diff --git a/src/sampletones/self_check.py b/src/sampletones/self_check.py
index a2fd9d29..ff5bb192 100644
--- a/src/sampletones/self_check.py
+++ b/src/sampletones/self_check.py
@@ -1,11 +1,12 @@
import sys
from dataclasses import dataclass
-from typing import TYPE_CHECKING, Callable, Final, Tuple, Type
+from typing import TYPE_CHECKING, Callable, Final, List, Tuple, Type
from sampletones_shared.exceptions import SampleToNESError
if TYPE_CHECKING:
- from sampletones_application.utils.palette import Palette
+ from sampletones_application.utils.palette.catalog import PaletteCatalog
+ from sampletones_application.utils.palette.source import PaletteSource
CHECK_FAILURES: Final[Tuple[Type[Exception], ...]] = (
ImportError,
@@ -13,6 +14,7 @@
KeyError,
TypeError,
ValueError,
+ SystemError,
SampleToNESError,
)
@@ -36,11 +38,11 @@ class SelfCheck:
run: Callable[[], str]
-def _load_palette() -> "Palette":
- from sampletones_application.paths import PALETTE_PATH
- from sampletones_application.utils.palette import Palette
+def _load_palette_catalog() -> "PaletteCatalog":
+ from sampletones_application.paths import PALETTES_DIRECTORY
+ from sampletones_application.utils.palette.catalog import PaletteCatalog
- return Palette.load(PALETTE_PATH)
+ return PaletteCatalog.load(PALETTES_DIRECTORY)
def _check_application_import() -> str:
@@ -63,25 +65,44 @@ def _check_deployment_config() -> str:
return f"log_level={deployment.log_level}, strict_history={deployment.strict_history}"
-def _check_palette() -> str:
- palette = _load_palette()
- return f"{palette.name}, {len(palette.colors)} colors"
+def _check_palettes() -> str:
+ catalog = _load_palette_catalog()
+ return f"{', '.join(catalog.names)}, {len(catalog.default.colors)} colors each"
+
+
+def _palette_sources() -> "List[PaletteSource]":
+ from sampletones_application.utils.palette.source import PaletteSource
+
+ return [PaletteSource(palette) for palette in _load_palette_catalog().palettes.values()]
+
+
+def _check_keybindings() -> str:
+ """Loads every shipped scheme, which is where an unanswered action or a clashing key surfaces."""
+ from sampletones_application.paths import KEYBINDINGS_DIRECTORY
+ from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog
+
+ catalog = ShortcutCatalog.load(KEYBINDINGS_DIRECTORY)
+ return f"{', '.join(catalog.names)}, {len(catalog.default.bindings)} actions each"
def _check_layout_config() -> str:
+ """Resolves the layout against every shipped palette, since each answers the colour tokens itself."""
from sampletones_application.layout import LayoutConfig, load_layout_config
from sampletones_application.paths import BEHAVIOR_DIRECTORY, LAYOUT_DIRECTORY
- load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, _load_palette())
+ for source in _palette_sources():
+ load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
+
return f"{len(LayoutConfig.model_fields)} sections"
def _check_themes() -> str:
+ """Resolves the theme set against every shipped palette, since each answers the colour tokens itself."""
from sampletones_application.paths import THEME_DIRECTORY
from sampletones_application.ui.themes.loader import ThemeLoader
- themes = ThemeLoader(THEME_DIRECTORY, _load_palette()).load_all()
- return f"{len(themes)} themes"
+ themes = [ThemeLoader(THEME_DIRECTORY, source).load_all() for source in _palette_sources()]
+ return f"{len(themes[0])} themes"
def _check_language() -> str:
@@ -114,7 +135,8 @@ def _check_file_dialog_backend() -> str:
CHECKS: Final[Tuple[SelfCheck, ...]] = (
SelfCheck(name="application import", run=_check_application_import),
SelfCheck(name="deployment config", run=_check_deployment_config),
- SelfCheck(name="palette", run=_check_palette),
+ SelfCheck(name="palettes", run=_check_palettes),
+ SelfCheck(name="keybindings", run=_check_keybindings),
SelfCheck(name="layout config", run=_check_layout_config),
SelfCheck(name="themes", run=_check_themes),
SelfCheck(name="language", run=_check_language),
diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py
index b36732bd..ecb423e3 100644
--- a/src/sampletones_application/application.py
+++ b/src/sampletones_application/application.py
@@ -11,7 +11,12 @@
)
from sampletones_application.config.managers.config import ConfigManager
from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.config.profile import UserProfile
+from sampletones_application.constants.playback import FollowMode
from sampletones_application.coordinators.config import ConfigCoordinator
+from sampletones_application.coordinators.display import DisplayCoordinator
+from sampletones_application.coordinators.edit.router import EditRouter
+from sampletones_application.coordinators.keybindings import KeybindingsCoordinator
from sampletones_application.coordinators.original_audio import OriginalAudioLocator
from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol
from sampletones_application.coordinators.playback.router import PlaybackRouter
@@ -19,7 +24,10 @@
from sampletones_application.coordinators.reconstruction import (
ReconstructionCoordinator,
)
-from sampletones_application.coordinators.tabs.instructions import InstructionsTabCoordinator
+from sampletones_application.coordinators.render import SongRenderCoordinator
+from sampletones_application.coordinators.tabs.instructions import (
+ InstructionsTabCoordinator,
+)
from sampletones_application.coordinators.tabs.main import MainTabCoordinator
from sampletones_application.coordinators.tabs.reconstruction import (
ReconstructionTabCoordinator,
@@ -38,8 +46,9 @@
ReconstructionTitlePart,
document_title,
)
-from sampletones_application.logic.reconstruction.browser_manager import BrowserManager
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
+from sampletones_application.logic.render import SongRenderLogic
from sampletones_application.parameters import (
InstructionsTabParameters,
MainTabParameters,
@@ -49,9 +58,10 @@
from sampletones_application.paths import (
BEHAVIOR_DIRECTORY,
DEPLOYMENT_CONFIG_PATH,
+ KEYBINDINGS_DIRECTORY,
LANG_EN,
LAYOUT_DIRECTORY,
- PALETTE_PATH,
+ PALETTES_DIRECTORY,
THEME_DIRECTORY,
)
from sampletones_application.services import (
@@ -65,11 +75,13 @@
ServiceCancelled,
ServiceError,
ServiceSuccess,
+ SongRenderService,
)
from sampletones_application.shell import ApplicationShell, ShortcutBindings
from sampletones_application.tags.general import (
TAG_GLOBAL_DIALOG_ABOUT,
TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION,
+ TAG_GLOBAL_TEXTURE_LOGO,
TAG_GLOBAL_THEME_DEFAULT,
TAG_GLOBAL_THEME_MENU_FPS,
TAG_GLOBAL_THEME_PLAYER_BUTTON,
@@ -85,9 +97,15 @@
from sampletones_application.ui.panels.dialogs.audio_settings import (
GUIAudioSettingsWindow,
)
+from sampletones_application.ui.panels.dialogs.countdown import GUICountdownWindow
+from sampletones_application.ui.panels.dialogs.display_settings import (
+ GUIDisplaySettingsWindow,
+)
+from sampletones_application.ui.panels.dialogs.keybindings import GUIKeybindingsWindow
from sampletones_application.ui.panels.dialogs.project_properties import (
GUIProjectPropertiesWindow,
)
+from sampletones_application.ui.panels.dialogs.render import GUIRenderWindow
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.ui.themes.setup import setup_themes
from sampletones_application.utils.callbacks.queue import CallbackQueue
@@ -101,8 +119,14 @@
from sampletones_application.utils.frame_limiter import FrameLimiter
from sampletones_application.utils.gui.dialogs import DialogsRenderer, get_dialog_tag
from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.palette.palette import PaletteBindings
+from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog
from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager
-from sampletones_application.utils.palette import Palette
+from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.palette import Palette
+from sampletones_application.utils.palette.source import PaletteSource
from sampletones_application.utils.parallelization.background import (
stop_background_workers,
)
@@ -118,9 +142,9 @@
from sampletones_core.constants.audio import BufferSize, SampleRate
from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.exporters import Features
-from sampletones_core.paths import EXT_FILES_AUDIO
from sampletones_core.project.instruments.sample import Sample
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.structures.tree import FileSystemNode
from sampletones_core.trackers.backend import TrackerBackend
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.registry import build_tracker_backends
@@ -130,7 +154,9 @@
SAMPLETONES_GROUP,
SAMPLETONES_NAME_VERSION,
)
+from sampletones_shared.exceptions import PlaybackError
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILES_AUDIO
from sampletones_shared.types.application import Sender
SEQUENCER_SAMPLE_TITLE_FORMAT: Final[str] = "{ordinal}: {name}"
@@ -154,6 +180,7 @@ class Application:
def __init__(
self,
+ profile: UserProfile,
config_path: Optional[Path] = None,
library_path: Optional[Path] = None,
reconstruction_path: Optional[Path] = None,
@@ -162,7 +189,11 @@ def __init__(
self.deployment: DeploymentConfig = DeploymentConfig.load(DEPLOYMENT_CONFIG_PATH)
self._set_logging_level()
- self._palette: Palette = Palette.load(PALETTE_PATH)
+ self.session_manager = SessionManager(profile)
+ self._palette_catalog: PaletteCatalog = PaletteCatalog.load(PALETTES_DIRECTORY)
+ self._palette_source: PaletteSource = PaletteSource(
+ self._palette_catalog.select(self.session_manager.palette_name),
+ )
self.layout: LayoutConfig = self._load_layout_config()
self._setup_gui_elements()
@@ -172,16 +203,21 @@ def __init__(
display_time=self.layout.behavior.ui.status_bar_display_time,
)
self.key_router: KeyRouter = KeyRouter()
- self.shortcut_manager: ShortcutManager = ShortcutManager(key_router=self.key_router)
+ self._shortcut_catalog: ShortcutCatalog = ShortcutCatalog.load(KEYBINDINGS_DIRECTORY)
+ self._shortcut_source: ShortcutSource = ShortcutSource(self._preferred_scheme())
+ self.shortcut_manager: ShortcutManager = ShortcutManager(
+ key_router=self.key_router,
+ shortcut_source=self._shortcut_source,
+ )
self.dialogs: DialogsRenderer = DialogsRenderer(
layout=self.layout.general,
language_manager=self.language_manager,
status_bar=self.status_bar,
key_router=self.key_router,
+ shortcut_source=self._shortcut_source,
)
self.audio_device_manager: AudioDeviceManager = AudioDeviceManager()
self.config_manager = ConfigManager(config_path)
- self.session_manager = SessionManager()
self.library_manager = InstructionsLibraryManager(
self.config_manager,
@@ -199,6 +235,7 @@ def __init__(
self.conversion_service: ConversionService = ConversionService(priority=_priority)
self.regeneration_service: RegenerationService = RegenerationService(priority=_priority)
self.export_service: ExportService = ExportService(priority=_priority)
+ self.render_service: SongRenderService = SongRenderService(priority=_priority)
self.retune_service: SampleRetuneService = SampleRetuneService(priority=_priority)
self.retune_service.subscribe(self._on_retune_result)
@@ -215,22 +252,54 @@ def __init__(
self.project_controller.on_saved = self.history.mark_saved
self.history.on_history_changed = self._on_history_changed
- self.fps_timer: FPSTimer = FPSTimer(interval=self.layout.behavior.main.fps_update_interval)
- self.frame_limiter: FrameLimiter = FrameLimiter(self.layout.behavior.main.max_fps)
+ self.fps_timer: FPSTimer = FPSTimer(interval=self.layout.behavior.ui.fps_update_interval)
+ self.frame_limiter: FrameLimiter = FrameLimiter(self.session_manager.max_fps)
self._audio_was_playing: bool = False
self.audio_settings_window: GUIAudioSettingsWindow = GUIAudioSettingsWindow(
layout=self.layout.settings,
language_manager=self.language_manager,
key_router=self.key_router,
+ shortcut_source=self._shortcut_source,
)
self.audio_settings_window.on_commit = self._apply_audio_settings
self.audio_settings_window.on_refresh_devices = self._refresh_audio_devices
self.audio_settings_window.on_master_gain_changed = self.session_manager.set_master_gain
+ self.display_settings_window: GUIDisplaySettingsWindow = GUIDisplaySettingsWindow(
+ layout=self.layout.settings,
+ language_manager=self.language_manager,
+ key_router=self.key_router,
+ shortcut_source=self._shortcut_source,
+ )
+ self.keybindings_window: GUIKeybindingsWindow = GUIKeybindingsWindow(
+ layout=self.layout.settings,
+ language_manager=self.language_manager,
+ key_router=self.key_router,
+ shortcut_source=self._shortcut_source,
+ )
+ self.display_countdown_window: GUICountdownWindow = GUICountdownWindow(
+ layout=self.layout.settings.display.countdown,
+ title=self.language_manager["settings.display.title.countdown"],
+ message=self.language_manager["settings.display.message.countdown"],
+ remaining_format=self.language_manager["settings.display.template.countdown_remaining"],
+ keep_label=self.language_manager["settings.display.label.keep_button"],
+ revert_label=self.language_manager["settings.display.label.revert_button"],
+ key_router=self.key_router,
+ shortcut_source=self._shortcut_source,
+ )
+ self.render_window: GUIRenderWindow = GUIRenderWindow(
+ layout=self.layout.settings,
+ path_colors=self.layout.general.colors.paths,
+ language_manager=self.language_manager,
+ key_router=self.key_router,
+ shortcut_source=self._shortcut_source,
+ status_bar=self.status_bar,
+ )
self.project_properties_window: GUIProjectPropertiesWindow = GUIProjectPropertiesWindow(
layout=self.layout.project_properties,
language_manager=self.language_manager,
key_router=self.key_router,
+ shortcut_source=self._shortcut_source,
)
self.project_properties_window.on_commit = self._commit_project_properties
self.theme = ThemeRegistry.get(TAG_GLOBAL_THEME_DEFAULT)
@@ -246,20 +315,43 @@ def __init__(
player_glyphs=self.layout.glyphs.player,
player_layout=self.layout.player,
language_manager=self.language_manager,
+ build_edit_actions=self._build_edit_actions,
on_play_from_start=self._play_from_start,
on_pause_or_resume=self._play,
on_stop=self._stop,
+ on_channel_muted=self._mute_channel,
)
self._viewport_manager = ViewportManager(
self.session_manager,
self.theme,
- min_width=self.layout.general.window.min_width,
- min_height=self.layout.general.window.min_height,
- vsync=self.layout.behavior.main.vsync,
+ self.layout.general.window,
on_fullscreen_state_changed=self._update_menu,
)
+ self._display_coordinator = DisplayCoordinator(
+ self.session_manager,
+ self._viewport_manager,
+ self.frame_limiter,
+ self._palette_source,
+ self._palette_catalog,
+ window=self.display_settings_window,
+ countdown=self.display_countdown_window,
+ behavior=self.layout.behavior.display,
+ window_layout=self.layout.general.window,
+ dialogs=self.dialogs,
+ language_manager=self.language_manager,
+ )
+
+ self._keybindings_coordinator = KeybindingsCoordinator(
+ self.session_manager,
+ self._shortcut_source,
+ self._shortcut_catalog,
+ window=self.keybindings_window,
+ dialogs=self.dialogs,
+ language_manager=self.language_manager,
+ )
+
self._project_coordinator = ProjectCoordinator(
self.project_controller,
self.project_manager,
@@ -299,11 +391,9 @@ def __init__(
export_service=self.export_service,
tracker_backends=self.tracker_backends,
on_load_reconstruction_with_confirmation=self._reconstruction_coordinator.load_with_confirmation,
- on_reconstruct_file=self._reconstruct_file_dialog,
- on_reconstruct_directory=self._reconstruct_directory_dialog,
on_change_audio_state=self._update_menu,
+ on_favorite_changed=self._repaint_reconstruction_favorites,
on_reconstruction_instrument_updated=self._regenerate_instrument,
- is_operation_active=self._is_operation_active,
original_audio_locator=self._original_audio_locator,
layout=ReconstructionTabParameters.from_config(self.layout),
language_manager=self.language_manager,
@@ -356,21 +446,26 @@ def __init__(
session_manager=self.session_manager,
audio_device_manager=self.audio_device_manager,
key_router=self.key_router,
+ shortcut_source=self._shortcut_source,
browser_manager=self.browser_manager,
project_controller=self.project_controller,
history=self.history,
original_audio_locator=self._original_audio_locator,
+ tab_active=self._is_sequencer_tab_current,
layout=SequencerTabParameters.from_config(self.layout),
language_manager=self.language_manager,
dialogs=self.dialogs,
status_bar=self.status_bar,
on_edit_sample_requested=self._edit_project_sample,
+ on_favorite_changed=self._repaint_reconstruction_favorites,
on_sample_reconstruction_replaced=self._rebind_replaced_sample,
on_tab_switch=self._set_current_tab,
on_nes_frequency_changed=self._retune_samples_for_rate,
on_channels_changed=self._update_menu,
)
+ self._edit_router = EditRouter(surfaces=self._sequencer_tab.edit_surfaces)
+
self._playback_router = PlaybackRouter(
sources=(
self._reconstructions_tab.player,
@@ -389,6 +484,23 @@ def __init__(
language_manager=self.language_manager,
)
+ self._render_logic = SongRenderLogic(
+ self.project_controller,
+ self.config_manager,
+ self.session_manager,
+ self.render_service,
+ language_manager=self.language_manager,
+ is_operation_active=self._is_operation_active,
+ )
+
+ self._render_coordinator = SongRenderCoordinator(
+ self._render_logic,
+ window=self.render_window,
+ dialogs=self.dialogs,
+ language_manager=self.language_manager,
+ on_activity_changed=self._on_render_activity_changed,
+ )
+
self._shell = ApplicationShell(
session_manager=self.session_manager,
language_manager=self.language_manager,
@@ -435,10 +547,15 @@ def _try_load_library(self, path: Path) -> None:
def _load_layout_config(self) -> LayoutConfig:
try:
- return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, self._palette)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, self._palette_source)
except ValidationError as exception:
raise SystemError(f"Invalid layout configuration: {exception}") from exception
+ def _preferred_scheme(self) -> ShortcutScheme:
+ """The keys the session runs under: the scheme it names, as its own overrides rebind it."""
+ scheme = self._shortcut_catalog.select(self.session_manager.shortcut_scheme_name)
+ return scheme.with_overrides(self.session_manager.shortcut_overrides)
+
def _setup_gui_elements(self) -> None:
FontRegistry.setup(self.layout.fonts)
GUIPanel.configure_section_header(
@@ -448,7 +565,7 @@ def _setup_gui_elements(self) -> None:
)
try:
- setup_themes(THEME_DIRECTORY, self._palette)
+ setup_themes(THEME_DIRECTORY, self._palette_source)
except ValidationError as exception:
raise SystemError(f"Invalid theme configuration: {exception}") from exception
@@ -472,6 +589,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings:
save_project_as=self._project_coordinator.save_as_dialog,
project_properties=self._open_project_properties,
export_project=self._project_coordinator.export_project_dialog,
+ render_song=self._render_coordinator.open,
close_project=self._project_coordinator.close_with_confirmation,
exit=self._on_close,
undo=self._sequencer_tab.undo,
@@ -494,16 +612,21 @@ def _create_shortcut_bindings(self) -> ShortcutBindings:
play_from_frame=self._play_from_frame,
stop=self._stop,
toggle_autoplay=self._toggle_autoplay,
- toggle_follow_playback=self._toggle_follow_playback,
+ set_follow_mode=self._set_follow_mode,
toggle_loop_song=self._toggle_loop_song,
- toggle_channel=self._sequencer_tab.toggle_channel,
+ toggle_channel=self._toggle_channel,
unmute_all_channels=self._sequencer_tab.unmute_all_channels,
audio_settings=self._open_audio_settings,
+ display_settings=self._display_coordinator.open,
+ keyboard_settings=self._keybindings_coordinator.open,
toggle_advanced_settings=self._toggle_advanced_settings,
+ toggle_auto_expand_favorite_reconstructions=self._toggle_auto_expand_favorite_reconstructions,
+ toggle_auto_expand_favorite_directories=self._toggle_auto_expand_favorite_directories,
toggle_fullscreen=self._shell.toggle_fullscreen,
about=self._open_about_dialog,
next_tab=self._next_tab,
previous_tab=self._previous_tab,
+ select_tab=self._set_current_tab,
)
def _setup_shell(self, bindings: ShortcutBindings) -> None:
@@ -540,8 +663,35 @@ def _set_callbacks(self) -> None:
self.audio_device_manager.set_callbacks(on_playback_error=self._on_playback_error)
self._reconstructions_tab.set_on_add_to_sequencer(self._sequencer_tab.import_reconstruction)
self._reconstructions_tab.set_can_add_to_sequencer(self._is_project_open)
+ self._palette_source.on_palette_changed = self._on_palette_changed
+ self._shortcut_source.on_bindings_changed = self._on_bindings_changed
+
+ def _on_bindings_changed(self, _scheme: ShortcutScheme) -> None:
+ """Hands the keys of the scheme now in place to what has already read a combination.
+
+ Every registration names the action it fires, so the work left is the copies of the keys:
+ the index a press resolves through and the accelerators the menus print.
+ """
+ self.shortcut_manager.rebind()
- def _on_tab_changed(self, sender: Sender, app_data: Any, user_data: Any) -> None:
+ def _on_palette_changed(self, _palette: Palette) -> None:
+ """Repaints what holds a colour DearPyGui has copied, once another palette is in place.
+
+ Every layout and theme colour already answers with the new palette, so the work left is
+ handing those values to the copies DearPyGui keeps: the registered theme colours and item
+ arguments, the viewport clear colour, and the sequencer tables, whose tints belong to the
+ table rather than to an item.
+ """
+ PaletteBindings.apply()
+ self._viewport_manager.refresh_clear_color()
+ self._sequencer_tab.repaint()
+
+ def _on_tab_changed(
+ self,
+ _sender: Sender,
+ _app_data: Any,
+ _user_data: Any,
+ ) -> None:
self._update_menu()
def _build_initial_menu_state(self) -> MenuBarViewModel:
@@ -552,6 +702,7 @@ def _build_initial_menu_state(self) -> MenuBarViewModel:
reconstruction_in_project=self._editing_project_sample(),
reconstruction_file_backed=self._reconstruction_coordinator.is_saveable(),
reconstruction_audio_recorded=self.reconstruction_manager.audio_filepath is not None,
+ operation_active=self._is_operation_active(),
can_undo=self.history.can_undo,
can_redo=self.history.can_redo,
play_label=self.language_manager["global.menu.label.item_playback_play"],
@@ -562,16 +713,27 @@ def _build_initial_menu_state(self) -> MenuBarViewModel:
player_paused=False,
stop_enabled=False,
autoplay=self.session_manager.autoplay,
- follow_playback=self.session_manager.follow_playback,
+ follow_mode=self.session_manager.follow_mode,
loop_song=self.session_manager.loop_song,
channels=self._sequencer_tab.channels,
fullscreen=self.session_manager.fullscreen,
advanced_settings=self.session_manager.advanced_settings,
+ auto_expand_favorite_reconstructions=self.session_manager.auto_expand_favorite_reconstructions,
+ auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories,
)
+ def _is_sequencer_tab_current(self) -> bool:
+ """Whether the Sequencer is the tab in front, which is what puts its panels on the keyboard.
+
+ The tracker, order and samples panels keep their cursor and selection while another tab is
+ worked on, so this is what tells a press meant for the reconstruction in front from one
+ meant for the song.
+ """
+ return self._shell.get_current_tab() == Tab.SEQUENCER
+
def _is_play_from_frame_enabled(self) -> bool:
"""Playing from the current frame applies to the Sequencer's song, so it needs that tab open."""
- return self._shell.get_current_tab() == Tab.SEQUENCER and self.project_manager.is_open
+ return self._is_sequencer_tab_current() and self.project_manager.is_open
def _build_menu_bar_viewmodel(self) -> MenuBarViewModel:
return MenuBarViewModel(
@@ -581,6 +743,7 @@ def _build_menu_bar_viewmodel(self) -> MenuBarViewModel:
reconstruction_in_project=self._editing_project_sample(),
reconstruction_file_backed=self._reconstruction_coordinator.is_saveable(),
reconstruction_audio_recorded=self.reconstruction_manager.audio_filepath is not None,
+ operation_active=self._is_operation_active(),
can_undo=self.history.can_undo,
can_redo=self.history.can_redo,
play_label=self._playback_router.play_label,
@@ -591,11 +754,13 @@ def _build_menu_bar_viewmodel(self) -> MenuBarViewModel:
player_paused=self._playback_router.is_paused,
stop_enabled=self._playback_router.is_stop_enabled,
autoplay=self.session_manager.autoplay,
- follow_playback=self.session_manager.follow_playback,
+ follow_mode=self.session_manager.follow_mode,
loop_song=self.session_manager.loop_song,
channels=self._sequencer_tab.channels,
fullscreen=self.session_manager.fullscreen,
advanced_settings=self.session_manager.advanced_settings,
+ auto_expand_favorite_reconstructions=self.session_manager.auto_expand_favorite_reconstructions,
+ auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories,
)
def _on_history_changed(self) -> None:
@@ -614,40 +779,52 @@ def _update_menu(self) -> None:
def _toggle_autoplay(
self,
- sender: Optional[Sender] = None,
- app_data: Optional[Any] = None,
- user_data: Optional[Any] = None,
+ _sender: Optional[Sender] = None,
+ _app_data: Optional[Any] = None,
+ _user_data: Optional[Any] = None,
) -> None:
self.session_manager.toggle_autoplay()
self._update_menu()
- def _toggle_follow_playback(
- self,
- sender: Optional[Sender] = None,
- app_data: Optional[Any] = None,
- user_data: Optional[Any] = None,
- ) -> None:
- self.session_manager.set_follow_playback(not self.session_manager.follow_playback)
+ def _set_follow_mode(self, mode: FollowMode) -> None:
+ """Chooses how far the sequencer view chases the playhead, and marks the choice in the menu.
+
+ The tab coordinator carries this to the player, which holds the setting and emits a view as
+ it changes, so the grid's following settles in the same step as the menu's mark.
+ """
+ self._sequencer_tab.set_follow_mode(mode)
self._update_menu()
def _toggle_loop_song(
self,
- sender: Optional[Sender] = None,
- app_data: Optional[Any] = None,
- user_data: Optional[Any] = None,
+ _sender: Optional[Sender] = None,
+ _app_data: Optional[Any] = None,
+ _user_data: Optional[Any] = None,
) -> None:
self.session_manager.set_loop_song(not self.session_manager.loop_song)
self._update_menu()
def _toggle_advanced_settings(
self,
- sender: Optional[Sender] = None,
- app_data: Optional[Any] = None,
- user_data: Optional[Any] = None,
+ _sender: Optional[Sender] = None,
+ _app_data: Optional[Any] = None,
+ _user_data: Optional[Any] = None,
) -> None:
self._main_tab.toggle_advanced_settings()
self._update_menu()
+ def _toggle_auto_expand_favorite_reconstructions(self) -> None:
+ self.session_manager.set_auto_expand_favorite_reconstructions(
+ not self.session_manager.auto_expand_favorite_reconstructions
+ )
+ self._update_menu()
+
+ def _toggle_auto_expand_favorite_directories(self) -> None:
+ self.session_manager.set_auto_expand_favorite_directories(
+ not self.session_manager.auto_expand_favorite_directories
+ )
+ self._update_menu()
+
def _reconstruct_file_dialog(self) -> None:
if self._is_operation_active():
logger.warning("A conversion or library generation is already in progress; cannot start a new one")
@@ -689,14 +866,30 @@ def _is_converter_panel_visible(self) -> bool:
return self._main_tab.is_converter_panel_visible()
def _is_operation_active(self) -> bool:
- return self._main_tab.is_converter_active() or self._instructions_tab.is_library_generating()
+ return (
+ self._main_tab.is_converter_active()
+ or self._instructions_tab.is_library_generating()
+ or self._render_coordinator.is_active
+ )
def _refresh_busy_state(self) -> None:
- """Re-evaluate the reconstruct and generate-library buttons whenever a conversion or library
- generation starts or finishes, keeping the two long operations mutually exclusive. Each panel
- reads the live ``_is_operation_active`` state for itself; this only nudges them to
- re-apply, so the busy truth lives in one place."""
+ """Re-evaluate the reconstruct and generate-library buttons whenever a conversion, library
+ generation or render starts or finishes, keeping the long operations mutually exclusive. Each
+ panel reads the live ``_is_operation_active`` state for itself; this only nudges them to
+ re-apply, so the busy truth lives in one place. The menu follows the same edge, since what
+ greys an entry offering another such operation is one already running."""
self._instructions_tab.refresh_generate_button()
+ self._update_menu()
+
+ def _on_render_activity_changed(self) -> None:
+ """Follows a render claiming the application and handing it back.
+
+ What a render occupies is the same ground a conversion or a library generation occupies,
+ so its edges reach the same busy state — the action buttons of each tab, the converter's
+ own view, and the menu entries that would start another exclusive operation.
+ """
+ self._refresh_busy_state()
+ self._main_tab.refresh_converter_view()
def _on_library_operation_changed(self) -> None:
"""Responds to a library generation starting or finishing: refreshes the cross-tab action
@@ -754,6 +947,16 @@ def _refresh_reconstruction_trees(self) -> None:
self._reconstructions_tab.refresh_browser()
self._sequencer_tab.refresh_browser()
+ def _repaint_reconstruction_favorites(self, node: FileSystemNode) -> None:
+ """Repaints the toggled path in both browsers, whichever tab the star was clicked in.
+
+ The two browsers render one tree and read one set of favorites, so the rows standing for the
+ toggled path are read once here and handed to each of them.
+ """
+ nodes = self.browser_manager.nodes_at(node.filepath)
+ self._reconstructions_tab.repaint_browser_favorites(nodes)
+ self._sequencer_tab.repaint_browser_favorites(nodes)
+
def _navigate_to_reconstructions(self) -> None:
self._set_current_tab(Tab.RECONSTRUCTIONS)
@@ -920,11 +1123,14 @@ def _open_project_properties(self) -> None:
return
info = self.project_controller.project.info
+ settings = self.project_controller.project.settings
self.project_properties_window.open(
ProjectPropertiesViewModel(
title=info.title,
author=info.author,
comment=info.comment,
+ first_highlight=settings.first_highlight,
+ second_highlight=settings.second_highlight,
created=info.created,
modified=info.modified,
)
@@ -935,13 +1141,16 @@ def _commit_project_properties(
title: str,
author: str,
comment: str,
+ first_highlight: int,
+ second_highlight: int,
) -> None:
"""Applies the properties dialog's values as one undoable gesture.
- Only fields that differ from the current project info reach the controller,
- so confirming the dialog with no edits is a no-op.
+ Only fields that differ from the current project reach the controller, so
+ confirming the dialog with no edits is a no-op.
"""
info = self.project_controller.project.info
+ settings = self.project_controller.project.settings
with self.history.transaction(HistoryAction.EDIT_PROJECT_PROPERTIES):
if title != info.title:
self.project_controller.set_title(title)
@@ -949,6 +1158,10 @@ def _commit_project_properties(
self.project_controller.set_author(author)
if comment != info.comment:
self.project_controller.set_comment(comment)
+ if first_highlight != settings.first_highlight:
+ self.project_controller.set_first_highlight(first_highlight)
+ if second_highlight != settings.second_highlight:
+ self.project_controller.set_second_highlight(second_highlight)
def _open_audio_settings(self) -> None:
"""Opens the audio settings dialog seeded with the device manager's state."""
@@ -960,7 +1173,8 @@ def _open_audio_settings(self) -> None:
)
def _open_about_dialog(self) -> None:
- """Presents the application name, version, description, and authorship in a modal notice."""
+ """Presents the application's mark beside its name, version, description, and authorship."""
+ about = self.layout.general.dialogs.about
description = self.language_manager["global.dialog.message.about_description"]
author_line = self.language_manager["global.dialog.template.about_author"].format(
author=SAMPLETONES_AUTHOR,
@@ -968,26 +1182,40 @@ def _open_about_dialog(self) -> None:
)
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,
- )
- author_text = dpg.add_text(author_line, parent=parent)
- FontRegistry.bind_to_item(author_text, Font.ITALIC)
+ with dpg.group(horizontal=True, parent=parent):
+ dpg.add_image(
+ TAG_GLOBAL_TEXTURE_LOGO,
+ width=about.logo,
+ height=about.logo,
+ )
+ with dpg.group():
+ name_text = dpg.add_text(SAMPLETONES_NAME_VERSION)
+ FontRegistry.bind_to_item(name_text, Font.BOLD_TITLE)
+ dpg.add_separator()
+ dpg.add_text(description, wrap=about.text_wrap)
+ author_text = dpg.add_text(author_line)
+ FontRegistry.bind_to_item(author_text, Font.ITALIC)
self.dialogs.show_modal(
get_dialog_tag(TAG_GLOBAL_DIALOG_ABOUT),
self.language_manager["global.dialog.title.about"],
content,
+ width=about.width,
+ height=about.height,
)
def _refresh_audio_devices(self) -> None:
- """Re-enumerates the output devices and repaints the open dialog in place."""
- self.audio_device_manager.refresh_devices()
+ """Re-enumerates the output devices and repaints the open dialog in place.
+
+ Re-enumeration restarts the audio backend, which needs the output free; a source that
+ keeps hold of it leaves the device list as it stands and reports the failure.
+ """
+ try:
+ self.audio_device_manager.refresh_devices()
+ except PlaybackError as exception:
+ self._on_playback_error(exception)
+ return
+
self.audio_settings_window.update_view(
AudioSettingsViewModel.from_device_manager(
self.audio_device_manager,
@@ -1001,8 +1229,17 @@ def _apply_audio_settings(
sample_rate: SampleRate,
buffer_size: BufferSize,
) -> None:
- """Applies the dialog's committed device, sample rate, and buffer size."""
- self.audio_device_manager.configure_device(device_index, sample_rate)
+ """Applies the dialog's committed device, sample rate, and buffer size.
+
+ Switching devices needs the output free; a source that keeps hold of it leaves the
+ settings as they stand and reports the failure.
+ """
+ try:
+ self.audio_device_manager.configure_device(device_index, sample_rate)
+ except PlaybackError as exception:
+ self._on_playback_error(exception)
+ return
+
self.audio_device_manager.set_buffer_size(buffer_size)
def _owning_project_sample(self) -> Optional[Sample]:
@@ -1124,10 +1361,26 @@ def _get_active_source(self) -> Optional[AudioPlayerProtocol]:
def _persist_application_state(self) -> None:
self.session_manager.set_current_audio_device(self.audio_device_manager)
self._viewport_manager.save_window_state()
+ self._save_browser_shapes()
current_tab = self._shell.get_current_tab()
self.session_manager.set_current_tab(current_tab)
self.session_manager.save_config()
+ def _save_browser_shapes(self) -> None:
+ """Asks every tab holding a tree to write down which of its rows stand open.
+
+ The shape belongs to the browser showing it, and it is read the once here rather than followed
+ row by row, a pass over the rows running on the tree worker.
+ """
+ self._main_tab.save_browser_shape()
+ self._reconstructions_tab.save_browser_shape()
+ self._sequencer_tab.save_browser_shape()
+ self._instructions_tab.save_browser_shape()
+
+ def _build_edit_actions(self) -> bool:
+ """States the actions of the grid holding the cursor into the Edit menu being built."""
+ return self._edit_router.build_menu_actions()
+
def _play_from_start(self) -> None:
self._playback_router.play_from_start()
self._update_menu()
@@ -1138,7 +1391,7 @@ def _play(self) -> None:
def _play_from_frame(self) -> None:
"""Plays from the current order frame; available only in the Sequencer tab."""
- if self._shell.get_current_tab() != Tab.SEQUENCER:
+ if not self._is_sequencer_tab_current():
return
self._sequencer_tab.play_from_current_frame()
@@ -1148,6 +1401,26 @@ def _stop(self) -> None:
self._playback_router.stop()
self._update_menu()
+ def _toggle_channel(self, generator: GeneratorName) -> None:
+ """Switches one NES channel in the tab in front of the reader.
+
+ A channel is switched by a control of its own on three tabs: the generators a
+ reconstruction is built from on the Main tab, the slices the waveform draws and plays on
+ the Reconstructions tab, and the sequencer's mix elsewhere. One key reaches whichever of
+ them is on screen, so a reader silences what they are listening to without leaving it.
+ """
+ match self._shell.get_current_tab():
+ case Tab.MAIN:
+ self._main_tab.toggle_generator(generator)
+ case Tab.RECONSTRUCTIONS:
+ self._reconstructions_tab.toggle_generator(generator)
+ case _:
+ self._mute_channel(generator)
+
+ def _mute_channel(self, generator: GeneratorName) -> None:
+ """Flips one channel of the sequencer's mix, the gesture the Channels submenu offers."""
+ self._sequencer_tab.toggle_channel(generator)
+
def _show_confirmation_dialog(
self,
message: str,
@@ -1193,8 +1466,9 @@ def _is_project_open(self) -> bool:
return self.project_controller.is_open
def _exit_application(self) -> None:
+ self._render_coordinator.cleanup()
stop_background_workers()
- self.audio_device_manager.stop()
+ self._playback_router.shutdown()
self._main_tab.cleanup()
dpg.stop_dearpygui()
@@ -1203,6 +1477,7 @@ def _update_status(self) -> None:
delta_time = dpg.get_delta_time()
self._shell.update_fps(delta_time)
self._shell.update_status_bar(delta_time)
+ self._display_coordinator.tick(delta_time)
self._refresh_playback_menu_state()
def _refresh_playback_menu_state(self) -> None:
@@ -1248,7 +1523,9 @@ def run(self) -> None:
except KeyboardInterrupt:
return
finally:
+ self._render_coordinator.cleanup()
stop_background_workers()
+ self._playback_router.shutdown()
self._main_tab.cleanup()
self.library_manager.shutdown()
save_failed = self._save_config()
diff --git a/src/sampletones_application/categories/context.py b/src/sampletones_application/categories/context.py
new file mode 100644
index 00000000..820d60a3
--- /dev/null
+++ b/src/sampletones_application/categories/context.py
@@ -0,0 +1,65 @@
+from typing import Dict, Final
+
+from sampletones_application.categories.elements.global_ import ContextElements
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_core.constants.enums import GeneratorName
+
+CHANNEL_ELEMENTS: Final[Dict[GeneratorName, ContextElements]] = {
+ GeneratorName.PULSE1: ContextElements.PULSE_1,
+ GeneratorName.PULSE2: ContextElements.PULSE_2,
+ GeneratorName.TRIANGLE: ContextElements.TRIANGLE,
+ GeneratorName.NOISE: ContextElements.NOISE,
+}
+
+
+def context_text(
+ language_manager: LanguageManager,
+ text_type: TextType,
+ element: ContextElements,
+) -> str:
+ """Resolves one reading of a context element: its label, the template it fills or its tooltip.
+
+ A context element is stated once and read in several voices — the byte figures name a size
+ with a label, print it through a template and explain it in a tooltip — so every voice of an
+ element comes from the same place.
+
+ Args:
+ language_manager: The catalogue the words are read from.
+ text_type: The voice the element is read in.
+ element: The context element being read.
+
+ Returns:
+ str: The words the catalogue holds for that element in that voice.
+ """
+ return language_manager[
+ Page.GLOBAL,
+ Panel.CONTEXT,
+ text_type,
+ element,
+ ]
+
+
+def context_label(
+ language_manager: LanguageManager,
+ element: ContextElements,
+) -> str:
+ """Resolves a context-action label, the words every menu offering that action prints.
+
+ Cut, Copy and Play name one gesture wherever they are offered, so the cell menus of the
+ sequencer grids, the file trees and the menu bar read them from one entry. A reader then
+ meets the same word for the same action, and a translation reaches all of them at once.
+ """
+ return context_text(language_manager, TextType.LABEL, element)
+
+
+def channel_label(
+ language_manager: LanguageManager,
+ generator: GeneratorName,
+) -> str:
+ """Resolves an NES channel's name, the words every display naming a channel prints.
+
+ The playback menu's mix, the samples menu's byte figures and anything else addressing a
+ channel read it from one entry, so a reader meets the same name for the same channel.
+ """
+ return context_label(language_manager, CHANNEL_ELEMENTS[generator])
diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py
index 7071a083..c032172e 100644
--- a/src/sampletones_application/categories/elements/global_.py
+++ b/src/sampletones_application/categories/elements/global_.py
@@ -29,10 +29,15 @@ class TreeElements(AbstractElement):
SEARCH = "search"
FILTER = "filter"
CLEAR_SEARCH = "clear_search"
+ FAVORITES_ONLY = "favorites_only"
class ContextElements(AbstractElement):
PLAY = "play"
+ CUT = "cut"
+ COPY = "copy"
+ PASTE = "paste"
+ DELETE = "delete"
MARK_AS_FAVORITE = "mark_as_favorite"
UNMARK_AS_FAVORITE = "unmark_as_favorite"
COPY_FILENAME = "copy_filename"
@@ -45,6 +50,9 @@ class ContextElements(AbstractElement):
PULSE_1 = "pulse_1"
PULSE_2 = "pulse_2"
NOISE = "noise"
+ SAMPLE_SIZE = "sample_size"
+ INSTRUMENT_SIZE = "instrument_size"
+ SIZE_BYTES = "size_bytes"
class NodeDetailElements(AbstractElement):
@@ -67,6 +75,7 @@ class MenuElements(AbstractElement):
GROUP_FILE_EXPORT = "group_file_export"
ITEM_FILE_EXPORT_FAMITRACKER = "item_file_export_famitracker"
ITEM_FILE_EXPORT_BITPHASE = "item_file_export_bitphase"
+ ITEM_FILE_RENDER_SONG = "item_file_render_song"
ITEM_FILE_CLOSE_PROJECT = "item_file_close_project"
ITEM_FILE_EXIT = "item_file_exit"
GROUP_EDIT = "group_edit"
@@ -93,7 +102,10 @@ class MenuElements(AbstractElement):
ITEM_PLAYBACK_PLAY_FROM_FRAME = "item_playback_play_from_frame"
ITEM_PLAYBACK_STOP = "item_playback_stop"
ITEM_PLAYBACK_AUTOPLAY = "item_playback_autoplay"
- ITEM_PLAYBACK_FOLLOW_PLAYBACK = "item_playback_follow_playback"
+ GROUP_PLAYBACK_FOLLOW = "group_playback_follow"
+ ITEM_PLAYBACK_FOLLOW_ROWS = "item_playback_follow_rows"
+ ITEM_PLAYBACK_FOLLOW_PATTERNS = "item_playback_follow_patterns"
+ ITEM_PLAYBACK_FOLLOW_OFF = "item_playback_follow_off"
ITEM_PLAYBACK_LOOP_SONG = "item_playback_loop_song"
GROUP_PLAYBACK_CHANNELS = "group_playback_channels"
ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS = "item_playback_unmute_all_channels"
@@ -101,6 +113,11 @@ class MenuElements(AbstractElement):
GROUP_VIEW = "group_view"
ITEM_VIEW_SHOW_ADVANCED_SETTINGS = "item_view_show_advanced_settings"
ITEM_VIEW_FULLSCREEN = "item_view_fullscreen"
+ GROUP_VIEW_AUTO_EXPAND_FAVORITES = "group_view_auto_expand_favorites"
+ ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = "item_view_auto_expand_favorite_reconstructions"
+ ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES = "item_view_auto_expand_favorite_directories"
+ ITEM_VIEW_DISPLAY_SETTINGS = "item_view_display_settings"
+ ITEM_VIEW_KEYBOARD_SETTINGS = "item_view_keyboard_settings"
GROUP_HELP = "group_help"
ITEM_HELP_ABOUT = "item_help_about"
TAB_MAIN = "tab_main"
@@ -116,6 +133,7 @@ class StatusElements(AbstractElement):
NODE_LIBRARY = "node_library"
TREE_SEARCH = "tree_search"
CLEAR_SEARCH = "clear_search"
+ FAVORITES_ONLY = "favorites_only"
INPUT = "input"
COMBO = "combo"
NODE_DIRECTORY = "node_directory"
@@ -147,6 +165,7 @@ class GraphElements(AbstractElement):
class GlobalMessageElements(AbstractElement):
TREE_NO_RESULTS = "tree_no_results"
+ TREE_NO_FAVORITES = "tree_no_favorites"
INVALID_METADATA_ERROR = "invalid_metadata_error"
RECONSTRUCTION_NO_DATA = "reconstruction_no_data"
RECONSTRUCTION_SAVED_SUCCESSFULLY = "reconstruction_saved_successfully"
diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py
index c6823a69..496e1b82 100644
--- a/src/sampletones_application/categories/elements/main.py
+++ b/src/sampletones_application/categories/elements/main.py
@@ -4,7 +4,6 @@
class ExplorerElements(AbstractElement):
SECTION = "section"
REFRESH_BUTTON = "refresh_button"
- COLLAPSE_ALL_BUTTON = "collapse_all_button"
CONTEXT_LOAD_RECONSTRUCTION = "context_load_reconstruction"
CONTEXT_LOAD_LIBRARY = "context_load_library"
CONTEXT_RECONSTRUCT_FILE = "context_reconstruct_file"
@@ -12,7 +11,6 @@ class ExplorerElements(AbstractElement):
CONTEXT_SET_LIBRARY_DIRECTORY = "context_set_library_directory"
CONTEXT_SET_OUTPUT_DIRECTORY = "context_set_output_directory"
STATUS_REFRESH = "status_refresh"
- STATUS_COLLAPSE_ALL = "status_collapse_all"
STATUS_NODE_AUDIO_NO_AUTOPLAY = "status_node_audio_no_autoplay"
STATUS_NODE_AUDIO = "status_node_audio"
STATUS_NODE_LIBRARY = "status_node_library"
diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py
index 41939aa6..448a0264 100644
--- a/src/sampletones_application/categories/elements/sequencer.py
+++ b/src/sampletones_application/categories/elements/sequencer.py
@@ -18,7 +18,7 @@ class SequencerModuleElements(AbstractElement):
SPEED = "speed"
-class SequencerGridElements(AbstractElement):
+class SequencerTrackerElements(AbstractElement):
TRACKER_TEXT = "tracker_text"
COLUMN_ROW = "column_row"
COLUMN_SAMPLE = "column_sample"
@@ -30,6 +30,9 @@ class SequencerGridElements(AbstractElement):
HEADER_SAMPLE = "header_sample"
CONTEXT_PLAY = "context_play"
CONTEXT_PLAY_FROM_FRAME = "context_play_from_frame"
+ CONTEXT_SELECT_ALL = "context_select_all"
+ CONTEXT_SELECT_COLUMN = "context_select_column"
+ CONTEXT_SELECT_SUBCOLUMN = "context_select_subcolumn"
CONTEXT_NOTE_OFF = "context_note_off"
CONTEXT_SET_INSTRUMENT = "context_set_instrument"
CONTEXT_NO_SAMPLES = "context_no_samples"
@@ -62,7 +65,10 @@ class SequencerOrderElements(AbstractElement):
LABEL_CHANNEL = "label_channel"
LABEL_MASTER = "label_master"
CONTEXT_PLAY = "context_play"
+ CONTEXT_SELECT_ALL = "context_select_all"
+ CONTEXT_SELECT_ROW = "context_select_row"
CONTEXT_DUPLICATE = "context_duplicate"
+ CONTEXT_CLONE = "context_clone"
CONTEXT_INSERT = "context_insert"
CONTEXT_CLEAR = "context_clear"
CONTEXT_REMOVE = "context_remove"
@@ -102,35 +108,3 @@ class SequencerHistoryElements(AbstractElement):
EMPTY = "empty"
LOOP_ON = "loop_on"
LOOP_OFF = "loop_off"
-
-
-class SequencerHistoryActionElements(AbstractElement):
- """Display labels for history entries; values mirror ``HistoryAction`` members."""
-
- INITIAL = "initial"
- EDIT_ROW = "edit_row"
- NOTE_OFF = "note_off"
- CLEAR_ROW = "clear_row"
- CLEAR_SUBCOLUMN = "clear_subcolumn"
- ADJUST_TRANSPOSE = "adjust_transpose"
- ADJUST_VOLUME = "adjust_volume"
- ADD_FRAME = "add_frame"
- REMOVE_FRAME = "remove_frame"
- DUPLICATE_FRAME = "duplicate_frame"
- CLEAR_FRAME = "clear_frame"
- MOVE_FRAME = "move_frame"
- SET_ORDER_ENTRY = "set_order_entry"
- ADD_SAMPLE = "add_sample"
- REMOVE_SAMPLE = "remove_sample"
- REPLACE_SAMPLE = "replace_sample"
- RENAME_SAMPLE = "rename_sample"
- MOVE_SAMPLE = "move_sample"
- DUPLICATE_SAMPLE = "duplicate_sample"
- SET_SAMPLE_LOOP = "set_sample_loop"
- SET_TEMPO = "set_tempo"
- SET_SPEED = "set_speed"
- SET_NES_FREQUENCY = "set_nes_frequency"
- SET_ROWS_PER_PATTERN = "set_rows_per_pattern"
- EDIT_RECONSTRUCTION = "edit_reconstruction"
- EDIT_PROJECT_PROPERTIES = "edit_project_properties"
- UNTRACKED = "untracked"
diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py
index a511cbe7..ec4b1af2 100644
--- a/src/sampletones_application/categories/elements/settings.py
+++ b/src/sampletones_application/categories/elements/settings.py
@@ -20,5 +20,175 @@ class ProjectPropertiesElements(AbstractElement):
TITLE = "title"
AUTHOR = "author"
COMMENT = "comment"
+ FIRST_HIGHLIGHT = "first_highlight"
+ SECOND_HIGHLIGHT = "second_highlight"
CREATED = "created"
MODIFIED = "modified"
+
+
+class KeybindingActionElements(AbstractElement):
+ """The name a reader finds each editable action under, one member per :class:`ShortcutId`.
+
+ A member is named after the action it labels, so an action reaches its text through
+ ``KeybindingActionElements[shortcut_id.name]`` and a new action ships only once a reader has a
+ name for it.
+ """
+
+ NEW_PROJECT = "new_project"
+ OPEN_PROJECT = "open_project"
+ SAVE_PROJECT = "save_project"
+ SAVE_PROJECT_AS = "save_project_as"
+ PROJECT_PROPERTIES = "project_properties"
+ EXPORT_PROJECT_FAMITRACKER = "export_project_famitracker"
+ EXPORT_PROJECT_BITPHASE = "export_project_bitphase"
+ RENDER_SONG = "render_song"
+ CLOSE_PROJECT = "close_project"
+ EXIT = "exit"
+ UNDO = "undo"
+ REDO = "redo"
+ RECONSTRUCT_FILE = "reconstruct_file"
+ RECONSTRUCT_DIRECTORY = "reconstruct_directory"
+ LOAD_GENERATION_SETTINGS = "load_generation_settings"
+ SAVE_GENERATION_SETTINGS = "save_generation_settings"
+ OPEN_RECONSTRUCTION = "open_reconstruction"
+ SAVE_RECONSTRUCTION = "save_reconstruction"
+ SAVE_RECONSTRUCTION_AS = "save_reconstruction_as"
+ CLOSE_RECONSTRUCTION = "close_reconstruction"
+ EXPORT_RECONSTRUCTION_WAV = "export_reconstruction_wav"
+ EXPORT_INSTRUMENTS_FAMITRACKER = "export_instruments_famitracker"
+ EXPORT_INSTRUMENTS_BITPHASE_PRESET = "export_instruments_bitphase_preset"
+ ADD_RECONSTRUCTION_TO_SEQUENCER = "add_reconstruction_to_sequencer"
+ OPEN_RECONSTRUCTION_IN_EXPLORER = "open_reconstruction_in_explorer"
+ LOCATE_ORIGINAL_AUDIO = "locate_original_audio"
+ PLAY = "play"
+ PLAY_FROM_START = "play_from_start"
+ PLAY_FROM_FRAME = "play_from_frame"
+ STOP = "stop"
+ TOGGLE_AUTOPLAY = "toggle_autoplay"
+ FOLLOW_ROWS = "follow_rows"
+ FOLLOW_PATTERNS = "follow_patterns"
+ FOLLOW_OFF = "follow_off"
+ TOGGLE_LOOP_SONG = "toggle_loop_song"
+ TOGGLE_CHANNEL_PULSE_1 = "toggle_channel_pulse_1"
+ TOGGLE_CHANNEL_PULSE_2 = "toggle_channel_pulse_2"
+ TOGGLE_CHANNEL_TRIANGLE = "toggle_channel_triangle"
+ TOGGLE_CHANNEL_NOISE = "toggle_channel_noise"
+ UNMUTE_ALL_CHANNELS = "unmute_all_channels"
+ AUDIO_SETTINGS = "audio_settings"
+ DISPLAY_SETTINGS = "display_settings"
+ KEYBOARD_SETTINGS = "keyboard_settings"
+ TOGGLE_ADVANCED_SETTINGS = "toggle_advanced_settings"
+ TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = "toggle_auto_expand_favorite_reconstructions"
+ TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES = "toggle_auto_expand_favorite_directories"
+ TOGGLE_FULLSCREEN = "toggle_fullscreen"
+ ABOUT_DIALOG = "about_dialog"
+ NEXT_TAB = "next_tab"
+ PREVIOUS_TAB = "previous_tab"
+ SELECT_TAB_MAIN = "select_tab_main"
+ SELECT_TAB_RECONSTRUCTIONS = "select_tab_reconstructions"
+ SELECT_TAB_SEQUENCER = "select_tab_sequencer"
+ SELECT_TAB_INSTRUCTIONS = "select_tab_instructions"
+
+ ORDER_PREVIOUS_POSITION = "order_previous_position"
+ ORDER_NEXT_POSITION = "order_next_position"
+ ORDER_PREVIOUS_CHANNEL = "order_previous_channel"
+ ORDER_NEXT_CHANNEL = "order_next_channel"
+ ORDER_FIRST_POSITION = "order_first_position"
+ ORDER_LAST_POSITION = "order_last_position"
+ ORDER_EXTEND_SELECTION_UP = "order_extend_selection_up"
+ ORDER_EXTEND_SELECTION_DOWN = "order_extend_selection_down"
+ ORDER_EXTEND_SELECTION_LEFT = "order_extend_selection_left"
+ ORDER_EXTEND_SELECTION_RIGHT = "order_extend_selection_right"
+ ORDER_EXTEND_SELECTION_TO_FIRST_POSITION = "order_extend_selection_to_first_position"
+ ORDER_EXTEND_SELECTION_TO_LAST_POSITION = "order_extend_selection_to_last_position"
+ ORDER_SELECT_ALL = "order_select_all"
+ ORDER_SELECT_ROW = "order_select_row"
+ ORDER_COPY_BLOCK = "order_copy_block"
+ ORDER_CUT_BLOCK = "order_cut_block"
+ ORDER_PASTE_BLOCK = "order_paste_block"
+ ORDER_MOVE_FRAME_LEFT = "order_move_frame_left"
+ ORDER_MOVE_FRAME_RIGHT = "order_move_frame_right"
+ ORDER_MOVE_FRAME_TO_START = "order_move_frame_to_start"
+ ORDER_MOVE_FRAME_TO_END = "order_move_frame_to_end"
+ ORDER_ADD_FRAME = "order_add_frame"
+ ORDER_INSERT_FRAME = "order_insert_frame"
+ ORDER_REMOVE_FRAME = "order_remove_frame"
+ ORDER_DUPLICATE_FRAME = "order_duplicate_frame"
+ ORDER_CLONE_FRAME = "order_clone_frame"
+ ORDER_CLEAR_FRAME = "order_clear_frame"
+ ORDER_CLEAR_CELL = "order_clear_cell"
+ ORDER_CLEAR_PREVIOUS_CELL = "order_clear_previous_cell"
+ ORDER_CANCEL_ENTRY = "order_cancel_entry"
+
+ TRACKER_PREVIOUS_ROW = "tracker_previous_row"
+ TRACKER_NEXT_ROW = "tracker_next_row"
+ TRACKER_PREVIOUS_SUBCOLUMN = "tracker_previous_subcolumn"
+ TRACKER_NEXT_SUBCOLUMN = "tracker_next_subcolumn"
+ TRACKER_PREVIOUS_COLUMN = "tracker_previous_column"
+ TRACKER_NEXT_COLUMN = "tracker_next_column"
+ TRACKER_FIRST_ROW = "tracker_first_row"
+ TRACKER_LAST_ROW = "tracker_last_row"
+ TRACKER_EXTEND_SELECTION_UP = "tracker_extend_selection_up"
+ TRACKER_EXTEND_SELECTION_DOWN = "tracker_extend_selection_down"
+ TRACKER_EXTEND_SELECTION_LEFT = "tracker_extend_selection_left"
+ TRACKER_EXTEND_SELECTION_RIGHT = "tracker_extend_selection_right"
+ TRACKER_EXTEND_SELECTION_TO_FIRST_ROW = "tracker_extend_selection_to_first_row"
+ TRACKER_EXTEND_SELECTION_TO_LAST_ROW = "tracker_extend_selection_to_last_row"
+ TRACKER_SELECT_ALL = "tracker_select_all"
+ TRACKER_SELECT_COLUMN = "tracker_select_column"
+ TRACKER_SELECT_SUBCOLUMN = "tracker_select_subcolumn"
+ TRACKER_COPY_BLOCK = "tracker_copy_block"
+ TRACKER_CUT_BLOCK = "tracker_cut_block"
+ TRACKER_PASTE_BLOCK = "tracker_paste_block"
+ TRACKER_TRANSPOSE_UP = "tracker_transpose_up"
+ TRACKER_TRANSPOSE_DOWN = "tracker_transpose_down"
+ TRACKER_TRANSPOSE_OCTAVE_UP = "tracker_transpose_octave_up"
+ TRACKER_TRANSPOSE_OCTAVE_DOWN = "tracker_transpose_octave_down"
+ TRACKER_VOLUME_UP = "tracker_volume_up"
+ TRACKER_VOLUME_DOWN = "tracker_volume_down"
+ TRACKER_VOLUME_UP_COARSE = "tracker_volume_up_coarse"
+ TRACKER_VOLUME_DOWN_COARSE = "tracker_volume_down_coarse"
+ TRACKER_PAGE_UP = "tracker_page_up"
+ TRACKER_PAGE_DOWN = "tracker_page_down"
+ TRACKER_CLEAR_ROW = "tracker_clear_row"
+ TRACKER_CLEAR_PREVIOUS_ROW = "tracker_clear_previous_row"
+ TRACKER_CANCEL_ENTRY = "tracker_cancel_entry"
+ TRACKER_PLAY_FROM_ROW = "tracker_play_from_row"
+
+ SAMPLES_RENAME_SAMPLE = "samples_rename_sample"
+ SAMPLES_REMOVE_SAMPLE = "samples_remove_sample"
+ SAMPLES_MOVE_SAMPLE_UP = "samples_move_sample_up"
+ SAMPLES_MOVE_SAMPLE_DOWN = "samples_move_sample_down"
+ SAMPLES_MOVE_SAMPLE_TO_TOP = "samples_move_sample_to_top"
+ SAMPLES_MOVE_SAMPLE_TO_BOTTOM = "samples_move_sample_to_bottom"
+ SAMPLES_CANCEL_RENAME = "samples_cancel_rename"
+
+
+class KeybindingCategoryElements(AbstractElement):
+ """The name a reader finds each editable scope under, one member per :class:`ShortcutCategory`."""
+
+ APPLICATION = "application"
+ ORDER = "order"
+ TRACKER = "tracker"
+ SAMPLES = "samples"
+
+
+class KeybindingsElements(AbstractElement):
+ """The keybindings dialog's own text, apart from the actions it lists."""
+
+ WINDOW_TITLE = "window_title"
+ SCHEME = "scheme"
+ FILTER = "filter"
+ ACTION = "action"
+ SHORTCUT = "shortcut"
+ UNBOUND = "unbound"
+ CAPTURING = "capturing"
+ CLEAR_BUTTON = "clear_button"
+ RESET_BUTTON = "reset_button"
+ REASSIGN_BUTTON = "reassign_button"
+ DISCARD_BUTTON = "discard_button"
+ KEEP_EDITING_BUTTON = "keep_editing_button"
+ REASSIGN_CONFIRMATION = "reassign_confirmation"
+ RESET_CONFIRMATION = "reset_confirmation"
+ DISCARD_CONFIRMATION = "discard_confirmation"
+ UNREADABLE_COMBINATION = "unreadable_combination"
diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py
index 56e0d40d..8a2689e6 100644
--- a/src/sampletones_application/categories/hierarchy.py
+++ b/src/sampletones_application/categories/hierarchy.py
@@ -30,6 +30,7 @@ class Widget(StrEnum):
TABLE = "table"
TABS = "tabs"
TEXT = "text"
+ TEXTURE = "texture"
THEME = "theme"
TOOLTIP = "tooltip"
TREE = "tree"
@@ -80,7 +81,7 @@ class Panel(StrEnum):
RECONSTRUCTION = auto()
# Sequencer tab
- GRID = auto()
+ TRACKER = auto()
ORDER = auto()
MODULE = auto()
INSTRUMENTS = auto()
@@ -92,4 +93,7 @@ class Panel(StrEnum):
# Settings
AUDIO = auto()
+ DISPLAY = auto()
+ KEYBINDINGS = auto()
PROPERTIES = auto()
+ RENDER = auto()
diff --git a/src/sampletones_application/categories/key/tag.py b/src/sampletones_application/categories/key/tag.py
index a48d85ec..0d53608f 100644
--- a/src/sampletones_application/categories/key/tag.py
+++ b/src/sampletones_application/categories/key/tag.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from typing import Dict, Final
+from typing import Dict, Final, Self
from sampletones_application.categories.hierarchy import Page, Panel, Widget
from sampletones_application.tags.compose import compose_tag
@@ -26,7 +26,7 @@ def __new__(
panel: Panel,
widget: Widget,
element: str,
- ) -> TagName:
+ ) -> Self:
panel_str = _PANEL_SHORT_NAMES.get(panel, str(panel))
parts = [str(page)]
if panel != Panel.IMPLICIT:
@@ -36,7 +36,7 @@ def __new__(
if element and element != panel_str:
parts.append(element)
- instance: TagName = super().__new__(cls, compose_tag(*parts))
+ instance: Self = super().__new__(cls, compose_tag(*parts))
instance.page = page
instance.panel = panel
instance.widget = widget
diff --git a/src/sampletones_application/categories/pitch.py b/src/sampletones_application/categories/pitch.py
index 22bcda63..c5235a70 100644
--- a/src/sampletones_application/categories/pitch.py
+++ b/src/sampletones_application/categories/pitch.py
@@ -1,21 +1,77 @@
+from dataclasses import dataclass
+from typing import Self
+
from sampletones_application.categories.manager import LanguageManager
-from sampletones_core.utils.pitch_kind import PERIOD_VALUE_KIND, PitchValueKind
-
-
-def build_pitch_tooltip(
- language_manager: LanguageManager,
- kind: PitchValueKind,
- template: str,
-) -> str:
- """Fills a pitch stepper's help template with the shared example for ``kind``: the quantity's name
- ("pitch" or "period"), an example note name, and the matching numeric value. The value is resolved
- from the example name through the kind itself, so the name and value the tooltip shows always agree.
- Both the reconstruction and instruction steppers compose their tooltips through here, keeping one
- definition of the example while each supplies its own surrounding wording via ``template``."""
- is_period = kind is PERIOD_VALUE_KIND
- type_name = language_manager["global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name"]
- example_name = language_manager[
- "global.pitch.label.period_example" if is_period else "global.pitch.label.pitch_example"
- ]
- example_value = kind.from_text(example_name, kind.minimum)
- return template.format(type_name, example_name, example_value)
+from sampletones_core.utils.pitch_kind import (
+ PERIOD_VALUE_KIND,
+ PITCH_VALUE_KIND,
+ PitchValueKind,
+)
+
+
+@dataclass(frozen=True)
+class PitchTooltips:
+ """A pitch stepper's help in both readings, so a panel resolves the one its field takes.
+
+ A stepper states a pitch on the tonal channels and a period on the noise channel, and a panel
+ holding steppers of both kinds phrases each from the same template. Building the pair together
+ keeps the two readings in step and leaves the choice to the moment a field is drawn.
+
+ Attributes:
+ pitch: The help a stepper reading a pitch shows.
+ period: The help a stepper reading a period shows.
+ """
+
+ pitch: str
+ period: str
+
+ @classmethod
+ def build(
+ cls,
+ language_manager: LanguageManager,
+ template: str,
+ ) -> Self:
+ """Phrases both readings from one template.
+
+ Args:
+ language_manager: Where the example note name and value are read from.
+ template: The panel's own surrounding wording.
+
+ Returns:
+ PitchTooltips: The help in both readings.
+ """
+ return cls(
+ pitch=cls.build_pitch_tooltip(
+ language_manager,
+ PITCH_VALUE_KIND,
+ template,
+ ),
+ period=cls.build_pitch_tooltip(
+ language_manager,
+ PERIOD_VALUE_KIND,
+ template,
+ ),
+ )
+
+ def for_kind(self, kind: PitchValueKind) -> str:
+ """The help a stepper of ``kind`` shows."""
+ return self.period if kind is PERIOD_VALUE_KIND else self.pitch
+
+ @staticmethod
+ def build_pitch_tooltip(
+ language_manager: LanguageManager,
+ kind: PitchValueKind,
+ template: str,
+ ) -> str:
+ """Fills a pitch stepper's help template with the shared example for ``kind``: the quantity's name
+ ("pitch" or "period"), an example note name, and the matching numeric value. The value is resolved
+ from the example name through the kind itself, so the name and value the tooltip shows always agree.
+ Both the reconstruction and instruction steppers compose their tooltips through here, keeping one
+ definition of the example while each supplies its own surrounding wording via ``template``."""
+ is_period = kind is PERIOD_VALUE_KIND
+ type_name = language_manager["global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name"]
+ example_name = language_manager[
+ "global.pitch.label.period_example" if is_period else "global.pitch.label.pitch_example"
+ ]
+ example_value = kind.from_text(example_name, kind.minimum)
+ return template.format(type_name, example_name, example_value)
diff --git a/src/sampletones_application/config/deployment/deployment.py b/src/sampletones_application/config/deployment/deployment.py
index 43f2c9ec..c86e2382 100644
--- a/src/sampletones_application/config/deployment/deployment.py
+++ b/src/sampletones_application/config/deployment/deployment.py
@@ -33,7 +33,7 @@ class DeploymentConfig(BaseModel, frozen=True):
def _environment_overrides() -> Dict[str, str]:
return {
field: value
- for field in DeploymentConfig.model_fields.keys()
+ for field in DeploymentConfig.model_fields
if (value := os.getenv(f"{SAMPLETONES_ENV_PREFIX}{field.upper()}"))
}
diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py
index 2bf8f91e..20f91256 100644
--- a/src/sampletones_application/config/managers/application.py
+++ b/src/sampletones_application/config/managers/application.py
@@ -1,10 +1,11 @@
from pathlib import Path
-from typing import Set
+from typing import Dict, Optional, Set
from sampletones_application.config.session.application.config import ApplicationConfig
+from sampletones_application.constants.playback import FollowMode
from sampletones_core.audio import AudioDeviceManager, CurrentDevice
from sampletones_core.constants.audio import BufferSize
-from sampletones_core.paths import APPLICATION_CONFIG_PATH
+from sampletones_core.data.metadata import Metadata
from sampletones_shared.logger import logger
from sampletones_shared.utils.serialization import load_yaml, save_yaml_atomic
from sampletones_shared.utils.system.paths import to_path
@@ -12,22 +13,18 @@
class ApplicationConfigManager:
- def __init__(self) -> None:
+ def __init__(self, path: Path) -> None:
+ self.path: Path = path
self.config: ApplicationConfig = self._load()
def _load(self) -> ApplicationConfig:
- if not APPLICATION_CONFIG_PATH.exists():
- logger.warning(
- f"Application config file '{APPLICATION_CONFIG_PATH}' does not exist." " Loading default configuration."
- )
+ if not self.path.exists():
+ logger.warning(f"Application config file '{self.path}' does not exist. Loading default configuration.")
return ApplicationConfig()
- raw = load_yaml(to_path(APPLICATION_CONFIG_PATH))
+ raw = load_yaml(to_path(self.path))
if not raw or not isinstance(raw, dict):
- logger.warning(
- f"Application config file '{APPLICATION_CONFIG_PATH}' is empty or invalid."
- " Loading default configuration."
- )
+ logger.warning(f"Application config file '{self.path}' is empty or invalid. Loading default configuration.")
return ApplicationConfig()
raw.pop("state", None)
@@ -35,20 +32,26 @@ def _load(self) -> ApplicationConfig:
if recovered.dropped:
properties = ", ".join(flatten_location(location) for location in recovered.dropped)
logger.warning(
- f"Application config file '{APPLICATION_CONFIG_PATH}' had incompatible settings"
+ f"Application config file '{self.path}' had incompatible settings"
f" that were reset to defaults: {properties}"
)
return recovered.model
def save(self) -> None:
+ """Writes the configuration under the metadata of the build doing the writing.
+
+ The file records which build last wrote it, so a profile carried across an upgrade names
+ the version its settings were last saved by.
+ """
+ self.config.metadata = Metadata.default()
try:
- APPLICATION_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
- save_yaml_atomic(APPLICATION_CONFIG_PATH, self.config.model_dump())
+ self.path.parent.mkdir(parents=True, exist_ok=True)
+ save_yaml_atomic(self.path, self.config.model_dump())
except OSError as exception:
logger.error_with_traceback(
exception,
- f"File error while saving application config to {APPLICATION_CONFIG_PATH}",
+ f"File error while saving application config to {self.path}",
)
def toggle_favorite(self, path: Path) -> None:
@@ -72,6 +75,51 @@ def master_gain(self) -> float:
def set_master_gain(self, value: float) -> None:
self.config.audio.master_gain = value
+ @property
+ def palette_name(self) -> str:
+ return self.config.display.palette
+
+ def set_palette_name(self, name: str) -> None:
+ self.config.display.palette = name
+
+ @property
+ def vsync(self) -> bool:
+ return self.config.display.vsync
+
+ def set_vsync(self, vsync: bool) -> None:
+ self.config.display.vsync = vsync
+
+ @property
+ def max_fps(self) -> int:
+ return self.config.display.max_fps
+
+ def set_max_fps(self, max_fps: int) -> None:
+ self.config.display.max_fps = max_fps
+
+ @property
+ def borderless(self) -> bool:
+ return self.config.display.borderless
+
+ def set_borderless(self, borderless: bool) -> None:
+ self.config.display.borderless = borderless
+
+ @property
+ def shortcut_scheme_name(self) -> str:
+ return self.config.shortcuts.scheme
+
+ def set_shortcut_scheme_name(self, name: str) -> None:
+ self.config.shortcuts.scheme = name
+
+ @property
+ def shortcut_overrides(self) -> Dict[str, Optional[str]]:
+ return self.config.shortcuts.overrides
+
+ def set_shortcut_overrides(
+ self,
+ overrides: Dict[str, Optional[str]],
+ ) -> None:
+ self.config.shortcuts.overrides = overrides
+
@property
def favorites(self) -> Set[Path]:
return self.config.favorites.paths
@@ -85,11 +133,25 @@ def toggle_autoplay(self) -> bool:
return self.config.playback.autoplay
@property
- def follow_playback(self) -> bool:
- return self.config.playback.follow_playback
+ def auto_expand_favorite_reconstructions(self) -> bool:
+ return self.config.browser.auto_expand_favorite_reconstructions
+
+ def set_auto_expand_favorite_reconstructions(self, value: bool) -> None:
+ self.config.browser.auto_expand_favorite_reconstructions = value
+
+ @property
+ def auto_expand_favorite_directories(self) -> bool:
+ return self.config.browser.auto_expand_favorite_directories
+
+ def set_auto_expand_favorite_directories(self, value: bool) -> None:
+ self.config.browser.auto_expand_favorite_directories = value
+
+ @property
+ def follow_mode(self) -> FollowMode:
+ return self.config.playback.follow_mode
- def set_follow_playback(self, value: bool) -> None:
- self.config.playback.follow_playback = value
+ def set_follow_mode(self, value: FollowMode) -> None:
+ self.config.playback.follow_mode = value
@property
def loop_song(self) -> bool:
diff --git a/src/sampletones_application/config/managers/config.py b/src/sampletones_application/config/managers/config.py
index bd2fadd7..cd703831 100644
--- a/src/sampletones_application/config/managers/config.py
+++ b/src/sampletones_application/config/managers/config.py
@@ -20,9 +20,9 @@
from sampletones_core.data.metadata import Metadata
from sampletones_core.fft import Window
from sampletones_core.library import InstructionLibraryKey
-from sampletones_core.paths import CONFIG_PATH, LIBRARY_DIRECTORY
from sampletones_shared.constants.project import RECONSTRUCTIONS_DIRECTORY
from sampletones_shared.logger import logger
+from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.serialization import load_json
from sampletones_shared.utils.validation import validate_with_recovery
diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py
index 0ce098a3..c5ee6fa1 100644
--- a/src/sampletones_application/config/managers/session.py
+++ b/src/sampletones_application/config/managers/session.py
@@ -1,19 +1,21 @@
from pathlib import Path
-from typing import Optional, Set
+from typing import Dict, Optional, Set
from sampletones_application.categories.hierarchy import Tab
from sampletones_application.config.managers.application import ApplicationConfigManager
from sampletones_application.config.managers.state import ApplicationStateManager
+from sampletones_application.config.profile import UserProfile
from sampletones_application.config.session.application.config import ApplicationConfig
from sampletones_application.config.session.state.state import ApplicationState
+from sampletones_application.constants.playback import FollowMode
from sampletones_core.audio import AudioDeviceManager, CurrentDevice
from sampletones_core.constants.audio import BufferSize
class SessionManager:
- def __init__(self) -> None:
- self._config_manager = ApplicationConfigManager()
- self._state_manager = ApplicationStateManager()
+ def __init__(self, profile: UserProfile) -> None:
+ self._config_manager = ApplicationConfigManager(profile.config)
+ self._state_manager = ApplicationStateManager(profile.state)
@property
def config(self) -> ApplicationConfig:
@@ -51,11 +53,36 @@ def is_card_collapsed(self, card_tag: str) -> bool:
def set_card_collapsed(self, card_tag: str, collapsed: bool) -> None:
self._state_manager.set_card_collapsed(card_tag, collapsed)
+ def is_favorites_filter_active(self, panel_tag: str) -> bool:
+ return self._state_manager.is_favorites_filter_active(panel_tag)
+
+ def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None:
+ self._state_manager.set_favorites_filter_active(panel_tag, active)
+
+ def expanded_rows(self, panel_tag: str) -> Set[str]:
+ return self._state_manager.expanded_rows(panel_tag)
+
+ def set_expanded_rows(self, panel_tag: str, rows: Set[str]) -> None:
+ self._state_manager.set_expanded_rows(panel_tag, rows)
+
+ @property
+ def expanded_directories(self) -> Set[Path]:
+ return self._state_manager.expanded_directories
+
+ def set_expanded_directories(self, directories: Set[Path]) -> None:
+ self._state_manager.set_expanded_directories(directories)
+
def toggle_autoplay(self) -> bool:
return self._config_manager.toggle_autoplay()
- def set_follow_playback(self, value: bool) -> None:
- self._config_manager.set_follow_playback(value)
+ def set_auto_expand_favorite_reconstructions(self, value: bool) -> None:
+ self._config_manager.set_auto_expand_favorite_reconstructions(value)
+
+ def set_auto_expand_favorite_directories(self, value: bool) -> None:
+ self._config_manager.set_auto_expand_favorite_directories(value)
+
+ def set_follow_mode(self, value: FollowMode) -> None:
+ self._config_manager.set_follow_mode(value)
def set_loop_song(self, value: bool) -> None:
self._config_manager.set_loop_song(value)
@@ -127,6 +154,27 @@ def set_current_audio_device(
def set_master_gain(self, value: float) -> None:
self._config_manager.set_master_gain(value)
+ def set_palette_name(self, name: str) -> None:
+ self._config_manager.set_palette_name(name)
+
+ def set_vsync(self, vsync: bool) -> None:
+ self._config_manager.set_vsync(vsync)
+
+ def set_max_fps(self, max_fps: int) -> None:
+ self._config_manager.set_max_fps(max_fps)
+
+ def set_borderless(self, borderless: bool) -> None:
+ self._config_manager.set_borderless(borderless)
+
+ def set_shortcut_scheme_name(self, name: str) -> None:
+ self._config_manager.set_shortcut_scheme_name(name)
+
+ def set_shortcut_overrides(
+ self,
+ overrides: Dict[str, Optional[str]],
+ ) -> None:
+ self._config_manager.set_shortcut_overrides(overrides)
+
def save_config(self) -> None:
self._config_manager.save()
self._state_manager.save()
@@ -171,6 +219,30 @@ def current_buffer_size(self) -> BufferSize:
def master_gain(self) -> float:
return self._config_manager.master_gain
+ @property
+ def palette_name(self) -> str:
+ return self._config_manager.palette_name
+
+ @property
+ def vsync(self) -> bool:
+ return self._config_manager.vsync
+
+ @property
+ def max_fps(self) -> int:
+ return self._config_manager.max_fps
+
+ @property
+ def borderless(self) -> bool:
+ return self._config_manager.borderless
+
+ @property
+ def shortcut_scheme_name(self) -> str:
+ return self._config_manager.shortcut_scheme_name
+
+ @property
+ def shortcut_overrides(self) -> Dict[str, Optional[str]]:
+ return self._config_manager.shortcut_overrides
+
@property
def advanced_settings(self) -> bool:
return self._state_manager.advanced_settings
@@ -180,8 +252,16 @@ def autoplay(self) -> bool:
return self._config_manager.autoplay
@property
- def follow_playback(self) -> bool:
- return self._config_manager.follow_playback
+ def auto_expand_favorite_reconstructions(self) -> bool:
+ return self._config_manager.auto_expand_favorite_reconstructions
+
+ @property
+ def auto_expand_favorite_directories(self) -> bool:
+ return self._config_manager.auto_expand_favorite_directories
+
+ @property
+ def follow_mode(self) -> FollowMode:
+ return self._config_manager.follow_mode
@property
def loop_song(self) -> bool:
diff --git a/src/sampletones_application/config/managers/state.py b/src/sampletones_application/config/managers/state.py
index 8bcab291..97936b04 100644
--- a/src/sampletones_application/config/managers/state.py
+++ b/src/sampletones_application/config/managers/state.py
@@ -1,9 +1,8 @@
from pathlib import Path
-from typing import Optional
+from typing import Optional, Set
from sampletones_application.categories.hierarchy import Tab
from sampletones_application.config.session.state.state import ApplicationState
-from sampletones_application.paths import APPLICATION_STATE_PATH
from sampletones_shared.logger import logger
from sampletones_shared.utils.serialization import load_yaml, save_yaml_atomic
from sampletones_shared.utils.system.paths import get_directory, to_path
@@ -11,25 +10,24 @@
class ApplicationStateManager:
- def __init__(self) -> None:
+ def __init__(self, path: Path) -> None:
+ self.path: Path = path
self.state: ApplicationState = self._load()
def _load(self) -> ApplicationState:
- if not APPLICATION_STATE_PATH.exists():
+ if not self.path.exists():
return ApplicationState()
- raw = load_yaml(to_path(APPLICATION_STATE_PATH))
+ raw = load_yaml(to_path(self.path))
if not raw or not isinstance(raw, dict):
- logger.warning(
- f"Application state file '{APPLICATION_STATE_PATH}' is empty or invalid." " Loading default state."
- )
+ logger.warning(f"Application state file '{self.path}' is empty or invalid. Loading default state.")
return ApplicationState()
recovered = validate_with_recovery(ApplicationState, raw)
if recovered.dropped:
properties = ", ".join(flatten_location(location) for location in recovered.dropped)
logger.warning(
- f"Application state file '{APPLICATION_STATE_PATH}' had incompatible settings"
+ f"Application state file '{self.path}' had incompatible settings"
f" that were reset to defaults: {properties}"
)
@@ -37,15 +35,15 @@ def _load(self) -> ApplicationState:
def save(self) -> None:
try:
- APPLICATION_STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
+ self.path.parent.mkdir(parents=True, exist_ok=True)
save_yaml_atomic(
- APPLICATION_STATE_PATH,
+ self.path,
self.state.model_dump(mode="json"),
)
except OSError as exception:
logger.error_with_traceback(
exception,
- f"File error while saving application state to {APPLICATION_STATE_PATH}",
+ f"File error while saving application state to {self.path}",
)
def set_window_state(
@@ -96,6 +94,27 @@ def is_card_collapsed(self, card_tag: str) -> bool:
def set_card_collapsed(self, card_tag: str, collapsed: bool) -> None:
self.state.collapsed_cards[card_tag] = collapsed
+ def is_favorites_filter_active(self, panel_tag: str) -> bool:
+ return self.state.favorites_filters.get(panel_tag, False)
+
+ def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None:
+ self.state.favorites_filters[panel_tag] = active
+
+ def expanded_rows(self, panel_tag: str) -> Set[str]:
+ return set(self.state.expanded_rows.get(panel_tag, ()))
+
+ def set_expanded_rows(self, panel_tag: str, rows: Set[str]) -> None:
+ """Writes the rows a browser stands open, in a settled order so the file reads the same twice."""
+ self.state.expanded_rows[panel_tag] = sorted(rows)
+
+ @property
+ def expanded_directories(self) -> Set[Path]:
+ return set(self.state.expanded_directories)
+
+ def set_expanded_directories(self, directories: Set[Path]) -> None:
+ """Writes the folders the explorer stands open, in a settled order for a file read twice."""
+ self.state.expanded_directories = sorted(directories)
+
def load_current_tab(self) -> Tab:
return self.state.current.tab
diff --git a/src/sampletones_application/config/profile.py b/src/sampletones_application/config/profile.py
new file mode 100644
index 00000000..239bfeaf
--- /dev/null
+++ b/src/sampletones_application/config/profile.py
@@ -0,0 +1,27 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+from sampletones_application.paths import APPLICATION_STATE_PATH
+from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH
+
+
+@dataclass(frozen=True)
+class UserProfile:
+ """The two files a run keeps its settings and its session in.
+
+ A profile is chosen at startup and travels to the managers that read and write it, which is
+ what lets a run be pointed at a location of its own.
+ """
+
+ config: Path
+ state: Path
+
+ @classmethod
+ def user(cls) -> UserProfile:
+ """The profile in the user's configuration directory, which is where a normal run reads."""
+ return cls(
+ config=APPLICATION_CONFIG_PATH,
+ state=APPLICATION_STATE_PATH,
+ )
diff --git a/src/sampletones_application/config/session/application/browser.py b/src/sampletones_application/config/session/application/browser.py
new file mode 100644
index 00000000..73c18da2
--- /dev/null
+++ b/src/sampletones_application/config/session/application/browser.py
@@ -0,0 +1,12 @@
+from pydantic import BaseModel, Field
+
+
+class BrowserConfig(BaseModel):
+ auto_expand_favorite_reconstructions: bool = Field(
+ default=False,
+ description="If showing the favorites alone opens the rows above a favorite reconstruction.",
+ )
+ auto_expand_favorite_directories: bool = Field(
+ default=False,
+ description="If showing the favorites alone opens the rows above a favorite directory.",
+ )
diff --git a/src/sampletones_application/config/session/application/config.py b/src/sampletones_application/config/session/application/config.py
index 5b7d7314..0c3fc0c9 100644
--- a/src/sampletones_application/config/session/application/config.py
+++ b/src/sampletones_application/config/session/application/config.py
@@ -1,9 +1,12 @@
from pydantic import BaseModel, ConfigDict, Field
from sampletones_application.config.session.application.audio import AudioConfig
+from sampletones_application.config.session.application.browser import BrowserConfig
+from sampletones_application.config.session.application.display import DisplayConfig
from sampletones_application.config.session.application.favorites import Favorites
from sampletones_application.config.session.application.history import HistoryConfig
from sampletones_application.config.session.application.playback import PlaybackConfig
+from sampletones_application.config.session.application.shortcuts import ShortcutsConfig
from sampletones_core.data import Metadata
@@ -18,6 +21,14 @@ class ApplicationConfig(BaseModel):
default_factory=AudioConfig,
description="The audio configuration settings.",
)
+ browser: BrowserConfig = Field(
+ default_factory=BrowserConfig,
+ description="How the browsers of reconstructions read what they narrow to.",
+ )
+ display: DisplayConfig = Field(
+ default_factory=DisplayConfig,
+ description="The palette and frame pacing preferences.",
+ )
favorites: Favorites = Field(
default_factory=Favorites,
description="The user's favorite files and recent files.",
@@ -30,3 +41,7 @@ class ApplicationConfig(BaseModel):
default_factory=PlaybackConfig,
description="Playback behaviour preferences.",
)
+ shortcuts: ShortcutsConfig = Field(
+ default_factory=ShortcutsConfig,
+ description="The keybinding scheme and the actions rebound on it.",
+ )
diff --git a/src/sampletones_application/config/session/application/display.py b/src/sampletones_application/config/session/application/display.py
new file mode 100644
index 00000000..d59ba0d2
--- /dev/null
+++ b/src/sampletones_application/config/session/application/display.py
@@ -0,0 +1,36 @@
+from typing import Final
+
+from pydantic import BaseModel, Field
+
+from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME
+from sampletones_shared.display import UNLIMITED_FRAME_RATE
+
+DEFAULT_VSYNC: Final[bool] = True
+DEFAULT_MAX_FPS: Final[int] = 60
+DEFAULT_BORDERLESS: Final[bool] = False
+
+
+class DisplayConfig(BaseModel):
+ """How the application presents itself: the palette it wears and the pacing it renders at.
+
+ The window's own geometry belongs to the session state, which records where the user left
+ the window; these are the preferences a user picks in the display settings and keeps.
+ """
+
+ palette: str = Field(
+ default=DEFAULT_PALETTE_NAME,
+ description="The name of the palette the application draws with.",
+ )
+ vsync: bool = Field(
+ default=DEFAULT_VSYNC,
+ description="Whether the render loop waits for the monitor's refresh.",
+ )
+ max_fps: int = Field(
+ default=DEFAULT_MAX_FPS,
+ ge=UNLIMITED_FRAME_RATE,
+ description="The frame rate the render loop is held to, unlimited at zero.",
+ )
+ borderless: bool = Field(
+ default=DEFAULT_BORDERLESS,
+ description="Whether the window is drawn without the system's title bar and frame.",
+ )
diff --git a/src/sampletones_application/config/session/application/playback.py b/src/sampletones_application/config/session/application/playback.py
index 5a1f7cd1..bd6c8b65 100644
--- a/src/sampletones_application/config/session/application/playback.py
+++ b/src/sampletones_application/config/session/application/playback.py
@@ -1,4 +1,6 @@
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_serializer
+
+from sampletones_application.constants.playback import DEFAULT_FOLLOW_MODE, FollowMode
class PlaybackConfig(BaseModel):
@@ -6,11 +8,16 @@ class PlaybackConfig(BaseModel):
default=True,
description="If samples should autoplay when clicked.",
)
- follow_playback: bool = Field(
- default=True,
- description="If the sequencer grid follows the playhead during playback.",
+ follow_mode: FollowMode = Field(
+ default=DEFAULT_FOLLOW_MODE,
+ description="How far the sequencer view follows the playhead during playback.",
)
loop_song: bool = Field(
default=False,
description="If the song restarts from the beginning when playback reaches the end.",
)
+
+ @field_serializer("follow_mode")
+ def serialize_follow_mode(self, follow_mode: FollowMode) -> str:
+ """Writes the mode as the plain word it names, which is what the settings file carries."""
+ return follow_mode.value
diff --git a/src/sampletones_application/config/session/application/shortcuts.py b/src/sampletones_application/config/session/application/shortcuts.py
new file mode 100644
index 00000000..b70a0391
--- /dev/null
+++ b/src/sampletones_application/config/session/application/shortcuts.py
@@ -0,0 +1,30 @@
+from typing import Dict, Optional
+
+from pydantic import BaseModel, Field
+
+from sampletones_application.constants.keybindings import platform_scheme_name
+
+
+class ShortcutsConfig(BaseModel):
+ """The keys the application answers to: the scheme it runs under and the actions rebound on it.
+
+ A scheme names a whole set of keys the build ships, while an override rebinds one action on top
+ of it, so a reader who changes a single combination keeps every other key the scheme gives them.
+ Both are stored by name — the same names a keybinding file writes — which lets a preference
+ outlive the build that wrote it, since the names a build carries are what it reads back.
+
+ A fresh profile opens on the scheme its platform ships, so a Mac starts on Command where the
+ other platforms start on Control, and a stored preference is read ahead of that.
+ """
+
+ scheme: str = Field(
+ default_factory=platform_scheme_name,
+ description="The name of the keybinding scheme the application resolves its keys against.",
+ )
+ overrides: Dict[str, Optional[str]] = Field(
+ default_factory=dict,
+ description=(
+ "The combination each rebound action answers to, keyed by the action's name, "
+ "stating null for an action the reader left unbound."
+ ),
+ )
diff --git a/src/sampletones_application/config/session/state/paths.py b/src/sampletones_application/config/session/state/paths.py
index 3d8d300c..885909d6 100644
--- a/src/sampletones_application/config/session/state/paths.py
+++ b/src/sampletones_application/config/session/state/paths.py
@@ -2,7 +2,7 @@
from pydantic import BaseModel, Field, field_serializer
-from sampletones_core.paths import (
+from sampletones_shared.paths.user import (
CONFIG_PATH,
LIBRARY_DIRECTORY,
PROJECTS_DIRECTORY,
diff --git a/src/sampletones_application/config/session/state/state.py b/src/sampletones_application/config/session/state/state.py
index 00853aec..2524a797 100644
--- a/src/sampletones_application/config/session/state/state.py
+++ b/src/sampletones_application/config/session/state/state.py
@@ -1,4 +1,5 @@
-from typing import Dict
+from pathlib import Path
+from typing import Dict, List
from pydantic import BaseModel, Field
@@ -20,6 +21,18 @@ class ApplicationState(BaseModel):
default_factory=dict,
description="Collapsed state of each card, keyed by the card's tag.",
)
+ favorites_filters: Dict[str, bool] = Field(
+ default_factory=dict,
+ description="Whether each browser shows its favorites alone, keyed by the panel's tag.",
+ )
+ expanded_rows: Dict[str, List[str]] = Field(
+ default_factory=dict,
+ description="The rows each browser stands open, keyed by the panel's tag.",
+ )
+ expanded_directories: List[Path] = Field(
+ default_factory=list,
+ description="The folders the Main tab's explorer stands open.",
+ )
current: Current = Field(
default_factory=Current,
description="The current state of application elements, e.g. selected tab.",
diff --git a/src/sampletones_application/constants/__init__.py b/src/sampletones_application/constants/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/constants/keybindings.py b/src/sampletones_application/constants/keybindings.py
new file mode 100644
index 00000000..65672530
--- /dev/null
+++ b/src/sampletones_application/constants/keybindings.py
@@ -0,0 +1,25 @@
+from typing import Dict, Final
+
+from sampletones_shared.utils.system.system import System
+
+DEFAULT_SCHEME_NAME: Final[str] = "default"
+MACOS_SCHEME_NAME: Final[str] = "macos"
+
+PLATFORM_SCHEME_NAMES: Final[Dict[System, str]] = {
+ System.LINUX: DEFAULT_SCHEME_NAME,
+ System.WINDOWS: DEFAULT_SCHEME_NAME,
+ System.MACOS: MACOS_SCHEME_NAME,
+}
+
+
+def platform_scheme_name() -> str:
+ """The scheme a fresh profile starts on, which is the keyboard the platform is worked at.
+
+ A Mac carries Command where the other two carry Control, so the keys a reader already knows
+ from every other application on their machine are the keys the build opens with. A stored
+ preference is read ahead of this, so a reader who chose another scheme keeps it.
+
+ Returns:
+ str: The name of the scheme the current platform ships.
+ """
+ return PLATFORM_SCHEME_NAMES[System.current()]
diff --git a/src/sampletones_application/constants/playback.py b/src/sampletones_application/constants/playback.py
new file mode 100644
index 00000000..31324fed
--- /dev/null
+++ b/src/sampletones_application/constants/playback.py
@@ -0,0 +1,41 @@
+from enum import StrEnum
+from math import ceil
+from typing import Final
+
+from sampletones_core.timing import RowRate
+from sampletones_shared.constants.nes import MAX_NES_FREQUENCY
+from sampletones_shared.constants.project import MAX_SPEED, MIN_TEMPO
+
+
+class FollowMode(StrEnum):
+ """How far the sequencer view chases the playhead during song playback.
+
+ The two predicates are the whole contract: following rows is following patterns with the grid
+ scrolled to the sounding row as well, stated once here so every surface reads the same rule.
+ """
+
+ ROWS = "rows"
+ PATTERNS = "patterns"
+ OFF = "off"
+
+ @property
+ def follows_pattern(self) -> bool:
+ """Whether the tracker shows the order frame the playhead sounds."""
+ return self in (FollowMode.ROWS, FollowMode.PATTERNS)
+
+ @property
+ def follows_row(self) -> bool:
+ """Whether the tracker keeps the sounding row within the visible band."""
+ return self is FollowMode.ROWS
+
+
+DEFAULT_FOLLOW_MODE: Final[FollowMode] = FollowMode.ROWS
+
+MIN_TICKS_PER_ROW: Final[int] = 1
+MAX_TICKS_PER_ROW: Final[int] = ceil(
+ RowRate.from_parameters(
+ tempo=MIN_TEMPO,
+ speed=MAX_SPEED,
+ nes_frequency=MAX_NES_FREQUENCY,
+ ).ticks_per_row
+)
diff --git a/src/sampletones_application/constants/sequencer.py b/src/sampletones_application/constants/sequencer.py
new file mode 100644
index 00000000..0415da07
--- /dev/null
+++ b/src/sampletones_application/constants/sequencer.py
@@ -0,0 +1,5 @@
+from typing import Final, Optional, Tuple
+
+from sampletones_core.constants.enums import GeneratorName
+
+CHANNEL_AXIS: Final[Tuple[Optional[GeneratorName], ...]] = (None,) + tuple(GeneratorName.items())
diff --git a/src/sampletones_application/coordinators/config.py b/src/sampletones_application/coordinators/config.py
index 92567fc8..282f1df8 100644
--- a/src/sampletones_application/coordinators/config.py
+++ b/src/sampletones_application/coordinators/config.py
@@ -26,9 +26,9 @@
from sampletones_application.utils.file_dialogs.filter import FileFilter
from sampletones_application.utils.file_dialogs.result import ignore_none_path
from sampletones_application.utils.gui.dialogs import DialogsRenderer
-from sampletones_core.paths import EXT_FILE_JSON
from sampletones_shared.application import SAMPLETONES_VERSION
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_JSON
from sampletones_shared.utils.validation import flatten_location
_LOAD_FAILURE_MESSAGES: Dict[ConfigLoadFailureReason, GlobalMessageElements] = {
diff --git a/src/sampletones_application/coordinators/display.py b/src/sampletones_application/coordinators/display.py
new file mode 100644
index 00000000..1bc022c1
--- /dev/null
+++ b/src/sampletones_application/coordinators/display.py
@@ -0,0 +1,276 @@
+import math
+from typing import Optional
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.layout.behavior.display import DisplayBehavior
+from sampletones_application.layout.general.window import WindowLayout
+from sampletones_application.tags.settings import TAG_SETTINGS_DISPLAY_DIALOG_DISCARD
+from sampletones_application.ui.panels.dialogs.countdown import GUICountdownWindow
+from sampletones_application.ui.panels.dialogs.display_settings import (
+ GUIDisplaySettingsWindow,
+)
+from sampletones_application.utils.frame_limiter import FrameLimiter
+from sampletones_application.utils.gui.dialogs import DialogsRenderer
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_application.view_model.shared.display_settings import (
+ DisplaySettings,
+ DisplaySettingsViewModel,
+ WindowMode,
+)
+from sampletones_application.viewport import ViewportManager
+from sampletones_shared.display import Resolution
+
+
+class DisplayCoordinator:
+ """Owns the display settings: the options offered, the live application of a change, and the
+ countdown that returns a window mode nobody confirmed.
+
+ A change reaches the screen the moment it is made, so a user judges it by looking at it, while
+ the session keeps the values the dialog opened with until OK commits them. Cancel re-applies
+ that snapshot, asking first when there is something to lose.
+
+ Changing the window's size, its frame, or fullscreen can leave the window unreadable, so each
+ of those arms a countdown over the dialog: keeping it disarms the clock and leaves the change
+ pending, and letting the clock run out brings the last confirmed window mode back while every
+ other pending edit stays.
+ """
+
+ def __init__(
+ self,
+ session_manager: SessionManager,
+ viewport_manager: ViewportManager,
+ frame_limiter: FrameLimiter,
+ palette_source: PaletteSource,
+ palette_catalog: PaletteCatalog,
+ *,
+ window: GUIDisplaySettingsWindow,
+ countdown: GUICountdownWindow,
+ behavior: DisplayBehavior,
+ window_layout: WindowLayout,
+ dialogs: DialogsRenderer,
+ language_manager: LanguageManager,
+ ) -> None:
+ self._session_manager = session_manager
+ self._viewport_manager = viewport_manager
+ self._frame_limiter = frame_limiter
+ self._palette_source = palette_source
+ self._palette_catalog = palette_catalog
+ self._window = window
+ self._countdown = countdown
+ self._behavior = behavior
+ self._window_layout = window_layout
+ self._dialogs = dialogs
+ self._language_manager = language_manager
+
+ self._settings: Optional[DisplaySettings] = None
+ self._snapshot: Optional[DisplaySettings] = None
+ self._armed: Optional[WindowMode] = None
+ self._remaining: float = 0.0
+
+ self._window.on_settings_changed = self._change
+ self._window.on_commit = self._commit
+ self._window.on_cancel = self._request_close
+ self._countdown.on_keep = self._keep
+ self._countdown.on_revert = self._revert
+
+ def open(self) -> None:
+ """Shows the dialog seeded with the settings in force, snapshotting them for a cancel.
+
+ A window sitting at a size of its own selects the offered one nearest it, and that
+ selection becomes the state being edited, so what the dialog shows is what it applies.
+ """
+ view_model = self._view_model(self._settings_in_force())
+ self._snapshot = view_model.settings
+ self._settings = view_model.settings
+ self._window.open(view_model)
+
+ def tick(self, delta_time: float) -> None:
+ """Advances an armed countdown, restoring the last confirmed window mode when it runs out."""
+ if self._armed is None:
+ return
+
+ self._remaining -= delta_time
+ if self._remaining <= 0.0:
+ self._revert()
+ return
+
+ self._countdown.set_remaining(self._displayed_seconds())
+
+ def _change(self, settings: DisplaySettings) -> None:
+ """Puts an edit on screen, arming the countdown when it changed the window mode."""
+ previous = self._require_settings()
+ self._settings = settings
+ self._apply(previous, settings)
+ if settings.window != previous.window:
+ self._arm(previous.window)
+
+ self._window.update_view(self._view_model(settings))
+
+ def _arm(self, restorable: WindowMode) -> None:
+ """Starts the countdown that brings ``restorable`` back unless the change is confirmed.
+
+ A countdown already running keeps the mode it was going to restore and starts its count
+ again on the prompt already on screen, so a run of unconfirmed changes still returns to
+ the mode last seen as readable. The first change is what hands the screen over, since the
+ dialog steps aside for as long as the prompt stands.
+ """
+ self._remaining = self._behavior.revert_countdown_seconds
+ if self._armed is not None:
+ self._countdown.set_remaining(self._displayed_seconds())
+ return
+
+ self._armed = restorable
+ self._window.yield_to(lambda: self._countdown.open(self._displayed_seconds()))
+
+ def _disarm(self) -> None:
+ """Stops a running countdown and gives the dialog the screen back."""
+ if self._armed is None:
+ return
+
+ self._armed = None
+ self._remaining = 0.0
+ self._countdown.hide()
+ self._window.resume()
+
+ def _keep(self) -> None:
+ """Accepts the window mode on screen, which stays pending until OK commits it."""
+ self._disarm()
+
+ def _revert(self) -> None:
+ """Brings the last confirmed window mode back, leaving every other pending edit in place."""
+ restorable = self._armed
+ self._disarm()
+ if restorable is None:
+ return
+
+ self._restore(self._require_settings().with_window(restorable))
+
+ def _restore(self, settings: DisplaySettings) -> None:
+ """Puts ``settings`` on screen as the state in force, without arming a countdown."""
+ previous = self._require_settings()
+ self._settings = settings
+ self._apply(previous, settings)
+ self._window.update_view(self._view_model(settings))
+
+ def _commit(self) -> None:
+ """Writes the state on screen to the session and closes the dialog."""
+ settings = self._require_settings()
+ self._disarm()
+ self._session_manager.set_palette_name(settings.palette)
+ self._session_manager.set_vsync(settings.vsync)
+ self._session_manager.set_max_fps(settings.frame_rate)
+ self._session_manager.set_borderless(settings.window.borderless)
+ self._close()
+
+ def _request_close(self) -> None:
+ """Answers Cancel, Escape and the title bar's close button, asking before losing an edit.
+
+ The dialog steps aside for the prompt and comes back to carry on editing when the
+ answer is to keep what is on screen.
+ """
+ if self._require_settings() == self._snapshot:
+ self._discard()
+ return
+
+ self._window.yield_to(self._ask_to_discard)
+
+ def _ask_to_discard(self) -> None:
+ self._dialogs.show_confirmation(
+ tag=TAG_SETTINGS_DISPLAY_DIALOG_DISCARD,
+ title=self._language_manager["settings.display.title.discard_confirmation"],
+ message=self._language_manager["settings.display.message.discard_confirmation"],
+ on_confirm=self._discard,
+ on_cancel=self._window.resume,
+ ok_label=self._language_manager["settings.display.label.discard_button"],
+ cancel_label=self._language_manager["settings.display.label.keep_editing_button"],
+ )
+
+ def _discard(self) -> None:
+ """Puts back the settings the dialog opened with and closes it."""
+ self._disarm()
+ snapshot = self._snapshot
+ if snapshot is not None:
+ self._apply(self._require_settings(), snapshot)
+
+ self._close()
+
+ def _close(self) -> None:
+ self._settings = None
+ self._snapshot = None
+ self._window.hide()
+
+ def _apply(self, previous: DisplaySettings, current: DisplaySettings) -> None:
+ """Puts every setting that differs on screen, leaving the session untouched."""
+ if current.palette != previous.palette:
+ self._palette_source.activate(self._palette_catalog.select(current.palette))
+
+ if current.vsync != previous.vsync:
+ self._viewport_manager.set_vsync(current.vsync)
+
+ if current.frame_rate != previous.frame_rate:
+ self._frame_limiter.set_max_fps(current.frame_rate)
+
+ self._apply_window(previous.window, current.window)
+
+ def _apply_window(self, previous: WindowMode, current: WindowMode) -> None:
+ """Puts the window mode on screen, fullscreen first so a size lands on a windowed viewport.
+
+ Fullscreen goes through the viewport manager's own toggle, which is the path the View menu
+ and F11 take, so the menu's checkmark follows a change made here.
+ """
+ if current.fullscreen != previous.fullscreen:
+ self._viewport_manager.toggle_fullscreen()
+
+ if current.borderless != previous.borderless:
+ self._viewport_manager.set_borderless(current.borderless)
+
+ if current.resolution != previous.resolution:
+ self._viewport_manager.set_resolution(
+ current.resolution.width,
+ current.resolution.height,
+ )
+
+ def _settings_in_force(self) -> DisplaySettings:
+ """The display state the application is running under right now."""
+ width, height = self._viewport_manager.resolution
+ return DisplaySettings(
+ palette=self._palette_source.palette.name,
+ window=WindowMode(
+ resolution=Resolution(width=width, height=height),
+ borderless=self._session_manager.borderless,
+ fullscreen=self._session_manager.fullscreen,
+ ),
+ vsync=self._session_manager.vsync,
+ frame_rate=self._session_manager.max_fps,
+ )
+
+ def _view_model(self, settings: DisplaySettings) -> DisplaySettingsViewModel:
+ """The dialog's view of ``settings``, offering the sizes its monitor leaves room for."""
+ area = self._viewport_manager.monitor_area
+ return DisplaySettingsViewModel.build(
+ settings,
+ resolutions=self._behavior.resolutions,
+ frame_rates=self._behavior.frame_rates,
+ palettes=self._palette_catalog.names,
+ min_width=self._window_layout.min_width,
+ min_height=self._window_layout.min_height,
+ max_width=area.usable_width,
+ max_height=area.usable_height,
+ )
+
+ def _displayed_seconds(self) -> int:
+ """The whole seconds the prompt shows, rounded up so the last one reads as one."""
+ return math.ceil(self._remaining)
+
+ def _require_settings(self) -> DisplaySettings:
+ """The state the open dialog is editing.
+
+ Raises:
+ SystemError: when the dialog is driven while closed.
+ """
+ if self._settings is None:
+ raise SystemError("The display settings are edited only while the dialog is open")
+
+ return self._settings
diff --git a/src/sampletones_application/coordinators/edit/__init__.py b/src/sampletones_application/coordinators/edit/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/coordinators/edit/protocol.py b/src/sampletones_application/coordinators/edit/protocol.py
new file mode 100644
index 00000000..3c5c57fe
--- /dev/null
+++ b/src/sampletones_application/coordinators/edit/protocol.py
@@ -0,0 +1,7 @@
+from typing import Protocol
+
+
+class EditSurfaceProtocol(Protocol):
+ def owns_edit_actions(self) -> bool: ...
+
+ def build_edit_actions(self) -> None: ...
diff --git a/src/sampletones_application/coordinators/edit/router.py b/src/sampletones_application/coordinators/edit/router.py
new file mode 100644
index 00000000..edd6c3c2
--- /dev/null
+++ b/src/sampletones_application/coordinators/edit/router.py
@@ -0,0 +1,43 @@
+from typing import Optional, Sequence
+
+from sampletones_application.coordinators.edit.protocol import EditSurfaceProtocol
+
+
+class EditRouter:
+ """The single editing surface behind the menu bar's Edit menu.
+
+ A surface is a grid that offers editing gestures on the cell it holds a cursor in. Each one
+ states whether it owns those gestures at this moment, and the router asks the one that does to
+ build its actions into the menu being built. Surfaces are mutually exclusive, since taking a
+ cursor in one drops the cursor of the others, so at most one answers.
+
+ It is stateless: the surface is resolved on each call, so the menu states the actions of
+ whoever holds the cursor when it is opened, and needs no notice of cursors moving.
+
+ The router itself draws nothing. It calls the surface, which builds its items into the
+ container the menu bar has opened, the way :class:`PlaybackRouter` calls a source to play.
+ """
+
+ def __init__(self, *, surfaces: Sequence[EditSurfaceProtocol]) -> None:
+ self._surfaces = tuple(surfaces)
+
+ def build_menu_actions(self) -> bool:
+ """Builds the focused surface's actions, reporting whether a surface stated any.
+
+ Returns:
+ bool: Whether a surface owned the editing gestures and built its actions.
+ """
+ surface = self._focused_surface()
+ if surface is None:
+ return False
+
+ surface.build_edit_actions()
+ return True
+
+ def _focused_surface(self) -> Optional[EditSurfaceProtocol]:
+ """The surface owning the editing gestures, or ``None`` while a reader edits elsewhere."""
+ for surface in self._surfaces:
+ if surface.owns_edit_actions():
+ return surface
+
+ return None
diff --git a/src/sampletones_application/coordinators/keybindings.py b/src/sampletones_application/coordinators/keybindings.py
new file mode 100644
index 00000000..41894d9d
--- /dev/null
+++ b/src/sampletones_application/coordinators/keybindings.py
@@ -0,0 +1,341 @@
+from typing import Optional, Tuple
+
+from sampletones_application.categories.elements.settings import (
+ KeybindingActionElements,
+ KeybindingCategoryElements,
+ KeybindingsElements,
+)
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.tags.settings import (
+ TAG_SETTINGS_KEYBINDINGS_DIALOG_DISCARD,
+ TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN,
+ TAG_SETTINGS_KEYBINDINGS_DIALOG_RESET,
+)
+from sampletones_application.ui.panels.dialogs.keybindings import GUIKeybindingsWindow
+from sampletones_application.utils.gui.dialogs import DialogsRenderer
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog
+from sampletones_application.utils.gui.shortcuts.draft import ShortcutDraft
+from sampletones_application.utils.gui.shortcuts.ids import (
+ EDITABLE_SHORTCUT_CATEGORIES,
+ SHORTCUT_IDS_BY_NAME,
+ ShortcutCategory,
+ ShortcutId,
+)
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.view_model.shared.keybindings import (
+ KeybindingGroup,
+ KeybindingRow,
+ KeybindingsViewModel,
+)
+
+NO_COMBINATION: str = ""
+NO_MESSAGE: str = ""
+
+
+class KeybindingsCoordinator:
+ """Owns the keys a reader is editing: the draft they stand in, and what confirming them means.
+
+ The dialog edits a draft while the application keeps running on the keys it started with, so
+ Escape, Tab and Enter answer the same way throughout a session of rebinding them. Confirming
+ hands the draft's scheme to the source every action resolves against and writes the scheme name
+ and the rebound actions to the session; cancelling drops the draft and leaves the keys alone.
+
+ An assignment onto keys another action of the same scope holds is offered after a prompt naming
+ that action, which is then left unbound — one combination reaches one action within a scope.
+ """
+
+ def __init__(
+ self,
+ session_manager: SessionManager,
+ shortcut_source: ShortcutSource,
+ shortcut_catalog: ShortcutCatalog,
+ *,
+ window: GUIKeybindingsWindow,
+ dialogs: DialogsRenderer,
+ language_manager: LanguageManager,
+ ) -> None:
+ self._session_manager = session_manager
+ self._shortcut_source = shortcut_source
+ self._shortcut_catalog = shortcut_catalog
+ self._window = window
+ self._dialogs = dialogs
+ self._language_manager = language_manager
+
+ self._draft: Optional[ShortcutDraft] = None
+ self._scheme_name: str = shortcut_source.scheme.name
+ self._selected: Optional[ShortcutId] = None
+ self._message: str = NO_MESSAGE
+
+ self._window.on_scheme_selected = self._select_scheme
+ self._window.on_action_selected = self._select_action
+ self._window.on_combination_typed = self._type_combination
+ self._window.on_combination_captured = self._capture_combination
+ self._window.on_clear = self._clear
+ self._window.on_reset = self._request_reset
+ self._window.on_commit = self._commit
+ self._window.on_cancel = self._request_close
+
+ def open(self) -> None:
+ """Shows the dialog on a draft of the keys the session runs under."""
+ self._open_draft(self._session_manager.shortcut_scheme_name)
+ self._window.open(self._view_model())
+
+ def _open_draft(self, name: str) -> None:
+ """Starts a draft over the named scheme, on the keys a session stores for it."""
+ scheme = self._shortcut_catalog.select(name)
+ self._scheme_name = scheme.name
+ self._draft = ShortcutDraft.open(scheme, self._session_manager.shortcut_overrides)
+ self._selected = None
+ self._message = NO_MESSAGE
+
+ def _select_scheme(self, name: str) -> None:
+ """Opens another scheme as it ships, which is the keyboard the reader asked to work from."""
+ if name == self._scheme_name:
+ return
+
+ self._open_draft(name)
+ self._window.update_view(self._view_model())
+
+ def _select_action(self, name: str) -> None:
+ """Puts an action's keys in the entry box, which is where a written combination is given."""
+ self._selected = SHORTCUT_IDS_BY_NAME[name]
+ self._message = NO_MESSAGE
+ self._window.update_view(self._view_model())
+
+ def _type_combination(self, text: str) -> None:
+ """Gives the selected action the keys a reader wrote out, reporting what reads as no key."""
+ shortcut_id = self._require_selected()
+ try:
+ combination = KeyCombination.parse(text)
+ except KeyError:
+ self._message = self._template(KeybindingsElements.UNREADABLE_COMBINATION).format(combination=text)
+ self._window.update_view(self._view_model())
+ return
+
+ self._assign(shortcut_id, combination)
+
+ def _capture_combination(self, combination: KeyCombination) -> None:
+ """Gives the selected action the keys a reader pressed."""
+ self._assign(self._require_selected(), combination)
+
+ def _assign(self, shortcut_id: ShortcutId, combination: KeyCombination) -> None:
+ """Assigns the combination, asking first where another action of the scope holds it."""
+ draft = self._require_draft()
+ self._message = NO_MESSAGE
+ claimant = draft.claimant(shortcut_id, combination)
+ if claimant is None:
+ self._apply(draft.assign(shortcut_id, combination))
+ return
+
+ self._window.yield_to(lambda: self._ask_to_reassign(shortcut_id, combination, claimant))
+
+ def _ask_to_reassign(
+ self,
+ shortcut_id: ShortcutId,
+ combination: KeyCombination,
+ claimant: ShortcutId,
+ ) -> None:
+ message = self._template(KeybindingsElements.REASSIGN_CONFIRMATION).format(
+ combination=combination.display(),
+ holder=self._action_label(claimant),
+ action=self._action_label(shortcut_id),
+ )
+ self._dialogs.show_confirmation(
+ tag=TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN,
+ title=self._title(KeybindingsElements.REASSIGN_CONFIRMATION),
+ message=message,
+ on_confirm=lambda: self._reassign(shortcut_id, combination),
+ on_cancel=self._window.resume,
+ ok_label=self._label(KeybindingsElements.REASSIGN_BUTTON),
+ )
+
+ def _reassign(self, shortcut_id: ShortcutId, combination: KeyCombination) -> None:
+ """Takes the keys for the action the reader named, leaving the action that held them free."""
+ self._apply(self._require_draft().assign(shortcut_id, combination))
+ self._window.resume()
+
+ def _clear(self) -> None:
+ """Leaves the selected action unbound, its keys free for another action to take."""
+ self._message = NO_MESSAGE
+ self._apply(self._require_draft().clear(self._require_selected()))
+
+ def _request_reset(self) -> None:
+ """Answers Reset, asking before the shipped keys replace what the reader has given."""
+ self._window.yield_to(self._ask_to_reset)
+
+ def _ask_to_reset(self) -> None:
+ self._dialogs.show_confirmation(
+ tag=TAG_SETTINGS_KEYBINDINGS_DIALOG_RESET,
+ title=self._title(KeybindingsElements.RESET_CONFIRMATION),
+ message=self._message_text(KeybindingsElements.RESET_CONFIRMATION),
+ on_confirm=self._reset,
+ on_cancel=self._window.resume,
+ ok_label=self._language_manager["global.dialog.label.ok"],
+ )
+
+ def _reset(self) -> None:
+ self._message = NO_MESSAGE
+ self._apply(self._require_draft().reset())
+ self._window.resume()
+
+ def _commit(self) -> None:
+ """Puts the draft's keys in force and writes the scheme and the rebound actions down."""
+ draft = self._require_draft()
+ self._shortcut_source.activate(draft.scheme())
+ self._session_manager.set_shortcut_scheme_name(self._scheme_name)
+ self._session_manager.set_shortcut_overrides(draft.overrides())
+ self._close()
+
+ def _request_close(self) -> None:
+ """Answers Cancel, Escape and the title bar's close button, asking before losing an edit."""
+ if not self._require_draft().is_dirty:
+ self._close()
+ return
+
+ self._window.yield_to(self._ask_to_discard)
+
+ def _ask_to_discard(self) -> None:
+ self._dialogs.show_confirmation(
+ tag=TAG_SETTINGS_KEYBINDINGS_DIALOG_DISCARD,
+ title=self._title(KeybindingsElements.DISCARD_CONFIRMATION),
+ message=self._message_text(KeybindingsElements.DISCARD_CONFIRMATION),
+ on_confirm=self._close,
+ on_cancel=self._window.resume,
+ ok_label=self._label(KeybindingsElements.DISCARD_BUTTON),
+ cancel_label=self._label(KeybindingsElements.KEEP_EDITING_BUTTON),
+ )
+
+ def _close(self) -> None:
+ self._draft = None
+ self._selected = None
+ self._window.hide()
+
+ def _apply(self, draft: ShortcutDraft) -> None:
+ """Holds the edited draft and shows what it left the actions answering to."""
+ self._draft = draft
+ self._window.update_view(self._view_model())
+
+ def _view_model(self) -> KeybindingsViewModel:
+ draft = self._require_draft()
+ return KeybindingsViewModel(
+ groups=tuple(self._group(category, draft) for category in EDITABLE_SHORTCUT_CATEGORIES),
+ schemes=self._shortcut_catalog.names,
+ scheme=self._scheme_name,
+ selected=None if self._selected is None else self._selected.value,
+ combination=self._selected_combination(draft),
+ message=self._message,
+ )
+
+ def _group(self, category: ShortcutCategory, draft: ShortcutDraft) -> KeybindingGroup:
+ return KeybindingGroup(
+ category=category.value,
+ label=self._category_label(category),
+ rows=self._rows(category, draft),
+ )
+
+ def _rows(
+ self,
+ category: ShortcutCategory,
+ draft: ShortcutDraft,
+ ) -> Tuple[KeybindingRow, ...]:
+ return tuple(
+ KeybindingRow(
+ action=shortcut_id.value,
+ label=self._action_label(shortcut_id),
+ combination=self._displayed(draft.combination(shortcut_id)),
+ )
+ for shortcut_id in ShortcutId
+ if shortcut_id.category is category
+ )
+
+ def _selected_combination(self, draft: ShortcutDraft) -> str:
+ """The keys the entry box shows, empty while no action is selected."""
+ if self._selected is None:
+ return NO_COMBINATION
+
+ return self._displayed(draft.combination(self._selected))
+
+ @staticmethod
+ def _displayed(combination: Optional[KeyCombination]) -> str:
+ return NO_COMBINATION if combination is None else combination.display()
+
+ def _action_label(self, shortcut_id: ShortcutId) -> str:
+ """The name a reader finds an action under, which its element mirrors member for member."""
+ return self._action_text(KeybindingActionElements[shortcut_id.name])
+
+ def _category_label(self, category: ShortcutCategory) -> str:
+ """The name a reader finds a scope under, which its element mirrors member for member."""
+ return self._category_text(KeybindingCategoryElements[category.name])
+
+ def _action_text(self, element: KeybindingActionElements) -> str:
+ return self._language_manager[
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ TextType.LABEL,
+ element,
+ ]
+
+ def _category_text(self, element: KeybindingCategoryElements) -> str:
+ return self._language_manager[
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ TextType.TITLE,
+ element,
+ ]
+
+ def _label(self, element: KeybindingsElements) -> str:
+ return self._language_manager[
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ TextType.LABEL,
+ element,
+ ]
+
+ def _title(self, element: KeybindingsElements) -> str:
+ return self._language_manager[
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ TextType.TITLE,
+ element,
+ ]
+
+ def _message_text(self, element: KeybindingsElements) -> str:
+ return self._language_manager[
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ TextType.MESSAGE,
+ element,
+ ]
+
+ def _template(self, element: KeybindingsElements) -> str:
+ return self._language_manager[
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ TextType.TEMPLATE,
+ element,
+ ]
+
+ def _require_draft(self) -> ShortcutDraft:
+ """The keys the open dialog is editing.
+
+ Raises:
+ SystemError: when the dialog is driven while closed.
+ """
+ if self._draft is None:
+ raise SystemError("The keybindings are edited only while the dialog is open")
+
+ return self._draft
+
+ def _require_selected(self) -> ShortcutId:
+ """The action the reader is giving keys to.
+
+ Raises:
+ SystemError: when a combination arrives with no action selected.
+ """
+ if self._selected is None:
+ raise SystemError("A combination is given to the action the dialog has selected")
+
+ return self._selected
diff --git a/src/sampletones_application/coordinators/playback/router.py b/src/sampletones_application/coordinators/playback/router.py
index b67f0a5b..194460f3 100644
--- a/src/sampletones_application/coordinators/playback/router.py
+++ b/src/sampletones_application/coordinators/playback/router.py
@@ -52,6 +52,19 @@ def stop(self) -> None:
self._audio_device_manager.stop()
+ def shutdown(self) -> None:
+ """Quiesces every source and the device ahead of tearing the audio backend down.
+
+ A source that streams to the device writes from a thread of its own, so the audio
+ backend stays safe to terminate only once each such thread has stopped and closed its
+ stream. Teardown therefore reaches every source rather than the engaged one alone, so a
+ source holding a stream is wound down whatever the transport reports at that moment.
+ """
+ for source in self._sources:
+ source.stop()
+
+ self._audio_device_manager.stop()
+
@property
def play_label(self) -> str:
target = self._target()
diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py
index 295d790b..63f755e3 100644
--- a/src/sampletones_application/coordinators/project.py
+++ b/src/sampletones_application/coordinators/project.py
@@ -31,7 +31,6 @@
from sampletones_application.utils.file_dialogs.filter import FileFilter
from sampletones_application.utils.file_dialogs.result import ignore_none_path
from sampletones_application.utils.gui.dialogs import DialogsRenderer
-from sampletones_core.paths import EXT_FILE_PROJECT
from sampletones_core.trackers.backend import TrackerBackend
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.scope import ExportScope
@@ -45,6 +44,7 @@
SerializationError,
)
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_PROJECT
from sampletones_shared.types.callback import Callback, VoidCallback
from sampletones_shared.utils.system.paths import get_directory, get_filename
diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py
index f63b21e2..d2e5222a 100644
--- a/src/sampletones_application/coordinators/reconstruction.py
+++ b/src/sampletones_application/coordinators/reconstruction.py
@@ -30,10 +30,10 @@
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.exporters import Features
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
from sampletones_core.types.feature import FeatureValue
from sampletones_shared.exceptions import SampleToNESError
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
from sampletones_shared.types.callback import Callback, VoidCallback
from sampletones_shared.utils.system.paths import get_filename
diff --git a/src/sampletones_application/coordinators/render.py b/src/sampletones_application/coordinators/render.py
new file mode 100644
index 00000000..b2ed163a
--- /dev/null
+++ b/src/sampletones_application/coordinators/render.py
@@ -0,0 +1,170 @@
+from functools import partial
+from pathlib import Path
+from typing import Dict, Optional, Tuple
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.logic.render.logic import SongRenderLogic
+from sampletones_application.ui.panels.dialogs.render import GUIRenderWindow
+from sampletones_application.utils.file_dialogs.api import save_file_dialog
+from sampletones_application.utils.file_dialogs.filter import FileFilter
+from sampletones_application.utils.file_dialogs.result import ignore_none_path
+from sampletones_application.utils.gui.dialogs import DialogsRenderer
+from sampletones_application.utils.gui.frame import FrameCallbackManager
+from sampletones_application.view_model.shared.render import SongRenderViewModel
+from sampletones_core.audio.writers import AudioFormat, capability_of
+from sampletones_shared.types.callback import VoidCallback
+
+
+class SongRenderCoordinator:
+ """Owns writing the song to an audio file from the reader's side: the dialog, the destination
+ it is asked for, and the report a finished render makes.
+
+ The logic holds the render itself, so what is orchestrated here is the screen: the window
+ opens over the settings the logic offers and follows every view it emits, the destination is
+ asked for through the OS dialog, and each outcome leaves the window and raises the dialog that
+ reports it.
+
+ A render claims the application for as long as its dialog stands, so every way out passes
+ through one close, which releases the claim and tells the application to read it again.
+ """
+
+ def __init__(
+ self,
+ render_logic: SongRenderLogic,
+ *,
+ window: GUIRenderWindow,
+ dialogs: DialogsRenderer,
+ language_manager: LanguageManager,
+ on_activity_changed: VoidCallback,
+ ) -> None:
+ self._logic = render_logic
+ self._window = window
+ self._dialogs = dialogs
+ self._on_activity_changed = on_activity_changed
+ self._view_model: Optional[SongRenderViewModel] = None
+ self._window_open = False
+
+ self._title_destination = language_manager["settings.render.title.destination_dialog"]
+ self._title_rendered = language_manager["settings.render.title.rendered"]
+ self._msg_rendered = language_manager["settings.render.message.rendered"]
+ self._msg_failed = language_manager["settings.render.message.render_failed"]
+ self._filter_names: Dict[AudioFormat, str] = {
+ AudioFormat.WAVE: language_manager["global.dialog.filter.wave"],
+ AudioFormat.MP3: language_manager["global.dialog.filter.mp3"],
+ }
+
+ self._logic.on_view_changed = self._on_view_changed
+ self._logic.on_choose_destination = self._choose_destination
+ self._logic.on_success = self._on_success
+ self._logic.on_error = self._on_error
+ self._logic.on_cancelled = self._on_cancelled
+
+ self._window.on_settings_changed = self._logic.apply
+ self._window.on_browse = self._logic.request_destination
+ self._window.on_render = self._logic.start
+ self._window.on_cancel = self._logic.cancel
+ self._window.on_close = self._close
+
+ @property
+ def is_active(self) -> bool:
+ """A render occupies the application from the dialog opening until it closes."""
+ return self._logic.is_active
+
+ def open(self) -> None:
+ """Offers the render settings for the open song, over the document as it stands now."""
+ if not self._logic.open():
+ return
+
+ self._window_open = True
+ self._window.open(self._require_view_model())
+ self._on_activity_changed()
+
+ def cleanup(self) -> None:
+ """Winds a running render down for application exit."""
+ self._logic.cleanup()
+
+ def _on_view_changed(self, view_model: SongRenderViewModel) -> None:
+ """Keeps the open window standing at where the render has got to."""
+ self._view_model = view_model
+ if self._window_open:
+ self._window.update_view(view_model)
+
+ def _choose_destination(
+ self,
+ destination: Path,
+ audio_format: AudioFormat,
+ ) -> None:
+ """Asks for the file the render writes, starting from the one the dialog stands at."""
+ filepath = save_file_dialog(
+ title=self._title_destination,
+ initial_directory=destination.parent,
+ default_filename=destination.name,
+ filters=self._destination_filters(audio_format),
+ )
+
+ self._set_destination(filepath)
+
+ @ignore_none_path
+ def _set_destination(self, filepath: Path) -> None:
+ self._logic.set_destination(filepath)
+
+ def _destination_filters(self, audio_format: AudioFormat) -> Tuple[FileFilter, ...]:
+ """The type a destination is offered under: the container the dialog stands at.
+
+ The format is chosen in the dialog itself, so the file type follows it and a name typed
+ without an extension takes the one that container is written under.
+ """
+ return (
+ FileFilter.for_extensions(
+ self._filter_names[audio_format],
+ [capability_of(audio_format).extension],
+ ),
+ )
+
+ def _on_success(self, destination: Path) -> None:
+ """Reports the file a finished render wrote, as a path that opens in the file manager."""
+ self._close()
+ self._present(
+ partial(
+ self._dialogs.show_message_with_path,
+ self._title_rendered,
+ self._msg_rendered,
+ destination,
+ )
+ )
+
+ def _on_error(self, exception: Exception) -> None:
+ """Reports what a render failed on, leaving the destination as it was."""
+ self._close()
+ self._present(partial(self._dialogs.show_error, exception, self._msg_failed))
+
+ def _on_cancelled(self) -> None:
+ """Closes the dialog of a render that was stopped, which leaves no file to report."""
+ self._close()
+
+ def _close(self) -> None:
+ """Takes the dialog off screen and hands the application back."""
+ self._window_open = False
+ self._view_model = None
+ self._window.hide()
+ self._logic.close()
+ self._on_activity_changed()
+
+ def _present(self, raise_dialog: VoidCallback) -> None:
+ """Raises ``raise_dialog`` once the frame the window left the screen in has finished.
+
+ The render window is modal and DearPyGui carries one modal at a time, so a report waits
+ for the frame that draws the screen without it and opens onto a clear screen.
+ """
+ FrameCallbackManager.set_frame_callback(raise_dialog)
+
+ def _require_view_model(self) -> SongRenderViewModel:
+ """The render the dialog opens on.
+
+ Raises:
+ SystemError: when the window is raised before the logic offers a view.
+ """
+ if self._view_model is None:
+ raise SystemError("The render window is opened over the view the logic emits")
+
+ return self._view_model
diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py
index 3b42f1b6..fb6d0b4f 100644
--- a/src/sampletones_application/coordinators/tabs/instructions.py
+++ b/src/sampletones_application/coordinators/tabs/instructions.py
@@ -69,6 +69,7 @@
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.constants.enums import LibraryGeneratorName
from sampletones_core.library import InstructionLibraryKey
+from sampletones_core.structures.tree import FileSystemNode
from sampletones_shared.exceptions import LibraryDisplayError, SampleToNESError
from sampletones_shared.logger import logger
from sampletones_shared.types.callback import VoidCallback
@@ -114,7 +115,7 @@ def __init__(
self._side_panel_count: int
self._baseline_viewport_height = layout.baseline_viewport_height
self._base_graph_height = layout.base_graph_height
- self._max_stack_height = layout.max_stack_height
+ self._max_graph_height = layout.max_graph_height
self._details_width = layout.right_column_width
self._right_height = layout.right_column_height
self._ttl_generation_status = language_manager["instructions.library.title.generation_status_dialog"]
@@ -135,6 +136,7 @@ def __init__(
self._library_tree_logic,
scheduling=layout.scheduling,
initial_collapsed=session_manager.is_card_collapsed(TAG_INSTRUCTIONS_LIBRARY_PANEL),
+ initial_expanded_rows=session_manager.expanded_rows(TAG_INSTRUCTIONS_LIBRARY_PANEL),
language_manager=language_manager,
status_bar=status_bar,
colors=layout.tree_colors,
@@ -142,7 +144,7 @@ def __init__(
)
self._library_panel.set_collapse_handler(self._on_library_collapse_changed)
self._library_tree_logic.on_lock_state_changed = self._library_panel.set_tree_enabled
- self._library_tree_logic.on_favorite_changed = self._library_panel.update_favorite_indicator
+ self._library_tree_logic.on_favorite_changed = self._repaint_library_favorites
self._library_tree_logic.on_search_update_needed = self._library_panel.update_tree_visibility
self._library_logic.configure_lock(
@@ -352,6 +354,10 @@ def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None:
"""Persists a centre-column card's collapsed state so it restores on the next launch."""
self._session_manager.set_card_collapsed(card_tag, collapsed)
+ def _repaint_library_favorites(self, node: FileSystemNode) -> None:
+ """Repaints the row whose star was toggled: the catalogue lists a library once, so it is one row."""
+ self._library_panel.update_favorite_indicators((node,))
+
def _on_library_collapse_changed(self, card_tag: str, collapsed: bool) -> None:
"""Persists the library panel's collapse, then docks or restores the width of the column it fills."""
self._session_manager.set_card_collapsed(card_tag, collapsed)
@@ -377,13 +383,13 @@ def _sync_library_width(self) -> None:
dpg_configure_item(_LEFT_COLUMN_TAG, width=width)
def _sync_graph_heights(self) -> None:
- """Grows the stacked graphs to share the viewport's vertical surplus equally, filling the centre column."""
+ """Grows the stacked graphs to share the viewport's vertical surplus equally, up to their ceiling."""
height = stacked_graph_height(
self._base_graph_height,
dpg.get_viewport_client_height(),
self._baseline_viewport_height,
len(self._graph_panels),
- self._max_stack_height,
+ self._max_graph_height,
)
for panel in self._graph_panels:
panel.set_display_height(height)
@@ -480,6 +486,13 @@ def load_library_safely(self, filepath: Path) -> None:
except (SampleToNESError, OSError) as exception:
logger.warning(f"Could not load library from {logger.format_path(filepath)}: {exception}")
+ def save_browser_shape(self) -> None:
+ """Writes down the rows the catalogue stands open, so a later run brings them back."""
+ self._session_manager.set_expanded_rows(
+ self._library_panel.tag,
+ self._library_panel.expanded_rows,
+ )
+
def is_library_generating(self) -> bool:
return self._library_logic.is_library_generating()
diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py
index 7157f032..3cc17ada 100644
--- a/src/sampletones_application/coordinators/tabs/main.py
+++ b/src/sampletones_application/coordinators/tabs/main.py
@@ -61,6 +61,8 @@
ReconstructorPanelViewModel,
)
from sampletones_core.audio import AudioDeviceManager
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.structures.tree import FileSystemNode
from sampletones_shared.logger import logger
from sampletones_shared.types.callback import PathCallback, VoidCallback
@@ -128,6 +130,7 @@ def __init__(
self._explorer_logic: ExplorerLogic = ExplorerLogic(
config_manager,
language_manager=language_manager,
+ open_directories=session_manager.expanded_directories,
)
self._explorer_tree_logic: TreeLogic = TreeLogic(
session_manager,
@@ -144,7 +147,7 @@ def __init__(
initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_EXPLORER_PANEL),
)
self._explorer_tree_logic.on_lock_state_changed = self._explorer_panel.set_tree_enabled
- self._explorer_tree_logic.on_favorite_changed = self._explorer_panel.update_favorite_indicator
+ self._explorer_tree_logic.on_favorite_changed = self._repaint_explorer_favorites
self._explorer_tree_logic.on_search_update_needed = self._explorer_panel.update_tree_visibility
self._explorer_tree_logic.on_autoplay_error = self._on_explorer_autoplay_error
@@ -251,6 +254,10 @@ def __init__(
self._converter_panel.on_convert_requested = self._converter_logic.start_conversion
self._converter_panel.on_cancel_requested = self._request_cancel_confirmation
+ def _repaint_explorer_favorites(self, node: FileSystemNode) -> None:
+ """Repaints the row whose star was toggled: the explorer mirrors the disk, so a path is one row."""
+ self._explorer_panel.update_favorite_indicators((node,))
+
def _on_explorer_autoplay_error(self, exception: Exception) -> None:
FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception))
@@ -487,9 +494,17 @@ def refresh_converter_view(self) -> None:
def set_input_path(self, path: Path, convert: bool) -> None:
self._converter_logic.set_input_path(path, convert=convert)
+ def save_browser_shape(self) -> None:
+ """Writes down the folders the explorer stands open, so a later run reads down to them."""
+ self._session_manager.set_expanded_directories(self._explorer_logic.open_directories)
+
def refresh_browser(self) -> None:
self._explorer_panel.refresh()
+ def toggle_generator(self, generator: GeneratorName) -> None:
+ """Switches one generator in or out of the set a reconstruction is built from."""
+ self._reconstructor_panel.toggle_generator(generator)
+
def toggle_advanced_settings(self) -> None:
advanced_settings = self._session_manager.toggle_show_advanced_settings()
self._advanced_settings_panel.set_visibility(advanced_settings)
diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py
index e9a5876f..53a77dbc 100644
--- a/src/sampletones_application/coordinators/tabs/reconstruction.py
+++ b/src/sampletones_application/coordinators/tabs/reconstruction.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import Callable, Dict, Optional, Tuple
+from typing import Callable, Dict, Optional, Sequence, Tuple
import dearpygui.dearpygui as dpg
@@ -15,8 +15,8 @@
from sampletones_application.coordinators.original_audio import OriginalAudioLocator
from sampletones_application.coordinators.playback.guard import GuardedPlayer
from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol
-from sampletones_application.logic.reconstruction.browser import BrowserLogic
-from sampletones_application.logic.reconstruction.browser_manager import BrowserManager
+from sampletones_application.logic.reconstruction.browser.logic import BrowserLogic
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
from sampletones_application.logic.reconstruction.instruments import (
OnReconstructionInstrumentUpdatedCallback,
ReconstructionInstrumentsLogic,
@@ -27,7 +27,9 @@
)
from sampletones_application.logic.shared.player import PlayerLogic
from sampletones_application.logic.shared.tree import TreeLogic
-from sampletones_application.parameters.reconstruction import ReconstructionTabParameters
+from sampletones_application.parameters.reconstruction import (
+ ReconstructionTabParameters,
+)
from sampletones_application.services.export.error import ExportError
from sampletones_application.services.export.kind import ExportKind
from sampletones_application.services.export.result import ExportResult
@@ -57,7 +59,9 @@
from sampletones_application.ui.panels.reconstruction.audio import (
GUIReconstructionAudioPanel,
)
-from sampletones_application.ui.panels.reconstruction.browser import GUIBrowserPanel
+from sampletones_application.ui.panels.reconstruction.browser import (
+ GUIReconstructionsBrowserPanel,
+)
from sampletones_application.ui.panels.reconstruction.instruments.instruments import (
GUIReconstructionInstrumentsPanel,
)
@@ -77,7 +81,7 @@
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.exporters.truncation import EnvelopeTruncation
-from sampletones_core.paths import EXT_FILE_WAVE
+from sampletones_core.structures.tree import FileSystemNode
from sampletones_core.trackers.backend import TrackerBackend
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.scope import ExportScope
@@ -90,6 +94,7 @@
LoadReconstructionError,
)
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_WAVE
from sampletones_shared.types.callback import PathCallback, VoidCallback
_LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_RECONSTRUCTION, SUF_PANEL_LEFT)
@@ -108,11 +113,9 @@ def __init__(
export_service: ExportService,
tracker_backends: Dict[TrackerFormat, TrackerBackend],
on_load_reconstruction_with_confirmation: Callable[[Optional[Path]], None],
- on_reconstruct_file: VoidCallback,
- on_reconstruct_directory: VoidCallback,
on_change_audio_state: VoidCallback,
+ on_favorite_changed: Callable[[FileSystemNode], None],
on_reconstruction_instrument_updated: OnReconstructionInstrumentUpdatedCallback,
- is_operation_active: Callable[[], bool],
original_audio_locator: OriginalAudioLocator,
*,
layout: ReconstructionTabParameters,
@@ -154,21 +157,23 @@ def __init__(
audio_device_manager,
scheduling=layout.scheduling,
)
- self._browser_panel: GUIBrowserPanel = GUIBrowserPanel(
+ self._browser_panel: GUIReconstructionsBrowserPanel = GUIReconstructionsBrowserPanel(
self._browser_logic.tree,
self._browser_tree_logic,
scheduling=layout.scheduling,
language_manager=language_manager,
status_bar=status_bar,
colors=layout.tree_colors,
- is_operation_active=is_operation_active,
initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_BROWSER_PANEL),
+ initial_favorites_only=session_manager.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL),
+ initial_expanded_rows=session_manager.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL),
)
self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled
- self._browser_tree_logic.on_favorite_changed = self._browser_panel.update_favorite_indicator
+ self._browser_tree_logic.on_favorite_changed = on_favorite_changed
self._browser_tree_logic.on_search_update_needed = self._browser_panel.update_tree_visibility
self._browser_tree_logic.on_autoplay_error = self._on_browser_autoplay_error
self._browser_panel.set_collapse_handler(self._on_browser_collapse_changed)
+ self._browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed
self._reconstruction_player_logic = PlayerLogic(
audio_device_manager,
on_change_audio_state,
@@ -216,8 +221,6 @@ def __init__(
)
self._browser_panel.on_refresh_tree = self._browser_logic.refresh_tree
- self._browser_panel.on_reconstruct_file = on_reconstruct_file
- self._browser_panel.on_reconstruct_directory = on_reconstruct_directory
self._browser_panel.on_load_reconstruction = on_load_reconstruction_with_confirmation
self._browser_panel.on_reconstruction_remove_requested = self._request_remove_reconstruction
self._browser_panel.on_directory_remove_requested = self._request_remove_directory
@@ -494,6 +497,14 @@ def _on_browser_collapse_changed(
self._session_manager.set_card_collapsed(card_tag, collapsed)
self._sync_browser_width()
+ def _on_browser_favorites_filter_changed(
+ self,
+ panel_tag: str,
+ favorites_only: bool,
+ ) -> None:
+ """Persists the browser's favorites filter so it opens in the same mode on the next launch."""
+ self._session_manager.set_favorites_filter_active(panel_tag, favorites_only)
+
def _on_instruments_collapse_changed(
self,
card_tag: str,
@@ -547,6 +558,16 @@ def unlock(self) -> None:
def refresh_browser(self) -> None:
self._browser_panel.refresh()
+ def save_browser_shape(self) -> None:
+ """Writes down the rows the browser stands open, so a later run brings them back."""
+ self._session_manager.set_expanded_rows(
+ self._browser_panel.tag,
+ self._browser_panel.expanded_rows,
+ )
+
+ def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None:
+ self._browser_panel.update_favorite_indicators(nodes)
+
def display_reconstruction(self) -> None:
self._reconstruction_panel_logic.display_reconstruction()
self._reconstruction_instruments_logic.update_display()
@@ -609,10 +630,15 @@ def _remove_directory(self, directory: Path) -> None:
def update_reconstruction(self) -> None:
self._reconstruction_panel_logic.update_reconstruction()
+ self._reconstruction_instruments_logic.refresh_view()
def set_reconstruction_dimmed(self, dimmed: bool) -> None:
self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed)
+ def toggle_generator(self, generator: GeneratorName) -> None:
+ """Switches one generator's slice in and out of the waveform and of what plays."""
+ self._reconstruction_plot_panel.toggle_generator(generator)
+
@property
def player(self) -> AudioPlayerProtocol:
return self._guarded_player
@@ -660,12 +686,7 @@ def load_reconstruction(self, filepath: Path) -> None:
filepath,
self._language_manager["reconstructions.browser.message.file_not_found"],
)
- except (
- IOError,
- IsADirectoryError,
- PermissionError,
- OSError,
- ) as exception:
+ except (IsADirectoryError, PermissionError, OSError) as exception:
logger.error_with_traceback(
exception,
f"Error while loading reconstruction data from {filepath}",
diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py
index 54e531a0..5d16f7c1 100644
--- a/src/sampletones_application/coordinators/tabs/sequencer.py
+++ b/src/sampletones_application/coordinators/tabs/sequencer.py
@@ -1,32 +1,43 @@
from pathlib import Path
-from typing import Callable, Optional, ParamSpec, Union
+from typing import Callable, Optional, ParamSpec, Sequence, Tuple, Union
import dearpygui.dearpygui as dpg
from sampletones_application.categories.elements.sequencer import (
- SequencerHistoryActionElements,
SequencerHistoryElements,
)
from sampletones_application.categories.hierarchy import Page, Panel, Tab, TextType
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.config.managers.config import ConfigManager
from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.constants.playback import FollowMode
+from sampletones_application.coordinators.edit.protocol import EditSurfaceProtocol
from sampletones_application.coordinators.original_audio import OriginalAudioLocator
from sampletones_application.coordinators.playback.guard import GuardedPlayer
from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol
from sampletones_application.logic.history.action import HistoryAction
from sampletones_application.logic.history.manager import HistoryManager
-from sampletones_application.logic.history.snapshot import HistoryEntry
from sampletones_application.logic.history.transaction import CoalesceKey
from sampletones_application.logic.project.controller import ProjectController
-from sampletones_application.logic.reconstruction.browser_manager import BrowserManager
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
from sampletones_application.logic.sequencer.browser import SequencerBrowserLogic
from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic
-from sampletones_application.logic.sequencer.grid import SequencerGridLogic
+from sampletones_application.logic.sequencer.clipboard import (
+ OrderBlockText,
+ ParsedBlockCache,
+ ProjectSampleDirectory,
+ SequencerClipboard,
+ TrackerBlockText,
+)
from sampletones_application.logic.sequencer.history_detail import (
SequencerHistoryDetail,
)
-from sampletones_application.logic.sequencer.order import SequencerOrderLogic
+from sampletones_application.logic.sequencer.order import (
+ OrderBlock,
+ OrderBlockReader,
+ OrderBlockWriter,
+ SequencerOrderLogic,
+)
from sampletones_application.logic.sequencer.playback.playhead import (
remap_after_insert,
remap_after_move,
@@ -35,6 +46,13 @@
from sampletones_application.logic.sequencer.playback.song_player import SongPlayerLogic
from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer
from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic
+from sampletones_application.logic.sequencer.tracker import (
+ SequencerTrackerLogic,
+ TrackerBlock,
+ TrackerBlockReader,
+ TrackerBlockWriter,
+ TrackerRegionAdjuster,
+)
from sampletones_application.logic.shared.tree import TreeLogic
from sampletones_application.parameters.sequencer import SequencerTabParameters
from sampletones_application.services.song_player.player import SongPlayerService
@@ -53,28 +71,33 @@
from sampletones_application.tags.sequencer import (
TAG_SEQUENCER_BROWSER_DIALOG_FREQUENCY,
TAG_SEQUENCER_BROWSER_PANEL,
- TAG_SEQUENCER_GRID_PANEL,
TAG_SEQUENCER_HISTORY_PANEL,
TAG_SEQUENCER_INSTRUMENTS_DIALOG_REMOVE,
TAG_SEQUENCER_INSTRUMENTS_PANEL,
TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY,
TAG_SEQUENCER_MODULE_PANEL,
TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD,
+ TAG_SEQUENCER_TRACKER_PANEL,
)
from sampletones_application.ui.elements.layout.columns import ColumnSpec, TabColumns
from sampletones_application.ui.elements.layout.responsive import expanded_side_width
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
-from sampletones_application.ui.panels.sequencer.grid import GUISequencerGridPanel
from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel
from sampletones_application.ui.panels.sequencer.module import GUISequencerModulePanel
from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel
+from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.utils.gui.clipboard import (
+ SystemTextClipboard,
+ TextClipboard,
+)
from sampletones_application.utils.gui.dialogs import DialogsRenderer
from sampletones_application.utils.gui.dpg import dpg_configure_item
from sampletones_application.utils.gui.frame import FrameCallbackManager
-from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
from sampletones_application.view_model.sequencer.channels import (
SequencerChannelsViewModel,
)
@@ -82,11 +105,19 @@
HistoryEntryViewModel,
HistoryViewModel,
)
+from sampletones_application.view_model.sequencer.region import (
+ OrderCell,
+ OrderRegion,
+ TrackerCell,
+ TrackerRegion,
+)
from sampletones_application.view_model.sequencer.samples import (
SequencerSamplesViewModel,
)
+from sampletones_application.view_model.sequencer.settings import (
+ SequencerSettingsViewModel,
+)
from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel
-from sampletones_application.view_model.sequencer.subcolumn import SubColumn
from sampletones_application.view_model.shared.history import (
HistoryDetail,
HistoryDetailSegment,
@@ -94,8 +125,9 @@
)
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.constants.enums import FeatureKey, GeneratorName
-from sampletones_core.project.instruments.instrument import Instrument
+from sampletones_core.project.song_position import SongPosition
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.structures.tree import FileSystemNode
from sampletones_shared.exceptions import SampleToNESError
from sampletones_shared.logger import logger
from sampletones_shared.types.callback import StringCallback, VoidCallback
@@ -114,16 +146,19 @@ def __init__(
session_manager: SessionManager,
audio_device_manager: AudioDeviceManager,
key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
browser_manager: BrowserManager,
project_controller: ProjectController,
history: HistoryManager,
original_audio_locator: OriginalAudioLocator,
*,
+ tab_active: ActivePredicate,
layout: SequencerTabParameters,
language_manager: LanguageManager,
dialogs: DialogsRenderer,
status_bar: GUIStatusBar,
on_edit_sample_requested: StringCallback,
+ on_favorite_changed: Callable[[FileSystemNode], None],
on_sample_reconstruction_replaced: Callable[[str, Reconstruction], None],
on_tab_switch: Callable[[Tab], None],
on_nes_frequency_changed: Callable[[int], None],
@@ -134,6 +169,7 @@ def __init__(
self._history = history
self._original_audio_locator = original_audio_locator
self._on_edit_sample_requested = on_edit_sample_requested
+ self._on_favorite_changed = on_favorite_changed
self._on_sample_reconstruction_replaced = on_sample_reconstruction_replaced
self._on_tab_switch = on_tab_switch
self._on_nes_frequency_changed = on_nes_frequency_changed
@@ -144,7 +180,7 @@ def __init__(
self._msg_no_project = language_manager["global.dialog.message.no_project_open"]
self._ttl_no_project = language_manager["global.dialog.title.no_project_open"]
self._nes_frequency_change_acknowledged: bool = False
- self._playing_order: Optional[int] = None
+ self._playing_position: Optional[SongPosition] = None
self._geometry = layout.geometry
self._side_panel_count: int
self._instruments_width = layout.right_column_width
@@ -171,9 +207,24 @@ def __init__(
status_bar=status_bar,
colors=layout.tree_colors,
initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL),
+ initial_favorites_only=session_manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL),
+ initial_expanded_rows=session_manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL),
)
- self._sequencer_grid_logic: SequencerGridLogic = SequencerGridLogic(project_controller)
+ self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller)
self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller)
+ self._clipboard: SequencerClipboard = SequencerClipboard()
+ self._system_clipboard: TextClipboard = SystemTextClipboard()
+ self._tracker_block_text: TrackerBlockText = TrackerBlockText(
+ samples=ProjectSampleDirectory(project_controller),
+ )
+ self._order_block_text: OrderBlockText = OrderBlockText()
+ self._tracker_text_cache: ParsedBlockCache[TrackerBlock] = ParsedBlockCache(self._tracker_block_text.parse)
+ self._order_text_cache: ParsedBlockCache[OrderBlock] = ParsedBlockCache(self._order_block_text.parse)
+ self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic)
+ self._tracker_block_writer: TrackerBlockWriter = TrackerBlockWriter(self._sequencer_tracker_logic)
+ self._tracker_region_adjuster: TrackerRegionAdjuster = TrackerRegionAdjuster(self._sequencer_tracker_logic)
+ self._order_block_reader: OrderBlockReader = OrderBlockReader(self._sequencer_order_logic)
+ self._order_block_writer: OrderBlockWriter = OrderBlockWriter(self._sequencer_order_logic)
self._sequencer_samples_logic: SequencerSamplesLogic = SequencerSamplesLogic(
project_controller,
session_manager,
@@ -191,6 +242,7 @@ def __init__(
project_controller,
config_manager.config,
active_channels=lambda: self._sequencer_channels_logic.active_channels,
+ sample_rate=lambda: audio_device_manager.sample_rate,
),
should_loop=lambda: session_manager.loop_song,
master_gain=lambda: session_manager.master_gain,
@@ -201,14 +253,17 @@ def __init__(
dialogs=dialogs,
error_message=language_manager["global.player.message.audio_playback_error"],
)
- self._sequencer_grid_panel: GUISequencerGridPanel = GUISequencerGridPanel(
+ self._sequencer_tracker_panel: GUISequencerTrackerPanel = GUISequencerTrackerPanel(
+ self._sequencer_tracker_logic.settings,
layout=layout.sequencer,
- initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_GRID_PANEL),
+ initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_TRACKER_PANEL),
language_manager=language_manager,
key_router=key_router,
+ tab_active=tab_active,
+ shortcut_source=shortcut_source,
)
self._sequencer_module_panel: GUISequencerModulePanel = GUISequencerModulePanel(
- self._sequencer_grid_logic.settings,
+ self._sequencer_tracker_logic.settings,
layout=layout.sequencer,
inputs=layout.inputs,
initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_MODULE_PANEL),
@@ -221,12 +276,17 @@ def __init__(
initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD),
language_manager=language_manager,
key_router=key_router,
+ tab_active=tab_active,
+ shortcut_source=shortcut_source,
)
self._sequencer_samples_panel: GUISequencerSamplesPanel = GUISequencerSamplesPanel(
layout=layout.sequencer,
+ detail_color=layout.muted_color,
initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_INSTRUMENTS_PANEL),
language_manager=language_manager,
key_router=key_router,
+ tab_active=tab_active,
+ shortcut_source=shortcut_source,
)
self._sequencer_history_panel: GUISequencerHistoryPanel = GUISequencerHistoryPanel(
layout=layout.sequencer,
@@ -236,7 +296,7 @@ def __init__(
status_bar=status_bar,
)
self._history_detail: SequencerHistoryDetail = SequencerHistoryDetail(
- self._sequencer_grid_logic,
+ self._sequencer_tracker_logic,
self._sequencer_samples_logic,
)
@@ -246,9 +306,10 @@ def _wire_callbacks(self) -> None:
"""Connects every panel and logic object this tab owns to the handler that serves it."""
self._wire_collapse_handlers()
self._wire_module_callbacks()
- self._wire_grid_callbacks()
+ self._wire_tracker_callbacks()
self._wire_channels_callbacks()
self._wire_order_callbacks()
+ self._wire_block_callbacks()
self._wire_samples_callbacks()
self._wire_browser_callbacks()
self._wire_playback_callbacks()
@@ -258,7 +319,7 @@ def _wire_callbacks(self) -> None:
def _wire_collapse_handlers(self) -> None:
for panel in (
self._sequencer_order_panel,
- self._sequencer_grid_panel,
+ self._sequencer_tracker_panel,
self._sequencer_module_panel,
self._sequencer_samples_panel,
self._sequencer_history_panel,
@@ -269,64 +330,64 @@ def _wire_module_callbacks(self) -> None:
self._sequencer_module_panel.on_nes_frequency = self._request_nes_frequency_change
self._sequencer_module_panel.on_rows_per_pattern = self._undoable(
HistoryAction.SET_ROWS_PER_PATTERN,
- self._sequencer_grid_logic.set_rows_per_pattern,
+ self._sequencer_tracker_logic.set_rows_per_pattern,
detail=self._history_detail.value,
coalesce=self._module_setting_key,
)
self._sequencer_module_panel.on_tempo = self._undoable(
HistoryAction.SET_TEMPO,
- self._sequencer_grid_logic.set_tempo,
+ self._sequencer_tracker_logic.set_tempo,
detail=self._history_detail.value,
coalesce=self._module_setting_key,
)
self._sequencer_module_panel.on_speed = self._undoable(
HistoryAction.SET_SPEED,
- self._sequencer_grid_logic.set_speed,
+ self._sequencer_tracker_logic.set_speed,
detail=self._history_detail.value,
coalesce=self._module_setting_key,
)
- def _wire_grid_callbacks(self) -> None:
- self._sequencer_grid_panel.on_clear_row = self._undoable(
+ def _wire_tracker_callbacks(self) -> None:
+ self._sequencer_tracker_panel.on_clear_row = self._undoable(
HistoryAction.CLEAR_ROW,
- self._on_clear_row,
+ self._sequencer_tracker_logic.clear_cell,
detail=self._history_detail.clear_row,
)
- self._sequencer_grid_panel.on_clear_subcolumn = self._undoable(
+ self._sequencer_tracker_panel.on_clear_subcolumn = self._undoable(
HistoryAction.CLEAR_SUBCOLUMN,
- self._on_clear_subcolumn,
+ self._sequencer_tracker_logic.clear_cell_subcolumn,
detail=self._history_detail.clear_subcolumn,
)
- self._sequencer_grid_panel.on_set_row = self._undoable(
+ self._sequencer_tracker_panel.on_set_row = self._undoable(
HistoryAction.EDIT_ROW,
- self._on_set_row,
+ self._sequencer_tracker_logic.write_cell,
detail=self._history_detail.edit_row,
coalesce=self._edit_row_key,
)
- self._sequencer_grid_panel.on_set_note_off = self._undoable(
+ self._sequencer_tracker_panel.on_set_note_off = self._undoable(
HistoryAction.NOTE_OFF,
- self._on_set_note_off,
+ self._sequencer_tracker_logic.cut_note,
detail=self._history_detail.note_off,
coalesce=self._cell_key,
)
- self._sequencer_grid_panel.on_cell_selected = self._on_tracker_cell_focused
- self._sequencer_grid_panel.on_play_from_row = self._on_grid_play_from_row
- self._sequencer_grid_panel.on_play_from_frame = self.play_from_current_frame
- self._sequencer_grid_panel.on_adjust_transpose = self._undoable(
+ self._sequencer_tracker_panel.on_cell_selected = self._on_tracker_cell_focused
+ self._sequencer_tracker_panel.on_play_from_row = self._on_tracker_play_from_row
+ self._sequencer_tracker_panel.on_play_from_frame = self.play_from_current_frame
+ self._sequencer_tracker_panel.on_adjust_transpose = self._undoable(
HistoryAction.ADJUST_TRANSPOSE,
- self._on_adjust_transpose,
+ self._tracker_region_adjuster.adjust_transpose,
detail=self._history_detail.adjust_transpose,
coalesce=self._adjustment_key,
)
- self._sequencer_grid_panel.on_adjust_volume = self._undoable(
+ self._sequencer_tracker_panel.on_adjust_volume = self._undoable(
HistoryAction.ADJUST_VOLUME,
- self._on_adjust_volume,
+ self._tracker_region_adjuster.adjust_volume,
detail=self._history_detail.adjust_volume,
coalesce=self._adjustment_key,
)
- self._sequencer_grid_logic.on_settings_changed = self._sequencer_module_panel.update_settings
- self._sequencer_grid_logic.on_grid_changed = self._sequencer_grid_panel.update_grid
- self._sequencer_grid_logic.on_frame_changed = self._sequencer_order_panel.select_position
+ self._sequencer_tracker_logic.on_settings_changed = self._on_settings_changed
+ self._sequencer_tracker_logic.on_tracker_changed = self._sequencer_tracker_panel.update_tracker
+ self._sequencer_tracker_logic.on_frame_changed = self._sequencer_order_panel.select_position
def _wire_channels_callbacks(self) -> None:
"""Connects the tracker's column headers and the order table's row labels to the mute set
@@ -337,7 +398,7 @@ def _wire_channels_callbacks(self) -> None:
hooks record no history entry.
"""
self._sequencer_channels_logic.on_channels_changed = self._show_channels
- for panel in (self._sequencer_grid_panel, self._sequencer_order_panel):
+ for panel in (self._sequencer_tracker_panel, self._sequencer_order_panel):
panel.on_channel_mute_toggled = self._sequencer_channels_logic.toggle
panel.on_channel_soloed = self._sequencer_channels_logic.solo
panel.on_channels_toggled = self._sequencer_channels_logic.toggle_all
@@ -351,7 +412,7 @@ def _show_channels(self, view_model: SequencerChannelsViewModel) -> None:
The menu bar sits above this tab and rebuilds its own state, so it is handed the change
as a signal and reads the mute set back through :attr:`channels`.
"""
- self._sequencer_grid_panel.update_channels(view_model)
+ self._sequencer_tracker_panel.update_channels(view_model)
self._sequencer_order_panel.update_channels(view_model)
self._on_channels_changed()
@@ -368,6 +429,14 @@ def unmute_all_channels(self) -> None:
"""Returns every channel to audible, the menu's whole-mix gesture."""
self._sequencer_channels_logic.unmute_all()
+ def set_follow_mode(self, mode: FollowMode) -> None:
+ """Chooses how far the view chases the playhead, the menu's and keyboard's gesture.
+
+ The player holds the setting and emits a view as it changes, which is what settles the
+ grid's following and the menu's mark together.
+ """
+ self._song_player_logic.set_follow_mode(mode)
+
def _wire_order_callbacks(self) -> None:
self._sequencer_order_logic.on_order_changed = self._sequencer_order_panel.update_order
self._sequencer_order_panel.on_frame_selected = self._on_order_frame_selected
@@ -379,7 +448,12 @@ def _wire_order_callbacks(self) -> None:
self._sequencer_order_panel.on_duplicate_requested = self._undoable(
HistoryAction.DUPLICATE_FRAME,
self._on_order_duplicate,
- detail=self._history_detail.duplicate_frame,
+ detail=self._history_detail.copy_frame,
+ )
+ self._sequencer_order_panel.on_clone_requested = self._undoable(
+ HistoryAction.CLONE_FRAME,
+ self._on_order_clone,
+ detail=self._history_detail.copy_frame,
)
self._sequencer_order_panel.on_insert_requested = self._undoable(
HistoryAction.ADD_FRAME,
@@ -409,10 +483,131 @@ def _wire_order_callbacks(self) -> None:
)
self._sequencer_order_panel.on_cell_selected = self._on_order_cell_focused
+ def _wire_block_callbacks(self) -> None:
+ """Connects the grids' block gestures to the clipboard they copy into.
+
+ A copy reads the project and leaves it as it stands, so it is wired straight through
+ instead of through :meth:`_undoable`: a transaction over it would record an entry the
+ history has nothing to restore for. The three gestures that do write are whole ones, each
+ recording the single entry that takes the grid back to where it stood.
+
+ Each grid also asks whether its own slot holds a block, which is what a menu offering
+ Paste consults before it is opened.
+ """
+ self._sequencer_tracker_panel.can_paste_block = self._can_paste_tracker_block
+ self._sequencer_order_panel.can_paste_block = self._can_paste_order_block
+ self._sequencer_tracker_panel.on_copy_block = self._on_tracker_copy_block
+ self._sequencer_tracker_panel.on_cut_block = self._undoable(
+ HistoryAction.CUT_BLOCK,
+ self._cut_tracker_block,
+ detail=self._history_detail.tracker_block,
+ )
+ self._sequencer_tracker_panel.on_delete_block = self._undoable(
+ HistoryAction.DELETE_BLOCK,
+ self._tracker_block_writer.clear,
+ detail=self._history_detail.tracker_block,
+ )
+ self._sequencer_tracker_panel.on_paste_block = self._undoable(
+ HistoryAction.PASTE_BLOCK,
+ self._paste_tracker_block,
+ detail=self._history_detail.tracker_paste,
+ )
+ self._sequencer_order_panel.on_copy_block = self._on_order_copy_block
+ self._sequencer_order_panel.on_cut_block = self._undoable(
+ HistoryAction.CUT_BLOCK,
+ self._cut_order_block,
+ detail=self._history_detail.order_block,
+ )
+ self._sequencer_order_panel.on_delete_block = self._undoable(
+ HistoryAction.DELETE_BLOCK,
+ self._order_block_writer.clear,
+ detail=self._history_detail.order_block,
+ )
+ self._sequencer_order_panel.on_paste_block = self._undoable(
+ HistoryAction.PASTE_BLOCK,
+ self._paste_order_block,
+ detail=self._history_detail.order_paste,
+ )
+
+ def _can_paste_tracker_block(self) -> bool:
+ """Whether the tracker has a block to write, which is what its Paste item is offered on."""
+ return self._tracker_block_in_hand() is not None
+
+ def _can_paste_order_block(self) -> bool:
+ """Whether the order has a block to write, which is what its Paste item is offered on."""
+ return self._order_block_in_hand() is not None
+
+ def _tracker_block_in_hand(self) -> Optional[TrackerBlock]:
+ """The block a tracker paste would write: the system clipboard's while its text is one.
+
+ Text another instance copied reads as a block here, so it stands ahead of the slot the
+ tracker copied into, and text from anywhere else leaves that slot's own block in hand.
+ """
+ parsed = self._tracker_text_cache.block(self._system_clipboard.read())
+ if parsed is not None:
+ return parsed
+
+ return self._clipboard.tracker_block
+
+ def _order_block_in_hand(self) -> Optional[OrderBlock]:
+ """The block an order paste would write: the system clipboard's while its text is one.
+
+ Text another instance copied reads as a block here, so it stands ahead of the slot the
+ order copied into, and text from anywhere else leaves that slot's own block in hand.
+ """
+ parsed = self._order_text_cache.block(self._system_clipboard.read())
+ if parsed is not None:
+ return parsed
+
+ return self._clipboard.order_block
+
+ def _on_tracker_copy_block(self, region: TrackerRegion) -> None:
+ """Puts the tracker's selected block on both clipboards, for a paste to replay.
+
+ The slot keeps the block exactly, and the system clipboard keeps the text form of it, so
+ the same copy reaches a paste here and a paste in another instance.
+ """
+ block = self._tracker_block_reader.read(region)
+ self._clipboard.store_tracker_block(block)
+ self._system_clipboard.write(self._tracker_block_text.state(block, region))
+
+ def _cut_tracker_block(self, region: TrackerRegion) -> None:
+ """Takes the block a region covers onto the clipboard, then empties what it covered."""
+ self._on_tracker_copy_block(region)
+ self._tracker_block_writer.clear(region)
+
+ def _paste_tracker_block(self, cell: TrackerCell) -> None:
+ """Writes the block the tracker has in hand at a cell, while a copy has been made."""
+ block = self._tracker_block_in_hand()
+ if block is not None:
+ self._tracker_block_writer.write(block, cell)
+
+ def _on_order_copy_block(self, region: OrderRegion) -> None:
+ """Puts the order's selected block on both clipboards, for a paste to replay.
+
+ The slot keeps the block exactly, and the system clipboard keeps the text form of it, so
+ the same copy reaches a paste here and a paste in another instance.
+ """
+ block = self._order_block_reader.read(region)
+ self._clipboard.store_order_block(block)
+ self._system_clipboard.write(self._order_block_text.state(block, region))
+
+ def _cut_order_block(self, region: OrderRegion) -> None:
+ """Takes the block a region covers onto the clipboard, then silences what it covered."""
+ self._on_order_copy_block(region)
+ self._order_block_writer.clear(region)
+
+ def _paste_order_block(self, cell: OrderCell) -> None:
+ """Writes the block the order has in hand at a cell, while a copy has been made."""
+ block = self._order_block_in_hand()
+ if block is not None:
+ self._order_block_writer.write(block, cell)
+
def _wire_samples_callbacks(self) -> None:
self._sequencer_samples_logic.on_samples_changed = self._on_samples_changed
self._sequencer_samples_logic.on_edit_sample_requested = self._dispatch_edit_sample
self._sequencer_samples_logic.on_autoplay_error = self._on_preview_error
+ self._sequencer_samples_panel.sample_footprint = self._sequencer_samples_logic.build_sample_footprint
self._sequencer_samples_panel.on_sample_selected = self._on_sample_selected
self._sequencer_samples_panel.on_sample_edit_requested = self._sequencer_samples_logic.request_edit
self._sequencer_samples_panel.on_loop_changed = self._undoable(
@@ -436,6 +631,7 @@ def _wire_samples_callbacks(self) -> None:
def _wire_browser_callbacks(self) -> None:
self._sequencer_browser_panel.set_collapse_handler(self._on_browser_collapse_changed)
+ self._sequencer_browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed
self._sequencer_browser_panel.on_add_to_sequencer = self.import_reconstruction
self._sequencer_browser_panel.can_add_to_sequencer = self._is_project_open
self._sequencer_browser_panel.on_replace_in_sequencer = self.replace_reconstruction
@@ -443,7 +639,7 @@ def _wire_browser_callbacks(self) -> None:
self._sequencer_browser_panel.on_locate_original_audio = self._original_audio_locator.locate
self._sequencer_browser_panel.on_refresh_tree = self._sequencer_browser_logic.refresh_tree
self._sequencer_tree_logic.on_lock_state_changed = self._sequencer_browser_panel.set_tree_enabled
- self._sequencer_tree_logic.on_favorite_changed = self._sequencer_browser_panel.update_favorite_indicator
+ self._sequencer_tree_logic.on_favorite_changed = self._on_favorite_changed
self._sequencer_tree_logic.on_search_update_needed = self._sequencer_browser_panel.update_tree_visibility
self._sequencer_tree_logic.on_autoplay_error = self._on_preview_error
@@ -453,7 +649,7 @@ def _wire_playback_callbacks(self) -> None:
self._song_player_logic.on_error = self._on_player_error
def _wire_project_callbacks(self) -> None:
- self._project_controller.on_settings_changed = self._sequencer_grid_logic.push_settings
+ self._project_controller.on_settings_changed = self._sequencer_tracker_logic.push_settings
self._project_controller.on_song_changed = self._on_song_changed
self._project_controller.on_samples_changed = self._sequencer_samples_logic.push_samples
self._project_controller.on_project_replaced = self._on_project_replaced
@@ -469,6 +665,10 @@ def _on_browser_collapse_changed(self, card_tag: str, collapsed: bool) -> None:
self._session_manager.set_card_collapsed(card_tag, collapsed)
self._sync_browser_width()
+ def _on_browser_favorites_filter_changed(self, panel_tag: str, favorites_only: bool) -> None:
+ """Persists the browser's favorites filter so it opens in the same mode on the next launch."""
+ self._session_manager.set_favorites_filter_active(panel_tag, favorites_only)
+
def sync_responsive_layout(self) -> None:
"""Refits this tab's side column to the current viewport, the entry the resize handler calls."""
self._sync_browser_width()
@@ -540,6 +740,11 @@ def _undoable(
receives, and ``coalesce`` computes the gesture's target key from them:
consecutive gestures sharing the same action and target collapse into a
single entry.
+
+ The gesture is batched inside its transaction, so however many rows it
+ writes, the panels rebuild once — and they rebuild before the entry that
+ undoes them is recorded, because the snapshot reads the project rather
+ than the views.
"""
def wrapped(
@@ -548,7 +753,14 @@ def wrapped(
) -> 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):
+ with (
+ self._history.transaction(
+ action,
+ detail=description,
+ coalesce=key,
+ ),
+ self._project_controller.batch(),
+ ):
callback(*args, **kwargs)
return wrapped
@@ -564,15 +776,26 @@ def _cell_key(
every channel column.
"""
channel = generator if generator is not None else ""
- return (self._sequencer_grid_logic.frame_index, channel, row_index)
+ return (self._sequencer_tracker_logic.frame_index, channel, row_index)
def _adjustment_key(
self,
- row_index: int,
- generator: Optional[GeneratorName],
+ region: TrackerRegion,
_delta: int,
) -> CoalesceKey:
- return self._cell_key(row_index, generator)
+ """Identifies the cells an adjustment covers as one coalescing target.
+
+ A streak of nudges over the same block reads as one entry, so holding a transpose key steps
+ the selection and leaves a single step to undo; moving the cursor or reaching the selection
+ out starts the next one.
+ """
+ return (
+ self._sequencer_tracker_logic.frame_index,
+ region.first_row,
+ region.last_row,
+ region.first_slot,
+ region.last_slot,
+ )
def _edit_row_key(
self,
@@ -599,6 +822,19 @@ def _module_setting_key(self, _value: int) -> CoalesceKey:
"""Marks a module-wide setting as one target, shared by its whole streak."""
return ()
+ def _on_settings_changed(
+ self,
+ view_model: SequencerSettingsViewModel,
+ ) -> None:
+ """Hands the project's song settings to the two panels that read them.
+
+ The module panel shows the timing fields themselves; the tracker reads the metre out of
+ the same view model, so a highlight edited in the project properties retints the grid as
+ soon as the dialog commits.
+ """
+ self._sequencer_module_panel.update_settings(view_model)
+ self._sequencer_tracker_panel.update_settings(view_model)
+
def _on_project_replaced(self) -> None:
"""Realigns the tab with a replaced project, keeping the mute set across history navigation.
@@ -615,7 +851,7 @@ def _on_project_replaced(self) -> None:
def play_from_current_frame(self) -> None:
"""Plays from the frame the tracker is showing, seeking in place when already playing."""
- self._on_order_play_from(self._sequencer_grid_logic.frame_index)
+ self._on_order_play_from(self._sequencer_tracker_logic.frame_index)
def undo(self) -> None:
self._history.undo()
@@ -653,7 +889,7 @@ def _build_history_view_model(self) -> HistoryViewModel:
entries = tuple(
HistoryEntryViewModel(
index=index,
- label=self._history_action_label(entry),
+ label=self._history_action_label(entry.action),
detail_segments=tuple(self._resolve_detail_segment(segment) for segment in entry.detail),
is_current=index == cursor,
is_future=index > cursor,
@@ -662,12 +898,12 @@ def _build_history_view_model(self) -> HistoryViewModel:
)
return HistoryViewModel(entries=entries, cursor=cursor)
- def _history_action_label(self, entry: HistoryEntry) -> str:
+ def _history_action_label(self, action: HistoryAction) -> str:
return self._language_manager[
Page.SEQUENCER,
Panel.HISTORY,
TextType.LABEL,
- SequencerHistoryActionElements(entry.action.value),
+ action,
]
def _resolve_detail_segment(
@@ -697,53 +933,100 @@ def initialize(self) -> None:
def refresh(self) -> None:
self._nes_frequency_change_acknowledged = False
self._song_player_logic.stop()
- self._sequencer_grid_logic.refresh()
+ self._sequencer_tracker_logic.refresh()
self._sequencer_order_logic.refresh()
self._sequencer_samples_logic.push_samples()
self._sequencer_channels_logic.push_channels()
is_open = self._project_controller.is_open
self._sequencer_module_panel.set_enabled(is_open)
- self._sequencer_grid_panel.set_enabled(is_open)
+ self._sequencer_tracker_panel.set_enabled(is_open)
self._sequencer_order_panel.set_enabled(is_open)
self._sequencer_history_panel.set_enabled(is_open)
+ def repaint(self) -> None:
+ """Draws every table again so its tints take the palette now in place.
+
+ DearPyGui keeps a table's row, column and cell tints as state of the table rather than
+ as a property of an item, so they take a new colour by being issued again. Each panel
+ answers for the tints it owns, and this is where the palette asks all three.
+ """
+ self._sequencer_tracker_panel.repaint()
+ self._sequencer_order_panel.repaint()
+ self._sequencer_samples_panel.repaint()
+
def refresh_browser(self) -> None:
self._sequencer_browser_panel.refresh()
+ def save_browser_shape(self) -> None:
+ """Writes down the rows the browser stands open, so a later run brings them back."""
+ self._session_manager.set_expanded_rows(
+ self._sequencer_browser_panel.tag,
+ self._sequencer_browser_panel.expanded_rows,
+ )
+
+ def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None:
+ self._sequencer_browser_panel.update_favorite_indicators(nodes)
+
def _on_song_changed(self) -> None:
- self._sequencer_grid_logic.push_settings()
- self._sequencer_grid_logic.push_grid()
+ self._sequencer_tracker_logic.push_settings()
+ self._sequencer_tracker_logic.push_tracker()
self._sequencer_order_logic.push_order()
def _on_player_error(self, error: Exception) -> None:
self._dialogs.show_error(error)
def _on_player_view_changed(self, view_model: SongPlayerViewModel) -> None:
+ """Settles the marks the transport owns, and how far the grid chases the playhead.
+
+ The player emits a view on every position update and on every change to the setting, so
+ reading the follow behaviour here keeps the grid in step both while a song sounds and the
+ moment the reader picks another mode.
+ """
+ self._sequencer_tracker_panel.set_row_following(view_model.follow_mode.follows_row)
if not view_model.is_playing and not view_model.is_paused:
- self._playing_order = None
- self._sequencer_grid_panel.set_playing_row(None)
- self._sequencer_order_panel.set_playing_position(None)
+ self._playing_position = None
+ self._mark_playhead()
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)
- if self._song_player_logic.follow_playback:
- self._sequencer_grid_logic.select_frame(order_position)
+ """Moves the marks the playhead carries, showing the frame it sounds when following.
+
+ The frame is selected ahead of the marks so the row's mark, and the scroll that reveals it,
+ land on the pattern the playhead has reached.
+ """
+ self._playing_position = SongPosition(
+ order_position=order_position,
+ row_index=row_index,
+ )
+ if self._song_player_logic.follow_mode.follows_pattern:
+ self._sequencer_tracker_logic.select_frame(order_position)
+
+ self._mark_playhead()
+
+ def _mark_playhead(self) -> None:
+ """Puts the playhead's marks where it stands, on both grids.
+
+ The order grid marks the frame the playhead sounds; the tracker takes the whole position,
+ since the row it marks belongs to the pattern of that frame.
+ """
+ position = self._playing_position
+ self._sequencer_tracker_panel.set_playing_position(position)
+ self._sequencer_order_panel.set_playing_position(
+ position.order_position if position is not None else None,
+ )
def _on_order_frame_selected(self, frame_index: int) -> None:
- """Selects an order frame in the grid, and moves the playhead too when following.
+ """Selects an order frame in the tracker, and moves the playhead too when following.
- With follow-playback on, choosing another order during playback relocates the playhead to
- it (the seek no-ops when stopped); with it off, the selection only changes which pattern is
- edited, leaving playback where it is.
+ While the view follows the playhead, choosing another order during playback relocates the
+ playhead to it (the seek no-ops when stopped); otherwise the selection only changes which
+ pattern is edited, leaving playback where it is.
"""
- self._sequencer_grid_logic.select_frame(frame_index)
- if self._song_player_logic.follow_playback:
+ self._sequencer_tracker_logic.select_frame(frame_index)
+ if self._song_player_logic.follow_mode.follows_pattern:
self._song_player_logic.seek(frame_index)
def _on_preview_error(self, exception: Exception) -> None:
@@ -836,7 +1119,7 @@ def _reconcile_nes_frequency(
itself brings in, which settles a mismatch without asking.
"""
reconstruction_frequency = reconstruction.config.nes_frequency
- project_frequency = self._sequencer_grid_logic.settings.nes_frequency
+ project_frequency = self._sequencer_tracker_logic.settings.nes_frequency
if reconstruction_frequency == project_frequency:
commit(None)
@@ -874,7 +1157,7 @@ def _commit_add_reconstruction(
detail=self._history_detail.add_sample(name),
):
if adopt_frequency is not None:
- self._sequencer_grid_logic.set_nes_frequency(adopt_frequency)
+ self._sequencer_tracker_logic.set_nes_frequency(adopt_frequency)
self._sequencer_browser_logic.add_reconstruction(reconstruction, name)
self._on_tab_switch(Tab.SEQUENCER)
@@ -932,7 +1215,7 @@ def _commit_replace_reconstruction(
detail=detail,
):
if adopt_frequency is not None:
- self._sequencer_grid_logic.set_nes_frequency(adopt_frequency)
+ self._sequencer_tracker_logic.set_nes_frequency(adopt_frequency)
self._sequencer_samples_logic.rename_sample(sample_id, name)
self._on_sample_reconstruction_replaced(sample_id, reconstruction)
@@ -949,142 +1232,22 @@ def _replace_target_label(self) -> Optional[str]:
def _dispatch_edit_sample(self, sample_id: str) -> None:
self._on_edit_sample_requested(sample_id)
- def _on_clear_row(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- ) -> None:
- if generator is None:
- self._sequencer_grid_logic.clear_all_generators(row_index)
- else:
- self._sequencer_grid_logic.clear_row(generator, row_index)
-
- def _on_clear_subcolumn(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- subcolumn: SubColumn,
- ) -> None:
- instrument = subcolumn is SubColumn.INSTRUMENT
- transpose = subcolumn is SubColumn.TRANSPOSE
- volume = subcolumn is SubColumn.VOLUME
- if generator is None:
- if instrument:
- self._sequencer_grid_logic.clear_subcolumn_all_generators(
- row_index,
- instrument=True,
- )
- else:
- self._sequencer_grid_logic.clear_sample_subcolumn(
- row_index,
- transpose=transpose,
- volume=volume,
- )
- else:
- self._sequencer_grid_logic.clear_subcolumn(
- generator,
- row_index,
- instrument=instrument,
- transpose=transpose,
- volume=volume,
- )
-
- def _on_set_row(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- sample_id: Optional[str],
- transpose: Optional[int],
- volume: Optional[int],
- ) -> None:
- if generator is None:
- if sample_id is not None:
- self._sequencer_grid_logic.set_sample_instrument(
- row_index,
- sample_id,
- )
- elif transpose is not None or volume is not None:
- self._sequencer_grid_logic.set_sample_subcolumn(
- row_index,
- transpose=transpose,
- volume=volume,
- )
- else:
- command = (
- Instrument(
- sample_id=sample_id,
- generator_name=generator,
- )
- if sample_id is not None
- else None
- )
- self._sequencer_grid_logic.set_row(
- generator,
- row_index,
- command=command,
- transpose=transpose,
- volume=volume,
- )
-
- def _on_set_note_off(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- ) -> None:
- """Writes a note-off: to one channel, or across every channel from the sample column."""
- if generator is None:
- self._sequencer_grid_logic.set_note_off_all_generators(row_index)
- else:
- self._sequencer_grid_logic.set_note_off(generator, row_index)
-
- def _on_grid_play_from_row(self, row_index: int) -> None:
- """Starts playback from the right-clicked row of the frame the grid is showing."""
+ def _on_tracker_play_from_row(self, row_index: int) -> None:
+ """Starts playback from the right-clicked row of the frame the tracker is showing."""
self._song_player_logic.play_from(
- self._sequencer_grid_logic.frame_index,
+ self._sequencer_tracker_logic.frame_index,
row_index,
)
- def _on_adjust_transpose(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- delta: int,
- ) -> None:
- """Shifts transpose: one channel, or across the sample column's channels."""
- if generator is None:
- self._sequencer_grid_logic.adjust_sample_transpose(row_index, delta)
- else:
- self._sequencer_grid_logic.adjust_transpose(
- generator,
- row_index,
- delta,
- )
-
- def _on_adjust_volume(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- delta: int,
- ) -> None:
- """Shifts volume: one channel, or across the sample column's channels."""
- if generator is None:
- self._sequencer_grid_logic.adjust_sample_volume(row_index, delta)
- else:
- self._sequencer_grid_logic.adjust_volume(
- generator,
- row_index,
- delta,
- )
-
def _on_samples_changed(
self,
view_model: SequencerSamplesViewModel,
) -> None:
self._sequencer_samples_panel.update_view(view_model)
- self._sequencer_grid_panel.update_samples(view_model)
+ self._sequencer_tracker_panel.update_samples(view_model)
def _on_sample_selected(self, sample_id: str) -> None:
- self._sequencer_grid_panel.deselect_cell()
+ self._sequencer_tracker_panel.deselect_cell()
self._sequencer_order_panel.deselect_cell()
self._sequencer_samples_logic.request_autoplay(sample_id)
logger.debug(f"Sequencer sample selected: {sample_id}")
@@ -1137,7 +1300,7 @@ def _request_nes_frequency_change(self, nes_frequency: int) -> None:
holds samples prompts once (until acknowledged for the session); an empty or acknowledged
project applies silently. Cancelling restores the field to the project's current value.
"""
- if nes_frequency == self._sequencer_grid_logic.settings.nes_frequency:
+ if nes_frequency == self._sequencer_tracker_logic.settings.nes_frequency:
return
if self._nes_frequency_change_acknowledged or not self._project_controller.has_samples:
@@ -1152,7 +1315,7 @@ def _request_nes_frequency_change(self, nes_frequency: int) -> None:
ok_label=self._language_manager["global.dialog.label.change_and_retune"],
opt_out_label=self._language_manager["global.dialog.label.dont_ask_again"],
on_opt_out=self._acknowledge_nes_frequency_changes,
- on_cancel=self._sequencer_grid_logic.push_settings,
+ on_cancel=self._sequencer_tracker_logic.push_settings,
)
def _perform_nes_frequency_change(self, nes_frequency: int) -> None:
@@ -1168,7 +1331,7 @@ def _perform_nes_frequency_change(self, nes_frequency: int) -> None:
detail=self._history_detail.value(nes_frequency),
coalesce=(nes_frequency,),
):
- self._sequencer_grid_logic.set_nes_frequency(nes_frequency)
+ self._sequencer_tracker_logic.set_nes_frequency(nes_frequency)
self._on_nes_frequency_changed(nes_frequency)
@@ -1192,13 +1355,26 @@ def _on_order_remove(self, position: int) -> None:
def _on_order_duplicate(self, position: int) -> None:
self._sequencer_order_logic.duplicate_frame(position)
+ self._settle_inserted_frame(position + 1)
+
+ def _on_order_clone(self, position: int) -> None:
+ self._sequencer_order_logic.clone_frame(position)
+ self._settle_inserted_frame(position + 1)
+
+ def _settle_inserted_frame(self, position: int) -> None:
+ """Carries the playhead and the shown frame over a frame that has just been inserted.
+
+ A frame arriving at ``position`` pushes every later frame one along, so a playhead
+ standing on one of them follows it, and the grid moves to the new frame for the reader
+ to work on.
+ """
self._relocate_playhead(
lambda playhead: remap_after_insert(
playhead,
- position + 1,
+ position,
)
)
- self._select_frame_when_idle(position + 1)
+ self._select_frame_when_idle(position)
def _on_order_insert(self, position: int) -> None:
self._sequencer_order_logic.insert_frame(position + 1)
@@ -1226,7 +1402,7 @@ def _on_order_move(self, from_position: int, to_position: int) -> None:
to_position,
)
)
- self._sequencer_grid_logic.select_frame(to_position)
+ self._sequencer_tracker_logic.select_frame(to_position)
def _on_order_play_from(self, position: int) -> None:
"""Plays from a frame: relocates the playhead when already playing, else starts there."""
@@ -1238,31 +1414,31 @@ def _on_order_play_from(self, position: int) -> None:
def _relocate_playhead(self, remap: Callable[[int], int]) -> None:
"""Keeps the live playhead on the frame it was sounding after a structural order edit.
- The new position is reflected in the playing highlight straight away, ahead of the worker's
- next row update, so rapid edits (e.g. a held Alt+arrow) stay in step.
+ Both grids take the new position straight away, ahead of the worker's next row update, so
+ rapid edits (e.g. a held Alt+arrow) stay in step, and a paused playhead — which reports no
+ further rows — is marked on the frame the edit moved it to.
"""
- if self._playing_order is None:
+ if self._playing_position is None:
return
- new_order = remap(self._playing_order)
- if new_order == self._playing_order:
+ order_position = remap(self._playing_position.order_position)
+ if order_position == self._playing_position.order_position:
return
- self._playing_order = new_order
- self._song_player_logic.relocate(new_order)
- self._sequencer_order_panel.set_playing_position(new_order)
+ self._playing_position = SongPosition(
+ order_position=order_position,
+ row_index=self._playing_position.row_index,
+ )
+ self._song_player_logic.relocate(order_position)
+ self._mark_playhead()
def _select_frame_when_idle(self, frame_index: int) -> None:
"""Moves the editor selection to a frame, unless playback is actively driving it."""
if not self._song_player_logic.is_playing():
- self._sequencer_grid_logic.select_frame(frame_index)
+ self._sequencer_tracker_logic.select_frame(frame_index)
- def _on_tracker_cell_focused(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- ) -> None:
- """Drops the order cursor and sample selection when the tracker grid takes focus.
+ def _on_tracker_cell_focused(self) -> None:
+ """Drops the order cursor and sample selection when the tracker tracker takes focus.
The tracker, order, and samples panels each register a key-router scope active only while
it holds a selection; keeping a single selection across the three lets only the focused
@@ -1272,8 +1448,8 @@ def _on_tracker_cell_focused(
self._sequencer_samples_panel.deselect()
def _on_order_cell_focused(self) -> None:
- """Drops the tracker cursor and sample selection when the order grid takes focus."""
- self._sequencer_grid_panel.deselect_cell()
+ """Drops the tracker cursor and sample selection when the order tracker takes focus."""
+ self._sequencer_tracker_panel.deselect_cell()
self._sequencer_samples_panel.deselect()
def create_tab(self) -> None:
@@ -1314,10 +1490,10 @@ def create_tab(self) -> None:
self._sync_browser_width()
def _build_center_column(self, parent: str) -> None:
- """Stacks the order table and tracker grid down the centre column."""
+ """Stacks the order table and tracker tracker down the centre column."""
self._sequencer_order_panel.create_panel(parent)
dpg.add_spacer(height=self._geometry.panel_gap, parent=parent)
- self._sequencer_grid_panel.create_panel(parent)
+ self._sequencer_tracker_panel.create_panel(parent)
def _build_right_column(self, parent: str) -> None:
"""Stacks the module settings, samples, and history cards in the right column."""
@@ -1331,3 +1507,16 @@ def _build_right_column(self, parent: str) -> None:
@property
def player(self) -> AudioPlayerProtocol:
return self._guarded_player
+
+ @property
+ def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]:
+ """The panels offering editing gestures on what they hold selected.
+
+ The three hold one selection between them — a cursor in either grid, a row in the samples
+ list — so the menu bar reaches whichever one has it.
+ """
+ return (
+ self._sequencer_tracker_panel.edit_surface,
+ self._sequencer_order_panel.edit_surface,
+ self._sequencer_samples_panel,
+ )
diff --git a/src/sampletones_application/layout/behavior.py b/src/sampletones_application/layout/behavior.py
deleted file mode 100644
index f61cd1bc..00000000
--- a/src/sampletones_application/layout/behavior.py
+++ /dev/null
@@ -1,41 +0,0 @@
-from pydantic import BaseModel, Field
-
-
-class SchedulingDelays(BaseModel, extra="forbid", frozen=True):
- schedule: int
- reconstruction_update: int
- cancel: int
-
-
-class SchedulingPriorities(BaseModel, extra="forbid", frozen=True):
- update_status: int
- gui_action: int
- schedule: int
-
-
-class SchedulingEmit(BaseModel, extra="forbid", frozen=True):
- priority: int
- batch_size: int
-
-
-class SchedulingBehavior(BaseModel, extra="forbid", frozen=True):
- delays: SchedulingDelays
- priorities: SchedulingPriorities
- emit: SchedulingEmit
- queue_budget_seconds: float
-
-
-class UiBehavior(BaseModel, extra="forbid", frozen=True):
- status_bar_display_time: float
-
-
-class MainBehavior(BaseModel, extra="forbid", frozen=True):
- fps_update_interval: float
- vsync: bool
- max_fps: int = Field(ge=0)
-
-
-class BehaviorConfig(BaseModel, extra="forbid", frozen=True):
- scheduling: SchedulingBehavior
- ui: UiBehavior
- main: MainBehavior
diff --git a/src/sampletones_application/layout/behavior/__init__.py b/src/sampletones_application/layout/behavior/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/layout/behavior/behavior.py b/src/sampletones_application/layout/behavior/behavior.py
new file mode 100644
index 00000000..c374d2d8
--- /dev/null
+++ b/src/sampletones_application/layout/behavior/behavior.py
@@ -0,0 +1,11 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.behavior.display import DisplayBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
+from sampletones_application.layout.behavior.ui import UIBehavior
+
+
+class BehaviorConfig(BaseModel, extra="forbid", frozen=True):
+ scheduling: SchedulingBehavior
+ ui: UIBehavior
+ display: DisplayBehavior
diff --git a/src/sampletones_application/layout/behavior/display.py b/src/sampletones_application/layout/behavior/display.py
new file mode 100644
index 00000000..7cf821a5
--- /dev/null
+++ b/src/sampletones_application/layout/behavior/display.py
@@ -0,0 +1,42 @@
+from typing import Tuple
+
+from pydantic import BaseModel, Field, field_validator
+
+from sampletones_shared.display import Resolution
+
+
+class DisplayBehavior(BaseModel, extra="forbid", frozen=True):
+ """What the display settings offer: the window sizes and the frame rates a user picks from,
+ and how long a window mode nobody confirms stays on screen.
+
+ Each list is offered in the order it is written, so a combo shows the entries as the file
+ declares them and a selection maps to its position. A list holds each entry once, in
+ ascending order, which the load checks so a mistake in the file surfaces at startup.
+ """
+
+ resolutions: Tuple[Resolution, ...] = Field(min_length=1)
+ frame_rates: Tuple[int, ...] = Field(min_length=1)
+ revert_countdown_seconds: float = Field(gt=0.0)
+
+ @field_validator("resolutions")
+ @classmethod
+ def _validate_resolutions(
+ cls,
+ resolutions: Tuple[Resolution, ...],
+ ) -> Tuple[Resolution, ...]:
+ sizes = [(resolution.width, resolution.height) for resolution in resolutions]
+ if sizes != sorted(set(sizes)):
+ raise ValueError("Offered resolutions must be listed once each, in ascending order")
+
+ return resolutions
+
+ @field_validator("frame_rates")
+ @classmethod
+ def _validate_frame_rates(
+ cls,
+ frame_rates: Tuple[int, ...],
+ ) -> Tuple[int, ...]:
+ if list(frame_rates) != sorted(set(frame_rates)):
+ raise ValueError("Offered frame rates must be listed once each, in ascending order")
+
+ return frame_rates
diff --git a/src/sampletones_application/layout/behavior/scheduling/__init__.py b/src/sampletones_application/layout/behavior/scheduling/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/layout/behavior/scheduling/delays.py b/src/sampletones_application/layout/behavior/scheduling/delays.py
new file mode 100644
index 00000000..7a25eb70
--- /dev/null
+++ b/src/sampletones_application/layout/behavior/scheduling/delays.py
@@ -0,0 +1,7 @@
+from pydantic import BaseModel
+
+
+class SchedulingDelays(BaseModel, extra="forbid", frozen=True):
+ schedule: int
+ reconstruction_update: int
+ cancel: int
diff --git a/src/sampletones_application/layout/behavior/scheduling/emit.py b/src/sampletones_application/layout/behavior/scheduling/emit.py
new file mode 100644
index 00000000..fb06eeff
--- /dev/null
+++ b/src/sampletones_application/layout/behavior/scheduling/emit.py
@@ -0,0 +1,6 @@
+from pydantic import BaseModel
+
+
+class SchedulingEmit(BaseModel, extra="forbid", frozen=True):
+ priority: int
+ batch_size: int
diff --git a/src/sampletones_application/layout/behavior/scheduling/priorities.py b/src/sampletones_application/layout/behavior/scheduling/priorities.py
new file mode 100644
index 00000000..915daeb5
--- /dev/null
+++ b/src/sampletones_application/layout/behavior/scheduling/priorities.py
@@ -0,0 +1,7 @@
+from pydantic import BaseModel
+
+
+class SchedulingPriorities(BaseModel, extra="forbid", frozen=True):
+ update_status: int
+ gui_action: int
+ schedule: int
diff --git a/src/sampletones_application/layout/behavior/scheduling/scheduling.py b/src/sampletones_application/layout/behavior/scheduling/scheduling.py
new file mode 100644
index 00000000..9f55455a
--- /dev/null
+++ b/src/sampletones_application/layout/behavior/scheduling/scheduling.py
@@ -0,0 +1,12 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.behavior.scheduling.delays import SchedulingDelays
+from sampletones_application.layout.behavior.scheduling.emit import SchedulingEmit
+from sampletones_application.layout.behavior.scheduling.priorities import SchedulingPriorities
+
+
+class SchedulingBehavior(BaseModel, extra="forbid", frozen=True):
+ delays: SchedulingDelays
+ priorities: SchedulingPriorities
+ emit: SchedulingEmit
+ queue_budget_seconds: float
diff --git a/src/sampletones_application/layout/behavior/ui.py b/src/sampletones_application/layout/behavior/ui.py
new file mode 100644
index 00000000..d6754a97
--- /dev/null
+++ b/src/sampletones_application/layout/behavior/ui.py
@@ -0,0 +1,6 @@
+from pydantic import BaseModel
+
+
+class UIBehavior(BaseModel, extra="forbid", frozen=True):
+ status_bar_display_time: float
+ fps_update_interval: float
diff --git a/src/sampletones_application/layout/config.py b/src/sampletones_application/layout/config.py
index d2cc0a99..4d586e72 100644
--- a/src/sampletones_application/layout/config.py
+++ b/src/sampletones_application/layout/config.py
@@ -1,9 +1,9 @@
from pydantic import BaseModel
-from sampletones_application.layout.behavior import BehaviorConfig
+from sampletones_application.layout.behavior.behavior import BehaviorConfig
from sampletones_application.layout.fonts import FontsLayout
from sampletones_application.layout.general import GeneralLayout
-from sampletones_application.layout.glyphs import Glyphs
+from sampletones_application.layout.glyphs.glyphs import Glyphs
from sampletones_application.layout.graphs import GraphsLayout
from sampletones_application.layout.player import PlayerLayout
from sampletones_application.layout.project_properties import ProjectPropertiesLayout
diff --git a/src/sampletones_application/layout/fonts.py b/src/sampletones_application/layout/fonts.py
index 14c90435..7c5f4410 100644
--- a/src/sampletones_application/layout/fonts.py
+++ b/src/sampletones_application/layout/fonts.py
@@ -13,27 +13,31 @@ class Step(Enum):
SMALL = "small"
MEDIUM = "medium"
LARGE = "large"
+ TITLE = "title"
class FontScale(BaseModel, extra="forbid", frozen=True):
small: int
medium: int
large: int
+ title: int
def step(self, step: Step) -> int:
return {
Step.SMALL: self.small,
Step.MEDIUM: self.medium,
Step.LARGE: self.large,
+ Step.TITLE: self.title,
}[step]
class FontsLayout(BaseModel, extra="forbid", frozen=True):
"""Per-typeface pixel-size scales for every rendered font.
- Each typeface carries its own ``small``/``medium``/``large`` scale, so Sans and
- Mono are tuned to the same apparent size independently. ``scale`` is the DearPyGui
- global font multiplier applied on top.
+ Each typeface carries its own ``small``/``medium``/``large``/``title`` scale, so Sans
+ and Mono are tuned to the same apparent size independently, and a rung is drawn at
+ where a font asks for it. ``scale`` is the DearPyGui global font multiplier applied
+ on top.
"""
scale: int
diff --git a/src/sampletones_application/layout/general/__init__.py b/src/sampletones_application/layout/general/__init__.py
index a0c943e3..4c793068 100644
--- a/src/sampletones_application/layout/general/__init__.py
+++ b/src/sampletones_application/layout/general/__init__.py
@@ -3,9 +3,9 @@
from sampletones_application.layout.general.buttons import ButtonsLayout
from sampletones_application.layout.general.caret import CaretLayout
from sampletones_application.layout.general.collapse import CollapseLayout
-from sampletones_application.layout.general.colors import GeneralColors
+from sampletones_application.layout.general.colors.colors import GeneralColors
from sampletones_application.layout.general.columns import ColumnsLayout
-from sampletones_application.layout.general.dialogs import DialogsLayout
+from sampletones_application.layout.general.dialogs.dialogs import DialogsLayout
from sampletones_application.layout.general.inputs import InputsLayout
from sampletones_application.layout.general.pitch_stepper import PitchStepperLayout
from sampletones_application.layout.general.plus_minus_buttons import PlusMinusButtonsLayout
diff --git a/src/sampletones_application/layout/general/caret.py b/src/sampletones_application/layout/general/caret.py
index 72571ea7..4e9bb687 100644
--- a/src/sampletones_application/layout/general/caret.py
+++ b/src/sampletones_application/layout/general/caret.py
@@ -1,10 +1,10 @@
from pydantic import BaseModel
-from sampletones_application.utils.palette import PaletteColor
+from sampletones_application.utils.palette.colors.written import WrittenColor
class CaretLayout(BaseModel, extra="forbid", frozen=True):
offset: int
width_padding: int
- fill: PaletteColor
- border: PaletteColor
+ fill: WrittenColor
+ border: WrittenColor
diff --git a/src/sampletones_application/layout/general/colors.py b/src/sampletones_application/layout/general/colors.py
deleted file mode 100644
index c9ade8b4..00000000
--- a/src/sampletones_application/layout/general/colors.py
+++ /dev/null
@@ -1,53 +0,0 @@
-from pydantic import BaseModel
-
-from sampletones_application.utils.palette import PaletteColor
-
-
-class TextColors(BaseModel, extra="forbid", frozen=True):
- default: PaletteColor
- disabled: PaletteColor
- error: PaletteColor
- highlight: PaletteColor
-
-
-class FavoriteColors(BaseModel, extra="forbid", frozen=True):
- default: PaletteColor
- child: PaletteColor
-
-
-class TableColors(BaseModel, extra="forbid", frozen=True):
- label: PaletteColor
- value: PaletteColor
-
-
-class PathColors(BaseModel, extra="forbid", frozen=True):
- default: PaletteColor
- hover: PaletteColor
-
-
-class HeaderColors(BaseModel, extra="forbid", frozen=True):
- library: PaletteColor
- reconstruction: PaletteColor
-
-
-class FeatureColors(BaseModel, extra="forbid", frozen=True):
- """The per-feature palette shared by every view that names a feature.
-
- The details tab's bar plots and the history panel's detail segments both
- paint from this block, so a feature keeps one colour across the
- application.
- """
-
- volume: PaletteColor
- arpeggio: PaletteColor
- pitch: PaletteColor
- duty_cycle: PaletteColor
-
-
-class GeneralColors(BaseModel, extra="forbid", frozen=True):
- text: TextColors
- favorites: FavoriteColors
- tables: TableColors
- paths: PathColors
- headers: HeaderColors
- features: FeatureColors
diff --git a/src/sampletones_application/layout/general/colors/__init__.py b/src/sampletones_application/layout/general/colors/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/layout/general/colors/colors.py b/src/sampletones_application/layout/general/colors/colors.py
new file mode 100644
index 00000000..41208d3a
--- /dev/null
+++ b/src/sampletones_application/layout/general/colors/colors.py
@@ -0,0 +1,17 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.general.colors.favorite import FavoriteColors
+from sampletones_application.layout.general.colors.feature import FeatureColors
+from sampletones_application.layout.general.colors.header import HeaderColors
+from sampletones_application.layout.general.colors.path import PathColors
+from sampletones_application.layout.general.colors.table import TableColors
+from sampletones_application.layout.general.colors.text import TextColors
+
+
+class GeneralColors(BaseModel, extra="forbid", frozen=True):
+ text: TextColors
+ favorites: FavoriteColors
+ tables: TableColors
+ paths: PathColors
+ headers: HeaderColors
+ features: FeatureColors
diff --git a/src/sampletones_application/layout/general/colors/favorite.py b/src/sampletones_application/layout/general/colors/favorite.py
new file mode 100644
index 00000000..16ca858f
--- /dev/null
+++ b/src/sampletones_application/layout/general/colors/favorite.py
@@ -0,0 +1,8 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class FavoriteColors(BaseModel, extra="forbid", frozen=True):
+ default: WrittenColor
+ child: WrittenColor
diff --git a/src/sampletones_application/layout/general/colors/feature.py b/src/sampletones_application/layout/general/colors/feature.py
new file mode 100644
index 00000000..e8780ee5
--- /dev/null
+++ b/src/sampletones_application/layout/general/colors/feature.py
@@ -0,0 +1,17 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class FeatureColors(BaseModel, extra="forbid", frozen=True):
+ """The per-feature palette shared by every view that names a feature.
+
+ The details tab's bar plots and the history panel's detail segments both
+ paint from this block, so a feature keeps one colour across the
+ application.
+ """
+
+ volume: WrittenColor
+ arpeggio: WrittenColor
+ pitch: WrittenColor
+ duty_cycle: WrittenColor
diff --git a/src/sampletones_application/layout/general/colors/header.py b/src/sampletones_application/layout/general/colors/header.py
new file mode 100644
index 00000000..578ac325
--- /dev/null
+++ b/src/sampletones_application/layout/general/colors/header.py
@@ -0,0 +1,8 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class HeaderColors(BaseModel, extra="forbid", frozen=True):
+ library: WrittenColor
+ reconstruction: WrittenColor
diff --git a/src/sampletones_application/layout/general/colors/path.py b/src/sampletones_application/layout/general/colors/path.py
new file mode 100644
index 00000000..d38e6051
--- /dev/null
+++ b/src/sampletones_application/layout/general/colors/path.py
@@ -0,0 +1,8 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class PathColors(BaseModel, extra="forbid", frozen=True):
+ default: WrittenColor
+ hover: WrittenColor
diff --git a/src/sampletones_application/layout/general/colors/table.py b/src/sampletones_application/layout/general/colors/table.py
new file mode 100644
index 00000000..88727b1a
--- /dev/null
+++ b/src/sampletones_application/layout/general/colors/table.py
@@ -0,0 +1,8 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class TableColors(BaseModel, extra="forbid", frozen=True):
+ label: WrittenColor
+ value: WrittenColor
diff --git a/src/sampletones_application/layout/general/colors/text.py b/src/sampletones_application/layout/general/colors/text.py
new file mode 100644
index 00000000..f700365a
--- /dev/null
+++ b/src/sampletones_application/layout/general/colors/text.py
@@ -0,0 +1,10 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class TextColors(BaseModel, extra="forbid", frozen=True):
+ default: WrittenColor
+ disabled: WrittenColor
+ error: WrittenColor
+ highlight: WrittenColor
diff --git a/src/sampletones_application/layout/general/dialogs/__init__.py b/src/sampletones_application/layout/general/dialogs/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/layout/general/dialogs/about.py b/src/sampletones_application/layout/general/dialogs/about.py
new file mode 100644
index 00000000..c7cf36db
--- /dev/null
+++ b/src/sampletones_application/layout/general/dialogs/about.py
@@ -0,0 +1,15 @@
+from pydantic import BaseModel
+
+
+class AboutDialogLayout(BaseModel, extra="forbid", frozen=True):
+ """The About dialog's size, the size its mark is drawn at, and the room left around the mark."""
+
+ width: int
+ height: int
+ logo: int
+ padding: int
+
+ @property
+ def text_wrap(self) -> int:
+ """Width the text standing beside the mark wraps at."""
+ return self.width - self.logo - self.padding
diff --git a/src/sampletones_application/layout/general/dialogs.py b/src/sampletones_application/layout/general/dialogs/dialogs.py
similarity index 62%
rename from src/sampletones_application/layout/general/dialogs.py
rename to src/sampletones_application/layout/general/dialogs/dialogs.py
index d1270e1b..3d1d8ebf 100644
--- a/src/sampletones_application/layout/general/dialogs.py
+++ b/src/sampletones_application/layout/general/dialogs/dialogs.py
@@ -1,12 +1,10 @@
from pydantic import BaseModel
+from sampletones_application.layout.general.dialogs.about import AboutDialogLayout
+from sampletones_application.layout.general.dialogs.height import DialogSizeNoWidth
from sampletones_application.layout.primitives import Dimensions
-class DialogSizeNoWidth(BaseModel, extra="forbid", frozen=True):
- height: int
-
-
class DialogsLayout(BaseModel, extra="forbid", frozen=True):
default: Dimensions
error: Dimensions
@@ -14,3 +12,4 @@ class DialogsLayout(BaseModel, extra="forbid", frozen=True):
confirmation: DialogSizeNoWidth
text_input: DialogSizeNoWidth
traceback: Dimensions
+ about: AboutDialogLayout
diff --git a/src/sampletones_application/layout/general/dialogs/height.py b/src/sampletones_application/layout/general/dialogs/height.py
new file mode 100644
index 00000000..2a4bf449
--- /dev/null
+++ b/src/sampletones_application/layout/general/dialogs/height.py
@@ -0,0 +1,5 @@
+from pydantic import BaseModel
+
+
+class DialogSizeNoWidth(BaseModel, extra="forbid", frozen=True):
+ height: int
diff --git a/src/sampletones_application/layout/general/responsive.py b/src/sampletones_application/layout/general/responsive.py
index 138f8b83..fc632ed3 100644
--- a/src/sampletones_application/layout/general/responsive.py
+++ b/src/sampletones_application/layout/general/responsive.py
@@ -8,10 +8,10 @@ class ResponsiveLayout(BaseModel, extra="forbid", frozen=True):
dimensions at which the side columns sit at their configured widths and the stacked
graphs at their configured heights. Surplus above either baseline is shared out — width
widens the side columns (``expanded_side_width``), height grows the graph stack
- (``stacked_graph_height``). ``max_stack_height`` caps the combined height that a
- vertical graph stack grows to before the surplus is left free.
+ (``stacked_graph_height``). ``max_graph_height`` is the tallest a single stacked graph
+ grows to, from where the surplus is left free.
"""
baseline_viewport_width: int
baseline_viewport_height: int
- max_stack_height: int
+ max_graph_height: int
diff --git a/src/sampletones_application/layout/general/section_header.py b/src/sampletones_application/layout/general/section_header.py
index 5cd74612..0ed4731d 100644
--- a/src/sampletones_application/layout/general/section_header.py
+++ b/src/sampletones_application/layout/general/section_header.py
@@ -1,6 +1,6 @@
from pydantic import BaseModel
-from sampletones_application.layout.glyphs import GlyphLayout
+from sampletones_application.layout.glyphs.glyph import GlyphLayout
class SectionHeaderLayout(BaseModel, extra="forbid", frozen=True):
diff --git a/src/sampletones_application/layout/general/window.py b/src/sampletones_application/layout/general/window.py
index 8f3d9f36..71f38219 100644
--- a/src/sampletones_application/layout/general/window.py
+++ b/src/sampletones_application/layout/general/window.py
@@ -1,4 +1,6 @@
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
+
+from sampletones_shared.display import Resolution
class WindowLayout(BaseModel, extra="forbid", frozen=True):
@@ -8,3 +10,5 @@ class WindowLayout(BaseModel, extra="forbid", frozen=True):
min_height: int
position_x: int
fullscreen: bool
+ max_monitor_ratio: float = Field(gt=0.0, le=1.0)
+ fallback_monitor: Resolution
diff --git a/src/sampletones_application/layout/glyphs.py b/src/sampletones_application/layout/glyphs.py
deleted file mode 100644
index 969996aa..00000000
--- a/src/sampletones_application/layout/glyphs.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from pydantic import BaseModel
-
-
-class CommonGlyphs(BaseModel, extra="forbid", frozen=True):
- tick: str
- favorite: str
- expanded: str
- collapsed: str
- chevron_left: str
- chevron_right: str
-
-
-class HeaderGlyphs(BaseModel, extra="forbid", frozen=True):
- waveform: str
- spectrum: str
- reconstruction: str
- converter: str
- settings: str
- advanced: str
- filesystem: str
- instruction_data: str
- details: str
- parameters: str
- source: str
- instruments: str
- samples: str
- tracker: str
- order: str
- history: str
-
-
-class PlayerGlyphs(BaseModel, extra="forbid", frozen=True):
- play: str
- pause: str
- resume: str
- stop: str
-
-
-class Glyphs(BaseModel, extra="forbid", frozen=True):
- common: CommonGlyphs
- headers: HeaderGlyphs
- player: PlayerGlyphs
-
-
-class GlyphLayout(BaseModel, extra="forbid", frozen=True):
- indent: int
- width: int
- top_offset: int
diff --git a/src/sampletones_application/layout/glyphs/__init__.py b/src/sampletones_application/layout/glyphs/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/layout/glyphs/common.py b/src/sampletones_application/layout/glyphs/common.py
new file mode 100644
index 00000000..10fddf7b
--- /dev/null
+++ b/src/sampletones_application/layout/glyphs/common.py
@@ -0,0 +1,10 @@
+from pydantic import BaseModel
+
+
+class CommonGlyphs(BaseModel, extra="forbid", frozen=True):
+ tick: str
+ favorite: str
+ expanded: str
+ collapsed: str
+ chevron_left: str
+ chevron_right: str
diff --git a/src/sampletones_application/layout/glyphs/glyph.py b/src/sampletones_application/layout/glyphs/glyph.py
new file mode 100644
index 00000000..bdbb2623
--- /dev/null
+++ b/src/sampletones_application/layout/glyphs/glyph.py
@@ -0,0 +1,7 @@
+from pydantic import BaseModel
+
+
+class GlyphLayout(BaseModel, extra="forbid", frozen=True):
+ indent: int
+ width: int
+ top_offset: int
diff --git a/src/sampletones_application/layout/glyphs/glyphs.py b/src/sampletones_application/layout/glyphs/glyphs.py
new file mode 100644
index 00000000..f54ee443
--- /dev/null
+++ b/src/sampletones_application/layout/glyphs/glyphs.py
@@ -0,0 +1,11 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.glyphs.common import CommonGlyphs
+from sampletones_application.layout.glyphs.header import HeaderGlyphs
+from sampletones_application.layout.glyphs.player import PlayerGlyphs
+
+
+class Glyphs(BaseModel, extra="forbid", frozen=True):
+ common: CommonGlyphs
+ headers: HeaderGlyphs
+ player: PlayerGlyphs
diff --git a/src/sampletones_application/layout/glyphs/header.py b/src/sampletones_application/layout/glyphs/header.py
new file mode 100644
index 00000000..660fbbbd
--- /dev/null
+++ b/src/sampletones_application/layout/glyphs/header.py
@@ -0,0 +1,20 @@
+from pydantic import BaseModel
+
+
+class HeaderGlyphs(BaseModel, extra="forbid", frozen=True):
+ waveform: str
+ spectrum: str
+ reconstruction: str
+ converter: str
+ settings: str
+ advanced: str
+ filesystem: str
+ instruction_data: str
+ details: str
+ parameters: str
+ source: str
+ instruments: str
+ samples: str
+ tracker: str
+ order: str
+ history: str
diff --git a/src/sampletones_application/layout/glyphs/player.py b/src/sampletones_application/layout/glyphs/player.py
new file mode 100644
index 00000000..5afa25fc
--- /dev/null
+++ b/src/sampletones_application/layout/glyphs/player.py
@@ -0,0 +1,8 @@
+from pydantic import BaseModel
+
+
+class PlayerGlyphs(BaseModel, extra="forbid", frozen=True):
+ play: str
+ pause: str
+ resume: str
+ stop: str
diff --git a/src/sampletones_application/layout/graphs/colors.py b/src/sampletones_application/layout/graphs/colors.py
index 03da2b2a..c5df1915 100644
--- a/src/sampletones_application/layout/graphs/colors.py
+++ b/src/sampletones_application/layout/graphs/colors.py
@@ -1,9 +1,9 @@
from pydantic import BaseModel
-from sampletones_application.utils.palette import PaletteColor
+from sampletones_application.utils.palette.colors.written import WrittenColor
class GraphColors(BaseModel, extra="forbid", frozen=True):
- bar_plot: PaletteColor
- waveform_sample: PaletteColor
- waveform_reconstruction: PaletteColor
+ bar_plot: WrittenColor
+ waveform_sample: WrittenColor
+ waveform_reconstruction: WrittenColor
diff --git a/src/sampletones_application/layout/graphs/spectrum.py b/src/sampletones_application/layout/graphs/spectrum.py
index 1d5760e0..6757fdf3 100644
--- a/src/sampletones_application/layout/graphs/spectrum.py
+++ b/src/sampletones_application/layout/graphs/spectrum.py
@@ -1,9 +1,9 @@
from pydantic import BaseModel
-from sampletones_application.utils.palette import PaletteColor
+from sampletones_application.utils.palette.colors.written import WrittenColor
class SpectrumLayout(BaseModel, extra="forbid", frozen=True):
max_display_bins: int
- color_dim: PaletteColor
- color_bright: PaletteColor
+ color_dim: WrittenColor
+ color_bright: WrittenColor
diff --git a/src/sampletones_application/layout/loader.py b/src/sampletones_application/layout/loader.py
index 4779ae9b..35b5ebac 100644
--- a/src/sampletones_application/layout/loader.py
+++ b/src/sampletones_application/layout/loader.py
@@ -1,10 +1,10 @@
from pathlib import Path
-from sampletones_application.layout.behavior import BehaviorConfig
+from sampletones_application.layout.behavior.behavior import BehaviorConfig
from sampletones_application.layout.config import LayoutConfig
from sampletones_application.layout.fonts import FontsLayout
from sampletones_application.layout.general import GeneralLayout
-from sampletones_application.layout.glyphs import Glyphs
+from sampletones_application.layout.glyphs.glyphs import Glyphs
from sampletones_application.layout.graphs import GraphsLayout
from sampletones_application.layout.player import PlayerLayout
from sampletones_application.layout.project_properties import ProjectPropertiesLayout
@@ -14,16 +14,17 @@
from sampletones_application.layout.tabs.main import MainLayout
from sampletones_application.layout.tabs.reconstruction import ReconstructionLayout
from sampletones_application.layout.tabs.sequencer import SequencerLayout
-from sampletones_application.utils.palette import PALETTE_CONTEXT_KEY, Palette
+from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY
+from sampletones_application.utils.palette.source import PaletteSource
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,
+ palette_source: PaletteSource,
) -> LayoutConfig:
- context = {PALETTE_CONTEXT_KEY: palette}
+ context = {PALETTE_SOURCE_CONTEXT_KEY: palette_source}
tabs_directory = layout_directory / "tabs"
return LayoutConfig(
general=load_yaml_model_dir(
diff --git a/src/sampletones_application/layout/settings/__init__.py b/src/sampletones_application/layout/settings/__init__.py
index 35eb09aa..99c11b68 100644
--- a/src/sampletones_application/layout/settings/__init__.py
+++ b/src/sampletones_application/layout/settings/__init__.py
@@ -1,11 +1,21 @@
from pydantic import BaseModel
-from sampletones_application.layout.primitives import Dimensions
-from sampletones_application.layout.settings.master_gain import MasterGainLayout
+from sampletones_application.layout.settings.audio import AudioSettingsLayout
+from sampletones_application.layout.settings.display import DisplaySettingsLayout
+from sampletones_application.layout.settings.keybindings import KeybindingsSettingsLayout
+from sampletones_application.layout.settings.render import RenderSettingsLayout
class SettingsLayout(BaseModel, extra="forbid", frozen=True):
- window: Dimensions
+ """The geometry every settings dialog draws with.
+
+ The label and combo columns are shared, so a field reads the same width in whichever dialog
+ it appears; each dialog then states the size of its own windows.
+ """
+
combo_width: int
label_width: int
- master_gain: MasterGainLayout
+ audio: AudioSettingsLayout
+ display: DisplaySettingsLayout
+ keybindings: KeybindingsSettingsLayout
+ render: RenderSettingsLayout
diff --git a/src/sampletones_application/layout/settings/audio.py b/src/sampletones_application/layout/settings/audio.py
new file mode 100644
index 00000000..ca8eaad9
--- /dev/null
+++ b/src/sampletones_application/layout/settings/audio.py
@@ -0,0 +1,9 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.primitives import Dimensions
+from sampletones_application.layout.settings.master_gain import MasterGainLayout
+
+
+class AudioSettingsLayout(BaseModel, extra="forbid", frozen=True):
+ window: Dimensions
+ master_gain: MasterGainLayout
diff --git a/src/sampletones_application/layout/settings/display.py b/src/sampletones_application/layout/settings/display.py
new file mode 100644
index 00000000..c5ef1234
--- /dev/null
+++ b/src/sampletones_application/layout/settings/display.py
@@ -0,0 +1,8 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.primitives import Dimensions
+
+
+class DisplaySettingsLayout(BaseModel, extra="forbid", frozen=True):
+ window: Dimensions
+ countdown: Dimensions
diff --git a/src/sampletones_application/layout/settings/keybindings.py b/src/sampletones_application/layout/settings/keybindings.py
new file mode 100644
index 00000000..863f0adf
--- /dev/null
+++ b/src/sampletones_application/layout/settings/keybindings.py
@@ -0,0 +1,15 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.primitives import Dimensions
+
+
+class KeybindingsSettingsLayout(BaseModel, extra="forbid", frozen=True):
+ """The geometry the keybindings dialog draws with.
+
+ The list takes a stated height so the window keeps one size whichever scope a filter leaves
+ showing, and the action column takes a stated width so every combination reads down one edge.
+ """
+
+ window: Dimensions
+ list_height: int
+ action_width: int
diff --git a/src/sampletones_application/layout/settings/master_gain.py b/src/sampletones_application/layout/settings/master_gain.py
index 8b5b86df..49a1639a 100644
--- a/src/sampletones_application/layout/settings/master_gain.py
+++ b/src/sampletones_application/layout/settings/master_gain.py
@@ -1,9 +1,9 @@
from pydantic import BaseModel
-from sampletones_application.utils.palette import PaletteColor
+from sampletones_application.utils.palette.colors.written import WrittenColor
class MasterGainLayout(BaseModel, extra="forbid", frozen=True):
slider_width: int
- label_color: PaletteColor
- clip_color: PaletteColor
+ label_color: WrittenColor
+ clip_color: WrittenColor
diff --git a/src/sampletones_application/layout/settings/render.py b/src/sampletones_application/layout/settings/render.py
new file mode 100644
index 00000000..58dc9e84
--- /dev/null
+++ b/src/sampletones_application/layout/settings/render.py
@@ -0,0 +1,7 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.primitives import Dimensions
+
+
+class RenderSettingsLayout(BaseModel, extra="forbid", frozen=True):
+ window: Dimensions
diff --git a/src/sampletones_application/layout/tabs/sequencer/__init__.py b/src/sampletones_application/layout/tabs/sequencer/__init__.py
index e4857e0e..c21774f1 100644
--- a/src/sampletones_application/layout/tabs/sequencer/__init__.py
+++ b/src/sampletones_application/layout/tabs/sequencer/__init__.py
@@ -1,20 +1,16 @@
from pydantic import BaseModel
from sampletones_application.layout.primitives import Dimensions
-from sampletones_application.layout.tabs.sequencer.colors import SequencerColors
+from sampletones_application.layout.tabs.sequencer.colors.colors import SequencerColors
from sampletones_application.layout.tabs.sequencer.history import HistoryLayout
from sampletones_application.layout.tabs.sequencer.order import OrderLayout
-from sampletones_application.layout.tabs.sequencer.speed import SpeedLayout
-from sampletones_application.layout.tabs.sequencer.table_cells import SequencerTableCells
-from sampletones_application.layout.tabs.sequencer.tempo import TempoLayout
-from sampletones_application.layout.tabs.sequencer.tracker import TrackerLayout
+from sampletones_application.layout.tabs.sequencer.tables.cells import SequencerTableCells
+from sampletones_application.layout.tabs.sequencer.tracker.tracker import TrackerLayout
class SequencerLayout(BaseModel, extra="forbid", frozen=True):
order: OrderLayout
table_cells: SequencerTableCells
- tempo: TempoLayout
- speed: SpeedLayout
tracker: TrackerLayout
history: HistoryLayout
colors: SequencerColors
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors.py b/src/sampletones_application/layout/tabs/sequencer/colors.py
deleted file mode 100644
index 22da79ab..00000000
--- a/src/sampletones_application/layout/tabs/sequencer/colors.py
+++ /dev/null
@@ -1,118 +0,0 @@
-from pydantic import BaseModel
-
-from sampletones_application.utils.palette import PaletteColor
-
-
-class TrackerColors(BaseModel, extra="forbid", frozen=True):
- """The semantic text colours shared across every tracker view.
-
- One palette feeds the pattern grid, the order table, and the history detail so a
- concept keeps its colour everywhere: ``instrument`` (the note/sample reference,
- yellow like ``sample``), ``transpose``, ``volume``, ``sample``, the ``frame`` and
- ``row`` indices, and the ``order`` entries. Defining them once keeps every panel in
- step.
- """
-
- instrument: PaletteColor
- transpose: PaletteColor
- volume: PaletteColor
- sample: PaletteColor
- frame: PaletteColor
- row: PaletteColor
- order: PaletteColor
-
-
-class HistoryRoleColors(BaseModel, extra="forbid", frozen=True):
- """Colours for the history-detail token roles unique to the detail line.
-
- The instrument/transpose/volume, frame, row, and sample tokens draw from the
- shared :class:`TrackerColors` palette; only the roles unique to the detail line
- live here.
- """
-
- channel: PaletteColor
- value: PaletteColor
- separator: PaletteColor
-
-
-class ChannelColors(BaseModel, extra="forbid", frozen=True):
- """Per-channel identity colours shared by the order table and the tracker grid.
-
- The order table paints each channel's row label in its colour; the tracker grid
- tints each channel's column background with the same colour at a low alpha, so a
- channel keeps one identity across both views.
- """
-
- pulse1: PaletteColor
- pulse2: PaletteColor
- triangle: PaletteColor
- noise: PaletteColor
-
-
-class OrderColors(BaseModel, extra="forbid", frozen=True):
- """Colours specific to the order table: the row-label column, the master row and
- the divider below it, and the per-column highlights for the current and playing
- positions.
- """
-
- label: PaletteColor
- master: PaletteColor
- master_divider: PaletteColor
- column_current: PaletteColor
- column_playing: PaletteColor
-
-
-class SampleColors(BaseModel, extra="forbid", frozen=True):
- """Colours marking the tracker's sample column and the divider beside it."""
-
- column: PaletteColor
- divider: PaletteColor
-
-
-class HeaderColors(BaseModel, extra="forbid", frozen=True):
- """Colours the tracker's clickable column header takes.
-
- ``background`` is the band the header row sits in, the shade a table header carries;
- ``hovered`` and ``active`` are the washes a header label takes under the pointer and while
- it is held, which is how the label shows it answers to a click.
- """
-
- background: PaletteColor
- hovered: PaletteColor
- active: PaletteColor
-
-
-class MutedColors(BaseModel, extra="forbid", frozen=True):
- """Colours marking a channel the song player silences.
-
- ``background`` is the neutral shade the channel takes in place of its identity tint —
- down its column in the tracker, along its row in the order table — so the channel
- recedes as a whole; ``text`` is the shade its name takes.
- """
-
- background: PaletteColor
- text: PaletteColor
-
-
-class HistoryColors(BaseModel, extra="forbid", frozen=True):
- """Colours for the history detail: the dimmed tint of future (redoable) entries
- and the per-role token palette.
- """
-
- future: PaletteColor
- roles: HistoryRoleColors
-
-
-class SequencerColors(BaseModel, extra="forbid", frozen=True):
- pattern_highlight: PaletteColor
- cell_cursor: PaletteColor
- cursor_row: PaletteColor
- playback_row: PaletteColor
- label: PaletteColor
- order: OrderColors
- sample: SampleColors
- header: HeaderColors
- muted: MutedColors
- history: HistoryColors
- text: TrackerColors
- channels: ChannelColors
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/__init__.py b/src/sampletones_application/layout/tabs/sequencer/colors/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/channel.py b/src/sampletones_application/layout/tabs/sequencer/colors/channel.py
new file mode 100644
index 00000000..c43e3655
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/channel.py
@@ -0,0 +1,17 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class ChannelColors(BaseModel, extra="forbid", frozen=True):
+ """Per-channel identity colours shared by the order table and the tracker grid.
+
+ The order table paints each channel's row label in its colour; the tracker grid
+ tints each channel's column background with the same colour at a low alpha, so a
+ channel keeps one identity across both views.
+ """
+
+ pulse1: WrittenColor
+ pulse2: WrittenColor
+ triangle: WrittenColor
+ noise: WrittenColor
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/colors.py b/src/sampletones_application/layout/tabs/sequencer/colors/colors.py
new file mode 100644
index 00000000..ba05e75e
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/colors.py
@@ -0,0 +1,27 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors
+from sampletones_application.layout.tabs.sequencer.colors.header import HeaderColors
+from sampletones_application.layout.tabs.sequencer.colors.history import HistoryColors
+from sampletones_application.layout.tabs.sequencer.colors.muted import MutedColors
+from sampletones_application.layout.tabs.sequencer.colors.order import OrderColors
+from sampletones_application.layout.tabs.sequencer.colors.row import RowColors
+from sampletones_application.layout.tabs.sequencer.colors.sample import SampleColors
+from sampletones_application.layout.tabs.sequencer.colors.tracker import TrackerColors
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class SequencerColors(BaseModel, extra="forbid", frozen=True):
+ pattern_highlight: WrittenColor
+ cell_cursor: WrittenColor
+ cursor_row: WrittenColor
+ playback_row: WrittenColor
+ label: WrittenColor
+ rows: RowColors
+ order: OrderColors
+ sample: SampleColors
+ header: HeaderColors
+ muted: MutedColors
+ history: HistoryColors
+ text: TrackerColors
+ channels: ChannelColors
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/header.py b/src/sampletones_application/layout/tabs/sequencer/colors/header.py
new file mode 100644
index 00000000..21dce042
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/header.py
@@ -0,0 +1,16 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class HeaderColors(BaseModel, extra="forbid", frozen=True):
+ """Colours the tracker's clickable column header takes.
+
+ ``background`` is the band the header row sits in, the shade a table header carries;
+ ``hovered`` and ``active`` are the washes a header label takes under the pointer and while
+ it is held, which is how the label shows it answers to a click.
+ """
+
+ background: WrittenColor
+ hovered: WrittenColor
+ active: WrittenColor
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/history.py b/src/sampletones_application/layout/tabs/sequencer/colors/history.py
new file mode 100644
index 00000000..837c60ac
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/history.py
@@ -0,0 +1,13 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.tabs.sequencer.colors.history_role import HistoryRoleColors
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class HistoryColors(BaseModel, extra="forbid", frozen=True):
+ """Colours for the history detail: the dimmed tint of future (redoable) entries
+ and the per-role token palette.
+ """
+
+ future: WrittenColor
+ roles: HistoryRoleColors
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py b/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py
new file mode 100644
index 00000000..ee02d51f
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/history_role.py
@@ -0,0 +1,16 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class HistoryRoleColors(BaseModel, extra="forbid", frozen=True):
+ """Colours for the history-detail token roles unique to the detail line.
+
+ The instrument/transpose/volume, frame, row, and sample tokens draw from the
+ shared :class:`TrackerColors` palette; only the roles unique to the detail line
+ live here.
+ """
+
+ channel: WrittenColor
+ value: WrittenColor
+ separator: WrittenColor
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/muted.py b/src/sampletones_application/layout/tabs/sequencer/colors/muted.py
new file mode 100644
index 00000000..7e430b1a
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/muted.py
@@ -0,0 +1,15 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class MutedColors(BaseModel, extra="forbid", frozen=True):
+ """Colours marking a channel the song player silences.
+
+ ``background`` is the neutral shade the channel takes in place of its identity tint —
+ down its column in the tracker, along its row in the order table — so the channel
+ recedes as a whole; ``text`` is the shade its name takes.
+ """
+
+ background: WrittenColor
+ text: WrittenColor
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/order.py b/src/sampletones_application/layout/tabs/sequencer/colors/order.py
new file mode 100644
index 00000000..ffd0eed4
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/order.py
@@ -0,0 +1,16 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class OrderColors(BaseModel, extra="forbid", frozen=True):
+ """Colours specific to the order table: the row-label column, the master row and
+ the divider below it, and the per-column highlights for the current and playing
+ positions.
+ """
+
+ label: WrittenColor
+ master: WrittenColor
+ master_divider: WrittenColor
+ column_current: WrittenColor
+ column_playing: WrittenColor
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/row.py b/src/sampletones_application/layout/tabs/sequencer/colors/row.py
new file mode 100644
index 00000000..559bc800
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/row.py
@@ -0,0 +1,14 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class RowColors(BaseModel, extra="forbid", frozen=True):
+ """Colours marking where a tracker row falls in the pulse of the pattern.
+
+ ``beat`` lifts the row that opens each beat off the zebra stripe and ``bar`` marks the
+ row that opens each bar more strongly, so a long pattern reads as a rhythm at a glance.
+ """
+
+ beat: WrittenColor
+ bar: WrittenColor
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/sample.py b/src/sampletones_application/layout/tabs/sequencer/colors/sample.py
new file mode 100644
index 00000000..02ccae71
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/sample.py
@@ -0,0 +1,10 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class SampleColors(BaseModel, extra="forbid", frozen=True):
+ """Colours marking the tracker's sample column and the divider beside it."""
+
+ column: WrittenColor
+ divider: WrittenColor
diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py
new file mode 100644
index 00000000..b6882e86
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/colors/tracker.py
@@ -0,0 +1,22 @@
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.colors.written import WrittenColor
+
+
+class TrackerColors(BaseModel, extra="forbid", frozen=True):
+ """The semantic text colours shared across every tracker view.
+
+ One palette feeds the pattern grid, the order table, and the history detail so a
+ concept keeps its colour everywhere: ``instrument`` (the note/sample reference,
+ yellow like ``sample``), ``transpose``, ``volume``, ``sample``, the ``frame`` and
+ ``row`` indices, and the ``order`` entries. Defining them once keeps every panel in
+ step.
+ """
+
+ instrument: WrittenColor
+ transpose: WrittenColor
+ volume: WrittenColor
+ sample: WrittenColor
+ frame: WrittenColor
+ row: WrittenColor
+ order: WrittenColor
diff --git a/src/sampletones_application/layout/tabs/sequencer/speed.py b/src/sampletones_application/layout/tabs/sequencer/speed.py
deleted file mode 100644
index ed54b7f9..00000000
--- a/src/sampletones_application/layout/tabs/sequencer/speed.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from pydantic import BaseModel
-
-
-class SpeedLayout(BaseModel, extra="forbid", frozen=True):
- min: int
- max: int
- default: int
diff --git a/src/sampletones_application/layout/tabs/sequencer/tables/__init__.py b/src/sampletones_application/layout/tabs/sequencer/tables/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/layout/tabs/sequencer/tables/cells.py b/src/sampletones_application/layout/tabs/sequencer/tables/cells.py
new file mode 100644
index 00000000..8cc234eb
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/tables/cells.py
@@ -0,0 +1,11 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.tabs.sequencer.tables.instrument import InstrumentColumnWidths
+
+
+class SequencerTableCells(BaseModel, extra="forbid", frozen=True):
+ row: int
+ sample: int
+ divider: int
+ generator: int
+ instrument: InstrumentColumnWidths
diff --git a/src/sampletones_application/layout/tabs/sequencer/table_cells.py b/src/sampletones_application/layout/tabs/sequencer/tables/instrument.py
similarity index 65%
rename from src/sampletones_application/layout/tabs/sequencer/table_cells.py
rename to src/sampletones_application/layout/tabs/sequencer/tables/instrument.py
index d204f544..83ac368b 100644
--- a/src/sampletones_application/layout/tabs/sequencer/table_cells.py
+++ b/src/sampletones_application/layout/tabs/sequencer/tables/instrument.py
@@ -9,11 +9,3 @@ class InstrumentColumnWidths(BaseModel, extra="forbid", frozen=True):
id: int
name: int
loop: int
-
-
-class SequencerTableCells(BaseModel, extra="forbid", frozen=True):
- row: int
- sample: int
- divider: int
- generator: int
- instrument: InstrumentColumnWidths
diff --git a/src/sampletones_application/layout/tabs/sequencer/tempo.py b/src/sampletones_application/layout/tabs/sequencer/tempo.py
deleted file mode 100644
index fe52a26c..00000000
--- a/src/sampletones_application/layout/tabs/sequencer/tempo.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from pydantic import BaseModel
-
-
-class TempoLayout(BaseModel, extra="forbid", frozen=True):
- min: int
- max: int
- default: int
diff --git a/src/sampletones_application/layout/tabs/sequencer/tracker.py b/src/sampletones_application/layout/tabs/sequencer/tracker.py
deleted file mode 100644
index 2725899d..00000000
--- a/src/sampletones_application/layout/tabs/sequencer/tracker.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from pydantic import BaseModel
-
-
-class SubcolumnWidths(BaseModel, extra="forbid", frozen=True):
- instrument: int
- transpose: int
- volume: int
-
-
-class TrackerLayout(BaseModel, extra="forbid", frozen=True):
- rows: int
- page_size: int
- subcolumn_widths: SubcolumnWidths
- channel_column_tint: float
- muted_text_fraction: float
diff --git a/src/sampletones_application/layout/tabs/sequencer/tracker/__init__.py b/src/sampletones_application/layout/tabs/sequencer/tracker/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/layout/tabs/sequencer/tracker/subcolumn.py b/src/sampletones_application/layout/tabs/sequencer/tracker/subcolumn.py
new file mode 100644
index 00000000..67ff0740
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/tracker/subcolumn.py
@@ -0,0 +1,7 @@
+from pydantic import BaseModel
+
+
+class SubcolumnWidths(BaseModel, extra="forbid", frozen=True):
+ instrument: int
+ transpose: int
+ volume: int
diff --git a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py
new file mode 100644
index 00000000..07c908cd
--- /dev/null
+++ b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py
@@ -0,0 +1,23 @@
+from pydantic import BaseModel
+
+from sampletones_application.layout.tabs.sequencer.tracker.subcolumn import SubcolumnWidths
+
+
+class TrackerLayout(BaseModel, extra="forbid", frozen=True):
+ """The tracker's row counts, cell sizes and tint strengths.
+
+ The grouping the rows are tinted by is the project's own metre, read from its highlights,
+ so this model carries the geometry alone.
+
+ A row states its height rather than growing to the text in it, because the grid's tints are
+ drawn by the cells: a cell that stands exactly as tall as its row lets a selection, a hover
+ and the cursor cover the row edge to edge.
+ """
+
+ rows: int
+ page_size: int
+ row_height: int
+ header_height: int
+ subcolumn_widths: SubcolumnWidths
+ channel_column_tint: float
+ muted_text_fraction: float
diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py
index 595655c1..6a607474 100644
--- a/src/sampletones_application/logic/history/action.py
+++ b/src/sampletones_application/logic/history/action.py
@@ -1,12 +1,11 @@
-from enum import StrEnum
+from sampletones_application.categories.abstract import AbstractElement
-class HistoryAction(StrEnum):
+class HistoryAction(AbstractElement):
"""Names a single user-facing gesture recorded as one history entry.
- Each member's value doubles as the language-lookup element for the entry's
- display label, so the history panel resolves a human-readable name from the
- same enum the coordinators tag their transactions with.
+ Each member is the language-lookup element for the entry's display label, so the history panel
+ resolves a human-readable name from the same enum the coordinators tag their transactions with.
"""
INITIAL = "initial"
@@ -16,9 +15,13 @@ class HistoryAction(StrEnum):
CLEAR_SUBCOLUMN = "clear_subcolumn"
ADJUST_TRANSPOSE = "adjust_transpose"
ADJUST_VOLUME = "adjust_volume"
+ CUT_BLOCK = "cut_block"
+ PASTE_BLOCK = "paste_block"
+ DELETE_BLOCK = "delete_block"
ADD_FRAME = "add_frame"
REMOVE_FRAME = "remove_frame"
DUPLICATE_FRAME = "duplicate_frame"
+ CLONE_FRAME = "clone_frame"
CLEAR_FRAME = "clear_frame"
MOVE_FRAME = "move_frame"
SET_ORDER_ENTRY = "set_order_entry"
diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py
index 236bff43..62bb8677 100644
--- a/src/sampletones_application/logic/history/manager.py
+++ b/src/sampletones_application/logic/history/manager.py
@@ -1,8 +1,9 @@
from contextlib import contextmanager
-from datetime import datetime
+from datetime import UTC, datetime
from typing import Iterator, List, Optional, Tuple
from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.shared.project_source import snapshot_project
from sampletones_application.view_model.shared.history import HistoryDetail
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.callbacks import CallbackMixin
@@ -11,7 +12,7 @@
from .action import HistoryAction
from .errors import HistoryIntegrityError, UntrackedMutationError
from .fingerprint import ReconstructionHashCache, fingerprint_project
-from .snapshot import HistoryEntry, snapshot_project
+from .snapshot import HistoryEntry
from .transaction import CoalesceKey, PendingTransaction
@@ -283,7 +284,7 @@ def _capture(
return HistoryEntry(
project=snapshot_project(project),
action=action,
- created=datetime.now(),
+ created=datetime.now(UTC),
detail=detail,
fingerprint=fingerprint,
)
diff --git a/src/sampletones_application/logic/history/snapshot.py b/src/sampletones_application/logic/history/snapshot.py
index d3ed3c4a..711fe0f9 100644
--- a/src/sampletones_application/logic/history/snapshot.py
+++ b/src/sampletones_application/logic/history/snapshot.py
@@ -1,7 +1,6 @@
-import copy
from dataclasses import dataclass, field
from datetime import datetime
-from typing import Dict, Optional
+from typing import Optional
from sampletones_application.view_model.shared.history import HistoryDetail
from sampletones_core.project import Project
@@ -9,22 +8,6 @@
from .action import HistoryAction
-def snapshot_project(project: Project) -> Project:
- """Captures an independent copy of a project that shares reconstruction audio.
-
- The song, settings, metadata and sample shells are deep-copied so later edits
- to the live project leave the snapshot untouched. Each sample's reconstruction
- is shared by reference, so the snapshot reuses those multi-megabyte audio
- arrays. Reconstruction edits are copy-on-write — each installs a fresh
- reconstruction — so the shared reconstruction stays valid for the life of the
- snapshot.
- """
- shared_reconstructions: Dict[int, object] = {
- id(sample.reconstruction): sample.reconstruction for sample in project.samples
- }
- return copy.deepcopy(project, shared_reconstructions)
-
-
@dataclass(frozen=True)
class HistoryEntry:
"""One committed state in the history stack.
diff --git a/src/sampletones_application/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py
index 5636a9e5..23439f21 100644
--- a/src/sampletones_application/logic/instruction/library.py
+++ b/src/sampletones_application/logic/instruction/library.py
@@ -286,12 +286,7 @@ def _load_library(self, library_key: InstructionLibraryKey) -> None:
self._library_manager.get_path(library_key),
self._language_manager["instructions.library.message.status_file_not_found"],
)
- except (
- IOError,
- IsADirectoryError,
- PermissionError,
- OSError,
- ) as exception:
+ except (IsADirectoryError, PermissionError, OSError) as exception:
logger.error_with_traceback(
exception,
f"Error loading library file for key {library_key}",
@@ -367,7 +362,11 @@ def _on_generation_start(self) -> None:
self._eta_estimator = ETAEstimator(self._library_manager.creator.total_instructions)
self.call(self.on_generation_state_changed)
- def _on_generation_progress(self, task_status: TaskStatus, task_progress: TaskProgress) -> None:
+ def _on_generation_progress(
+ self,
+ task_status: TaskStatus,
+ task_progress: TaskProgress,
+ ) -> None:
with self._status_lock:
match task_status:
case TaskStatus.COMPLETED:
diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py
index 05ca9155..def2e397 100644
--- a/src/sampletones_application/logic/instruction/library_manager.py
+++ b/src/sampletones_application/logic/instruction/library_manager.py
@@ -18,7 +18,6 @@
from sampletones_core.library.creator import InstructionsLibraryCreator
from sampletones_core.library.filename.fields import InstructionsFilenameFields
from sampletones_core.parallelization import TaskProgress, TaskStatus
-from sampletones_core.paths import EXT_FILE_LIBRARY
from sampletones_core.structures.tree import (
GeneratorNode,
LibraryNode,
@@ -27,6 +26,7 @@
TreeNode,
)
from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.callbacks import CallbackMixin
from sampletones_shared.utils.system.paths import to_path
diff --git a/src/sampletones_application/logic/instruction/table.py b/src/sampletones_application/logic/instruction/table.py
index e2eab0bd..18808eea 100644
--- a/src/sampletones_application/logic/instruction/table.py
+++ b/src/sampletones_application/logic/instruction/table.py
@@ -3,7 +3,9 @@
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.view_model.instruction.cell import TableCell
from sampletones_application.view_model.instruction.data import InstructionPanelData
-from sampletones_application.view_model.instruction.table_data import InstructionTableData
+from sampletones_application.view_model.instruction.table_data import (
+ InstructionTableData,
+)
from sampletones_core.constants.general import DUTY_CYCLES, NOISE_PERIODS
from sampletones_core.utils.frequencies import pitch_to_name
from sampletones_shared.utils.serialization import hash_model
@@ -115,7 +117,7 @@ def _build_parameter_rows(self) -> List[TableCell]:
def _format_parameter_value(
self,
name: str,
- value: Union[float, bool, List[Any], Tuple[Any, ...], str, int],
+ value: Union[float, bool, List[Any], Tuple[Any, ...], str],
) -> str:
if name == "pitch" and isinstance(value, (int, float)):
return self._language_manager["instructions.details.template.pitch_template"].format(
diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py
index b2b639fb..edb7361e 100644
--- a/src/sampletones_application/logic/main/converter.py
+++ b/src/sampletones_application/logic/main/converter.py
@@ -4,7 +4,7 @@
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.config.managers.config import ConfigManager
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.services.result import (
ConversionResult,
ServiceCancelled,
diff --git a/src/sampletones_application/logic/main/explorer.py b/src/sampletones_application/logic/main/explorer.py
index 5e665f2c..40a1ec06 100644
--- a/src/sampletones_application/logic/main/explorer.py
+++ b/src/sampletones_application/logic/main/explorer.py
@@ -1,4 +1,5 @@
from pathlib import Path
+from typing import AbstractSet, Set
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.config.managers.config import ConfigManager
@@ -12,10 +13,12 @@ def __init__(
config_manager: ConfigManager,
*,
language_manager: LanguageManager,
+ open_directories: AbstractSet[Path],
) -> None:
self._manager = ExplorerManager(
config_manager,
language_manager=language_manager,
+ open_directories=open_directories,
)
@property
@@ -25,8 +28,18 @@ def tree(self) -> Tree:
def refresh_tree(self) -> None:
self._manager.refresh_tree()
- def is_directory_expanded(self, filepath: Path) -> bool:
- return self._manager.is_directory_expanded(filepath)
+ def has_loaded_children(self, filepath: Path) -> bool:
+ return self._manager.has_loaded_children(filepath)
+
+ def is_directory_open(self, filepath: Path) -> bool:
+ return self._manager.is_directory_open(filepath)
+
+ def set_directory_open(self, filepath: Path, is_open: bool) -> None:
+ self._manager.set_directory_open(filepath, is_open)
+
+ @property
+ def open_directories(self) -> Set[Path]:
+ return self._manager.open_directories
def expand_directory(self, node: FileSystemNode) -> None:
self._manager.expand_directory(node)
diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py
index 40b84ed2..0458af98 100644
--- a/src/sampletones_application/logic/main/explorer_manager.py
+++ b/src/sampletones_application/logic/main/explorer_manager.py
@@ -1,38 +1,56 @@
from pathlib import Path
-from typing import Dict, List, Optional
+from typing import AbstractSet, List, Optional, Set
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.config.managers.config import ConfigManager
-from sampletones_core.paths import (
- EXT_FILE_LIBRARY,
- EXT_FILE_RECONSTRUCTION,
- EXT_FILES_AUDIO,
-)
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from sampletones_core.structures.tree import (
FileSystemNode,
NodeType,
Tree,
TreeNode,
+ create_directory_node,
+)
+from sampletones_shared.paths.extensions import (
+ EXT_FILE_LIBRARY,
+ EXT_FILE_RECONSTRUCTION,
+ EXT_FILES_AUDIO,
)
from sampletones_shared.utils.system.system import System
class ExplorerManager:
+ """Reads the filesystem into the Main tab's tree, a folder at a time as the reader opens it.
+
+ Two facts are held about a folder: whether its children have been read, and whether its row stands
+ open. They part company — a folder the reader read and then folded away is loaded and closed — and
+ the shape a session is left in is the open one, which is what a later run is handed back.
+ """
+
def __init__(
self,
config_manager: ConfigManager,
depth: int = 0,
*,
language_manager: LanguageManager,
+ open_directories: AbstractSet[Path],
) -> None:
self._language_manager = language_manager
self.tree = Tree()
self.config_manager = config_manager
- self._expanded_directories: Dict[Path, bool] = {}
+ self._loaded_directories: Set[Path] = set()
+ self._open_directories: Set[Path] = {path for path in open_directories if path.is_dir()}
self.depth = depth
def refresh_tree(self) -> None:
+ """Reads the filesystem afresh, down to every folder the tree has to show a row for.
+
+ A refresh builds the tree from nothing, so ``_loaded_directories`` starts empty and each folder
+ it needs is read into it once: the rows a read places under a folder stand as the walk carries
+ on deeper.
+ """
+ self._loaded_directories.clear()
container_root = TreeNode(
name=self._language_manager["global.browser.label.root"],
node_type=NodeType.ROOT,
@@ -45,25 +63,37 @@ def refresh_tree(self) -> None:
parent=container_root,
)
- selected_path = self._get_ancestor_of_selected(filesystem_path)
- if selected_path is not None:
- self._expand_path_to_selected(filesystem_node, selected_path)
+ for path in self._paths_to_reveal(filesystem_path):
+ self._expand_path_to(filesystem_node, path)
self.tree.set_root(container_root)
+ def _paths_to_reveal(self, filesystem_path: Path) -> List[Path]:
+ """The folders a refresh reads down to, among the ones this filesystem holds.
+
+ A folder standing open is read again so it comes back open, and the directories the
+ application works in are revealed so the reader finds them without walking there.
+ """
+ candidates = (*sorted(self._open_directories), *self.selected_directories)
+ return [path for path in candidates if self._holds(filesystem_path, path)]
+
+ def _holds(self, filesystem_path: Path, path: Path) -> bool:
+ return path == filesystem_path or filesystem_path in path.parents
+
def _create_directory_node(
self,
directory_path: Path,
parent: Optional[TreeNode] = None,
) -> FileSystemNode:
- node = FileSystemNode(
+ node = create_directory_node(
+ directory_path,
name=directory_path.name or str(directory_path),
- filepath=directory_path,
- node_type=NodeType.DIRECTORY,
+ config=ConfigDirectoryFields.from_directory_name(directory_path.name),
parent=parent,
)
self._load_directory_children(node)
+ self._open_directories.add(node.filepath)
return node
def _load_directory_children(
@@ -72,13 +102,10 @@ def _load_directory_children(
level: int = 0,
) -> None:
directory_path = directory_node.filepath
- if not directory_path.is_dir():
+ if not directory_path.is_dir() or directory_path in self._loaded_directories:
return
- self._expanded_directories[directory_path] = level == 0
- for existing_child in list(directory_node.children):
- existing_child.parent = None
-
+ self._loaded_directories.add(directory_path)
try:
entries = sorted(
directory_path.iterdir(),
@@ -93,10 +120,10 @@ def _load_directory_children(
if entry_path.name.startswith("."):
continue
- child_node = FileSystemNode(
+ child_node = create_directory_node(
+ entry_path,
name=entry_path.name,
- filepath=entry_path,
- node_type=NodeType.DIRECTORY,
+ config=ConfigDirectoryFields.from_directory_name(entry_path.name),
parent=directory_node,
)
if level < self.depth:
@@ -143,7 +170,9 @@ def has_relevant_content(self, directory_path: Path) -> bool:
return False
def collapse_all(self) -> None:
- self._expanded_directories.clear()
+ """Folds every folder away and drops what was read, so opening one lists it as it stands."""
+ self._loaded_directories.clear()
+ self._open_directories.clear()
root = self.tree.get_root()
if not root:
@@ -155,15 +184,32 @@ def collapse_all(self) -> None:
child.parent = None
def expand_directory(self, directory_node: FileSystemNode) -> None:
+ """Reads a folder's children the first time it is opened, which is what fills its row."""
if directory_node.node_type != NodeType.DIRECTORY:
return
- directory_path = directory_node.filepath
- if not self.is_directory_expanded(directory_path):
- self._load_directory_children(directory_node)
+ self._load_directory_children(directory_node)
- def is_directory_expanded(self, directory_path: Path) -> bool:
- return self._expanded_directories.get(directory_path, False)
+ def has_loaded_children(self, directory_path: Path) -> bool:
+ """Whether the folder's children have been read, which is what a row below it needs."""
+ return directory_path in self._loaded_directories
+
+ def is_directory_open(self, directory_path: Path) -> bool:
+ """Whether the folder's row stands open, which a refresh brings it back as."""
+ return directory_path in self._open_directories
+
+ def set_directory_open(self, directory_path: Path, is_open: bool) -> None:
+ """Takes what a click left the folder standing as, which is the shape a session writes down."""
+ if is_open:
+ self._open_directories.add(directory_path)
+ return
+
+ self._open_directories.discard(directory_path)
+
+ @property
+ def open_directories(self) -> Set[Path]:
+ """The folders standing open, which is the shape a later run is handed back."""
+ return set(self._open_directories)
def _get_filesystems(self) -> List[Path]:
system = System.current()
@@ -182,23 +228,18 @@ def _get_windows_drives(self) -> List[Path]:
return drives
- def _get_ancestor_of_selected(self, path: Path) -> Optional[Path]:
- for selected_path in self.selected_directories:
- try:
- selected_path.relative_to(path)
- return selected_path
- except ValueError:
- continue
-
- return None
-
- def _expand_path_to_selected(
+ def _expand_path_to(
self,
filesystem_node: FileSystemNode,
- selected_path: Path,
+ path: Path,
) -> None:
+ """Reads the folders down to a path, so a row stands for it and for every folder above it.
+
+ Each folder walked through is opened, that being what shows the row below it. The folder at the
+ end is read as well where it stands open, so it comes back holding what it held.
+ """
try:
- relative_parts = selected_path.relative_to(filesystem_node.filepath).parts
+ relative_parts = path.relative_to(filesystem_node.filepath).parts
except ValueError:
return
@@ -208,13 +249,27 @@ def _expand_path_to_selected(
for part in relative_parts:
current_path = current_path / part
self._load_directory_children(current_node)
+ self._open_directories.add(current_node.filepath)
+
+ child = self._child_at(current_node, current_path)
+ if child is None:
+ return
+
+ current_node = child
+
+ if self.is_directory_open(current_node.filepath):
+ self._load_directory_children(current_node)
- for child in current_node.children:
- if isinstance(child, FileSystemNode) and child.filepath == current_path:
- current_node = child
- break
- else:
- break
+ def _child_at(
+ self,
+ directory_node: FileSystemNode,
+ path: Path,
+ ) -> Optional[FileSystemNode]:
+ for child in directory_node.children:
+ if isinstance(child, FileSystemNode) and child.filepath == path:
+ return child
+
+ return None
@property
def selected_directories(self) -> List[Path]:
diff --git a/src/sampletones_application/logic/project/batch.py b/src/sampletones_application/logic/project/batch.py
new file mode 100644
index 00000000..1ffc444d
--- /dev/null
+++ b/src/sampletones_application/logic/project/batch.py
@@ -0,0 +1,26 @@
+from dataclasses import dataclass, field
+from typing import List, Optional
+
+from sampletones_shared.types.callback import VoidCallback
+
+
+@dataclass
+class MutationBatch:
+ """The open batch's accumulating state.
+
+ Bundles the nesting ``depth`` of coalesced ``batch()`` scopes, the
+ ``announcements`` the mutations raised in the order they first arose, and
+ whether the project still needs its dirty ``stamp``. Keeping these together
+ holds one gesture's deferred notifications in lockstep. The presence of a
+ ``MutationBatch`` instance is itself the signal that a batch is open, and
+ nesting a scope increments its ``depth``.
+ """
+
+ depth: int = 1
+ announcements: List[Optional[VoidCallback]] = field(default_factory=list)
+ stamped: bool = False
+
+ def record(self, announcement: Optional[VoidCallback]) -> None:
+ """Keeps an announcement for the flush, once per distinct signal."""
+ if announcement not in self.announcements:
+ self.announcements.append(announcement)
diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py
index e0656a64..fd6b4b95 100644
--- a/src/sampletones_application/logic/project/controller.py
+++ b/src/sampletones_application/logic/project/controller.py
@@ -1,5 +1,6 @@
+from contextlib import contextmanager
from pathlib import Path
-from typing import Optional
+from typing import Iterator, Optional
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE
@@ -13,6 +14,7 @@
from sampletones_shared.utils.arrays import clamp
from sampletones_shared.utils.callbacks import CallbackMixin
+from .batch import MutationBatch
from .manager import ProjectManager
@@ -24,10 +26,14 @@ class ProjectController(CallbackMixin):
and the observer signal always happen together.
- Each mutation kind fires a distinct callback so that subscribers can
respond precisely to the specific change.
+ - :meth:`batch` widens that grain to a whole gesture: its mutations still
+ apply one at a time, while the dirty stamp and the observer signals they
+ raise arrive once each, on the way out.
"""
def __init__(self, project_manager: ProjectManager) -> None:
self._project_manager = project_manager
+ self._batch: Optional[MutationBatch] = None
self.on_project_replaced: Optional[VoidCallback] = None
self.on_info_changed: Optional[VoidCallback] = None
@@ -53,6 +59,11 @@ def order_length(self) -> int:
def is_open(self) -> bool:
return self._project_manager.is_open
+ @property
+ def name(self) -> str:
+ """The name the open project is known by, which a project saved to a file takes from it."""
+ return self._project_manager.name
+
@property
def has_samples(self) -> bool:
return bool(self.project.samples)
@@ -65,6 +76,29 @@ def sample_count(self) -> int:
def is_dirty(self) -> bool:
return self._project_manager.is_dirty
+ @contextmanager
+ def batch(self) -> Iterator[None]:
+ """Groups every mutation of one gesture into a single round of notifications.
+
+ A gesture that writes many rows — pasting a block of cells, spreading a
+ sample across the channels it covers — leaves each mutation applying the
+ moment it is made, while the dirty stamp and the observer signals it raises
+ wait for the scope to close and then arrive once each, in the order they
+ first arose. Subscribers therefore rebuild their views once per gesture
+ instead of once per row.
+
+ Nested scopes join the outermost one, and the flush runs on scope exit even
+ when the gesture raises: the mutations that already landed are part of the
+ live project, so their subscribers hear about them. The history's mutation
+ signal stays immediate (see :meth:`_touch`), and the lifecycle signals —
+ a project replaced, a project saved — are unaffected.
+ """
+ self._begin_batch()
+ try:
+ yield
+ finally:
+ self._flush_batch()
+
def new(self) -> None:
self._project_manager.new()
self.call(self.on_project_replaced)
@@ -101,47 +135,57 @@ def export_request(self) -> ProjectExport:
def mark_updated(self) -> None:
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
def set_title(self, title: str) -> None:
self.project.info.title = title
self._touch()
- self.call(self.on_info_changed)
+ self._announce(self.on_info_changed)
def set_author(self, author: str) -> None:
self.project.info.author = author
self._touch()
- self.call(self.on_info_changed)
+ self._announce(self.on_info_changed)
def set_comment(self, comment: str) -> None:
self.project.info.comment = comment
self._touch()
- self.call(self.on_info_changed)
+ self._announce(self.on_info_changed)
def set_tempo(self, tempo: int) -> None:
self.project.settings.tempo = tempo
self._touch()
- self.call(self.on_settings_changed)
+ self._announce(self.on_settings_changed)
def set_speed(self, speed: int) -> None:
self.project.settings.speed = speed
self._touch()
- self.call(self.on_settings_changed)
+ self._announce(self.on_settings_changed)
+
+ def set_first_highlight(self, first_highlight: int) -> None:
+ self.project.settings.first_highlight = first_highlight
+ self._touch()
+ self._announce(self.on_settings_changed)
+
+ def set_second_highlight(self, second_highlight: int) -> None:
+ self.project.settings.second_highlight = second_highlight
+ self._touch()
+ self._announce(self.on_settings_changed)
def set_nes_frequency(self, nes_frequency: int) -> None:
self.project.settings.nes_frequency = nes_frequency
self._touch()
- self.call(self.on_settings_changed)
+ self._announce(self.on_settings_changed)
def set_sample_rate(self, sample_rate: int) -> None:
self.project.settings.sample_rate = sample_rate
self._touch()
- self.call(self.on_settings_changed)
+ self._announce(self.on_settings_changed)
def set_rows_per_pattern(self, rows_per_pattern: int) -> None:
self.song.resize_patterns(rows_per_pattern)
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample:
"""Embeds a reconstruction as a project sample, detaching its local source-audio origin.
@@ -153,7 +197,7 @@ def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample:
sample = Sample(name=name, reconstruction=reconstruction)
self.project.samples.append(sample)
self._touch()
- self.call(self.on_samples_changed)
+ self._announce(self.on_samples_changed)
return sample
def replace_sample_reconstruction(self, sample_id: str, reconstruction: Reconstruction) -> None:
@@ -166,19 +210,19 @@ def replace_sample_reconstruction(self, sample_id: str, reconstruction: Reconstr
reconstruction.detach_source()
self.project.samples[sample_id].reconstruction = reconstruction
self._touch()
- self.call(self.on_samples_changed)
- self.call(self.on_song_changed)
+ self._announce(self.on_samples_changed)
+ self._announce(self.on_song_changed)
def rename_sample(self, sample_id: str, name: str) -> None:
self.project.samples[sample_id].name = name
self._touch()
- self.call(self.on_samples_changed)
- self.call(self.on_song_changed)
+ self._announce(self.on_samples_changed)
+ self._announce(self.on_song_changed)
def set_sample_loop(self, sample_id: str, loop: bool) -> None:
self.project.samples[sample_id].loop = loop
self._touch()
- self.call(self.on_samples_changed)
+ self._announce(self.on_samples_changed)
def is_sample_used(self, sample_id: str) -> bool:
return self.song.references_sample(sample_id)
@@ -187,8 +231,8 @@ def remove_sample(self, sample_id: str) -> None:
self.project.samples.pop(sample_id)
self.song.clear_sample_references(sample_id)
self._touch()
- self.call(self.on_samples_changed)
- self.call(self.on_song_changed)
+ self._announce(self.on_samples_changed)
+ self._announce(self.on_song_changed)
def duplicate_sample(self, sample_id: str) -> Sample:
"""Appends an independent copy of a sample (same name and loop flag).
@@ -199,7 +243,7 @@ def duplicate_sample(self, sample_id: str) -> Sample:
clone = self.project.samples[sample_id].clone()
self.project.samples.append(clone)
self._touch()
- self.call(self.on_samples_changed)
+ self._announce(self.on_samples_changed)
return clone
def move_sample(self, sample_id: str, to_index: int) -> None:
@@ -211,23 +255,23 @@ def move_sample(self, sample_id: str, to_index: int) -> None:
"""
self.project.samples.move(sample_id, to_index)
self._touch()
- self.call(self.on_samples_changed)
- self.call(self.on_song_changed)
+ self._announce(self.on_samples_changed)
+ self._announce(self.on_song_changed)
def add_pattern(self, generator: GeneratorName) -> int:
index = self.song.add_pattern(generator)
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
return index
- def duplicate_pattern(
+ def clone_pattern(
self,
generator: GeneratorName,
pattern_index: int,
) -> int:
- clone_index = self.song.duplicate_pattern(generator, pattern_index)
+ clone_index = self.song.clone_pattern(generator, pattern_index)
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
return clone_index
def remove_pattern(
@@ -237,7 +281,7 @@ def remove_pattern(
) -> None:
self.song.remove_pattern(generator, pattern_index)
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
def _clamp_transpose(self, transpose: Optional[int]) -> Optional[int]:
if transpose is None:
@@ -300,7 +344,7 @@ def set_row(
row,
)
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
def update_row(
self,
@@ -357,12 +401,12 @@ def clear_row(
def append_frame(self) -> None:
self.song.append_frame()
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
def insert_frame(self, position: int) -> None:
self.song.insert_frame(position)
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
def set_order_entry(
self,
@@ -372,36 +416,89 @@ def set_order_entry(
) -> None:
self.song.set_order_entry(position, generator, pattern_index)
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
def remove_frame(self, position: int) -> None:
self.song.remove_frame(position)
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
def move_frame(self, from_position: int, to_position: int) -> None:
self.song.move_frame(from_position, to_position)
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
def duplicate_frame(self, position: int) -> None:
self.song.duplicate_frame(position)
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
+
+ def clone_frame(self, position: int) -> None:
+ self.song.clone_frame(position)
+ self._touch()
+ self._announce(self.on_song_changed)
def clear_frame(self, position: int) -> None:
self.song.clear_frame(position)
self._touch()
- self.call(self.on_song_changed)
+ self._announce(self.on_song_changed)
- def _touch(self) -> None:
- """Stamps the project as modified and signals the mutation to the history.
+ def _begin_batch(self) -> None:
+ if self._batch is None:
+ self._batch = MutationBatch()
+ return
+
+ self._batch.depth += 1
- ``on_mutation`` is invoked through a direct ``None`` check so mutations stay
- silent in history-free contexts (tests, tools), where the hook is intentionally
- unwired and :meth:`CallbackMixin.call` would log a warning for each one.
+ def _flush_batch(self) -> None:
+ """Delivers the outermost batch's stamp and announcements, each exactly once.
+
+ The batch is closed before anything is delivered, so a subscriber that reads
+ the project — or mutates it further — sees a controller that notifies
+ immediately again.
"""
+ if self._batch is None:
+ return
+
+ self._batch.depth -= 1
+ if self._batch.depth > 0:
+ return
+
+ batch = self._batch
+ self._batch = None
+ if batch.stamped:
+ self._stamp()
+
+ for announcement in batch.announcements:
+ self.call(announcement)
+
+ def _announce(self, announcement: Optional[VoidCallback]) -> None:
+ """Signals a change to its subscribers, or keeps it for the open batch's flush."""
+ if self._batch is not None:
+ self._batch.record(announcement)
+ return
+
+ self.call(announcement)
+
+ def _stamp(self) -> None:
+ """Records the project as carrying unsaved changes, once per batch while one is open."""
+ if self._batch is not None:
+ self._batch.stamped = True
+ return
+
self.project.info.touch()
self._project_manager.mark_updated()
+
+ def _touch(self) -> None:
+ """Stamps the project as modified and signals the mutation to the history.
+
+ ``on_mutation`` fires for every mutation as it lands, batch or no batch, so the
+ history keeps seeing each one inside the transaction that caused it — that
+ immediacy is what its completeness check rests on. It is invoked through a
+ direct ``None`` check so mutations stay silent in history-free contexts (tests,
+ tools), where the hook is intentionally unwired and :meth:`CallbackMixin.call`
+ would log a warning for each one.
+ """
+ self._stamp()
if self.on_mutation is not None:
self.on_mutation()
diff --git a/src/sampletones_application/logic/reconstruction/browser/__init__.py b/src/sampletones_application/logic/reconstruction/browser/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/logic/reconstruction/browser.py b/src/sampletones_application/logic/reconstruction/browser/logic.py
similarity index 93%
rename from src/sampletones_application/logic/reconstruction/browser.py
rename to src/sampletones_application/logic/reconstruction/browser/logic.py
index 9e1550f5..4b512b02 100644
--- a/src/sampletones_application/logic/reconstruction/browser.py
+++ b/src/sampletones_application/logic/reconstruction/browser/logic.py
@@ -1,7 +1,7 @@
from pathlib import Path
from sampletones_application.config.managers.config import ConfigManager
-from sampletones_application.logic.reconstruction.browser_manager import BrowserManager
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
from sampletones_core.structures.tree import Tree
from sampletones_shared.utils.system.filesystem import remove_path
diff --git a/src/sampletones_application/logic/reconstruction/browser/manager.py b/src/sampletones_application/logic/reconstruction/browser/manager.py
new file mode 100644
index 00000000..4fd3deef
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/manager.py
@@ -0,0 +1,93 @@
+from pathlib import Path
+from typing import List, Tuple
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.config.managers.config import ConfigManager
+from sampletones_application.logic.reconstruction.browser.tree.collapse import (
+ collapse_single_child_containers,
+)
+from sampletones_application.logic.reconstruction.browser.tree.configurations.branch import (
+ build_configuration_branch,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_application.logic.reconstruction.browser.tree.order import order_children
+from sampletones_application.logic.reconstruction.browser.tree.prune import (
+ prune_empty_containers,
+)
+from sampletones_application.logic.reconstruction.browser.tree.samples.branch import (
+ build_sample_branch,
+)
+from sampletones_application.logic.reconstruction.browser.tree.scan import (
+ scan_reconstructions,
+)
+from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode
+
+
+class BrowserManager:
+ """Owns the reconstruction browser tree, rebuilt from one reading of the reconstructions directory.
+
+ A refresh scans the directory, builds the configuration branch and the sample branch from that
+ one reading, shapes what came out — empty headings pruned, lone headings folded into the row they
+ lead to, siblings ordered — and publishes the result as the tree both browser tabs render.
+ """
+
+ def __init__(
+ self,
+ config_manager: ConfigManager,
+ *,
+ language_manager: LanguageManager,
+ ) -> None:
+ self._language_manager = language_manager
+ self.config_manager = config_manager
+ self.reconstructions_directory = config_manager.get_reconstructions_directory()
+
+ self.tree = Tree()
+ self._scan = ReconstructionScan(entries=())
+
+ def set_reconstructions_directory(self, directory: Path) -> None:
+ self.reconstructions_directory = directory
+ self.refresh_tree()
+
+ def refresh_tree(self) -> None:
+ if not self.reconstructions_directory.is_dir():
+ self._scan = ReconstructionScan(entries=())
+ self.tree.set_root(None)
+ return
+
+ self._scan = scan_reconstructions(self.reconstructions_directory)
+ self.tree.set_root(self._build_root(self._scan))
+
+ def _build_root(self, scan: ReconstructionScan) -> TreeNode:
+ container_root = TreeNode(
+ name=self._language_manager["global.browser.label.root"],
+ node_type=NodeType.ROOT,
+ )
+ build_configuration_branch(
+ scan,
+ name=self._language_manager["global.browser.label.by_configuration"],
+ parent=container_root,
+ )
+ build_sample_branch(
+ scan,
+ name=self._language_manager["global.browser.label.by_sample"],
+ parent=container_root,
+ )
+
+ prune_empty_containers(container_root)
+ collapse_single_child_containers(container_root)
+ order_children(container_root)
+ return container_root
+
+ def get_all_reconstruction_files(self) -> List[Path]:
+ return sorted({entry.path for entry in self._scan.reconstructions})
+
+ def nodes_at(self, filepath: Path) -> Tuple[FileSystemNode, ...]:
+ """Answers every row the browser offers for a path, across both views.
+
+ A reconstruction is listed by its configuration and again by the sample it came from, so a
+ caller acting on the file rather than on one row — repainting a favorite star, for instance —
+ asks here once and hands the rows to each browser tab.
+ """
+ return self.tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath)
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py
new file mode 100644
index 00000000..1bb4026c
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py
@@ -0,0 +1,57 @@
+from sampletones_application.logic.reconstruction.browser.tree.containers import (
+ ARTIFICIAL_CONTAINERS,
+)
+from sampletones_core.configs.display import DISPLAY_SEPARATOR
+from sampletones_core.structures.tree import NodeType, TreeNode
+
+
+def collapse_single_child_containers(node: TreeNode) -> None:
+ """Folds every heading the browser invents that stands above a single row into that row.
+
+ A heading leading to one row asks the reader to open a level that tells them nothing new, so the
+ row takes the heading's name ahead of its own and rises into its place. Working from the deepest
+ rows upwards folds a whole chain at once, one separator per level: with a single configuration
+ present the configuration branch reads ``44.1 kHz·30 Hz·FFT·γ0·PTN`` as one row, and it grows back
+ into groups as soon as a second configuration arrives.
+
+ The row that survives keeps its node type, its path, its configuration and its children, so its
+ click behaviour, theme, context menu and favorite star carry over from before the fold. The two
+ branch roots stay in place, since each names a way of reading the whole tree, and a folder the disk
+ holds stays a folder of its own, since the configuration branch mirrors the disk.
+ """
+ for child in list(node.children):
+ collapse_single_child_containers(child)
+
+ if _can_fold(node):
+ _fold_into_child(node)
+
+
+def _can_fold(node: TreeNode) -> bool:
+ parent = node.parent
+ if parent is None or parent.node_type == NodeType.ROOT:
+ return False
+
+ if node.node_type not in ARTIFICIAL_CONTAINERS or len(node.children) != 1:
+ return False
+
+ return not _siblings_hold(node, _joined_name(node, node.children[0]))
+
+
+def _siblings_hold(node: TreeNode, name: str) -> bool:
+ """Whether a row beside this heading already reads as the name the fold would produce.
+
+ The folded row joins the siblings of the heading it replaces, and a browser row is addressed by
+ the names leading to it, so a heading whose fold would repeat a name beside it stays as it is.
+ """
+ return any(sibling.name == name for sibling in node.parent.children if sibling is not node)
+
+
+def _fold_into_child(node: TreeNode) -> None:
+ child = node.children[0]
+ child.name = _joined_name(node, child)
+ child.parent = node.parent
+ node.parent = None
+
+
+def _joined_name(node: TreeNode, child: TreeNode) -> str:
+ return DISPLAY_SEPARATOR.join([str(node.name), str(child.name)])
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/branch.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/branch.py
new file mode 100644
index 00000000..702c6a28
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/branch.py
@@ -0,0 +1,61 @@
+from sampletones_application.logic.reconstruction.browser.tree.configurations.grouping import (
+ organize_top_level_config_directories,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.directory import (
+ DirectoryEntry,
+ ScanEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import (
+ ReconstructionEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_core.structures.tree import (
+ FileSystemNode,
+ NodeType,
+ TreeNode,
+ create_directory_node,
+)
+
+
+def build_configuration_branch(
+ scan: ReconstructionScan,
+ *,
+ name: str,
+ parent: TreeNode,
+) -> TreeNode:
+ """Builds the branch listing reconstructions by the configuration that produced them.
+
+ The scanned folders appear as they sit on disk, and a top-level configuration directory is then
+ lifted under frequency ▶ method groups and named by its generators, so configurations sharing a
+ spectrum read side by side. A configuration directory nested inside a plain folder keeps its
+ friendly name in place, and a reconstruction outside every configuration directory is listed
+ here, this being the branch that follows the disk.
+ """
+ branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent)
+ for entry in scan.entries:
+ _append_entry(entry, parent=branch)
+
+ organize_top_level_config_directories(branch)
+ return branch
+
+
+def _append_entry(entry: ScanEntry, *, parent: TreeNode) -> None:
+ match entry:
+ case ReconstructionEntry():
+ FileSystemNode(
+ entry.name,
+ node_type=NodeType.FILE,
+ filepath=entry.path,
+ parent=parent,
+ )
+ case DirectoryEntry():
+ directory_node = create_directory_node(
+ entry.path,
+ name=entry.name,
+ config=entry.config,
+ parent=parent,
+ )
+ for child_entry in entry.entries:
+ _append_entry(child_entry, parent=directory_node)
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py
new file mode 100644
index 00000000..c98c3265
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py
@@ -0,0 +1,52 @@
+from sampletones_application.logic.reconstruction.browser.tree.configurations.naming import (
+ assign_display_names,
+ disambiguate_generator_siblings,
+)
+from sampletones_application.logic.reconstruction.browser.tree.containers import (
+ find_or_create_group,
+)
+from sampletones_core.configs.display import (
+ format_frequencies,
+ format_transformation,
+)
+from sampletones_core.structures.tree import (
+ ConfigNode,
+ FileSystemNode,
+ NodeType,
+ TreeNode,
+)
+
+
+def organize_top_level_config_directories(branch: TreeNode) -> None:
+ """Groups top-level config directories under frequencies/transformation nodes, leaving other folders flat.
+
+ A config directory moves under ``frequencies`` ▶ ``transformation`` artificial group nodes and is
+ renamed to its generator abbreviation, while any other top-level folder keeps the existing
+ flat friendly naming for the config directories nested inside it.
+ """
+ for child in list(branch.children):
+ match child:
+ case ConfigNode() if child.node_type == NodeType.DIRECTORY:
+ _attach_config_directory_under_groups(child, branch)
+ case FileSystemNode() if child.node_type == NodeType.DIRECTORY:
+ assign_display_names(child)
+
+ disambiguate_generator_siblings(branch)
+
+
+def _attach_config_directory_under_groups(
+ directory_node: ConfigNode,
+ branch: TreeNode,
+) -> None:
+ fields = directory_node.config
+ frequencies_node = find_or_create_group(
+ format_frequencies(fields.sr, fields.nf),
+ parent=branch,
+ )
+ transformation_node = find_or_create_group(
+ format_transformation(fields.sm, fields.tg),
+ parent=frequencies_node,
+ )
+
+ directory_node.name = fields.gn
+ directory_node.parent = transformation_node
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py
new file mode 100644
index 00000000..aac9cec3
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py
@@ -0,0 +1,42 @@
+from typing import List, Sequence, Tuple
+
+from sampletones_core.configs.display import unique_display_names
+from sampletones_core.structures.tree import ConfigNode, NodeType, TreeNode
+
+
+def disambiguate_generator_siblings(node: TreeNode) -> None:
+ """Appends a short config hash to generator directories sharing a name under one method group."""
+ if node.node_type == NodeType.GROUP:
+ _rename_config_directories(
+ [(directory_node, directory_node.config.gn) for directory_node in _config_directory_children(node)]
+ )
+
+ for child in node.children:
+ disambiguate_generator_siblings(child)
+
+
+def assign_display_names(node: TreeNode) -> None:
+ """Renames config-directory nodes to friendly labels, disambiguating colliding siblings.
+
+ Only directories whose names parse as reconstruction config directories are rewritten;
+ plain folders keep their on-disk name. The check is scoped per parent because duplicate
+ display names among siblings would otherwise collapse to duplicate widget tags downstream.
+ """
+ _rename_config_directories(
+ [(directory_node, directory_node.config.display_name) for directory_node in _config_directory_children(node)]
+ )
+ for child in node.children:
+ assign_display_names(child)
+
+
+def _config_directory_children(node: TreeNode) -> List[ConfigNode]:
+ return [child for child in node.children if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY]
+
+
+def _rename_config_directories(
+ proposed_names: Sequence[Tuple[ConfigNode, str]],
+) -> None:
+ """Names each configuration directory, marking those a sibling would otherwise shadow."""
+ labels = unique_display_names([(name, directory_node.config.ch) for directory_node, name in proposed_names])
+ for (directory_node, _), label in zip(proposed_names, labels):
+ directory_node.name = label
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/containers.py b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py
new file mode 100644
index 00000000..18687ede
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py
@@ -0,0 +1,42 @@
+from typing import Final, FrozenSet
+
+from sampletones_core.structures.tree import NodeType, TreeNode
+
+ARTIFICIAL_CONTAINERS: Final[FrozenSet[NodeType]] = frozenset(
+ {
+ NodeType.GROUP,
+ NodeType.SAMPLE,
+ }
+)
+
+
+def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode:
+ """Answers the group of this name under ``parent``, adding one where the parent holds none.
+
+ A group stands for something the disk states rather than holds — a frequency pair, a spectrum
+ method, a source folder — so a builder meeting that name again extends the group it already made.
+ """
+ return _find_or_create(name, node_type=NodeType.GROUP, parent=parent)
+
+
+def find_or_create_sample(name: str, *, parent: TreeNode) -> TreeNode:
+ """Answers the sample of this name under ``parent``, adding one where the parent holds none.
+
+ A sample stands for one source audio and gathers the reconstructions made from it. It carries a
+ node type of its own, so a folder and an audio of the same name stay two rows: each is looked up
+ among the siblings of its own kind.
+ """
+ return _find_or_create(name, node_type=NodeType.SAMPLE, parent=parent)
+
+
+def _find_or_create(
+ name: str,
+ *,
+ node_type: NodeType,
+ parent: TreeNode,
+) -> TreeNode:
+ for child in parent.children:
+ if isinstance(child, TreeNode) and child.node_type == node_type and child.name == name:
+ return child
+
+ return TreeNode(name, node_type=node_type, parent=parent)
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py
new file mode 100644
index 00000000..ccc1ec18
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py
@@ -0,0 +1,30 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Optional, Tuple, TypeAlias, Union
+
+from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import (
+ ReconstructionEntry,
+)
+from sampletones_core.reconstructions.converter.paths.fields import (
+ ConfigDirectoryFields,
+)
+
+ScanEntry: TypeAlias = Union["DirectoryEntry", ReconstructionEntry]
+
+
+@dataclass(frozen=True)
+class DirectoryEntry:
+ """A folder a scan met, holding the configuration its name states and the entries inside it.
+
+ A folder whose name encodes a reconstruction configuration carries those fields, read once here,
+ so every branch builder states the configuration from the record it already has. A folder holds
+ folders as readily as reconstructions, which is why the entry kinds are named together here.
+ """
+
+ path: Path
+ config: Optional[ConfigDirectoryFields]
+ entries: Tuple[ScanEntry, ...]
+
+ @property
+ def name(self) -> str:
+ return self.path.name
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py
new file mode 100644
index 00000000..ba4b4ce1
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py
@@ -0,0 +1,13 @@
+from dataclasses import dataclass
+from pathlib import Path
+
+
+@dataclass(frozen=True)
+class ReconstructionEntry:
+ """A reconstruction file a scan met, named by the audio it reconstructs."""
+
+ path: Path
+
+ @property
+ def name(self) -> str:
+ return self.path.stem
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py
new file mode 100644
index 00000000..c5d007e8
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py
@@ -0,0 +1,40 @@
+from dataclasses import dataclass
+from typing import List, Sequence, Tuple
+
+from sampletones_application.logic.reconstruction.browser.tree.entries.directory import (
+ DirectoryEntry,
+ ScanEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import (
+ ReconstructionEntry,
+)
+
+
+@dataclass(frozen=True)
+class ReconstructionScan:
+ """One reading of a reconstructions directory, shared by every browser branch.
+
+ Both branches describe the same disk because both describe this record: the configuration view
+ follows the entries as they sit, and the sample view regroups them by the audio they came from.
+ """
+
+ entries: Tuple[ScanEntry, ...]
+
+ @property
+ def reconstructions(self) -> Tuple[ReconstructionEntry, ...]:
+ return self.collect_reconstructions(self.entries)
+
+ @staticmethod
+ def collect_reconstructions(
+ entries: Sequence[ScanEntry],
+ ) -> Tuple[ReconstructionEntry, ...]:
+ """Flattens scanned entries into the reconstructions they hold, in the order the scan met them."""
+ collected: List[ReconstructionEntry] = []
+ for entry in entries:
+ match entry:
+ case ReconstructionEntry():
+ collected.append(entry)
+ case DirectoryEntry():
+ collected.extend(ReconstructionScan.collect_reconstructions(entry.entries))
+
+ return tuple(collected)
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/order.py b/src/sampletones_application/logic/reconstruction/browser/tree/order.py
new file mode 100644
index 00000000..901f8517
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/order.py
@@ -0,0 +1,25 @@
+from typing import Tuple
+
+from sampletones_core.structures.tree import NodeType, TreeNode
+from sampletones_shared.utils.text import NaturalSortKey, natural_sort_key
+
+
+def order_children(node: TreeNode) -> None:
+ """Sorts every set of siblings into reading order: what opens first, then names read naturally.
+
+ The pass runs once every label is final, so a row sits where its displayed name puts it — `8 kHz`
+ ahead of `44.1 kHz`, whatever the folder names on disk spell. The branches directly under the
+ container root keep the order the browser states them in.
+ """
+ for child in node.children:
+ order_children(child)
+
+ if node.node_type != NodeType.ROOT:
+ node.children = tuple(sorted(node.children, key=_sibling_key))
+
+
+def _sibling_key(node: TreeNode) -> Tuple[bool, NaturalSortKey]:
+ return (
+ node.node_type == NodeType.FILE,
+ natural_sort_key(str(node.name)),
+ )
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/prune.py b/src/sampletones_application/logic/reconstruction/browser/tree/prune.py
new file mode 100644
index 00000000..1bc61eef
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/prune.py
@@ -0,0 +1,20 @@
+from sampletones_application.logic.reconstruction.browser.tree.containers import (
+ ARTIFICIAL_CONTAINERS,
+)
+from sampletones_core.structures.tree import TreeNode
+
+
+def prune_empty_containers(node: TreeNode) -> None:
+ """Drops the containers the browser invents that gather nothing, deepest first.
+
+ A group or a sample is a heading the browser writes itself, so one left holding nothing says
+ nothing and leaves. Working from the deepest rows upwards lets a whole chain of such headings go
+ at once, the branch root among them, which keeps a reconstructions directory holding nothing to
+ show silent. A folder the disk holds stays where it is, since the configuration branch reads the
+ disk as it is.
+ """
+ for child in list(node.children):
+ prune_empty_containers(child)
+
+ if node.node_type in ARTIFICIAL_CONTAINERS and not node.children and node.parent is not None:
+ node.parent = None
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py
new file mode 100644
index 00000000..7ae3c774
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py
@@ -0,0 +1,36 @@
+from sampletones_application.logic.reconstruction.browser.tree.containers import (
+ find_or_create_group,
+ find_or_create_sample,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_application.logic.reconstruction.browser.tree.samples.variants import (
+ append_variants,
+ collect_variants,
+)
+from sampletones_core.structures.tree import NodeType, TreeNode
+
+
+def build_sample_branch(
+ scan: ReconstructionScan,
+ *,
+ name: str,
+ parent: TreeNode,
+) -> TreeNode:
+ """Builds the branch listing each source audio with the configurations that reconstructed it.
+
+ Every top-level configuration directory contributes its reconstructions under the source folders
+ they mirror, so one audio gathers its variants and each variant is labelled by its configuration.
+ """
+ branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent)
+ variants_by_source = collect_variants(scan)
+ for source in sorted(variants_by_source):
+ source_node = branch
+ for part in source.directory_parts:
+ source_node = find_or_create_group(part, parent=source_node)
+
+ audio_node = find_or_create_sample(source.name, parent=source_node)
+ append_variants(audio_node, variants_by_source[source])
+
+ return branch
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py
new file mode 100644
index 00000000..f6ce7811
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py
@@ -0,0 +1,14 @@
+from dataclasses import dataclass
+from typing import Tuple
+
+
+@dataclass(frozen=True, order=True)
+class SampleSource:
+ """The audio a set of reconstructions was made from, as its folder and name within a configuration.
+
+ Two configuration directories reconstructing one audio file mirror the same source subtree, so
+ the relative folder and the audio name together gather the variants of that audio.
+ """
+
+ directory_parts: Tuple[str, ...]
+ name: str
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/variant.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/variant.py
new file mode 100644
index 00000000..15960a45
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/variant.py
@@ -0,0 +1,14 @@
+from dataclasses import dataclass
+from pathlib import Path
+
+from sampletones_core.reconstructions.converter.paths.fields import (
+ ConfigDirectoryFields,
+)
+
+
+@dataclass(frozen=True)
+class SampleVariant:
+ """One reconstruction of a source audio, with the configuration that produced it."""
+
+ config: ConfigDirectoryFields
+ path: Path
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/variants.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/variants.py
new file mode 100644
index 00000000..79a4002d
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/variants.py
@@ -0,0 +1,50 @@
+from typing import Dict, List, Sequence
+
+from sampletones_application.logic.reconstruction.browser.tree.entries.directory import (
+ DirectoryEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_application.logic.reconstruction.browser.tree.samples.source import (
+ SampleSource,
+)
+from sampletones_application.logic.reconstruction.browser.tree.samples.variant import (
+ SampleVariant,
+)
+from sampletones_core.configs.display import unique_display_names
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+from sampletones_core.structures.tree import ConfigNode, NodeType, TreeNode
+
+
+def collect_variants(scan: ReconstructionScan) -> Dict[SampleSource, List[SampleVariant]]:
+ variants_by_source: Dict[SampleSource, List[SampleVariant]] = {}
+ for entry in scan.entries:
+ match entry:
+ case DirectoryEntry(config=ConfigDirectoryFields() as config):
+ for reconstruction in scan.collect_reconstructions(entry.entries):
+ relative_path = reconstruction.path.relative_to(entry.path)
+ source = SampleSource(
+ directory_parts=relative_path.parent.parts,
+ name=relative_path.stem,
+ )
+ variants_by_source.setdefault(source, []).append(
+ SampleVariant(config=config, path=reconstruction.path)
+ )
+
+ return variants_by_source
+
+
+def append_variants(
+ audio_node: TreeNode,
+ variants: Sequence[SampleVariant],
+) -> None:
+ labels = unique_display_names([(variant.config.display_name, variant.config.ch) for variant in variants])
+ for variant, label in zip(variants, labels):
+ ConfigNode(
+ label,
+ node_type=NodeType.FILE,
+ filepath=variant.path,
+ config=variant.config,
+ parent=audio_node,
+ )
diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py
new file mode 100644
index 00000000..56a4bb4c
--- /dev/null
+++ b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py
@@ -0,0 +1,49 @@
+from pathlib import Path
+from typing import List, Optional, Tuple
+
+from sampletones_application.logic.reconstruction.browser.tree.entries.directory import (
+ DirectoryEntry,
+ ScanEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import (
+ ReconstructionEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
+
+
+def scan_reconstructions(directory: Path) -> ReconstructionScan:
+ """Reads a reconstructions directory once, recording its folders and the reconstructions inside.
+
+ Every folder is recorded together with the configuration its name states, and every
+ reconstruction file beneath it. This single reading feeds both browser branches, so the two
+ views agree on what is on disk.
+ """
+ return ReconstructionScan(entries=_scan_entries(directory))
+
+
+def _scan_entries(directory: Path) -> Tuple[ScanEntry, ...]:
+ entries: List[ScanEntry] = []
+ for path in sorted(directory.iterdir()):
+ entry = _scan_path(path)
+ if entry is not None:
+ entries.append(entry)
+
+ return tuple(entries)
+
+
+def _scan_path(path: Path) -> Optional[ScanEntry]:
+ if path.is_dir():
+ return DirectoryEntry(
+ path=path,
+ config=ConfigDirectoryFields.from_directory_name(path.name),
+ entries=_scan_entries(path),
+ )
+
+ if path.suffix == EXT_FILE_RECONSTRUCTION:
+ return ReconstructionEntry(path=path)
+
+ return None
diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py
deleted file mode 100644
index b5881275..00000000
--- a/src/sampletones_application/logic/reconstruction/browser_manager.py
+++ /dev/null
@@ -1,122 +0,0 @@
-from pathlib import Path
-from typing import Dict, List, Optional, Tuple
-
-from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.config.managers.config import ConfigManager
-from sampletones_core.configs.display import DISPLAY_SEPARATOR, short_hash
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
-from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
-from sampletones_core.structures.tree import (
- FileSystemNode,
- NodeType,
- Tree,
- TreeNode,
-)
-
-
-class BrowserManager:
- def __init__(
- self,
- config_manager: ConfigManager,
- *,
- language_manager: LanguageManager,
- ) -> None:
- self._language_manager = language_manager
- self.config_manager = config_manager
- self.reconstructions_directory = config_manager.get_reconstructions_directory()
-
- self.tree = Tree()
-
- def set_reconstructions_directory(self, directory: Path) -> None:
- self.reconstructions_directory = directory
- self.refresh_tree()
-
- def refresh_tree(self) -> None:
- if not self.reconstructions_directory.exists() or not self.reconstructions_directory.is_dir():
- self.tree.set_root(None)
- return
-
- container_root = TreeNode(
- name=self._language_manager["global.browser.label.root"],
- node_type=NodeType.ROOT,
- )
- for path in sorted(self.reconstructions_directory.iterdir()):
- self._build_tree(path, parent=container_root)
-
- 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]:
- if not path.exists():
- return None
-
- if path.is_file():
- if path.suffix == EXT_FILE_RECONSTRUCTION:
- return FileSystemNode(
- path.stem,
- filepath=path,
- node_type=NodeType.FILE,
- parent=parent,
- )
- return None
-
- children_nodes = []
- for child_path in sorted(path.iterdir()):
- child_node = self._build_tree(child_path, parent=parent)
- if child_node is not None:
- children_nodes.append(child_node)
-
- directory_node = FileSystemNode(
- path.name,
- filepath=path,
- node_type=NodeType.DIRECTORY,
- parent=parent,
- )
- for child_node in children_nodes:
- child_node.parent = directory_node
-
- return directory_node
-
- def _assign_directory_display_names(self, node: TreeNode) -> None:
- """Renames config-directory nodes to friendly labels, disambiguating colliding siblings.
-
- Only directories whose names parse as reconstruction config directories are rewritten;
- plain folders keep their on-disk name. The check is scoped per parent because duplicate
- display names among siblings would otherwise collapse to duplicate widget tags downstream.
- """
- self._rename_config_directory_children(node)
- for child in node.children:
- self._assign_directory_display_names(child)
-
- def _rename_config_directory_children(self, node: TreeNode) -> None:
- groups: Dict[str, List[Tuple[FileSystemNode, ConfigDirectoryFields]]] = {}
- for child in node.children:
- if not isinstance(child, FileSystemNode) or child.node_type != NodeType.DIRECTORY:
- continue
-
- fields = ConfigDirectoryFields.from_directory_name(child.filepath.name)
- if fields is None:
- continue
-
- groups.setdefault(fields.display_name, []).append((child, fields))
-
- for display_name, members in groups.items():
- if len(members) == 1:
- directory_node, _ = members[0]
- directory_node.name = display_name
- continue
-
- for directory_node, fields in members:
- directory_node.name = f"{display_name}{DISPLAY_SEPARATOR}#{short_hash(fields.ch)}"
-
- def get_all_reconstruction_files(self) -> List[Path]:
- file_nodes = [
- node
- for node in self.tree.collect_leaves()
- if isinstance(node, FileSystemNode) and node.node_type == NodeType.FILE
- ]
- return [node.filepath for node in file_nodes]
diff --git a/src/sampletones_application/logic/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py
index b7e37e0c..e74ddf55 100644
--- a/src/sampletones_application/logic/reconstruction/data.py
+++ b/src/sampletones_application/logic/reconstruction/data.py
@@ -131,13 +131,7 @@ def _load_original_audio(
normalize=config.general.normalize,
quantize=config.general.quantize,
)
- except (
- FileNotFoundError,
- IOError,
- IsADirectoryError,
- PermissionError,
- OSError,
- ):
+ except (FileNotFoundError, IsADirectoryError, PermissionError, OSError):
logger.warning(f"Could not load original audio from '{audio_filepath}'. The original is unavailable")
return None
diff --git a/src/sampletones_application/logic/reconstruction/feature.py b/src/sampletones_application/logic/reconstruction/feature.py
index 430dd972..a6a653f6 100644
--- a/src/sampletones_application/logic/reconstruction/feature.py
+++ b/src/sampletones_application/logic/reconstruction/feature.py
@@ -12,6 +12,12 @@
@dataclass(frozen=True)
class FeatureData:
+ """The envelopes of every channel a reconstruction holds, keyed by channel.
+
+ A reconstruction exports one entry per channel whatever it sounds, so a subscript answers
+ for any of them and :attr:`Features.has_frames` says which ones play.
+ """
+
generators: Dict[GeneratorName, Features]
def __getitem__(self, generator_name: GeneratorName) -> Features:
@@ -36,9 +42,3 @@ def load(cls, reconstruction: Reconstruction) -> FeatureData:
generators[generator_name] = feature
return cls(generators=generators)
-
- 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 8171b68e..aec69a88 100644
--- a/src/sampletones_application/logic/reconstruction/instruments.py
+++ b/src/sampletones_application/logic/reconstruction/instruments.py
@@ -2,7 +2,9 @@
import numpy as np
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
+)
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
from sampletones_application.utils.callbacks.queue import CallbackQueue
from sampletones_application.view_model.reconstruction.instruments import (
@@ -11,8 +13,10 @@
from sampletones_application.view_model.reconstruction.update import (
ReconstructionUpdate,
)
+from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.exporters import Features
+from sampletones_core.formats.famitracker.footprint import features_footprint
from sampletones_core.types.feature import FeatureValue
from sampletones_shared.utils.callbacks import CallbackMixin
@@ -39,27 +43,61 @@ def __init__(
self.on_reconstruction_instrument_updated: Optional[OnReconstructionInstrumentUpdatedCallback] = None
def update_display(self) -> None:
+ generators = self._current_generators()
+ self.call(self.on_view_changed, self._build_view_model(generators))
+ self.call(self.on_feature_data_changed, generators)
+
+ def refresh_view(self) -> None:
+ """Reports which channels play and the sizes they occupy, leaving the displayed envelopes as they are.
+
+ A regeneration replaces what an instrument exports, so the byte figures and the standing-by
+ channels settle on it. The envelopes themselves are left to the edit that started the
+ regeneration, so a field the user is still typing in keeps what they wrote.
+ """
+ self.call(self.on_view_changed, self._build_view_model(self._current_generators()))
+
+ def _current_generators(self) -> Optional[Dict[GeneratorName, Features]]:
feature_data = self.reconstruction_manager.current_features
- if feature_data is None:
- self.call(
- self.on_view_changed,
- ReconstructionInstrumentsViewModel(
- reconstruction_loaded=False,
- available_generators=frozenset(),
- ),
+ return None if feature_data is None else feature_data.generators
+
+ def _build_view_model(
+ self,
+ generators: Optional[Dict[GeneratorName, Features]],
+ ) -> ReconstructionInstrumentsViewModel:
+ if generators is None:
+ return ReconstructionInstrumentsViewModel(
+ reconstruction_loaded=False,
+ playing_generators=frozenset(),
+ footprint=None,
)
- self.call(self.on_feature_data_changed, None)
- return
- available_generators: FrozenSet[GeneratorName] = frozenset(feature_data.generators.keys())
- self.call(
- self.on_view_changed,
- ReconstructionInstrumentsViewModel(
- reconstruction_loaded=True,
- available_generators=available_generators,
- ),
+ playing_generators: FrozenSet[GeneratorName] = frozenset(
+ generator_name for generator_name, features in generators.items() if features.has_frames
+ )
+ return ReconstructionInstrumentsViewModel(
+ reconstruction_loaded=True,
+ playing_generators=playing_generators,
+ footprint=self._build_footprint(generators),
+ )
+
+ def _build_footprint(
+ self,
+ generators: Dict[GeneratorName, Features],
+ ) -> SampleFootprintViewModel:
+ """Measures each playing channel's instrument as the size its own export writes.
+
+ A reconstruction has no loop flag of its own — that belongs to a sample placed in a
+ project — so each instrument is measured playing its envelopes once, matching what
+ **Export instrument...** produces. A channel standing by is written nowhere, so it is
+ measured nowhere and the sample's total names what the export costs.
+ """
+ return SampleFootprintViewModel.from_footprints(
+ {
+ generator_name: features_footprint(features, loop=False)
+ for generator_name, features in generators.items()
+ if features.has_frames
+ }
)
- self.call(self.on_feature_data_changed, feature_data.generators)
def handle_pitch_value_changed(
self,
@@ -80,6 +118,7 @@ def handle_bar_point_clicked(
feature_key: FeatureKey,
data: np.ndarray,
) -> None:
+ self._report_edited_size(generator_name, feature_key, data)
self._schedule_reconstruction_update(
ReconstructionUpdate(
generator_name,
@@ -94,6 +133,7 @@ def handle_raw_data_changed(
feature_key: FeatureKey,
data: np.ndarray,
) -> None:
+ self._report_edited_size(generator_name, feature_key, data)
self._schedule_reconstruction_update(
ReconstructionUpdate(
generator_name,
@@ -102,6 +142,46 @@ def handle_raw_data_changed(
)
)
+ def _report_edited_size(
+ self,
+ generator_name: GeneratorName,
+ feature_key: FeatureKey,
+ data: np.ndarray,
+ ) -> None:
+ """Reports what the edited envelope costs as the edit arrives, ahead of its regeneration.
+
+ Measuring the envelope the user just wrote keeps the figures answering what is on screen
+ while the reconstruction is still being rebuilt. The regenerated instruments report again
+ once they land, so the figures settle on the exported form.
+ """
+ generators = self._current_generators()
+ if generators is None:
+ return
+
+ self.call(
+ self.on_view_changed,
+ self._build_view_model(
+ self._with_edit(
+ generators,
+ generator_name,
+ feature_key,
+ data,
+ )
+ ),
+ )
+
+ def _with_edit(
+ self,
+ generators: Dict[GeneratorName, Features],
+ generator_name: GeneratorName,
+ feature_key: FeatureKey,
+ data: np.ndarray,
+ ) -> Dict[GeneratorName, Features]:
+ """The loaded channels with one envelope replaced, leaving the loaded ones as they are."""
+ edited = generators[generator_name].model_copy(deep=True)
+ edited[feature_key] = data
+ return {**generators, generator_name: edited}
+
def _schedule_reconstruction_update(
self,
update: ReconstructionUpdate,
@@ -138,6 +218,4 @@ def _get_features(self, generator_name: GeneratorName) -> Features:
current_features = self.reconstruction_manager.current_features
assert current_features is not None, "Current features should not be None"
- features = current_features.get_generator_features(generator_name)
- assert features is not None, f"Features for generator {generator_name} should not be None"
- return features
+ return current_features[generator_name]
diff --git a/src/sampletones_application/logic/reconstruction/manager.py b/src/sampletones_application/logic/reconstruction/manager.py
index c91db8ae..d88078b7 100644
--- a/src/sampletones_application/logic/reconstruction/manager.py
+++ b/src/sampletones_application/logic/reconstruction/manager.py
@@ -1,7 +1,7 @@
from pathlib import Path
from typing import Optional
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.logic.reconstruction.data import ReconstructionData
from sampletones_application.logic.reconstruction.feature import FeatureData
from sampletones_application.logic.reconstruction.session import ReconstructionSession
diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py
index ab4a51d3..39e0a735 100644
--- a/src/sampletones_application/logic/reconstruction/reconstruction.py
+++ b/src/sampletones_application/logic/reconstruction/reconstruction.py
@@ -70,6 +70,7 @@ def __init__(
self._tracker_backends = tracker_backends
self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION
+ self._playing_generators: FrozenSet[GeneratorName] = frozenset()
self._selected_generators: List[GeneratorName] = []
self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None
@@ -90,18 +91,10 @@ def display_reconstruction(self) -> None:
if not reconstruction_data:
return
- available_generators: FrozenSet[GeneratorName] = frozenset(
- reconstruction_data.reconstruction.instructions.keys()
- )
- self._selected_generators = list(available_generators)
+ self._playing_generators = frozenset(reconstruction_data.reconstruction.playing_generators)
+ self._selected_generators = self._in_channel_order(self._playing_generators)
- reconstruction_file, original_audio = self._build_path_view_models(reconstruction_data)
- view_model = ReconstructionViewModel(
- reconstruction_loaded=True,
- available_generators=available_generators,
- reconstruction_file=reconstruction_file,
- original_audio=original_audio,
- )
+ view_model = self._build_view_model(reconstruction_data)
if not view_model.audio_source_enabled:
self._current_audio_source = AudioSourceType.RECONSTRUCTION
@@ -119,6 +112,9 @@ def update_reconstruction(self) -> None:
if not reconstruction_data:
return
+ self._adopt_playing_generators(frozenset(reconstruction_data.reconstruction.playing_generators))
+
+ self.call(self.on_view_changed, self._build_view_model(reconstruction_data))
self.call(
self.on_waveform_update_changed,
reconstruction_data.waveform_data(),
@@ -127,8 +123,42 @@ def update_reconstruction(self) -> None:
if self._current_audio_source != AudioSourceType.ORIGINAL:
self._emit_audio_data()
+ def _adopt_playing_generators(
+ self,
+ playing_generators: FrozenSet[GeneratorName],
+ ) -> None:
+ """Carries the reader's choice of channels across an edit.
+
+ An edit puts a channel in play or takes it out. A channel that keeps playing keeps
+ whatever the reader chose for it, and one gaining its first frame joins the waveform,
+ so the checkboxes report what plays while a deliberate choice survives.
+ """
+ selected = (set(self._selected_generators) & playing_generators) | (
+ playing_generators - self._playing_generators
+ )
+ self._playing_generators = playing_generators
+ self._selected_generators = self._in_channel_order(frozenset(selected))
+
+ @staticmethod
+ def _in_channel_order(generators: FrozenSet[GeneratorName]) -> List[GeneratorName]:
+ return [generator_name for generator_name in GeneratorName.items() if generator_name in generators]
+
+ def _build_view_model(
+ self,
+ reconstruction_data: ReconstructionData,
+ ) -> ReconstructionViewModel:
+ reconstruction_file, original_audio = self._build_path_view_models(reconstruction_data)
+ return ReconstructionViewModel(
+ reconstruction_loaded=True,
+ playing_generators=self._playing_generators,
+ selected_generators=frozenset(self._selected_generators),
+ reconstruction_file=reconstruction_file,
+ original_audio=original_audio,
+ )
+
def close_reconstruction(self) -> None:
self._current_audio_source = AudioSourceType.RECONSTRUCTION
+ self._playing_generators = frozenset()
self._selected_generators = []
self.call(self.on_audio_data_changed, None)
self.call(self.on_waveform_cleared)
@@ -140,7 +170,8 @@ def close_reconstruction(self) -> None:
self.on_view_changed,
ReconstructionViewModel(
reconstruction_loaded=False,
- available_generators=frozenset(),
+ playing_generators=frozenset(),
+ selected_generators=frozenset(),
reconstruction_file=empty_path,
original_audio=empty_path,
),
@@ -182,8 +213,7 @@ def request_export_instrument_dialog(
if not reconstruction_data:
raise AssertionError("Expected reconstruction data to be loaded before exporting an instrument")
- feature_data = reconstruction_data.feature_data
- if generator_name not in feature_data.generators:
+ if generator_name not in reconstruction_data.reconstruction.playing_generators:
return
instrument_name = self._get_instrument_name(generator_name)
@@ -267,11 +297,12 @@ def handle_export_instruments_confirmed(
destination: Path,
tracker_format: TrackerFormat,
) -> None:
- """Writes every generator slice of the loaded reconstruction to ``destination``.
+ """Writes the slice of every playing channel of the loaded reconstruction to ``destination``.
The destination names the batch: each slice takes its generator suffix from the stem,
so a format gathering the whole reconstruction into one document writes it there while
- one keeping an instrument per file writes its slices beside it.
+ one keeping an instrument per file writes its slices beside it. A channel standing by
+ describes no frame and is written nowhere.
Args:
destination: The file the export was confirmed with.
@@ -292,6 +323,7 @@ def handle_export_instruments_confirmed(
instrument_slice_name(base_name, generator_name),
)
for generator_name, feature in reconstruction_data.feature_data.generators.items()
+ if feature.has_frames
),
nes_frequency=self._nes_frequency(),
)
diff --git a/src/sampletones_application/logic/render/__init__.py b/src/sampletones_application/logic/render/__init__.py
new file mode 100644
index 00000000..d44f30fd
--- /dev/null
+++ b/src/sampletones_application/logic/render/__init__.py
@@ -0,0 +1,7 @@
+from .logic import SongRenderLogic
+from .protocol import SongRenderServiceProtocol
+
+__all__ = [
+ "SongRenderLogic",
+ "SongRenderServiceProtocol",
+]
diff --git a/src/sampletones_application/logic/render/logic.py b/src/sampletones_application/logic/render/logic.py
new file mode 100644
index 00000000..0cc91e35
--- /dev/null
+++ b/src/sampletones_application/logic/render/logic.py
@@ -0,0 +1,308 @@
+from pathlib import Path
+from typing import Callable, Dict, Optional, Tuple
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.config.managers.config import ConfigManager
+from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.sequencer.channels import ALL_CHANNELS
+from sampletones_application.logic.sequencer.playback.synthesizer import (
+ RowSynthesizer,
+ SongLength,
+)
+from sampletones_application.logic.shared.project_source import ProjectSnapshot
+from sampletones_application.services.render.result import RenderResult, RenderStage
+from sampletones_application.services.result import (
+ ServiceCancelled,
+ ServiceError,
+ ServiceProgress,
+ ServiceStarted,
+ ServiceSuccess,
+)
+from sampletones_application.view_model.shared.render import (
+ ACTIVE_PHASES,
+ RenderPhase,
+ SongRenderSettings,
+ SongRenderViewModel,
+)
+from sampletones_core.audio.writers import (
+ DEFAULT_AUDIO_FORMAT,
+ AudioFormat,
+ available_audio_formats,
+ available_depths,
+)
+from sampletones_core.parallelization import ETAEstimator
+from sampletones_shared.constants.project import DEFAULT_EXPORT_NAME
+from sampletones_shared.logger import logger
+from sampletones_shared.types.callback import PathCallback, VoidCallback
+from sampletones_shared.utils.callbacks import CallbackMixin
+from sampletones_shared.utils.system.paths import get_filename, replace_suffix
+
+from .protocol import SongRenderServiceProtocol
+
+
+class SongRenderLogic(CallbackMixin):
+ """Owns writing the open song to an audio file: what is written, where, and how far it has got.
+
+ The song is rendered through the engine that plays it, over the document as it stood when the
+ render was asked for, so a file describes one state of the project however the editing goes
+ on. The rate is whatever the chosen format is written at, which the engine follows, so the
+ tempo the groove states is the tempo the file holds.
+
+ A render is an exclusive operation, held from the moment the dialog opens until it closes, so
+ the phase alone reports whether the application is busy with one.
+ """
+
+ def __init__(
+ self,
+ project_controller: ProjectController,
+ config_manager: ConfigManager,
+ session_manager: SessionManager,
+ render_service: SongRenderServiceProtocol,
+ *,
+ language_manager: LanguageManager,
+ is_operation_active: Callable[[], bool],
+ ) -> None:
+ self._project_controller = project_controller
+ self._config_manager = config_manager
+ self._session_manager = session_manager
+ self._service = render_service
+ self._is_operation_active = is_operation_active
+ self._msg_cancelling = language_manager["settings.render.message.status_cancelling"]
+ self._msg_cancelled = language_manager["settings.render.message.status_cancelled"]
+ self._msg_completed = language_manager["settings.render.message.status_completed"]
+ self._msg_failed = language_manager["settings.render.message.status_failed"]
+ self._eta_template = language_manager["global.dialog.template.time_estimation"]
+ self._stage_messages: Dict[RenderStage, str] = {
+ RenderStage.SYNTHESIS: language_manager["settings.render.message.status_synthesis"],
+ RenderStage.ENCODING: language_manager["settings.render.message.status_encoding"],
+ }
+
+ self._formats: Tuple[AudioFormat, ...] = available_audio_formats()
+ self._settings = SongRenderSettings.initial(self._offered_format())
+ self._phase: RenderPhase = RenderPhase.IDLE
+ self._destination: Optional[Path] = None
+ self._status_text: str = ""
+ self._progress: float = 0.0
+
+ self._service.subscribe(self._on_service_result)
+
+ self.on_view_changed: Optional[Callable[[SongRenderViewModel], None]] = None
+ self.on_choose_destination: Optional[Callable[[Path, AudioFormat], None]] = None
+ self.on_success: Optional[PathCallback] = None
+ self.on_error: Optional[Callable[[Exception], None]] = None
+ self.on_cancelled: Optional[VoidCallback] = None
+
+ @property
+ def is_active(self) -> bool:
+ """A render occupies the application from the dialog opening until it closes."""
+ return self._phase in ACTIVE_PHASES
+
+ def open(self) -> bool:
+ """Offers the render settings for the open song, reporting whether the dialog took over.
+
+ The destination is proposed afresh each time, so it carries the name the project is known
+ by into the directory audio was last written to. This is where the exclusivity is claimed,
+ which is why the busy authority is asked here and nowhere else along the way.
+ """
+ if self._is_operation_active():
+ logger.warning("An exclusive operation is already in progress; the render was not offered")
+ return False
+
+ self._phase = RenderPhase.CONFIGURING
+ self._destination = self._proposed_destination()
+ self._status_text = ""
+ self._progress = 0.0
+ self._emit_view()
+ return True
+
+ def close(self) -> None:
+ """Returns to idle once the dialog is done with, releasing the application."""
+ self._phase = RenderPhase.IDLE
+ self._progress = 0.0
+
+ def apply(self, settings: SongRenderSettings) -> None:
+ """Takes the choices the dialog stands at, renaming the destination after the format.
+
+ Args:
+ settings: The reconciled choices the dialog reports.
+ """
+ if self._phase != RenderPhase.CONFIGURING:
+ return
+
+ previous = self._settings.spec.extension
+ self._settings = settings
+ extension = settings.spec.extension
+ if extension != previous:
+ self._destination = replace_suffix(self._require_destination(), previous, extension)
+
+ self._emit_view()
+
+ def request_destination(self) -> None:
+ """Asks for the file the render writes, starting from the one standing."""
+ self.call(
+ self.on_choose_destination,
+ self._require_destination(),
+ self._settings.spec.audio_format,
+ )
+
+ def set_destination(self, destination: Path) -> None:
+ """Writes the render to ``destination``, remembering its directory for the next one."""
+ self._destination = destination
+ self._session_manager.set_audio_path(destination)
+ self._emit_view()
+
+ def start(self) -> None:
+ """Renders the song to the chosen file, from its first row to its last.
+
+ The kernel is built here, over a snapshot of the document and at the rate the chosen
+ format is written at, so the worker reads a project that stands still while the editing
+ carries on.
+ """
+ if self._phase != RenderPhase.CONFIGURING:
+ return
+
+ length = self._length()
+ if length.samples <= 0:
+ logger.warning("The song holds no rows to render")
+ return
+
+ started = self._service.start(
+ synthesizer=self._build_synthesizer(),
+ destination=self._require_destination(),
+ spec=self._settings.spec,
+ normalize=self._settings.normalize,
+ total_samples=length.samples,
+ )
+ if not started:
+ return
+
+ self._phase = RenderPhase.RENDERING
+ self._status_text = self._stage_messages[RenderStage.SYNTHESIS]
+ self._progress = 0.0
+ self._emit_view()
+
+ def cancel(self) -> None:
+ """Asks a running render to stop at its next row or block."""
+ if not self._service.is_running():
+ return
+
+ self._phase = RenderPhase.CANCELLING
+ self._status_text = self._msg_cancelling
+ self._emit_view()
+ self._service.cancel()
+
+ def cleanup(self) -> None:
+ """Winds a running render down for application exit."""
+ self._service.shutdown()
+
+ def _on_service_result(self, result: RenderResult) -> None:
+ match result:
+ case ServiceStarted():
+ self._report(self._stage_messages[RenderStage.SYNTHESIS], 0.0)
+ case ServiceProgress() as progress:
+ self._handle_progress(progress)
+ case ServiceSuccess(value=destination):
+ self._on_render_complete(destination)
+ case ServiceError(exception=exception):
+ self._on_render_error(exception)
+ case ServiceCancelled():
+ self._on_cancellation_complete()
+
+ def _handle_progress(self, progress: ServiceProgress[RenderStage]) -> None:
+ """Puts a pass's report on the bar, holding the message a stop was asked under."""
+ if self._phase == RenderPhase.CANCELLING:
+ return
+
+ self._phase = RenderPhase.RENDERING
+ stage = progress.current_item
+ status_text = self._status_text if stage is None else self._stage_status(stage, progress.eta_seconds)
+ self._report(status_text, progress.completed / max(progress.total, 1))
+
+ def _stage_status(self, stage: RenderStage, eta_seconds: Optional[float]) -> str:
+ """What the pass is doing, and how long it has left where an estimate stands."""
+ status_text = self._stage_messages[stage]
+ eta_string = ETAEstimator.format_duration(eta_seconds)
+ if eta_string:
+ status_text += self._eta_template.format(eta_string=eta_string)
+
+ return status_text
+
+ def _on_render_complete(self, destination: Path) -> None:
+ self._phase = RenderPhase.COMPLETED
+ self._report(self._msg_completed, 1.0)
+ self.call(self.on_success, destination)
+
+ def _on_render_error(self, exception: Exception) -> None:
+ self._phase = RenderPhase.FAILED
+ self._report(self._msg_failed, 0.0)
+ self.call(self.on_error, exception)
+
+ def _on_cancellation_complete(self) -> None:
+ self._phase = RenderPhase.CANCELLED
+ self._report(self._msg_cancelled, 0.0)
+ self.call(self.on_cancelled)
+
+ def _report(self, status_text: str, progress: float) -> None:
+ self._status_text = status_text
+ self._progress = progress
+ self._emit_view()
+
+ def _build_synthesizer(self) -> RowSynthesizer:
+ """The kernel a render runs on: the engine that plays the song, over a held document.
+
+ Every channel sounds and the level stays at unity, since muting and the master gain are
+ choices a listener makes about what reaches the speakers, while a render describes the
+ document.
+ """
+ sample_rate = self._settings.spec.sample_rate
+ return RowSynthesizer(
+ ProjectSnapshot.capture(self._project_controller),
+ self._config_manager.config.with_library(sample_rate=sample_rate),
+ active_channels=lambda: ALL_CHANNELS,
+ sample_rate=lambda: sample_rate,
+ )
+
+ def _length(self) -> SongLength:
+ return SongLength.measure(
+ self._project_controller.project,
+ sample_rate=self._settings.spec.sample_rate,
+ )
+
+ def _offered_format(self) -> AudioFormat:
+ """The container a dialog opens on: the usual one, or the first this installation writes."""
+ if DEFAULT_AUDIO_FORMAT in self._formats:
+ return DEFAULT_AUDIO_FORMAT
+
+ return next(iter(self._formats), DEFAULT_AUDIO_FORMAT)
+
+ def _proposed_destination(self) -> Path:
+ """The file the dialog opens on: the project's name, where audio was last written."""
+ name = self._project_controller.name or DEFAULT_EXPORT_NAME
+ return self._session_manager.get_audio_path() / get_filename(name, self._settings.spec.extension)
+
+ def _require_destination(self) -> Path:
+ """The file the open dialog writes to.
+
+ Raises:
+ SystemError: when a render is driven while its dialog is closed.
+ """
+ if self._destination is None:
+ raise SystemError("A render is set up only while its dialog is open")
+
+ return self._destination
+
+ def _emit_view(self) -> None:
+ self.call(self.on_view_changed, self._build_view())
+
+ def _build_view(self) -> SongRenderViewModel:
+ return SongRenderViewModel(
+ phase=self._phase,
+ formats=self._formats,
+ depths=available_depths(self._settings.spec.audio_format),
+ settings=self._settings,
+ destination=self._require_destination(),
+ total_samples=self._length().samples,
+ status_text=self._status_text,
+ progress=self._progress,
+ )
diff --git a/src/sampletones_application/logic/render/protocol.py b/src/sampletones_application/logic/render/protocol.py
new file mode 100644
index 00000000..03461dbd
--- /dev/null
+++ b/src/sampletones_application/logic/render/protocol.py
@@ -0,0 +1,34 @@
+from pathlib import Path
+from typing import Callable, Protocol
+
+from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer
+from sampletones_application.services.render.result import RenderResult
+from sampletones_core.audio.writers import AudioOutputSpec
+
+
+class SongRenderServiceProtocol(Protocol):
+ """The slice of the render service the render logic drives.
+
+ Typing the collaborator structurally keeps the logic layer bound to the service's result
+ contract alone; the composition root supplies the real service. The kernel named here is the
+ one the logic builds, which the service takes through the wider contract every consumer of a
+ song's audio is written against.
+ """
+
+ def subscribe(self, handler: Callable[[RenderResult], None]) -> None: ...
+
+ def start(
+ self,
+ *,
+ synthesizer: RowSynthesizer,
+ destination: Path,
+ spec: AudioOutputSpec,
+ normalize: bool,
+ total_samples: int,
+ ) -> bool: ...
+
+ def cancel(self) -> None: ...
+
+ def is_running(self) -> bool: ...
+
+ def shutdown(self) -> None: ...
diff --git a/src/sampletones_application/logic/sequencer/browser.py b/src/sampletones_application/logic/sequencer/browser.py
index 6e6ff157..43fc3565 100644
--- a/src/sampletones_application/logic/sequencer/browser.py
+++ b/src/sampletones_application/logic/sequencer/browser.py
@@ -2,7 +2,7 @@
from sampletones_application.config.managers.config import ConfigManager
from sampletones_application.logic.project.controller import ProjectController
-from sampletones_application.logic.reconstruction.browser_manager import BrowserManager
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
from sampletones_core.project.instruments.sample import Sample
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.structures.tree import Tree
diff --git a/src/sampletones_application/logic/sequencer/channels.py b/src/sampletones_application/logic/sequencer/channels.py
index f5d080f7..aff9faef 100644
--- a/src/sampletones_application/logic/sequencer/channels.py
+++ b/src/sampletones_application/logic/sequencer/channels.py
@@ -1,6 +1,8 @@
from typing import Callable, Final, FrozenSet, Optional
-from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel
+from sampletones_application.view_model.sequencer.channels import (
+ SequencerChannelsViewModel,
+)
from sampletones_core.constants.enums import GeneratorName
from sampletones_shared.utils.callbacks import CallbackMixin
@@ -11,7 +13,7 @@
class SequencerChannelsLogic(CallbackMixin):
"""Owns which tracker channels the song player silences.
- Holds monitoring state for the open document alone, the way :class:`SequencerGridLogic`
+ Holds monitoring state for the open document alone, the way :class:`SequencerTrackerLogic`
holds the visible frame: the project keeps every channel, so saving, export, and the
history stack read the full song. A document transition calls :meth:`reset`, which returns
the whole set to audible.
diff --git a/src/sampletones_application/logic/sequencer/clipboard/__init__.py b/src/sampletones_application/logic/sequencer/clipboard/__init__.py
new file mode 100644
index 00000000..689e06d8
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/clipboard/__init__.py
@@ -0,0 +1,14 @@
+from .cache import ParsedBlockCache
+from .order import OrderBlockText
+from .samples import ProjectSampleDirectory, SampleDirectory
+from .store import SequencerClipboard
+from .tracker import TrackerBlockText
+
+__all__ = [
+ "OrderBlockText",
+ "ParsedBlockCache",
+ "ProjectSampleDirectory",
+ "SampleDirectory",
+ "SequencerClipboard",
+ "TrackerBlockText",
+]
diff --git a/src/sampletones_application/logic/sequencer/clipboard/cache.py b/src/sampletones_application/logic/sequencer/clipboard/cache.py
new file mode 100644
index 00000000..df915db8
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/clipboard/cache.py
@@ -0,0 +1,25 @@
+from typing import Callable, Generic, Optional, TypeVar
+
+BlockT = TypeVar("BlockT")
+
+
+class ParsedBlockCache(Generic[BlockT]):
+ """Holds the block a text last read as, so asking again about it costs one comparison.
+
+ A menu opening asks whether a paste has anything to write and the paste that follows asks
+ for the block itself, both about the text standing on the system clipboard, so one parse
+ serves every question put about that text.
+ """
+
+ def __init__(self, parse: Callable[[str], Optional[BlockT]]) -> None:
+ self._parse = parse
+ self._text: Optional[str] = None
+ self._block: Optional[BlockT] = None
+
+ def block(self, text: str) -> Optional[BlockT]:
+ """The block a text reads as, parsed on its first reading and held for the rest."""
+ if text != self._text:
+ self._text = text
+ self._block = self._parse(text)
+
+ return self._block
diff --git a/src/sampletones_application/logic/sequencer/clipboard/fields.py b/src/sampletones_application/logic/sequencer/clipboard/fields.py
new file mode 100644
index 00000000..3e17c3aa
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/clipboard/fields.py
@@ -0,0 +1,85 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Dict, Final, Generic, Optional, TypeVar
+
+from sampletones_shared.constants.symbols import DOT, HEXADECIMAL, MIXED
+
+KeyT = TypeVar("KeyT")
+ValueT = TypeVar("ValueT")
+
+HEXADECIMAL_BASE: Final[int] = 16
+
+
+@dataclass(frozen=True)
+class FieldReading(Generic[ValueT]):
+ """What one printed field states about the cell it stands for.
+
+ ``stated`` separates the two readings a value of ``None`` carries: a field printing the dots
+ an empty cell shows states emptiness, and one printing the marks a mixed cell shows states
+ nothing at all, so its key stays out of the block and a paste passes that cell by.
+ """
+
+ value: Optional[ValueT]
+ stated: bool
+
+ @classmethod
+ def of(cls, value: Optional[ValueT]) -> FieldReading[ValueT]:
+ return cls(value=value, stated=True)
+
+ @classmethod
+ def mixed(cls) -> FieldReading[ValueT]:
+ return cls(value=None, stated=False)
+
+
+def state_mixed(width: int) -> str:
+ """The marks a mixed cell prints, filling its field so every row line reads as a grid."""
+ return MIXED * width
+
+
+def read_placeholder(field: str) -> Optional[FieldReading[ValueT]]:
+ """The reading a field of one repeated mark carries: emptiness, or nothing at all.
+
+ Returns:
+ The reading, present while the field is dots throughout or marks throughout. A field
+ carrying anything else is left to the reader of its own kind.
+ """
+ marks = set(field)
+ if marks == {MIXED}:
+ return FieldReading.mixed()
+
+ if marks == {DOT}:
+ return FieldReading.of(None)
+
+ return None
+
+
+def read_hexadecimal(field: str) -> Optional[int]:
+ """The number a field of hexadecimal digits names, present while every character is one.
+
+ Digits are read in either case, so a field typed by hand reads as the one the grid prints.
+ """
+ digits = field.upper()
+ if not digits or any(digit not in HEXADECIMAL for digit in digits):
+ return None
+
+ return int(digits, HEXADECIMAL_BASE)
+
+
+def store_reading(
+ values: Dict[KeyT, Optional[ValueT]],
+ key: KeyT,
+ reading: Optional[FieldReading[ValueT]],
+) -> bool:
+ """Puts the cell a reading states into the map, answering whether the field had a reading.
+
+ A field the form has no reading for answers ``False``, which is what refuses a whole text
+ rather than letting one unreadable cell reach the grid.
+ """
+ if reading is None:
+ return False
+
+ if reading.stated:
+ values[key] = reading.value
+
+ return True
diff --git a/src/sampletones_application/logic/sequencer/clipboard/header.py b/src/sampletones_application/logic/sequencer/clipboard/header.py
new file mode 100644
index 00000000..9bd24a59
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/clipboard/header.py
@@ -0,0 +1,88 @@
+from dataclasses import dataclass
+from typing import Final, Optional, Tuple
+
+BLOCK_MAGIC: Final[str] = "SampleToNES/1"
+ROW_KEY: Final[str] = "rows"
+LABEL_SEPARATOR: Final[str] = "="
+SPAN_SEPARATOR: Final[str] = ".."
+HEADER_TOKEN_COUNT: Final[int] = 4
+
+
+@dataclass(frozen=True)
+class BlockShape:
+ """How far a block reaches: the rows it holds, and the span its fields cross.
+
+ The span is stated in the coordinates of the grid the block was read from, so a tracker
+ block names the slots it began and ended on and a reading of it lands on the same kinds of
+ subcolumn.
+ """
+
+ rows: int
+ first: int
+ last: int
+
+ @property
+ def width(self) -> int:
+ return self.last - self.first + 1
+
+
+def state_header(*, grid: str, span_key: str, shape: BlockShape) -> str:
+ """The line a block opens with, naming the grid it came from and the shape it covers."""
+ rows = f"{ROW_KEY}{LABEL_SEPARATOR}{shape.rows}"
+ span = f"{span_key}{LABEL_SEPARATOR}{shape.first}{SPAN_SEPARATOR}{shape.last}"
+ return f"{BLOCK_MAGIC} {grid} {rows} {span}"
+
+
+def parse_header(
+ line: str,
+ *,
+ grid: str,
+ span_key: str,
+) -> Optional[BlockShape]:
+ """The shape a header states, present while it names this grid in the form written here.
+
+ The shape is also the declaration the body is held to, so a text whose lines state a
+ different count or width is refused by the reader that asked for it.
+ """
+ tokens = line.split()
+ if len(tokens) != HEADER_TOKEN_COUNT or tokens[0] != BLOCK_MAGIC or tokens[1] != grid:
+ return None
+
+ rows = _read_count(tokens[2], ROW_KEY)
+ span = _read_span(tokens[3], span_key)
+ if rows is None or span is None:
+ return None
+
+ first, last = span
+ return BlockShape(rows=rows, first=first, last=last)
+
+
+def _read_label(token: str, label: str) -> Optional[str]:
+ """What a ``label=value`` token states, present while it carries the label asked for."""
+ name, separator, value = token.partition(LABEL_SEPARATOR)
+ if name != label or not separator:
+ return None
+
+ return value
+
+
+def _read_count(token: str, label: str) -> Optional[int]:
+ """The count a ``rows=4`` token names, present while it covers at least one row."""
+ value = _read_label(token, label)
+ if value is None or not value.isdigit() or int(value) < 1:
+ return None
+
+ return int(value)
+
+
+def _read_span(token: str, label: str) -> Optional[Tuple[int, int]]:
+ """The bounds a ``slots=3..11`` token names, present while they stand in reading order."""
+ value = _read_label(token, label)
+ if value is None:
+ return None
+
+ first, separator, last = value.partition(SPAN_SEPARATOR)
+ if not separator or not first.isdigit() or not last.isdigit() or int(last) < int(first):
+ return None
+
+ return int(first), int(last)
diff --git a/src/sampletones_application/logic/sequencer/clipboard/order.py b/src/sampletones_application/logic/sequencer/clipboard/order.py
new file mode 100644
index 00000000..c065e42e
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/clipboard/order.py
@@ -0,0 +1,112 @@
+from typing import Dict, Final, List, Optional
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.logic.sequencer.order.block import BlockKey, OrderBlock
+from sampletones_application.view_model.sequencer.region import OrderRegion
+from sampletones_core.utils.display import display_id
+
+from .fields import (
+ FieldReading,
+ read_hexadecimal,
+ read_placeholder,
+ state_mixed,
+ store_reading,
+)
+from .header import BlockShape, parse_header, state_header
+
+ORDER_GRID: Final[str] = "order"
+POSITION_KEY: Final[str] = "positions"
+ENTRY_WIDTH: Final[int] = len(display_id(None))
+
+
+class OrderBlockText:
+ """States an order block as the lines the table prints, and reads the same form back.
+
+ One line per channel row, positions running across it, every field carrying what the table
+ shows in its cell: a pattern index, the dots of a silent slot, or the marks a master cell
+ fills its field with where the channels beneath it disagree.
+ """
+
+ def state(self, block: OrderBlock, region: OrderRegion) -> str:
+ """The text a copy puts on the system clipboard, the region supplying the shape.
+
+ The region is what states the positions the block stands on, since a mixed cell leaves
+ its key out and a block alone therefore names less than the rectangle it came from.
+ """
+ shape = BlockShape(
+ rows=len(region.rows),
+ first=region.first_position,
+ last=region.last_position,
+ )
+ lines = [state_header(grid=ORDER_GRID, span_key=POSITION_KEY, shape=shape)]
+ lines.extend(self._state_row(block, shape, row_offset) for row_offset in range(shape.rows))
+ return "\n".join(lines)
+
+ def parse(self, text: str) -> Optional[OrderBlock]:
+ """The block a text states, present while it is one this table writes.
+
+ Text naming another grid, declaring a shape its lines do not fill, or carrying a field
+ the form has no reading for states no block, so the slot the order copied into stands.
+ """
+ lines = text.strip().splitlines()
+ if not lines:
+ return None
+
+ shape = parse_header(lines[0], grid=ORDER_GRID, span_key=POSITION_KEY)
+ if shape is None or shape.rows > len(CHANNEL_AXIS) or len(lines) != shape.rows + 1:
+ return None
+
+ return self._read_rows(lines[1:], shape)
+
+ def _state_row(
+ self,
+ block: OrderBlock,
+ shape: BlockShape,
+ row_offset: int,
+ ) -> str:
+ """One row of the block, its fields standing in the order the positions run."""
+ return " ".join(
+ self._state_entry(
+ block,
+ (row_offset, position_offset),
+ )
+ for position_offset in range(shape.width)
+ )
+
+ @staticmethod
+ def _state_entry(block: OrderBlock, key: BlockKey) -> str:
+ if key not in block.entries:
+ return state_mixed(ENTRY_WIDTH)
+
+ return display_id(block.entries[key])
+
+ def _read_rows(
+ self,
+ lines: List[str],
+ shape: BlockShape,
+ ) -> Optional[OrderBlock]:
+ entries: Dict[BlockKey, Optional[int]] = {}
+ for row_offset, line in enumerate(lines):
+ fields = line.split()
+ if len(fields) != shape.width:
+ return None
+
+ for position_offset, field in enumerate(fields):
+ key = (row_offset, position_offset)
+ if not store_reading(entries, key, self._read_entry(field)):
+ return None
+
+ return OrderBlock(entries=entries)
+
+ @staticmethod
+ def _read_entry(field: str) -> Optional[FieldReading[int]]:
+ """The pattern a field names, present while it states an index or one of the two marks."""
+ placeholder: Optional[FieldReading[int]] = read_placeholder(field)
+ if placeholder is not None:
+ return placeholder
+
+ pattern_index = read_hexadecimal(field)
+ if pattern_index is None:
+ return None
+
+ return FieldReading.of(pattern_index)
diff --git a/src/sampletones_application/logic/sequencer/clipboard/samples.py b/src/sampletones_application/logic/sequencer/clipboard/samples.py
new file mode 100644
index 00000000..2e409952
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/clipboard/samples.py
@@ -0,0 +1,38 @@
+from typing import Optional, Protocol
+
+from sampletones_application.logic.project.controller import ProjectController
+
+
+class SampleDirectory(Protocol):
+ """The samples a note can name, read the way a grid prints them: by list position."""
+
+ def position_of(self, sample_id: str) -> Optional[int]: ...
+
+ def sample_at(self, position: int) -> Optional[str]: ...
+
+
+class ProjectSampleDirectory:
+ """The samples the open project holds, in the order the samples panel lists them.
+
+ The project is read on each lookup, because opening a document and every undo put another
+ one in place, so a block stated as text names whichever sample stands at that position now.
+ """
+
+ def __init__(self, project_controller: ProjectController) -> None:
+ self._controller = project_controller
+
+ def position_of(self, sample_id: str) -> Optional[int]:
+ """Where a sample stands in the list, present while the project holds it."""
+ samples = self._controller.project.samples
+ if samples.get(sample_id) is None:
+ return None
+
+ return samples.get_index(sample_id)
+
+ def sample_at(self, position: int) -> Optional[str]:
+ """The sample a position names, present while the list reaches that far."""
+ samples = self._controller.project.samples
+ if 0 <= position < len(samples):
+ return samples[position].id
+
+ return None
diff --git a/src/sampletones_application/logic/sequencer/clipboard/store.py b/src/sampletones_application/logic/sequencer/clipboard/store.py
new file mode 100644
index 00000000..5d19bd90
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/clipboard/store.py
@@ -0,0 +1,37 @@
+from typing import Optional
+
+from sampletones_application.logic.sequencer.order import OrderBlock
+from sampletones_application.logic.sequencer.tracker import TrackerBlock
+
+
+class SequencerClipboard:
+ """Holds the block each sequencer grid last copied, one slot per grid.
+
+ Separate slots are what keep a paste in the grid it belongs to: the tracker reads only what a
+ tracker copied, so a block never has to be asked which grid it came from.
+
+ A slot outlives the project it was filled from, because a project is replaced on every undo
+ and redo as well as on opening a document, and a copy the reader made is theirs to keep across
+ all of it. A note naming a sample the project in place lacks is settled where the block is
+ written.
+ """
+
+ def __init__(self) -> None:
+ self._tracker_block: Optional[TrackerBlock] = None
+ self._order_block: Optional[OrderBlock] = None
+
+ @property
+ def tracker_block(self) -> Optional[TrackerBlock]:
+ """The block the tracker last copied, present once a copy has been made."""
+ return self._tracker_block
+
+ def store_tracker_block(self, block: TrackerBlock) -> None:
+ self._tracker_block = block
+
+ @property
+ def order_block(self) -> Optional[OrderBlock]:
+ """The block the order last copied, present once a copy has been made."""
+ return self._order_block
+
+ def store_order_block(self, block: OrderBlock) -> None:
+ self._order_block = block
diff --git a/src/sampletones_application/logic/sequencer/clipboard/tracker.py b/src/sampletones_application/logic/sequencer/clipboard/tracker.py
new file mode 100644
index 00000000..53b4f2b3
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/clipboard/tracker.py
@@ -0,0 +1,273 @@
+from typing import Callable, Dict, Final, List, Optional
+
+from sampletones_application.logic.sequencer.tracker.block import (
+ BlockKey,
+ BlockNote,
+ TrackerBlock,
+)
+from sampletones_application.view_model.sequencer.region import TrackerRegion
+from sampletones_application.view_model.sequencer.slot import (
+ SLOT_COUNT,
+ column_slot_base,
+ slot_from_flat,
+)
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.general import (
+ MAX_TRANSPOSE,
+ MAX_VOLUME,
+ MIN_TRANSPOSE,
+ SILENT_VOLUME,
+)
+from sampletones_core.project.instruments.note_off import NoteOff
+from sampletones_core.utils.display import (
+ NOTE_OFF,
+ display_id,
+ display_transpose,
+ display_volume,
+)
+from sampletones_shared.constants.symbols import PLUS, SIGNS
+
+from .fields import (
+ FieldReading,
+ read_hexadecimal,
+ read_placeholder,
+ state_mixed,
+ store_reading,
+)
+from .header import BlockShape, parse_header, state_header
+from .samples import SampleDirectory
+
+TRACKER_GRID: Final[str] = "tracker"
+SLOT_KEY: Final[str] = "slots"
+COLUMN_SEPARATOR: Final[str] = "|"
+NOTE_WIDTH: Final[int] = len(display_id(None))
+TRANSPOSE_WIDTH: Final[int] = len(display_transpose(None))
+VOLUME_WIDTH: Final[int] = len(display_volume(None))
+
+
+class TrackerBlockText:
+ """States a tracker block as the lines the grid prints, and reads the same form back.
+
+ Every field carries what the grid shows in its cell, which is what makes the three states a
+ cell reaches a block in survive a round trip: a value reads as its value, an empty cell as
+ the dots beneath it, and a mixed one as the marks filling its field. A bar stands between
+ columns, so a line reads as the row it was taken from.
+
+ A note names its sample by the list position the grid prints, so a block carried to another
+ project plays whichever sample stands at that position there.
+ """
+
+ def __init__(self, *, samples: SampleDirectory) -> None:
+ self._samples = samples
+
+ def state(self, block: TrackerBlock, region: TrackerRegion) -> str:
+ """The text a copy puts on the system clipboard, the region supplying the shape.
+
+ The region is what states the slots the block stands on, since a mixed cell leaves its
+ key out and a block alone therefore names less than the rectangle it was read from.
+ """
+ shape = BlockShape(
+ rows=len(region.rows),
+ first=region.first_slot,
+ last=region.last_slot,
+ )
+ lines = [state_header(grid=TRACKER_GRID, span_key=SLOT_KEY, shape=shape)]
+ lines.extend(
+ self._state_row(
+ block,
+ region,
+ row_offset,
+ )
+ for row_offset in range(shape.rows)
+ )
+ return "\n".join(lines)
+
+ def parse(self, text: str) -> Optional[TrackerBlock]:
+ """The block a text states, present while it is one this grid writes.
+
+ Text naming another grid, declaring a shape its lines do not fill, or carrying a field
+ the form has no reading for states no block, so the slot the tracker copied into stands.
+ """
+ lines = text.strip().splitlines()
+ if not lines:
+ return None
+
+ shape = parse_header(lines[0], grid=TRACKER_GRID, span_key=SLOT_KEY)
+ if shape is None or shape.last >= SLOT_COUNT or len(lines) != shape.rows + 1:
+ return None
+
+ return self._read_rows(lines[1:], shape)
+
+ def _state_row(
+ self,
+ block: TrackerBlock,
+ region: TrackerRegion,
+ row_offset: int,
+ ) -> str:
+ """One row of the block, its fields in slot order and its columns held apart by a bar."""
+ base = column_slot_base(slot_from_flat(region.first_slot).generator)
+ fields: List[str] = []
+ for position, slot in enumerate(region.slots):
+ if position > 0 and slot.generator != region.slots[position - 1].generator:
+ fields.append(COLUMN_SEPARATOR)
+
+ key = (row_offset, region.first_slot + position - base)
+ fields.append(self._state_slot(block, slot.subcolumn, key))
+
+ return " ".join(fields)
+
+ def _state_slot(
+ self,
+ block: TrackerBlock,
+ subcolumn: SubColumn,
+ key: BlockKey,
+ ) -> str:
+ match subcolumn:
+ case SubColumn.INSTRUMENT:
+ return self._state_note(block.notes, key)
+ case SubColumn.TRANSPOSE:
+ return self._state_number(
+ block.transposes,
+ key,
+ display_transpose,
+ TRANSPOSE_WIDTH,
+ )
+ case SubColumn.VOLUME:
+ return self._state_number(
+ block.volumes,
+ key,
+ display_volume,
+ VOLUME_WIDTH,
+ )
+
+ def _state_note(
+ self,
+ notes: Dict[BlockKey, Optional[BlockNote]],
+ key: BlockKey,
+ ) -> str:
+ """What the note column prints at a cell, a sample naming the position it stands at.
+
+ A note whose sample the project in place lacks prints as mixed, so reading the text back
+ passes that cell by, the way a paste passes over a sample it has nothing to place.
+ """
+ if key not in notes:
+ return state_mixed(NOTE_WIDTH)
+
+ match notes[key]:
+ case NoteOff():
+ return NOTE_OFF
+ case str() as sample_id:
+ position = self._samples.position_of(sample_id)
+ return state_mixed(NOTE_WIDTH) if position is None else display_id(position)
+ case _:
+ return display_id(None)
+
+ @staticmethod
+ def _state_number(
+ values: Dict[BlockKey, Optional[int]],
+ key: BlockKey,
+ display: Callable[[Optional[int]], str],
+ width: int,
+ ) -> str:
+ if key not in values:
+ return state_mixed(width)
+
+ return display(values[key])
+
+ def _read_rows(
+ self,
+ lines: List[str],
+ shape: BlockShape,
+ ) -> Optional[TrackerBlock]:
+ """The block a body states, each kind of subcolumn gathered into a map of its own."""
+ base = column_slot_base(slot_from_flat(shape.first).generator)
+ notes: Dict[BlockKey, Optional[BlockNote]] = {}
+ transposes: Dict[BlockKey, Optional[int]] = {}
+ volumes: Dict[BlockKey, Optional[int]] = {}
+ for row_offset, line in enumerate(lines):
+ fields = line.replace(COLUMN_SEPARATOR, " ").split()
+ if len(fields) != shape.width:
+ return None
+
+ for position, field in enumerate(fields):
+ slot = slot_from_flat(shape.first + position)
+ key = (row_offset, shape.first + position - base)
+ match slot.subcolumn:
+ case SubColumn.INSTRUMENT:
+ read = store_reading(
+ notes,
+ key,
+ self._read_note(field),
+ )
+ case SubColumn.TRANSPOSE:
+ read = store_reading(
+ transposes,
+ key,
+ self._read_transpose(field),
+ )
+ case SubColumn.VOLUME:
+ read = store_reading(
+ volumes,
+ key,
+ self._read_volume(field),
+ )
+
+ if not read:
+ return None
+
+ return TrackerBlock(
+ notes=notes,
+ transposes=transposes,
+ volumes=volumes,
+ )
+
+ def _read_note(self, field: str) -> Optional[FieldReading[BlockNote]]:
+ """The note a field states: the sample standing at the position it names, a cut, or emptiness.
+
+ A position the project's samples fall short of states nothing, so a paste passes that
+ cell by rather than silencing it.
+ """
+ placeholder: Optional[FieldReading[BlockNote]] = read_placeholder(field)
+ if placeholder is not None:
+ return placeholder
+
+ if field == NOTE_OFF:
+ return FieldReading.of(NoteOff())
+
+ position = read_hexadecimal(field)
+ if position is None:
+ return None
+
+ sample_id = self._samples.sample_at(position)
+ return FieldReading.mixed() if sample_id is None else FieldReading.of(sample_id)
+
+ @staticmethod
+ def _read_transpose(field: str) -> Optional[FieldReading[int]]:
+ """The transpose a signed field states, present while it lies in the range a row accepts."""
+ placeholder: Optional[FieldReading[int]] = read_placeholder(field)
+ if placeholder is not None:
+ return placeholder
+
+ sign = field[:1]
+ magnitude = read_hexadecimal(field[1:])
+ if sign not in SIGNS or magnitude is None:
+ return None
+
+ transpose = magnitude if sign == PLUS else -magnitude
+ if not MIN_TRANSPOSE <= transpose <= MAX_TRANSPOSE:
+ return None
+
+ return FieldReading.of(transpose)
+
+ @staticmethod
+ def _read_volume(field: str) -> Optional[FieldReading[int]]:
+ """The volume a field states, present while it lies in the range a row accepts."""
+ placeholder: Optional[FieldReading[int]] = read_placeholder(field)
+ if placeholder is not None:
+ return placeholder
+
+ volume = read_hexadecimal(field)
+ if volume is None or not SILENT_VOLUME <= volume <= MAX_VOLUME:
+ return None
+
+ return FieldReading.of(volume)
diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py
index 68c5d30e..32f1672b 100644
--- a/src/sampletones_application/logic/sequencer/history_detail.py
+++ b/src/sampletones_application/logic/sequencer/history_detail.py
@@ -1,7 +1,13 @@
-from typing import Dict, Final, List, Optional
+from typing import Dict, Final, List, Optional, Set
-from sampletones_application.logic.sequencer.grid import SequencerGridLogic
from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic
+from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic
+from sampletones_application.view_model.sequencer.region import (
+ OrderCell,
+ OrderRegion,
+ TrackerCell,
+ TrackerRegion,
+)
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
from sampletones_application.view_model.shared.history import (
HistoryDetail,
@@ -10,12 +16,19 @@
HistoryDetailWord,
HistoryDetailWordSegment,
)
-from sampletones_core.constants.enums import FeatureKey, GeneratorName, abbreviate_generator_names
+from sampletones_core.constants.enums import (
+ FeatureKey,
+ GeneratorName,
+ abbreviate_generator_names,
+)
from sampletones_core.utils.display import display_id, display_transpose, display_volume
Segments = HistoryDetail
_ARROW: Final[str] = ">"
+_RANGE: Final[str] = "-"
+
+
_SUBCOLUMN_LETTERS: Final[Dict[SubColumn, str]] = {
SubColumn.INSTRUMENT: "i",
SubColumn.TRANSPOSE: "t",
@@ -44,6 +57,11 @@
}
+def _span(first: int, last: int) -> str:
+ """Reads a run of indices as the pair it lies between."""
+ return f"{display_id(first)}{_RANGE}{display_id(last)}"
+
+
class SequencerHistoryDetail:
"""Builds the coloured detail line for each undoable sequencer gesture.
@@ -60,10 +78,10 @@ class SequencerHistoryDetail:
def __init__(
self,
- grid_logic: SequencerGridLogic,
+ tracker_logic: SequencerTrackerLogic,
samples_logic: SequencerSamplesLogic,
) -> None:
- self._grid_logic = grid_logic
+ self._tracker_logic = tracker_logic
self._samples_logic = samples_logic
def edit_row(
@@ -120,35 +138,47 @@ def clear_subcolumn(
affected = (
GeneratorName.items()
if subcolumn is SubColumn.INSTRUMENT
- else self._grid_logic.relevant_generators(row_index)
+ else self._tracker_logic.relevant_generators(row_index)
)
segments = list(self._location(row_index, generator, affected))
segments.append(self._subcolumn(subcolumn))
return tuple(segments)
- def adjust_transpose(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- delta: int,
- ) -> Segments:
- affected = self._grid_logic.relevant_generators(row_index)
- segments = list(self._location(row_index, generator, affected))
- segments.append(
+ def adjust_transpose(self, region: TrackerRegion, delta: int) -> Segments:
+ """Reads as the cells a shift covers, followed by the semitones it moves them."""
+ return (
+ *self._tracker_region(region),
self._segment(display_transpose(delta), HistoryDetailRole.TRANSPOSE),
)
- return tuple(segments)
- def adjust_volume(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- delta: int,
- ) -> Segments:
- affected = self._grid_logic.relevant_generators(row_index)
- segments = list(self._location(row_index, generator, affected))
- segments.append(self._segment(f"{delta:+d}", HistoryDetailRole.VOLUME))
- return tuple(segments)
+ def adjust_volume(self, region: TrackerRegion, delta: int) -> Segments:
+ """Reads as the cells a shift covers, followed by the steps it moves them."""
+ return (
+ *self._tracker_region(region),
+ self._segment(f"{delta:+d}", HistoryDetailRole.VOLUME),
+ )
+
+ def tracker_block(self, region: TrackerRegion) -> Segments:
+ """Reads as the frame, the channels a block spans and the rows it covers."""
+ return self._tracker_region(region)
+
+ def tracker_paste(self, cell: TrackerCell) -> Segments:
+ """Reads as the cell a block was written from, the one place a paste chooses."""
+ return self._location(cell.row, cell.generator, GeneratorName.items())
+
+ def order_block(self, region: OrderRegion) -> Segments:
+ """Reads as the positions a block covers and the channels its rows reach."""
+ return (
+ self._frame_range(region.first_position, region.last_position),
+ self._channel(self._covered_channels(set(region.generators))),
+ )
+
+ def order_paste(self, cell: OrderCell) -> Segments:
+ """Reads as the cell a block was written from, the one place a paste chooses."""
+ return (
+ self._frame(cell.position),
+ self._channel(self._covered_channels({cell.generator})),
+ )
def add_frame(self, position: int) -> Segments:
return (self._frame(position + 1),)
@@ -159,7 +189,8 @@ def remove_frame(self, position: int) -> Segments:
def clear_frame(self, position: int) -> Segments:
return (self._frame(position),)
- def duplicate_frame(self, position: int) -> Segments:
+ def copy_frame(self, position: int) -> Segments:
+ """Reads as source frame to copy, which is what both duplicating and cloning produce."""
return (self._frame(position), self._arrow(), self._frame(position + 1))
def move_frame(self, from_position: int, to_position: int) -> Segments:
@@ -270,9 +301,21 @@ def _edit_row_generators(
return [generator]
if sample_id is not None:
- return self._grid_logic.used_generators(sample_id)
+ return self._tracker_logic.used_generators(sample_id)
+
+ return self._tracker_logic.relevant_generators(row_index)
- return self._grid_logic.relevant_generators(row_index)
+ def _tracker_region(self, region: TrackerRegion) -> Segments:
+ """Reads a rectangle of the tracker as its frame, the channels it spans and the rows it covers.
+
+ Every gesture over a region reads the same way, so a block and a shift describe the cells
+ they reach in one form.
+ """
+ return (
+ self._frame(self._tracker_logic.frame_index),
+ self._channel(self._covered_channels(set(region.columns))),
+ self._row_range(region.first_row, region.last_row),
+ )
def _location(
self,
@@ -282,7 +325,7 @@ def _location(
) -> Segments:
channels = [generator] if generator is not None else affected
return (
- self._frame(self._grid_logic.frame_index),
+ self._frame(self._tracker_logic.frame_index),
self._channel(channels),
self._row(row_index),
)
@@ -299,6 +342,38 @@ def _row(self, index: int) -> HistoryDetailSegment:
role=HistoryDetailRole.ROW,
)
+ def _row_range(self, first_row: int, last_row: int) -> HistoryDetailSegment:
+ """Reads a span of rows as one row token, a single row standing as its own index."""
+ if first_row == last_row:
+ return self._row(first_row)
+
+ return HistoryDetailSegment(
+ text=_span(first_row, last_row),
+ role=HistoryDetailRole.ROW,
+ )
+
+ def _frame_range(self, first_position: int, last_position: int) -> HistoryDetailSegment:
+ """Reads a span of positions as one frame token, a single position standing as its own."""
+ if first_position == last_position:
+ return self._frame(first_position)
+
+ return HistoryDetailSegment(
+ text=_span(first_position, last_position),
+ role=HistoryDetailRole.FRAME,
+ )
+
+ @staticmethod
+ def _covered_channels(covered: Set[Optional[GeneratorName]]) -> List[GeneratorName]:
+ """The channels a run of columns names, an aggregate one standing for all it summarises.
+
+ Both grids carry a column that answers for every channel — the tracker's sample column and
+ the order's master row — so a gesture reaching one of them reads as the whole set.
+ """
+ if None in covered:
+ return GeneratorName.items()
+
+ return [generator for generator in GeneratorName.items() if generator in covered]
+
def _channel(self, generators: List[GeneratorName]) -> HistoryDetailSegment:
return HistoryDetailSegment(
text=abbreviate_generator_names(generators),
diff --git a/src/sampletones_application/logic/sequencer/order/__init__.py b/src/sampletones_application/logic/sequencer/order/__init__.py
new file mode 100644
index 00000000..f6dd4e09
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/order/__init__.py
@@ -0,0 +1,11 @@
+from .block import OrderBlock
+from .order import SequencerOrderLogic
+from .reader import OrderBlockReader
+from .writer import OrderBlockWriter
+
+__all__ = [
+ "OrderBlock",
+ "OrderBlockReader",
+ "OrderBlockWriter",
+ "SequencerOrderLogic",
+]
diff --git a/src/sampletones_application/logic/sequencer/order/block.py b/src/sampletones_application/logic/sequencer/order/block.py
new file mode 100644
index 00000000..3ba479ea
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/order/block.py
@@ -0,0 +1,23 @@
+from dataclasses import dataclass
+from typing import Dict, Optional, Tuple
+
+BlockKey = Tuple[int, int]
+
+
+@dataclass(frozen=True)
+class OrderBlock:
+ """A rectangle of the order table, addressed by the offsets it was read at.
+
+ A key is a row offset paired with a position offset, both counted from the cell the block
+ begins at, so a block carries its own shape and lands wherever it is anchored.
+
+ A cell reaches the block in one of three states, and the map holds them apart: a key carrying
+ an index plays that pattern, a key carrying ``None`` silences the slot, and an absent key
+ states that the block says nothing about that cell — which is how a master row its channels
+ disagree over stays transparent to whatever it is pasted onto.
+
+ Absence also settles how far a paste grows the order: a column the block says nothing about
+ reaches nothing, so the order ends where the last written column does.
+ """
+
+ entries: Dict[BlockKey, Optional[int]]
diff --git a/src/sampletones_application/logic/sequencer/order.py b/src/sampletones_application/logic/sequencer/order/order.py
similarity index 65%
rename from src/sampletones_application/logic/sequencer/order.py
rename to src/sampletones_application/logic/sequencer/order/order.py
index b1c8e4cd..15d27ac5 100644
--- a/src/sampletones_application/logic/sequencer/order.py
+++ b/src/sampletones_application/logic/sequencer/order/order.py
@@ -3,7 +3,7 @@
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.view_model.sequencer.order import (
OrderEntryViewModel,
- SequencerOrderGridViewModel,
+ SequencerOrderTrackerViewModel,
SequencerOrderViewModel,
)
from sampletones_core.constants.enums import GeneratorName
@@ -14,7 +14,7 @@
class SequencerOrderLogic(CallbackMixin):
"""Builds the order arrangement view model and exposes order mutations.
- Navigation (the current frame) is owned by :class:`SequencerGridLogic`; this
+ Navigation (the current frame) is owned by :class:`SequencerTrackerLogic`; this
class is only responsible for which pattern index each channel plays at every
order position, and how to change that arrangement.
"""
@@ -22,12 +22,12 @@ class is only responsible for which pattern index each channel plays at every
def __init__(self, project_controller: ProjectController) -> None:
self._controller = project_controller
- self.on_order_changed: Optional[Callable[[SequencerOrderGridViewModel], None]] = None
+ self.on_order_changed: Optional[Callable[[SequencerOrderTrackerViewModel], None]] = None
- def build_order(self) -> SequencerOrderGridViewModel:
+ def build_order(self) -> SequencerOrderTrackerViewModel:
song = self._controller.project.song
channels = {generator: self._build_channel_view(generator, song) for generator in GeneratorName.items()}
- return SequencerOrderGridViewModel(
+ return SequencerOrderTrackerViewModel(
position_count=song.order_length(),
channels=channels,
)
@@ -50,6 +50,37 @@ def set_master_entry(self, position: int, pattern_index: Optional[int]) -> None:
for generator in GeneratorName.items():
self._controller.set_order_entry(generator, position, pattern_index)
+ def write_entry(
+ self,
+ generator: Optional[GeneratorName],
+ position: int,
+ pattern_index: Optional[int],
+ ) -> None:
+ """Plays a pattern index at a position, the master row settling every channel at once.
+
+ This is the rule the table's two kinds of row follow, kept in one place so a gesture
+ reaching across them writes what the reader typing into each by hand would.
+ """
+ if generator is None:
+ self.set_master_entry(position, pattern_index)
+ else:
+ self.set_order_entry(generator, position, pattern_index)
+
+ def entry(self, generator: GeneratorName, position: int) -> Optional[int]:
+ """The pattern index a channel plays at a position, empty past the order's last frame."""
+ order = self._controller.song.order
+ if position >= len(order):
+ return None
+
+ return order[position].get(generator)
+
+ def position_count(self) -> int:
+ return self._controller.order_length
+
+ def append_frame(self) -> None:
+ """Adds one empty frame (all channels silent) after the order's last."""
+ self._controller.append_frame()
+
def remove_from_order(self, position: int) -> None:
self._controller.remove_frame(position)
@@ -58,9 +89,13 @@ def insert_frame(self, position: int) -> None:
self._controller.insert_frame(position)
def duplicate_frame(self, position: int) -> None:
- """Inserts a copy of the frame at ``position`` directly after it."""
+ """Repeats the frame at ``position`` directly after it, playing the same patterns."""
self._controller.duplicate_frame(position)
+ def clone_frame(self, position: int) -> None:
+ """Inserts a copy of the frame at ``position`` directly after it, with its own patterns."""
+ self._controller.clone_frame(position)
+
def clear_frame(self, position: int) -> None:
"""Empties every channel in the frame at ``position``."""
self._controller.clear_frame(position)
diff --git a/src/sampletones_application/logic/sequencer/order/reader.py b/src/sampletones_application/logic/sequencer/order/reader.py
new file mode 100644
index 00000000..c1481970
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/order/reader.py
@@ -0,0 +1,51 @@
+from typing import Dict, Optional
+
+from sampletones_application.view_model.sequencer.region import OrderRegion
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_shared.utils.agreement import Agreement
+
+from .block import BlockKey, OrderBlock
+from .order import SequencerOrderLogic
+
+
+class OrderBlockReader:
+ """Reads a selected region of the order table into a block a paste can replay.
+
+ The block is anchored at the cell the region begins in, so it carries offsets rather than
+ table coordinates and lands wherever it is written.
+ """
+
+ def __init__(self, order_logic: SequencerOrderLogic) -> None:
+ self._order = order_logic
+
+ def read(self, region: OrderRegion) -> OrderBlock:
+ """Takes the pattern indices a region covers, keyed by the offsets they stand at.
+
+ A cell holding an index keeps it, a silent one keeps its silence, and a master cell whose
+ channels disagree leaves its key out — which carries the table's mixed reading over as a
+ value the paste passes by.
+ """
+ entries: Dict[BlockKey, Optional[int]] = {}
+ for row_offset, generator in enumerate(region.generators):
+ for position_offset, position in enumerate(region.positions):
+ agreement = self._agree(generator, position)
+ if agreement.is_unanimous:
+ entries[(row_offset, position_offset)] = agreement.value
+
+ return OrderBlock(entries=entries)
+
+ def _agree(
+ self,
+ generator: Optional[GeneratorName],
+ position: int,
+ ) -> Agreement[Optional[int]]:
+ """What a row holds at a position: a channel's own index, or the one its channels share.
+
+ A channel row answers for itself, so it is a group of one and always agrees. The master row
+ answers for every channel, which is the group its display summarises too, so a block states
+ about a cell exactly what the table it came from shows there.
+ """
+ if generator is not None:
+ return Agreement.collapse([self._order.entry(generator, position)])
+
+ return Agreement.collapse(self._order.entry(channel, position) for channel in GeneratorName.items())
diff --git a/src/sampletones_application/logic/sequencer/order/writer.py b/src/sampletones_application/logic/sequencer/order/writer.py
new file mode 100644
index 00000000..5e4a90de
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/order/writer.py
@@ -0,0 +1,71 @@
+from typing import List, Optional, Tuple
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion
+
+from .block import OrderBlock
+from .order import SequencerOrderLogic
+
+OrderWrite = Tuple[int, int, Optional[int]]
+
+
+class OrderBlockWriter:
+ """Replays a block into the order table, and empties the cells a region covers.
+
+ Every cell reaches the table through the single-entry edit that already governs it, so a paste
+ lands exactly the writes a reader typing the same indices by hand would make, master row
+ included.
+ """
+
+ def __init__(self, order_logic: SequencerOrderLogic) -> None:
+ self._order = order_logic
+
+ def write(self, block: OrderBlock, cell: OrderCell) -> None:
+ """Writes a block anchored at a cell, the order growing to hold what it reaches past its end.
+
+ The whole block is resolved before any of it lands, so the frames a write needs exist by the
+ time it reaches them and the growth belongs to the same gesture as the writes it carries.
+ """
+ writes = self._resolve(block, cell)
+ self._grow(writes)
+ for row, position, pattern_index in writes:
+ self._order.write_entry(CHANNEL_AXIS[row], position, pattern_index)
+
+ def clear(self, region: OrderRegion) -> None:
+ """Silences every cell a region covers, each by the rule its own row follows.
+
+ The order keeps its length, so emptying the frames at its end leaves them standing as
+ silent ones rather than taking positions away from the arrangement.
+ """
+ for generator in region.generators:
+ for position in region.positions:
+ self._order.write_entry(generator, position, None)
+
+ def _resolve(self, block: OrderBlock, cell: OrderCell) -> List[OrderWrite]:
+ """Where each of a block's entries lands, in the reading order they are written in.
+
+ Keys are taken in reading order, so a position's master row is written before the channels
+ beneath it and the more specific write is the one that stands. A row past the last channel
+ is left out, which clips a block at the bottom edge rather than wrapping it round to the
+ master row.
+ """
+ base_row = CHANNEL_AXIS.index(cell.generator)
+ return [
+ (base_row + row_offset, cell.position + position_offset, pattern_index)
+ for (row_offset, position_offset), pattern_index in sorted(block.entries.items())
+ if base_row + row_offset < len(CHANNEL_AXIS)
+ ]
+
+ def _grow(self, writes: List[OrderWrite]) -> None:
+ """Appends the frames a block reaches past the order's end.
+
+ The order grows to the last position a write actually lands at, so a column the block says
+ nothing about appends no frame while one it silences appends the frame it silences.
+ """
+ positions = [position for _, position, _ in writes]
+ if not positions:
+ return
+
+ required = max(positions) + 1
+ for _ in range(required - self._order.position_count()):
+ self._order.append_frame()
diff --git a/src/sampletones_application/logic/sequencer/playback/protocol.py b/src/sampletones_application/logic/sequencer/playback/protocol.py
index 71986e11..c605c864 100644
--- a/src/sampletones_application/logic/sequencer/playback/protocol.py
+++ b/src/sampletones_application/logic/sequencer/playback/protocol.py
@@ -17,8 +17,14 @@ class ChannelGeneratorProtocol(Protocol):
The instruction parameter is typed ``Any`` because the generator-to-instruction
pairing is a runtime invariant maintained by ``GENERATOR_CLASSES`` dispatch, which
lies outside the static type system.
+
+ ``frame_length`` is settable so the synthesiser can give each tick the span its clock
+ states, which is what keeps a rendered tick lasting ``1 / nes_frequency`` seconds at a
+ sample rate the tick divides unevenly.
"""
+ frame_length: int
+
def __call__(
self,
instruction: Any,
diff --git a/src/sampletones_application/logic/sequencer/playback/song_player.py b/src/sampletones_application/logic/sequencer/playback/song_player.py
index 5a2e3663..a30e3b57 100644
--- a/src/sampletones_application/logic/sequencer/playback/song_player.py
+++ b/src/sampletones_application/logic/sequencer/playback/song_player.py
@@ -1,6 +1,7 @@
from typing import Callable, Optional, Protocol
from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.constants.playback import FollowMode
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.shared.playback_priority import PlaybackPriority
from sampletones_application.services.song_player.result import (
@@ -159,11 +160,11 @@ def is_loaded(self) -> bool:
return self._project_controller.is_open
@property
- def follow_playback(self) -> bool:
- return self._session_manager.follow_playback
+ def follow_mode(self) -> FollowMode:
+ return self._session_manager.follow_mode
- def set_follow_playback(self, value: bool) -> None:
- self._session_manager.set_follow_playback(value)
+ def set_follow_mode(self, value: FollowMode) -> None:
+ self._session_manager.set_follow_mode(value)
self._emit_view()
def refresh_view(self) -> None:
@@ -226,7 +227,7 @@ def _build_view_model(
is_loaded=self.is_loaded(),
is_playing=is_playing,
is_paused=is_paused,
- follow_playback=self.follow_playback,
+ follow_mode=self.follow_mode,
order_position=self._position.order_position,
row_index=self._position.row_index,
error=self._last_error,
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer.py
deleted file mode 100644
index 12e1d65a..00000000
--- a/src/sampletones_application/logic/sequencer/playback/synthesizer.py
+++ /dev/null
@@ -1,331 +0,0 @@
-from dataclasses import dataclass, field, replace
-from typing import Callable, Dict, FrozenSet, List, Optional, Tuple
-
-import numpy as np
-
-from sampletones_application.logic.project.controller import ProjectController
-from sampletones_core.audio import clip_audio_inplace
-from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH
-from sampletones_core.generators.maps import GENERATOR_CLASSES
-from sampletones_core.instructions import (
- InstructionUnion,
- NoiseInstruction,
- PulseInstruction,
- TriangleInstruction,
-)
-from sampletones_core.project import Project
-from sampletones_core.project.instruments.instrument import Instrument
-from sampletones_core.project.instruments.note_off import NoteOff
-from sampletones_core.project.patterns.row import Row
-from sampletones_core.project.settings import ProjectSettings
-from sampletones_core.project.song import Song
-from sampletones_core.project.song_position import SongPosition
-from sampletones_shared.constants.project import REFERENCE_NES_FREQUENCY, REFERENCE_TEMPO
-
-from .protocol import ChannelGeneratorProtocol
-
-
-@dataclass
-class _ChannelState:
- generator: ChannelGeneratorProtocol
- sample_id: Optional[str] = field(default=None)
- tick_index: int = field(default=0)
- transpose: int = field(default=0)
- volume: int = field(default=MAX_VOLUME)
-
-
-def _silence(samples: int) -> np.ndarray:
- return np.zeros(samples, dtype=np.float32)
-
-
-def _apply_modifiers(
- instruction: InstructionUnion,
- transpose: int,
- row_volume: int,
-) -> InstructionUnion:
- match instruction:
- case PulseInstruction():
- scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME)))
- effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose))
- return instruction.model_copy(update={"pitch": effective_pitch, "volume": scaled_volume})
- case TriangleInstruction():
- effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose))
- on = instruction.on and row_volume > MAX_VOLUME // 2
- return instruction.model_copy(update={"pitch": effective_pitch, "on": on})
- case NoiseInstruction():
- scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME)))
- effective_period = (instruction.period + transpose) % 16
- return instruction.model_copy(update={"period": effective_period, "volume": scaled_volume})
-
-
-class RowSynthesizer:
- """Real-time synthesis engine for tracker song playback.
-
- Reads the live ``Project`` from ``project_controller`` on every ``render_row``
- call so that pattern edits, tempo changes, and sample swaps take effect
- immediately while playback keeps running.
-
- Generators are constructed once from ``config`` and carry timer state across
- rows for phase continuity within a sustained note. Triggering a new note
- calls ``generator.reset()`` for a clean phase start.
-
- ``active_channels`` reports which channels sound and is consulted once per channel per
- row, so muting or unmuting during playback is heard as the render-ahead buffer drains. A
- silenced channel still takes each row's instrument, transpose, and volume, so unmuting
- resumes on the state the pattern has reached.
- """
-
- def __init__(
- self,
- project_controller: ProjectController,
- config: Config,
- *,
- active_channels: Callable[[], FrozenSet[GeneratorName]],
- ) -> None:
- self._project_controller = project_controller
- self._config = config
- self._active_channels = active_channels
- self._nes_frequency: int = config.library.nes_frequency
- self._position = SongPosition()
- self._tick_debt: int = 0
- self._channel_states: Dict[GeneratorName, _ChannelState] = {
- generator_name: _ChannelState(
- generator=GENERATOR_CLASSES[generator_name](
- config,
- generator_name.value,
- ),
- )
- for generator_name in GeneratorName.items()
- }
-
- @property
- def order_position(self) -> int:
- return self._position.order_position
-
- @property
- def row_index(self) -> int:
- return self._position.row_index
-
- @property
- def is_finished(self) -> bool:
- project = self._project_controller.project
- return self._position.order_position >= project.song.order_length()
-
- def set_position(self, order_position: int, row_index: int) -> None:
- self._position.order_position = order_position
- self._position.row_index = row_index
-
- def reset(self) -> None:
- self._tick_debt = 0
- for state in self._channel_states.values():
- state.sample_id = None
- state.tick_index = 0
- state.transpose = 0
- state.volume = MAX_VOLUME
-
- def _ensure_generators(self, nes_frequency: int) -> None:
- """Rebuilds the channel generators when the engine refresh rate changes.
-
- ``nes_frequency`` is the rate at which instructions (engine ticks) are
- consumed, so each tick spans ``sample_rate / nes_frequency`` audio samples.
- The generators must follow the project's current value so a row keeps a
- constant real-time duration as the rate changes (the tempo is otherwise tied
- to the frequency). Pitch is derived from the APU clock, not this rate, so only
- the per-tick frame length changes; the generators' phase continuity resets,
- which is acceptable for an occasional settings edit.
- """
- if nes_frequency == self._nes_frequency:
- return
-
- 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,
- )
-
- def _playback_config(self, nes_frequency: int) -> Config:
- library = self._config.library.model_copy(update={"nes_frequency": nes_frequency})
- return self._config.model_copy(update={"library": library})
-
- def render_row(self) -> Tuple[np.ndarray, SongPosition]:
- project = self._project_controller.project
- settings = project.settings
- song = project.song
- self._position.wrap_overflow(song.rows_per_pattern)
- self._ensure_generators(settings.nes_frequency)
-
- frame_length = round(self._config.library.sample_rate / settings.nes_frequency)
- ticks_per_row = self._ticks_for_row(settings)
- chunk_length = frame_length * ticks_per_row
-
- position_before = replace(self._position)
- if self.is_finished:
- return np.zeros(chunk_length, dtype=np.float32), position_before
-
- mixed = self._mix_channels(
- project,
- song,
- frame_length,
- ticks_per_row,
- chunk_length,
- )
- self._advance_position(song)
-
- return mixed, position_before
-
- def _ticks_for_row(self, settings: ProjectSettings) -> int:
- """ticks_per_row == speed at REFERENCE_TEMPO and REFERENCE_NES_FREQUENCY."""
- self._tick_debt += settings.speed * settings.nes_frequency * REFERENCE_TEMPO
- return self._drain_tick_debt(settings.tempo)
-
- def _drain_tick_debt(self, tempo: int) -> int:
- divisor = tempo * REFERENCE_NES_FREQUENCY
- ticks = self._tick_debt // divisor
- self._tick_debt -= ticks * divisor
- return ticks
-
- def _mix_channels(
- self,
- project: Project,
- song: Song,
- frame_length: int,
- ticks_per_row: int,
- chunk_length: int,
- ) -> np.ndarray:
- mixed = _silence(chunk_length)
- for generator_name in GeneratorName.items():
- channel_audio = self._render_channel(
- generator_name,
- project,
- song,
- frame_length,
- ticks_per_row,
- chunk_length,
- )
- mixed += channel_audio
-
- return clip_audio_inplace(mixed)
-
- def _render_channel(
- self,
- generator_name: GeneratorName,
- project: Project,
- song: Song,
- frame_length: int,
- ticks_per_row: int,
- chunk_length: int,
- ) -> np.ndarray:
- state = self._channel_states[generator_name]
-
- row = self._resolve_row(generator_name, song)
- if row is not None:
- self._apply_row_to_state(state, row)
-
- sample_id = state.sample_id
- if sample_id is None or generator_name not in self._active_channels():
- return _silence(chunk_length)
-
- return self._synthesize_ticks(
- state,
- sample_id,
- project,
- generator_name,
- frame_length,
- ticks_per_row,
- chunk_length,
- )
-
- def _resolve_row(self, generator_name: GeneratorName, song: Song) -> Optional[Row]:
- if self._position.order_position >= song.order_length():
- return None
-
- order_entry = song.order[self._position.order_position].get(generator_name)
- if order_entry is None:
- return None
-
- pattern = song.pattern(generator_name, order_entry)
- if pattern is None or self._position.row_index >= len(pattern.rows):
- return None
-
- return pattern.rows[self._position.row_index]
-
- def _apply_row_to_state(self, state: _ChannelState, row: Row) -> None:
- match row.command:
- case Instrument() as instrument:
- state.generator.reset()
- state.sample_id = instrument.sample_id
- state.tick_index = 0
- state.transpose = row.transpose if row.transpose is not None else 0
- state.volume = row.volume if row.volume is not None else MAX_VOLUME
- case NoteOff():
- state.generator.reset()
- state.sample_id = None
- state.tick_index = 0
- case None:
- if row.transpose is not None:
- state.transpose = row.transpose
- if row.volume is not None:
- state.volume = row.volume
-
- def _synthesize_ticks(
- self,
- state: _ChannelState,
- sample_id: str,
- project: Project,
- generator_name: GeneratorName,
- frame_length: int,
- ticks_per_row: int,
- chunk_length: int,
- ) -> np.ndarray:
- sample = project.sample(sample_id)
- if sample is None:
- return _silence(chunk_length)
-
- instructions = sample.reconstruction.instructions.get(generator_name)
- if not instructions:
- return _silence(chunk_length)
-
- output = _silence(chunk_length)
- silence_frame = _silence(frame_length)
-
- for tick in range(ticks_per_row):
- frame = self._synthesize_tick(
- state,
- instructions,
- silence_frame,
- sample.loop,
- )
- output[tick * frame_length : (tick + 1) * frame_length] = frame
- state.tick_index += 1
-
- return output
-
- def _synthesize_tick(
- self,
- state: _ChannelState,
- instructions: List[InstructionUnion],
- silence_frame: np.ndarray,
- loop: bool,
- ) -> np.ndarray:
- if loop:
- instruction = instructions[state.tick_index % len(instructions)]
- elif state.tick_index < len(instructions):
- instruction = instructions[state.tick_index]
- else:
- return silence_frame
-
- return state.generator(
- _apply_modifiers(
- instruction,
- state.transpose,
- state.volume,
- ),
- save=True,
- )
-
- def _advance_position(self, song: Song) -> None:
- self._position.advance(song.rows_per_pattern, song.order_length())
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py
new file mode 100644
index 00000000..a60f7ba8
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py
@@ -0,0 +1,21 @@
+from .bank import ChannelBank
+from .frames import RowFrames
+from .length import SongLength
+from .modifiers import apply_modifiers
+from .rates import EngineRates
+from .state import ChannelState
+from .synthesizer import RowSynthesizer
+from .timing import SongTiming
+from .voice import SampleVoice
+
+__all__ = [
+ "ChannelBank",
+ "ChannelState",
+ "EngineRates",
+ "RowFrames",
+ "RowSynthesizer",
+ "SampleVoice",
+ "SongLength",
+ "SongTiming",
+ "apply_modifiers",
+]
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py
new file mode 100644
index 00000000..1e8ab94a
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py
@@ -0,0 +1,86 @@
+from typing import Dict
+
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.generators.maps import GENERATOR_CLASSES
+from sampletones_core.timing import TickClock
+
+from ..protocol import ChannelGeneratorProtocol
+from .rates import EngineRates
+from .state import ChannelState
+
+
+class ChannelBank:
+ """The channels a song sounds through, and the rates they are built at.
+
+ One generator per NES channel, each holding the timer state that carries a note's phase across
+ ticks and rows, beside the pattern state its channel has reached. Holding the rates here as
+ well is what makes following them a single decision: the generators and the tick clock are
+ built from the same pair, so they agree on how long a tick is.
+ """
+
+ def __init__(self, config: Config, rates: EngineRates) -> None:
+ self._config = config
+ self._rates = rates
+ self._clock: TickClock = rates.clock()
+ self._states: Dict[GeneratorName, ChannelState] = {
+ generator_name: ChannelState(generator=generator)
+ for generator_name, generator in self._build_generators(rates).items()
+ }
+
+ @property
+ def clock(self) -> TickClock:
+ """The samples each tick spans at the rates in force."""
+ return self._clock
+
+ def state(self, generator_name: GeneratorName) -> ChannelState:
+ """What ``generator_name`` carries from row to row."""
+ return self._states[generator_name]
+
+ def reset(self) -> None:
+ """Returns every channel to silence at full volume, as a song starts them."""
+ for state in self._states.values():
+ state.reset()
+
+ def follow(self, rates: EngineRates) -> None:
+ """Rebuilds the generators when either rate a tick is sized from changes.
+
+ The engine consumes ``nes_frequency`` instructions a second and the audio holds
+ ``sample_rate`` samples a second, so a tick spans the quotient of the two. Following the
+ project's frequency keeps a row a constant real-time duration as that frequency changes,
+ and following the output's rate keeps a rendered second a second wherever the audio goes.
+ Pitch derives from the APU clock rather than either rate, so a change moves only the
+ per-tick frame length; the generators' phase continuity resets, which is acceptable for an
+ occasional settings edit.
+
+ The tick clock follows the same pair, since it states how long one of those ticks lasts.
+
+ Args:
+ rates: The pair in force for the row about to be rendered.
+ """
+ if rates == self._rates:
+ return
+
+ self._rates = rates
+ self._clock = rates.clock()
+ for generator_name, generator in self._build_generators(rates).items():
+ self._states[generator_name].generator = generator
+
+ def _build_generators(
+ self,
+ rates: EngineRates,
+ ) -> Dict[GeneratorName, ChannelGeneratorProtocol]:
+ config = self._engine_config(rates)
+ return {
+ generator_name: GENERATOR_CLASSES[generator_name](
+ config,
+ generator_name.value,
+ )
+ for generator_name in GeneratorName.items()
+ }
+
+ def _engine_config(self, rates: EngineRates) -> Config:
+ return self._config.with_library(
+ nes_frequency=rates.nes_frequency,
+ sample_rate=rates.sample_rate,
+ )
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/frames.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/frames.py
new file mode 100644
index 00000000..8f461055
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/frames.py
@@ -0,0 +1,47 @@
+from dataclasses import dataclass
+from itertools import accumulate
+from typing import Self, Tuple
+
+from sampletones_core.timing import TickClock
+
+
+@dataclass(frozen=True)
+class RowFrames:
+ """Where each of a row's ticks starts and ends within the row's audio.
+
+ A tick clock gives consecutive ticks whole sample counts that sum to their exact span, so the
+ lengths within one row vary where the sample rate does not divide the tick rate. Resolving the
+ boundaries once per row is what lets every channel write into the same offsets.
+
+ Attributes:
+ lengths: The samples each of the row's ticks spans, in order.
+ bounds: Each tick's start offset, ending with the row's total length.
+ """
+
+ lengths: Tuple[int, ...]
+ bounds: Tuple[int, ...]
+
+ @classmethod
+ def from_clock(
+ cls,
+ clock: TickClock,
+ *,
+ elapsed_ticks: int,
+ ticks: int,
+ ) -> Self:
+ """Resolves the row starting at ``elapsed_ticks`` and spanning ``ticks`` ticks."""
+ lengths = tuple(clock.frame_length(elapsed_ticks + tick) for tick in range(ticks))
+ return cls(
+ lengths=lengths,
+ bounds=tuple(accumulate(lengths, initial=0)),
+ )
+
+ @property
+ def total(self) -> int:
+ """The samples the whole row spans."""
+ return self.bounds[-1]
+
+ @property
+ def longest(self) -> int:
+ """The samples the row's longest tick spans."""
+ return max(self.lengths, default=0)
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py
new file mode 100644
index 00000000..718e9ff5
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py
@@ -0,0 +1,43 @@
+from dataclasses import dataclass
+from typing import Self
+
+from sampletones_core.project import Project
+
+from .rates import EngineRates
+from .timing import SongTiming
+
+
+@dataclass(frozen=True)
+class SongLength:
+ """How long a song runs, in the units the audio it produces is measured in.
+
+ Every row lasts the ticks the project's groove gives it, so a song's length is a whole
+ number of engine ticks before a sample is rendered. The rates the audio is produced at
+ turn those ticks into samples, which is the total a progress bar crosses and the duration
+ a dialog projects.
+
+ Attributes:
+ ticks: The engine ticks the whole order lasts.
+ rates: The engine and audio rates those ticks are rendered at.
+ """
+
+ ticks: int
+ rates: EngineRates
+
+ @classmethod
+ def measure(cls, project: Project, *, sample_rate: int) -> Self:
+ """The length ``project`` runs to when rendered at ``sample_rate``.
+
+ Every pattern holds the song's row count, so one groove covers the whole order and the
+ tick total is that groove's, once for each position the order plays.
+ """
+ groove = SongTiming.from_project(project).groove()
+ return cls(
+ ticks=project.song.order_length() * groove.total_ticks,
+ rates=EngineRates.from_project(project, sample_rate),
+ )
+
+ @property
+ def samples(self) -> int:
+ """The samples the whole song holds at the rate it is rendered at."""
+ return self.rates.clock().samples_at(self.ticks)
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py
new file mode 100644
index 00000000..52a88b7e
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py
@@ -0,0 +1,43 @@
+from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH
+from sampletones_core.instructions import (
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
+
+
+def apply_modifiers(
+ instruction: InstructionUnion,
+ transpose: int,
+ row_volume: int,
+) -> InstructionUnion:
+ """Bends one tick's instruction by the transpose and volume the pattern has reached.
+
+ A sample carries the instructions it was reconstructed from; a pattern states how loud and how
+ high it is played. Each channel takes both in the terms it understands: the pulse channels
+ scale their volume and shift their pitch, the triangle shifts its pitch and sounds while the
+ row asks for more than half volume, and the noise channel scales its volume and walks its
+ period around the sixteen the hardware offers.
+
+ Args:
+ instruction: The tick's instruction as the sample holds it.
+ transpose: The semitone offset the pattern has reached, held within the pitch range.
+ row_volume: The level the pattern has reached, scaling the instruction's own.
+
+ Returns:
+ InstructionUnion: A copy of the instruction as the channel sounds it.
+ """
+ match instruction:
+ case PulseInstruction():
+ scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME)))
+ effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose))
+ return instruction.model_copy(update={"pitch": effective_pitch, "volume": scaled_volume})
+ case TriangleInstruction():
+ effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose))
+ on = instruction.on and row_volume > MAX_VOLUME // 2
+ return instruction.model_copy(update={"pitch": effective_pitch, "on": on})
+ case NoiseInstruction():
+ scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME)))
+ effective_period = (instruction.period + transpose) % 16
+ return instruction.model_copy(update={"period": effective_period, "volume": scaled_volume})
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py
new file mode 100644
index 00000000..b256c6b0
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py
@@ -0,0 +1,38 @@
+from dataclasses import dataclass
+from typing import Self
+
+from sampletones_core.project import Project
+from sampletones_core.timing import TickClock
+
+
+@dataclass(frozen=True)
+class EngineRates:
+ """The pair of rates a tick is sized from, held together so a change is one comparison.
+
+ Each rate is owned elsewhere: the project states how many instructions the engine consumes
+ each second, and whoever takes the audio states the rate it is rendered at — the output
+ device for playback, the chosen format for a file. Together they fix how many samples one
+ tick spans, so the synthesiser follows both.
+
+ Attributes:
+ nes_frequency: The engine ticks consumed each second.
+ sample_rate: The samples the rendered audio holds each second.
+ """
+
+ nes_frequency: int
+ sample_rate: int
+
+ @classmethod
+ def from_project(cls, project: Project, sample_rate: int) -> Self:
+ """The rates in force for ``project`` rendered at ``sample_rate``."""
+ return cls(
+ nes_frequency=project.settings.nes_frequency,
+ sample_rate=sample_rate,
+ )
+
+ def clock(self) -> TickClock:
+ """The samples each tick spans under this pair of rates."""
+ return TickClock.from_parameters(
+ sample_rate=self.sample_rate,
+ nes_frequency=self.nes_frequency,
+ )
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py
new file mode 100644
index 00000000..5af877fe
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py
@@ -0,0 +1,53 @@
+from dataclasses import dataclass, field
+from typing import Dict, Optional
+
+from sampletones_core.constants.enums import FeatureKey
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS
+
+from ..protocol import ChannelGeneratorProtocol
+
+
+@dataclass
+class ChannelState:
+ """What one channel carries from row to row.
+
+ A pattern states a channel's instrument, transpose, and volume only where it changes them, so
+ the channel keeps the last of each until another row states otherwise. The tick index is how
+ far into the sounding sample's instructions the channel has played, which is what lets a note
+ sustain across rows.
+
+ The channel carries a value per envelope dimension too, which is what an instrument leaving a
+ dimension to the channel sounds at. A frame the instrument writes hands its value over, so the
+ channel keeps the last one written for as long as the song runs.
+
+ Attributes:
+ generator: The synthesiser filling the channel's ticks.
+ sample_id: The sample the channel is sounding, or ``None`` while it is silent.
+ tick_index: How many ticks of that sample's instructions the channel has played.
+ transpose: The semitone offset a row last set.
+ volume: The level a row last set.
+ feature_values: The value the channel holds for each envelope dimension.
+ """
+
+ generator: ChannelGeneratorProtocol
+ sample_id: Optional[str] = field(default=None)
+ tick_index: int = field(default=0)
+ transpose: int = field(default=0)
+ volume: int = field(default=MAX_VOLUME)
+ feature_values: Dict[FeatureKey, int] = field(default_factory=CHANNEL_FEATURE_DEFAULTS.copy)
+
+ def reset(self) -> None:
+ """Returns the channel to silence at full volume, as a song starts it.
+
+ The envelope dimensions return to the values a channel holds from the start of a song,
+ so a pass through the song sounds the same however the previous one left them.
+
+ The generator is kept, since it is built from the rates in force rather than from
+ anything a song reaches.
+ """
+ self.sample_id = None
+ self.tick_index = 0
+ self.transpose = 0
+ self.volume = MAX_VOLUME
+ self.feature_values = CHANNEL_FEATURE_DEFAULTS.copy()
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py
new file mode 100644
index 00000000..2e1de2d8
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py
@@ -0,0 +1,314 @@
+from dataclasses import replace
+from typing import Callable, FrozenSet, List, Optional, Tuple
+
+import numpy as np
+
+from sampletones_application.logic.shared.project_source import ProjectSource
+from sampletones_core.audio import clip_audio_inplace, silence
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.project import Project
+from sampletones_core.project.instruments.instrument import Instrument
+from sampletones_core.project.instruments.note_off import NoteOff
+from sampletones_core.project.patterns.row import Row
+from sampletones_core.project.song import Song
+from sampletones_core.project.song_position import SongPosition
+from sampletones_core.timing import Groove
+
+from .bank import ChannelBank
+from .frames import RowFrames
+from .modifiers import apply_modifiers
+from .rates import EngineRates
+from .state import ChannelState
+from .timing import SongTiming
+from .voice import SampleVoice
+
+
+class RowSynthesizer:
+ """Synthesis engine for tracker song audio, one row at a time.
+
+ Reads the ``Project`` from ``project_source`` on every ``render_row`` call. Over the live
+ controller that makes pattern edits, tempo changes, and sample swaps take effect immediately
+ while playback keeps running; over a
+ :class:`~sampletones_application.logic.shared.project_source.ProjectSnapshot` it makes a whole
+ render describe one state of the document.
+
+ A row lasts the ticks the project's groove gives its position within the pattern, so the
+ row a pattern's tenth row plays for is the row an exported module plays it for: both index
+ the same groove from the pattern's first row.
+
+ Each of those ticks spans the samples the :class:`~sampletones_core.timing.clock.TickClock`
+ gives its position in the run, so a tick lasts ``1 / nes_frequency`` seconds at every sample
+ rate and the groove's tempo is the tempo heard.
+
+ ``sample_rate`` reports the rate the audio is rendered at, and is what the caller taking that
+ audio runs at: the output device for live playback, the chosen format for a file. Reading it
+ per row keeps the two in step, so a rendered second is a second wherever the audio goes.
+
+ Generators are held in a :class:`ChannelBank` built from ``config`` at the rates the first row
+ is rendered at, so the rate is asked for once there is audio to take it — a device is chosen by
+ the time playback starts, and a format by the time a render does. They carry timer state across
+ rows for phase continuity within a sustained note, and triggering a new note calls
+ ``generator.reset()`` for a clean phase start.
+
+ ``active_channels`` reports which channels sound and is consulted once per channel per
+ row, so muting or unmuting during playback is heard as the render-ahead buffer drains. A
+ silenced channel still takes each row's instrument, transpose, and volume, so unmuting
+ resumes on the state the pattern has reached.
+ """
+
+ def __init__(
+ self,
+ project_source: ProjectSource,
+ config: Config,
+ *,
+ active_channels: Callable[[], FrozenSet[GeneratorName]],
+ sample_rate: Callable[[], int],
+ ) -> None:
+ self._project_source = project_source
+ self._config = config
+ self._active_channels = active_channels
+ self._sample_rate = sample_rate
+ self._position = SongPosition()
+ self._timing: SongTiming = SongTiming.from_project(project_source.project)
+ self._groove: Groove = self._timing.groove()
+ self._channels: Optional[ChannelBank] = None
+ self._elapsed_ticks: int = 0
+
+ @property
+ def order_position(self) -> int:
+ return self._position.order_position
+
+ @property
+ def row_index(self) -> int:
+ return self._position.row_index
+
+ @property
+ def is_finished(self) -> bool:
+ project = self._project_source.project
+ return self._position.order_position >= project.song.order_length()
+
+ def set_position(self, order_position: int, row_index: int) -> None:
+ self._position.order_position = order_position
+ self._position.row_index = row_index
+
+ def reset(self) -> None:
+ self._elapsed_ticks = 0
+ if self._channels is not None:
+ self._channels.reset()
+
+ def render_row(self) -> Tuple[np.ndarray, SongPosition]:
+ project = self._project_source.project
+ song = project.song
+ self._position.wrap_overflow(song.rows_per_pattern)
+ channels = self._bank()
+ self._ensure_groove(project)
+
+ frames = RowFrames.from_clock(
+ channels.clock,
+ elapsed_ticks=self._elapsed_ticks,
+ ticks=self._groove.ticks[self._position.row_index],
+ )
+
+ position_before = replace(self._position)
+ finished = self.is_finished
+ mixed = (
+ silence(frames.total)
+ if finished
+ else self._mix_channels(
+ project,
+ song,
+ frames,
+ channels,
+ )
+ )
+
+ self._elapsed_ticks += len(frames.lengths)
+ if not finished:
+ self._advance_position(song)
+
+ return mixed, position_before
+
+ def _bank(self) -> ChannelBank:
+ """The channels the row about to be rendered sounds through, at the rates in force.
+
+ Building them here is what lets a session start where nothing yet takes the audio: the rate
+ belongs to whoever consumes it, so it is asked for at the moment there is a consumer to
+ answer. Every later row follows the pair, so a device or a format changing underneath is
+ heard from the next row on.
+ """
+ rates = self._current_rates()
+ if self._channels is None:
+ self._channels = ChannelBank(self._config, rates)
+ else:
+ self._channels.follow(rates)
+
+ return self._channels
+
+ def _current_rates(self) -> EngineRates:
+ return EngineRates.from_project(
+ self._project_source.project,
+ self._sample_rate(),
+ )
+
+ def _ensure_groove(self, project: Project) -> None:
+ """Rebuilds the groove when the row rate or the metre it is spread over changes.
+
+ An engine that holds a row for a whole number of ticks reaches a fractional row rate by
+ varying that number from row to row, and the groove is where those counts are decided.
+ Rebuilding only on a timing edit keeps a tempo change immediate while the distribution
+ itself, which spans a whole pattern, is computed once.
+ """
+ timing = SongTiming.from_project(project)
+ if timing == self._timing:
+ return
+
+ self._timing = timing
+ self._groove = timing.groove()
+
+ def _mix_channels(
+ self,
+ project: Project,
+ song: Song,
+ frames: RowFrames,
+ channels: ChannelBank,
+ ) -> np.ndarray:
+ mixed = silence(frames.total)
+ for generator_name in GeneratorName.items():
+ channel_audio = self._render_channel(
+ generator_name,
+ project,
+ song,
+ frames,
+ channels,
+ )
+ mixed += channel_audio
+
+ return clip_audio_inplace(mixed)
+
+ def _render_channel(
+ self,
+ generator_name: GeneratorName,
+ project: Project,
+ song: Song,
+ frames: RowFrames,
+ channels: ChannelBank,
+ ) -> np.ndarray:
+ state = channels.state(generator_name)
+
+ row = self._resolve_row(generator_name, song)
+ if row is not None:
+ self._apply_row_to_state(state, row)
+
+ sample_id = state.sample_id
+ if sample_id is None or generator_name not in self._active_channels():
+ return silence(frames.total)
+
+ return self._synthesize_ticks(
+ state,
+ sample_id,
+ project,
+ generator_name,
+ frames,
+ )
+
+ def _resolve_row(
+ self,
+ generator_name: GeneratorName,
+ song: Song,
+ ) -> Optional[Row]:
+ if self._position.order_position >= song.order_length():
+ return None
+
+ order_entry = song.order[self._position.order_position].get(generator_name)
+ if order_entry is None:
+ return None
+
+ pattern = song.pattern(generator_name, order_entry)
+ if pattern is None or self._position.row_index >= len(pattern.rows):
+ return None
+
+ return pattern.rows[self._position.row_index]
+
+ def _apply_row_to_state(self, state: ChannelState, row: Row) -> None:
+ match row.command:
+ case Instrument() as instrument:
+ state.generator.reset()
+ state.sample_id = instrument.sample_id
+ state.tick_index = 0
+ state.transpose = row.transpose if row.transpose is not None else 0
+ state.volume = row.volume if row.volume is not None else MAX_VOLUME
+ case NoteOff():
+ state.generator.reset()
+ state.sample_id = None
+ state.tick_index = 0
+ case None:
+ if row.transpose is not None:
+ state.transpose = row.transpose
+ if row.volume is not None:
+ state.volume = row.volume
+
+ def _synthesize_ticks(
+ self,
+ state: ChannelState,
+ sample_id: str,
+ project: Project,
+ generator_name: GeneratorName,
+ frames: RowFrames,
+ ) -> np.ndarray:
+ sample = project.sample(sample_id)
+ if sample is None:
+ return silence(frames.total)
+
+ instructions = sample.reconstruction.instructions[generator_name]
+ if not instructions:
+ return silence(frames.total)
+
+ voice = SampleVoice.read(sample.reconstruction, generator_name)
+ output = silence(frames.total)
+ silence_frame = silence(frames.longest)
+
+ for tick, frame_length in enumerate(frames.lengths):
+ frame = self._synthesize_tick(
+ state,
+ instructions,
+ silence_frame[:frame_length],
+ sample.loop,
+ frame_length,
+ voice,
+ )
+ output[frames.bounds[tick] : frames.bounds[tick + 1]] = frame
+ state.tick_index += 1
+
+ return output
+
+ def _synthesize_tick(
+ self,
+ state: ChannelState,
+ instructions: List[InstructionUnion],
+ silence_frame: np.ndarray,
+ loop: bool,
+ frame_length: int,
+ voice: SampleVoice,
+ ) -> np.ndarray:
+ if loop:
+ instruction = instructions[state.tick_index % len(instructions)]
+ elif state.tick_index < len(instructions):
+ instruction = instructions[state.tick_index]
+ else:
+ return silence_frame
+
+ state.generator.frame_length = frame_length
+ return state.generator(
+ apply_modifiers(
+ voice.sound(instruction, state.feature_values),
+ state.transpose,
+ state.volume,
+ ),
+ save=True,
+ )
+
+ def _advance_position(self, song: Song) -> None:
+ self._position.advance(song.rows_per_pattern, song.order_length())
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py
new file mode 100644
index 00000000..96c72dd7
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py
@@ -0,0 +1,47 @@
+from dataclasses import dataclass
+from typing import Self
+
+from sampletones_application.constants.playback import (
+ MAX_TICKS_PER_ROW,
+ MIN_TICKS_PER_ROW,
+)
+from sampletones_core.project import Project
+from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove
+
+
+@dataclass(frozen=True)
+class SongTiming:
+ """Everything a project's groove is built from, held together so a change is one comparison.
+
+ Attributes:
+ rate: The exact ticks one row lasts under the project's tempo, speed and tick rate.
+ metre: The pattern length and the beat and bar grouping the ticks are spread over.
+ """
+
+ rate: RowRate
+ metre: Metre
+
+ @classmethod
+ def from_project(cls, project: Project) -> Self:
+ """Reads the timing a project plays at, taking the pattern length from its song."""
+ return cls(
+ rate=RowRate.from_settings(project.settings),
+ metre=Metre.from_settings(
+ project.settings,
+ rows=project.song.rows_per_pattern,
+ ),
+ )
+
+ def groove(self) -> Groove:
+ """Spreads the row rate across a pattern's rows.
+
+ Playback follows whatever tempo the project states, so the one bound it sets is that
+ every row lasts at least a tick and keeps sounding; the ceiling is the fastest row the
+ settings can ask for, which leaves the groove free to realize the rate exactly.
+ """
+ return calculate_groove(
+ self.rate,
+ self.metre,
+ minimum_ticks=MIN_TICKS_PER_ROW,
+ maximum_ticks=MAX_TICKS_PER_ROW,
+ )
diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py
new file mode 100644
index 00000000..51b34832
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py
@@ -0,0 +1,84 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Dict, Tuple
+
+from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.exporters import GENERATOR_NAME_TO_EXPORTER_MAP, ExporterTypeUnion
+from sampletones_core.instructions import InstructionUnion
+from sampletones_core.reconstructions import Reconstruction
+
+
+@dataclass(frozen=True)
+class SampleVoice:
+ """How one channel reads a sample's frames.
+
+ A sample carries a frame per tick stating every dimension the channel reads, and the
+ reconstruction names which of those dimensions the instrument itself wrote. The rest are the
+ channel's own: the instrument leaves an empty envelope for them and the channel sounds them at
+ the value it holds, which is what clearing an envelope in the instruments panel means once the
+ sample is played in a song.
+
+ Attributes:
+ exporter: The reading that turns this channel's frames into envelope values and back.
+ initial_pitch: Reference pitch the arpeggio values are measured against.
+ held_features: The dimensions the instrument leaves to the channel.
+ """
+
+ exporter: ExporterTypeUnion
+ initial_pitch: int
+ held_features: Tuple[FeatureKey, ...]
+
+ @classmethod
+ def read(
+ cls,
+ reconstruction: Reconstruction,
+ generator_name: GeneratorName,
+ ) -> SampleVoice:
+ """The voice one channel of ``reconstruction`` is played through.
+
+ Args:
+ reconstruction: The sample's reconstruction.
+ generator_name: The channel being sounded.
+
+ Returns:
+ SampleVoice: The reading of that channel's frames.
+ """
+ return cls(
+ exporter=GENERATOR_NAME_TO_EXPORTER_MAP[generator_name],
+ initial_pitch=reconstruction.initial_pitches[generator_name],
+ held_features=reconstruction.held_features[generator_name],
+ )
+
+ def sound(
+ self,
+ instruction: InstructionUnion,
+ feature_values: Dict[FeatureKey, int],
+ ) -> InstructionUnion:
+ """The frame the channel sounds, once the dimensions it governs are filled in.
+
+ ``feature_values`` is the channel's own, and this is where it moves: the dimensions the
+ frame states and the instrument writes are handed over to it, and every dimension the
+ frame plays is then read back out of it. So an instrument that writes a dimension sets
+ what the channel holds, and one that leaves it empty sounds at what the channel holds.
+
+ Args:
+ instruction: The frame as the sample holds it.
+ feature_values: The values the channel holds, updated with what the instrument writes.
+
+ Returns:
+ InstructionUnion: The frame to sound, before the pattern's transpose and volume.
+ """
+ stated = self.exporter.feature_values(
+ instruction, # type: ignore[arg-type]
+ self.initial_pitch,
+ )
+ for feature_key, value in stated.items():
+ if feature_key not in self.held_features:
+ feature_values[feature_key] = value
+
+ sounded: InstructionUnion = self.exporter.instruction_from_values(
+ feature_values,
+ self.initial_pitch,
+ )
+ return sounded
diff --git a/src/sampletones_application/logic/sequencer/renderer.py b/src/sampletones_application/logic/sequencer/renderer.py
deleted file mode 100644
index 7c8bf44d..00000000
--- a/src/sampletones_application/logic/sequencer/renderer.py
+++ /dev/null
@@ -1,31 +0,0 @@
-from typing import Protocol
-
-import numpy as np
-
-from sampletones_core.project import Project
-
-
-class SongRenderer(Protocol):
- """Renders a project's song into a single playable mono waveform.
-
- This is the seam between the sequencer and audio output. An implementation
- walks each channel's ``order`` → ``patterns`` → ``rows``, feeds every active
- row's referenced sample reconstruction instructions (shifted by the row
- ``transpose`` and scaled by ``volume``) through the matching
- :class:`~sampletones_core.generators.generator.Generator`, advancing one
- tracker row every ``speed`` engine ticks at the project ``tempo`` /
- ``nes_frequency``, then mixes the four channels into one buffer.
- """
-
- def render(self, project: Project) -> np.ndarray: ...
-
-
-class UnimplementedSongRenderer:
- """Placeholder renderer until the synthesis engine lands.
-
- Kept as a concrete type so the sequencer can hold a renderer reference and
- fail loudly if play is wired before the engine exists.
- """
-
- def render(self, project: Project) -> np.ndarray:
- raise NotImplementedError("Song rendering is not implemented yet; see SongRenderer.")
diff --git a/src/sampletones_application/logic/sequencer/samples.py b/src/sampletones_application/logic/sequencer/samples.py
index a48edb44..49051bf3 100644
--- a/src/sampletones_application/logic/sequencer/samples.py
+++ b/src/sampletones_application/logic/sequencer/samples.py
@@ -1,7 +1,9 @@
from typing import Callable, Optional
from sampletones_application.config.managers.session import SessionManager
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
+)
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.shared.playback_priority import PlaybackPriority
from sampletones_application.utils.callbacks.queue import CallbackQueue
@@ -9,7 +11,9 @@
SampleEntryViewModel,
SequencerSamplesViewModel,
)
+from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
from sampletones_core.audio import AudioDeviceManager
+from sampletones_core.formats.famitracker.footprint import reconstruction_footprints
from sampletones_core.project.instruments.sample import Sample
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.utils.display import display_sample
@@ -73,12 +77,37 @@ def rename_sample(self, sample_id: str, name: str) -> None:
def is_sample_used(self, sample_id: str) -> bool:
return self._controller.is_sample_used(sample_id)
+ def build_sample_footprint(self, sample_id: str) -> Optional[SampleFootprintViewModel]:
+ """Measures one sample's instruments as the module export writes them.
+
+ A sample carries its own loop flag, and a looping instrument is compiled to the shortest
+ length its envelopes share, so the sample is measured the way it is placed. Measuring a
+ single sample on demand keeps a pool edit clear of an export it was not asked for.
+
+ Args:
+ sample_id: The sample to measure.
+
+ Returns:
+ Optional[SampleFootprintViewModel]: The sample's byte figures, or ``None`` while the
+ pool holds no such sample.
+ """
+ sample = self._controller.project.samples.get(sample_id)
+ if sample is None:
+ return None
+
+ return SampleFootprintViewModel.from_footprints(
+ reconstruction_footprints(sample.reconstruction, loop=sample.loop)
+ )
+
def sample_name(self, sample_id: str) -> str:
return self._controller.project.samples[sample_id].name
def sample_position(self, sample_id: str) -> str:
"""Returns the sample's hex list position, matching how the tracker labels it."""
- return display_sample(samples=self._controller.project.samples, sample_id=sample_id)
+ return display_sample(
+ samples=self._controller.project.samples,
+ sample_id=sample_id,
+ )
def remove_sample(self, sample_id: str) -> None:
self._controller.remove_sample(sample_id)
@@ -125,7 +154,12 @@ def _execute_autoplay(self) -> None:
if self._session_manager.autoplay:
self._play_sample(sample_id, priority=PlaybackPriority.PREVIEW)
- def _play_sample(self, sample_id: str, *, priority: PlaybackPriority) -> None:
+ def _play_sample(
+ self,
+ sample_id: str,
+ *,
+ priority: PlaybackPriority,
+ ) -> None:
sample = self._controller.project.samples.get(sample_id)
if sample is None:
return
diff --git a/src/sampletones_application/logic/sequencer/tracker/__init__.py b/src/sampletones_application/logic/sequencer/tracker/__init__.py
new file mode 100644
index 00000000..1d2e89c8
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/tracker/__init__.py
@@ -0,0 +1,14 @@
+from .adjuster import TrackerRegionAdjuster
+from .block import BlockNote, TrackerBlock
+from .reader import TrackerBlockReader
+from .tracker import SequencerTrackerLogic
+from .writer import TrackerBlockWriter
+
+__all__ = [
+ "BlockNote",
+ "SequencerTrackerLogic",
+ "TrackerBlock",
+ "TrackerBlockReader",
+ "TrackerBlockWriter",
+ "TrackerRegionAdjuster",
+]
diff --git a/src/sampletones_application/logic/sequencer/tracker/adjuster.py b/src/sampletones_application/logic/sequencer/tracker/adjuster.py
new file mode 100644
index 00000000..3e77e4b5
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/tracker/adjuster.py
@@ -0,0 +1,57 @@
+from typing import Iterator, List, Optional, Tuple
+
+from sampletones_application.view_model.sequencer.region import TrackerRegion
+from sampletones_core.constants.enums import GeneratorName
+
+from .tracker import SequencerTrackerLogic
+
+
+class TrackerRegionAdjuster:
+ """Shifts transpose and volume across the cells a region covers.
+
+ A region names its edges as subcolumns while these two gestures act on whole cells, so an
+ adjustment reads the columns behind the region and reaches each of their channels once. The
+ sample column stands for the channels a value typed in it writes to, which is what keeps a
+ region covering it and a channel beneath it moving that channel a single step.
+
+ Each cell reaches the grid through the single-cell adjustment that already governs it, so a
+ shift over a region lands exactly the writes the same nudge repeated by hand would make.
+ """
+
+ def __init__(self, tracker_logic: SequencerTrackerLogic) -> None:
+ self._tracker = tracker_logic
+
+ def adjust_transpose(self, region: TrackerRegion, delta: int) -> None:
+ """Shifts every covered cell's transpose by ``delta`` semitones."""
+ for row_index, generator in self._cells(region):
+ self._tracker.adjust_transpose(generator, row_index, delta)
+
+ def adjust_volume(self, region: TrackerRegion, delta: int) -> None:
+ """Shifts every covered cell's volume by ``delta``."""
+ for row_index, generator in self._cells(region):
+ self._tracker.adjust_volume(generator, row_index, delta)
+
+ def _cells(
+ self,
+ region: TrackerRegion,
+ ) -> Iterator[Tuple[int, GeneratorName]]:
+ """The channel cells a region reaches, row by row and each named once."""
+ columns = region.columns
+ for row_index in region.rows:
+ for generator in self._channels(columns, row_index):
+ yield row_index, generator
+
+ def _channels(
+ self,
+ columns: Tuple[Optional[GeneratorName], ...],
+ row_index: int,
+ ) -> List[GeneratorName]:
+ """The channels a row's columns reach, the sample column standing for the ones it governs."""
+ channels: List[GeneratorName] = []
+ for column in columns:
+ if column is None:
+ channels.extend(self._tracker.relevant_generators(row_index))
+ else:
+ channels.append(column)
+
+ return list(dict.fromkeys(channels))
diff --git a/src/sampletones_application/logic/sequencer/tracker/block.py b/src/sampletones_application/logic/sequencer/tracker/block.py
new file mode 100644
index 00000000..a5501932
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/tracker/block.py
@@ -0,0 +1,35 @@
+from dataclasses import dataclass
+from typing import Dict, Optional, Tuple, Union
+
+from sampletones_core.project.instruments.note_off import NoteOff
+
+BlockNote = Union[str, NoteOff]
+BlockKey = Tuple[int, int]
+
+
+@dataclass(frozen=True)
+class TrackerBlock:
+ """A rectangle of tracker values, addressed by the offsets it was read at.
+
+ A key is a row offset paired with a slot offset. The row offset counts down from the block's
+ first row; the slot offset is measured from the base of the column the block begins in, so it
+ stays a multiple of three apart from the column it is replayed against and each value keeps
+ the kind of subcolumn it was read from.
+
+ A cell reaches the block in one of three states, and the maps hold them apart: a key carrying
+ a value writes that value, a key carrying ``None`` writes emptiness, and an absent key states
+ that the block says nothing about that cell — which is how a sample column its channels
+ disagree over stays transparent to whatever it is pasted onto.
+
+ Notes, transposes and volumes are kept in maps of their own so the kind of a value is
+ structural. It also fixes the order a write takes: every note lands before the transposes and
+ volumes sharing its row, which matters where a sample-column note clears the channels around
+ it.
+
+ A note names a sample by id rather than by instrument, so it carries a pitch and leaves the
+ channel to whichever column it is written into.
+ """
+
+ notes: Dict[BlockKey, Optional[BlockNote]]
+ transposes: Dict[BlockKey, Optional[int]]
+ volumes: Dict[BlockKey, Optional[int]]
diff --git a/src/sampletones_application/logic/sequencer/tracker/reader.py b/src/sampletones_application/logic/sequencer/tracker/reader.py
new file mode 100644
index 00000000..c0ef980d
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/tracker/reader.py
@@ -0,0 +1,106 @@
+from collections.abc import Hashable
+from typing import Callable, Dict, Optional, TypeVar
+
+from sampletones_application.view_model.sequencer.region import TrackerRegion
+from sampletones_application.view_model.sequencer.slot import (
+ column_slot_base,
+ slot_from_flat,
+)
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.project.instruments.instrument import Instrument
+from sampletones_core.project.instruments.note_off import NoteOff
+from sampletones_core.project.patterns.row import Row
+from sampletones_shared.utils.agreement import Agreement
+
+from .block import BlockKey, BlockNote, TrackerBlock
+from .tracker import SequencerTrackerLogic
+
+ValueT = TypeVar("ValueT", bound=Hashable)
+
+
+class TrackerBlockReader:
+ """Reads a selected region of the shown frame into a block a paste can replay.
+
+ The block is anchored at the column the region begins in, so it carries offsets rather than
+ grid coordinates and lands wherever it is written by the kind of each subcolumn.
+ """
+
+ def __init__(self, tracker_logic: SequencerTrackerLogic) -> None:
+ self._tracker = tracker_logic
+
+ def read(self, region: TrackerRegion) -> TrackerBlock:
+ """Takes the values a region covers, keeping each kind of subcolumn in a map of its own."""
+ base = column_slot_base(slot_from_flat(region.first_slot).generator)
+ return TrackerBlock(
+ notes=self._read_subcolumn(region, base, SubColumn.INSTRUMENT, self._note_of),
+ transposes=self._read_subcolumn(region, base, SubColumn.TRANSPOSE, self._transpose_of),
+ volumes=self._read_subcolumn(region, base, SubColumn.VOLUME, self._volume_of),
+ )
+
+ def _read_subcolumn(
+ self,
+ region: TrackerRegion,
+ base: int,
+ subcolumn: SubColumn,
+ select: Callable[[Optional[Row]], ValueT],
+ ) -> Dict[BlockKey, ValueT]:
+ """The values one kind of subcolumn holds across a region, keyed by the offsets it stands at.
+
+ A cell holding a definite value keeps it, an empty one keeps its emptiness, and a cell
+ whose channels disagree leaves its key out — which is what carries the sample column's
+ mixed reading over as a value the paste passes by.
+ """
+ values: Dict[BlockKey, ValueT] = {}
+ for row_offset, row_index in enumerate(region.rows):
+ for position, slot in enumerate(region.slots):
+ if slot.subcolumn is not subcolumn:
+ continue
+
+ agreement = self._agree(row_index, slot.generator, select)
+ if agreement.is_unanimous:
+ values[(row_offset, region.first_slot + position - base)] = agreement.value
+
+ return values
+
+ def _agree(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ select: Callable[[Optional[Row]], ValueT],
+ ) -> Agreement[ValueT]:
+ """What a column holds at a cell: a channel's own value, or the one its channels share.
+
+ A channel column answers for itself, so it is a group of one and always agrees. The sample
+ column answers for the channels it governs, which is the group its display summarises too,
+ so a block states about a cell exactly what the grid it came from shows there.
+ """
+ if generator is not None:
+ return Agreement.collapse([select(self._tracker.row(generator, row_index))])
+
+ return Agreement.collapse(
+ select(self._tracker.row(channel, row_index)) for channel in self._tracker.relevant_generators(row_index)
+ )
+
+ @staticmethod
+ def _note_of(row: Optional[Row]) -> Optional[BlockNote]:
+ """The note a row carries: the id of the sample it names, or the cut it holds.
+
+ A sample is taken by id so the note keeps its pitch and takes the channel of whichever
+ column it is written into.
+ """
+ match row.command if row is not None else None:
+ case Instrument() as instrument:
+ return instrument.sample_id
+ case NoteOff() as note_off:
+ return note_off
+ case None:
+ return None
+
+ @staticmethod
+ def _transpose_of(row: Optional[Row]) -> Optional[int]:
+ return row.transpose if row is not None else None
+
+ @staticmethod
+ def _volume_of(row: Optional[Row]) -> Optional[int]:
+ return row.volume if row is not None else None
diff --git a/src/sampletones_application/logic/sequencer/grid.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py
similarity index 68%
rename from src/sampletones_application/logic/sequencer/grid.py
rename to src/sampletones_application/logic/sequencer/tracker/tracker.py
index fa59aa7a..b9ad587f 100644
--- a/src/sampletones_application/logic/sequencer/grid.py
+++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py
@@ -1,12 +1,15 @@
from typing import Callable, Dict, FrozenSet, List, Optional, Set
from sampletones_application.logic.project.controller import ProjectController
-from sampletones_application.view_model.sequencer.grid import (
+from sampletones_application.view_model.sequencer.settings import (
+ SequencerSettingsViewModel,
+)
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.view_model.sequencer.tracker import (
SequencerCellViewModel,
- SequencerGridViewModel,
SequencerRowViewModel,
+ SequencerTrackerViewModel,
)
-from sampletones_application.view_model.sequencer.settings import SequencerSettingsViewModel
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.constants.general import MAX_VOLUME
from sampletones_core.project.instruments.instrument import Instrument
@@ -29,13 +32,17 @@
)
-class SequencerGridLogic(CallbackMixin):
+class SequencerTrackerLogic(CallbackMixin):
"""Builds the tracker grid and module-options view models from the project.
Holds the only piece of grid-local UI state, the visible order frame, and
translates raw panel events into :class:`ProjectController` mutations. The
controller's change events are wired (by the coordinator) back to the push
methods here, so a single mutation round-trips into a refreshed view.
+
+ Cell-level edits take an ``Optional[GeneratorName]`` naming the column they
+ address: a generator reaches that channel alone, while ``None`` addresses the
+ sample column and spreads the edit over the channels that column governs.
"""
def __init__(self, project_controller: ProjectController) -> None:
@@ -43,7 +50,7 @@ def __init__(self, project_controller: ProjectController) -> None:
self._frame_index: int = 0
self.on_settings_changed: Optional[Callable[[SequencerSettingsViewModel], None]] = None
- self.on_grid_changed: Optional[Callable[[SequencerGridViewModel], None]] = None
+ self.on_tracker_changed: Optional[Callable[[SequencerTrackerViewModel], None]] = None
self.on_frame_changed: Optional[Callable[[int], None]] = None
@property
@@ -55,57 +62,72 @@ def settings(self) -> SequencerSettingsViewModel:
tempo=project_settings.tempo,
speed=project_settings.speed,
rows_per_pattern=project.song.rows_per_pattern,
+ first_highlight=project_settings.first_highlight,
+ second_highlight=project_settings.second_highlight,
)
- def build_grid(self) -> SequencerGridViewModel:
+ def build_grid(self) -> SequencerTrackerViewModel:
song = self._controller.project.song
frame_count = song.order_length()
frame_index = self._clamp_frame(frame_count)
- patterns: Dict[GeneratorName, Pattern] = {}
- if frame_count > 0:
- for generator in GeneratorName.items():
- index = song.order[frame_index].get(generator)
- pattern = song.pattern(generator, index) if index is not None else None
- if pattern is not None:
- patterns[generator] = pattern
-
- row_count = self._frame_row_count(patterns, song.rows_per_pattern) if frame_count > 0 else 0
- rows = tuple(self._build_row(index, patterns) for index in range(row_count))
- return SequencerGridViewModel(
+ patterns = self._frame_patterns()
+ rows = tuple(self._build_row(index, patterns) for index in range(self.frame_row_count()))
+ return SequencerTrackerViewModel(
frame_index=frame_index,
frame_count=frame_count,
rows=rows,
)
- def _frame_row_count(
- self,
- patterns: Dict[GeneratorName, Pattern],
- rows_per_pattern: int,
- ) -> int:
- """Rows to show for the current frame.
+ def frame_row_count(self) -> int:
+ """Rows the current frame holds, the height a whole-frame edit spans.
- Empty (None) slots contribute no pattern, so a frame whose channels are all
- empty falls back to ``rows_per_pattern`` blank rows — keeping the frame
- editable so the first keystroke can auto-create a pattern for that channel.
+ A frame is as tall as its longest pattern. Empty (None) slots contribute no
+ pattern, so a frame whose channels are all empty falls back to
+ ``rows_per_pattern`` blank rows — keeping the frame editable so the first
+ keystroke can auto-create a pattern for that channel. Until the order holds
+ its first frame, the count is zero.
"""
- lengths = [pattern.length for pattern in patterns.values()]
+ song = self._controller.project.song
+ if song.order_length() == 0:
+ return 0
+
+ lengths = [pattern.length for pattern in self._frame_patterns().values()]
if lengths:
return max(lengths)
- return rows_per_pattern
+ return song.rows_per_pattern
+
+ def _frame_patterns(self) -> Dict[GeneratorName, Pattern]:
+ """The patterns the current frame's channels point at.
+
+ A channel contributes an entry once its slot names a pattern the song holds,
+ so the result covers exactly the channels carrying content at this frame.
+ """
+ song = self._controller.project.song
+ if self._frame_index >= song.order_length():
+ return {}
+
+ patterns: Dict[GeneratorName, Pattern] = {}
+ for generator in GeneratorName.items():
+ index = song.order[self._frame_index].get(generator)
+ pattern = song.pattern(generator, index) if index is not None else None
+ if pattern is not None:
+ patterns[generator] = pattern
+
+ return patterns
def push_settings(self) -> None:
self.call(self.on_settings_changed, self.settings)
- def push_grid(self) -> None:
+ def push_tracker(self) -> None:
view_model = self.build_grid()
- self.call(self.on_grid_changed, view_model)
+ self.call(self.on_tracker_changed, view_model)
self.call(self.on_frame_changed, view_model.frame_index)
def refresh(self) -> None:
self.push_settings()
- self.push_grid()
+ self.push_tracker()
def set_nes_frequency(self, nes_frequency: int) -> None:
self._controller.set_nes_frequency(nes_frequency)
@@ -119,6 +141,121 @@ def set_tempo(self, tempo: int) -> None:
def set_speed(self, speed: int) -> None:
self._controller.set_speed(speed)
+ def clear_cell(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ ) -> None:
+ if generator is None:
+ self.clear_all_generators(row_index)
+ else:
+ self.clear_row(generator, row_index)
+
+ def clear_cell_subcolumn(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ subcolumn: SubColumn,
+ ) -> None:
+ """Empties one subcolumn of a cell.
+
+ From the sample column an instrument reaches every channel, since the sample
+ it names is the row's whole note, while transpose and volume follow the
+ channels that column governs.
+ """
+ instrument = subcolumn is SubColumn.INSTRUMENT
+ transpose = subcolumn is SubColumn.TRANSPOSE
+ volume = subcolumn is SubColumn.VOLUME
+ if generator is not None:
+ self.clear_subcolumn(
+ generator,
+ row_index,
+ instrument=instrument,
+ transpose=transpose,
+ volume=volume,
+ )
+ elif instrument:
+ self.clear_subcolumn_all_generators(row_index, instrument=True)
+ else:
+ self.clear_sample_subcolumn(
+ row_index,
+ transpose=transpose,
+ volume=volume,
+ )
+
+ def write_cell(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ sample_id: Optional[str],
+ transpose: Optional[int],
+ volume: Optional[int],
+ ) -> None:
+ """Writes the value a cell edit carries, keeping the rest of the cell as it stands.
+
+ An edit names one subcolumn, so a sample takes the write whenever one
+ arrives, and an offset lands on its own otherwise.
+ """
+ if sample_id is not None:
+ self.place_note(row_index, generator, sample_id)
+ elif transpose is not None or volume is not None:
+ self.set_cell_subcolumn(
+ row_index,
+ generator,
+ transpose=transpose,
+ volume=volume,
+ )
+
+ def place_note(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ sample_id: str,
+ ) -> None:
+ if generator is None:
+ self.set_sample_instrument(row_index, sample_id)
+ else:
+ self.set_row(
+ generator,
+ row_index,
+ command=Instrument(
+ sample_id=sample_id,
+ generator_name=generator,
+ ),
+ )
+
+ def cut_note(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ ) -> None:
+ if generator is None:
+ self.set_note_off_all_generators(row_index)
+ else:
+ self.set_note_off(generator, row_index)
+
+ def set_cell_subcolumn(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ *,
+ transpose: Optional[int] = None,
+ volume: Optional[int] = None,
+ ) -> None:
+ if generator is None:
+ self.set_sample_subcolumn(
+ row_index,
+ transpose=transpose,
+ volume=volume,
+ )
+ else:
+ self.set_row(
+ generator,
+ row_index,
+ transpose=transpose,
+ volume=volume,
+ )
+
def set_row(
self,
generator: GeneratorName,
@@ -307,21 +444,12 @@ def adjust_volume(
volume=self._current_volume(generator, row_index) + delta,
)
- def adjust_sample_transpose(self, row_index: int, delta: int) -> None:
- """Shifts transpose by ``delta`` across the sample column's channels."""
- for generator in self._subcolumn_generators(row_index):
- self.adjust_transpose(generator, row_index, delta)
-
- def adjust_sample_volume(self, row_index: int, delta: int) -> None:
- """Shifts volume by ``delta`` across the sample column's channels."""
- for generator in self._subcolumn_generators(row_index):
- self.adjust_volume(generator, row_index, delta)
-
- def _current_row(
+ def row(
self,
generator: GeneratorName,
row_index: int,
) -> Optional[Row]:
+ """The row stored at a cell, present while its channel holds a pattern reaching that far."""
pattern_index = self._pattern_index_at_frame(generator)
if pattern_index is None:
return None
@@ -337,14 +465,14 @@ def _current_transpose(
generator: GeneratorName,
row_index: int,
) -> int:
- row = self._current_row(generator, row_index)
+ row = self.row(generator, row_index)
if row is None or row.transpose is None:
return 0
return row.transpose
def _current_volume(self, generator: GeneratorName, row_index: int) -> int:
- row = self._current_row(generator, row_index)
+ row = self.row(generator, row_index)
if row is None or row.volume is None:
return MAX_VOLUME
@@ -356,7 +484,11 @@ def frame_index(self) -> int:
def select_frame(self, frame_index: int) -> None:
self._frame_index = frame_index
- self.push_grid()
+ self.push_tracker()
+
+ def holds_sample(self, sample_id: str) -> bool:
+ """Whether the project holds the sample a note names, which is what makes the note placeable."""
+ return self._controller.project.samples.get(sample_id) is not None
def used_generators(self, sample_id: str) -> List[GeneratorName]:
"""The channels a sample provides instructions for, empty when it is unknown."""
@@ -415,13 +547,20 @@ def _subcolumn_generators(self, row_index: int) -> List[GeneratorName]:
Falls back to every channel when no sample constrains the row, mirroring
:attr:`SequencerRowViewModel.subcolumn_generators`.
"""
- relevant = self._relevant_generators(row_index)
- if not relevant:
+ referenced = self.referenced_generators(row_index)
+ if not referenced:
return GeneratorName.items()
- return [generator for generator in GeneratorName.items() if generator in relevant]
+ return [generator for generator in GeneratorName.items() if generator in referenced]
- def _relevant_generators(self, row_index: int) -> FrozenSet[GeneratorName]:
+ def referenced_generators(self, row_index: int) -> FrozenSet[GeneratorName]:
+ """The channels spanned by the samples a row names.
+
+ Reads the row from every channel's pattern, so it reports a sample's whole
+ span even where some of its cells stand empty. A row naming no sample
+ references no channel, which is what :meth:`relevant_generators` widens to
+ every channel.
+ """
rows: Dict[GeneratorName, Optional[Row]] = {}
for generator in GeneratorName.items():
pattern_index = self._pattern_index_at_frame(generator)
@@ -435,9 +574,9 @@ def _relevant_generators(self, row_index: int) -> FrozenSet[GeneratorName]:
)
rows[generator] = pattern.rows[row_index] if pattern is not None else None
- return self._relevant_generators_from_rows(rows)
+ return self._referenced_generators_from_rows(rows)
- def _relevant_generators_from_rows(
+ def _referenced_generators_from_rows(
self,
rows: Dict[GeneratorName, Optional[Row]],
) -> FrozenSet[GeneratorName]:
@@ -487,7 +626,7 @@ def _build_row(
return SequencerRowViewModel(
index=index,
cells=cells,
- relevant_generators=self._relevant_generators_from_rows(rows),
+ relevant_generators=self._referenced_generators_from_rows(rows),
)
def _build_cell(self, row: Row) -> SequencerCellViewModel:
diff --git a/src/sampletones_application/logic/sequencer/tracker/writer.py b/src/sampletones_application/logic/sequencer/tracker/writer.py
new file mode 100644
index 00000000..22a98438
--- /dev/null
+++ b/src/sampletones_application/logic/sequencer/tracker/writer.py
@@ -0,0 +1,140 @@
+from collections.abc import Hashable
+from typing import Callable, Dict, Optional, TypeVar
+
+from sampletones_application.view_model.sequencer.region import (
+ TrackerCell,
+ TrackerRegion,
+)
+from sampletones_application.view_model.sequencer.slot import (
+ SLOT_COUNT,
+ column_slot_base,
+ slot_from_flat,
+)
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.project.instruments.note_off import NoteOff
+
+from .block import BlockKey, BlockNote, TrackerBlock
+from .tracker import SequencerTrackerLogic
+
+ValueT = TypeVar("ValueT", bound=Hashable)
+
+
+class TrackerBlockWriter:
+ """Replays a block into the shown frame, and empties the cells a region covers.
+
+ Every cell reaches the grid through the single-slot edit that already governs it, so a paste
+ lands exactly the writes a reader typing the same values by hand would make, sample column
+ included.
+ """
+
+ def __init__(self, tracker_logic: SequencerTrackerLogic) -> None:
+ self._tracker = tracker_logic
+
+ def write(self, block: TrackerBlock, cell: TrackerCell) -> None:
+ """Writes a block anchored at a cell, the cell supplying the column and the block the rest.
+
+ Each kind of subcolumn is written in a pass of its own, notes first: a note through the
+ sample column decides the whole row, so the transposes and volumes sharing that row land
+ on top of the channels it settled.
+ """
+ base = column_slot_base(cell.generator)
+ self._write_pass(block.notes, cell, base, self._write_note)
+ self._write_pass(block.transposes, cell, base, self._write_transpose)
+ self._write_pass(block.volumes, cell, base, self._write_volume)
+
+ def clear(self, region: TrackerRegion) -> None:
+ """Empties every subcolumn a region covers, each by the rule its own column follows."""
+ for row_index in region.rows:
+ for slot in region.slots:
+ self._tracker.clear_cell_subcolumn(
+ row_index,
+ slot.generator,
+ slot.subcolumn,
+ )
+
+ def _write_pass(
+ self,
+ values: Dict[BlockKey, ValueT],
+ cell: TrackerCell,
+ base: int,
+ write: Callable[[int, Optional[GeneratorName], ValueT], None],
+ ) -> None:
+ """Writes one kind of subcolumn across the block, dropping what falls outside the grid.
+
+ Keys are taken in reading order, so a row's sample column is written before the channels
+ beside it and the more specific write is the one that stands. A row past the frame's last
+ or a slot past the last column is left out, which clips a block at the edge rather than
+ wrapping it around.
+ """
+ row_count = self._tracker.frame_row_count()
+ for (row_offset, slot_offset), value in sorted(values.items()):
+ row_index = cell.row + row_offset
+ slot_index = base + slot_offset
+ if row_index >= row_count or slot_index >= SLOT_COUNT:
+ continue
+
+ write(row_index, slot_from_flat(slot_index).generator, value)
+
+ def _write_note(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ note: Optional[BlockNote],
+ ) -> None:
+ """Writes the note a cell carries: a sample by id, a cut, or the emptiness of neither.
+
+ A sample the project no longer holds leaves the cell as it stands, so a block outliving
+ the project it was read from writes the notes that still name something and passes over
+ the rest.
+ """
+ match note:
+ case NoteOff():
+ self._tracker.cut_note(row_index, generator)
+ case str() as sample_id:
+ if self._tracker.holds_sample(sample_id):
+ self._tracker.place_note(row_index, generator, sample_id)
+ case None:
+ self._tracker.clear_cell_subcolumn(
+ row_index,
+ generator,
+ SubColumn.INSTRUMENT,
+ )
+
+ def _write_transpose(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ transpose: Optional[int],
+ ) -> None:
+ if transpose is None:
+ self._tracker.clear_cell_subcolumn(
+ row_index,
+ generator,
+ SubColumn.TRANSPOSE,
+ )
+ else:
+ self._tracker.set_cell_subcolumn(
+ row_index,
+ generator,
+ transpose=transpose,
+ )
+
+ def _write_volume(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ volume: Optional[int],
+ ) -> None:
+ if volume is None:
+ self._tracker.clear_cell_subcolumn(
+ row_index,
+ generator,
+ SubColumn.VOLUME,
+ )
+ else:
+ self._tracker.set_cell_subcolumn(
+ row_index,
+ generator,
+ volume=volume,
+ )
diff --git a/src/sampletones_application/logic/shared/project_source.py b/src/sampletones_application/logic/shared/project_source.py
new file mode 100644
index 00000000..20854674
--- /dev/null
+++ b/src/sampletones_application/logic/shared/project_source.py
@@ -0,0 +1,55 @@
+import copy
+from dataclasses import dataclass
+from typing import Dict, Protocol, Self
+
+from sampletones_core.project import Project
+
+
+def snapshot_project(project: Project) -> Project:
+ """Captures an independent copy of a project that shares reconstruction audio.
+
+ The song, settings, metadata and sample shells are deep-copied so later edits
+ to the live project leave the snapshot untouched. Each sample's reconstruction
+ is shared by reference, so the snapshot reuses those multi-megabyte audio
+ arrays. Reconstruction edits are copy-on-write — each installs a fresh
+ reconstruction — so the shared reconstruction stays valid for the life of the
+ snapshot.
+ """
+ shared_reconstructions: Dict[int, object] = {
+ id(sample.reconstruction): sample.reconstruction for sample in project.samples
+ }
+ return copy.deepcopy(project, shared_reconstructions)
+
+
+class ProjectSource(Protocol):
+ """Where a reader of the open document finds the project it works on.
+
+ A reader of the song needs the project and nothing else about where it came from.
+ :class:`~sampletones_application.logic.project.controller.ProjectController` satisfies this, so
+ playback follows every edit as it is made; :class:`ProjectSnapshot` satisfies it too, so a long
+ operation describes the document as it stood when it was asked for. Depending on this protocol
+ is what lets one synthesis kernel serve both.
+ """
+
+ @property
+ def project(self) -> Project: ...
+
+
+@dataclass(frozen=True)
+class ProjectSnapshot:
+ """One project held still, the document a long operation reads.
+
+ A render walks the whole song on a worker thread while the user keeps editing. Reading a
+ snapshot makes the result describe one state of the document: the state it was requested in,
+ from the first row to the last.
+
+ Attributes:
+ project: The document as it stood when the snapshot was taken.
+ """
+
+ project: Project
+
+ @classmethod
+ def capture(cls, source: ProjectSource) -> Self:
+ """Takes the document ``source`` currently holds, copied through :func:`snapshot_project`."""
+ return cls(project=snapshot_project(source.project))
diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py
index 155c548e..c42332c9 100644
--- a/src/sampletones_application/logic/shared/tree.py
+++ b/src/sampletones_application/logic/shared/tree.py
@@ -2,15 +2,15 @@
from typing import Callable, Optional
from sampletones_application.config.managers.session import SessionManager
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.logic.shared.playback_priority import PlaybackPriority
from sampletones_application.utils.callbacks.queue import CallbackQueue
-from sampletones_core import paths
from sampletones_core.audio import AudioDeviceManager
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode
from sampletones_shared.exceptions import SampleToNESError
from sampletones_shared.logger import logger
+from sampletones_shared.paths import extensions
from sampletones_shared.types.callback import VoidCallback
from sampletones_shared.utils.callbacks import CallbackMixin
@@ -103,7 +103,7 @@ def is_playable_file(self, node: TreeNode) -> bool:
return False
suffix = node.filepath.suffix.lower()
- return suffix == paths.EXT_FILE_RECONSTRUCTION or suffix in paths.EXT_FILES_AUDIO
+ return suffix == extensions.EXT_FILE_RECONSTRUCTION or suffix in extensions.EXT_FILES_AUDIO
def _execute_autoplay(self) -> None:
if self._pending_autoplay_node is not None:
@@ -119,7 +119,7 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None:
return
match node.filepath.suffix.lower():
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
try:
reconstruction = Reconstruction.load(node.filepath)
self._audio_device_manager.play(
@@ -133,7 +133,7 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None:
f"Failed to play reconstruction file: {node.filepath}",
)
self.call(self.on_autoplay_error, exception)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
self._audio_device_manager.play_file(
node.filepath,
update=False,
@@ -147,17 +147,14 @@ def is_node_favorite(self, node: TreeNode) -> bool:
return node.filepath in self._session_manager.favorites
def has_favorite_ancestor(self, node: FileSystemNode) -> bool:
- current_node = node.parent
- while current_node is not None:
- if not isinstance(current_node, FileSystemNode):
- break
+ """Whether a favorite directory holds this path, at any depth above it.
- if self.is_node_favorite(current_node):
- return True
-
- current_node = current_node.parent
-
- return False
+ The answer reads the path rather than the rows above it, so it holds wherever a view puts
+ the node: a reconstruction listed under the sample it came from sits below groups the
+ browser invented, and the directory that makes it a favorite child is still on its path.
+ """
+ favorites = self._session_manager.favorites
+ return any(directory in favorites for directory in node.filepath.parents)
def toggle_favorite(self, node: FileSystemNode) -> None:
self._session_manager.toggle_favorite(node.filepath)
@@ -179,3 +176,11 @@ def _execute_search_update(self) -> None:
@property
def autoplay_enabled(self) -> bool:
return self._session_manager.autoplay
+
+ @property
+ def auto_expand_favorite_reconstructions(self) -> bool:
+ return self._session_manager.auto_expand_favorite_reconstructions
+
+ @property
+ def auto_expand_favorite_directories(self) -> bool:
+ return self._session_manager.auto_expand_favorite_directories
diff --git a/src/sampletones_application/parameters/instructions.py b/src/sampletones_application/parameters/instructions.py
index 4ddb2aa5..10c592ea 100644
--- a/src/sampletones_application/parameters/instructions.py
+++ b/src/sampletones_application/parameters/instructions.py
@@ -2,9 +2,9 @@
from dataclasses import dataclass
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.layout.config import LayoutConfig
-from sampletones_application.layout.general.colors import TableColors
+from sampletones_application.layout.general.colors.table import TableColors
from sampletones_application.layout.general.tables import TablesLayout
from sampletones_application.layout.graphs import GraphsLayout
from sampletones_application.layout.tabs.instructions import InstructionsLayout
@@ -18,7 +18,7 @@ class InstructionsTabParameters:
"""Everything the Instructions tab coordinator needs, shaped for the coordinator.
The stacked-graph geometry — the vertical baseline, the per-graph base height, and the
- ceiling the stack grows to — is flattened alongside the shared column geometry because it
+ ceiling each graph grows to — is flattened alongside the shared column geometry because it
feeds the ``stacked_graph_height`` pure-int sink; the choice panel's slice of the general
layout is narrowed to a ``PitchStepperStyle`` so the whole ``GeneralLayout`` never reaches
a panel.
@@ -26,7 +26,7 @@ class InstructionsTabParameters:
geometry: TabGeometry
baseline_viewport_height: int
- max_stack_height: int
+ max_graph_height: int
base_graph_height: int
right_column_width: int
right_column_height: int
@@ -44,7 +44,7 @@ def from_config(cls, config: LayoutConfig) -> InstructionsTabParameters:
return cls(
geometry=TabGeometry.from_config(config),
baseline_viewport_height=general.responsive.baseline_viewport_height,
- max_stack_height=general.responsive.max_stack_height,
+ max_graph_height=general.responsive.max_graph_height,
base_graph_height=config.graphs.dimensions.height,
right_column_width=config.tabs.instructions.right_column.width,
right_column_height=config.tabs.instructions.right_column.height,
diff --git a/src/sampletones_application/parameters/main.py b/src/sampletones_application/parameters/main.py
index 2e0db49b..ec602b45 100644
--- a/src/sampletones_application/parameters/main.py
+++ b/src/sampletones_application/parameters/main.py
@@ -2,9 +2,9 @@
from dataclasses import dataclass
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.layout.config import LayoutConfig
-from sampletones_application.layout.general.colors import PathColors
+from sampletones_application.layout.general.colors.path import PathColors
from sampletones_application.layout.general.inputs import InputsLayout
from sampletones_application.layout.tabs.main import MainLayout
from sampletones_application.parameters.geometry import TabGeometry
diff --git a/src/sampletones_application/parameters/reconstruction.py b/src/sampletones_application/parameters/reconstruction.py
index 7cb9245d..42adf017 100644
--- a/src/sampletones_application/parameters/reconstruction.py
+++ b/src/sampletones_application/parameters/reconstruction.py
@@ -2,14 +2,15 @@
from dataclasses import dataclass
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.layout.config import LayoutConfig
-from sampletones_application.layout.general.colors import FeatureColors, PathColors
+from sampletones_application.layout.general.colors.feature import FeatureColors
+from sampletones_application.layout.general.colors.path import PathColors
from sampletones_application.layout.graphs import GraphsLayout
from sampletones_application.parameters.geometry import TabGeometry
from sampletones_application.ui.elements.pitch_stepper import PitchStepperStyle
from sampletones_application.ui.elements.tree.colors import TreeColors
-from sampletones_application.utils.palette import PaletteColor
+from sampletones_application.utils.palette.colors.base import BaseColor
@dataclass(frozen=True)
@@ -29,7 +30,7 @@ class ReconstructionTabParameters:
copy_width: int
feature_colors: FeatureColors
path_colors: PathColors
- path_status_color: PaletteColor
+ path_status_color: BaseColor
tree_colors: TreeColors
scheduling: SchedulingBehavior
diff --git a/src/sampletones_application/parameters/sequencer.py b/src/sampletones_application/parameters/sequencer.py
index f80163d0..d071ee3c 100644
--- a/src/sampletones_application/parameters/sequencer.py
+++ b/src/sampletones_application/parameters/sequencer.py
@@ -2,14 +2,15 @@
from dataclasses import dataclass
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.layout.config import LayoutConfig
-from sampletones_application.layout.general.colors import FeatureColors
+from sampletones_application.layout.general.colors.feature import FeatureColors
from sampletones_application.layout.general.inputs import InputsLayout
from sampletones_application.layout.general.plus_minus_buttons import PlusMinusButtonsLayout
from sampletones_application.layout.tabs.sequencer import SequencerLayout
from sampletones_application.parameters.geometry import TabGeometry
from sampletones_application.ui.elements.tree.colors import TreeColors
+from sampletones_application.utils.palette.colors.base import BaseColor
@dataclass(frozen=True)
@@ -33,6 +34,7 @@ class SequencerTabParameters:
plus_minus: PlusMinusButtonsLayout
feature_colors: FeatureColors
tree_colors: TreeColors
+ muted_color: BaseColor
scheduling: SchedulingBehavior
@classmethod
@@ -52,5 +54,6 @@ def from_config(cls, config: LayoutConfig) -> SequencerTabParameters:
general.colors,
accent=general.colors.headers.reconstruction,
),
+ muted_color=general.colors.text.disabled,
scheduling=config.behavior.scheduling,
)
diff --git a/src/sampletones_application/paths.py b/src/sampletones_application/paths.py
index 93872f20..0ae44e42 100644
--- a/src/sampletones_application/paths.py
+++ b/src/sampletones_application/paths.py
@@ -1,13 +1,14 @@
from pathlib import Path
from typing import Final
-from sampletones_core.paths import USER_PATH_CONFIG
-from sampletones_shared.paths import CONFIG_DIRECTORY
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
+from sampletones_shared.paths.user import USER_PATH_CONFIG
APPLICATION_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "application"
BEHAVIOR_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "behavior"
+KEYBINDINGS_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "keybindings"
LAYOUT_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "layout"
-PALETTE_PATH: Final[Path] = LAYOUT_DIRECTORY / "palette.yaml"
+PALETTES_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "palettes"
LANG_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "lang"
THEME_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "theme"
diff --git a/src/sampletones_application/services/__init__.py b/src/sampletones_application/services/__init__.py
index 2ce762af..59f0dcca 100644
--- a/src/sampletones_application/services/__init__.py
+++ b/src/sampletones_application/services/__init__.py
@@ -10,6 +10,11 @@
RegenerationResult,
RegenerationService,
)
+from sampletones_application.services.render import (
+ RenderResult,
+ RenderStage,
+ SongRenderService,
+)
from sampletones_application.services.result import (
ConversionResult,
ServiceCancelled,
@@ -20,6 +25,7 @@
ServiceSuccess,
)
from sampletones_application.services.retune import RetunedSample, RetuneResult, SampleRetuneService
+from sampletones_application.services.synthesis import RowSynthesizerProtocol
__all__ = [
"ConversionResult",
@@ -32,8 +38,11 @@
"RegeneratedInstrument",
"RegenerationResult",
"RegenerationService",
+ "RenderResult",
+ "RenderStage",
"RetuneResult",
"RetunedSample",
+ "RowSynthesizerProtocol",
"SampleRetuneService",
"ServiceBase",
"ServiceCancelled",
@@ -42,4 +51,5 @@
"ServiceProgress",
"ServiceStarted",
"ServiceSuccess",
+ "SongRenderService",
]
diff --git a/src/sampletones_application/services/conversion.py b/src/sampletones_application/services/conversion.py
index d47a7322..5b07c9ed 100644
--- a/src/sampletones_application/services/conversion.py
+++ b/src/sampletones_application/services/conversion.py
@@ -86,7 +86,11 @@ def _on_start(self) -> None:
self._eta_estimator = ETAEstimator(total=total)
self._emit(ServiceStarted(total=total))
- def _on_progress(self, task_status: TaskStatus, task_progress: TaskProgress) -> None:
+ def _on_progress(
+ self,
+ task_status: TaskStatus,
+ task_progress: TaskProgress,
+ ) -> None:
current_item: Optional[Path] = None
if task_progress.current_item is not None:
current_item = to_path(task_progress.current_item)
diff --git a/src/sampletones_application/services/regeneration.py b/src/sampletones_application/services/regeneration.py
index 7281497d..5602803d 100644
--- a/src/sampletones_application/services/regeneration.py
+++ b/src/sampletones_application/services/regeneration.py
@@ -12,6 +12,7 @@
from sampletones_application.utils.parallelization.coalescing import LatestWinsExecutor
from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.exporters import GENERATOR_NAME_TO_EXPORTER_MAP, Features
+from sampletones_core.generators import GeneratorUnion
from sampletones_core.instructions import InstructionUnion
from sampletones_core.reconstructions import Reconstruction
from sampletones_core.types.feature import FeatureValue
@@ -100,9 +101,7 @@ def _run(
exporter_class.from_features(features),
)
generator = generator_class(reconstruction.config, generator_name)
- audio = np.concatenate(
- [generator(instruction, save=True) for instruction in instructions], # type: ignore[arg-type]
- )
+ audio = self._render(generator, instructions)
updated = reconstruction.model_copy(deep=True)
updated.update_generator_data(
@@ -110,6 +109,7 @@ def _run(
instructions,
audio,
features.initial_pitch,
+ features.held_features,
)
self._emit(
ServiceSuccess(
@@ -122,3 +122,20 @@ def _run(
)
except Exception as exception: # pylint: disable=broad-exception-caught
self._emit(ServiceError(exception=exception))
+
+ @staticmethod
+ def _render(
+ generator: GeneratorUnion,
+ instructions: List[InstructionUnion],
+ ) -> np.ndarray:
+ """Synthesizes the frames the instructions describe, one after another.
+
+ An instrument whose every dimension is left to the channel describes no frame, and
+ sounds as the silence of an empty waveform.
+ """
+ if not instructions:
+ return np.zeros(0, dtype=np.float32)
+
+ return np.concatenate(
+ [generator(instruction, save=True) for instruction in instructions], # type: ignore[arg-type]
+ )
diff --git a/src/sampletones_application/services/render/__init__.py b/src/sampletones_application/services/render/__init__.py
new file mode 100644
index 00000000..589c798b
--- /dev/null
+++ b/src/sampletones_application/services/render/__init__.py
@@ -0,0 +1,18 @@
+from sampletones_application.services.render.result import RenderResult, RenderStage
+from sampletones_application.services.render.service import SongRenderService
+from sampletones_application.services.render.sink import (
+ DirectRenderSink,
+ NormalizingRenderSink,
+ RenderSink,
+ build_render_sink,
+)
+
+__all__ = [
+ "DirectRenderSink",
+ "NormalizingRenderSink",
+ "RenderResult",
+ "RenderSink",
+ "RenderStage",
+ "SongRenderService",
+ "build_render_sink",
+]
diff --git a/src/sampletones_application/services/render/constants.py b/src/sampletones_application/services/render/constants.py
new file mode 100644
index 00000000..df1ca36e
--- /dev/null
+++ b/src/sampletones_application/services/render/constants.py
@@ -0,0 +1,5 @@
+from typing import Final
+
+PROGRESS_STEPS: Final[int] = 200
+ENCODE_BLOCK_SAMPLES: Final[int] = 1 << 16
+SCRATCH_SUFFIX: Final[str] = ".scratch"
diff --git a/src/sampletones_application/services/render/progress.py b/src/sampletones_application/services/render/progress.py
new file mode 100644
index 00000000..da09d67e
--- /dev/null
+++ b/src/sampletones_application/services/render/progress.py
@@ -0,0 +1,49 @@
+from typing import Callable
+
+from sampletones_application.services.render.constants import PROGRESS_STEPS
+from sampletones_application.services.render.result import RenderStage
+from sampletones_application.services.result import ServiceProgress
+from sampletones_core.parallelization import ETAEstimator
+
+
+class StageProgress:
+ """One pass of a render, reported at a bounded rate.
+
+ A render walks a song sample by sample, so reporting every step would fill the callback
+ queue with updates no eye resolves and no bar redraws. Emitting on a fraction of the total
+ holds the report rate steady whatever the song's length, and the last position is always
+ reported, so a bar arrives at its end.
+ """
+
+ def __init__(
+ self,
+ stage: RenderStage,
+ total: int,
+ *,
+ emit: Callable[[ServiceProgress[RenderStage]], None],
+ ) -> None:
+ self._stage = stage
+ self._total = total
+ self._emit = emit
+ self._estimator = ETAEstimator(total=total)
+ self._interval = max(1, total // PROGRESS_STEPS)
+ self._reported: int = 0
+
+ def advance(self, completed: int) -> None:
+ """Reports the pass at ``completed`` samples where a step is due.
+
+ Args:
+ completed: The samples this pass has covered so far.
+ """
+ if completed < self._total and completed - self._reported < self._interval:
+ return
+
+ self._reported = completed
+ self._emit(
+ ServiceProgress(
+ completed=completed,
+ total=self._total,
+ current_item=self._stage,
+ eta_seconds=self._estimator.update(completed),
+ )
+ )
diff --git a/src/sampletones_application/services/render/result.py b/src/sampletones_application/services/render/result.py
new file mode 100644
index 00000000..9ee105e5
--- /dev/null
+++ b/src/sampletones_application/services/render/result.py
@@ -0,0 +1,31 @@
+from enum import StrEnum
+from pathlib import Path
+from typing import Union
+
+from sampletones_application.services.result import (
+ ServiceCancelled,
+ ServiceError,
+ ServiceProgress,
+ ServiceStarted,
+ ServiceSuccess,
+)
+
+
+class RenderStage(StrEnum):
+ """The pass a render is on, naming what a progress report is counting.
+
+ Both passes count the samples the song holds, so a report reads the same way whichever one
+ it comes from and a bar crosses the same axis twice.
+ """
+
+ SYNTHESIS = "synthesis"
+ ENCODING = "encoding"
+
+
+RenderResult = Union[
+ ServiceStarted,
+ ServiceProgress[RenderStage],
+ ServiceSuccess[Path],
+ ServiceError,
+ ServiceCancelled,
+]
diff --git a/src/sampletones_application/services/render/scratch.py b/src/sampletones_application/services/render/scratch.py
new file mode 100644
index 00000000..0e838ab7
--- /dev/null
+++ b/src/sampletones_application/services/render/scratch.py
@@ -0,0 +1,86 @@
+from pathlib import Path
+from typing import BinaryIO, Final, Iterator, Optional
+
+import numpy as np
+
+from sampletones_shared.exceptions import AudioWriteError
+
+NO_PEAK: Final[float] = 0.0
+
+
+class ScratchAudio:
+ """A render's samples spilled to disk beside its destination while their peak is discovered.
+
+ Scaling a render to its peak needs the whole render before any of it can be written, and a
+ song is longer than a buffer worth holding in memory. Raw float32 samples are what a private
+ intermediate needs: the file is written once, read back once in blocks, and removed, so a
+ container would only describe what the writer already knows.
+
+ Attributes:
+ path: Where the samples are spilled.
+ """
+
+ def __init__(self, path: Path) -> None:
+ self.path = path
+ self._handle: Optional[BinaryIO] = None
+ self._peak: float = NO_PEAK
+ self._samples: int = 0
+
+ @property
+ def samples(self) -> int:
+ """How many samples have been spilled."""
+ return self._samples
+
+ @property
+ def peak(self) -> float:
+ """The loudest sample spilled so far, as an absolute amplitude."""
+ return self._peak
+
+ def start(self) -> None:
+ """Opens the spill file, replacing anything a previous run left at the path."""
+ self._handle = self.path.open("wb")
+
+ def write(self, chunk: np.ndarray) -> None:
+ """Appends one chunk, keeping the loudest sample seen across the whole spill.
+
+ Args:
+ chunk: Float samples to spill.
+
+ Raises:
+ AudioWriteError: If the spill file is not open.
+ """
+ if self._handle is None:
+ raise AudioWriteError(f"No spill file open at '{self.path}'; write between start and seal")
+
+ chunk.astype(np.float32, copy=False).tofile(self._handle)
+ self._peak = max(self._peak, float(np.max(np.abs(chunk), initial=NO_PEAK)))
+ self._samples += len(chunk)
+
+ def seal(self) -> None:
+ """Closes the spill file, leaving what was written ready to read back."""
+ if self._handle is None:
+ return
+
+ self._handle.close()
+ self._handle = None
+
+ def blocks(self, size: int) -> Iterator[np.ndarray]:
+ """Reads the spilled samples back in order, in blocks of at most ``size`` samples.
+
+ Args:
+ size: The samples one block holds at most; the last block holds what remains.
+
+ Yields:
+ np.ndarray: One block of the spilled float samples.
+ """
+ with self.path.open("rb") as handle:
+ while True:
+ block = np.fromfile(handle, dtype=np.float32, count=size)
+ if not block.size:
+ return
+
+ yield block
+
+ def remove(self) -> None:
+ """Deletes the spill file, whether or not it was read back."""
+ self.path.unlink(missing_ok=True)
diff --git a/src/sampletones_application/services/render/service.py b/src/sampletones_application/services/render/service.py
new file mode 100644
index 00000000..fd43c284
--- /dev/null
+++ b/src/sampletones_application/services/render/service.py
@@ -0,0 +1,167 @@
+import threading
+from functools import partial
+from pathlib import Path
+
+from sampletones_application.services.base import ServiceBase
+from sampletones_application.services.render.progress import StageProgress
+from sampletones_application.services.render.result import RenderResult, RenderStage
+from sampletones_application.services.render.sink import (
+ EncodeReporter,
+ RenderSink,
+ build_render_sink,
+)
+from sampletones_application.services.result import (
+ ServiceCancelled,
+ ServiceError,
+ ServiceStarted,
+ ServiceSuccess,
+)
+from sampletones_application.services.synthesis.protocol import RowSynthesizerProtocol
+from sampletones_application.utils.parallelization.thread import SingleThreadExecutor
+from sampletones_core.audio.writers import AudioOutputSpec
+from sampletones_shared.logger import logger
+
+
+class SongRenderService(ServiceBase[RenderResult]):
+ """Renders a whole song to a file on a background thread, reporting each pass as it runs.
+
+ The synthesiser arrives per call, so the service holds no opinion on what a song sounds
+ like: it drives the same kernel the player drives, one row at a time, and hands each row to
+ a sink. The sink decides what becomes of a row — straight to the encoder, or spilled and
+ written back at the level the whole render turned out to reach — so the service reports one
+ pass or two without knowing which format waits on the other side.
+
+ A render is one at a time. Cancelling is honoured between rows and between encoded blocks,
+ and the file a cancelled or failed run was writing is removed, so a result names a path only
+ where a finished file stands.
+ """
+
+ def __init__(self, priority: int = 0) -> None:
+ super().__init__(priority)
+ self._executor = SingleThreadExecutor()
+ self._cancel_event = threading.Event()
+ self._running = threading.Event()
+
+ def start(
+ self,
+ *,
+ synthesizer: RowSynthesizerProtocol,
+ destination: Path,
+ spec: AudioOutputSpec,
+ normalize: bool,
+ total_samples: int,
+ ) -> bool:
+ """Begins a render on the worker thread; reports whether it took the request.
+
+ Args:
+ synthesizer: The kernel the song is rendered through, from its first row.
+ destination: Where the finished file is written.
+ spec: The format, rate, and quality it is written at.
+ normalize: Whether the render is scaled so its loudest sample reaches full scale.
+ total_samples: The samples the whole song holds, which the passes are measured against.
+
+ Returns:
+ bool: Whether a render started; a request arriving while one runs is declined.
+ """
+ if self.is_running():
+ logger.warning(f"{self.class_name}: a render is already running; start ignored")
+ return False
+
+ self._cancel_event.clear()
+ self._running.set()
+ sink = build_render_sink(destination, spec, normalize=normalize)
+ started = self._executor.execute(
+ partial(self._run, synthesizer, sink, total_samples),
+ wait=False,
+ )
+ if not started:
+ self._running.clear()
+
+ return started
+
+ def cancel(self) -> None:
+ """Asks a running render to stop at its next row or block."""
+ self._cancel_event.set()
+
+ def is_running(self) -> bool:
+ return self._running.is_set()
+
+ def shutdown(self) -> None:
+ """Winds a running render down for application exit.
+
+ The worker runs on a :class:`SingleThreadExecutor`, so the teardown that joins every
+ background worker reaches this one; asking it to stop first is what keeps that join short.
+ """
+ self._cancel_event.set()
+
+ def _run(
+ self,
+ synthesizer: RowSynthesizerProtocol,
+ sink: RenderSink,
+ total_samples: int,
+ ) -> None:
+ try:
+ self._emit(ServiceStarted(total=total_samples))
+ self._report_outcome(sink, self._render(synthesizer, sink, total_samples))
+ except Exception as exception: # pylint: disable=broad-exception-caught
+ logger.error_with_traceback(exception, f"{self.class_name}: failed to render to {sink.destination}")
+ sink.discard()
+ self._emit(ServiceError(exception=exception))
+ finally:
+ self._running.clear()
+
+ def _render(
+ self,
+ synthesizer: RowSynthesizerProtocol,
+ sink: RenderSink,
+ total_samples: int,
+ ) -> bool:
+ with sink:
+ if not self._synthesize(synthesizer, sink, total_samples):
+ return False
+
+ return sink.finish(self._encode_reporter(total_samples))
+
+ def _synthesize(
+ self,
+ synthesizer: RowSynthesizerProtocol,
+ sink: RenderSink,
+ total_samples: int,
+ ) -> bool:
+ """Renders the song from its first row into the sink; reports whether it reached the end.
+
+ The song is rendered as the document holds it, from the top: the position a listener left
+ the playhead at is a listening choice, and a render describes the whole song.
+ """
+ progress = StageProgress(RenderStage.SYNTHESIS, total_samples, emit=self._emit)
+ synthesizer.set_position(0, 0)
+ synthesizer.reset()
+
+ rendered = 0
+ while not synthesizer.is_finished:
+ if self._cancel_event.is_set():
+ return False
+
+ chunk, _ = synthesizer.render_row()
+ sink.write(chunk)
+ rendered = min(total_samples, rendered + len(chunk))
+ progress.advance(rendered)
+
+ return not self._cancel_event.is_set()
+
+ def _encode_reporter(self, total_samples: int) -> EncodeReporter:
+ progress = StageProgress(RenderStage.ENCODING, total_samples, emit=self._emit)
+ return partial(self._report_encoded, progress)
+
+ def _report_encoded(self, progress: StageProgress, encoded: int) -> bool:
+ progress.advance(encoded)
+ return not self._cancel_event.is_set()
+
+ def _report_outcome(self, sink: RenderSink, completed: bool) -> None:
+ if not completed:
+ sink.discard()
+ self._emit(ServiceCancelled())
+ return
+
+ logger.info(f"Rendered the song to: {logger.format_path(sink.destination)}")
+ self._emit(ServiceSuccess(value=sink.destination))
diff --git a/src/sampletones_application/services/render/sink.py b/src/sampletones_application/services/render/sink.py
new file mode 100644
index 00000000..4f46dae3
--- /dev/null
+++ b/src/sampletones_application/services/render/sink.py
@@ -0,0 +1,194 @@
+from contextlib import ExitStack
+from pathlib import Path
+from types import TracebackType
+from typing import Callable, Final, Optional, Protocol, Self, Type
+
+import numpy as np
+
+from sampletones_application.services.render.constants import (
+ ENCODE_BLOCK_SAMPLES,
+ SCRATCH_SUFFIX,
+)
+from sampletones_application.services.render.scratch import NO_PEAK, ScratchAudio
+from sampletones_core.audio.writers import AudioOutputSpec, AudioWriter, open_audio_writer
+from sampletones_shared.constants.audio import UNITY_GAIN
+from sampletones_shared.exceptions import AudioWriteError
+
+FULL_SCALE: Final[float] = 1.0
+
+EncodeReporter = Callable[[int], bool]
+
+
+class RenderSink(Protocol):
+ """Where a render's rows go on their way to the destination file.
+
+ A sink is entered for the length of one render: rows arrive through ``write`` in the order
+ they are synthesised, and ``finish`` completes whatever the sink still owes the destination.
+ Leaving the sink closes what it opened and clears what was only ever temporary; ``discard``
+ is how a caller that decided against the result removes the file itself.
+
+ Attributes:
+ destination: The file the render is written to.
+ """
+
+ destination: Path
+
+ def __enter__(self) -> Self: ...
+
+ def __exit__(
+ self,
+ exception_type: Optional[Type[BaseException]],
+ exception: Optional[BaseException],
+ traceback: Optional[TracebackType],
+ ) -> None: ...
+
+ def write(self, chunk: np.ndarray) -> None: ...
+
+ def finish(self, report: EncodeReporter, /) -> bool: ...
+
+ def discard(self) -> None: ...
+
+
+class DirectRenderSink:
+ """Writes each row to the destination as it is synthesised.
+
+ One pass over the song, at the level the synthesiser produced: the encoder receives a row as
+ soon as it exists, so the file grows with the render and nothing is held between the two.
+ """
+
+ def __init__(self, destination: Path, spec: AudioOutputSpec) -> None:
+ self.destination = destination
+ self._spec = spec
+ self._stack = ExitStack()
+ self._writer: Optional[AudioWriter] = None
+
+ def __enter__(self) -> Self:
+ self._writer = self._stack.enter_context(open_audio_writer(self.destination, self._spec))
+ return self
+
+ def __exit__(
+ self,
+ exception_type: Optional[Type[BaseException]],
+ exception: Optional[BaseException],
+ traceback: Optional[TracebackType],
+ ) -> None:
+ self._writer = None
+ self._stack.close()
+
+ def write(self, chunk: np.ndarray) -> None:
+ """Hands one row to the encoder.
+
+ Args:
+ chunk: The row's samples.
+
+ Raises:
+ AudioWriteError: If the sink has not been entered.
+ """
+ if self._writer is None:
+ raise AudioWriteError(f"No file open at '{self.destination}'; write within the sink's context")
+
+ self._writer.write(chunk)
+
+ def finish(self, _report: EncodeReporter, /) -> bool:
+ """Reports the destination complete, since every row was written as it arrived."""
+ return True
+
+ def discard(self) -> None:
+ """Deletes the destination, so a render the caller dropped names no file."""
+ self.destination.unlink(missing_ok=True)
+
+
+class NormalizingRenderSink:
+ """Spills the render, then writes it at the scale that brings its peak to full.
+
+ The loudest sample is known only once the last row is synthesised, so the rows are spilled
+ beside the destination as they arrive and read back in blocks against the peak they turned
+ out to hold. The destination is opened for the second pass alone, which is what makes the
+ encoder see the finished levels rather than the raw ones.
+ """
+
+ def __init__(self, destination: Path, spec: AudioOutputSpec) -> None:
+ self.destination = destination
+ self._spec = spec
+ self._scratch = ScratchAudio(destination.with_name(destination.name + SCRATCH_SUFFIX))
+
+ def __enter__(self) -> Self:
+ self._scratch.start()
+ return self
+
+ def __exit__(
+ self,
+ exception_type: Optional[Type[BaseException]],
+ exception: Optional[BaseException],
+ traceback: Optional[TracebackType],
+ ) -> None:
+ self._scratch.seal()
+ self._scratch.remove()
+
+ def write(self, chunk: np.ndarray) -> None:
+ """Spills one row, keeping the peak the render has reached.
+
+ Args:
+ chunk: The row's samples.
+
+ Raises:
+ AudioWriteError: If the sink has not been entered.
+ """
+ self._scratch.write(chunk)
+
+ def finish(self, report: EncodeReporter, /) -> bool:
+ """Encodes the spilled render at its scale, reporting how far the pass has come.
+
+ Args:
+ report: Takes the samples encoded so far and states whether to carry on.
+
+ Returns:
+ bool: Whether the destination holds the whole render.
+ """
+ self._scratch.seal()
+ scale = self._scale()
+ encoded = 0
+ with open_audio_writer(self.destination, self._spec) as writer:
+ for block in self._scratch.blocks(ENCODE_BLOCK_SAMPLES):
+ writer.write(block * scale)
+ encoded += len(block)
+ if not report(encoded):
+ return False
+
+ return True
+
+ def discard(self) -> None:
+ """Deletes the destination, so a render the caller dropped names no file."""
+ self.destination.unlink(missing_ok=True)
+
+ def _scale(self) -> float:
+ """The factor bringing the spilled render's peak to full scale.
+
+ A render that stayed silent has no peak to reach for, so it is written as it stands.
+ """
+ if self._scratch.peak <= NO_PEAK:
+ return UNITY_GAIN
+
+ return FULL_SCALE / self._scratch.peak
+
+
+def build_render_sink(
+ destination: Path,
+ spec: AudioOutputSpec,
+ *,
+ normalize: bool,
+) -> RenderSink:
+ """The sink a render writes through, chosen by whether its level is scaled to its peak.
+
+ Args:
+ destination: The file the render is written to.
+ spec: The format, rate, and quality it is written at.
+ normalize: Whether the render is scaled so its loudest sample reaches full scale.
+
+ Returns:
+ RenderSink: A sink ready to be entered.
+ """
+ if normalize:
+ return NormalizingRenderSink(destination, spec)
+
+ return DirectRenderSink(destination, spec)
diff --git a/src/sampletones_application/services/retune/__init__.py b/src/sampletones_application/services/retune/__init__.py
index 0af32c70..963b47d8 100644
--- a/src/sampletones_application/services/retune/__init__.py
+++ b/src/sampletones_application/services/retune/__init__.py
@@ -3,7 +3,7 @@
from sampletones_application.services.retune.sample import RetunedSample
__all__ = [
- "RetunedSample",
"RetuneResult",
+ "RetunedSample",
"SampleRetuneService",
]
diff --git a/src/sampletones_application/services/song_player/constants.py b/src/sampletones_application/services/song_player/constants.py
index ef4a6638..00938292 100644
--- a/src/sampletones_application/services/song_player/constants.py
+++ b/src/sampletones_application/services/song_player/constants.py
@@ -2,3 +2,4 @@
PREFETCH_SECONDS: Final[float] = 0.25
STOP_POLL_TIMEOUT: Final[float] = 0.05
+STOP_JOIN_TIMEOUT: Final[float] = 2.0
diff --git a/src/sampletones_application/services/song_player/player.py b/src/sampletones_application/services/song_player/player.py
index 27303176..2cdc565c 100644
--- a/src/sampletones_application/services/song_player/player.py
+++ b/src/sampletones_application/services/song_player/player.py
@@ -9,16 +9,18 @@
from sampletones_application.services.base import ServiceBase
from sampletones_application.services.song_player.constants import (
PREFETCH_SECONDS,
+ STOP_JOIN_TIMEOUT,
STOP_POLL_TIMEOUT,
)
-from sampletones_application.services.song_player.protocol import RowSynthesizerProtocol
from sampletones_application.services.song_player.result import (
SongPlaybackError,
SongPlaybackStopped,
SongPlayerResult,
SongPositionUpdate,
)
+from sampletones_application.services.synthesis.protocol import RowSynthesizerProtocol
from sampletones_core.audio import AudioDeviceManager, clip_audio_inplace
+from sampletones_core.constants.audio import DEFAULT_BUFFER_SIZE
from sampletones_core.project.song_position import SongPosition
from sampletones_shared.constants.audio import UNITY_GAIN
from sampletones_shared.logger import logger
@@ -67,6 +69,7 @@ def __init__(
self._buffer_condition = threading.Condition()
self._queued_samples: int = 0
self._prefetch_samples: int = 0
+ self._write_block_frames: int = DEFAULT_BUFFER_SIZE
self._playback_error: Optional[Exception] = None
@property
@@ -88,6 +91,10 @@ def start(
row_index: int = 0,
) -> None:
self.stop()
+ if self.alive:
+ logger.error(f"{self.class_name}: the previous writer still holds the output; start ignored")
+ return
+
self._synthesizer.set_position(order_position, row_index)
self._synthesizer.reset()
self._playback_error = None
@@ -97,6 +104,7 @@ def start(
PREFETCH_SECONDS * self._audio_device_manager.sample_rate,
),
)
+ self._write_block_frames = self._audio_device_manager.buffer_size
self._stop_event.clear()
self._resume_event.set()
self._render_thread = threading.Thread(
@@ -116,12 +124,8 @@ def stop(self) -> None:
self._stop_event.set()
self._resume_event.set()
self._wake_buffer()
- for thread in (self._render_thread, self._write_thread):
- if thread is not None:
- thread.join(timeout=2.0)
-
- self._render_thread = None
- self._write_thread = None
+ self._render_thread = self._join_worker(self._render_thread)
+ self._write_thread = self._join_worker(self._write_thread)
self._clear_buffer()
def pause(self) -> None:
@@ -154,6 +158,23 @@ def relocate(self, order_position: int) -> None:
self._synthesizer.set_position(order_position, self._synthesizer.row_index)
+ def _join_worker(self, thread: Optional[threading.Thread]) -> Optional[threading.Thread]:
+ """Joins one worker; keeps the thread when it outlives the stop deadline.
+
+ Keeping a surviving writer is what makes ``alive`` report the truth: the thread still
+ holds the output stream, so callers waiting on quiescence — the audio device before it
+ tears the backend down — can see that the stream is still outstanding.
+ """
+ if thread is None:
+ return None
+
+ thread.join(timeout=STOP_JOIN_TIMEOUT)
+ if thread.is_alive():
+ logger.error(f"{self.class_name}: {thread.name} outlived the stop deadline")
+ return thread
+
+ return None
+
def _render_loop(self) -> None:
"""Renders rows into the prefetch buffer until the song ends or a stop is requested.
@@ -181,9 +202,12 @@ def _write_loop(self) -> None:
try:
self._drain_to_stream(stream)
+ except Exception as exception: # pylint: disable=broad-exception-caught
+ logger.error_with_traceback(exception, f"{self.class_name}: playback error")
+ self._playback_error = exception
+ self._emit_terminal()
finally:
- stream.stop_stream()
- stream.close()
+ self._audio_device_manager.close_output_stream(stream)
def _open_stream(self) -> Optional[pyaudio.Stream]:
try:
@@ -191,6 +215,7 @@ def _open_stream(self) -> Optional[pyaudio.Stream]:
stream = self._audio_device_manager.open_output_stream(
sample_rate=sample_rate,
buffer_size=self._audio_device_manager.buffer_size,
+ release=self.stop,
)
logger.debug(f"{self.class_name}: audio stream opened at {sample_rate} Hz")
return stream
@@ -218,11 +243,32 @@ def _drain_to_stream(self, stream: pyaudio.Stream) -> None:
self._play_row(stream, row)
def _play_row(self, stream: pyaudio.Stream, row: _RenderedRow) -> None:
- if len(row.chunk):
- stream.write(self._scale_to_gain(row.chunk).tobytes())
+ """Hands one row to the device, reporting its position once the whole row is written.
+
+ A row cut short by a stop reports no position, so the playhead reflects the audio the
+ device actually received.
+ """
+ if len(row.chunk) and not self._write_chunk(stream, self._scale_to_gain(row.chunk)):
+ return
self._emit(SongPositionUpdate(position=row.position))
+ def _write_chunk(self, stream: pyaudio.Stream, chunk: np.ndarray) -> bool:
+ """Writes one row to the device in buffer-sized blocks; reports whether it completed.
+
+ Each block is a separate blocking write, so a stop reached mid-row is honoured within
+ roughly one buffer period rather than at the next row boundary. That bounds how long the
+ writer holds its stream open after a stop, which is what keeps the audio backend safe to
+ tear down on demand.
+ """
+ for offset in range(0, len(chunk), self._write_block_frames):
+ if self._stop_event.is_set():
+ return False
+
+ stream.write(chunk[offset : offset + self._write_block_frames].tobytes())
+
+ return True
+
def _scale_to_gain(self, chunk: np.ndarray) -> np.ndarray:
"""Scales one row by the live master gain, clipped to the output stream's range.
diff --git a/src/sampletones_application/services/song_player/protocol.py b/src/sampletones_application/services/song_player/protocol.py
deleted file mode 100644
index 58a7dd0c..00000000
--- a/src/sampletones_application/services/song_player/protocol.py
+++ /dev/null
@@ -1,30 +0,0 @@
-from typing import Protocol, Tuple
-
-import numpy as np
-
-from sampletones_core.project.song_position import SongPosition
-
-
-class RowSynthesizerProtocol(Protocol):
- """Streaming synthesis kernel the song-player service drives, one row at a time.
-
- This is the service's input contract; the concrete synthesiser lives in the logic layer
- and satisfies it structurally. Each ``render_row`` call produces one row's worth of audio
- (ticks_per_row × frame_length samples), advances the internal position cursor, and returns
- a snapshot of the cursor from before the advance so callers can post accurate position events.
- """
-
- @property
- def order_position(self) -> int: ...
-
- @property
- def row_index(self) -> int: ...
-
- @property
- def is_finished(self) -> bool: ...
-
- def set_position(self, order_position: int, row_index: int) -> None: ...
-
- def render_row(self) -> Tuple[np.ndarray, SongPosition]: ...
-
- def reset(self) -> None: ...
diff --git a/src/sampletones_application/services/synthesis/__init__.py b/src/sampletones_application/services/synthesis/__init__.py
new file mode 100644
index 00000000..767f0ca5
--- /dev/null
+++ b/src/sampletones_application/services/synthesis/__init__.py
@@ -0,0 +1,5 @@
+from sampletones_application.services.synthesis.protocol import RowSynthesizerProtocol
+
+__all__ = [
+ "RowSynthesizerProtocol",
+]
diff --git a/src/sampletones_application/services/synthesis/protocol.py b/src/sampletones_application/services/synthesis/protocol.py
new file mode 100644
index 00000000..7b257f70
--- /dev/null
+++ b/src/sampletones_application/services/synthesis/protocol.py
@@ -0,0 +1,33 @@
+from typing import Protocol, Tuple
+
+import numpy as np
+
+from sampletones_core.project.song_position import SongPosition
+
+
+class RowSynthesizerProtocol(Protocol):
+ """Streaming synthesis kernel a service drives, one row at a time.
+
+ This is the input contract every consumer of a song's audio takes; the concrete synthesiser
+ lives in the logic layer and satisfies it structurally. Each ``render_row`` call produces one
+ row's worth of audio, advances the internal position cursor, and returns a snapshot of the
+ cursor from before the advance so callers can post accurate position events.
+
+ The player and the renderer drive the same kernel through this one contract, which is what
+ makes a rendered file sound like what playback produces: the synthesis code is written once.
+ """
+
+ @property
+ def order_position(self) -> int: ...
+
+ @property
+ def row_index(self) -> int: ...
+
+ @property
+ def is_finished(self) -> bool: ...
+
+ def set_position(self, order_position: int, row_index: int) -> None: ...
+
+ def render_row(self) -> Tuple[np.ndarray, SongPosition]: ...
+
+ def reset(self) -> None: ...
diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py
index b58edf61..6477969c 100644
--- a/src/sampletones_application/shell.py
+++ b/src/sampletones_application/shell.py
@@ -1,15 +1,18 @@
from dataclasses import dataclass
from functools import partial
from pathlib import Path
-from typing import Any, Callable, Dict, Final, Optional
+from typing import Any, Callable, Dict, Optional
import dearpygui.dearpygui as dpg
from sampletones_application.categories.hierarchy import Tab
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.constants.playback import FollowMode
from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol
-from sampletones_application.coordinators.tabs.instructions import InstructionsTabCoordinator
+from sampletones_application.coordinators.tabs.instructions import (
+ InstructionsTabCoordinator,
+)
from sampletones_application.coordinators.tabs.main import MainTabCoordinator
from sampletones_application.coordinators.tabs.reconstruction import (
ReconstructionTabCoordinator,
@@ -29,32 +32,22 @@
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.texture import TextureRegistry
from sampletones_application.ui.menu import MenuBar
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.ui.themes.theme import Theme
from sampletones_application.utils.callbacks.queue import CallbackQueue
from sampletones_application.utils.fps import FPSTimer
from sampletones_application.utils.gui.keyboard import KeyRouter
-from sampletones_application.utils.gui.keyboard.modifiers import (
- ALT,
- CTRL,
- CTRL_ALT,
- CTRL_ALT_SHIFT,
- CTRL_SHIFT,
- SHIFT,
-)
from sampletones_application.utils.gui.shortcuts.ids import (
CHANNEL_SHORTCUT_IDS,
+ FOLLOW_MODE_SHORTCUT_IDS,
PROJECT_EXPORT_SHORTCUT_IDS,
SAMPLE_EXPORT_SHORTCUT_IDS,
+ TAB_SHORTCUT_IDS,
ShortcutId,
)
-from sampletones_application.utils.gui.shortcuts.keys import (
- KEY_PAGE_DOWN,
- KEY_PAGE_UP,
-)
from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager
-from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut
from sampletones_application.utils.parallelization.thread import SingleThreadExecutor
from sampletones_application.view_model.shared.menu import MenuBarViewModel
from sampletones_application.viewport import ViewportManager
@@ -70,14 +63,6 @@
Tab.INSTRUCTIONS: TAG_GLOBAL_TAB_INSTRUCTIONS,
}
_TAG_TABS: Dict[str, Tab] = {tag: Tab(tab) for tab, tag in _TAB_TAGS.items()}
-_PROJECT_EXPORT_SHORTCUTS: Final[Dict[TrackerFormat, Shortcut]] = {
- TrackerFormat.FAMITRACKER: Shortcut(dpg.mvKey_M, CTRL),
- TrackerFormat.BITPHASE: Shortcut(dpg.mvKey_B, CTRL),
-}
-_SAMPLE_EXPORT_SHORTCUTS: Final[Dict[TrackerFormat, Shortcut]] = {
- TrackerFormat.FAMITRACKER: Shortcut(dpg.mvKey_I, CTRL),
- TrackerFormat.BITPHASE_PRESET: Shortcut(),
-}
@dataclass(frozen=True)
@@ -88,6 +73,7 @@ class ShortcutBindings:
save_project_as: Callback
project_properties: Callback
export_project: Callable[[TrackerFormat], None]
+ render_song: Callback
close_project: Callback
exit: Callback
undo: Callback
@@ -110,16 +96,21 @@ class ShortcutBindings:
play_from_frame: Callback
stop: Callback
toggle_autoplay: Callback
- toggle_follow_playback: Callback
+ set_follow_mode: Callable[[FollowMode], None]
toggle_loop_song: Callback
toggle_channel: Callable[[GeneratorName], None]
unmute_all_channels: Callback
audio_settings: Callback
+ display_settings: Callback
+ keyboard_settings: Callback
toggle_advanced_settings: Callback
+ toggle_auto_expand_favorite_reconstructions: Callback
+ toggle_auto_expand_favorite_directories: Callback
toggle_fullscreen: Callback
about: Callback
next_tab: Callback
previous_tab: Callback
+ select_tab: Callable[[Tab], None]
class ApplicationShell:
@@ -179,6 +170,7 @@ def setup(
) -> None:
dpg.create_context()
self._set_fonts()
+ self._set_textures()
self._register_shortcuts(bindings)
self._set_default_theme()
self._viewport_manager.create_viewport()
@@ -208,229 +200,130 @@ def _setup_dearpygui(self) -> None:
def _set_fonts(self) -> None:
FontRegistry.register_fonts(self._layout.fonts.scale)
+ def _set_textures(self) -> None:
+ TextureRegistry.register_textures()
+
def _set_default_theme(self) -> None:
self._theme.bind()
def _register_shortcuts(self, bindings: ShortcutBindings) -> None:
- self._shortcut_manager.register(
- ShortcutId.NEW_PROJECT,
- Shortcut(dpg.mvKey_N, CTRL),
- bindings.new_project,
- )
- self._shortcut_manager.register(
- ShortcutId.OPEN_PROJECT,
- Shortcut(dpg.mvKey_O, CTRL),
- bindings.open_project,
- )
- self._shortcut_manager.register(
- ShortcutId.SAVE_PROJECT,
- Shortcut(dpg.mvKey_S, CTRL),
- bindings.save_project,
- )
- self._shortcut_manager.register(
- ShortcutId.SAVE_PROJECT_AS,
- Shortcut(dpg.mvKey_S, CTRL_SHIFT),
- bindings.save_project_as,
- )
- self._register_export_shortcuts(bindings)
- self._shortcut_manager.register(
- ShortcutId.PROJECT_PROPERTIES,
- Shortcut(dpg.mvKey_P, ALT),
- bindings.project_properties,
- )
- self._shortcut_manager.register(
- ShortcutId.CLOSE_PROJECT,
- Shortcut(dpg.mvKey_W, CTRL),
- bindings.close_project,
- )
- self._shortcut_manager.register(
- ShortcutId.SAVE_RECONSTRUCTION,
- Shortcut(dpg.mvKey_S, CTRL_ALT),
- bindings.save_reconstruction,
- )
- self._shortcut_manager.register(
- ShortcutId.SAVE_RECONSTRUCTION_AS,
- Shortcut(dpg.mvKey_S, CTRL_ALT_SHIFT),
- bindings.save_reconstruction_as,
- )
- self._shortcut_manager.register(
- ShortcutId.OPEN_RECONSTRUCTION,
- Shortcut(dpg.mvKey_O, CTRL_ALT),
- bindings.open_reconstruction,
- )
- self._shortcut_manager.register(
- ShortcutId.CLOSE_RECONSTRUCTION,
- Shortcut(dpg.mvKey_W, CTRL_ALT),
- bindings.close_reconstruction,
- )
- self._shortcut_manager.register(
- ShortcutId.SAVE_GENERATION_SETTINGS,
- Shortcut(),
- bindings.save_generation_settings,
- )
- self._shortcut_manager.register(
- ShortcutId.LOAD_GENERATION_SETTINGS,
- Shortcut(),
- bindings.load_generation_settings,
- )
- self._shortcut_manager.register(
- ShortcutId.AUDIO_SETTINGS,
- Shortcut(dpg.mvKey_A, CTRL),
- bindings.audio_settings,
- )
- self._shortcut_manager.register(
- ShortcutId.EXIT,
- Shortcut(dpg.mvKey_F4, ALT),
- bindings.exit,
- )
- self._shortcut_manager.register(
- ShortcutId.RECONSTRUCT_FILE,
- Shortcut(dpg.mvKey_R, CTRL),
- bindings.reconstruct_file,
- )
- self._shortcut_manager.register(
- ShortcutId.RECONSTRUCT_DIRECTORY,
- Shortcut(dpg.mvKey_R, CTRL_SHIFT),
- bindings.reconstruct_directory,
- )
- self._shortcut_manager.register(
- ShortcutId.EXPORT_RECONSTRUCTION_WAV,
- Shortcut(dpg.mvKey_E, CTRL),
- bindings.export_wav,
- )
- self._shortcut_manager.register(
- ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER,
- Shortcut(),
- bindings.add_reconstruction_to_sequencer,
- )
- self._shortcut_manager.register(
- ShortcutId.OPEN_RECONSTRUCTION_IN_EXPLORER,
- Shortcut(),
- bindings.open_reconstruction_in_explorer,
- )
- self._shortcut_manager.register(
- ShortcutId.LOCATE_ORIGINAL_AUDIO,
- Shortcut(),
- bindings.locate_original_audio,
- )
- self._shortcut_manager.register(
- ShortcutId.TOGGLE_FULLSCREEN,
- Shortcut(dpg.mvKey_F11),
- bindings.toggle_fullscreen,
- )
- self._shortcut_manager.register(
- ShortcutId.TOGGLE_ADVANCED_SETTINGS,
- Shortcut(dpg.mvKey_A, CTRL_SHIFT),
- bindings.toggle_advanced_settings,
- )
- self._shortcut_manager.register(
- ShortcutId.PLAY,
- Shortcut(dpg.mvKey_Spacebar),
- bindings.play,
- )
- self._shortcut_manager.register(
- ShortcutId.PLAY_FROM_START,
- Shortcut(dpg.mvKey_Spacebar, SHIFT),
- bindings.play_from_start,
- )
- self._shortcut_manager.register(
- ShortcutId.PLAY_FROM_FRAME,
- Shortcut(dpg.mvKey_Spacebar, CTRL),
- bindings.play_from_frame,
- )
- self._shortcut_manager.register(
- ShortcutId.STOP,
- Shortcut(dpg.mvKey_Escape),
- bindings.stop,
- )
- self._shortcut_manager.register(
- ShortcutId.TOGGLE_AUTOPLAY,
- Shortcut(dpg.mvKey_P, CTRL),
- bindings.toggle_autoplay,
- )
- self._shortcut_manager.register(
- ShortcutId.TOGGLE_FOLLOW_PLAYBACK,
- Shortcut(),
- bindings.toggle_follow_playback,
- )
- self._shortcut_manager.register(
- ShortcutId.TOGGLE_LOOP_SONG,
- Shortcut(),
- bindings.toggle_loop_song,
- )
- self._register_channel_shortcuts(bindings)
- self._shortcut_manager.register(
- ShortcutId.UNDO,
- Shortcut(dpg.mvKey_Z, CTRL),
- bindings.undo,
- )
- self._shortcut_manager.register(
- ShortcutId.REDO,
- Shortcut(dpg.mvKey_Y, CTRL),
- bindings.redo,
- )
- self._shortcut_manager.register_alias(
- ShortcutId.REDO,
- Shortcut(dpg.mvKey_Z, CTRL_SHIFT),
- )
- self._shortcut_manager.register(
- ShortcutId.ABOUT_DIALOG,
- Shortcut(),
- bindings.about,
- )
- self._shortcut_manager.register(
- ShortcutId.NEXT_TAB,
- Shortcut(KEY_PAGE_DOWN, CTRL, field_transparent=True),
- bindings.next_tab,
- )
- self._shortcut_manager.register(
- ShortcutId.PREVIOUS_TAB,
- Shortcut(KEY_PAGE_UP, CTRL, field_transparent=True),
- bindings.previous_tab,
- )
+ """Names the call each application action makes, then binds the scope to the key router.
+
+ Which combination reaches an action is the keybinding scheme's to say, so the shell states
+ only the pairing of an action with its coordinator call.
+ """
+ for shortcut_id, callback in ApplicationShell._shortcut_callbacks(bindings).items():
+ self._shortcut_manager.register(shortcut_id, callback)
self._shortcut_manager.bind_all()
- def _register_export_shortcuts(self, bindings: ShortcutBindings) -> None:
- """Registers one export action per tracker format, the entries the Export submenus list.
+ @staticmethod
+ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback]:
+ """The call every application action makes, one entry per action the menus offer."""
+ return {
+ ShortcutId.NEW_PROJECT: bindings.new_project,
+ ShortcutId.OPEN_PROJECT: bindings.open_project,
+ ShortcutId.SAVE_PROJECT: bindings.save_project,
+ ShortcutId.SAVE_PROJECT_AS: bindings.save_project_as,
+ ShortcutId.PROJECT_PROPERTIES: bindings.project_properties,
+ ShortcutId.RENDER_SONG: bindings.render_song,
+ ShortcutId.CLOSE_PROJECT: bindings.close_project,
+ ShortcutId.EXIT: bindings.exit,
+ ShortcutId.UNDO: bindings.undo,
+ ShortcutId.REDO: bindings.redo,
+ ShortcutId.RECONSTRUCT_FILE: bindings.reconstruct_file,
+ ShortcutId.RECONSTRUCT_DIRECTORY: bindings.reconstruct_directory,
+ ShortcutId.LOAD_GENERATION_SETTINGS: bindings.load_generation_settings,
+ ShortcutId.SAVE_GENERATION_SETTINGS: bindings.save_generation_settings,
+ ShortcutId.OPEN_RECONSTRUCTION: bindings.open_reconstruction,
+ ShortcutId.SAVE_RECONSTRUCTION: bindings.save_reconstruction,
+ ShortcutId.SAVE_RECONSTRUCTION_AS: bindings.save_reconstruction_as,
+ ShortcutId.CLOSE_RECONSTRUCTION: bindings.close_reconstruction,
+ ShortcutId.EXPORT_RECONSTRUCTION_WAV: bindings.export_wav,
+ ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER: bindings.add_reconstruction_to_sequencer,
+ ShortcutId.OPEN_RECONSTRUCTION_IN_EXPLORER: bindings.open_reconstruction_in_explorer,
+ ShortcutId.LOCATE_ORIGINAL_AUDIO: bindings.locate_original_audio,
+ ShortcutId.PLAY: bindings.play,
+ ShortcutId.PLAY_FROM_START: bindings.play_from_start,
+ ShortcutId.PLAY_FROM_FRAME: bindings.play_from_frame,
+ ShortcutId.STOP: bindings.stop,
+ ShortcutId.TOGGLE_AUTOPLAY: bindings.toggle_autoplay,
+ ShortcutId.TOGGLE_LOOP_SONG: bindings.toggle_loop_song,
+ ShortcutId.AUDIO_SETTINGS: bindings.audio_settings,
+ ShortcutId.DISPLAY_SETTINGS: bindings.display_settings,
+ ShortcutId.KEYBOARD_SETTINGS: bindings.keyboard_settings,
+ ShortcutId.TOGGLE_ADVANCED_SETTINGS: bindings.toggle_advanced_settings,
+ ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS: (
+ bindings.toggle_auto_expand_favorite_reconstructions
+ ),
+ ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES: bindings.toggle_auto_expand_favorite_directories,
+ ShortcutId.TOGGLE_FULLSCREEN: bindings.toggle_fullscreen,
+ ShortcutId.ABOUT_DIALOG: bindings.about,
+ ShortcutId.NEXT_TAB: bindings.next_tab,
+ ShortcutId.PREVIOUS_TAB: bindings.previous_tab,
+ **ApplicationShell._export_callbacks(bindings),
+ **ApplicationShell._follow_mode_callbacks(bindings),
+ **ApplicationShell._channel_callbacks(bindings),
+ **ApplicationShell._tab_callbacks(bindings),
+ }
+
+ @staticmethod
+ def _export_callbacks(
+ bindings: ShortcutBindings,
+ ) -> Dict[ShortcutId, Callback]:
+ """One export action per tracker format, the entries the Export submenus list.
Each action carries the format it writes, so a menu entry and its key combination reach
- the same coordinator call. A format registered without a key is offered by the menu
- alone, which leaves the assignment to the keybindings options.
+ the same coordinator call.
"""
- for tracker_format, shortcut in _PROJECT_EXPORT_SHORTCUTS.items():
- self._shortcut_manager.register(
- PROJECT_EXPORT_SHORTCUT_IDS[tracker_format],
- shortcut,
- partial(bindings.export_project, tracker_format),
- )
-
- for tracker_format, shortcut in _SAMPLE_EXPORT_SHORTCUTS.items():
- self._shortcut_manager.register(
- SAMPLE_EXPORT_SHORTCUT_IDS[tracker_format],
- shortcut,
- partial(bindings.export_instruments, tracker_format),
- )
-
- def _register_channel_shortcuts(self, bindings: ShortcutBindings) -> None:
- """Registers one action per tracker channel, plus the one that brings the whole mix back.
+ project = {
+ shortcut_id: partial(bindings.export_project, tracker_format)
+ for tracker_format, shortcut_id in PROJECT_EXPORT_SHORTCUT_IDS.items()
+ }
+ instruments = {
+ shortcut_id: partial(bindings.export_instruments, tracker_format)
+ for tracker_format, shortcut_id in SAMPLE_EXPORT_SHORTCUT_IDS.items()
+ }
+ return {**project, **instruments}
+
+ @staticmethod
+ def _follow_mode_callbacks(
+ bindings: ShortcutBindings,
+ ) -> Dict[ShortcutId, Callback]:
+ """One action per reach the sequencer view follows the playhead at.
+
+ Each action carries the mode it chooses, so a key press and the Follow playback submenu
+ item beside it settle on the same reach.
+ """
+ return {
+ shortcut_id: partial(bindings.set_follow_mode, mode)
+ for mode, shortcut_id in FOLLOW_MODE_SHORTCUT_IDS.items()
+ }
+
+ @staticmethod
+ def _channel_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback]:
+ """One action per tracker channel, plus the one that brings the whole mix back.
Each action carries the channel it switches, so the Playback menu lists them as its
- Channels submenu. They are registered without a key combination, which leaves the
- assignment to the keybindings options.
+ Channels submenu.
"""
- for generator, shortcut_id in CHANNEL_SHORTCUT_IDS.items():
- self._shortcut_manager.register(
- shortcut_id,
- Shortcut(),
- partial(bindings.toggle_channel, generator),
- )
-
- self._shortcut_manager.register(
- ShortcutId.UNMUTE_ALL_CHANNELS,
- Shortcut(),
- bindings.unmute_all_channels,
- )
+ channels: Dict[ShortcutId, Callback] = {
+ shortcut_id: partial(bindings.toggle_channel, generator)
+ for generator, shortcut_id in CHANNEL_SHORTCUT_IDS.items()
+ }
+ return {
+ **channels,
+ ShortcutId.UNMUTE_ALL_CHANNELS: bindings.unmute_all_channels,
+ }
+
+ @staticmethod
+ def _tab_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback]:
+ """One action per tab, each carrying the tab it brings to the front.
+
+ A tab is reached by naming it as well as by stepping to the next one, so a reader moves
+ across the whole window in one press.
+ """
+ return {shortcut_id: partial(bindings.select_tab, tab) for tab, shortcut_id in TAB_SHORTCUT_IDS.items()}
def _setup_handlers(self) -> None:
self._key_router.bind()
@@ -471,20 +364,22 @@ def update_menu(self, state: MenuBarViewModel) -> None:
def _create_tabs(self, on_tab_changed: Callback) -> None:
status_bar_layout = self._layout.general.status_bar
- with dpg.child_window(
- height=-(status_bar_layout.height + status_bar_layout.reserved_margin),
- border=False,
- no_scrollbar=True,
- no_scroll_with_mouse=True,
- ) as tab_container:
- with dpg.tab_bar(
+ with (
+ dpg.child_window(
+ height=-(status_bar_layout.height + status_bar_layout.reserved_margin),
+ border=False,
+ no_scrollbar=True,
+ no_scroll_with_mouse=True,
+ ) as tab_container,
+ dpg.tab_bar(
tag=TAG_GLOBAL_TABS,
callback=on_tab_changed,
- ):
- self._main_tab.create_tab()
- self._reconstructions_tab.create_tab()
- self._sequencer_tab.create_tab()
- self._instructions_tab.create_tab()
+ ),
+ ):
+ self._main_tab.create_tab()
+ self._reconstructions_tab.create_tab()
+ self._sequencer_tab.create_tab()
+ self._instructions_tab.create_tab()
ThemeRegistry.get(TAG_GLOBAL_THEME_TAB_STRIP).bind_to_item(tab_container)
for tab_tag in (
diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py
index 99b50d7b..05eac7e1 100644
--- a/src/sampletones_application/tags/general.py
+++ b/src/sampletones_application/tags/general.py
@@ -32,6 +32,12 @@
Widget.FONT,
"bold_large",
)
+TAG_GLOBAL_FONT_BOLD_TITLE = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.FONT,
+ "bold_title",
+)
TAG_GLOBAL_FONT_ITALIC = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
@@ -182,6 +188,12 @@
Widget.THEME,
"instrument_tabs",
)
+TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.THEME,
+ "instrument_tabs_muted",
+)
TAG_GLOBAL_THEME_PANEL_INSTRUMENT = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
@@ -296,12 +308,6 @@
Widget.THEME,
"file_wave",
)
-TAG_GLOBAL_THEME_FILE_NOT_EXPANDED_DIRECTORY = TagName(
- Page.GLOBAL,
- Panel.IMPLICIT,
- Widget.THEME,
- "file_not_expanded_directory",
-)
TAG_GLOBAL_THEME_INPUT_INVALID = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
@@ -416,6 +422,12 @@
Widget.MENU,
"item_file_export",
)
+TAG_GLOBAL_MENU_ITEM_FILE_RENDER_SONG = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "item_file_render_song",
+)
TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
@@ -434,6 +446,18 @@
Widget.MENU,
"item_edit_redo",
)
+TAG_GLOBAL_MENU_GROUP_EDIT = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "group_edit",
+)
+TAG_GLOBAL_MENU_GROUP_EDIT_MARKER = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "group_edit_marker",
+)
TAG_GLOBAL_DIALOG_PROJECT_SAVED = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
@@ -536,6 +560,18 @@
Widget.MENU,
"item_view_fullscreen",
)
+TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "item_view_auto_expand_favorite_reconstructions",
+)
+TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.MENU,
+ "item_view_auto_expand_favorite_directories",
+)
TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
@@ -572,11 +608,11 @@
Widget.MENU,
"item_playback_autoplay",
)
-TAG_GLOBAL_MENU_ITEM_PLAYBACK_FOLLOW_PLAYBACK = TagName(
+TAG_GLOBAL_MENU_ITEM_PLAYBACK_FOLLOW = TagName(
Page.GLOBAL,
Panel.IMPLICIT,
Widget.MENU,
- "item_playback_follow_playback",
+ "item_playback_follow",
)
TAG_GLOBAL_MENU_ITEM_PLAYBACK_LOOP_SONG = TagName(
Page.GLOBAL,
@@ -645,6 +681,13 @@
"sequencer",
)
+TAG_GLOBAL_TEXTURE_LOGO = TagName(
+ Page.GLOBAL,
+ Panel.IMPLICIT,
+ Widget.TEXTURE,
+ "logo",
+)
+
SUF_BUTTON = "button"
SUF_BUTTONS = "buttons"
SUF_BUTTON_COPY = compose_tag(SUF_BUTTON, "copy")
@@ -652,6 +695,7 @@
SUF_BUTTON_SAVE = compose_tag(SUF_BUTTON, "save")
SUF_BUTTON_CANCEL = compose_tag(SUF_BUTTON, "cancel")
SUF_BUTTON_SEARCH = compose_tag(SUF_BUTTON, "search")
+SUF_BUTTON_COLLAPSE_ALL = compose_tag(SUF_BUTTON, "collapse_all")
SUF_BUTTON_SHOW_TRACEBACK = compose_tag(SUF_BUTTON, "show_traceback")
SUF_BUTTON_DECREMENT = compose_tag(SUF_BUTTON, "decrement")
SUF_BUTTON_INCREMENT = compose_tag(SUF_BUTTON, "increment")
@@ -662,12 +706,15 @@
SUF_HANDLER_NODE = compose_tag("handler", "node")
SUF_HANDLER_DETAIL_TOOLTIP = compose_tag("handler", "detail_tooltip")
SUF_HANDLER_HEADER = compose_tag("handler", "header")
+SUF_HANDLER_DRAG = compose_tag("handler", "drag")
SUF_LABEL = "label"
SUF_PATH = "path"
SUF_TEXT = "text"
+SUF_TEXT_FAVORITES = compose_tag(SUF_TEXT, "favorites")
SUF_INPUT = "input"
SUF_INPUT_SEARCH = compose_tag(SUF_INPUT, "search")
SUF_CHECKBOX = "checkbox"
+SUF_CHECKBOX_FAVORITES = compose_tag(SUF_CHECKBOX, "favorites")
SUF_TABLE = "table"
SUF_TOOLTIP = "tooltip"
SUF_TOOLTIP_DETAIL = compose_tag(SUF_TOOLTIP, "detail")
diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py
index 164febdf..f50d7bea 100644
--- a/src/sampletones_application/tags/main.py
+++ b/src/sampletones_application/tags/main.py
@@ -61,12 +61,6 @@
Widget.BUTTON,
"refresh",
)
-TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL = TagName(
- Page.MAIN,
- Panel.EXPLORER,
- Widget.BUTTON,
- "collapse_all",
-)
TAG_MAIN_CONFIG_PANEL = TagName(
Page.MAIN,
Panel.CONFIG,
diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py
index 86e6599b..90d3f9e0 100644
--- a/src/sampletones_application/tags/reconstructions.py
+++ b/src/sampletones_application/tags/reconstructions.py
@@ -122,8 +122,15 @@
Widget.BUTTON,
"export_instrument",
)
+TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE = TagName(
+ Page.RECONSTRUCTIONS,
+ Panel.INSTRUMENTS,
+ Widget.TEXT,
+ "sample_size",
+)
PRE_RECONSTRUCTION_GENERATOR = compose_tag("reconstruction", "generator")
SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE = "no_data_message"
+SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE = "instrument_size"
SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW = "window"
SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE = "autoscale"
diff --git a/src/sampletones_application/tags/sequencer.py b/src/sampletones_application/tags/sequencer.py
index 6e0c2e1d..0fddc398 100644
--- a/src/sampletones_application/tags/sequencer.py
+++ b/src/sampletones_application/tags/sequencer.py
@@ -38,27 +38,27 @@
"refresh_reconstructions",
)
-TAG_SEQUENCER_GRID_PANEL = TagName(
+TAG_SEQUENCER_TRACKER_PANEL = TagName(
Page.SEQUENCER,
- Panel.GRID,
+ Panel.TRACKER,
Widget.PANEL,
- "grid",
+ "tracker",
)
-TAG_SEQUENCER_GRID_TABLE_TRACKER = TagName(
+TAG_SEQUENCER_TRACKER_TABLE = TagName(
Page.SEQUENCER,
- Panel.GRID,
+ Panel.TRACKER,
Widget.TABLE,
"tracker",
)
-TAG_SEQUENCER_GRID_GROUP_TRACKER = TagName(
+TAG_SEQUENCER_TRACKER_GROUP = TagName(
Page.SEQUENCER,
- Panel.GRID,
+ Panel.TRACKER,
Widget.GROUP,
"tracker",
)
-TAG_SEQUENCER_GRID_WINDOW_TRACKER = TagName(
+TAG_SEQUENCER_TRACKER_WINDOW = TagName(
Page.SEQUENCER,
- Panel.GRID,
+ Panel.TRACKER,
Widget.WINDOW,
"tracker",
)
diff --git a/src/sampletones_application/tags/settings.py b/src/sampletones_application/tags/settings.py
index 35acc794..8a48c2f2 100644
--- a/src/sampletones_application/tags/settings.py
+++ b/src/sampletones_application/tags/settings.py
@@ -1,5 +1,6 @@
from sampletones_application.categories.hierarchy import Page, Panel, Widget
from sampletones_application.categories.key.tag import TagName
+from sampletones_application.tags.compose import compose_tag
TAG_SETTINGS_AUDIO_WINDOW = TagName(
Page.SETTINGS,
@@ -50,6 +51,297 @@
"refresh",
)
+TAG_SETTINGS_DISPLAY_WINDOW = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.WINDOW,
+ "display",
+)
+TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.COMBO,
+ "resolution",
+)
+TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.COMBO,
+ "frame_rate",
+)
+TAG_SETTINGS_DISPLAY_COMBO_PALETTE = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.COMBO,
+ "palette",
+)
+TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.CHECKBOX,
+ "borderless",
+)
+TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.CHECKBOX,
+ "fullscreen",
+)
+TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.CHECKBOX,
+ "vsync",
+)
+TAG_SETTINGS_DISPLAY_BUTTON_OK = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.BUTTON,
+ "ok",
+)
+TAG_SETTINGS_DISPLAY_BUTTON_CANCEL = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.BUTTON,
+ "cancel",
+)
+TAG_SETTINGS_DISPLAY_DIALOG_DISCARD = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.DIALOG,
+ "discard",
+)
+
+TAG_SETTINGS_DISPLAY_WINDOW_COUNTDOWN = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.WINDOW,
+ "countdown",
+)
+TAG_SETTINGS_DISPLAY_TEXT_COUNTDOWN = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.TEXT,
+ "countdown",
+)
+TAG_SETTINGS_DISPLAY_BUTTON_KEEP = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.BUTTON,
+ "keep",
+)
+TAG_SETTINGS_DISPLAY_BUTTON_REVERT = TagName(
+ Page.SETTINGS,
+ Panel.DISPLAY,
+ Widget.BUTTON,
+ "revert",
+)
+
+TAG_SETTINGS_RENDER_WINDOW = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.WINDOW,
+ "render",
+)
+TAG_SETTINGS_RENDER_GROUP_SETUP = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.GROUP,
+ "setup",
+)
+TAG_SETTINGS_RENDER_GROUP_PROGRESS = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.GROUP,
+ "progress",
+)
+TAG_SETTINGS_RENDER_GROUP_DEPTH = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.GROUP,
+ "depth",
+)
+TAG_SETTINGS_RENDER_GROUP_BITRATE = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.GROUP,
+ "bitrate",
+)
+TAG_SETTINGS_RENDER_GROUP_DESTINATION = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.GROUP,
+ "destination",
+)
+TAG_SETTINGS_RENDER_COMBO_FORMAT = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.COMBO,
+ "format",
+)
+TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.COMBO,
+ "sample_rate",
+)
+TAG_SETTINGS_RENDER_COMBO_DEPTH = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.COMBO,
+ "depth",
+)
+TAG_SETTINGS_RENDER_COMBO_BITRATE = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.COMBO,
+ "bitrate",
+)
+TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.CHECKBOX,
+ "normalize",
+)
+TAG_SETTINGS_RENDER_TEXT_DURATION = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.TEXT,
+ "duration",
+)
+TAG_SETTINGS_RENDER_TEXT_STATUS = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.TEXT,
+ "status",
+)
+TAG_SETTINGS_RENDER_PATH_DESTINATION = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.PATH,
+ "destination",
+)
+TAG_SETTINGS_RENDER_PROGRESS = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.PROGRESS,
+ "render",
+)
+TAG_SETTINGS_RENDER_BUTTON_BROWSE = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.BUTTON,
+ "browse",
+)
+TAG_SETTINGS_RENDER_BUTTON_START = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.BUTTON,
+ "start",
+)
+TAG_SETTINGS_RENDER_BUTTON_CLOSE = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.BUTTON,
+ "close",
+)
+TAG_SETTINGS_RENDER_BUTTON_CANCEL = TagName(
+ Page.SETTINGS,
+ Panel.RENDER,
+ Widget.BUTTON,
+ "cancel",
+)
+
+TAG_SETTINGS_KEYBINDINGS_WINDOW = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.WINDOW,
+ "keybindings",
+)
+TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.COMBO,
+ "scheme",
+)
+TAG_SETTINGS_KEYBINDINGS_INPUT_FILTER = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.INPUT,
+ "filter",
+)
+TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.INPUT,
+ "shortcut",
+)
+TAG_SETTINGS_KEYBINDINGS_PANEL_ACTIONS = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.PANEL,
+ "actions",
+)
+TAG_SETTINGS_KEYBINDINGS_TABLE_ACTIONS = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.TABLE,
+ "actions",
+)
+TAG_SETTINGS_KEYBINDINGS_TEXT_MESSAGE = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.TEXT,
+ "message",
+)
+TAG_SETTINGS_KEYBINDINGS_BUTTON_CLEAR = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.BUTTON,
+ "clear",
+)
+TAG_SETTINGS_KEYBINDINGS_BUTTON_RESET = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.BUTTON,
+ "reset",
+)
+TAG_SETTINGS_KEYBINDINGS_BUTTON_OK = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.BUTTON,
+ "ok",
+)
+TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.BUTTON,
+ "cancel",
+)
+TAG_SETTINGS_KEYBINDINGS_DIALOG_REASSIGN = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.DIALOG,
+ "reassign",
+)
+TAG_SETTINGS_KEYBINDINGS_DIALOG_RESET = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.DIALOG,
+ "reset",
+)
+TAG_SETTINGS_KEYBINDINGS_DIALOG_DISCARD = TagName(
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ Widget.DIALOG,
+ "discard",
+)
+
+PRE_SETTINGS_KEYBINDINGS_GROUP = compose_tag(TAG_SETTINGS_KEYBINDINGS_TABLE_ACTIONS, "group")
+PRE_SETTINGS_KEYBINDINGS_ROW = compose_tag(TAG_SETTINGS_KEYBINDINGS_TABLE_ACTIONS, "row")
+SUF_SETTINGS_KEYBINDINGS_ACTION = "action"
+SUF_SETTINGS_KEYBINDINGS_SHORTCUT = "shortcut"
+
TAG_SETTINGS_PROPERTIES_WINDOW = TagName(
Page.SETTINGS,
Panel.PROPERTIES,
@@ -74,6 +366,18 @@
Widget.INPUT,
"comment",
)
+TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT = TagName(
+ Page.SETTINGS,
+ Panel.PROPERTIES,
+ Widget.INPUT,
+ "first_highlight",
+)
+TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT = TagName(
+ Page.SETTINGS,
+ Panel.PROPERTIES,
+ Widget.INPUT,
+ "second_highlight",
+)
TAG_SETTINGS_PROPERTIES_BUTTON_OK = TagName(
Page.SETTINGS,
Panel.PROPERTIES,
diff --git a/src/sampletones_application/ui/elements/button.py b/src/sampletones_application/ui/elements/button.py
index 24326901..9e77c027 100644
--- a/src/sampletones_application/ui/elements/button.py
+++ b/src/sampletones_application/ui/elements/button.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from typing import Any, Dict, Optional
+from typing import Any, ClassVar, Dict, Optional
import dearpygui.dearpygui as dpg
@@ -15,7 +15,7 @@
class GUIButton:
- _REGISTRY: Dict[Sender, GUIButton] = {}
+ _REGISTRY: ClassVar[Dict[Sender, GUIButton]] = {}
def __init__(
self,
diff --git a/src/sampletones_application/ui/elements/context_menu.py b/src/sampletones_application/ui/elements/context_menu.py
index b882407d..e63e62f5 100644
--- a/src/sampletones_application/ui/elements/context_menu.py
+++ b/src/sampletones_application/ui/elements/context_menu.py
@@ -1,8 +1,13 @@
import contextlib
-from typing import Iterator
+from typing import Iterator, Optional, Sequence, Tuple
import dearpygui.dearpygui as dpg
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
+from sampletones_application.utils.gui.tooltip import show_tooltip
+from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_shared.types.callback import VoidCallback
@@ -43,3 +48,33 @@ def add_play_menu_item(
shortcut=shortcut,
callback=on_play,
)
+
+
+def add_detail_items(
+ items: Sequence[Tuple[str, str]],
+ *,
+ color: BaseColor,
+ tooltip: Optional[str] = None,
+) -> None:
+ """Add a block of read-only ``label: value`` lines to the context menu being built.
+
+ A menu states what its target is alongside what can be done to it: a file browser prints the
+ settings a reconstruction was made with, and the samples menu prints the bytes a sample
+ occupies. Both read as the same tinted, monospaced block under a separator of its own, so the
+ facts stay apart from the items a reader clicks.
+
+ Args:
+ items: The label and value of each line, in the order the menu prints them.
+ color: The tint the lines take, which marks them as facts rather than actions.
+ tooltip: An explanation the whole block shares, reached by hovering any of its lines.
+ """
+ if not items:
+ return
+
+ dpg.add_separator()
+ for label, value in items:
+ detail_text = dpg.add_text(f"{label}: {value}")
+ dpg_set_palette_color(detail_text, color)
+ FontRegistry.bind_to_item(detail_text, Font.MONO_SMALL)
+ if tooltip is not None:
+ show_tooltip(detail_text, tooltip)
diff --git a/src/sampletones_application/ui/elements/dialog.py b/src/sampletones_application/ui/elements/dialog.py
new file mode 100644
index 00000000..4b29db79
--- /dev/null
+++ b/src/sampletones_application/ui/elements/dialog.py
@@ -0,0 +1,83 @@
+from abc import ABC
+from typing import Final, List, Optional
+
+from sampletones_application.tags.general import TAG_GLOBAL_THEME_DIALOG
+from sampletones_application.ui.elements.window import GUIWindow
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.utils.gui.dialog_navigation import (
+ DialogKeyboardNavigator,
+ FocusStop,
+)
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_shared.types.callback import VoidCallback
+
+INITIAL_FOCUS_STOP: Final[int] = 0
+
+
+class GUIDialogWindow(GUIWindow, ABC):
+ """A ``GUIWindow`` whose controls answer to Tab, Enter and Escape while it stands.
+
+ The keyboard claim belongs to the appearance rather than to the dialog: a window names its
+ stops as it builds its tree, and the navigator installed over them is released when that tree
+ is deleted. Every reopen wires a fresh one, which is what holds the ring and the tree it reads
+ in step across a rebuild.
+
+ A dialog states the router and the scheme its navigation reads, so which keys cycle, activate
+ and cancel follow the reader's own bindings.
+ """
+
+ def __init__(
+ self,
+ tag: str,
+ width: int,
+ height: int,
+ *,
+ key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
+ ) -> None:
+ self._router = key_router
+ self._shortcuts = shortcut_source
+ self._navigator: Optional[DialogKeyboardNavigator] = None
+
+ super().__init__(
+ tag,
+ width,
+ height,
+ )
+
+ def _install_navigation(
+ self,
+ stops: List[FocusStop],
+ *,
+ on_escape: VoidCallback,
+ initial_index: int = INITIAL_FOCUS_STOP,
+ ) -> None:
+ """Wires Tab, Enter and Escape over the controls this appearance offers.
+
+ Args:
+ stops: The controls the focus ring cycles, in reading order.
+ on_escape: What cancelling this dialog means.
+ initial_index: The stop focus opens on, which points a prompt at the answer it expects.
+ """
+ self._navigator = DialogKeyboardNavigator(
+ window_tag=self.tag,
+ stops=stops,
+ on_escape=on_escape,
+ key_router=self._router,
+ shortcut_source=self._shortcuts,
+ initial_index=initial_index,
+ )
+ self._navigator.install()
+
+ def _bind_dialog_theme(self, *tags: str) -> None:
+ """Gives each named control the field styling a dialog's own surface reads."""
+ theme = ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG)
+ for tag in tags:
+ theme.bind_to_item(tag)
+
+ def _teardown(self) -> None:
+ """Releases the keyboard claim of the appearance being torn down."""
+ if self._navigator is not None:
+ self._navigator.dispose()
+ self._navigator = None
diff --git a/src/sampletones_application/ui/elements/fonts/font.py b/src/sampletones_application/ui/elements/fonts/font.py
index dcb5bc46..319f5578 100644
--- a/src/sampletones_application/ui/elements/fonts/font.py
+++ b/src/sampletones_application/ui/elements/fonts/font.py
@@ -11,6 +11,7 @@ class Font(Enum):
BOLD = "Bold"
BOLD_SMALL = "BoldSmall"
BOLD_LARGE = "BoldLarge"
+ BOLD_TITLE = "BoldTitle"
MONO = "Mono"
MONO_SMALL = "MonoSmall"
MONO_BOLD = "MonoBold"
diff --git a/src/sampletones_application/ui/elements/fonts/registry.py b/src/sampletones_application/ui/elements/fonts/registry.py
index edfa5f1e..837e58b4 100644
--- a/src/sampletones_application/ui/elements/fonts/registry.py
+++ b/src/sampletones_application/ui/elements/fonts/registry.py
@@ -1,4 +1,4 @@
-from typing import Dict, Optional, Tuple
+from typing import ClassVar, Dict, Optional, Tuple
import dearpygui.dearpygui as dpg
@@ -7,6 +7,7 @@
TAG_GLOBAL_FONT_BOLD,
TAG_GLOBAL_FONT_BOLD_LARGE,
TAG_GLOBAL_FONT_BOLD_SMALL,
+ TAG_GLOBAL_FONT_BOLD_TITLE,
TAG_GLOBAL_FONT_ICON,
TAG_GLOBAL_FONT_ITALIC,
TAG_GLOBAL_FONT_ITALIC_LARGE,
@@ -27,8 +28,8 @@
class FontRegistry:
- _REGISTRY: Dict[Font, FontData] = {}
- _SPECS: Dict[Font, Tuple[str, FontResource, Typeface, Step]] = {
+ _REGISTRY: ClassVar[Dict[Font, FontData]] = {}
+ _SPECS: ClassVar[Dict[Font, Tuple[str, FontResource, Typeface, Step]]] = {
Font.REGULAR: (TAG_GLOBAL_FONT_REGULAR, FontResource.REGULAR, Typeface.SANS, Step.MEDIUM),
Font.REGULAR_SMALL: (TAG_GLOBAL_FONT_REGULAR_SMALL, FontResource.REGULAR, Typeface.SANS, Step.SMALL),
Font.REGULAR_LARGE: (TAG_GLOBAL_FONT_REGULAR_LARGE, FontResource.REGULAR, Typeface.SANS, Step.LARGE),
@@ -38,6 +39,7 @@ class FontRegistry:
Font.BOLD: (TAG_GLOBAL_FONT_BOLD, FontResource.BOLD, Typeface.SANS, Step.MEDIUM),
Font.BOLD_SMALL: (TAG_GLOBAL_FONT_BOLD_SMALL, FontResource.BOLD, Typeface.SANS, Step.SMALL),
Font.BOLD_LARGE: (TAG_GLOBAL_FONT_BOLD_LARGE, FontResource.BOLD, Typeface.SANS, Step.LARGE),
+ Font.BOLD_TITLE: (TAG_GLOBAL_FONT_BOLD_TITLE, FontResource.BOLD, Typeface.SANS, Step.TITLE),
Font.MONO: (TAG_GLOBAL_FONT_MONO, FontResource.MONO, Typeface.MONO, Step.MEDIUM),
Font.MONO_SMALL: (TAG_GLOBAL_FONT_MONO_SMALL, FontResource.MONO, Typeface.MONO, Step.SMALL),
Font.MONO_BOLD: (TAG_GLOBAL_FONT_MONO_BOLD, FontResource.MONO_BOLD, Typeface.MONO, Step.MEDIUM),
diff --git a/src/sampletones_application/ui/elements/graphs/bar.py b/src/sampletones_application/ui/elements/graphs/bar.py
index a97180db..3b83a6ed 100644
--- a/src/sampletones_application/ui/elements/graphs/bar.py
+++ b/src/sampletones_application/ui/elements/graphs/bar.py
@@ -24,8 +24,12 @@
dpg_delete_item,
dpg_is_item_hovered,
)
-from sampletones_shared.types.application import Color, Sender
+from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.faded import FadedColor
+from sampletones_shared.types.application import Sender
from sampletones_shared.utils.arrays import interpolate_segment
+from sampletones_shared.utils.color import MAX_CHANNEL_VALUE
OnBarPointClickedCallback = Callable[[np.ndarray], None]
OnBarPointHoveredCallback = Callable[[Optional[str], Optional[int]], None]
@@ -150,13 +154,12 @@ def _bind_theme(
if dpg.does_item_exist(theme_tag):
return dpg_bind_item_theme(series_tag, theme_tag)
- with dpg.theme(tag=theme_tag):
- with dpg.theme_component(dpg.mvBarSeries):
- dpg.add_theme_color(
- dpg.mvPlotCol_Fill,
- layer.color,
- category=dpg.mvThemeCat_Plots,
- )
+ with dpg.theme(tag=theme_tag), dpg.theme_component(dpg.mvBarSeries):
+ dpg_add_palette_theme_color(
+ dpg.mvPlotCol_Fill,
+ layer.color,
+ category=dpg.mvThemeCat_Plots,
+ )
return dpg_bind_item_theme(series_tag, theme_tag)
@@ -168,19 +171,16 @@ def _bind_hover_theme(self) -> None:
if layer is None:
raise RuntimeError("No layers available to bind hover theme")
- hover_color = (
- layer.color[0],
- layer.color[1],
- layer.color[2],
- self._hover_alpha,
+ hover_color = FadedColor(
+ color=layer.color,
+ fraction=self._hover_alpha / MAX_CHANNEL_VALUE,
)
- with dpg.theme(tag=self.hover_theme_tag):
- with dpg.theme_component(dpg.mvBarSeries):
- dpg.add_theme_color(
- dpg.mvPlotCol_Fill,
- hover_color,
- category=dpg.mvThemeCat_Plots,
- )
+ with dpg.theme(tag=self.hover_theme_tag), dpg.theme_component(dpg.mvBarSeries):
+ dpg_add_palette_theme_color(
+ dpg.mvPlotCol_Fill,
+ hover_color,
+ category=dpg.mvThemeCat_Plots,
+ )
return dpg_bind_item_theme(self.hover_bar_tag, self.hover_theme_tag)
@@ -188,7 +188,7 @@ def load_data(
self,
data: np.ndarray,
name: str,
- color: Color,
+ color: BaseColor,
y_ticks: Optional[Tuple[int, ...]] = None,
) -> None:
self._delete_hover_bar()
@@ -300,7 +300,7 @@ def _update_ticks(self) -> None:
tick_labels = [str(val) for val in self.y_ticks]
dpg.set_axis_ticks(self.y_axis_tag, tuple(zip(tick_labels, self.y_ticks)))
- def _on_mouse_action(self, sender: Sender) -> None:
+ def _on_mouse_action(self, _sender: Sender) -> None:
previous_stroke = self._draw_stroke
self._draw_stroke = None
diff --git a/src/sampletones_application/ui/elements/graphs/graph.py b/src/sampletones_application/ui/elements/graphs/graph.py
index 19bdd7c1..19e9c21c 100644
--- a/src/sampletones_application/ui/elements/graphs/graph.py
+++ b/src/sampletones_application/ui/elements/graphs/graph.py
@@ -79,7 +79,12 @@ def _bind_event_handler(self) -> None:
@abstractmethod
def _create_content(self) -> None: ...
- def _on_hover(self, sender: Sender, app_data: int, user_data: Any) -> None:
+ def _on_hover(
+ self,
+ _sender: Sender,
+ _app_data: int,
+ _user_data: Any,
+ ) -> None:
shift = dpg.is_key_down(dpg.mvKey_LShift)
dpg.configure_item(self.x_axis_tag, lock_min=shift, lock_max=shift)
dpg.configure_item(self.y_axis_tag, lock_min=not shift, lock_max=not shift)
diff --git a/src/sampletones_application/ui/elements/graphs/layers/array.py b/src/sampletones_application/ui/elements/graphs/layers/array.py
index 41ad7672..87ec8aba 100644
--- a/src/sampletones_application/ui/elements/graphs/layers/array.py
+++ b/src/sampletones_application/ui/elements/graphs/layers/array.py
@@ -3,15 +3,15 @@
import numpy as np
from sampletones_application.ui.elements.graphs.layers.layer import Layer
+from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_core.audio import minmax_decimate
-from sampletones_shared.types.application import Color
@dataclass(frozen=True)
class ArrayLayer(Layer):
data: np.ndarray
name: str
- color: Color
+ color: BaseColor
max_display_points: int
def __post_init__(self) -> None:
diff --git a/src/sampletones_application/ui/elements/graphs/layers/bar.py b/src/sampletones_application/ui/elements/graphs/layers/bar.py
index e1a21df9..8a4e177d 100644
--- a/src/sampletones_application/ui/elements/graphs/layers/bar.py
+++ b/src/sampletones_application/ui/elements/graphs/layers/bar.py
@@ -3,14 +3,14 @@
import numpy as np
from sampletones_application.ui.elements.graphs.layers.layer import Layer
-from sampletones_shared.types.application import Color
+from sampletones_application.utils.palette.colors.base import BaseColor
@dataclass(frozen=True)
class BarLayer(Layer):
data: np.ndarray
name: str
- color: Color
+ color: BaseColor
bar_weight: float
def __post_init__(self) -> None:
diff --git a/src/sampletones_application/ui/elements/graphs/layers/instruction.py b/src/sampletones_application/ui/elements/graphs/layers/instruction.py
index 6dfebe4c..4361cf3f 100644
--- a/src/sampletones_application/ui/elements/graphs/layers/instruction.py
+++ b/src/sampletones_application/ui/elements/graphs/layers/instruction.py
@@ -4,16 +4,16 @@
import numpy as np
from sampletones_application.ui.elements.graphs.layers.layer import Layer
+from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_core.generators import MIXER_LEVELS
from sampletones_core.library import InstructionLibraryFragment
-from sampletones_shared.types.application import Color
@dataclass(frozen=True)
class InstructionLayer(Layer):
data: InstructionLibraryFragment[Any]
name: str
- color: Color
+ color: BaseColor
def __post_init__(self) -> None:
mixer = MIXER_LEVELS[self.data.generator_class]
diff --git a/src/sampletones_application/ui/elements/graphs/layers/spectrum.py b/src/sampletones_application/ui/elements/graphs/layers/spectrum.py
index 01bcc5f0..9c07aef2 100644
--- a/src/sampletones_application/ui/elements/graphs/layers/spectrum.py
+++ b/src/sampletones_application/ui/elements/graphs/layers/spectrum.py
@@ -4,17 +4,17 @@
import numpy as np
from sampletones_application.ui.elements.graphs.layers.layer import Layer
+from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_core.library import InstructionLibraryFragment
from sampletones_core.structures.histogram import Histogram
-from sampletones_shared.types.application import Color
@dataclass(frozen=True)
class SpectrumLayer(Layer):
data: InstructionLibraryFragment[Any]
name: str
- color_dim: Color
- color_bright: Color
+ color_dim: BaseColor
+ color_bright: BaseColor
max_display_bins: int
spectrum: Histogram = field(init=False)
diff --git a/src/sampletones_application/ui/elements/graphs/spectrum.py b/src/sampletones_application/ui/elements/graphs/spectrum.py
index a93d508c..d36cc0a2 100644
--- a/src/sampletones_application/ui/elements/graphs/spectrum.py
+++ b/src/sampletones_application/ui/elements/graphs/spectrum.py
@@ -14,10 +14,14 @@
dpg_bind_item_theme,
dpg_delete_children,
)
+from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.blended import BlendedColor
from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE
from sampletones_core.constants.general import MIN_FREQUENCY
from sampletones_core.library import InstructionLibraryFragment
-from sampletones_shared.types.application import Color, Sender
+from sampletones_shared.types.application import Sender
+from sampletones_shared.utils.color import MAX_CHANNEL_VALUE
class GUISpectrumGraph(GUIGraph[SpectrumLayer]):
@@ -43,7 +47,7 @@ def __init__(
self.spectrum: Optional[np.ndarray] = None
self.frequencies: Optional[np.ndarray] = None
- self.themes: Dict[Color, str] = {}
+ self.themes: Dict[BaseColor, str] = {}
super().__init__(
tag,
@@ -91,8 +95,8 @@ def _create_content(self) -> None:
def load_library_fragment(
self,
fragment: InstructionLibraryFragment[Any],
- sample_rate: int,
- frame_length: int,
+ _sample_rate: int,
+ _frame_length: int,
) -> None:
self.clear_layers()
@@ -101,14 +105,19 @@ def load_library_fragment(
data=fragment,
name=self._language_manager["global.graph.label.spectrum_name"],
max_display_bins=self._layout.spectrum.max_display_bins,
- color_dim=self._layout.spectrum.color_dim[:3],
- color_bright=self._layout.spectrum.color_bright[:3],
+ color_dim=self._layout.spectrum.color_dim,
+ color_bright=self._layout.spectrum.color_bright,
)
)
self._update_ranges()
- def _on_hover(self, sender: Sender, app_data: Any, user_data: Any) -> None:
+ def _on_hover(
+ self,
+ _sender: Sender,
+ _app_data: Any,
+ _user_data: Any,
+ ) -> None:
self._status_bar.set(self._language_manager["global.graph.message.spectrum_navigation"])
def _update_ranges(self) -> None:
@@ -118,29 +127,33 @@ def _update_ranges(self) -> None:
frequencies = [frequency for layer in self.layers.values() for frequency, _, _ in layer]
self.y_range = (frequencies[0], frequencies[-1])
- def _get_color_theme_tag(self, color: Color) -> str:
- color_part = "_".join(str(c) for c in color)
- return compose_tag(self.tag, SUF_GRAPH_THEME, color_part)
-
- def _create_brightness_theme(self, color_dim: Color, color_bright: Color, brightness: float) -> str:
- t = brightness / 255.0
- color = (
- round(color_dim[0] + (color_bright[0] - color_dim[0]) * t),
- round(color_dim[1] + (color_bright[1] - color_dim[1]) * t),
- round(color_dim[2] + (color_bright[2] - color_dim[2]) * t),
- 255,
+ def _create_brightness_theme(
+ self,
+ color_dim: BaseColor,
+ color_bright: BaseColor,
+ brightness: float,
+ ) -> str:
+ """The theme filling a band at ``brightness``, built once per shade the spectrum shows.
+
+ A band's shade sits on the gradient between the dim and bright ends, and is held as the
+ blend of the two tokens rather than as the value it currently reads, so every band the
+ spectrum has drawn takes the new gradient when another palette is activated.
+ """
+ color = BlendedColor(
+ start=color_dim,
+ end=color_bright,
+ fraction=brightness / MAX_CHANNEL_VALUE,
)
if color in self.themes:
return self.themes[color]
- theme_tag = self._get_color_theme_tag(color)
- with dpg.theme(tag=theme_tag):
- with dpg.theme_component(dpg.mvBarSeries):
- dpg.add_theme_color(
- dpg.mvPlotCol_Fill,
- color,
- category=dpg.mvThemeCat_Plots,
- )
+ theme_tag = compose_tag(self.tag, SUF_GRAPH_THEME, str(len(self.themes)))
+ with dpg.theme(tag=theme_tag), dpg.theme_component(dpg.mvBarSeries):
+ dpg_add_palette_theme_color(
+ dpg.mvPlotCol_Fill,
+ color,
+ category=dpg.mvThemeCat_Plots,
+ )
self.themes[color] = theme_tag
return theme_tag
diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py
index f795fadd..a782ab12 100644
--- a/src/sampletones_application/ui/elements/graphs/waveform.py
+++ b/src/sampletones_application/ui/elements/graphs/waveform.py
@@ -1,3 +1,4 @@
+from enum import StrEnum
from typing import Any, List, Optional, Tuple, Union
import dearpygui.dearpygui as dpg
@@ -26,11 +27,21 @@
dpg_delete_children,
dpg_delete_item,
)
+from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.faded import FadedColor
+from sampletones_application.utils.palette.colors.grayscale import GrayscaleColor
from sampletones_application.view_model.shared.waveform_data import WaveformData
from sampletones_core.constants.enums import AudioSourceType, GeneratorName
from sampletones_core.library import InstructionLibraryFragment
-from sampletones_shared.types.application import Color, Sender
-from sampletones_shared.utils.color import to_grayscale, with_alpha_fraction
+from sampletones_shared.types.application import Sender
+
+
+class SeriesShade(StrEnum):
+ """How strongly a waveform series is drawn, which decides the colour its theme carries."""
+
+ FULL = "full"
+ DIMMED = "dimmed"
class GUIWaveformGraph(GUIGraph[Union[ArrayLayer, InstructionLayer]]):
@@ -268,7 +279,7 @@ def set_reconstruction_dimmed(self, dimmed: bool) -> None:
series_tag = self._series_tag(layer.name)
if dpg.does_item_exist(series_tag):
- self._bind_series_theme(series_tag, self._series_color(layer))
+ self._bind_series_theme(series_tag, layer)
def reconstruction_layer(self, data: np.ndarray) -> ArrayLayer:
return ArrayLayer(
@@ -348,18 +359,27 @@ def _update_display(self) -> None:
for layer in self.layers.values():
series_tag = self._series_tag(layer.name)
self._upsert_series(series_tag, layer)
- self._bind_series_theme(series_tag, self._series_color(layer))
+ self._bind_series_theme(series_tag, layer)
- def _series_color(self, layer: Union[ArrayLayer, InstructionLayer]) -> Color:
- """Resolves a layer's line colour, greying the reconstruction while a regeneration runs.
+ def _series_shade(self, layer: Union[ArrayLayer, InstructionLayer]) -> SeriesShade:
+ dimmed = self._reconstruction_dimmed and layer.name == self._lbl_waveform_reconstruction
+ return SeriesShade.DIMMED if dimmed else SeriesShade.FULL
+
+ def _series_color(
+ self,
+ layer: Union[ArrayLayer, InstructionLayer],
+ shade: SeriesShade,
+ ) -> BaseColor:
+ """A layer's line colour in one of its two shades.
The dimmed reconstruction is desaturated to gray and faded, so the drawn waveform — not just
the legend swatch — clearly reads as inactive while its audio is recomputed.
"""
- if self._reconstruction_dimmed and layer.name == self._lbl_waveform_reconstruction:
- return with_alpha_fraction(
- to_grayscale(self._layout.colors.waveform_reconstruction),
- self._layout.waveform.reconstruction_dim_opacity,
+ if shade is SeriesShade.DIMMED:
+ reconstruction = self._layout.colors.waveform_reconstruction
+ return FadedColor(
+ color=GrayscaleColor(color=reconstruction),
+ fraction=self._layout.waveform.reconstruction_dim_opacity,
)
return layer.color
@@ -377,7 +397,11 @@ def _prune_stale_series(self) -> None:
if child_tag not in live_series_tags:
dpg_delete_item(child)
- def _upsert_series(self, series_tag: str, layer: Union[ArrayLayer, InstructionLayer]) -> None:
+ def _upsert_series(
+ self,
+ series_tag: str,
+ layer: Union[ArrayLayer, InstructionLayer],
+ ) -> None:
"""Refreshes the points of an existing series, or creates it on the y-axis when new."""
if dpg.does_item_exist(series_tag):
dpg.configure_item(
@@ -394,22 +418,26 @@ def _upsert_series(self, series_tag: str, layer: Union[ArrayLayer, InstructionLa
tag=series_tag,
)
- def _bind_series_theme(self, series_tag: str, color: Color) -> None:
- """Binds a line-color theme to a series, creating one cached theme per colour.
+ def _bind_series_theme(
+ self,
+ series_tag: str,
+ layer: Union[ArrayLayer, InstructionLayer],
+ ) -> None:
+ """Binds a line-colour theme to a series, holding one theme per shade the series takes.
- Keying the theme by colour lets a series switch between colour variants — such as the
- dimmed reconstruction line during regeneration — by binding the matching cached theme.
+ A series switches between its full and dimmed shades — the reconstruction line greys while
+ its audio is recomputed — by binding the theme built for that shade, and each theme carries
+ the colour token behind its shade, so both follow a palette swap.
"""
- color_part = "_".join(str(channel) for channel in color)
- theme_tag = compose_tag(series_tag, SUF_GRAPH_THEME, color_part)
+ shade = self._series_shade(layer)
+ theme_tag = compose_tag(series_tag, SUF_GRAPH_THEME, shade)
if not dpg.does_item_exist(theme_tag):
- with dpg.theme(tag=theme_tag):
- with dpg.theme_component(dpg.mvLineSeries):
- dpg.add_theme_color(
- dpg.mvPlotCol_Line,
- color,
- category=dpg.mvThemeCat_Plots,
- )
+ with dpg.theme(tag=theme_tag), dpg.theme_component(dpg.mvLineSeries):
+ dpg_add_palette_theme_color(
+ dpg.mvPlotCol_Line,
+ self._series_color(layer, shade),
+ category=dpg.mvThemeCat_Plots,
+ )
dpg_bind_item_theme(series_tag, theme_tag)
diff --git a/src/sampletones_application/ui/elements/layout/collapse.py b/src/sampletones_application/ui/elements/layout/collapse.py
index a3a44cb5..616f4312 100644
--- a/src/sampletones_application/ui/elements/layout/collapse.py
+++ b/src/sampletones_application/ui/elements/layout/collapse.py
@@ -3,7 +3,7 @@
import dearpygui.dearpygui as dpg
-from sampletones_application.layout.glyphs import Glyphs
+from sampletones_application.layout.glyphs.glyphs import Glyphs
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
SUF_COLLAPSE_BODY,
diff --git a/src/sampletones_application/ui/elements/layout/responsive.py b/src/sampletones_application/ui/elements/layout/responsive.py
index 0253a207..8caffb6f 100644
--- a/src/sampletones_application/ui/elements/layout/responsive.py
+++ b/src/sampletones_application/ui/elements/layout/responsive.py
@@ -25,17 +25,16 @@ def stacked_graph_height(
viewport_height: int,
baseline_viewport_height: int,
graph_count: int,
- max_stack_height: int,
+ max_graph_height: int,
) -> int:
"""Grows each graph of a vertical stack as the viewport grows past the lowest-resolution baseline.
At ``baseline_viewport_height`` — the smallest supported window — the stacked graphs sit at
``base_height`` and together fill their column. The extra room a taller viewport offers is shared
- equally across the ``graph_count`` graphs, so the stack keeps filling as the window grows, until the
- graphs together reach ``max_stack_height``; from there each graph holds at its
- ``max_stack_height // graph_count`` cap and the surplus stays free.
+ equally across the ``graph_count`` graphs, so the stack keeps filling as the window grows, until
+ each graph stands at ``max_graph_height`` and holds there, leaving the remaining surplus free. The
+ ceiling reads as one graph's height so it stays the same however many graphs the stack holds.
"""
surplus = viewport_height - baseline_viewport_height
expansion = max(0, round(surplus / graph_count))
- max_graph_height = max_stack_height // graph_count
return min(base_height + expansion, max_graph_height)
diff --git a/src/sampletones_application/ui/elements/panel.py b/src/sampletones_application/ui/elements/panel.py
index 7ab9c78d..7a510d4e 100644
--- a/src/sampletones_application/ui/elements/panel.py
+++ b/src/sampletones_application/ui/elements/panel.py
@@ -7,7 +7,8 @@
from sampletones_application.layout.general.collapse import CollapseLayout
from sampletones_application.layout.general.section_header import SectionHeaderLayout
-from sampletones_application.layout.glyphs import GlyphLayout, Glyphs
+from sampletones_application.layout.glyphs.glyph import GlyphLayout
+from sampletones_application.layout.glyphs.glyphs import Glyphs
from sampletones_application.tags.general import (
TAG_GLOBAL_THEME_PANEL_SURFACE,
TAG_GLOBAL_THEME_SECTION_HEADER,
@@ -152,7 +153,10 @@ def _enable_horizontal_collapse(
initial_collapsed=initial_collapsed,
)
- def set_collapse_handler(self, callback: Callable[[str, bool], None]) -> None:
+ def set_collapse_handler(
+ self,
+ callback: Callable[[str, bool], None],
+ ) -> None:
"""Route this card's collapse toggles to ``callback`` so the coordinator can persist and react to them."""
if self._collapse is not None:
self._collapse.on_toggle = callback
@@ -195,48 +199,49 @@ def _create_section_header(
marker_glyph = glyph if glyph is not None else self._glyphs.common.tick
collapsible = affordance is not None
policy = dpg.mvTable_SizingStretchProp if collapsible else dpg.mvTable_SizingFixedFit
- with dpg.group(
- parent=parent,
- tag=tag,
- ) as header:
- with dpg.table(
+ with (
+ dpg.group(
+ parent=parent,
+ tag=tag,
+ ) as header,
+ dpg.table(
header_row=False,
policy=policy,
resizable=False,
- ):
+ ),
+ ):
+ dpg.add_table_column(
+ width_fixed=True,
+ init_width_or_weight=self._glyph_layout.width,
+ )
+ dpg.add_table_column(width_fixed=not collapsible)
+ if collapsible:
+ dpg.add_table_column(width_fixed=True)
dpg.add_table_column(
width_fixed=True,
- init_width_or_weight=self._glyph_layout.width,
+ init_width_or_weight=self._section_header_layout.chevron_offset,
)
- dpg.add_table_column(width_fixed=not collapsible)
- if collapsible:
- dpg.add_table_column(width_fixed=True)
- dpg.add_table_column(
- width_fixed=True,
- init_width_or_weight=self._section_header_layout.chevron_offset,
+
+ with dpg.table_row():
+ with dpg.table_cell(), dpg.group() as marker_group:
+ dpg.add_spacer(height=self._glyph_layout.top_offset)
+ marker = dpg.add_text(
+ marker_glyph,
+ indent=self._glyph_layout.indent,
)
+ FontRegistry.bind_to_item(marker, Font.ICON)
+ dpg.bind_item_theme(marker_group, self._get_marker_group_theme())
- with dpg.table_row():
- with dpg.table_cell():
- with dpg.group() as marker_group:
- dpg.add_spacer(height=self._glyph_layout.top_offset)
- marker = dpg.add_text(
- marker_glyph,
- indent=self._glyph_layout.indent,
- )
- FontRegistry.bind_to_item(marker, Font.ICON)
- dpg.bind_item_theme(marker_group, self._get_marker_group_theme())
+ with dpg.table_cell():
+ label_text = dpg.add_text(label.upper())
+ FontRegistry.bind_to_item(label_text, Font.BOLD_LARGE)
+ if collapsible:
with dpg.table_cell():
- label_text = dpg.add_text(label.upper())
- FontRegistry.bind_to_item(label_text, Font.BOLD_LARGE)
-
- if collapsible:
- with dpg.table_cell():
- chevron = dpg.add_text(affordance, tag=affordance_tag)
- FontRegistry.bind_to_item(chevron, Font.ICON)
- with dpg.table_cell():
- dpg.add_spacer()
+ chevron = dpg.add_text(affordance, tag=affordance_tag)
+ FontRegistry.bind_to_item(chevron, Font.ICON)
+ with dpg.table_cell():
+ dpg.add_spacer()
if not collapsible:
dpg.add_separator()
@@ -267,18 +272,20 @@ def _collapsible_card(
if controller is None:
raise RuntimeError(f"Card {self.tag} opened a collapsible card without a collapse controller.")
- with card(
- parent,
- controller.card_tag,
- theme=card_theme,
- width=width,
- height=controller.expanded_height,
- auto_resize_y=controller.auto_height,
- no_scrollbar=no_scrollbar,
- show=show,
+ with (
+ card(
+ parent,
+ controller.card_tag,
+ theme=card_theme,
+ width=width,
+ height=controller.expanded_height,
+ auto_resize_y=controller.auto_height,
+ no_scrollbar=no_scrollbar,
+ show=show,
+ ),
+ self._collapsible_section(label, glyph=glyph),
):
- with self._collapsible_section(label, glyph=glyph):
- yield
+ yield
@contextmanager
def _collapsible_section(
@@ -332,6 +339,7 @@ def _collapsible_section(
dpg.add_spacer(height=self._collapse_layout.rail_title_gap)
rail_title = dpg.add_text("\n".join(label.upper()))
FontRegistry.bind_to_item(rail_title, Font.MONO_BOLD_SMALL)
+
ThemeRegistry.get(TAG_GLOBAL_THEME_SECTION_HEADER).bind_to_item(rail_content)
self._center_rail_items(
controller.rail_width,
@@ -349,7 +357,11 @@ def _collapsible_section(
controller.set_collapsed(controller.collapsed, notify=False)
- def _center_rail_items(self, rail_width: int, items: List[Tuple[Sender, Font]]) -> None:
+ def _center_rail_items(
+ self,
+ rail_width: int,
+ items: List[Tuple[Sender, Font]],
+ ) -> None:
"""Indent each rail item so its glyph sits centered in the rail's content region.
Text is left-aligned, so an item is nudged right by half the slack between the content width
@@ -362,8 +374,15 @@ def _center_rail_items(self, rail_width: int, items: List[Tuple[Sender, Font]])
for item, font in items:
size = dpg.get_text_size(dpg.get_value(item), font=FontRegistry.get_tag(font))
if size is None:
- FrameCallbackManager.set_frame_callback(partial(self._center_rail_items, rail_width, items))
+ FrameCallbackManager.set_frame_callback(
+ partial(
+ self._center_rail_items,
+ rail_width,
+ items,
+ )
+ )
return
+
indents.append((item, max(0, round((content_width - size[0]) / 2))))
for item, indent in indents:
diff --git a/src/sampletones_application/ui/elements/path.py b/src/sampletones_application/ui/elements/path.py
index c12944de..0c394378 100644
--- a/src/sampletones_application/ui/elements/path.py
+++ b/src/sampletones_application/ui/elements/path.py
@@ -14,8 +14,10 @@
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.utils.gui.dpg import dpg_delete_item, dpg_set_value
from sampletones_application.utils.gui.frame import FrameCallbackManager
+from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
from sampletones_application.utils.gui.tooltip import show_tooltip
-from sampletones_shared.types.application import Color, Sender
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_shared.types.application import Sender
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.callbacks import CallbackMixin
from sampletones_shared.utils.system.paths import (
@@ -33,8 +35,8 @@ def __init__(
tag: str,
path: Optional[Path],
parent: str,
- color: Color,
- hover_color: Color,
+ color: BaseColor,
+ hover_color: BaseColor,
status_message: str,
prefix: Optional[str] = None,
font: Optional[Font] = None,
@@ -78,8 +80,8 @@ def _create_text(self) -> None:
self.display_text,
tag=self.tag,
parent=parent,
- color=self.color,
)
+ dpg_set_palette_color(self.tag, self.color)
if self.font is not None:
FontRegistry.bind_to_item(self.label_tag, self.font)
@@ -113,13 +115,13 @@ def _on_hover(self) -> None:
if dpg.does_item_exist(self.tag):
if dpg.is_item_hovered(self.tag):
self._status_bar.set(self._status_message)
- dpg.configure_item(self.tag, color=self.hover_color)
+ dpg_set_palette_color(self.tag, self.hover_color)
FrameCallbackManager.set_frame_callback(
self._on_hover,
2,
)
else:
- dpg.configure_item(self.tag, color=self.color)
+ dpg_set_palette_color(self.tag, self.color)
def _on_clicked(self) -> None:
if not self.path.exists():
@@ -137,11 +139,11 @@ def set_path(self, path: Pathlike, shorten: bool = True) -> None:
self.color = self._path_color
self.hover_color = self._path_hover_color
dpg_set_value(self.tag, self.display_text)
- dpg.configure_item(self.tag, color=self.color)
+ dpg_set_palette_color(self.tag, self.color)
if self.tooltip is not None:
dpg.set_value(self.tooltip, self.path_text)
- def set_status(self, text: str, color: Color) -> None:
+ def set_status(self, text: str, color: BaseColor) -> None:
"""Displays a non-path status (missing or not applicable) in a muted colour.
The path is cleared so the row is inert: hovering holds the muted colour and a
@@ -152,7 +154,7 @@ def set_status(self, text: str, color: Color) -> None:
self.color = color
self.hover_color = color
dpg_set_value(self.tag, text)
- dpg.configure_item(self.tag, color=color)
+ dpg_set_palette_color(self.tag, color)
if self.tooltip is not None:
dpg.set_value(self.tooltip, text)
@@ -162,3 +164,30 @@ def get_path(self) -> Path:
def destroy(self) -> None:
dpg_delete_item(self.handler_tag)
dpg_delete_item(self.tag)
+
+
+class GUIDestinationPathText(GUIPathText):
+ """A path an operation is going to write to, which reveals the nearest place that stands.
+
+ A destination names what the operation will leave behind, so it points into the filesystem
+ before anything is there. A click therefore reaches the destination itself once it is written,
+ and the closest directory on its way while it is still being described.
+ """
+
+ def _on_clicked(self) -> None:
+ standing = self._nearest_standing()
+ if standing is not None:
+ open_path_in_explorer(standing)
+
+ def _nearest_standing(self) -> Optional[Path]:
+ if not self.path.name:
+ return None
+
+ if self.path.exists():
+ return self.path
+
+ for directory in self.path.parents:
+ if directory.is_dir():
+ return directory
+
+ return None
diff --git a/src/sampletones_application/ui/elements/pitch_stepper.py b/src/sampletones_application/ui/elements/pitch_stepper.py
index 6e504d8a..a275fe30 100644
--- a/src/sampletones_application/ui/elements/pitch_stepper.py
+++ b/src/sampletones_application/ui/elements/pitch_stepper.py
@@ -24,10 +24,10 @@
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.callbacks.queue import CallbackQueue
from sampletones_application.utils.gui.dpg import dpg_delete_item, dpg_set_value
+from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
from sampletones_application.utils.gui.tooltip import show_tooltip
-from sampletones_application.utils.palette import PaletteColor
+from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_core.utils.pitch_kind import PitchValueKind
-from sampletones_shared.types.application import Color
from sampletones_shared.utils.callbacks import CallbackMixin
@@ -43,7 +43,7 @@ class PitchStepperStyle:
dimensions: PitchStepperLayout
plus_minus: PlusMinusButtonsLayout
- value_color: PaletteColor
+ value_color: BaseColor
@classmethod
def from_general(cls, general: GeneralLayout) -> Self:
@@ -78,7 +78,7 @@ def __init__(
status_bar: GUIStatusBar,
layout: PitchStepperLayout,
plus_minus_layout: PlusMinusButtonsLayout,
- value_color: Color,
+ value_color: BaseColor,
) -> None:
self.on_value_changed: Optional[Callable[[int], None]] = None
self._status_bar = status_bar
@@ -154,8 +154,8 @@ def _build(self) -> None:
dpg.add_text(
str(self._value),
tag=self._value_tag,
- color=self._value_color,
)
+ dpg_set_palette_color(self._value_tag, self._value_color)
FontRegistry.bind_to_item(self._value_tag, Font.MONO)
with dpg.table_cell():
dpg.add_input_text(
diff --git a/src/sampletones_application/ui/elements/plus_minus_buttons.py b/src/sampletones_application/ui/elements/plus_minus_buttons.py
index eef9289f..73dc9edc 100644
--- a/src/sampletones_application/ui/elements/plus_minus_buttons.py
+++ b/src/sampletones_application/ui/elements/plus_minus_buttons.py
@@ -3,7 +3,9 @@
import dearpygui.dearpygui as dpg
-from sampletones_application.layout.general.plus_minus_buttons import PlusMinusButtonsLayout
+from sampletones_application.layout.general.plus_minus_buttons import (
+ PlusMinusButtonsLayout,
+)
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
SUF_BUTTON_DECREMENT,
@@ -188,8 +190,8 @@ def _on_decrement(self, *_arguments: Any) -> None:
def _on_mouse_down(
self,
sender: Sender,
- app_data: Any,
- user_data: Any,
+ _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)
@@ -207,9 +209,9 @@ def _on_mouse_down(
def _on_mouse_release(
self,
- sender: Sender,
- app_data: Any,
- user_data: Any,
+ _sender: Sender,
+ _app_data: Any,
+ _user_data: Any,
) -> None:
self._hold_timer = None
self._hold_direction = None
diff --git a/src/sampletones_application/ui/elements/status.py b/src/sampletones_application/ui/elements/status.py
index ffa5686f..d1204682 100644
--- a/src/sampletones_application/ui/elements/status.py
+++ b/src/sampletones_application/ui/elements/status.py
@@ -105,7 +105,7 @@ def create_message_function(
) -> MessageCallback:
if isinstance(message_or_function, str):
- def message_function(*args: Any, **kwargs: Any) -> str:
+ def message_function(*_args: Any, **_kwargs: Any) -> str:
return message_or_function
elif callable(message_or_function):
diff --git a/src/sampletones_application/ui/elements/table/caret.py b/src/sampletones_application/ui/elements/table/caret.py
index 87bd3c44..40f0a016 100644
--- a/src/sampletones_application/ui/elements/table/caret.py
+++ b/src/sampletones_application/ui/elements/table/caret.py
@@ -7,7 +7,7 @@
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.utils.gui.dpg import dpg_get_item_parent
from sampletones_shared.meta import NonInstantiableMeta
-from sampletones_shared.types.application import ColorRGBA, Sender
+from sampletones_shared.types.application import Sender
Box = Tuple[float, float, float, float]
@@ -35,14 +35,11 @@ class CaretOverlay(metaclass=NonInstantiableMeta):
character's position and redrawn every frame (from the application loop) so it
follows the table as it scrolls. Because at
most one cell across both tracker tables holds the cursor at a time, one
- shared rectangle is enough; the ``owner`` token keeps the order and grid
+ shared rectangle is enough; the ``owner`` token keeps the order and tracker
panels' arm/clear calls from clobbering each other during focus hand-off.
"""
- _fill: ColorRGBA = (0, 0, 0, 0)
- _border: ColorRGBA = (0, 0, 0, 0)
- _offset: float = 0.0
- _width_padding: float = 0.0
+ _layout: Optional[CaretLayout] = None
_rectangle: Optional[Sender] = None
_owner: Optional[Any] = None
@@ -61,18 +58,15 @@ def initialize(cls, layout: CaretLayout, *, root_window_tag: str) -> None:
other top-level window that takes focus keeps the front-drawn caret from painting
over it.
"""
- cls._fill = layout.fill
- cls._border = layout.border
- cls._offset = layout.offset
- cls._width_padding = layout.width_padding
+ cls._layout = layout
cls._root_window = root_window_tag
drawlist = dpg.add_viewport_drawlist(front=True)
cls._rectangle = dpg.draw_rectangle(
(0.0, 0.0),
(0.0, 0.0),
parent=drawlist,
- fill=cls._fill,
- color=cls._border,
+ fill=layout.fill.rgba,
+ color=layout.border.rgba,
show=False,
)
@@ -119,7 +113,7 @@ def redraw(cls) -> None:
dialog or another window holds focus), keeping the armed state so the caret returns
to the same cell once focus comes back.
"""
- if cls._rectangle is None:
+ if cls._rectangle is None or cls._layout is None:
return
if not cls._active_within_root():
@@ -136,15 +130,15 @@ def redraw(cls) -> None:
cls._rectangle,
pmin=pmin,
pmax=pmax,
- fill=cls._fill,
- color=cls._border,
+ fill=cls._layout.fill.rgba,
+ color=cls._layout.border.rgba,
show=True,
)
@classmethod
def _compute_box(cls) -> Optional[Box]:
widget = cls._widget
- if widget is None or cls._font is None:
+ if widget is None or cls._font is None or cls._layout is None:
return None
if not dpg.does_item_exist(widget):
@@ -167,8 +161,8 @@ def _compute_box(cls) -> Optional[Box]:
x0, y0, _, y1 = cell
char_width = size[0] / len(text)
- caret_x0 = x0 + cls._offset + cls._caret_index * char_width
- caret_x1 = caret_x0 + char_width + cls._width_padding
+ caret_x0 = x0 + cls._layout.offset + cls._caret_index * char_width
+ caret_x1 = caret_x0 + char_width + cls._layout.width_padding
return cls._clip((caret_x0, y0, caret_x1, y1))
diff --git a/src/sampletones_application/ui/elements/table/cells.py b/src/sampletones_application/ui/elements/table/cells.py
index 379ad571..7943de02 100644
--- a/src/sampletones_application/ui/elements/table/cells.py
+++ b/src/sampletones_application/ui/elements/table/cells.py
@@ -35,6 +35,7 @@ class EditableCells(Generic[KeyT]):
def __init__(self) -> None:
self._widgets: Dict[KeyT, Sender] = {}
+ self._keys: Dict[Sender, KeyT] = {}
self._values: Dict[KeyT, str] = {}
@property
@@ -44,14 +45,24 @@ def values(self) -> Dict[KeyT, str]:
def reset(self, values: Dict[KeyT, str]) -> None:
"""Drops the widget references and reseeds the value cache for a rebuild."""
self._widgets = {}
+ self._keys = {}
self._values = dict(values)
def register(self, key: KeyT, widget: Sender) -> None:
self._widgets[key] = widget
+ self._keys[widget] = key
def widget(self, key: KeyT) -> Optional[Sender]:
return self._widgets.get(key)
+ def key(self, widget: Sender) -> Optional[KeyT]:
+ """The cell a widget stands for, which is what a handler reporting an item needs.
+
+ DearPyGui hands an item handler the widget it fired for, so the cache is read from
+ both sides: a panel looks a widget up here rather than reading its user data back.
+ """
+ return self._keys.get(widget)
+
def reconcile(self, values: Dict[KeyT, str], render: Callable[[KeyT], str]) -> None:
"""Updates only the cells whose label changed since the last reconcile."""
for key, value in values.items():
diff --git a/src/sampletones_application/ui/elements/table/drag.py b/src/sampletones_application/ui/elements/table/drag.py
new file mode 100644
index 00000000..41f12172
--- /dev/null
+++ b/src/sampletones_application/ui/elements/table/drag.py
@@ -0,0 +1,105 @@
+from collections.abc import Hashable
+from dataclasses import dataclass
+from typing import Callable, Generic, Optional, TypeVar
+
+from sampletones_application.ui.elements.table.cells import EditableCells
+from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers
+from sampletones_shared.types.application import Sender
+
+KeyT = TypeVar("KeyT", bound=Hashable)
+
+
+@dataclass
+class DragGesture(Generic[KeyT]):
+ """The press a drag selection grows from.
+
+ ``origin`` is the cell the button went down on, which is the end a plain drag anchors its
+ selection at. ``extends`` records that the press held Shift, so the drag carries the
+ selection already on the grid instead of starting a new one. ``moved`` states that the
+ pointer has reached another cell, which is what tells a drag apart from a click: until it
+ is set, the press is still a click and the selection is left alone.
+ """
+
+ origin: KeyT
+ extends: bool
+ moved: bool = False
+
+
+@dataclass(frozen=True)
+class DragReach(Generic[KeyT]):
+ """How far a drag has carried the pointer, and which end it grew from.
+
+ A plain drag anchors a fresh selection at ``origin`` and runs it out to ``reached``; a drag
+ whose press held Shift reports ``extends``, and carries the selection already on the grid
+ out to ``reached`` instead.
+ """
+
+ origin: KeyT
+ reached: KeyT
+ extends: bool
+
+
+class DragSelection(Generic[KeyT]):
+ """The gesture a grid selection is dragged out with.
+
+ A grid hands its pointer reports here — the cell a press holds, the click that follows, the
+ press that starts the next gesture — and states the reach that comes back as a selection in
+ its own coordinates. The cell cache names the widget a press landed on, and ``cell_at`` reads
+ the cell the pointer stands on now off the grid's geometry.
+ """
+
+ def __init__(
+ self,
+ *,
+ cells: EditableCells[KeyT],
+ cell_at: Callable[[], Optional[KeyT]],
+ ) -> None:
+ self._cells = cells
+ self._cell_at = cell_at
+ self._gesture: Optional[DragGesture[KeyT]] = None
+
+ def hold(self, widget: Sender) -> Optional[DragReach[KeyT]]:
+ """How far a held pointer has carried, once it has left the cell the press landed on.
+
+ DearPyGui reports no hover for the cells a held pointer passes over, so the cell the drag
+ has reached is read off the grid's own geometry while the held widget names where the press
+ landed. A press that stays on its own cell is still a click, and the click itself is what
+ places the cursor there.
+ """
+ if self._gesture is None:
+ origin = self._cells.key(widget)
+ if origin is not None:
+ self._gesture = DragGesture(
+ origin=origin,
+ extends=Modifier.SHIFT in capture_modifiers(),
+ )
+
+ return None
+
+ reached = self._cell_at()
+ if reached is None or (reached == self._gesture.origin and not self._gesture.moved):
+ return None
+
+ self._gesture.moved = True
+ return DragReach(
+ origin=self._gesture.origin,
+ reached=reached,
+ extends=self._gesture.extends,
+ )
+
+ def claims_click(self) -> bool:
+ """Whether the click reaching the grid ends a drag, which the drag then takes as its own.
+
+ A drag that comes back to the cell it started from releases there, and the release reports
+ a click; that click belongs to the drag, so the range dragged out stands and the gesture
+ ends here.
+ """
+ claimed = self._gesture is not None and self._gesture.moved
+ if claimed:
+ self._gesture = None
+
+ return claimed
+
+ def clear(self) -> None:
+ """Drops the gesture in hand, so the next press drags a selection out on its own."""
+ self._gesture = None
diff --git a/src/sampletones_application/ui/elements/table/selection.py b/src/sampletones_application/ui/elements/table/selection.py
new file mode 100644
index 00000000..14729cab
--- /dev/null
+++ b/src/sampletones_application/ui/elements/table/selection.py
@@ -0,0 +1,74 @@
+from collections.abc import Hashable
+from typing import Callable, FrozenSet, Generic, Optional, TypeVar
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.ui.elements.table.cells import EditableCells
+from sampletones_application.ui.elements.table.drag import DragReach, DragSelection
+from sampletones_shared.types.application import Sender
+
+KeyT = TypeVar("KeyT", bound=Hashable)
+
+
+class TableSelection(Generic[KeyT]):
+ """The selection a table shows, and the pointer gesture that draws it.
+
+ A grid states which of its cells the selection covers, in whatever coordinates it selects in;
+ which of them stand painted, and how far a held pointer has carried, are held here. A selected
+ cell is drawn by the selectable's own selected state, which the table's theme colours, so a
+ repaint reaches only the cells whose membership changed.
+ """
+
+ def __init__(
+ self,
+ *,
+ cells: EditableCells[KeyT],
+ cell_at: Callable[[], Optional[KeyT]],
+ covered: Callable[[], FrozenSet[KeyT]],
+ ) -> None:
+ self._cells = cells
+ self._covered = covered
+ self._drag: DragSelection[KeyT] = DragSelection(cells=cells, cell_at=cell_at)
+ self._painted: FrozenSet[KeyT] = frozenset()
+
+ def hold(self, widget: Sender) -> Optional[DragReach[KeyT]]:
+ """How far a held pointer has carried, which is what a drag grows the selection out to."""
+ return self._drag.hold(widget)
+
+ def claims_click(self, sender: Sender, key: KeyT) -> bool:
+ """Whether the click on a cell ends a drag, which the drag then takes as its own.
+
+ DearPyGui toggles a selectable as it reports the click, so the cell is released here and
+ dropped from what stands painted: the repaint that follows is what states whether the cell
+ belongs to the selection.
+ """
+ dpg.set_value(sender, False)
+ self._painted -= {key}
+ if not self._drag.claims_click():
+ return False
+
+ self.repaint()
+ return True
+
+ def drop_gesture(self) -> None:
+ """Drops the gesture in hand, the selection standing as it is."""
+ self._drag.clear()
+
+ def repaint(self) -> None:
+ """Marks the cells the selection now covers and releases the ones it has left."""
+ covered = self._covered()
+ for key in self._painted ^ covered:
+ widget = self._cells.widget(key)
+ if widget is not None:
+ dpg.set_value(widget, key in covered)
+
+ self._painted = covered
+
+ def reset(self) -> None:
+ """Forgets the selection and the gesture, which is what a rebuilt table asks for.
+
+ The cells a selection stood on belong to the body being replaced, so the paint is forgotten
+ with them and the grid states its selection onto the new cells afresh.
+ """
+ self._drag.clear()
+ self._painted = frozenset()
diff --git a/src/sampletones_application/ui/elements/table/table.py b/src/sampletones_application/ui/elements/table/table.py
index a5791b9c..b6a6eb65 100644
--- a/src/sampletones_application/ui/elements/table/table.py
+++ b/src/sampletones_application/ui/elements/table/table.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from typing import Dict, List, Optional, Tuple
+from typing import ClassVar, Dict, List, Optional, Tuple
import dearpygui.dearpygui as dpg
@@ -10,13 +10,15 @@
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.ui.themes.theme import Theme
from sampletones_application.utils.gui.dpg import dpg_delete_children
+from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
+from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_application.view_model.instruction.cell import TableCell
-from sampletones_shared.types.application import Color, Sender
+from sampletones_shared.types.application import Sender
from sampletones_shared.types.data import SerializedData
class GUITable:
- _REGISTRY: Dict[str, GUITable] = {}
+ _REGISTRY: ClassVar[Dict[str, GUITable]] = {}
def __init__(
self,
@@ -24,8 +26,8 @@ def __init__(
rows: Tuple[TableCell, ...],
*,
label_column_width: int,
- label_color: Color,
- value_color: Color,
+ label_color: BaseColor,
+ value_color: BaseColor,
parent: Optional[str] = None,
before: Optional[str] = None,
header_row: bool = False,
@@ -116,12 +118,12 @@ def _add_row(self, cell: TableCell) -> None:
label_text = dpg.add_text(cell.label)
label_font = Font.BOLD_SMALL if self._bold_labels else Font.REGULAR_SMALL
FontRegistry.bind_to_item(label_text, label_font)
- dpg.configure_item(label_text, color=self._label_color)
+ dpg_set_palette_color(label_text, self._label_color)
self._labels.append(label_text)
value_text = dpg.add_text(cell.value)
FontRegistry.bind_to_item(value_text, Font.REGULAR_SMALL)
- dpg.configure_item(value_text, color=self._value_color)
+ dpg_set_palette_color(value_text, self._value_color)
self._values.append(value_text)
@classmethod
diff --git a/src/sampletones_application/ui/elements/texture.py b/src/sampletones_application/ui/elements/texture.py
new file mode 100644
index 00000000..fe933c39
--- /dev/null
+++ b/src/sampletones_application/ui/elements/texture.py
@@ -0,0 +1,26 @@
+from typing import ClassVar, Dict
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.tags.general import TAG_GLOBAL_TEXTURE_LOGO
+from sampletones_application.ui.resources.items import IconResource
+from sampletones_application.ui.resources.resources import get_icon_path
+
+
+class TextureRegistry:
+ """Reads the images the interface draws into DearPyGui textures, the once at startup.
+
+ A texture is created before any window asks for it and stands for the whole run, so whatever draws
+ the application's mark names it by the tag it was created under.
+ """
+
+ _IMAGES: ClassVar[Dict[str, IconResource]] = {
+ TAG_GLOBAL_TEXTURE_LOGO: IconResource.UNIX,
+ }
+
+ @classmethod
+ def register_textures(cls) -> None:
+ with dpg.texture_registry():
+ for tag, resource in cls._IMAGES.items():
+ width, height, _channels, data = dpg.load_image(get_icon_path(resource))
+ dpg.add_static_texture(width, height, data, tag=tag)
diff --git a/src/sampletones_application/ui/elements/trace.py b/src/sampletones_application/ui/elements/trace.py
index 6390ea38..40d08639 100644
--- a/src/sampletones_application/ui/elements/trace.py
+++ b/src/sampletones_application/ui/elements/trace.py
@@ -1,7 +1,7 @@
from __future__ import annotations
import traceback
-from typing import Dict, Optional
+from typing import ClassVar, Dict, Optional
import dearpygui.dearpygui as dpg
@@ -23,7 +23,7 @@
class GUITraceback:
- _REGISTRY: Dict[str, GUITraceback] = {}
+ _REGISTRY: ClassVar[Dict[str, GUITraceback]] = {}
def __init__(
self,
diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py
new file mode 100644
index 00000000..2428bd6e
--- /dev/null
+++ b/src/sampletones_application/ui/elements/tree/browser.py
@@ -0,0 +1,285 @@
+from abc import ABC, abstractmethod
+from typing import AbstractSet, Dict
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
+)
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_BUTTON_COLLAPSE_ALL,
+ TAG_GLOBAL_THEME_SECONDARY_BUTTON,
+)
+from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.layout.collapse import CollapseAxis
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.tree.colors import TreeColors
+from sampletones_application.ui.elements.tree.handler import NodeHandler
+from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
+from sampletones_application.ui.elements.tree.state import TreeNodeState
+from sampletones_application.ui.elements.tree.tags import FileBrowserTags
+from sampletones_application.ui.elements.tree.tree import GUITreePanel
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.utils.gui.dpg import dpg_configure_item
+from sampletones_application.utils.parallelization.thread import concurrent
+from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree
+from sampletones_shared.types.callback import Callback, MessageCallback, VoidCallback
+
+
+class GUIFileBrowserPanel(GUITreePanel, ABC):
+ """Shared skeleton of a panel offering a tree of files as a collapsible, searchable card.
+
+ The card holds the controls bringing the tree up to date and folding it away, above the search box
+ and the tree it filters. This base builds that arrangement, rebuilds the tree off the main thread
+ on demand, and enables or disables the whole card as the tree locks and unlocks. A subclass
+ declares its widgets as a :class:`FileBrowserTags`, states what its card and its refresh control
+ read, answers what refreshing the model means, and shapes each row.
+
+ A browser whose rows carry favorites states ``_OFFERS_FAVORITES_FILTER``, which adds the control
+ showing those favorites alone to the card.
+ """
+
+ _REBUILD_ON_CREATE: bool = True
+ _OFFERS_FAVORITES_FILTER: bool = False
+
+ def __init__(
+ self,
+ tree: Tree,
+ tree_logic: TreeLogicProtocol,
+ *,
+ scheduling: SchedulingBehavior,
+ search_label: str,
+ language_manager: LanguageManager,
+ status_bar: GUIStatusBar,
+ colors: TreeColors,
+ initial_collapsed: bool,
+ initial_favorites_only: bool,
+ initial_expanded_rows: AbstractSet[str],
+ ) -> None:
+ self._lbl_collapse_all = language_manager["global.browser.label.collapse_all"]
+ self._msg_collapse_all = language_manager["global.status.message.collapse_all"]
+
+ super().__init__(
+ tree=tree,
+ tag=self._tags.panel,
+ tree_tag=self._tags.tree,
+ tree_logic=tree_logic,
+ scheduling=scheduling,
+ search_label=search_label,
+ language_manager=language_manager,
+ status_bar=status_bar,
+ colors=colors,
+ initial_favorites_only=initial_favorites_only,
+ initial_expanded_rows=initial_expanded_rows,
+ )
+
+ self._enable_horizontal_collapse(
+ initial_collapsed=initial_collapsed,
+ side=CollapseAxis.HORIZONTAL_LEFT,
+ )
+
+ @property
+ @abstractmethod
+ def _tags(self) -> FileBrowserTags:
+ """The tags naming this browser's widgets, which a panel states as a class attribute."""
+
+ @property
+ @abstractmethod
+ def section_label(self) -> str: ...
+
+ @property
+ @abstractmethod
+ def section_glyph(self) -> str: ...
+
+ @property
+ @abstractmethod
+ def refresh_button_label(self) -> str: ...
+
+ @property
+ @abstractmethod
+ def refresh_status_message(self) -> str: ...
+
+ def create_panel(self, parent: str) -> None:
+ """Builds the card, and fills the tree where the panel is the one reading its model.
+
+ A browser reading the filesystem shows its rows as it appears, while a catalogue filled by
+ the owner that gathers it waits for that reading to arrive.
+ """
+ self._setup_handlers()
+ with (
+ dpg.child_window(
+ tag=self.tag,
+ width=self.width,
+ height=self.height,
+ parent=parent,
+ border=False,
+ ),
+ self._collapsible_section(
+ self.section_label,
+ glyph=self.section_glyph,
+ ),
+ ):
+ self._create_controls()
+ dpg.add_separator()
+ self._create_tree_window()
+
+ self._create_detail_tooltip(self._tags.window_tree)
+ if self._REBUILD_ON_CREATE:
+ self.rebuild_tree()
+
+ def _create_controls(self) -> None:
+ """Offers the two controls every browser of files carries: bring it up to date, fold it away."""
+ with dpg.group(tag=self._tags.group_controls):
+ self._create_refresh_button()
+ self._create_collapse_all_button()
+
+ self._bind_refresh_message()
+
+ def _create_refresh_button(self) -> None:
+ GUIButton(
+ tag=self._tags.button_refresh,
+ label=self.refresh_button_label,
+ width=-1,
+ callback=self._on_refresh_clicked,
+ theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON),
+ )
+
+ def _create_collapse_all_button(self) -> None:
+ """Offers the control folding the whole tree away, reading as the utility the refresh one does."""
+ collapse_all_tag = compose_tag(self.tag, SUF_BUTTON_COLLAPSE_ALL)
+ GUIButton(
+ tag=collapse_all_tag,
+ label=self._lbl_collapse_all,
+ width=-1,
+ callback=self._on_collapse_all_clicked,
+ theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON),
+ )
+ self._status_bar.bind_to_item(
+ collapse_all_tag,
+ self._msg_collapse_all,
+ )
+
+ def _bind_refresh_message(self) -> None:
+ self._status_bar.bind_to_item(
+ self._tags.button_refresh,
+ self.refresh_status_message,
+ )
+
+ def _on_refresh_clicked(self) -> None:
+ """Answers the refresh control, by default with a rebuild of the tree as the model stands."""
+ self.rebuild_tree()
+
+ def _on_collapse_all_clicked(self) -> None:
+ """Folds every row of the tree away, leaving the reader the level the tree opens at.
+
+ The rows are reached through the model, so one pass covers a branch however deep it runs,
+ and the browser is told what each row now stands as.
+ """
+ root = self.tree.get_root()
+ if root is None:
+ return
+
+ for child in root.children:
+ self._set_subtree_expanded(child, expanded=False)
+
+ def _create_tree_window(self) -> None:
+ self.create_search(self._body_container)
+ if self._OFFERS_FAVORITES_FILTER:
+ self.create_favorites_filter(self._body_container)
+
+ with (
+ dpg.child_window(
+ tag=self._tags.window_tree,
+ horizontal_scrollbar=True,
+ ),
+ dpg.group(tag=self._tags.group_tree),
+ ):
+ self._create_tree_root()
+
+ def _create_tree_root(self) -> None:
+ """Opens the container every row attaches to, as a group the rows read directly under."""
+ with dpg.group(tag=self.tree_tag):
+ pass
+
+ def _create_tree_root_heading(self, label: str) -> None:
+ """Opens the root container as a labelled row the whole tree folds under."""
+ with dpg.tree_node(
+ label=label,
+ tag=self.tree_tag,
+ default_open=True,
+ ):
+ pass
+
+ def _create_file_system_handlers(
+ self,
+ *,
+ on_directory_clicked: Callback,
+ on_file_clicked: Callback,
+ on_file_double_clicked: Callback,
+ file_status_message: MessageCallback,
+ ) -> Dict[NodeType, NodeHandler]:
+ """The two rows a browser of files offers: a folder that expands, and a file it opens.
+
+ A folder row reads the same wherever it appears — the status bar says it expands — so the pair
+ is shaped here, and each browser states what a click on one of its own rows means.
+ """
+ return {
+ NodeType.DIRECTORY: NodeHandler(
+ tag=self._get_node_handler_tag(NodeType.DIRECTORY),
+ node_type=NodeType.DIRECTORY,
+ item_click_callback=on_directory_clicked,
+ status_bar_callback=self._create_status_bar_message_function_for_expandable_node(),
+ ),
+ NodeType.FILE: NodeHandler(
+ tag=self._get_node_handler_tag(NodeType.FILE),
+ node_type=NodeType.FILE,
+ item_click_callback=on_file_clicked,
+ item_double_click_callback=on_file_double_clicked,
+ status_bar_callback=file_status_message,
+ ),
+ }
+
+ def _mark_favorite_ancestry(
+ self,
+ node: FileSystemNode,
+ state: TreeNodeState,
+ ) -> None:
+ """Carries a favorite down the branch, so every row under one reads as part of it."""
+ state.has_favorite_ancestor |= self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(node)
+
+ def refresh(self) -> None:
+ self.rebuild_tree()
+
+ @concurrent(wait=False, method_bound=True)
+ def rebuild_tree(self) -> None:
+ self._launch_tree_rebuild(self._refresh_model)
+
+ @concurrent(wait=False, method_bound=True)
+ def redraw_tree(self) -> None:
+ self._launch_tree_rebuild(self._keep_model)
+
+ def _launch_tree_rebuild(self, refresh: VoidCallback) -> None:
+ """Fills the whole tree from the model ``refresh`` leaves behind, off the main thread."""
+ self._launch_rebuild(
+ refresh,
+ lambda: self._collect_specs(self.tree_tag),
+ root_tag=self.tree_tag,
+ on_finished=self._on_rebuild_finished,
+ )
+
+ @abstractmethod
+ def _refresh_model(self) -> None:
+ """Brings the model the tree renders up to date, on the background rebuild worker."""
+
+ def _keep_model(self) -> None:
+ """Leaves the model as the last refresh brought it, which is what a redraw reads."""
+
+ def _on_rebuild_finished(self) -> None:
+ """Runs on the main thread with the rows on screen, where a browser reads something out."""
+
+ def set_tree_enabled(self, enabled: bool) -> None:
+ dpg_configure_item(self._tags.group_tree, enabled=enabled)
+ dpg_configure_item(self._tags.group_controls, enabled=enabled)
+ self.set_favorites_filter_enabled(enabled)
diff --git a/src/sampletones_application/ui/elements/tree/colors.py b/src/sampletones_application/ui/elements/tree/colors.py
index 8c8fb19b..abff8d57 100644
--- a/src/sampletones_application/ui/elements/tree/colors.py
+++ b/src/sampletones_application/ui/elements/tree/colors.py
@@ -1,8 +1,8 @@
from dataclasses import dataclass
from typing import Self
-from sampletones_application.layout.general.colors import GeneralColors
-from sampletones_shared.types.application import ColorRGBA
+from sampletones_application.layout.general.colors.colors import GeneralColors
+from sampletones_application.utils.palette.colors.base import BaseColor
@dataclass(frozen=True)
@@ -13,13 +13,13 @@ class TreeColors:
per browser, while the others are shared across browsers.
"""
- favorite: ColorRGBA
- node: ColorRGBA
- muted: ColorRGBA
- accent: ColorRGBA
+ favorite: BaseColor
+ node: BaseColor
+ muted: BaseColor
+ accent: BaseColor
@classmethod
- def create(cls, colors: GeneralColors, *, accent: ColorRGBA) -> Self:
+ def create(cls, colors: GeneralColors, *, accent: BaseColor) -> Self:
"""Assigns shared palette entries to tree roles; only ``accent`` differs between browsers.
Defining the shared mapping in one place keeps every browser's favorite/node/muted colors
diff --git a/src/sampletones_application/ui/elements/tree/emitter.py b/src/sampletones_application/ui/elements/tree/emitter.py
index 4ef94a8d..3ca79119 100644
--- a/src/sampletones_application/ui/elements/tree/emitter.py
+++ b/src/sampletones_application/ui/elements/tree/emitter.py
@@ -3,7 +3,7 @@
import dearpygui.dearpygui as dpg
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.tree.spec import NodeSpec
from sampletones_application.ui.themes.registry import ThemeRegistry
diff --git a/src/sampletones_application/ui/elements/tree/expansion.py b/src/sampletones_application/ui/elements/tree/expansion.py
new file mode 100644
index 00000000..3143afc0
--- /dev/null
+++ b/src/sampletones_application/ui/elements/tree/expansion.py
@@ -0,0 +1,75 @@
+from threading import RLock
+from typing import AbstractSet, Set
+
+
+class RowExpansionMemory:
+ """The rows a browser stands open, held apart by the hand that opened them.
+
+ The reader's rows are the ones they opened themselves, and they are the shape a session writes down.
+ The mode's rows are the way down the favorites mode opened on the pass the reader asked for, which
+ stands for as long as the mode does. Each row is held by the tag it is addressed under, a pass
+ replacing every node the model states.
+
+ A pass writes from the tree worker while a click writes from the main thread, so one lock covers
+ every answer the memory gives.
+ """
+
+ def __init__(self, reader_rows: AbstractSet[str]) -> None:
+ self._lock = RLock()
+ self._reader_rows: Set[str] = set(reader_rows)
+ self._mode_rows: Set[str] = set()
+
+ def __bool__(self) -> bool:
+ with self._lock:
+ return bool(self._reader_rows or self._mode_rows)
+
+ @property
+ def rows(self) -> Set[str]:
+ """The rows the reader stands open, which is the shape a session writes down."""
+ with self._lock:
+ return set(self._reader_rows)
+
+ @property
+ def follows_the_mode(self) -> bool:
+ """Whether the mode's way down stands open, which is what a release has rows to answer for."""
+ with self._lock:
+ return bool(self._mode_rows)
+
+ def stands_open(self, node_tag: str) -> bool:
+ with self._lock:
+ return node_tag in self._reader_rows or node_tag in self._mode_rows
+
+ def remember(self, node_tag: str, *, expanded: bool) -> None:
+ """Holds what the reader left a row standing as, which is theirs from then on.
+
+ A row they fold is theirs to fold whichever hand opened it, so folding it lets go of the mode's
+ claim on it as well and the row stays folded.
+ """
+ with self._lock:
+ if expanded:
+ self._reader_rows.add(node_tag)
+ return
+
+ self._reader_rows.discard(node_tag)
+ self._mode_rows.discard(node_tag)
+
+ def follow(self, way_down: AbstractSet[str]) -> None:
+ """Notes the way down a pass opened, which stands open for as long as the mode does."""
+ with self._lock:
+ self._mode_rows |= way_down
+
+ def release(self, ways_down: AbstractSet[str]) -> None:
+ """Folds the rows the mode opened, keeping the ones a row of the reader's stands on.
+
+ A row of the mode's holding one of theirs below it holds theirs on the screen, and it therefore
+ becomes the reader's to keep. What is left held the mode's opening alone, and folds with it.
+ """
+ with self._lock:
+ self._reader_rows |= self._mode_rows & ways_down
+ self._mode_rows = set()
+
+ def hold_to(self, rows: AbstractSet[str]) -> None:
+ """Holds both memories to the rows given, a row held beyond them having left the model."""
+ with self._lock:
+ self._reader_rows &= rows
+ self._mode_rows &= rows
diff --git a/src/sampletones_application/ui/elements/tree/filter.py b/src/sampletones_application/ui/elements/tree/filter.py
new file mode 100644
index 00000000..310ea91a
--- /dev/null
+++ b/src/sampletones_application/ui/elements/tree/filter.py
@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+from typing import Final
+
+
+@dataclass(frozen=True)
+class TreeFilter:
+ """What a browser is currently asked to show, held by the panel showing it.
+
+ Several browsers render one tree, so what each of them narrows to belongs to the panel: a query
+ typed in one tab leaves the other reading as it was. A filter is stated whole and replaced whole,
+ so the panel resolves what it shows in one place.
+
+ The two criteria answer different questions: the query decides which of the rows on screen are
+ shown, while showing favorites alone decides which rows are drawn at all.
+ """
+
+ query: str
+ favorites_only: bool
+
+ @property
+ def is_active(self) -> bool:
+ """Whether the filter narrows what the browser shows."""
+ return bool(self.query) or self.favorites_only
+
+ def with_query(self, query: str) -> TreeFilter:
+ """The filter reading a new query, keeping everything else it states."""
+ return replace(self, query=query)
+
+ def with_favorites_only(self, favorites_only: bool) -> TreeFilter:
+ """The filter showing the favorites alone or the whole tree, keeping the query it states."""
+ return replace(self, favorites_only=favorites_only)
+
+
+NO_FILTER: Final[TreeFilter] = TreeFilter(
+ query="",
+ favorites_only=False,
+)
diff --git a/src/sampletones_application/ui/elements/tree/protocol.py b/src/sampletones_application/ui/elements/tree/protocol.py
index eea8262d..83aa4dd1 100644
--- a/src/sampletones_application/ui/elements/tree/protocol.py
+++ b/src/sampletones_application/ui/elements/tree/protocol.py
@@ -15,6 +15,12 @@ class TreeLogicProtocol(Protocol):
@property
def autoplay_enabled(self) -> bool: ...
+ @property
+ def auto_expand_favorite_reconstructions(self) -> bool: ...
+
+ @property
+ def auto_expand_favorite_directories(self) -> bool: ...
+
@property
def locked(self) -> bool: ...
diff --git a/src/sampletones_application/ui/elements/tree/tag.py b/src/sampletones_application/ui/elements/tree/tag.py
new file mode 100644
index 00000000..c4e375e8
--- /dev/null
+++ b/src/sampletones_application/ui/elements/tree/tag.py
@@ -0,0 +1,29 @@
+from typing import Final
+
+from sampletones_application.tags.compose import compose_tag
+from sampletones_core.structures.tree import TreeNode
+from sampletones_shared.utils.serialization import calculate_hash
+
+NODE_TAG_DIGEST_LENGTH: Final[int] = 8
+
+_IDENTITY_SEPARATOR: Final[str] = "\x00"
+
+
+def compose_node_tag(node: TreeNode, *, panel_tag: str) -> str:
+ """Composes the widget tag of one tree row: readable by the names above it, unique by its path.
+
+ The names read the row back to whoever inspects the widget tree, and the digest states the exact
+ path — each ancestor's node type together with its name — so every row the names alone spell
+ alike keeps a tag of its own: a folder and the audio beside it, or two labels differing only in
+ spacing or case. The separator the digest joins on is one the disk gives no name, which is what
+ makes one identity reach one digest.
+ """
+ names = "_".join(str(ancestor.name) for ancestor in node.path)
+ return compose_tag(panel_tag, f"node_{names}", _node_digest(node))
+
+
+def _node_digest(node: TreeNode) -> str:
+ identity = _IDENTITY_SEPARATOR.join(
+ part for ancestor in node.path for part in (ancestor.node_type.value, str(ancestor.name))
+ )
+ return calculate_hash(identity, length=NODE_TAG_DIGEST_LENGTH)
diff --git a/src/sampletones_application/ui/elements/tree/tags.py b/src/sampletones_application/ui/elements/tree/tags.py
new file mode 100644
index 00000000..d9c65ea7
--- /dev/null
+++ b/src/sampletones_application/ui/elements/tree/tags.py
@@ -0,0 +1,19 @@
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class FileBrowserTags:
+ """The DearPyGui tags naming one file browser's widgets, stated together where the browser is declared.
+
+ Every browser builds the same arrangement — a panel card holding a controls group with a refresh
+ button, and a window holding the group the tree attaches to — so the tags naming those widgets
+ are one value the panel declares beside its class. Stating them together makes each browser name
+ a complete set at one place, checked where it is written.
+ """
+
+ panel: str
+ tree: str
+ window_tree: str
+ group_tree: str
+ group_controls: str
+ button_refresh: str
diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py
index c84f6804..f63f5db6 100644
--- a/src/sampletones_application/ui/elements/tree/tree.py
+++ b/src/sampletones_application/ui/elements/tree/tree.py
@@ -1,25 +1,41 @@
from abc import ABC, abstractmethod
from functools import partial
from pathlib import Path
-from typing import Any, Callable, Dict, List, Optional, Tuple, Union
+from typing import (
+ AbstractSet,
+ Any,
+ Callable,
+ Dict,
+ Final,
+ FrozenSet,
+ List,
+ Optional,
+ Sequence,
+ Set,
+ Tuple,
+ Union,
+)
import dearpygui.dearpygui as dpg
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
+)
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
SUF_BUTTON_SEARCH,
+ SUF_CHECKBOX_FAVORITES,
SUF_HANDLER_DETAIL_TOOLTIP,
SUF_HANDLER_NODE,
SUF_INPUT_SEARCH,
+ SUF_TEXT_FAVORITES,
SUF_TOOLTIP_DETAIL,
TAG_GLOBAL_THEME_DEFAULT,
TAG_GLOBAL_THEME_FAVORITE,
TAG_GLOBAL_THEME_FAVORITE_CHILD,
TAG_GLOBAL_THEME_FILE_LIBRARY,
TAG_GLOBAL_THEME_FILE_NO_CONTENT,
- TAG_GLOBAL_THEME_FILE_NOT_EXPANDED_DIRECTORY,
TAG_GLOBAL_THEME_FILE_RECONSTRUCTION,
TAG_GLOBAL_THEME_FILE_WAVE,
TAG_GLOBAL_THEME_TREE_WINDOW,
@@ -31,34 +47,43 @@
TAG_INSTRUCTIONS_LIBRARY_THEME_INSTRUCTION,
)
from sampletones_application.ui.elements.button import GUIButton
-from sampletones_application.ui.elements.context_menu import add_play_menu_item
+from sampletones_application.ui.elements.context_menu import (
+ add_detail_items,
+ add_play_menu_item,
+)
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.ui.elements.tree.colors import TreeColors
from sampletones_application.ui.elements.tree.emitter import TreeEmitter
+from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory
+from sampletones_application.ui.elements.tree.filter import NO_FILTER, TreeFilter
from sampletones_application.ui.elements.tree.handler import NodeHandler
from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
from sampletones_application.ui.elements.tree.spec import NodeSpec
from sampletones_application.ui.elements.tree.state import TreeNodeState
+from sampletones_application.ui.elements.tree.tag import compose_node_tag
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.callbacks.queue import CallbackQueue
from sampletones_application.utils.gui.dpg import (
dpg_configure_item,
dpg_get_value,
dpg_is_item_hovered,
+ dpg_set_value,
)
+from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
from sampletones_application.utils.gui.tooltip import (
create_detail_tooltip,
populate_detail_tooltip,
)
+from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_application.utils.parallelization.thread import (
BackgroundWorkCancelled,
SingleThreadExecutor,
)
-from sampletones_core import paths
from sampletones_core.configs.display import (
+ format_generators,
format_nes_frequency,
format_sample_rate,
format_spectrum_method,
@@ -67,13 +92,17 @@
from sampletones_core.library import InstructionLibraryKey
from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from sampletones_core.structures.tree import (
+ ConfigNode,
FileSystemNode,
LibraryNode,
NodeType,
Tree,
TreeNode,
+ TreeVisibility,
+ resolve_visibility,
)
-from sampletones_shared.types.application import ColorRGBA, Sender
+from sampletones_shared.paths import extensions
+from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import (
Callback,
MessageCallback,
@@ -82,11 +111,14 @@
)
from sampletones_shared.utils.system.paths import open_path_in_explorer
+NO_EXPANDED_ROWS: Final[FrozenSet[str]] = frozenset()
+
class GUITreePanel(GUIPanel, ABC):
_NAME_FONT: Font = Font.REGULAR_SMALL
_CONFIG_FONT: Font = Font.MONO_SMALL
_MONOSPACE_CONFIG_NODES: bool = False
+ _REMEMBERS_EXPANSION: bool = False
def __init__(
self,
@@ -94,14 +126,14 @@ def __init__(
tag: str,
tree_tag: str,
tree_logic: TreeLogicProtocol,
- width: int = -1,
- height: int = -1,
*,
scheduling: SchedulingBehavior,
search_label: str,
language_manager: LanguageManager,
status_bar: GUIStatusBar,
colors: TreeColors,
+ initial_favorites_only: bool,
+ initial_expanded_rows: AbstractSet[str],
) -> None:
self._language_manager = language_manager
self._logic = tree_logic
@@ -111,11 +143,19 @@ def __init__(
self.tree_tag = tree_tag
self._pending_specs: List[NodeSpec] = []
+ self._expansion = RowExpansionMemory(initial_expanded_rows)
self._emitter = TreeEmitter(scheduling=scheduling)
+ self._filter: TreeFilter = NO_FILTER.with_favorites_only(initial_favorites_only)
+ self._search_visibility: Optional[TreeVisibility] = None
+ self._favorites_visibility: Optional[TreeVisibility] = None
+ self._auto_expand_pending: bool = False
+
self._selected_node_tag: Optional[Union[str, int]] = None
self._search_input_tag: Optional[str] = None
self._search_button_tag: Optional[str] = None
+ self._favorites_checkbox_tag: Optional[str] = None
+ self._favorites_glyph_tag: Optional[str] = None
self._detail_tooltip_tag = compose_tag(tag, SUF_TOOLTIP_DETAIL)
self._detail_tooltip_handler_tag = compose_tag(tag, SUF_HANDLER_DETAIL_TOOLTIP)
@@ -135,6 +175,7 @@ def __init__(
self._lbl_detail_generators = language_manager["global.context.label.detail_generators"]
self._lbl_detail_configuration = language_manager["global.context.label.detail_configuration"]
+ self.on_favorites_filter_changed: Optional[Callable[[str, bool], None]] = None
self.on_add_to_sequencer: Optional[PathCallback] = None
self.can_add_to_sequencer: Optional[Callable[[], bool]] = None
self.on_replace_in_sequencer: Optional[PathCallback] = None
@@ -143,8 +184,8 @@ def __init__(
super().__init__(
tag=tag,
- width=width,
- height=height,
+ width=-1,
+ height=-1,
)
def _launch_rebuild(
@@ -161,9 +202,9 @@ def _launch_rebuild(
1. A rebuild already in flight holds the lock, so return and let it finish.
2. Acquire the lock; responsibility for releasing it passes to the emit pipeline.
- 3. ``refresh`` updates the model, then ``collect`` resolves it into a flat
- :class:`NodeSpec` list -- every per-node decision, including the filesystem
- content check, happens here on the worker.
+ 3. ``refresh`` updates the model and the filter is resolved against it, then
+ ``collect`` resolves it into a flat :class:`NodeSpec` list -- every per-node
+ decision, including the filesystem content check, happens here on the worker.
4. Post the specs to :class:`TreeEmitter` through the queue. This crosses back to
the main thread, where the emitter clears the old tree and stages the new nodes
across frames.
@@ -179,12 +220,18 @@ def _launch_rebuild(
handed_off = False
try:
refresh()
+ self._resolve_filter()
specs = collect()
CallbackQueue.add(
self._emitter.emit,
tuple(specs),
root_tag,
- partial(self._finish_emit, root_tag, on_finished),
+ partial(
+ self._finish_emit,
+ root_tag,
+ on_finished,
+ len(specs),
+ ),
priority=self._scheduling.emit.priority,
)
handed_off = True
@@ -199,8 +246,23 @@ def _collect_specs(self, root_tag: str) -> List[NodeSpec]:
if root is not None:
self._build_tree_node(root, state=TreeNodeState(parent=root_tag))
+ self._forget_rows_the_model_dropped()
return self._pending_specs
+ def _forget_rows_the_model_dropped(self) -> None:
+ """Holds the memory of open rows to the rows the model states, read afresh on every pass.
+
+ A row the memory holds beyond the rows the model states belongs to a folder the disk has lost,
+ so its place goes with it. The model is what the answer is read from, so a browser opening in the
+ favorites mode — or opening on a session written before the reconstructions directory moved —
+ drops what is gone.
+ """
+ root = self.tree.get_root()
+ if root is None or not self._expansion:
+ return
+
+ self._expansion.hold_to({self._generate_node_tag(node) for node in root.descendants if node.children})
+
def create_search(self, parent: str) -> None:
self._search_input_tag = compose_tag(self.tag, SUF_INPUT_SEARCH)
self._search_button_tag = compose_tag(self.tag, SUF_BUTTON_SEARCH)
@@ -228,6 +290,104 @@ def create_search(self, parent: str) -> None:
self._language_manager["global.status.message.clear_search"],
)
+ def create_favorites_filter(self, parent: str) -> None:
+ """Builds the control showing the favorites alone, as a row of its own under the search box.
+
+ The checkbox carries the label, so the words are part of what the reader clicks, and the star
+ beside it reads in the colour the mode it stands for is drawn in. The label reads in the pair
+ every checkbox reads — the text colour while the control is live, the muted one while a
+ rebuild holds it — so the shade states whether the control can be acted on.
+ """
+ self._favorites_checkbox_tag = compose_tag(self.tag, SUF_CHECKBOX_FAVORITES)
+ self._favorites_glyph_tag = compose_tag(self.tag, SUF_TEXT_FAVORITES)
+
+ with dpg.group(horizontal=True, parent=parent):
+ dpg.add_checkbox(
+ tag=self._favorites_checkbox_tag,
+ label=self._language_manager["global.browser.label.favorites_only"],
+ default_value=self._filter.favorites_only,
+ callback=self._on_favorites_only_changed,
+ )
+ dpg.add_text(
+ self._glyphs.common.favorite,
+ tag=self._favorites_glyph_tag,
+ )
+
+ FontRegistry.bind_to_item(self._favorites_glyph_tag, Font.ICON)
+ self._apply_favorites_glyph_color()
+ self._status_bar.bind_to_item(
+ self._favorites_checkbox_tag,
+ self._language_manager["global.status.message.favorites_only"],
+ )
+
+ def _on_favorites_only_changed(
+ self,
+ _sender: Sender,
+ favorites_only: bool,
+ ) -> None:
+ """Takes the mode the control now reads, and draws the rows that mode names.
+
+ The rebuild resolves the filter against the model as it collects the rows, so the mode is
+ stated here and answered there, and turning it on walks the model once.
+ """
+ self._state_favorites_only(favorites_only)
+ self._apply_favorites_glyph_color()
+ self.call(
+ self.on_favorites_filter_changed,
+ self.tag,
+ favorites_only,
+ )
+ self.redraw_tree()
+
+ def _apply_favorites_glyph_color(self) -> None:
+ """Colours the star by the mode the control reads, wherever the browser offers one."""
+ if self._favorites_glyph_tag is None:
+ return
+
+ dpg_set_palette_color(self._favorites_glyph_tag, self._favorites_glyph_color())
+
+ def _favorites_glyph_color(self) -> BaseColor:
+ """The colour the star takes: the favorite colour while the mode is on, muted while it is off."""
+ if self._filter.favorites_only:
+ return self._colors.favorite
+
+ return self._colors.muted
+
+ def set_favorites_filter_enabled(self, enabled: bool) -> None:
+ """Follows the tree's lock through to the control, which asks for a rebuild of that tree."""
+ if self._favorites_checkbox_tag is None:
+ return
+
+ dpg_configure_item(self._favorites_checkbox_tag, enabled=enabled)
+
+ def _state_favorites_only(self, favorites_only: bool) -> None:
+ """Takes the mode the reader switched to, asking the pass it starts to follow the stars.
+
+ Switching the mode on is the reader asking to be shown their favorites, so the pass it starts
+ opens the way down to them and notes which rows it opened. That way stands open for as long as
+ the mode does, so a refresh, a query or a star gained meanwhile leaves the reader looking at
+ their favorites. Switching the mode off is answered by the pass that follows it as well, which
+ hands those rows back.
+ """
+ self._filter = self._filter.with_favorites_only(favorites_only)
+ self._auto_expand_pending = favorites_only
+
+ def _release_mode_rows(self) -> None:
+ """Hands back the rows the favorites mode opened, which the memory holds the rule for.
+
+ The rows the reader stands open are read off the model, and the way down to each of them is what
+ the mode keeps standing however deep theirs stands. The model is read on the pass that finds the
+ mode off, on the tree worker.
+ """
+ root = self.tree.get_root()
+ if root is None or not self._expansion.follows_the_mode:
+ return
+
+ reader_rows = self._expansion.rows
+ self._expansion.release(
+ self._way_down_to([node for node in root.descendants if self._generate_node_tag(node) in reader_rows]),
+ )
+
def _get_node_handler_tag(self, node_type: NodeType) -> str:
return compose_tag(self.tag, node_type.value, SUF_HANDLER_NODE)
@@ -241,22 +401,28 @@ def _append_spec(
open_on_double_click: bool = False,
should_expand: bool = False,
has_favorite_ancestor: bool = False,
- is_node_expanded: bool = False,
) -> None:
"""Resolve a node into a :class:`NodeSpec` and record it for emission.
Runs on the background traversal worker, so the theme and handler tags — including the
directory content check that touches the filesystem — are chosen here, off the main
thread. A shutdown request raises to unwind the traversal promptly.
+
+ Which rows are recorded is the favorites mode's to state, and it shows a row together with
+ every row above it: a row it holds back therefore stands above rows it holds back too, so one
+ decision covers the whole subtree and the traversal walks on.
"""
if SingleThreadExecutor.is_shutting_down():
raise BackgroundWorkCancelled
+ if not self._is_node_drawn(node):
+ return
+
theme_tag = self._resolve_node_theme_tag(
node,
has_favorite_ancestor=has_favorite_ancestor,
- is_node_expanded=is_node_expanded,
)
+ stands_open = should_expand or self._expansion.stands_open(node_tag)
self._pending_specs.append(
NodeSpec(
node=node,
@@ -267,37 +433,71 @@ def _append_spec(
leaf=leaf,
open_on_arrow=open_on_arrow,
open_on_double_click=open_on_double_click,
- should_expand=should_expand,
+ should_expand=stands_open,
theme_tag=theme_tag,
handler_tag=self._node_handlers[node.node_type].tag,
)
)
- def _finish_emit(self, root_tag: str, on_finished: Optional[VoidCallback]) -> None:
+ @property
+ def expanded_rows(self) -> Set[str]:
+ return self._expansion.rows
+
+ def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None:
+ """Folds or unfolds the row together with every row below it holding something.
+
+ Each row is reached by the tag it was built under and set directly, and the browser is told
+ what it now stands as, so a rebuild brings the whole subtree back the way this left it.
+ """
+ for container in (node, *node.descendants):
+ if container.children:
+ node_tag = self._generate_node_tag(container)
+ dpg_set_value(node_tag, expanded)
+ self._expansion.remember(node_tag, expanded=expanded)
+
+ def _finish_emit(
+ self,
+ root_tag: str,
+ on_finished: Optional[VoidCallback],
+ drawn_rows: int,
+ ) -> None:
"""Complete a rebuild on the main thread: show the empty state, run the hook, unlock.
- The emitter runs this once its last batch has attached. When a filtered tree
- resolved to an empty model, the no-results message fills the cleared tree so the
- filter outcome is visible. Applying the filter here lets late-emitted nodes honour
+ The emitter runs this once its last batch has attached. A filtered rebuild that drew no
+ row fills the cleared tree with the message naming that outcome, so the filter's answer is
+ legible where the rows would be. Applying the filter here lets late-emitted nodes honour
an active search, and releasing the lock hands control back to interactive rebuilds.
"""
- if root_tag == self.tree_tag and self.tree.is_filtered() and self.tree.get_root() is None:
- dpg.add_text(self._language_manager["global.dialog.message.tree_no_results"], parent=root_tag)
+ if root_tag == self.tree_tag and self._filter.is_active and not drawn_rows:
+ dpg.add_text(
+ self._empty_filter_message(),
+ parent=root_tag,
+ )
if on_finished is not None:
on_finished()
- if self.tree.is_filtered():
+ if self._filter.query:
self.update_tree_visibility()
self.unlock()
+ def _empty_filter_message(self) -> str:
+ """Names the filter a rebuild came back empty from: the favorites mode, or the search."""
+ return self._language_manager[
+ (
+ "global.dialog.message.tree_no_favorites"
+ if self._filter.favorites_only
+ else "global.dialog.message.tree_no_results"
+ )
+ ]
+
def _create_hover_callback(
self,
status_bar_callback: Optional[MessageCallback],
) -> Callback:
def hover_callback(
- sender: Sender,
+ _sender: Sender,
app_data: int,
) -> None:
user_data = dpg.get_item_user_data(app_data)
@@ -347,7 +547,11 @@ def _hide_detail_tooltip(self) -> None:
self._detail_tooltip_owner_tag = None
dpg_configure_item(self._detail_tooltip_tag, show=False)
- def _on_detail_tooltip_mouse_move(self, sender: Sender, app_data: Any) -> None:
+ def _on_detail_tooltip_mouse_move(
+ self,
+ _sender: Sender,
+ _app_data: Any,
+ ) -> None:
owner_tag = self._detail_tooltip_owner_tag
if owner_tag is None:
return
@@ -365,8 +569,10 @@ def single_click_callback(
app_data: Tuple[int, int],
) -> None:
user_data = dpg.get_item_user_data(app_data[1])
+ self._remember_clicked_row(user_data)
if item_click_callback is not None:
item_click_callback(sender, app_data, user_data=user_data)
+
if status_bar_callback is not None:
self._status_bar.set(status_bar_callback, user_data=user_data)
@@ -382,10 +588,42 @@ def double_click_callback(
) -> None:
user_data = dpg.get_item_user_data(app_data[1])
if item_double_click_callback is not None:
- item_double_click_callback(sender, app_data, user_data=user_data)
+ item_double_click_callback(
+ sender,
+ app_data,
+ user_data=user_data,
+ )
return double_click_callback
+ def _remember_clicked_row(self, user_data: Any) -> None:
+ """Follows a click through to what it left the row standing as, a frame after it landed.
+
+ A click on a row the reader can open is how that row folds and unfolds, and the row states
+ its own answer once the frame carrying the click has drawn. Reading it the frame after
+ therefore reports what the reader did, whichever button they pressed, and a row holding
+ nothing has nothing to remember.
+ """
+ if not self._REMEMBERS_EXPANSION or not isinstance(user_data, tuple):
+ return
+
+ node, node_tag = user_data
+ if not node.children:
+ return
+
+ CallbackQueue.add(
+ self._read_row_expansion,
+ node_tag,
+ delay=1,
+ )
+
+ def _read_row_expansion(self, node_tag: str) -> None:
+ """Takes the state a row stands in into the memory, on the main thread that owns the row."""
+ if not dpg.does_item_exist(node_tag):
+ return
+
+ self._expansion.remember(node_tag, expanded=bool(dpg_get_value(node_tag)))
+
def _setup_handlers(self) -> None:
for handler in self._node_handlers.values():
with dpg.item_handler_registry(tag=handler.tag):
@@ -426,14 +664,13 @@ def _build_tree_node(
def _has_relevant_content(self, node: TreeNode) -> bool: ...
def _should_expand_node(self, node: TreeNode) -> bool:
- if not self.tree.is_filtered():
- return False
+ """Whether the search points at the row, which a match and every row above one is.
- for descendant in node.descendants:
- if self.tree.is_node_visible(descendant):
- return True
-
- return False
+ A search names the rows whose label matched and shows what each of them gathers, so a folder it
+ named opens, for as long as the query stands. What the favorites mode opens is its memory to
+ answer, read as each row is created.
+ """
+ return self._search_visibility is not None and self._search_visibility.should_expand(node)
def _create_status_bar_message_function(
self,
@@ -444,7 +681,7 @@ def _create_status_bar_message_function(
def _create_status_bar_message_function_for_reconstruction_node(
self,
) -> MessageCallback:
- def message_function(*args: Any, **kwargs: Any) -> str:
+ def message_function(*_args: Any, **_kwargs: Any) -> str:
if self._logic.autoplay_enabled:
return self._language_manager["global.status.message.node_reconstruction"]
@@ -457,25 +694,41 @@ def _create_status_bar_message_function_for_library_node(
) -> MessageCallback:
return self._create_status_bar_message_function(self._language_manager["global.status.message.node_library"])
- def _create_status_bar_message_function_for_directory_node(
+ def _create_status_bar_message_function_for_expandable_node(
self,
) -> MessageCallback:
- def message_function(*args: Any, user_data: Tuple[FileSystemNode, str], **kwargs: Any) -> str:
- _, node_tag = user_data
+ """Builds the hover message of a row the reader opens, naming what that row holds.
+
+ A folder, a group and a sample are all opened the same way and hold different things, so the
+ message follows the node it is asked about: the sample names the reconstructions it gathers.
+ """
+
+ def message_function(
+ *_args: Any,
+ user_data: Tuple[TreeNode, str],
+ **_kwargs: Any,
+ ) -> str:
+ node, node_tag = user_data
expand_or_collapse = (
self._language_manager["global.dialog.template.collapse"]
if dpg_get_value(node_tag)
else self._language_manager["global.dialog.template.expand"]
)
- return self._language_manager["global.status.message.node_directory"].format(
- expand_or_collapse=expand_or_collapse
- )
+ return self._expandable_node_message(node).format(expand_or_collapse=expand_or_collapse)
return self._create_status_bar_message_function(message_function)
+ def _expandable_node_message(self, node: TreeNode) -> str:
+ match node.node_type:
+ case NodeType.SAMPLE:
+ return self._language_manager["global.status.message.node_sample"]
+ case NodeType.GROUP:
+ return self._language_manager["global.status.message.node_group"]
+
+ return self._language_manager["global.status.message.node_directory"]
+
def _generate_node_tag(self, node: TreeNode) -> str:
- path_parts = [ancestor.name for ancestor in node.path]
- return compose_tag(self.tag, f"node_{'_'.join(path_parts)}")
+ return compose_node_tag(node, panel_tag=self.tag)
def _context_menu_header_name(self, node: TreeNode) -> str:
"""Returns the raw on-disk identifier, complementing the friendly label shown in the tree."""
@@ -487,7 +740,7 @@ def _context_menu_header_name(self, node: TreeNode) -> str:
return str(node.name)
- def _node_header_color(self, node: TreeNode) -> ColorRGBA:
+ def _node_header_color(self, node: TreeNode) -> BaseColor:
if self._logic.is_node_favorite(node):
return self._colors.favorite
@@ -514,22 +767,27 @@ def _add_context_menu_text(self, node: TreeNode) -> None:
with dpg.group(horizontal=True):
if is_favorite:
- star_text = dpg.add_text(self._glyphs.common.favorite, color=color)
+ star_text = dpg.add_text(self._glyphs.common.favorite)
+ dpg_set_palette_color(star_text, color)
FontRegistry.bind_to_item(star_text, Font.ICON)
- text = dpg.add_text(self._context_menu_header_name(node), color=color)
+ text = dpg.add_text(self._context_menu_header_name(node))
+ dpg_set_palette_color(text, color)
FontRegistry.bind_to_item(text, Font.BOLD)
def _node_detail_items(self, node: TreeNode) -> List[Tuple[str, str]]:
match node:
case LibraryNode():
return self._library_detail_items(node.library_key)
- case FileSystemNode() if node.node_type == NodeType.DIRECTORY:
- return self._reconstruction_detail_items(node.filepath.name)
+ case ConfigNode():
+ return self._reconstruction_detail_items(node.config)
return []
- def _library_detail_items(self, key: InstructionLibraryKey) -> List[Tuple[str, str]]:
+ def _library_detail_items(
+ self,
+ key: InstructionLibraryKey,
+ ) -> List[Tuple[str, str]]:
nes_frequency = round(key.sample_rate / key.frame_length)
return [
(self._lbl_detail_sample_rate, format_sample_rate(key.sample_rate)),
@@ -540,37 +798,34 @@ def _library_detail_items(self, key: InstructionLibraryKey) -> List[Tuple[str, s
(self._lbl_detail_configuration, short_hash(key.config_hash)),
]
- def _reconstruction_detail_items(self, directory_name: str) -> List[Tuple[str, str]]:
- fields = ConfigDirectoryFields.from_directory_name(directory_name)
- if fields is None:
- return []
-
- generators = ", ".join(generator.capitalized for generator in fields.generators)
+ def _reconstruction_detail_items(
+ self,
+ fields: ConfigDirectoryFields,
+ ) -> List[Tuple[str, str]]:
return [
(self._lbl_detail_sample_rate, format_sample_rate(fields.sr)),
(self._lbl_detail_nes_frequency, format_nes_frequency(fields.nf)),
(self._lbl_detail_spectrum_method, format_spectrum_method(fields.sm)),
(self._lbl_detail_transformation_gamma, str(fields.tg)),
- (self._lbl_detail_generators, generators),
+ (self._lbl_detail_generators, format_generators(fields.generators)),
(self._lbl_detail_configuration, short_hash(fields.ch)),
]
def _add_context_menu_details(self, node: TreeNode) -> None:
- detail_items = self._node_detail_items(node)
- if not detail_items:
- return
-
- dpg.add_separator()
- for label, value in detail_items:
- detail_text = dpg.add_text(f"{label}: {value}", color=self._colors.muted)
- FontRegistry.bind_to_item(detail_text, Font.MONO_SMALL)
+ add_detail_items(
+ self._node_detail_items(node),
+ color=self._colors.muted,
+ )
def _add_context_menu_play_item(self, node: FileSystemNode) -> None:
if not self._logic.is_playable_file(node):
return
dpg.add_separator()
- add_play_menu_item(self._language_manager["global.context.label.play"], lambda: self._logic.play_node(node))
+ add_play_menu_item(
+ self._language_manager["global.context.label.play"],
+ lambda: self._logic.play_node(node),
+ )
def _add_context_menu_path_items(self, path: Path) -> None:
dpg.add_separator()
@@ -622,7 +877,12 @@ def _add_context_menu_locate_audio_item(self, node: FileSystemNode) -> None:
user_data=node,
)
- def _on_locate_original_audio(self, sender: Sender, app_data: Any, user_data: FileSystemNode) -> None:
+ def _on_locate_original_audio(
+ self,
+ _sender: Sender,
+ _app_data: Any,
+ user_data: FileSystemNode,
+ ) -> None:
if not isinstance(user_data, FileSystemNode) or user_data.node_type != NodeType.FILE:
return
@@ -640,33 +900,167 @@ def _add_context_menu_favorite_item(self, node: FileSystemNode) -> None:
callback=lambda: self._context_mark_as_favorite(node),
)
- def _on_add_to_sequencer(self, sender: Sender, app_data: Any, user_data: FileSystemNode) -> None:
+ def _on_add_to_sequencer(
+ self,
+ _sender: Sender,
+ _app_data: Any,
+ user_data: FileSystemNode,
+ ) -> None:
if not isinstance(user_data, FileSystemNode) or user_data.node_type != NodeType.FILE:
return
self.call(self.on_add_to_sequencer, user_data.filepath)
- def _on_replace_in_sequencer(self, sender: Sender, app_data: Any, user_data: FileSystemNode) -> None:
+ def _on_replace_in_sequencer(
+ self,
+ _sender: Sender,
+ _app_data: Any,
+ user_data: FileSystemNode,
+ ) -> None:
if not isinstance(user_data, FileSystemNode) or user_data.node_type != NodeType.FILE:
return
self.call(self.on_replace_in_sequencer, user_data.filepath)
- def _on_search_changed(self, sender: Sender, query: str) -> None:
- if query:
- self.apply_filter(query, self._default_search_predicate)
- else:
- self.clear_filter()
-
- self._logic.schedule_search_update(query)
+ def _on_search_changed(self, _sender: Sender, query: str) -> None:
+ self._set_query(query)
def _on_clear_search_clicked(self) -> None:
if self._search_input_tag is not None:
dpg.set_value(self._search_input_tag, "")
- self.clear_filter()
+ self._set_query("")
- self._logic.schedule_search_update("")
+ def _set_query(self, query: str) -> None:
+ """Take the query the browser is now asked to show, and resolve the rows it names.
+
+ The rows already drawn are the favorites mode's to state, so a keystroke resolves the search
+ alone and the tree on screen answers the one after it.
+ """
+ self._filter = self._filter.with_query(query)
+ self._search_visibility = self._resolve_search_visibility()
+ self._logic.schedule_search_update(query)
+
+ def _resolve_filter(self) -> None:
+ """Resolve the filter against the model as it stands, which a rebuild does once per pass.
+
+ The model is what the whole answer is read from, so the resolution runs on the rebuild worker
+ and a filter stated before a refresh answers for the rows that refresh brings. Both of the
+ favorites mode's turns are answered here, each by the pass that follows it: the pass the reader
+ asked for notes the way down it opens, and the pass that reads the mode off hands those rows
+ back.
+ """
+ self._search_visibility = self._resolve_search_visibility()
+ (
+ self._favorites_visibility,
+ way_down,
+ ) = self._resolve_favorites()
+ if self._filter.favorites_only:
+ self._expansion.follow(way_down)
+ else:
+ self._release_mode_rows()
+
+ self._auto_expand_pending = False
+
+ def _resolve_search_visibility(self) -> Optional[TreeVisibility]:
+ """The rows the search query names, and nothing to narrow by while no query is typed."""
+ query = self._filter.query
+ if not query:
+ return None
+
+ return resolve_visibility(
+ self.tree.find_nodes(
+ TreeNode,
+ lambda node: self._default_search_predicate(node, query),
+ )
+ )
+
+ def _resolve_favorites(self) -> Tuple[Optional[TreeVisibility], Set[str]]:
+ """The rows the favorites mode keeps, and the rows it opens the way down through.
+
+ The two answer different questions — which rows the browser draws, and which of them stand
+ open — so each is read out of one walk of the model: the rows a star reaches state what is
+ drawn, and the rows above the anchors among them state the way down. A corpus of any size
+ therefore resolves into a walk and a pair of sets.
+ """
+ if not self._filter.favorites_only:
+ return None, set()
+
+ reached = self.tree.find_nodes(TreeNode, self._is_node_starred)
+ return (
+ resolve_visibility(reached),
+ self._way_down_to(self._auto_expanded_anchors(reached)),
+ )
+
+ def _way_down_to(self, rows: Sequence[TreeNode]) -> Set[str]:
+ """The tags of the rows standing above these rows, which is a way down to them.
+
+ A row given here is one the browser is pointed at, so a way down opens the rows above it while
+ its own row stands as the reader left it. The container both branches hang from is a row on no
+ screen, and stays out of the answer.
+ """
+ root = self.tree.get_root()
+ return {self._generate_node_tag(ancestor) for row in rows for ancestor in row.ancestors if ancestor is not root}
+
+ def _auto_expanded_anchors(
+ self,
+ reached: Sequence[TreeNode],
+ ) -> List[TreeNode]:
+ """The anchors whose star the reader asked the browser to open the way down to.
+
+ The pass the reader started by switching the mode on is the one that follows a star, so a pass
+ of its own accord points at nothing. Which stars are followed is a preference stated per kind
+ and read once per pass: a starred reconstruction answers for itself, and a starred folder
+ answers for itself together with the rows it brings in where no row stands for the folder.
+ """
+ if not self._auto_expand_pending:
+ return []
+
+ reconstructions = self._logic.auto_expand_favorite_reconstructions
+ directories = self._logic.auto_expand_favorite_directories
+ return [
+ node
+ for node in reached
+ if self._is_node_anchored(node)
+ and (reconstructions if self._is_starred_reconstruction(node) else directories)
+ ]
+
+ def _is_starred_reconstruction(self, node: TreeNode) -> bool:
+ """Whether the star the mode reaches this row through sits on a reconstruction.
+
+ A row the reader starred answers by its own kind. A row a starred folder brings in answers by
+ that folder, a folder being the only thing that holds another row.
+ """
+ return node.node_type == NodeType.FILE and self._logic.is_node_favorite(node)
+
+ def _is_node_starred(self, node: TreeNode) -> bool:
+ """Whether the favorites mode names the row: it carries a star, or a starred folder holds it.
+
+ Being held by a starred folder is a fact about the path, so a reconstruction listed under the
+ sample it came from answers the same as the row standing for it beside its configuration.
+ """
+ if self._logic.is_node_favorite(node):
+ return True
+
+ return isinstance(node, FileSystemNode) and self._logic.has_favorite_ancestor(node)
+
+ def _is_node_anchored(self, node: TreeNode) -> bool:
+ """Whether the mode points the reader at the row, which is what the way down opens to.
+
+ A star sits on a row the reader marked, so that row is pointed at wherever it sits — inside
+ another starred folder among the rest, which is what lets an explicit favorite open the folder
+ above it. A row a starred folder merely holds is where the star first reaches only while no
+ row above it is reached, which is how the sample branch answers: its headings carry no path,
+ so the variants are where the star arrives.
+
+ Asked of the rows the star reaches, so a row it declines stands under a row it named: the
+ reader is pointed at the folder, and the rows inside it stand as they were.
+ """
+ if self._logic.is_node_favorite(node):
+ return True
+
+ parent = node.parent
+ return parent is None or not self._is_node_starred(parent)
def _default_search_predicate(self, node: TreeNode, query: str) -> bool:
return query.lower() in node.name.lower()
@@ -674,7 +1068,16 @@ def _default_search_predicate(self, node: TreeNode, query: str) -> bool:
@abstractmethod
def rebuild_tree(self) -> None: ...
+ @abstractmethod
+ def redraw_tree(self) -> None:
+ """Draws the rows again from the model in hand, which a change of filter asks for."""
+
def update_tree_visibility(self) -> None:
+ """Show the rows the search names and hide the rest, over the rows already on screen.
+
+ Runs on the main thread once the typing settles, so a query narrows what is drawn in place
+ of asking for a rebuild.
+ """
root = self.tree.get_root()
if root is None:
return
@@ -687,30 +1090,35 @@ def _update_node_visibility_recursive(self, node: TreeNode) -> None:
if not dpg.does_item_exist(node_tag):
return
- is_visible = self.tree.is_node_visible(node)
- dpg.configure_item(node_tag, show=is_visible)
+ dpg.configure_item(node_tag, show=self._is_node_visible(node))
for child in node.children:
self._update_node_visibility_recursive(child)
- def apply_filter(self, query: str, predicate: Callable[[TreeNode, str], bool]) -> None:
- self.tree.apply_filter(query, predicate)
+ def _is_node_visible(self, node: TreeNode) -> bool:
+ """Whether the search shows the row, which every row on screen reads as while none is typed."""
+ if self._search_visibility is None:
+ return True
+
+ return self._search_visibility.is_visible(node)
+
+ def _is_node_drawn(self, node: TreeNode) -> bool:
+ """Whether the favorites mode draws the row, which it does for every row while it is off."""
+ if self._favorites_visibility is None:
+ return True
- def clear_filter(self) -> None:
- self.tree.clear_filter()
+ return self._favorites_visibility.is_visible(node)
def _apply_node_theme(
self,
node_tag: str,
node: TreeNode,
has_favorite_ancestor: bool = False,
- is_node_expanded: bool = False,
) -> None:
FontRegistry.bind_to_item(node_tag, self._resolve_node_name_font(node))
theme_tag = self._resolve_node_theme_tag(
node,
has_favorite_ancestor=has_favorite_ancestor,
- is_node_expanded=is_node_expanded,
)
ThemeRegistry.get(theme_tag).bind_to_item(node_tag)
@@ -719,7 +1127,6 @@ def _resolve_node_theme_tag(
node: TreeNode,
*,
has_favorite_ancestor: bool = False,
- is_node_expanded: bool = False,
) -> str:
"""Select the theme tag for a node from its type, favorite state, and content.
@@ -729,12 +1136,14 @@ def _resolve_node_theme_tag(
if isinstance(node, FileSystemNode):
match node.node_type:
case NodeType.DIRECTORY:
- return self._resolve_directory_theme_tag(node, has_favorite_ancestor=has_favorite_ancestor)
+ return self._resolve_directory_theme_tag(
+ node,
+ has_favorite_ancestor=has_favorite_ancestor,
+ )
case NodeType.FILE:
return self._resolve_file_theme_tag(
node,
has_favorite_ancestor=has_favorite_ancestor,
- is_not_expanded=is_node_expanded,
)
return self._resolve_other_theme_tag(node)
@@ -761,23 +1170,20 @@ def _resolve_file_theme_tag(
node: FileSystemNode,
*,
has_favorite_ancestor: bool = False,
- is_not_expanded: bool = False,
) -> str:
if self._logic.is_node_favorite(node):
return TAG_GLOBAL_THEME_FAVORITE
match node.filepath.suffix.lower():
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
return TAG_GLOBAL_THEME_FILE_RECONSTRUCTION
- case paths.EXT_FILE_LIBRARY:
+ case extensions.EXT_FILE_LIBRARY:
return TAG_GLOBAL_THEME_FILE_LIBRARY
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
return TAG_GLOBAL_THEME_FILE_WAVE
case _:
if has_favorite_ancestor:
return TAG_GLOBAL_THEME_FAVORITE_CHILD
- if is_not_expanded:
- return TAG_GLOBAL_THEME_FILE_NOT_EXPANDED_DIRECTORY
return TAG_GLOBAL_THEME_DEFAULT
def _resolve_other_theme_tag(self, node: TreeNode) -> str:
@@ -793,7 +1199,11 @@ def _resolve_other_theme_tag(self, node: TreeNode) -> str:
case _:
return TAG_GLOBAL_THEME_DEFAULT
- def _reapply_theme_recursively(self, node: FileSystemNode, has_favorite_ancestor: bool = False) -> None:
+ def _reapply_theme_recursively(
+ self,
+ node: FileSystemNode,
+ has_favorite_ancestor: bool = False,
+ ) -> None:
node_tag = self._generate_node_tag(node)
if not dpg.does_item_exist(node_tag):
return
@@ -820,9 +1230,30 @@ def _context_mark_as_favorite(self, node: TreeNode) -> None:
self._logic.toggle_favorite(node)
- def update_favorite_indicator(self, node: FileSystemNode) -> None:
- has_favorite_ancestor = self._logic.has_favorite_ancestor(node)
- self._reapply_theme_recursively(node, has_favorite_ancestor)
+ def update_favorite_indicators(
+ self,
+ nodes: Sequence[FileSystemNode],
+ ) -> None:
+ """Follows a favorite change through the rows it reaches, and what each of them holds.
+
+ A path reaches the panel as many rows as the views offer it — a reconstruction is listed both
+ by its configuration and by the sample it came from — and the star belongs to the path, so
+ the caller names every row standing for it and each of them takes the new theme with the
+ ancestry its own path carries.
+
+ While the mode shows the favorites alone the star decides which rows exist, so the change is
+ answered by drawing the tree again from the model in hand: starring a row brings it in, and
+ unstarring one takes it out along with what it held.
+ """
+ if self._filter.favorites_only:
+ self.redraw_tree()
+ return
+
+ for node in nodes:
+ self._reapply_theme_recursively(
+ node,
+ self._logic.has_favorite_ancestor(node),
+ )
@abstractmethod
def set_tree_enabled(self, enabled: bool) -> None: ...
diff --git a/src/sampletones_application/ui/elements/window.py b/src/sampletones_application/ui/elements/window.py
index e31e1ad3..4a832b97 100644
--- a/src/sampletones_application/ui/elements/window.py
+++ b/src/sampletones_application/ui/elements/window.py
@@ -1,5 +1,6 @@
from abc import ABC, abstractmethod
-from typing import Any
+from contextlib import contextmanager
+from typing import Any, Iterator, Optional
import dearpygui.dearpygui as dpg
@@ -7,7 +8,9 @@
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.gui.align import center_item
-from sampletones_application.utils.gui.dpg import dpg_delete_item
+from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item
+from sampletones_application.utils.gui.frame import FrameCallbackManager
+from sampletones_shared.types.callback import VoidCallback
class GUIWindow(GUIPanel, ABC):
@@ -19,11 +22,66 @@ class GUIWindow(GUIPanel, ABC):
The ``prepare`` step captures arguments before the previous tree is torn
down. Each rebuild binds the elevated dialog-window theme so the window
floats above the app with an accent border and title bar.
+
+ A dialog that raises another modal — a prompt, a countdown — hands the screen
+ over with ``yield_to`` and takes it back with ``resume``, which is what keeps
+ the two from competing for the one modal DearPyGui carries at a time.
"""
def center(self) -> None:
center_item(self.tag)
+ def yield_to(self, raise_modal: VoidCallback) -> None:
+ """Steps off screen and runs ``raise_modal`` a frame later, so what it raises can open.
+
+ DearPyGui carries one modal at a time: a modal built while another one is still on
+ screen opens as a hidden window nobody can reach. This window goes off screen first
+ and the frame it was drawn in finishes, leaving the new modal alone on screen. The
+ widget tree stays where it is, so whatever is being edited here survives the visit
+ and :meth:`resume` brings it back untouched.
+ """
+ dpg_configure_item(self.tag, show=False)
+ FrameCallbackManager.set_frame_callback(raise_modal)
+
+ def resume(self) -> None:
+ """Comes back on screen once the modal this window yielded to is gone.
+
+ The return waits a frame for the same reason the hand-off does: the modal being
+ dismissed still holds the screen for the frame it is dismissed in.
+ """
+ FrameCallbackManager.set_frame_callback(lambda: dpg_configure_item(self.tag, show=True))
+
+ @contextmanager
+ def dialog_window(
+ self,
+ *,
+ label: str,
+ on_close: Optional[VoidCallback],
+ ) -> Iterator[None]:
+ """Open this window's modal frame, with the block's widgets building inside it.
+
+ The window holds the width it states and fits its height to the content it is given, which
+ is what lets a field, a combo or a button stretch across it: a stretched item measures one
+ pixel inside the region it is offered, so a window sized from its own content would take
+ that pixel back on every frame. A stated width settles the geometry in one pass and gives
+ every dialog the same reading width whatever it holds.
+
+ A dialog offers the title bar's close button when ``on_close`` names what closing means,
+ and omits it otherwise, so the only way out of a window is one the window answers for.
+ """
+ with dpg.window(
+ tag=self.tag,
+ label=label,
+ width=self.width,
+ height=self.height,
+ no_resize=True,
+ no_collapse=True,
+ no_close=on_close is None,
+ on_close=on_close,
+ modal=True,
+ ):
+ yield
+
def show(self, *args: Any, **kwargs: Any) -> None:
self.hide()
self.prepare(*args, **kwargs)
diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py
index 34edc06d..0ef75f5d 100644
--- a/src/sampletones_application/ui/menu.py
+++ b/src/sampletones_application/ui/menu.py
@@ -1,18 +1,27 @@
-from typing import Dict, Final, Tuple
+from functools import partial
+from typing import Callable, Dict, Final, Optional, Tuple
import dearpygui.dearpygui as dpg
-from sampletones_application.categories.elements.global_ import ContextElements, MenuElements
+from sampletones_application.categories.context import channel_label, context_label
+from sampletones_application.categories.elements.global_ import (
+ ContextElements,
+ MenuElements,
+)
from sampletones_application.categories.hierarchy import Page, Panel, TextType
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.categories.trackers import (
TRACKER_PROJECT_MENU_LABELS,
TRACKER_SAMPLE_MENU_LABELS,
)
-from sampletones_application.layout.glyphs import PlayerGlyphs
+from sampletones_application.constants.playback import FollowMode
+from sampletones_application.layout.glyphs.player import PlayerGlyphs
from sampletones_application.layout.player import PlayerLayout
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
+ SUF_HANDLER_REGISTRY,
+ TAG_GLOBAL_MENU_GROUP_EDIT,
+ TAG_GLOBAL_MENU_GROUP_EDIT_MARKER,
TAG_GLOBAL_MENU_ITEM_EDIT_REDO,
TAG_GLOBAL_MENU_ITEM_EDIT_UNDO,
TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT,
@@ -20,11 +29,12 @@
TAG_GLOBAL_MENU_ITEM_FILE_NEW_PROJECT,
TAG_GLOBAL_MENU_ITEM_FILE_OPEN_PROJECT,
TAG_GLOBAL_MENU_ITEM_FILE_PROJECT_PROPERTIES,
+ TAG_GLOBAL_MENU_ITEM_FILE_RENDER_SONG,
TAG_GLOBAL_MENU_ITEM_FILE_SAVE_PROJECT,
TAG_GLOBAL_MENU_ITEM_FILE_SAVE_PROJECT_AS,
TAG_GLOBAL_MENU_ITEM_PLAYBACK_AUTOPLAY,
TAG_GLOBAL_MENU_ITEM_PLAYBACK_CHANNELS,
- TAG_GLOBAL_MENU_ITEM_PLAYBACK_FOLLOW_PLAYBACK,
+ TAG_GLOBAL_MENU_ITEM_PLAYBACK_FOLLOW,
TAG_GLOBAL_MENU_ITEM_PLAYBACK_LOOP_SONG,
TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY,
TAG_GLOBAL_MENU_ITEM_PLAYBACK_PLAY_FROM_FRAME,
@@ -42,6 +52,8 @@
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_RECONSTRUCT_FILE,
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_SAVE,
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_SAVE_AS,
+ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES,
+ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS,
TAG_GLOBAL_MENU_ITEM_VIEW_FULLSCREEN,
TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS,
TAG_GLOBAL_PANEL_PLAYER,
@@ -60,12 +72,15 @@
)
from sampletones_application.ui.themes.theme import Theme
from sampletones_application.utils.gui.dpg import (
+ dpg_append_items,
dpg_configure_item,
+ dpg_delete_item,
dpg_set_item_label,
dpg_set_value,
)
from sampletones_application.utils.gui.shortcuts.ids import (
CHANNEL_SHORTCUT_IDS,
+ FOLLOW_MODE_SHORTCUT_IDS,
PROJECT_EXPORT_SHORTCUT_IDS,
SAMPLE_EXPORT_SHORTCUT_IDS,
ShortcutId,
@@ -73,6 +88,7 @@
from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager
from sampletones_application.view_model.shared.menu import MenuBarViewModel
from sampletones_core.constants.enums import GeneratorName
+from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import VoidCallback
PROJECT_ITEM_TAGS: Final[Tuple[str, ...]] = (
@@ -88,12 +104,17 @@
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_WAV,
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS,
)
-CHANNEL_LABELS: Final[Dict[GeneratorName, ContextElements]] = {
- GeneratorName.PULSE1: ContextElements.PULSE_1,
- GeneratorName.PULSE2: ContextElements.PULSE_2,
- GeneratorName.TRIANGLE: ContextElements.TRIANGLE,
- GeneratorName.NOISE: ContextElements.NOISE,
+FOLLOW_MODE_LABELS: Final[Dict[FollowMode, MenuElements]] = {
+ FollowMode.ROWS: MenuElements.ITEM_PLAYBACK_FOLLOW_ROWS,
+ FollowMode.PATTERNS: MenuElements.ITEM_PLAYBACK_FOLLOW_PATTERNS,
+ FollowMode.OFF: MenuElements.ITEM_PLAYBACK_FOLLOW_OFF,
}
+UNFOCUSED_CLIPBOARD_ELEMENTS: Final[Tuple[ContextElements, ...]] = (
+ ContextElements.COPY,
+ ContextElements.CUT,
+ ContextElements.PASTE,
+ ContextElements.DELETE,
+)
class MenuBar:
@@ -107,9 +128,11 @@ def __init__(
player_glyphs: PlayerGlyphs,
player_layout: PlayerLayout,
language_manager: LanguageManager,
+ build_edit_actions: Callable[[], bool],
on_play_from_start: VoidCallback,
on_pause_or_resume: VoidCallback,
on_stop: VoidCallback,
+ on_channel_muted: Callable[[GeneratorName], None],
) -> None:
self._shortcut_manager = shortcut_manager
self._fps_theme = fps_theme
@@ -118,9 +141,11 @@ def __init__(
self._player_glyphs = player_glyphs
self._player_layout = player_layout
self._language_manager = language_manager
+ self._build_edit_actions = build_edit_actions
self._on_play_from_start = on_play_from_start
self._on_pause_or_resume = on_pause_or_resume
self._on_stop = on_stop
+ self._on_channel_muted = on_channel_muted
self._tpl_fps = language_manager["global.dialog.template.fps"]
self._play_button_tag = compose_tag(TAG_GLOBAL_PANEL_PLAYER, SUF_PLAYER_PLAY)
@@ -129,6 +154,13 @@ def __init__(
self._pause_tooltip_tag = compose_tag(self._pause_button_tag, SUF_PLAYER_TOOLTIP)
self._lbl_pause = language_manager["global.player.label.pause"]
+ self._edit_actions_handler_tag = compose_tag(
+ TAG_GLOBAL_MENU_GROUP_EDIT_MARKER,
+ SUF_HANDLER_REGISTRY,
+ )
+ self._edit_actions_frame: Optional[int] = None
+ self._edit_action_items: Tuple[Sender, ...] = ()
+
def _label(self, element: MenuElements) -> str:
return self._language_manager[
Page.GLOBAL,
@@ -138,13 +170,7 @@ def _label(self, element: MenuElements) -> str:
]
def _context_label(self, element: ContextElements) -> str:
- """Resolves a shared context-action label reused between the tree menus and this bar."""
- return self._language_manager[
- Page.GLOBAL,
- Panel.CONTEXT,
- TextType.LABEL,
- element,
- ]
+ return context_label(self._language_manager, element)
def create(self, state: MenuBarViewModel) -> None:
with dpg.menu_bar():
@@ -190,6 +216,12 @@ def _create_file_menu(self, state: MenuBarViewModel) -> None:
enabled=state.project_open,
)
self._create_project_export_menu(state)
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.RENDER_SONG,
+ tag=TAG_GLOBAL_MENU_ITEM_FILE_RENDER_SONG,
+ label=self._label(MenuElements.ITEM_FILE_RENDER_SONG),
+ enabled=state.render_enabled,
+ )
dpg.add_separator()
self._shortcut_manager.add_menu_item(
ShortcutId.CLOSE_PROJECT,
@@ -222,7 +254,18 @@ def _create_project_export_menu(self, state: MenuBarViewModel) -> None:
)
def _create_edit_menu(self, state: MenuBarViewModel) -> None:
- with dpg.menu(label=self._label(MenuElements.GROUP_EDIT)):
+ """Builds the Edit menu: the history steps, then the actions of whoever holds the cursor.
+
+ The actions are stated into the menu itself and taken away again on each opening, so they
+ follow the cursor. A marker leads the menu, holding nothing and reporting the popup drawn:
+ a container standing below a menu item takes the width those items span as its own, which
+ the popup then grows to fit on every frame it stays open.
+ """
+ with dpg.menu(
+ label=self._label(MenuElements.GROUP_EDIT),
+ tag=TAG_GLOBAL_MENU_GROUP_EDIT,
+ ):
+ dpg.add_group(tag=TAG_GLOBAL_MENU_GROUP_EDIT_MARKER)
self._shortcut_manager.add_menu_item(
ShortcutId.UNDO,
tag=TAG_GLOBAL_MENU_ITEM_EDIT_UNDO,
@@ -235,6 +278,62 @@ def _create_edit_menu(self, state: MenuBarViewModel) -> None:
label=self._label(MenuElements.ITEM_EDIT_REDO),
enabled=state.redo_enabled,
)
+ dpg.add_separator()
+
+ with dpg.item_handler_registry(tag=self._edit_actions_handler_tag):
+ dpg.add_item_visible_handler(callback=self._on_edit_actions_drawn)
+
+ dpg.bind_item_handler_registry(
+ TAG_GLOBAL_MENU_GROUP_EDIT_MARKER,
+ self._edit_actions_handler_tag,
+ )
+ self._refresh_edit_actions()
+
+ def _on_edit_actions_drawn(
+ self,
+ _sender: Sender,
+ _app_data: Sender,
+ ) -> None:
+ """States the actions afresh each time the Edit menu is opened.
+
+ DearPyGui reports the marker drawn once a frame while the menu stands open, so a gap in
+ those reports marks a fresh opening. The actions stay standing between openings, which
+ gives the popup its full height on the frame it appears, and the rebuilt ones take over a
+ frame later — long before an item can be reached and chosen.
+ """
+ frame = dpg.get_frame_count()
+ reopened = self._edit_actions_frame is None or frame - self._edit_actions_frame > 1
+ self._edit_actions_frame = frame
+ if reopened:
+ self._refresh_edit_actions()
+
+ def _refresh_edit_actions(self) -> None:
+ """Takes the standing actions out of the Edit menu and asks the focused surface for its own.
+
+ A surface builds the same actions its own cell menu offers, so the two doors print one set
+ with the keys and the enablement each action carries. The history steps above them stand
+ where they are, since only what the last build stated is taken away.
+ """
+ for item in self._edit_action_items:
+ dpg_delete_item(item)
+
+ self._edit_action_items = dpg_append_items(
+ TAG_GLOBAL_MENU_GROUP_EDIT,
+ self._add_edit_action_items,
+ )
+
+ def _add_edit_action_items(self) -> None:
+ """States the focused surface's actions, or the clipboard four greyed out while none is."""
+ if not self._build_edit_actions():
+ self._add_unfocused_clipboard_items()
+
+ def _add_unfocused_clipboard_items(self) -> None:
+ """Names the clipboard actions greyed out, the Edit menu with no grid holding a cursor."""
+ for element in UNFOCUSED_CLIPBOARD_ELEMENTS:
+ dpg.add_menu_item(
+ label=self._context_label(element),
+ enabled=False,
+ )
def _create_reconstruction_menu(self, state: MenuBarViewModel) -> None:
with dpg.menu(label=self._label(MenuElements.GROUP_RECONSTRUCTION)):
@@ -360,12 +459,7 @@ def _create_playback_menu(self, state: MenuBarViewModel) -> None:
label=self._label(MenuElements.ITEM_PLAYBACK_AUTOPLAY),
check=True,
)
- self._shortcut_manager.add_menu_item(
- ShortcutId.TOGGLE_FOLLOW_PLAYBACK,
- tag=TAG_GLOBAL_MENU_ITEM_PLAYBACK_FOLLOW_PLAYBACK,
- label=self._label(MenuElements.ITEM_PLAYBACK_FOLLOW_PLAYBACK),
- check=True,
- )
+ self._create_follow_menu(state)
self._shortcut_manager.add_menu_item(
ShortcutId.TOGGLE_LOOP_SONG,
tag=TAG_GLOBAL_MENU_ITEM_PLAYBACK_LOOP_SONG,
@@ -379,12 +473,35 @@ def _create_playback_menu(self, state: MenuBarViewModel) -> None:
label=self._label(MenuElements.ITEM_PLAYBACK_AUDIO_SETTINGS),
)
+ def _create_follow_menu(self, state: MenuBarViewModel) -> None:
+ """Builds the submenu that chooses how far the sequencer view chases the playhead.
+
+ The modes stand as one choice, so the check marks the reach in place and choosing another
+ item moves it there. Each mode carries its own key, which is what lets a reader switch
+ reach mid-playback straight from the keyboard.
+ """
+ with dpg.menu(
+ tag=TAG_GLOBAL_MENU_ITEM_PLAYBACK_FOLLOW,
+ label=self._label(MenuElements.GROUP_PLAYBACK_FOLLOW),
+ ):
+ for mode, shortcut_id in FOLLOW_MODE_SHORTCUT_IDS.items():
+ self._shortcut_manager.add_menu_item(
+ shortcut_id,
+ tag=self._follow_menu_item_tag(mode),
+ label=self._label(FOLLOW_MODE_LABELS[mode]),
+ check=True,
+ default_value=state.follow_mode is mode,
+ )
+
def _create_channels_menu(self, state: MenuBarViewModel) -> None:
"""Builds the submenu that switches each tracker channel of the sequencer's song.
A channel carries a check while it sounds, so the submenu reads as the mix the tracker's
columns and the order table's rows show, and choosing one silences it. The closing item
brings the whole mix back in one gesture, and it is offered while a channel is silenced.
+
+ The check names the sequencer's mix, so choosing an item switches that mix wherever the
+ reader stands, while the key printed beside it reaches the channels of the tab in front.
"""
with dpg.menu(
tag=TAG_GLOBAL_MENU_ITEM_PLAYBACK_CHANNELS,
@@ -393,8 +510,9 @@ def _create_channels_menu(self, state: MenuBarViewModel) -> None:
for generator, shortcut_id in CHANNEL_SHORTCUT_IDS.items():
self._shortcut_manager.add_menu_item(
shortcut_id,
+ callback=partial(self._on_channel_muted, generator),
tag=self._channel_menu_item_tag(generator),
- label=self._context_label(CHANNEL_LABELS[generator]),
+ label=channel_label(self._language_manager, generator),
check=True,
default_value=not state.channels.is_muted(generator),
)
@@ -420,6 +538,37 @@ def _create_view_menu(self) -> None:
label=self._label(MenuElements.ITEM_VIEW_FULLSCREEN),
check=True,
)
+ dpg.add_separator()
+ self._create_auto_expand_favorites_menu()
+ dpg.add_separator()
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.DISPLAY_SETTINGS,
+ label=self._label(MenuElements.ITEM_VIEW_DISPLAY_SETTINGS),
+ )
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.KEYBOARD_SETTINGS,
+ label=self._label(MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS),
+ )
+
+ def _create_auto_expand_favorites_menu(self) -> None:
+ """Offers, per kind of favorite, whether showing the favorites alone opens the way down to one.
+
+ A browser showing its favorites alone decides which rows it draws; whether it also unfolds the
+ rows above a star is the reader's, and a reconstruction and a directory are answered apart.
+ """
+ with dpg.menu(label=self._label(MenuElements.GROUP_VIEW_AUTO_EXPAND_FAVORITES)):
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS,
+ tag=TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS,
+ label=self._label(MenuElements.ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS),
+ check=True,
+ )
+ self._shortcut_manager.add_menu_item(
+ ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES,
+ tag=TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES,
+ label=self._label(MenuElements.ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES),
+ check=True,
+ )
def _create_help_menu(self) -> None:
with dpg.menu(label=self._label(MenuElements.GROUP_HELP)):
@@ -472,6 +621,10 @@ def update(self, state: MenuBarViewModel) -> None:
for project_item_tag in PROJECT_ITEM_TAGS:
dpg_configure_item(project_item_tag, enabled=state.project_open)
+ dpg_configure_item(
+ TAG_GLOBAL_MENU_ITEM_FILE_RENDER_SONG,
+ enabled=state.render_enabled,
+ )
dpg_configure_item(
TAG_GLOBAL_MENU_ITEM_EDIT_UNDO,
enabled=state.undo_enabled,
@@ -524,17 +677,31 @@ 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_LOOP_SONG, state.loop_song)
+ self._update_follow_mode(state)
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,
)
+ self._update_auto_expand_favorites(state)
+
+ def _update_auto_expand_favorites(self, state: MenuBarViewModel) -> None:
+ """Shows, per kind of favorite, whether the browsers open the way down to one."""
+ dpg_set_value(
+ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS,
+ state.auto_expand_favorite_reconstructions,
+ )
+ dpg_set_value(
+ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES,
+ state.auto_expand_favorite_directories,
+ )
+
+ def _update_follow_mode(self, state: MenuBarViewModel) -> None:
+ """Marks the reach the view follows the playhead at, the one mode carrying the check."""
+ for mode in FOLLOW_MODE_SHORTCUT_IDS:
+ dpg_set_value(self._follow_menu_item_tag(mode), state.follow_mode is mode)
def _update_channels(self, state: MenuBarViewModel) -> None:
"""Shows the mute set the sequencer's tables show: a check on every channel that sounds."""
@@ -577,3 +744,8 @@ def update_fps(self, fps: float) -> None:
def _channel_menu_item_tag(generator: GeneratorName) -> str:
"""The tag of the Channels submenu item that switches ``generator``."""
return compose_tag(TAG_GLOBAL_MENU_ITEM_PLAYBACK_CHANNELS, generator.value)
+
+ @staticmethod
+ def _follow_menu_item_tag(mode: FollowMode) -> str:
+ """The tag of the Follow playback submenu item that chooses ``mode``."""
+ return compose_tag(TAG_GLOBAL_MENU_ITEM_PLAYBACK_FOLLOW, mode.value)
diff --git a/src/sampletones_application/ui/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py
index a2f52f90..c45b76d6 100644
--- a/src/sampletones_application/ui/panels/dialogs/audio_settings.py
+++ b/src/sampletones_application/ui/panels/dialogs/audio_settings.py
@@ -4,7 +4,6 @@
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.layout.settings import SettingsLayout
-from sampletones_application.tags.general import TAG_GLOBAL_THEME_DIALOG
from sampletones_application.tags.settings import (
TAG_SETTINGS_AUDIO_BUTTON_APPLY,
TAG_SETTINGS_AUDIO_BUTTON_REFRESH,
@@ -16,16 +15,13 @@
TAG_SETTINGS_AUDIO_WINDOW,
)
from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.dialog import GUIDialogWindow
from sampletones_application.ui.elements.field import labeled_field
-from sampletones_application.ui.elements.window import GUIWindow
-from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.gui.align import table_wrapper
-from sampletones_application.utils.gui.dialog_navigation import (
- DialogKeyboardNavigator,
- FocusStop,
-)
+from sampletones_application.utils.gui.dialog_navigation import FocusStop
from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value
from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
from sampletones_application.view_model.shared.audio_settings import (
BUFFER_SIZE_ITEMS,
AudioDeviceItem,
@@ -43,19 +39,17 @@
from sampletones_shared.utils.color import blend
-class GUIAudioSettingsWindow(GUIWindow):
+class GUIAudioSettingsWindow(GUIDialogWindow):
def __init__(
self,
*,
layout: SettingsLayout,
language_manager: LanguageManager,
key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
) -> None:
self._language_manager = language_manager
self._layout = layout
- self._router = key_router
- self._dialog_theme = ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG)
- self._navigator: Optional[DialogKeyboardNavigator] = None
self.on_commit: Optional[Callable[[int, SampleRate, BufferSize], None]] = None
self.on_refresh_devices: Optional[VoidCallback] = None
@@ -74,8 +68,10 @@ def __init__(
super().__init__(
tag=TAG_SETTINGS_AUDIO_WINDOW,
- width=layout.window.width,
- height=layout.window.height,
+ width=layout.audio.window.width,
+ height=layout.audio.window.height,
+ key_router=key_router,
+ shortcut_source=shortcut_source,
)
def open(self, view_model: AudioSettingsViewModel) -> None:
@@ -102,15 +98,9 @@ def _seed(self, view_model: AudioSettingsViewModel) -> None:
self._master_gain = view_model.master_gain
def create_window(self) -> None:
- with dpg.window(
- tag=self.tag,
+ with self.dialog_window(
label=self._language_manager["settings.audio.title.window_title"],
- width=self.width,
- height=self.height,
- no_resize=True,
- no_collapse=True,
on_close=self.hide,
- modal=True,
):
self._create_device_selection()
self._create_sample_rate_selection()
@@ -119,21 +109,15 @@ def create_window(self) -> None:
dpg.add_separator()
self._create_action_buttons()
- for combo_tag in (
+ self._bind_dialog_theme(
TAG_SETTINGS_AUDIO_COMBO_DEVICE,
TAG_SETTINGS_AUDIO_COMBO_SAMPLE_RATE,
TAG_SETTINGS_AUDIO_COMBO_BUFFER_SIZE,
- ):
- self._dialog_theme.bind_to_item(combo_tag)
+ )
self._update_combos()
- self._install_navigation()
-
- def _install_navigation(self) -> None:
- """Wires Tab/Enter/Escape keyboard navigation over the combos and buttons."""
- self._navigator = DialogKeyboardNavigator(
- window_tag=self.tag,
- stops=[
+ self._install_navigation(
+ [
FocusStop.field(TAG_SETTINGS_AUDIO_COMBO_DEVICE),
FocusStop.field(TAG_SETTINGS_AUDIO_COMBO_SAMPLE_RATE),
FocusStop.field(TAG_SETTINGS_AUDIO_COMBO_BUFFER_SIZE),
@@ -142,14 +126,7 @@ def _install_navigation(self) -> None:
FocusStop.button(TAG_SETTINGS_AUDIO_BUTTON_APPLY, self._commit),
],
on_escape=self.hide,
- key_router=self._router,
)
- self._navigator.install()
-
- def _teardown(self) -> None:
- if self._navigator is not None:
- self._navigator.dispose()
- self._navigator = None
def _create_device_selection(self) -> None:
with labeled_field(self._language_manager["settings.audio.label.output_device"], self._layout.label_width):
@@ -192,7 +169,7 @@ def _create_master_gain_slider(self) -> None:
min_value=MIN_MASTER_GAIN,
max_value=MAX_MASTER_GAIN,
default_value=self._master_gain,
- width=self._layout.master_gain.slider_width,
+ width=self._layout.audio.master_gain.slider_width,
format="",
callback=self._on_master_gain_changed,
)
@@ -230,8 +207,8 @@ def _master_gain_readout(self, gain: float) -> MasterGainReadout:
def _clip_warning_color(self, clip_fraction: float) -> ColorRGBA:
"""Reddens the readout colour along the layout gradient by the projected boost fraction."""
- colors = self._layout.master_gain
- return blend(colors.label_color, colors.clip_color, clip_fraction)
+ colors = self._layout.audio.master_gain
+ return blend(colors.label_color.rgba, colors.clip_color.rgba, clip_fraction)
@table_wrapper(columns=2)
def _create_action_buttons(self) -> None:
@@ -248,7 +225,7 @@ def _create_action_buttons(self) -> None:
width=-1,
)
- def _on_device_changed(self, sender: Sender, app_data: str) -> None:
+ def _on_device_changed(self, _sender: Sender, app_data: str) -> None:
self._current_device_label = app_data
self._update_combos()
diff --git a/src/sampletones_application/ui/panels/dialogs/countdown.py b/src/sampletones_application/ui/panels/dialogs/countdown.py
new file mode 100644
index 00000000..593c25e6
--- /dev/null
+++ b/src/sampletones_application/ui/panels/dialogs/countdown.py
@@ -0,0 +1,124 @@
+from typing import Any, Final, Optional
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.layout.primitives import Dimensions
+from sampletones_application.tags.settings import (
+ TAG_SETTINGS_DISPLAY_BUTTON_KEEP,
+ TAG_SETTINGS_DISPLAY_BUTTON_REVERT,
+ TAG_SETTINGS_DISPLAY_TEXT_COUNTDOWN,
+ TAG_SETTINGS_DISPLAY_WINDOW_COUNTDOWN,
+)
+from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.dialog import GUIDialogWindow
+from sampletones_application.utils.gui.align import table_wrapper
+from sampletones_application.utils.gui.dialog_navigation import FocusStop
+from sampletones_application.utils.gui.dpg import dpg_set_value
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_shared.types.callback import VoidCallback
+
+KEEP_FOCUS_STOP: Final[int] = 1
+
+
+class GUICountdownWindow(GUIDialogWindow):
+ """A modal asking to keep a change on screen, counting down while it waits.
+
+ A change that can leave the window unreadable is confirmed here: whoever can still read the
+ prompt keeps it, and the count reaching zero speaks for whoever cannot. The window shows the
+ seconds its owner reports and reports both answers back; the owner runs the clock and decides
+ what each answer means.
+
+ The dialog that armed it steps aside for as long as the prompt stands, so the change is
+ judged against the bare window it was made in and the dialog returns to its pending edits
+ once the prompt is answered.
+ """
+
+ def __init__(
+ self,
+ *,
+ layout: Dimensions,
+ title: str,
+ message: str,
+ remaining_format: str,
+ keep_label: str,
+ revert_label: str,
+ key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
+ ) -> None:
+ self._title = title
+ self._message = message
+ self._remaining_format = remaining_format
+ self._keep_label = keep_label
+ self._revert_label = revert_label
+ self._remaining = 0
+
+ self.on_keep: Optional[VoidCallback] = None
+ self.on_revert: Optional[VoidCallback] = None
+
+ super().__init__(
+ tag=TAG_SETTINGS_DISPLAY_WINDOW_COUNTDOWN,
+ width=layout.width,
+ height=layout.height,
+ key_router=key_router,
+ shortcut_source=shortcut_source,
+ )
+
+ def open(self, remaining: int) -> None:
+ """Shows the prompt with the given number of seconds left to answer in."""
+ self._remaining = remaining
+ self.show()
+
+ def set_remaining(self, remaining: int) -> None:
+ """Shows the seconds left, repainting only when the count reaches a new second."""
+ if remaining == self._remaining:
+ return
+
+ self._remaining = remaining
+ dpg_set_value(TAG_SETTINGS_DISPLAY_TEXT_COUNTDOWN, self._remaining_text())
+
+ def prepare(self, *_args: Any, **_kwargs: Any) -> None:
+ """The seconds left are seeded by :meth:`open` before the tree rebuilds."""
+
+ def create_window(self) -> None:
+ with self.dialog_window(
+ label=self._title,
+ on_close=None,
+ ):
+ dpg.add_text(self._message, wrap=self.width)
+ dpg.add_text(self._remaining_text(), tag=TAG_SETTINGS_DISPLAY_TEXT_COUNTDOWN)
+ dpg.add_separator()
+ self._create_action_buttons()
+
+ self._install_navigation(
+ [
+ FocusStop.button(TAG_SETTINGS_DISPLAY_BUTTON_REVERT, self._revert),
+ FocusStop.button(TAG_SETTINGS_DISPLAY_BUTTON_KEEP, self._keep),
+ ],
+ on_escape=self._revert,
+ initial_index=KEEP_FOCUS_STOP,
+ )
+
+ def _remaining_text(self) -> str:
+ return self._remaining_format.format(seconds=self._remaining)
+
+ @table_wrapper(columns=2)
+ def _create_action_buttons(self) -> None:
+ GUIButton(
+ tag=TAG_SETTINGS_DISPLAY_BUTTON_REVERT,
+ label=self._revert_label,
+ callback=self._revert,
+ width=-1,
+ )
+ GUIButton(
+ tag=TAG_SETTINGS_DISPLAY_BUTTON_KEEP,
+ label=self._keep_label,
+ callback=self._keep,
+ width=-1,
+ )
+
+ def _keep(self) -> None:
+ self.call(self.on_keep)
+
+ def _revert(self) -> None:
+ self.call(self.on_revert)
diff --git a/src/sampletones_application/ui/panels/dialogs/display_settings.py b/src/sampletones_application/ui/panels/dialogs/display_settings.py
new file mode 100644
index 00000000..e6aefb21
--- /dev/null
+++ b/src/sampletones_application/ui/panels/dialogs/display_settings.py
@@ -0,0 +1,272 @@
+from typing import Any, Callable, Optional
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.settings import SettingsLayout
+from sampletones_application.tags.settings import (
+ TAG_SETTINGS_DISPLAY_BUTTON_CANCEL,
+ TAG_SETTINGS_DISPLAY_BUTTON_OK,
+ TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS,
+ TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN,
+ TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC,
+ TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE,
+ TAG_SETTINGS_DISPLAY_COMBO_PALETTE,
+ TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION,
+ TAG_SETTINGS_DISPLAY_WINDOW,
+)
+from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.dialog import GUIDialogWindow
+from sampletones_application.ui.elements.field import labeled_field, subheader
+from sampletones_application.utils.gui.align import table_wrapper
+from sampletones_application.utils.gui.dialog_navigation import FocusStop
+from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.view_model.shared.display_settings import (
+ DisplaySettings,
+ DisplaySettingsViewModel,
+ WindowMode,
+)
+from sampletones_shared.types.application import Sender
+from sampletones_shared.types.callback import VoidCallback
+
+SettingsCallback = Callable[[DisplaySettings], None]
+
+
+class GUIDisplaySettingsWindow(GUIDialogWindow):
+ """Modal form over how the application presents itself: its window, its pacing and its theme.
+
+ Every control reports the whole edited state through ``on_settings_changed`` the moment it
+ changes, so the owner puts it on screen and the user judges the result by looking at it.
+ ``on_commit`` states that the state on screen is the one to keep, and ``on_cancel`` that the
+ dialog is done with — which the owner answers by restoring what it snapshotted.
+ """
+
+ def __init__(
+ self,
+ *,
+ layout: SettingsLayout,
+ language_manager: LanguageManager,
+ key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
+ ) -> None:
+ self._language_manager = language_manager
+ self._layout = layout
+ self._view_model: Optional[DisplaySettingsViewModel] = None
+
+ self.on_settings_changed: Optional[SettingsCallback] = None
+ self.on_commit: Optional[VoidCallback] = None
+ self.on_cancel: Optional[VoidCallback] = None
+
+ self._lbl_unlimited = language_manager["settings.display.label.unlimited_frame_rate"]
+
+ super().__init__(
+ tag=TAG_SETTINGS_DISPLAY_WINDOW,
+ width=layout.display.window.width,
+ height=layout.display.window.height,
+ key_router=key_router,
+ shortcut_source=shortcut_source,
+ )
+
+ def open(self, view_model: DisplaySettingsViewModel) -> None:
+ """Shows the window seeded with the given display settings."""
+ self._view_model = view_model
+ self.show()
+
+ def prepare(self, *_args: Any, **_kwargs: Any) -> None:
+ """The rendered values are seeded by :meth:`open` before the tree rebuilds."""
+
+ def update_view(self, view_model: DisplaySettingsViewModel) -> None:
+ """Re-seeds the controls of the open window from the given display settings."""
+ self._view_model = view_model
+ self._render()
+
+ def create_window(self) -> None:
+ with self.dialog_window(
+ label=self._language_manager["settings.display.title.window_title"],
+ on_close=self._request_cancel,
+ ):
+ self._create_window_section()
+ dpg.add_separator()
+ self._create_pacing_section()
+ dpg.add_separator()
+ self._create_appearance_section()
+ dpg.add_separator()
+ self._create_action_buttons()
+
+ self._bind_dialog_theme(
+ TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION,
+ TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE,
+ TAG_SETTINGS_DISPLAY_COMBO_PALETTE,
+ )
+
+ self._render()
+ self._install_navigation(
+ [
+ FocusStop.field(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION),
+ FocusStop.field(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS),
+ FocusStop.field(TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN),
+ FocusStop.field(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC),
+ FocusStop.field(TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE),
+ FocusStop.field(TAG_SETTINGS_DISPLAY_COMBO_PALETTE),
+ FocusStop.button(TAG_SETTINGS_DISPLAY_BUTTON_CANCEL, self._request_cancel),
+ FocusStop.button(TAG_SETTINGS_DISPLAY_BUTTON_OK, self._request_commit),
+ ],
+ on_escape=self._request_cancel,
+ )
+
+ def _create_window_section(self) -> None:
+ view_model = self._require_view_model()
+ subheader(self._language_manager["settings.display.title.section_window"])
+ with labeled_field(
+ self._language_manager["settings.display.label.resolution"],
+ self._layout.label_width,
+ ):
+ dpg.add_combo(
+ tag=TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION,
+ items=list(view_model.resolution_items),
+ default_value=view_model.current_resolution_item,
+ width=self._layout.combo_width,
+ callback=self._on_resolution_changed,
+ )
+
+ dpg.add_checkbox(
+ tag=TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS,
+ label=self._language_manager["settings.display.label.borderless"],
+ default_value=view_model.settings.window.borderless,
+ callback=self._on_borderless_changed,
+ )
+ dpg.add_checkbox(
+ tag=TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN,
+ label=self._language_manager["settings.display.label.fullscreen"],
+ default_value=view_model.settings.window.fullscreen,
+ callback=self._on_fullscreen_changed,
+ )
+
+ def _create_pacing_section(self) -> None:
+ view_model = self._require_view_model()
+ subheader(self._language_manager["settings.display.title.section_pacing"])
+ dpg.add_checkbox(
+ tag=TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC,
+ label=self._language_manager["settings.display.label.vsync"],
+ default_value=view_model.settings.vsync,
+ callback=self._on_vsync_changed,
+ )
+ with labeled_field(
+ self._language_manager["settings.display.label.frame_rate"],
+ self._layout.label_width,
+ ):
+ dpg.add_combo(
+ tag=TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE,
+ items=list(view_model.frame_rate_items(self._lbl_unlimited)),
+ default_value=view_model.current_frame_rate_item(self._lbl_unlimited),
+ width=self._layout.combo_width,
+ callback=self._on_frame_rate_changed,
+ )
+
+ def _create_appearance_section(self) -> None:
+ view_model = self._require_view_model()
+ subheader(self._language_manager["settings.display.title.section_appearance"])
+ with labeled_field(
+ self._language_manager["settings.display.label.theme"],
+ self._layout.label_width,
+ ):
+ dpg.add_combo(
+ tag=TAG_SETTINGS_DISPLAY_COMBO_PALETTE,
+ items=list(view_model.palettes),
+ default_value=view_model.settings.palette,
+ width=self._layout.combo_width,
+ callback=self._on_palette_changed,
+ )
+
+ @table_wrapper(columns=2)
+ def _create_action_buttons(self) -> None:
+ GUIButton(
+ tag=TAG_SETTINGS_DISPLAY_BUTTON_CANCEL,
+ label=self._language_manager["global.dialog.label.cancel"],
+ callback=self._request_cancel,
+ width=-1,
+ )
+ GUIButton(
+ tag=TAG_SETTINGS_DISPLAY_BUTTON_OK,
+ label=self._language_manager["global.dialog.label.ok"],
+ callback=self._request_commit,
+ width=-1,
+ )
+
+ def _render(self) -> None:
+ """Shows the standing selection, offering the size and frame controls while they apply."""
+ view_model = self._require_view_model()
+ dpg_configure_item(
+ TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION,
+ items=list(view_model.resolution_items),
+ enabled=view_model.window_controls_enabled,
+ )
+ dpg_set_value(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, view_model.current_resolution_item)
+ dpg_configure_item(
+ TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS,
+ enabled=view_model.window_controls_enabled,
+ )
+ dpg_set_value(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS, view_model.settings.window.borderless)
+ dpg_set_value(TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN, view_model.settings.window.fullscreen)
+ dpg_set_value(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC, view_model.settings.vsync)
+ dpg_configure_item(
+ TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE,
+ items=list(view_model.frame_rate_items(self._lbl_unlimited)),
+ )
+ dpg_set_value(
+ TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE,
+ view_model.current_frame_rate_item(self._lbl_unlimited),
+ )
+ dpg_configure_item(TAG_SETTINGS_DISPLAY_COMBO_PALETTE, items=list(view_model.palettes))
+ dpg_set_value(TAG_SETTINGS_DISPLAY_COMBO_PALETTE, view_model.settings.palette)
+
+ def _on_resolution_changed(self, _sender: Sender, app_data: str) -> None:
+ view_model = self._require_view_model()
+ resolution = view_model.resolution_for_item(app_data)
+ self._emit_window(view_model.settings.window.with_resolution(resolution))
+
+ def _on_borderless_changed(self, _sender: Sender, app_data: bool) -> None:
+ window = self._require_view_model().settings.window
+ self._emit_window(window.with_borderless(bool(app_data)))
+
+ def _on_fullscreen_changed(self, _sender: Sender, app_data: bool) -> None:
+ window = self._require_view_model().settings.window
+ self._emit_window(window.with_fullscreen(bool(app_data)))
+
+ def _on_vsync_changed(self, _sender: Sender, app_data: bool) -> None:
+ settings = self._require_view_model().settings
+ self._emit(settings.with_vsync(bool(app_data)))
+
+ def _on_frame_rate_changed(self, _sender: Sender, app_data: str) -> None:
+ view_model = self._require_view_model()
+ frame_rate = view_model.frame_rate_for_item(app_data, self._lbl_unlimited)
+ self._emit(view_model.settings.with_frame_rate(frame_rate))
+
+ def _on_palette_changed(self, _sender: Sender, app_data: str) -> None:
+ settings = self._require_view_model().settings
+ self._emit(settings.with_palette(app_data))
+
+ def _emit(self, settings: DisplaySettings) -> None:
+ self.call(self.on_settings_changed, settings)
+
+ def _emit_window(self, window: WindowMode) -> None:
+ self._emit(self._require_view_model().settings.with_window(window))
+
+ def _request_commit(self) -> None:
+ self.call(self.on_commit)
+
+ def _request_cancel(self) -> None:
+ self.call(self.on_cancel)
+
+ def _require_view_model(self) -> DisplaySettingsViewModel:
+ """The settings on screen.
+
+ Raises:
+ SystemError: when the window is drawn before :meth:`open` seeds it.
+ """
+ if self._view_model is None:
+ raise SystemError("The display settings window is drawn from a view model it was opened with")
+
+ return self._view_model
diff --git a/src/sampletones_application/ui/panels/dialogs/keybindings.py b/src/sampletones_application/ui/panels/dialogs/keybindings.py
new file mode 100644
index 00000000..c5db0248
--- /dev/null
+++ b/src/sampletones_application/ui/panels/dialogs/keybindings.py
@@ -0,0 +1,422 @@
+from typing import Any, Callable, Optional
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.elements.settings import KeybindingsElements
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.settings import SettingsLayout
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.settings import (
+ PRE_SETTINGS_KEYBINDINGS_GROUP,
+ PRE_SETTINGS_KEYBINDINGS_ROW,
+ SUF_SETTINGS_KEYBINDINGS_ACTION,
+ SUF_SETTINGS_KEYBINDINGS_SHORTCUT,
+ TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL,
+ TAG_SETTINGS_KEYBINDINGS_BUTTON_CLEAR,
+ TAG_SETTINGS_KEYBINDINGS_BUTTON_OK,
+ TAG_SETTINGS_KEYBINDINGS_BUTTON_RESET,
+ TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME,
+ TAG_SETTINGS_KEYBINDINGS_INPUT_FILTER,
+ TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT,
+ TAG_SETTINGS_KEYBINDINGS_PANEL_ACTIONS,
+ TAG_SETTINGS_KEYBINDINGS_TABLE_ACTIONS,
+ TAG_SETTINGS_KEYBINDINGS_TEXT_MESSAGE,
+ TAG_SETTINGS_KEYBINDINGS_WINDOW,
+)
+from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.dialog import GUIDialogWindow
+from sampletones_application.ui.elements.field import labeled_field
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.utils.gui.align import table_wrapper
+from sampletones_application.utils.gui.dialog_navigation import FocusStop
+from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value
+from sampletones_application.utils.gui.keyboard import KeyCombination, KeyRouter
+from sampletones_application.utils.gui.keyboard.capture import KeyCapture
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.view_model.shared.keybindings import (
+ KeybindingGroup,
+ KeybindingRow,
+ KeybindingsViewModel,
+)
+from sampletones_shared.types.application import Sender
+from sampletones_shared.types.callback import StringCallback, VoidCallback
+
+CombinationCallback = Callable[[KeyCombination], None]
+
+
+class GUIKeybindingsWindow(GUIDialogWindow):
+ """Modal form over the keys each action answers to, one row per action grouped by its scope.
+
+ A row is given keys either way round: clicking its shortcut cell listens for the press to
+ assign, and the entry box below writes a combination out for the actions a press cannot reach.
+ Both report through their own hook, so the owner decides what an assignment means and this
+ window shows what it decided.
+
+ The action set is fixed, so the rows are built once per appearance and every later view re-reads
+ their labels; the filter reaches the same rows through their visibility, which keeps a keystroke
+ off the widget tree.
+ """
+
+ def __init__(
+ self,
+ *,
+ layout: SettingsLayout,
+ language_manager: LanguageManager,
+ key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
+ ) -> None:
+ self._language_manager = language_manager
+ self._layout = layout
+ self._capture: Optional[KeyCapture] = None
+ self._view_model: Optional[KeybindingsViewModel] = None
+ self._filter = ""
+
+ self.on_scheme_selected: Optional[StringCallback] = None
+ self.on_action_selected: Optional[StringCallback] = None
+ self.on_combination_typed: Optional[StringCallback] = None
+ self.on_combination_captured: Optional[CombinationCallback] = None
+ self.on_clear: Optional[VoidCallback] = None
+ self.on_reset: Optional[VoidCallback] = None
+ self.on_commit: Optional[VoidCallback] = None
+ self.on_cancel: Optional[VoidCallback] = None
+
+ self._lbl_unbound = self._label(KeybindingsElements.UNBOUND)
+ self._msg_capturing = self._message(KeybindingsElements.CAPTURING)
+
+ super().__init__(
+ tag=TAG_SETTINGS_KEYBINDINGS_WINDOW,
+ width=layout.keybindings.window.width,
+ height=layout.keybindings.window.height,
+ key_router=key_router,
+ shortcut_source=shortcut_source,
+ )
+
+ def open(self, view_model: KeybindingsViewModel) -> None:
+ """Shows the window listing the actions of the draft being edited."""
+ self._view_model = view_model
+ self._filter = ""
+ self.show()
+
+ def prepare(self, *_args: Any, **_kwargs: Any) -> None:
+ """The rendered values are seeded by :meth:`open` before the tree rebuilds."""
+
+ def update_view(self, view_model: KeybindingsViewModel) -> None:
+ """Re-reads the rows of the open window from the draft as it now stands."""
+ self._view_model = view_model
+ self._render()
+
+ def create_window(self) -> None:
+ with self.dialog_window(
+ label=self._title(KeybindingsElements.WINDOW_TITLE),
+ on_close=self._request_cancel,
+ ):
+ self._create_scheme_field()
+ self._create_filter_field()
+ self._create_action_list()
+ dpg.add_separator()
+ self._create_shortcut_field()
+ dpg.add_text(tag=TAG_SETTINGS_KEYBINDINGS_TEXT_MESSAGE, default_value="")
+ dpg.add_separator()
+ self._create_action_buttons()
+
+ self._bind_dialog_theme(
+ TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME,
+ TAG_SETTINGS_KEYBINDINGS_INPUT_FILTER,
+ TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT,
+ )
+
+ self._install_capture()
+ self._render()
+ self._install_navigation(
+ [
+ FocusStop.field(TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME),
+ FocusStop.field(TAG_SETTINGS_KEYBINDINGS_INPUT_FILTER),
+ FocusStop.field(TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT),
+ FocusStop.button(TAG_SETTINGS_KEYBINDINGS_BUTTON_CLEAR, self._request_clear),
+ FocusStop.button(TAG_SETTINGS_KEYBINDINGS_BUTTON_RESET, self._request_reset),
+ FocusStop.button(TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, self._request_cancel),
+ FocusStop.button(TAG_SETTINGS_KEYBINDINGS_BUTTON_OK, self._request_commit),
+ ],
+ on_escape=self._request_cancel,
+ )
+
+ def _create_scheme_field(self) -> None:
+ view_model = self._require_view_model()
+ with labeled_field(
+ self._label(KeybindingsElements.SCHEME),
+ self._layout.label_width,
+ ):
+ dpg.add_combo(
+ tag=TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME,
+ items=list(view_model.schemes),
+ default_value=view_model.scheme,
+ width=self._layout.combo_width,
+ callback=self._on_scheme_changed,
+ )
+
+ def _create_filter_field(self) -> None:
+ with labeled_field(
+ self._label(KeybindingsElements.FILTER),
+ self._layout.label_width,
+ ):
+ dpg.add_input_text(
+ tag=TAG_SETTINGS_KEYBINDINGS_INPUT_FILTER,
+ default_value="",
+ width=self._layout.combo_width,
+ callback=self._on_filter_changed,
+ )
+
+ def _create_action_list(self) -> None:
+ with (
+ dpg.child_window(
+ tag=TAG_SETTINGS_KEYBINDINGS_PANEL_ACTIONS,
+ height=self._layout.keybindings.list_height,
+ border=True,
+ ),
+ dpg.table(
+ tag=TAG_SETTINGS_KEYBINDINGS_TABLE_ACTIONS,
+ header_row=True,
+ policy=dpg.mvTable_SizingFixedFit,
+ resizable=False,
+ scrollY=False,
+ ),
+ ):
+ dpg.add_table_column(
+ label=self._label(KeybindingsElements.ACTION),
+ init_width_or_weight=self._layout.keybindings.action_width,
+ )
+ dpg.add_table_column(label=self._label(KeybindingsElements.SHORTCUT))
+ for group in self._require_view_model().groups:
+ self._create_group(group)
+
+ def _create_group(self, group: KeybindingGroup) -> None:
+ with dpg.table_row(tag=compose_tag(PRE_SETTINGS_KEYBINDINGS_GROUP, group.category)):
+ header = dpg.add_text(group.label)
+ FontRegistry.bind_to_item(header, Font.BOLD)
+
+ for row in group.rows:
+ self._create_row(row)
+
+ def _create_row(self, row: KeybindingRow) -> None:
+ row_tag = compose_tag(PRE_SETTINGS_KEYBINDINGS_ROW, row.action)
+ with dpg.table_row(tag=row_tag):
+ dpg.add_selectable(
+ tag=compose_tag(row_tag, SUF_SETTINGS_KEYBINDINGS_ACTION),
+ label=row.label,
+ user_data=row.action,
+ callback=self._on_action_clicked,
+ )
+ dpg.add_selectable(
+ tag=compose_tag(row_tag, SUF_SETTINGS_KEYBINDINGS_SHORTCUT),
+ label=row.combination,
+ user_data=row.action,
+ callback=self._on_shortcut_clicked,
+ )
+
+ def _create_shortcut_field(self) -> None:
+ with labeled_field(
+ self._label(KeybindingsElements.SHORTCUT),
+ self._layout.label_width,
+ ):
+ dpg.add_input_text(
+ tag=TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT,
+ default_value="",
+ width=self._layout.combo_width,
+ on_enter=True,
+ callback=self._on_shortcut_typed,
+ )
+ GUIButton(
+ tag=TAG_SETTINGS_KEYBINDINGS_BUTTON_CLEAR,
+ label=self._label(KeybindingsElements.CLEAR_BUTTON),
+ callback=self._request_clear,
+ )
+
+ @table_wrapper(columns=3)
+ def _create_action_buttons(self) -> None:
+ GUIButton(
+ tag=TAG_SETTINGS_KEYBINDINGS_BUTTON_RESET,
+ label=self._label(KeybindingsElements.RESET_BUTTON),
+ callback=self._request_reset,
+ width=-1,
+ )
+ GUIButton(
+ tag=TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL,
+ label=self._language_manager["global.dialog.label.cancel"],
+ callback=self._request_cancel,
+ width=-1,
+ )
+ GUIButton(
+ tag=TAG_SETTINGS_KEYBINDINGS_BUTTON_OK,
+ label=self._language_manager["global.dialog.label.ok"],
+ callback=self._request_commit,
+ width=-1,
+ )
+
+ def _install_capture(self) -> None:
+ """Readies the capture that reads a press, cancelled by whatever a dialog is cancelled by."""
+ self._capture = KeyCapture(
+ key_router=self._router,
+ cancel=self._shortcuts.shortcut(ShortcutId.DIALOG_CANCEL).combinations(),
+ )
+ self._capture.on_captured = self._report_captured
+ self._capture.on_cancelled = self._render
+
+ def _teardown(self) -> None:
+ """Stops the capture this appearance armed before the keyboard claim is released."""
+ if self._capture is not None:
+ self._capture.stop()
+ self._capture = None
+
+ super()._teardown()
+
+ def _render(self) -> None:
+ """Shows each action's keys, the standing selection, and what the filter leaves listed."""
+ view_model = self._require_view_model()
+ dpg_set_value(TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME, view_model.scheme)
+ dpg_set_value(TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT, view_model.combination)
+ dpg_set_value(TAG_SETTINGS_KEYBINDINGS_TEXT_MESSAGE, view_model.message)
+ for group in view_model.groups:
+ self._render_group(group, view_model.selected)
+
+ def _render_group(self, group: KeybindingGroup, selected: Optional[str]) -> None:
+ listed = False
+ for row in group.rows:
+ matches = row.matches(self._filter)
+ listed = listed or matches
+ self._render_row(row, selected=selected, listed=matches)
+
+ dpg_configure_item(
+ compose_tag(PRE_SETTINGS_KEYBINDINGS_GROUP, group.category),
+ show=listed,
+ )
+
+ def _render_row(
+ self,
+ row: KeybindingRow,
+ *,
+ selected: Optional[str],
+ listed: bool,
+ ) -> None:
+ row_tag = compose_tag(PRE_SETTINGS_KEYBINDINGS_ROW, row.action)
+ is_selected = row.action == selected
+ dpg_configure_item(row_tag, show=listed)
+ dpg_configure_item(
+ compose_tag(row_tag, SUF_SETTINGS_KEYBINDINGS_ACTION),
+ label=row.label,
+ )
+ dpg_set_value(compose_tag(row_tag, SUF_SETTINGS_KEYBINDINGS_ACTION), is_selected)
+ dpg_configure_item(
+ compose_tag(row_tag, SUF_SETTINGS_KEYBINDINGS_SHORTCUT),
+ label=self._shortcut_label(row, is_selected=is_selected),
+ )
+ dpg_set_value(compose_tag(row_tag, SUF_SETTINGS_KEYBINDINGS_SHORTCUT), is_selected)
+
+ def _shortcut_label(self, row: KeybindingRow, *, is_selected: bool) -> str:
+ """What a row's shortcut cell reads: the prompt while it listens, its keys otherwise."""
+ if is_selected and self._capture is not None and self._capture.is_listening:
+ return self._msg_capturing
+
+ return row.combination if row.combination else self._lbl_unbound
+
+ def _on_scheme_changed(self, _sender: Sender, app_data: str) -> None:
+ self.call(self.on_scheme_selected, app_data)
+
+ def _on_filter_changed(self, _sender: Sender, app_data: str) -> None:
+ self._filter = app_data
+ self._render()
+
+ def _on_action_clicked(
+ self,
+ _sender: Sender,
+ _app_data: bool,
+ user_data: str,
+ ) -> None:
+ self._stop_capture()
+ self.call(self.on_action_selected, user_data)
+
+ def _on_shortcut_clicked(
+ self,
+ _sender: Sender,
+ _app_data: bool,
+ user_data: str,
+ ) -> None:
+ """Selects the row and listens for the press that gives it keys."""
+ self._stop_capture()
+ self.call(self.on_action_selected, user_data)
+ self._require_capture().start()
+ self._render()
+
+ def _on_shortcut_typed(self, _sender: Sender, app_data: str) -> None:
+ self.call(self.on_combination_typed, app_data)
+
+ def _report_captured(self, combination: KeyCombination) -> None:
+ self.call(self.on_combination_captured, combination)
+
+ def _stop_capture(self) -> None:
+ if self._capture is not None:
+ self._capture.stop()
+
+ def _request_clear(self) -> None:
+ self._stop_capture()
+ self.call(self.on_clear)
+
+ def _request_reset(self) -> None:
+ self._stop_capture()
+ self.call(self.on_reset)
+
+ def _request_commit(self) -> None:
+ self._stop_capture()
+ self.call(self.on_commit)
+
+ def _request_cancel(self) -> None:
+ self._stop_capture()
+ self.call(self.on_cancel)
+
+ def _label(self, element: KeybindingsElements) -> str:
+ return self._language_manager[
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ TextType.LABEL,
+ element,
+ ]
+
+ def _title(self, element: KeybindingsElements) -> str:
+ return self._language_manager[
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ TextType.TITLE,
+ element,
+ ]
+
+ def _message(self, element: KeybindingsElements) -> str:
+ return self._language_manager[
+ Page.SETTINGS,
+ Panel.KEYBINDINGS,
+ TextType.MESSAGE,
+ element,
+ ]
+
+ def _require_capture(self) -> KeyCapture:
+ """The capture the open window arms.
+
+ Raises:
+ SystemError: when a press is listened for before the window builds its tree.
+ """
+ if self._capture is None:
+ raise SystemError("The keybindings window listens for a press only while it is open")
+
+ return self._capture
+
+ def _require_view_model(self) -> KeybindingsViewModel:
+ """The actions on screen.
+
+ Raises:
+ SystemError: when the window is drawn before :meth:`open` seeds it.
+ """
+ if self._view_model is None:
+ raise SystemError("The keybindings window is drawn from a view model it was opened with")
+
+ return self._view_model
diff --git a/src/sampletones_application/ui/panels/dialogs/project_properties.py b/src/sampletones_application/ui/panels/dialogs/project_properties.py
index 5b4ca11c..1254a666 100644
--- a/src/sampletones_application/ui/panels/dialogs/project_properties.py
+++ b/src/sampletones_application/ui/panels/dialogs/project_properties.py
@@ -8,44 +8,49 @@
from sampletones_application.categories.hierarchy import Page, Panel, TextType
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.layout.project_properties import ProjectPropertiesLayout
-from sampletones_application.tags.general import TAG_GLOBAL_THEME_DIALOG
from sampletones_application.tags.settings import (
TAG_SETTINGS_PROPERTIES_BUTTON_CANCEL,
TAG_SETTINGS_PROPERTIES_BUTTON_OK,
TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR,
TAG_SETTINGS_PROPERTIES_INPUT_COMMENT,
+ TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT,
+ TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT,
TAG_SETTINGS_PROPERTIES_INPUT_TITLE,
TAG_SETTINGS_PROPERTIES_WINDOW,
)
from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.dialog import GUIDialogWindow
from sampletones_application.ui.elements.field import labeled_field
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
-from sampletones_application.ui.elements.window import GUIWindow
-from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.gui.align import table_wrapper
-from sampletones_application.utils.gui.dialog_navigation import (
- DialogKeyboardNavigator,
- FocusStop,
-)
+from sampletones_application.utils.gui.dialog_navigation import FocusStop
from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.utils.gui.tooltip import show_tooltip
+from sampletones_application.utils.gui.widgets import clamp_widget_value
from sampletones_application.view_model.shared.project_properties import (
ProjectPropertiesViewModel,
)
from sampletones_shared.constants.project import (
+ DEFAULT_FIRST_HIGHLIGHT,
+ DEFAULT_SECOND_HIGHLIGHT,
+ MAX_HIGHLIGHT,
MAX_PROJECT_AUTHOR_LENGTH,
MAX_PROJECT_COMMENT_LENGTH,
MAX_PROJECT_TITLE_LENGTH,
+ MIN_HIGHLIGHT,
)
-class GUIProjectPropertiesWindow(GUIWindow):
- """Modal form to view and edit the project's title, author, and comment.
+class GUIProjectPropertiesWindow(GUIDialogWindow):
+ """Modal form to view and edit the project's title, author, comment, and metre.
Each appearance renders the view model handed to :meth:`open`, and the edited
values reach the ``on_commit`` hook on confirmation, so the owner applies
them as one undoable gesture. The title/author/comment feed the exported
- ``.ftm`` INFO block.
+ ``.ftm`` INFO block, and the two metric highlights say how many rows a beat
+ and a bar span.
"""
def __init__(
@@ -54,18 +59,18 @@ def __init__(
layout: ProjectPropertiesLayout,
language_manager: LanguageManager,
key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
) -> None:
self._language_manager = language_manager
self._layout = layout
- self._router = key_router
- self._dialog_theme = ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG)
- self._navigator: Optional[DialogKeyboardNavigator] = None
- self.on_commit: Optional[Callable[[str, str, str], None]] = None
+ self.on_commit: Optional[Callable[[str, str, str, int, int], None]] = None
self._title_value = ""
self._author_value = ""
self._comment_value = ""
+ self._first_highlight_value = DEFAULT_FIRST_HIGHLIGHT
+ self._second_highlight_value = DEFAULT_SECOND_HIGHLIGHT
self._created_text = ""
self._modified_text = ""
@@ -81,6 +86,14 @@ def __init__(
language_manager,
ProjectPropertiesElements.COMMENT,
)
+ self._lbl_first_highlight = self._label(
+ language_manager,
+ ProjectPropertiesElements.FIRST_HIGHLIGHT,
+ )
+ self._lbl_second_highlight = self._label(
+ language_manager,
+ ProjectPropertiesElements.SECOND_HIGHLIGHT,
+ )
self._lbl_created = self._label(
language_manager,
ProjectPropertiesElements.CREATED,
@@ -94,30 +107,32 @@ def __init__(
tag=TAG_SETTINGS_PROPERTIES_WINDOW,
width=layout.window.width,
height=layout.window.height,
+ key_router=key_router,
+ shortcut_source=shortcut_source,
)
def open(self, view_model: ProjectPropertiesViewModel) -> None:
"""Shows the dialog seeded with the given project info."""
+ self._seed(view_model)
+ self.show()
+
+ def prepare(self, *_args: Any, **_kwargs: Any) -> None:
+ """The rendered values are seeded by :meth:`open` before the tree rebuilds."""
+
+ def _seed(self, view_model: ProjectPropertiesViewModel) -> None:
+ """Holds the values the next appearance renders."""
self._title_value = view_model.title
self._author_value = view_model.author
self._comment_value = view_model.comment
+ self._first_highlight_value = view_model.first_highlight
+ self._second_highlight_value = view_model.second_highlight
self._created_text = view_model.created_text
self._modified_text = view_model.modified_text
- self.show()
-
- def prepare(self, *_args: Any, **_kwargs: Any) -> None:
- """The rendered values are seeded by :meth:`open` before the tree rebuilds."""
def create_window(self) -> None:
- with dpg.window(
- tag=self.tag,
+ with self.dialog_window(
label=self._language_manager["settings.properties.title.window_title"],
- width=self.width,
- height=self.height,
- no_resize=True,
- no_collapse=True,
on_close=self.hide,
- modal=True,
):
self._create_text_field(
TAG_SETTINGS_PROPERTIES_INPUT_TITLE,
@@ -129,41 +144,34 @@ def create_window(self) -> None:
self._lbl_author,
self._author_value,
)
+ self._create_highlight_fields()
+ dpg.add_separator()
self._create_comment_field()
dpg.add_separator()
self._create_metadata()
dpg.add_separator()
self._create_action_buttons()
- for input_tag in (
+ self._bind_dialog_theme(
TAG_SETTINGS_PROPERTIES_INPUT_TITLE,
TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR,
TAG_SETTINGS_PROPERTIES_INPUT_COMMENT,
- ):
- self._dialog_theme.bind_to_item(input_tag)
-
- self._install_navigation()
+ TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT,
+ TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT,
+ )
- def _install_navigation(self) -> None:
- """Wires Tab/Enter/Escape keyboard navigation over the form's fields and buttons."""
- self._navigator = DialogKeyboardNavigator(
- window_tag=self.tag,
- stops=[
+ self._install_navigation(
+ [
FocusStop.field(TAG_SETTINGS_PROPERTIES_INPUT_TITLE),
FocusStop.field(TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR),
+ FocusStop.field(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT),
+ FocusStop.field(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT),
FocusStop.field(TAG_SETTINGS_PROPERTIES_INPUT_COMMENT),
FocusStop.button(TAG_SETTINGS_PROPERTIES_BUTTON_CANCEL, self.hide),
FocusStop.button(TAG_SETTINGS_PROPERTIES_BUTTON_OK, self._commit),
],
on_escape=self.hide,
- key_router=self._router,
)
- self._navigator.install()
-
- def _teardown(self) -> None:
- if self._navigator is not None:
- self._navigator.dispose()
- self._navigator = None
def _create_text_field(self, tag: str, label: str, value: str) -> None:
with labeled_field(label, self._layout.label_width):
@@ -184,6 +192,42 @@ def _create_comment_field(self) -> None:
height=self._layout.comment_height,
)
+ def _create_highlight_fields(self) -> None:
+ """Renders the two metric highlights, the row counts a beat and a bar span."""
+ self._create_highlight_field(
+ TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT,
+ self._lbl_first_highlight,
+ self._first_highlight_value,
+ self._language_manager["settings.properties.tooltip.first_highlight"],
+ )
+ self._create_highlight_field(
+ TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT,
+ self._lbl_second_highlight,
+ self._second_highlight_value,
+ self._language_manager["settings.properties.tooltip.second_highlight"],
+ )
+
+ def _create_highlight_field(
+ self,
+ tag: str,
+ label: str,
+ value: int,
+ tooltip: str,
+ ) -> None:
+ with labeled_field(label, self._layout.label_width):
+ dpg.add_input_int(
+ tag=tag,
+ default_value=value,
+ min_value=MIN_HIGHLIGHT,
+ max_value=MAX_HIGHLIGHT,
+ min_clamped=True,
+ max_clamped=True,
+ width=self._layout.input_width,
+ )
+
+ FontRegistry.bind_to_item(tag, Font.MONO)
+ show_tooltip(tag, tooltip)
+
def _create_metadata(self) -> None:
self._create_metadata_row(self._lbl_created, self._created_text)
self._create_metadata_row(self._lbl_modified, self._modified_text)
@@ -215,6 +259,8 @@ def _commit(self) -> None:
dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE)[:MAX_PROJECT_TITLE_LENGTH],
dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR)[:MAX_PROJECT_AUTHOR_LENGTH],
dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_COMMENT)[:MAX_PROJECT_COMMENT_LENGTH],
+ int(clamp_widget_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT)),
+ int(clamp_widget_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT)),
)
self.hide()
diff --git a/src/sampletones_application/ui/panels/dialogs/render.py b/src/sampletones_application/ui/panels/dialogs/render.py
new file mode 100644
index 00000000..459494d2
--- /dev/null
+++ b/src/sampletones_application/ui/panels/dialogs/render.py
@@ -0,0 +1,450 @@
+from typing import Any, Callable, Dict, Optional
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.general.colors.path import PathColors
+from sampletones_application.layout.settings import SettingsLayout
+from sampletones_application.tags.settings import (
+ TAG_SETTINGS_RENDER_BUTTON_BROWSE,
+ TAG_SETTINGS_RENDER_BUTTON_CANCEL,
+ TAG_SETTINGS_RENDER_BUTTON_CLOSE,
+ TAG_SETTINGS_RENDER_BUTTON_START,
+ TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE,
+ TAG_SETTINGS_RENDER_COMBO_BITRATE,
+ TAG_SETTINGS_RENDER_COMBO_DEPTH,
+ TAG_SETTINGS_RENDER_COMBO_FORMAT,
+ TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE,
+ TAG_SETTINGS_RENDER_GROUP_BITRATE,
+ TAG_SETTINGS_RENDER_GROUP_DEPTH,
+ TAG_SETTINGS_RENDER_GROUP_DESTINATION,
+ TAG_SETTINGS_RENDER_GROUP_PROGRESS,
+ TAG_SETTINGS_RENDER_GROUP_SETUP,
+ TAG_SETTINGS_RENDER_PATH_DESTINATION,
+ TAG_SETTINGS_RENDER_PROGRESS,
+ TAG_SETTINGS_RENDER_TEXT_DURATION,
+ TAG_SETTINGS_RENDER_TEXT_STATUS,
+ TAG_SETTINGS_RENDER_WINDOW,
+)
+from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.dialog import GUIDialogWindow
+from sampletones_application.ui.elements.field import labeled_field
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.elements.path import GUIDestinationPathText
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.utils.gui.align import table_wrapper
+from sampletones_application.utils.gui.dialog_navigation import FocusStop
+from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.view_model.shared.render import (
+ SongRenderSettings,
+ SongRenderViewModel,
+)
+from sampletones_core.audio.writers import AudioDepth, AudioFormat
+from sampletones_shared.types.application import Sender
+from sampletones_shared.types.callback import VoidCallback
+
+SettingsCallback = Callable[[SongRenderSettings], None]
+
+
+class GUIRenderWindow(GUIDialogWindow):
+ """Modal form over writing the open song to an audio file.
+
+ The dialog has two faces and shows one at a time: the setup, where the file is described and
+ the render is started, and the progress, where the running pass reports itself and takes a
+ stop. Which one stands is the phase the view model carries, so the window draws whatever it
+ is handed.
+
+ Every control reports the whole edited state through ``on_settings_changed``, which the owner
+ reconciles and hands back — so what a container accepts decides what the next combo offers,
+ and the dialog shows the choices as they end up rather than as they were asked for.
+ """
+
+ def __init__(
+ self,
+ *,
+ layout: SettingsLayout,
+ path_colors: PathColors,
+ language_manager: LanguageManager,
+ key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
+ status_bar: GUIStatusBar,
+ ) -> None:
+ self._language_manager = language_manager
+ self._layout = layout
+ self._path_colors = path_colors
+ self._status_bar = status_bar
+ self._view_model: Optional[SongRenderViewModel] = None
+ self._destination_text: Optional[GUIDestinationPathText] = None
+
+ self.on_settings_changed: Optional[SettingsCallback] = None
+ self.on_browse: Optional[VoidCallback] = None
+ self.on_render: Optional[VoidCallback] = None
+ self.on_cancel: Optional[VoidCallback] = None
+ self.on_close: Optional[VoidCallback] = None
+
+ self._formats_by_label: Dict[str, AudioFormat] = {}
+ self._sample_rates_by_label: Dict[str, int] = {}
+ self._depths_by_label: Dict[str, AudioDepth] = {}
+ self._bitrates_by_label: Dict[str, int] = {}
+
+ self._fmt_sample_rate = language_manager["settings.render.template.sample_rate"]
+ self._fmt_bitrate = language_manager["settings.render.template.bitrate"]
+ self._msg_destination = language_manager["global.status.message.destination"]
+ self._format_labels: Dict[AudioFormat, str] = {
+ AudioFormat.WAVE: language_manager["settings.render.label.format_wave"],
+ AudioFormat.MP3: language_manager["settings.render.label.format_mp3"],
+ }
+ self._depth_labels: Dict[AudioDepth, str] = {
+ AudioDepth.PCM_U8: language_manager["settings.render.label.depth_pcm_u8"],
+ AudioDepth.PCM_16: language_manager["settings.render.label.depth_pcm_16"],
+ AudioDepth.PCM_24: language_manager["settings.render.label.depth_pcm_24"],
+ AudioDepth.PCM_32: language_manager["settings.render.label.depth_pcm_32"],
+ AudioDepth.FLOAT_32: language_manager["settings.render.label.depth_float_32"],
+ }
+
+ super().__init__(
+ tag=TAG_SETTINGS_RENDER_WINDOW,
+ width=layout.render.window.width,
+ height=layout.render.window.height,
+ key_router=key_router,
+ shortcut_source=shortcut_source,
+ )
+
+ def open(self, view_model: SongRenderViewModel) -> None:
+ """Shows the window seeded with the render being set up."""
+ self._view_model = view_model
+ self.show()
+
+ def prepare(self, *_args: Any, **_kwargs: Any) -> None:
+ """The rendered values are seeded by :meth:`open` before the tree rebuilds."""
+
+ def update_view(self, view_model: SongRenderViewModel) -> None:
+ """Re-seeds the controls of the open window from where the render stands."""
+ self._view_model = view_model
+ self._render()
+
+ def create_window(self) -> None:
+ with self.dialog_window(
+ label=self._language_manager["settings.render.title.window_title"],
+ on_close=self._request_close,
+ ):
+ self._create_setup()
+ self._create_progress()
+
+ self._bind_dialog_theme(
+ TAG_SETTINGS_RENDER_COMBO_FORMAT,
+ TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE,
+ TAG_SETTINGS_RENDER_COMBO_DEPTH,
+ TAG_SETTINGS_RENDER_COMBO_BITRATE,
+ )
+
+ self._render()
+ self._install_navigation(
+ [
+ FocusStop.field(TAG_SETTINGS_RENDER_COMBO_FORMAT),
+ FocusStop.field(TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE),
+ FocusStop.field(TAG_SETTINGS_RENDER_COMBO_DEPTH),
+ FocusStop.field(TAG_SETTINGS_RENDER_COMBO_BITRATE),
+ FocusStop.field(TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE),
+ FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_BROWSE, self._request_destination),
+ FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_CLOSE, self._request_close),
+ FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_START, self._request_render),
+ FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_CANCEL, self._request_cancel),
+ ],
+ on_escape=self._request_close,
+ )
+
+ def _create_setup(self) -> None:
+ with dpg.group(tag=TAG_SETTINGS_RENDER_GROUP_SETUP):
+ self._create_format_selection()
+ self._create_sample_rate_selection()
+ self._create_depth_selection()
+ self._create_bitrate_selection()
+ self._create_normalize_switch()
+ self._create_duration()
+ dpg.add_separator()
+ self._create_destination()
+ dpg.add_separator()
+ self._create_setup_buttons()
+
+ def _create_format_selection(self) -> None:
+ with labeled_field(
+ self._language_manager["settings.render.label.format"],
+ self._layout.label_width,
+ ):
+ dpg.add_combo(
+ tag=TAG_SETTINGS_RENDER_COMBO_FORMAT,
+ items=[],
+ width=self._layout.combo_width,
+ callback=self._on_format_changed,
+ )
+
+ def _create_sample_rate_selection(self) -> None:
+ with labeled_field(
+ self._language_manager["settings.render.label.sample_rate"],
+ self._layout.label_width,
+ ):
+ dpg.add_combo(
+ tag=TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE,
+ items=[],
+ width=self._layout.combo_width,
+ callback=self._on_sample_rate_changed,
+ )
+
+ def _create_depth_selection(self) -> None:
+ with (
+ dpg.group(tag=TAG_SETTINGS_RENDER_GROUP_DEPTH),
+ labeled_field(
+ self._language_manager["settings.render.label.depth"],
+ self._layout.label_width,
+ ),
+ ):
+ dpg.add_combo(
+ tag=TAG_SETTINGS_RENDER_COMBO_DEPTH,
+ items=[],
+ width=self._layout.combo_width,
+ callback=self._on_depth_changed,
+ )
+
+ def _create_bitrate_selection(self) -> None:
+ with (
+ dpg.group(tag=TAG_SETTINGS_RENDER_GROUP_BITRATE),
+ labeled_field(
+ self._language_manager["settings.render.label.bitrate"],
+ self._layout.label_width,
+ ),
+ ):
+ dpg.add_combo(
+ tag=TAG_SETTINGS_RENDER_COMBO_BITRATE,
+ items=[],
+ width=self._layout.combo_width,
+ callback=self._on_bitrate_changed,
+ )
+
+ def _create_normalize_switch(self) -> None:
+ dpg.add_checkbox(
+ tag=TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE,
+ label=self._language_manager["settings.render.label.normalize"],
+ callback=self._on_normalize_changed,
+ )
+
+ def _create_duration(self) -> None:
+ with labeled_field(
+ self._language_manager["settings.render.label.duration"],
+ self._layout.label_width,
+ ):
+ dpg.add_text("", tag=TAG_SETTINGS_RENDER_TEXT_DURATION)
+ FontRegistry.bind_to_item(TAG_SETTINGS_RENDER_TEXT_DURATION, Font.MONO)
+
+ def _create_destination(self) -> None:
+ """Lays out the file a render writes, with the browse button beside the path it stands at.
+
+ The button leads the row so it holds its place whatever the path reads, and the path
+ follows it as the answer to what the button asks.
+ """
+ with labeled_field(
+ self._language_manager["settings.render.label.destination"],
+ self._layout.label_width,
+ ):
+ dpg.add_group(horizontal=True, tag=TAG_SETTINGS_RENDER_GROUP_DESTINATION)
+
+ GUIButton(
+ tag=TAG_SETTINGS_RENDER_BUTTON_BROWSE,
+ label=self._language_manager["settings.render.label.browse_button"],
+ parent=TAG_SETTINGS_RENDER_GROUP_DESTINATION,
+ callback=self._request_destination,
+ )
+ self._destination_text = GUIDestinationPathText(
+ tag=TAG_SETTINGS_RENDER_PATH_DESTINATION,
+ path=self._require_view_model().destination,
+ parent=TAG_SETTINGS_RENDER_GROUP_DESTINATION,
+ color=self._path_colors.default,
+ hover_color=self._path_colors.hover,
+ status_message=self._msg_destination,
+ font=Font.REGULAR_SMALL,
+ status_bar=self._status_bar,
+ )
+
+ @table_wrapper(columns=2)
+ def _create_setup_buttons(self) -> None:
+ GUIButton(
+ tag=TAG_SETTINGS_RENDER_BUTTON_CLOSE,
+ label=self._language_manager["global.dialog.label.cancel"],
+ callback=self._request_close,
+ width=-1,
+ )
+ GUIButton(
+ tag=TAG_SETTINGS_RENDER_BUTTON_START,
+ label=self._language_manager["settings.render.label.render_button"],
+ callback=self._request_render,
+ width=-1,
+ )
+
+ def _create_progress(self) -> None:
+ with dpg.group(tag=TAG_SETTINGS_RENDER_GROUP_PROGRESS, show=False):
+ dpg.add_text("", tag=TAG_SETTINGS_RENDER_TEXT_STATUS)
+ FontRegistry.bind_to_item(TAG_SETTINGS_RENDER_TEXT_STATUS, Font.MONO_SMALL)
+ dpg.add_progress_bar(
+ tag=TAG_SETTINGS_RENDER_PROGRESS,
+ default_value=0.0,
+ width=-1,
+ )
+ FontRegistry.bind_to_item(TAG_SETTINGS_RENDER_PROGRESS, Font.MONO)
+ dpg.add_separator()
+ GUIButton(
+ tag=TAG_SETTINGS_RENDER_BUTTON_CANCEL,
+ label=self._language_manager["global.dialog.label.cancel"],
+ callback=self._request_cancel,
+ width=-1,
+ )
+
+ def _render(self) -> None:
+ """Shows the face the phase calls for, with each choice standing at what it reconciled to."""
+ view_model = self._require_view_model()
+ self._render_setup(view_model)
+ self._render_progress(view_model)
+
+ def _render_setup(self, view_model: SongRenderViewModel) -> None:
+ dpg_configure_item(TAG_SETTINGS_RENDER_GROUP_SETUP, show=view_model.setup_visible)
+ self._render_formats(view_model)
+ self._render_sample_rates(view_model)
+ self._render_depths(view_model)
+ self._render_bitrates(view_model)
+ dpg_configure_item(
+ TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE,
+ enabled=view_model.setup_visible,
+ )
+ dpg_set_value(TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, view_model.settings.normalize)
+ dpg_set_value(TAG_SETTINGS_RENDER_TEXT_DURATION, view_model.duration_label)
+ if self._destination_text is not None:
+ self._destination_text.set_path(view_model.destination)
+
+ dpg_configure_item(TAG_SETTINGS_RENDER_BUTTON_BROWSE, enabled=view_model.setup_visible)
+ dpg_configure_item(TAG_SETTINGS_RENDER_BUTTON_CLOSE, enabled=view_model.setup_visible)
+ dpg_configure_item(TAG_SETTINGS_RENDER_BUTTON_START, enabled=view_model.render_enabled)
+
+ def _render_formats(self, view_model: SongRenderViewModel) -> None:
+ self._formats_by_label = {
+ self._format_labels[audio_format]: audio_format for audio_format in view_model.formats
+ }
+ dpg_configure_item(
+ TAG_SETTINGS_RENDER_COMBO_FORMAT,
+ items=list(self._formats_by_label),
+ enabled=view_model.setup_visible,
+ )
+ dpg_set_value(
+ TAG_SETTINGS_RENDER_COMBO_FORMAT,
+ self._format_labels[view_model.spec.audio_format],
+ )
+
+ def _render_sample_rates(self, view_model: SongRenderViewModel) -> None:
+ self._sample_rates_by_label = dict(
+ zip(
+ view_model.sample_rate_labels(self._fmt_sample_rate),
+ view_model.sample_rates,
+ )
+ )
+ dpg_configure_item(
+ TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE,
+ items=list(self._sample_rates_by_label),
+ enabled=view_model.setup_visible,
+ )
+ dpg_set_value(
+ TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE,
+ view_model.sample_rate_label(self._fmt_sample_rate),
+ )
+
+ def _render_depths(self, view_model: SongRenderViewModel) -> None:
+ self._depths_by_label = {self._depth_labels[depth]: depth for depth in view_model.depths}
+ depth = view_model.settings.depth
+ dpg_configure_item(TAG_SETTINGS_RENDER_GROUP_DEPTH, show=view_model.depth_visible)
+ dpg_configure_item(
+ TAG_SETTINGS_RENDER_COMBO_DEPTH,
+ items=list(self._depths_by_label),
+ enabled=view_model.depth_enabled,
+ )
+ dpg_set_value(
+ TAG_SETTINGS_RENDER_COMBO_DEPTH,
+ self._depth_labels[depth] if depth is not None else "",
+ )
+
+ def _render_bitrates(self, view_model: SongRenderViewModel) -> None:
+ self._bitrates_by_label = dict(
+ zip(
+ view_model.bitrate_labels(self._fmt_bitrate),
+ view_model.bitrates,
+ )
+ )
+ dpg_configure_item(TAG_SETTINGS_RENDER_GROUP_BITRATE, show=view_model.bitrate_visible)
+ dpg_configure_item(
+ TAG_SETTINGS_RENDER_COMBO_BITRATE,
+ items=list(self._bitrates_by_label),
+ enabled=view_model.bitrate_enabled,
+ )
+ dpg_set_value(
+ TAG_SETTINGS_RENDER_COMBO_BITRATE,
+ view_model.bitrate_label(self._fmt_bitrate),
+ )
+
+ def _render_progress(self, view_model: SongRenderViewModel) -> None:
+ dpg_configure_item(TAG_SETTINGS_RENDER_GROUP_PROGRESS, show=view_model.progress_visible)
+ dpg_set_value(TAG_SETTINGS_RENDER_TEXT_STATUS, view_model.status_text)
+ dpg_set_value(TAG_SETTINGS_RENDER_PROGRESS, view_model.progress)
+ dpg_configure_item(TAG_SETTINGS_RENDER_PROGRESS, overlay=view_model.progress_overlay)
+ dpg_configure_item(TAG_SETTINGS_RENDER_BUTTON_CANCEL, enabled=view_model.cancel_enabled)
+
+ def _on_format_changed(self, _sender: Sender, app_data: str) -> None:
+ self._emit(self._settings().with_format(self._formats_by_label[app_data]))
+
+ def _on_sample_rate_changed(self, _sender: Sender, app_data: str) -> None:
+ self._emit(self._settings().with_sample_rate(self._sample_rates_by_label[app_data]))
+
+ def _on_depth_changed(self, _sender: Sender, app_data: str) -> None:
+ self._emit(self._settings().with_depth(self._depths_by_label[app_data]))
+
+ def _on_bitrate_changed(self, _sender: Sender, app_data: str) -> None:
+ self._emit(self._settings().with_bitrate(self._bitrates_by_label[app_data]))
+
+ def _on_normalize_changed(self, _sender: Sender, app_data: bool) -> None:
+ self._emit(self._settings().with_normalize(bool(app_data)))
+
+ def _emit(self, settings: SongRenderSettings) -> None:
+ self.call(self.on_settings_changed, settings)
+
+ def _request_destination(self) -> None:
+ self.call(self.on_browse)
+
+ def _request_render(self) -> None:
+ self.call(self.on_render)
+
+ def _request_cancel(self) -> None:
+ self.call(self.on_cancel)
+
+ def _request_close(self) -> None:
+ """Answers Escape and the title bar: a setup is done with, a running render is asked to stop.
+
+ A render already stopping, and one that has reported its outcome, answer neither — what
+ they are waiting for is the service, which arrives on its own.
+ """
+ view_model = self._require_view_model()
+ if view_model.cancel_enabled:
+ self._request_cancel()
+ elif view_model.setup_visible:
+ self.call(self.on_close)
+
+ def _settings(self) -> SongRenderSettings:
+ return self._require_view_model().settings
+
+ def _require_view_model(self) -> SongRenderViewModel:
+ """The render on screen.
+
+ Raises:
+ SystemError: when the window is drawn before :meth:`open` seeds it.
+ """
+ if self._view_model is None:
+ raise SystemError("The render window is drawn from a view model it was opened with")
+
+ return self._view_model
diff --git a/src/sampletones_application/ui/panels/instruction/choice.py b/src/sampletones_application/ui/panels/instruction/choice.py
index f2e0805d..c2123529 100644
--- a/src/sampletones_application/ui/panels/instruction/choice.py
+++ b/src/sampletones_application/ui/panels/instruction/choice.py
@@ -3,7 +3,7 @@
import dearpygui.dearpygui as dpg
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.categories.pitch import build_pitch_tooltip
+from sampletones_application.categories.pitch import PitchTooltips
from sampletones_application.layout.tabs.instructions import InstructionsLayout
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import SUF_HANDLER_REGISTRY
@@ -23,7 +23,10 @@
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.panel import GUIPanel
-from sampletones_application.ui.elements.pitch_stepper import GUIPitchStepper, PitchStepperStyle
+from sampletones_application.ui.elements.pitch_stepper import (
+ GUIPitchStepper,
+ PitchStepperStyle,
+)
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.utils.gui.dpg import (
dpg_configure_item,
@@ -70,16 +73,9 @@ def __init__(
self._pitch_stepper: Optional[GUIPitchStepper] = None
self._msg_status_input = language_manager["global.status.message.input"]
- tooltip_template = language_manager["instructions.details.template.pitch_tooltip_template"]
- self._pitch_tooltip = build_pitch_tooltip(
- language_manager,
- PITCH_VALUE_KIND,
- tooltip_template,
- )
- self._period_tooltip = build_pitch_tooltip(
+ self._pitch_tooltips = PitchTooltips.build(
language_manager,
- PERIOD_VALUE_KIND,
- tooltip_template,
+ language_manager["instructions.details.template.pitch_tooltip_template"],
)
super().__init__(
@@ -167,7 +163,7 @@ def _create_pitch_stepper(
kind=kind,
initial_value=initial_value,
label=label,
- tooltip=self._period_tooltip if is_period else self._pitch_tooltip,
+ tooltip=self._pitch_tooltips.for_kind(kind),
status_message=(
self._language_manager["instructions.details.message.status_input_period"]
if is_period
@@ -180,7 +176,7 @@ def _create_pitch_stepper(
)
self._pitch_stepper.on_value_changed = self._on_pitch_value_changed
- def _on_pitch_value_changed(self, value: int) -> None:
+ def _on_pitch_value_changed(self, _value: int) -> None:
self._on_instruction_changed()
def _create_pulse_instruction_choice_panel(self, instruction: PulseInstruction) -> None:
diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py
index c59e19a6..5c98d092 100644
--- a/src/sampletones_application/ui/panels/instruction/library.py
+++ b/src/sampletones_application/ui/panels/instruction/library.py
@@ -1,14 +1,13 @@
from pathlib import Path
-from typing import Any, Callable, Dict, Optional, Protocol, Tuple
+from typing import AbstractSet, Any, Callable, Optional, Protocol, Tuple
import dearpygui.dearpygui as dpg
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.behavior import SchedulingBehavior
-from sampletones_application.tags.general import (
- TAG_GLOBAL_THEME_PRIMARY_BUTTON,
- TAG_GLOBAL_THEME_SECONDARY_BUTTON,
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
)
+from sampletones_application.tags.general import TAG_GLOBAL_THEME_PRIMARY_BUTTON
from sampletones_application.tags.instructions import (
TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION,
TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY,
@@ -29,17 +28,16 @@
from sampletones_application.ui.elements.context_menu import context_menu
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
-from sampletones_application.ui.elements.layout.collapse import CollapseAxis
from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel
from sampletones_application.ui.elements.tree.colors import TreeColors
from sampletones_application.ui.elements.tree.handler import NodeHandler
from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
from sampletones_application.ui.elements.tree.state import TreeNodeState
-from sampletones_application.ui.elements.tree.tree import GUITreePanel
+from sampletones_application.ui.elements.tree.tags import FileBrowserTags
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value
from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip
-from sampletones_application.utils.parallelization.thread import concurrent
from sampletones_application.view_model.instruction.library import LibraryPanelViewModel
from sampletones_core.constants.enums import LibraryGeneratorName
from sampletones_core.library import InstructionLibraryKey
@@ -79,9 +77,21 @@ def update_status(self) -> None: ...
def get_path(self, key: InstructionLibraryKey) -> Path: ...
-class GUIInstructionsLibraryPanel(GUITreePanel):
+class GUIInstructionsLibraryPanel(GUIFileBrowserPanel):
+ """The Instructions tab's catalogue of instruction libraries and the generators inside them."""
+
_NAME_FONT: Font = Font.REGULAR_SMALL
_MONOSPACE_CONFIG_NODES: bool = True
+ _REBUILD_ON_CREATE: bool = False
+ _REMEMBERS_EXPANSION: bool = True
+ _tags: FileBrowserTags = FileBrowserTags(
+ panel=TAG_INSTRUCTIONS_LIBRARY_PANEL,
+ tree=TAG_INSTRUCTIONS_LIBRARY_TREE,
+ window_tree=TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE,
+ group_tree=TAG_INSTRUCTIONS_LIBRARY_GROUP_TREE,
+ group_controls=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS,
+ button_refresh=TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES,
+ )
def __init__(
self,
@@ -89,7 +99,8 @@ def __init__(
tree_logic: TreeLogicProtocol,
*,
scheduling: SchedulingBehavior,
- initial_collapsed: bool = False,
+ initial_collapsed: bool,
+ initial_expanded_rows: AbstractSet[str],
language_manager: LanguageManager,
status_bar: GUIStatusBar,
colors: TreeColors,
@@ -107,25 +118,35 @@ def __init__(
self.on_generator_selected: Optional[Callable[[InstructionLibraryKey, LibraryGeneratorName], None]] = None
self.on_library_remove_requested: Optional[Callable[[InstructionLibraryKey], None]] = None
- self._node_handlers: Dict[NodeType, NodeHandler]
-
super().__init__(
- self._library_logic.tree,
- tag=TAG_INSTRUCTIONS_LIBRARY_PANEL,
- tree_tag=TAG_INSTRUCTIONS_LIBRARY_TREE,
+ tree=library_logic.tree,
tree_logic=tree_logic,
scheduling=scheduling,
search_label=language_manager["global.browser.label.search"],
language_manager=language_manager,
status_bar=status_bar,
colors=colors,
- )
-
- self._enable_horizontal_collapse(
initial_collapsed=initial_collapsed,
- side=CollapseAxis.HORIZONTAL_LEFT,
+ initial_favorites_only=False,
+ initial_expanded_rows=initial_expanded_rows,
)
+ @property
+ def section_label(self) -> str:
+ return self._language_manager["instructions.library.label.libraries_text"]
+
+ @property
+ def section_glyph(self) -> str:
+ return self._glyphs.headers.instruction_data
+
+ @property
+ def refresh_button_label(self) -> str:
+ return self._language_manager["instructions.library.label.refresh_libraries_button"]
+
+ @property
+ def refresh_status_message(self) -> str:
+ return self._language_manager["instructions.library.message.status_refresh"]
+
def _setup_handlers(self) -> None:
self._node_handlers = {
NodeType.LIBRARY: NodeHandler(
@@ -144,39 +165,16 @@ def _setup_handlers(self) -> None:
super()._setup_handlers()
- def create_panel(self, parent: str) -> None:
- self._setup_handlers()
- with dpg.child_window(
- tag=self.tag,
- width=self.width,
- height=self.height,
- parent=parent,
- border=False,
- ):
- with self._collapsible_section(
- self._language_manager["instructions.library.label.libraries_text"],
- glyph=self._glyphs.headers.instruction_data,
- ):
- self._create_library_status()
- self._create_library_controls()
- self._create_library_tree()
-
- self._create_detail_tooltip(TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE)
-
- def _create_library_status(self) -> None:
- text = dpg.add_text("", tag=TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS)
- FontRegistry.bind_to_item(text, Font.MONO_SMALL)
+ def _create_controls(self) -> None:
+ """Reads out what the catalogue holds, and offers what can be done to it.
- def _create_library_controls(self) -> None:
- with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS):
+ The controls come in two sets: the ones a reader picks from while the catalogue sits still,
+ and the progress bar and cancel button a generation replaces them with.
+ """
+ self._create_library_status()
+ with dpg.group(tag=self._tags.group_controls):
with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS_IDLE):
- GUIButton(
- tag=TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES,
- label=self._language_manager["instructions.library.label.refresh_libraries_button"],
- width=-1,
- callback=self._on_refresh_clicked,
- theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON),
- )
+ self._create_refresh_button()
with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_GENERATE):
GUIButton(
tag=TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY,
@@ -210,10 +208,7 @@ def _create_library_controls(self) -> None:
width=-1,
callback=self._on_cancel_clicked,
)
- self._status_bar.bind_to_item(
- TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES,
- self._language_manager["instructions.library.message.status_refresh"],
- )
+ self._bind_refresh_message()
self._status_bar.bind_to_item(
TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY,
self._language_manager["instructions.library.message.status_generate"],
@@ -223,24 +218,15 @@ def _create_library_controls(self) -> None:
self._language_manager["instructions.library.message.status_cancel_generation"],
)
- def _create_library_tree(self) -> None:
- dpg.add_separator()
- self.create_search(self._body_container)
- with dpg.child_window(
- tag=TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE,
- width=-1,
- height=-1,
- horizontal_scrollbar=True,
- ):
- with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_TREE):
- with dpg.tree_node(
- label=self._language_manager["instructions.library.label.available_libraries_text"],
- tag=self.tree_tag,
- default_open=True,
- ):
- pass
+ def _create_library_status(self) -> None:
+ text = dpg.add_text("", tag=TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS)
+ FontRegistry.bind_to_item(text, Font.MONO_SMALL)
+
+ def _create_tree_root(self) -> None:
+ self._create_tree_root_heading(self._language_manager["instructions.library.label.available_libraries_text"])
def _on_refresh_clicked(self) -> None:
+ """Answers the refresh control by reading the libraries again, which rebuilds the tree."""
self.call(self.on_refresh_requested)
def _on_generate_clicked(self) -> None:
@@ -276,12 +262,13 @@ def update_view(self, view_model: LibraryPanelViewModel) -> None:
)
def set_tree_enabled(self, enabled: bool) -> None:
+ """Locks the tree and the control reading it again, leaving a running generation cancellable."""
dpg_configure_item(
- TAG_INSTRUCTIONS_LIBRARY_GROUP_TREE,
+ self._tags.group_tree,
enabled=enabled,
)
dpg_configure_item(
- TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES,
+ self._tags.button_refresh,
enabled=enabled,
)
self._apply_action_button_states()
@@ -306,14 +293,11 @@ def _apply_action_button_states(self) -> None:
show=operation_active,
)
- @concurrent(wait=False, method_bound=True)
- def rebuild_tree(self) -> None:
- self._launch_rebuild(
- self._library_logic.rebuild_tree,
- lambda: self._collect_specs(self.tree_tag),
- root_tag=self.tree_tag,
- on_finished=self._library_logic.update_status,
- )
+ def _refresh_model(self) -> None:
+ self._library_logic.rebuild_tree()
+
+ def _on_rebuild_finished(self) -> None:
+ self._library_logic.update_status()
def _has_relevant_content(self, node: TreeNode) -> bool:
return True
@@ -350,9 +334,9 @@ def _create_status_bar_message_function_for_instructions_node(
self,
) -> MessageCallback:
def message_function(
- *args: Any,
+ *_args: Any,
user_data: Tuple[TreeNode, str],
- **kwargs: Any,
+ **_kwargs: Any,
) -> str:
node, _ = user_data
match node.node_type:
@@ -375,7 +359,7 @@ def message_function(
def _on_generator_node_clicked(
self,
- sender: Sender,
+ _sender: Sender,
app_data: Tuple[int, int],
user_data: Tuple[GeneratorNode, str],
) -> None:
@@ -390,7 +374,7 @@ def _on_generator_node_clicked(
def _on_library_node_clicked(
self,
- sender: Sender,
+ _sender: Sender,
app_data: Tuple[int, int],
user_data: Tuple[LibraryNode, str],
) -> None:
@@ -458,8 +442,8 @@ def _is_current_library_node(self, node: TreeNode) -> bool:
def _on_load_generator(
self,
- sender: Sender,
- app_data: bool,
+ _sender: Sender,
+ _app_data: bool,
user_data: GeneratorNode,
) -> None:
assert isinstance(user_data.parent, LibraryNode), "Generator node parent is not a LibraryNode"
diff --git a/src/sampletones_application/ui/panels/instruction/parameters.py b/src/sampletones_application/ui/panels/instruction/parameters.py
index e6eddc80..5b73e32a 100644
--- a/src/sampletones_application/ui/panels/instruction/parameters.py
+++ b/src/sampletones_application/ui/panels/instruction/parameters.py
@@ -3,7 +3,7 @@
import dearpygui.dearpygui as dpg
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.general.colors import TableColors
+from sampletones_application.layout.general.colors.table import TableColors
from sampletones_application.layout.general.tables import TablesLayout
from sampletones_application.tags.instructions import (
TAG_INSTRUCTIONS_DETAILS_GROUP_TABLES,
@@ -86,7 +86,7 @@ def _create_instruction_tables(self) -> None:
self.general_table = GUITable(
tag=TAG_INSTRUCTIONS_DETAILS_TABLE_GENERAL,
parent=TAG_INSTRUCTIONS_DETAILS_GROUP_TABLES,
- rows=tuple(),
+ rows=(),
label_column_width=self._table_layout.label_width,
label_color=self._table_colors.label,
value_color=self._table_colors.value,
@@ -102,7 +102,7 @@ def _create_instruction_tables(self) -> None:
self.parameters_table = GUITable(
tag=TAG_INSTRUCTIONS_DETAILS_TABLE_PARAMETERS,
parent=TAG_INSTRUCTIONS_DETAILS_GROUP_TABLES,
- rows=tuple(),
+ rows=(),
label_column_width=self._table_layout.label_width,
label_color=self._table_colors.label,
value_color=self._table_colors.value,
diff --git a/src/sampletones_application/ui/panels/main/advanced.py b/src/sampletones_application/ui/panels/main/advanced.py
index 4421f05e..97becf51 100644
--- a/src/sampletones_application/ui/panels/main/advanced.py
+++ b/src/sampletones_application/ui/panels/main/advanced.py
@@ -4,7 +4,7 @@
import dearpygui.dearpygui as dpg
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.general.colors import PathColors
+from sampletones_application.layout.general.colors.path import PathColors
from sampletones_application.layout.general.inputs import InputsLayout
from sampletones_application.layout.tabs.main.advanced import AdvancedLayout
from sampletones_application.tags.compose import compose_tag
@@ -111,7 +111,7 @@ def _setup_handlers(self) -> None:
dpg.add_item_deactivated_after_edit_handler(callback=self._on_parameter_change)
dpg.add_item_edited_handler(callback=self._on_parameter_change)
- def _on_parameter_change(self, sender: Sender, app_data: Any) -> None:
+ def _on_parameter_change(self, _sender: Sender, _app_data: Any) -> None:
self.call(self.on_advanced_settings_changed, self._current_update())
def _current_update(self) -> AdvancedSettingsUpdate:
diff --git a/src/sampletones_application/ui/panels/main/config.py b/src/sampletones_application/ui/panels/main/config.py
index f3e427cc..13331362 100644
--- a/src/sampletones_application/ui/panels/main/config.py
+++ b/src/sampletones_application/ui/panels/main/config.py
@@ -27,7 +27,7 @@
LibrarySettingsUpdate,
)
from sampletones_core.constants.audio import MAX_SAMPLE_RATE, MIN_SAMPLE_RATE
-from sampletones_core.constants.general import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY
+from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY
from sampletones_shared.types.application import Sender
@@ -76,7 +76,7 @@ def _setup_handlers(self) -> None:
dpg.add_item_deactivated_after_edit_handler(callback=self._on_parameter_change)
dpg.add_item_edited_handler(callback=self._on_parameter_change)
- def _on_parameter_change(self, sender: Sender, app_data: Any) -> None:
+ def _on_parameter_change(self, _sender: Sender, _app_data: Any) -> None:
audio_update = AudioSettingsUpdate(
normalize=bool(dpg.get_value(TAG_MAIN_CONFIG_CHECKBOX_NORMALIZE)),
quantize=bool(dpg.get_value(TAG_MAIN_CONFIG_CHECKBOX_QUANTIZE)),
diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py
index 54caa9de..a5de9f92 100644
--- a/src/sampletones_application/ui/panels/main/converter.py
+++ b/src/sampletones_application/ui/panels/main/converter.py
@@ -3,7 +3,7 @@
import dearpygui.dearpygui as dpg
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.general.colors import PathColors
+from sampletones_application.layout.general.colors.path import PathColors
from sampletones_application.layout.tabs.main.converter import ConverterLayout
from sampletones_application.tags.general import (
TAG_GLOBAL_THEME_DANGER_BUTTON,
@@ -28,7 +28,7 @@
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.panel import GUIPanel
-from sampletones_application.ui.elements.path import GUIPathText
+from sampletones_application.ui.elements.path import GUIDestinationPathText, GUIPathText
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.ui.themes.theme import Theme
@@ -57,7 +57,7 @@ def __init__(
) -> None:
self._language_manager = language_manager
self.input_path_text: Optional[GUIPathText] = None
- self.output_path_text: Optional[GUIPathText] = None
+ self.output_path_text: Optional[GUIDestinationPathText] = None
self._status_bar = status_bar
self._action_button: Optional[GUIButton] = None
self._theme_convert: Optional[Theme] = None
@@ -69,6 +69,7 @@ def __init__(
self._layout = layout
self._path_colors = path_colors
self._msg_path = language_manager["global.status.message.path"]
+ self._msg_destination = language_manager["global.status.message.destination"]
self._msg_status_convert = language_manager["main.converter.message.status_convert"]
self._status_action_message = self._msg_status_convert
@@ -166,7 +167,7 @@ def _create_action_button(self) -> None:
self._action_status_message,
)
- def _action_status_message(self, *args: Any, **kwargs: Any) -> str:
+ def _action_status_message(self, *_args: Any, **_kwargs: Any) -> str:
return self._status_action_message
def _create_summary(self) -> None:
@@ -193,14 +194,14 @@ def _create_summary(self) -> None:
font=Font.REGULAR_SMALL,
status_bar=self._status_bar,
)
- self.output_path_text = GUIPathText(
+ self.output_path_text = GUIDestinationPathText(
path=None,
prefix=self._language_manager["main.converter.message.status_output_label"],
tag=TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH,
parent=TAG_MAIN_CONVERTER_GROUP_SUMMARY,
color=self._path_colors.default,
hover_color=self._path_colors.hover,
- status_message=self._msg_path,
+ status_message=self._msg_destination,
font=Font.REGULAR_SMALL,
status_bar=self._status_bar,
)
diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py
index b9eb0934..45da542d 100644
--- a/src/sampletones_application/ui/panels/main/explorer.py
+++ b/src/sampletones_application/ui/panels/main/explorer.py
@@ -1,13 +1,13 @@
from pathlib import Path
-from typing import Any, Dict, List, Optional, Protocol, Tuple
+from typing import Any, List, Optional, Protocol, Tuple
import dearpygui.dearpygui as dpg
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.behavior import SchedulingBehavior
-from sampletones_application.tags.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
+)
from sampletones_application.tags.main import (
- TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL,
TAG_MAIN_EXPLORER_BUTTON_REFRESH,
TAG_MAIN_EXPLORER_GROUP_CONTROLS,
TAG_MAIN_EXPLORER_GROUP_TREE,
@@ -15,20 +15,16 @@
TAG_MAIN_EXPLORER_TREE,
TAG_MAIN_EXPLORER_WINDOW_TREE,
)
-from sampletones_application.ui.elements.button import GUIButton
from sampletones_application.ui.elements.context_menu import context_menu
-from sampletones_application.ui.elements.layout.collapse import CollapseAxis
from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel
from sampletones_application.ui.elements.tree.colors import TreeColors
-from sampletones_application.ui.elements.tree.handler import NodeHandler
from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
from sampletones_application.ui.elements.tree.spec import NodeSpec
from sampletones_application.ui.elements.tree.state import TreeNodeState
-from sampletones_application.ui.elements.tree.tree import GUITreePanel
-from sampletones_application.ui.themes.registry import ThemeRegistry
-from sampletones_application.utils.gui.dpg import dpg_configure_item
+from sampletones_application.ui.elements.tree.tags import FileBrowserTags
+from sampletones_application.ui.elements.tree.tree import NO_EXPANDED_ROWS
from sampletones_application.utils.parallelization.thread import concurrent
-from sampletones_core import paths
from sampletones_core.structures.tree import (
FileSystemNode,
NodeType,
@@ -37,6 +33,7 @@
TreeTraversal,
traverse,
)
+from sampletones_shared.paths import extensions
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import MessageCallback, PathCallback
@@ -59,12 +56,27 @@ def collapse_all(self) -> None: ...
def expand_directory(self, node: FileSystemNode) -> None: ...
- def is_directory_expanded(self, filepath: Path) -> bool: ...
+ def has_loaded_children(self, filepath: Path) -> bool: ...
+
+ def is_directory_open(self, filepath: Path) -> bool: ...
+
+ def set_directory_open(self, filepath: Path, is_open: bool) -> None: ...
def has_relevant_content(self, filepath: Path) -> bool: ...
-class GUIExplorerPanel(GUITreePanel):
+class GUIExplorerPanel(GUIFileBrowserPanel):
+ """The Main tab's browser of the filesystem, whose rows are the folders and files on disk."""
+
+ _tags: FileBrowserTags = FileBrowserTags(
+ panel=TAG_MAIN_EXPLORER_PANEL,
+ tree=TAG_MAIN_EXPLORER_TREE,
+ window_tree=TAG_MAIN_EXPLORER_WINDOW_TREE,
+ group_tree=TAG_MAIN_EXPLORER_GROUP_TREE,
+ group_controls=TAG_MAIN_EXPLORER_GROUP_CONTROLS,
+ button_refresh=TAG_MAIN_EXPLORER_BUTTON_REFRESH,
+ )
+
def __init__(
self,
explorer_logic: ExplorerLogicProtocol,
@@ -74,14 +86,11 @@ def __init__(
language_manager: LanguageManager,
status_bar: GUIStatusBar,
colors: TreeColors,
- initial_collapsed: bool = False,
+ initial_collapsed: bool,
) -> None:
self._language_manager = language_manager
self._explorer_logic = explorer_logic
- self._lbl_section = language_manager["main.explorer.label.section"]
- self._node_handlers: Dict[NodeType, NodeHandler]
-
self.on_wave_file_clicked: Optional[PathCallback] = None
self.on_directory_clicked: Optional[PathCallback] = None
self.on_reconstruct_directory: Optional[PathCallback] = None
@@ -92,123 +101,58 @@ def __init__(
self.on_set_as_library_directory: Optional[PathCallback] = None
super().__init__(
- tree=self._explorer_logic.tree,
- tag=TAG_MAIN_EXPLORER_PANEL,
- tree_tag=TAG_MAIN_EXPLORER_TREE,
+ tree=explorer_logic.tree,
tree_logic=tree_logic,
scheduling=scheduling,
search_label=language_manager["global.browser.label.filter"],
language_manager=language_manager,
status_bar=status_bar,
colors=colors,
- )
-
- self._enable_horizontal_collapse(
initial_collapsed=initial_collapsed,
- side=CollapseAxis.HORIZONTAL_LEFT,
+ initial_favorites_only=False,
+ initial_expanded_rows=NO_EXPANDED_ROWS,
)
- def create_panel(self, parent: str) -> None:
- self._setup_handlers()
- with dpg.child_window(
- tag=self.tag,
- width=self.width,
- height=self.height,
- parent=parent,
- border=False,
- ):
- with self._collapsible_section(
- self._lbl_section,
- glyph=self._glyphs.headers.filesystem,
- ):
- self._create_buttons()
- dpg.add_separator()
- self._create_tree_window()
-
- self._create_detail_tooltip(TAG_MAIN_EXPLORER_WINDOW_TREE)
- self.rebuild_tree()
+ @property
+ def section_label(self) -> str:
+ return self._language_manager["main.explorer.label.section"]
+
+ @property
+ def section_glyph(self) -> str:
+ return self._glyphs.headers.filesystem
+
+ @property
+ def refresh_button_label(self) -> str:
+ return self._language_manager["main.explorer.label.refresh_button"]
+
+ @property
+ def refresh_status_message(self) -> str:
+ return self._language_manager["main.explorer.message.status_refresh"]
def _setup_handlers(self) -> None:
- self._node_handlers = {
- NodeType.DIRECTORY: NodeHandler(
- tag=self._get_node_handler_tag(NodeType.DIRECTORY),
- node_type=NodeType.DIRECTORY,
- item_click_callback=self._on_directory_node_clicked,
- status_bar_callback=self._create_status_bar_message_function_for_directory_node(),
- ),
- NodeType.FILE: NodeHandler(
- tag=self._get_node_handler_tag(NodeType.FILE),
- node_type=NodeType.FILE,
- item_click_callback=self._on_file_node_clicked,
- item_double_click_callback=self._on_file_node_double_clicked,
- status_bar_callback=self._create_status_bar_message_function_for_file_node(),
- ),
- }
+ self._node_handlers = self._create_file_system_handlers(
+ on_directory_clicked=self._on_directory_node_clicked,
+ on_file_clicked=self._on_file_node_clicked,
+ on_file_double_clicked=self._on_file_node_double_clicked,
+ file_status_message=self._create_status_bar_message_function_for_file_node(),
+ )
super()._setup_handlers()
- def _create_buttons(self) -> None:
- with dpg.group(tag=TAG_MAIN_EXPLORER_GROUP_CONTROLS):
- GUIButton(
- tag=TAG_MAIN_EXPLORER_BUTTON_REFRESH,
- label=self._language_manager["main.explorer.label.refresh_button"],
- parent=self._body_container,
- width=-1,
- callback=self.refresh,
- theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON),
- )
- GUIButton(
- tag=TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL,
- label=self._language_manager["main.explorer.label.collapse_all_button"],
- parent=self._body_container,
- width=-1,
- callback=self.collapse_all,
- )
- self._status_bar.bind_to_item(
- TAG_MAIN_EXPLORER_BUTTON_REFRESH,
- self._language_manager["main.explorer.message.status_refresh"],
- )
- self._status_bar.bind_to_item(
- TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL,
- self._language_manager["main.explorer.message.status_collapse_all"],
- )
+ def _create_tree_root(self) -> None:
+ self._create_tree_root_heading(self.section_label)
- 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.group(tag=TAG_MAIN_EXPLORER_GROUP_TREE):
- with dpg.tree_node(
- label=self._lbl_section,
- tag=self.tree_tag,
- default_open=True,
- ):
- pass
-
- 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."
- for node_tag in children:
- dpg.set_value(node_tag, False)
+ def _refresh_model(self) -> None:
+ self._explorer_logic.refresh_tree()
- def refresh(self) -> None:
- self.rebuild_tree()
+ def _on_collapse_all_clicked(self) -> None:
+ """Folds every folder away and drops the children it had loaded, so opening one reads it again.
- @concurrent(wait=False, method_bound=True)
- def rebuild_tree(self) -> None:
- self._launch_rebuild(
- self._explorer_logic.refresh_tree,
- lambda: self._collect_specs(self.tree_tag),
- root_tag=self.tree_tag,
- )
+ The rows fold while the model still states them, and the folders the model held go afterwards,
+ which is what makes a later open list the folder as it stands on disk.
+ """
+ super()._on_collapse_all_clicked()
+ self._explorer_logic.collapse_all()
@concurrent(wait=False, method_bound=True)
def _rebuild_node_subtree(
@@ -228,14 +172,13 @@ def _collect_subtree_specs(
node_tag: str,
) -> List[NodeSpec]:
self._pending_specs = []
- if self._explorer_logic.is_directory_expanded(node.filepath):
+ if self._explorer_logic.has_loaded_children(node.filepath):
for child in node.children:
- has_favorite_ancestor = self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(child)
self._build_tree_node(
child,
TreeNodeState(
parent=node_tag,
- has_favorite_ancestor=has_favorite_ancestor,
+ has_favorite_ancestor=self._logic.has_favorite_ancestor(child),
),
)
@@ -255,20 +198,16 @@ def _build_tree_node(
if not isinstance(node, FileSystemNode):
return
- is_favorite = self._logic.is_node_favorite(node)
- state.has_favorite_ancestor |= is_favorite
+ self._mark_favorite_ancestry(node, state)
if node.node_type == NodeType.DIRECTORY:
- should_expand = self._should_expand_node(node) or self._explorer_logic.is_directory_expanded(node.filepath)
- is_directory_expanded = self._explorer_logic.is_directory_expanded(node.filepath)
self._append_spec(
node,
node_tag,
state.parent,
open_on_double_click=True,
- should_expand=should_expand,
+ should_expand=self._should_expand_node(node) or self._explorer_logic.is_directory_open(node.filepath),
has_favorite_ancestor=state.has_favorite_ancestor,
- is_node_expanded=is_directory_expanded,
)
else:
self._append_spec(
@@ -296,19 +235,19 @@ def message_function(
node, _ = user_data
suffix = node.filepath.suffix.lower()
match suffix:
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
return reconstruction_message_function(
*args,
user_data=user_data,
**kwargs,
)
- case paths.EXT_FILE_LIBRARY:
+ case extensions.EXT_FILE_LIBRARY:
return library_message_function(
*args,
user_data=user_data,
**kwargs,
)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
return audio_message_function(
*args,
user_data=user_data,
@@ -321,7 +260,7 @@ def message_function(
def _on_file_node_clicked(
self,
- sender: Sender,
+ _sender: Sender,
app_data: Tuple[int, int],
user_data: Tuple[FileSystemNode, Sender],
) -> None:
@@ -329,9 +268,9 @@ def _on_file_node_clicked(
node, _ = user_data
if mouse_button == dpg.mvMouseButton_Left:
match node.filepath.suffix.lower():
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
return self._logic.request_autoplay(node)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
self.call(self.on_wave_file_clicked, node.filepath)
return self._logic.request_autoplay(node)
@@ -342,7 +281,7 @@ def _on_file_node_clicked(
def _on_file_node_double_clicked(
self,
- sender: Sender,
+ _sender: Sender,
app_data: Tuple[int, int],
user_data: Tuple[FileSystemNode, Sender],
) -> None:
@@ -350,19 +289,19 @@ def _on_file_node_double_clicked(
node, _ = user_data
if mouse_button == dpg.mvMouseButton_Left:
match node.filepath.suffix.lower():
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
self._load_reconstruction(node)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
self._logic.cancel_autoplay()
return self._reconstruct_file(node)
- case paths.EXT_FILE_LIBRARY:
+ case extensions.EXT_FILE_LIBRARY:
return self._load_library(node)
return None
def _on_directory_node_clicked(
self,
- sender: Sender,
+ _sender: Sender,
app_data: Tuple[int, int],
user_data: Tuple[FileSystemNode, str],
) -> None:
@@ -379,7 +318,7 @@ def _on_directory_node_clicked(
def _create_status_bar_message_function_for_audio_node(
self,
) -> MessageCallback:
- def message_function(*args: Any, **kwargs: Any) -> str:
+ def message_function(*_args: Any, **_kwargs: Any) -> str:
if self._logic.autoplay_enabled:
return self._language_manager["main.explorer.message.status_node_audio"]
@@ -415,10 +354,6 @@ def _has_relevant_content(self, node: TreeNode) -> bool:
return True
- def set_tree_enabled(self, enabled: bool) -> None:
- dpg_configure_item(TAG_MAIN_EXPLORER_GROUP_TREE, enabled=enabled)
- dpg_configure_item(TAG_MAIN_EXPLORER_GROUP_CONTROLS, enabled=enabled)
-
def _reconstruct_file(self, node: FileSystemNode) -> None:
if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE:
return
@@ -430,35 +365,40 @@ def _toggle_directory_expansion(
node: FileSystemNode,
node_tag: str,
) -> None:
+ """Folds or unfolds a folder, reading its children the first time it is opened.
+
+ The folder is told what it now stands as, which is the shape a refresh and a later run of the
+ application bring it back in.
+ """
if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY:
return
if not dpg.does_item_exist(node_tag):
return
- is_directory_expanded = self._explorer_logic.is_directory_expanded(node.filepath)
- state = dpg.get_value(node_tag)
- if not is_directory_expanded:
+ is_open = not dpg.get_value(node_tag)
+ if not self._explorer_logic.has_loaded_children(node.filepath):
self._explorer_logic.expand_directory(node)
self._rebuild_node_subtree(node, node_tag)
- dpg.set_value(node_tag, not state)
+ dpg.set_value(node_tag, is_open)
+ self._explorer_logic.set_directory_open(node.filepath, is_open)
def _add_context_menu_file_actions(self, node: FileSystemNode) -> None:
dpg.add_separator()
suffix = node.filepath.suffix.lower()
match suffix:
- case paths.EXT_FILE_RECONSTRUCTION:
+ case extensions.EXT_FILE_RECONSTRUCTION:
dpg.add_menu_item(
label=self._language_manager["main.explorer.label.context_load_reconstruction"],
callback=lambda: self._load_reconstruction(node),
)
- case paths.EXT_FILE_LIBRARY:
+ case extensions.EXT_FILE_LIBRARY:
dpg.add_menu_item(
label=self._language_manager["main.explorer.label.context_load_library"],
callback=lambda: self._load_library(node),
)
- case suffix if suffix in paths.EXT_FILES_AUDIO:
+ case suffix if suffix in extensions.EXT_FILES_AUDIO:
dpg.add_menu_item(
label=self._language_manager["main.explorer.label.context_reconstruct_file"],
callback=lambda: self._context_reconstruct_file(node),
diff --git a/src/sampletones_application/ui/panels/main/reconstructor.py b/src/sampletones_application/ui/panels/main/reconstructor.py
index 8c088728..398f686a 100644
--- a/src/sampletones_application/ui/panels/main/reconstructor.py
+++ b/src/sampletones_application/ui/panels/main/reconstructor.py
@@ -2,17 +2,12 @@
import dearpygui.dearpygui as dpg
+from sampletones_application.categories.context import channel_label
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.layout.general.inputs import InputsLayout
from sampletones_application.layout.tabs.main.reconstructor import ReconstructorLayout
from sampletones_application.tags.compose import compose_tag
-from sampletones_application.tags.general import (
- SUF_HANDLER_REGISTRY,
- TAG_GLOBAL_THEME_CHANNEL_NOISE,
- TAG_GLOBAL_THEME_CHANNEL_PULSE1,
- TAG_GLOBAL_THEME_CHANNEL_PULSE2,
- TAG_GLOBAL_THEME_CHANNEL_TRIANGLE,
-)
+from sampletones_application.tags.general import SUF_HANDLER_REGISTRY
from sampletones_application.tags.main import (
PRE_MAIN_RECONSTRUCTOR_GENERATOR,
TAG_MAIN_RECONSTRUCTOR_PANEL,
@@ -23,6 +18,7 @@
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.gui.dpg import dpg_set_value
from sampletones_application.utils.gui.tooltip import show_tooltip
@@ -98,21 +94,11 @@ def _create_generator_selection(self) -> None:
def _generator_chips(self) -> List[Tuple[GeneratorName, str, str]]:
return [
(
- GeneratorName.PULSE1,
- self._language_manager["global.context.label.pulse_1"],
- TAG_GLOBAL_THEME_CHANNEL_PULSE1,
- ),
- (
- GeneratorName.PULSE2,
- self._language_manager["global.context.label.pulse_2"],
- TAG_GLOBAL_THEME_CHANNEL_PULSE2,
- ),
- (
- GeneratorName.TRIANGLE,
- self._language_manager["global.context.label.triangle"],
- TAG_GLOBAL_THEME_CHANNEL_TRIANGLE,
- ),
- (GeneratorName.NOISE, self._language_manager["global.context.label.noise"], TAG_GLOBAL_THEME_CHANNEL_NOISE),
+ generator_name,
+ channel_label(self._language_manager, generator_name),
+ CHANNEL_THEME_TAGS[generator_name],
+ )
+ for generator_name in GeneratorName.items()
]
def _create_drive_slider(self) -> None:
@@ -145,7 +131,20 @@ def _create_tooltips(self) -> None:
self._language_manager["main.reconstructor.tooltip.tooltip_drive"],
)
- def _on_parameter_change(self, sender: Sender, app_data: Any) -> None:
+ def toggle_generator(self, generator: GeneratorName) -> None:
+ """Switches one generator in or out of the set a reconstruction is built from.
+
+ This is the gesture a click on the generator's checkbox makes, reached by the key the
+ channel answers to, so the panel reports the settings either way.
+ """
+ checkbox_tag = self._get_generator_checkbox_tag(generator)
+ dpg_set_value(checkbox_tag, not dpg.get_value(checkbox_tag))
+ self._report_generation_settings()
+
+ def _on_parameter_change(self, _sender: Sender, _app_data: Any) -> None:
+ self._report_generation_settings()
+
+ def _report_generation_settings(self) -> None:
generators = [
generator for generator in GeneratorName if dpg.get_value(self._get_generator_checkbox_tag(generator))
]
diff --git a/src/sampletones_application/ui/panels/player/controls.py b/src/sampletones_application/ui/panels/player/controls.py
index 99a8967d..bc6487c0 100644
--- a/src/sampletones_application/ui/panels/player/controls.py
+++ b/src/sampletones_application/ui/panels/player/controls.py
@@ -1,6 +1,6 @@
import dearpygui.dearpygui as dpg
-from sampletones_application.layout.glyphs import PlayerGlyphs
+from sampletones_application.layout.glyphs.player import PlayerGlyphs
from sampletones_application.layout.player import PlayerLayout
from sampletones_application.layout.primitives import Dimensions
from sampletones_application.tags.compose import compose_tag
diff --git a/src/sampletones_application/ui/panels/reconstruction/audio.py b/src/sampletones_application/ui/panels/reconstruction/audio.py
index 49109b9a..59a65183 100644
--- a/src/sampletones_application/ui/panels/reconstruction/audio.py
+++ b/src/sampletones_application/ui/panels/reconstruction/audio.py
@@ -3,7 +3,7 @@
import dearpygui.dearpygui as dpg
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.general.colors import PathColors
+from sampletones_application.layout.general.colors.path import PathColors
from sampletones_application.tags.reconstructions import (
TAG_RECONSTRUCTIONS_RECONSTRUCTION_GROUP_AUDIO_SOURCE,
TAG_RECONSTRUCTIONS_RECONSTRUCTION_PANEL_AUDIO,
@@ -17,6 +17,7 @@
from sampletones_application.ui.elements.path import GUIPathText
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value
+from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_application.view_model.reconstruction.reconstruction import (
ReconstructionPathState,
ReconstructionPathViewModel,
@@ -24,7 +25,6 @@
)
from sampletones_core.constants.enums import AudioSourceType
from sampletones_shared.types.application import Sender
-from sampletones_shared.utils.color import RGBA
class GUIReconstructionAudioPanel(GUIPanel):
@@ -32,7 +32,7 @@ def __init__(
self,
*,
path_colors: PathColors,
- path_status_color: RGBA,
+ path_status_color: BaseColor,
initial_collapsed: bool = False,
language_manager: LanguageManager,
status_bar: GUIStatusBar,
@@ -164,7 +164,7 @@ def _create_audio_source_radio_buttons(self) -> None:
enabled=False,
)
- def _on_audio_source_changed(self, sender: Sender, app_data: str) -> None:
+ def _on_audio_source_changed(self, _sender: Sender, app_data: str) -> None:
if app_data == self._lbl_original_audio_radio:
audio_source = AudioSourceType.ORIGINAL
else:
diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py
index fdbefa9a..8f618d03 100644
--- a/src/sampletones_application/ui/panels/reconstruction/browser.py
+++ b/src/sampletones_application/ui/panels/reconstruction/browser.py
@@ -1,12 +1,11 @@
from pathlib import Path
-from typing import Any, Callable, Dict, Optional, Tuple
+from typing import AbstractSet, Optional
import dearpygui.dearpygui as dpg
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.behavior import SchedulingBehavior
-from sampletones_application.tags.general import (
- TAG_GLOBAL_THEME_SECONDARY_BUTTON,
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
)
from sampletones_application.tags.reconstructions import (
TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
@@ -16,32 +15,29 @@
TAG_RECONSTRUCTIONS_BROWSER_TREE,
TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE,
)
-from sampletones_application.ui.elements.button import GUIButton
-from sampletones_application.ui.elements.context_menu import context_menu
-from sampletones_application.ui.elements.layout.collapse import CollapseAxis
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.ui.elements.tree.colors import TreeColors
-from sampletones_application.ui.elements.tree.handler import NodeHandler
from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
-from sampletones_application.ui.elements.tree.state import TreeNodeState
-from sampletones_application.ui.elements.tree.tree import GUITreePanel
-from sampletones_application.ui.themes.registry import ThemeRegistry
-from sampletones_application.utils.gui.dpg import dpg_configure_item
-from sampletones_application.utils.parallelization.thread import concurrent
-from sampletones_core.structures.tree import (
- FileSystemNode,
- NodeType,
- Tree,
- TreeNode,
- TreeTraversal,
- traverse,
+from sampletones_application.ui.elements.tree.tags import FileBrowserTags
+from sampletones_application.ui.panels.shared.browser import (
+ GUIReconstructionBrowserPanel,
)
+from sampletones_core.structures.tree import FileSystemNode, Tree
from sampletones_shared.types.application import Sender
-from sampletones_shared.types.callback import PathCallback, VoidCallback
+from sampletones_shared.types.callback import PathCallback
-class GUIBrowserPanel(GUITreePanel):
- _MONOSPACE_CONFIG_NODES: bool = True
+class GUIReconstructionsBrowserPanel(GUIReconstructionBrowserPanel):
+ """The Reconstructions tab's browser, whose reconstructions open in the tab beside it."""
+
+ _tags: FileBrowserTags = FileBrowserTags(
+ panel=TAG_RECONSTRUCTIONS_BROWSER_PANEL,
+ tree=TAG_RECONSTRUCTIONS_BROWSER_TREE,
+ window_tree=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE,
+ group_tree=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE,
+ group_controls=TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS,
+ button_refresh=TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
+ )
def __init__(
self,
@@ -52,243 +48,46 @@ def __init__(
language_manager: LanguageManager,
status_bar: GUIStatusBar,
colors: TreeColors,
- is_operation_active: Callable[[], bool],
- initial_collapsed: bool = False,
+ initial_collapsed: bool,
+ initial_favorites_only: bool,
+ initial_expanded_rows: AbstractSet[str],
) -> None:
self._language_manager = language_manager
- self.on_refresh_tree: Optional[VoidCallback] = None
- self.on_reconstruct_file: Optional[VoidCallback] = None
- self.on_reconstruct_directory: Optional[VoidCallback] = None
- self.on_load_reconstruction: Optional[PathCallback] = None
- self.on_reconstruction_remove_requested: Optional[PathCallback] = None
- self.on_directory_remove_requested: Optional[PathCallback] = None
-
- self._is_operation_active = is_operation_active
-
- self._lbl_reconstructions = language_manager["reconstructions.browser.label.reconstructions_tree"]
-
- self._node_handlers: Dict[NodeType, NodeHandler]
super().__init__(
tree=tree,
- tag=TAG_RECONSTRUCTIONS_BROWSER_PANEL,
- tree_tag=TAG_RECONSTRUCTIONS_BROWSER_TREE,
tree_logic=tree_logic,
scheduling=scheduling,
- search_label=language_manager["global.browser.label.search"],
language_manager=language_manager,
status_bar=status_bar,
colors=colors,
- )
-
- self._enable_horizontal_collapse(
initial_collapsed=initial_collapsed,
- side=CollapseAxis.HORIZONTAL_LEFT,
+ initial_favorites_only=initial_favorites_only,
+ initial_expanded_rows=initial_expanded_rows,
)
- def create_panel(self, parent: str) -> None:
- self._setup_handlers()
- with dpg.child_window(
- tag=self.tag,
- width=self.width,
- height=self.height,
- parent=parent,
- border=False,
- ):
- with self._collapsible_section(
- self._lbl_reconstructions,
- glyph=self._glyphs.headers.reconstruction,
- ):
- self._create_buttons()
- dpg.add_separator()
- self._create_tree_window()
-
- self._create_detail_tooltip(TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE)
- self.rebuild_tree()
-
- def _setup_handlers(self) -> None:
- self._node_handlers = {
- NodeType.DIRECTORY: NodeHandler(
- tag=self._get_node_handler_tag(NodeType.DIRECTORY),
- node_type=NodeType.DIRECTORY,
- item_click_callback=self._on_directory_node_clicked,
- status_bar_callback=self._create_status_bar_message_function_for_directory_node(),
- ),
- NodeType.FILE: NodeHandler(
- tag=self._get_node_handler_tag(NodeType.FILE),
- node_type=NodeType.FILE,
- item_click_callback=self._on_reconstruction_node_clicked,
- item_double_click_callback=self._on_reconstruction_node_double_clicked,
- status_bar_callback=self._create_status_bar_message_function_for_reconstruction_node(),
- ),
- }
-
- super()._setup_handlers()
-
- def _create_buttons(self) -> None:
- with dpg.group(tag=TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS):
- GUIButton(
- tag=TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
- label=self._language_manager["reconstructions.browser.label.refresh_button"],
- width=-1,
- callback=self.rebuild_tree,
- theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON),
- )
- self._status_bar.bind_to_item(
- TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
- self._language_manager["reconstructions.browser.message.status_refresh"],
- )
-
- 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.group(tag=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE):
- with dpg.tree_node(
- label=self._lbl_reconstructions,
- tag=self.tree_tag,
- default_open=True,
- ):
- pass
-
- def refresh(self) -> None:
- self.rebuild_tree()
-
- @concurrent(wait=False, method_bound=True)
- def rebuild_tree(self) -> None:
- self._launch_rebuild(
- lambda: self.call(self.on_refresh_tree),
- lambda: self._collect_specs(self.tree_tag),
- root_tag=self.tree_tag,
- )
-
- def _has_relevant_content(self, node: TreeNode) -> bool:
- if node.node_type == NodeType.FILE:
- return True
-
- return bool(node.children)
-
- @traverse(TreeTraversal.BFS)
- def _build_tree_node(
- self,
- node: TreeNode,
- state: TreeNodeState,
- **kwargs: Any,
- ) -> None:
- node_tag = self._generate_node_tag(node)
- if node.node_type == NodeType.ROOT:
- return
-
- if not isinstance(node, FileSystemNode):
- return
-
- is_favorite = self._logic.is_node_favorite(node)
- state.has_favorite_ancestor |= is_favorite
- if node.node_type == NodeType.DIRECTORY:
- should_expand = self._should_expand_node(node)
- self._append_spec(
- node=node,
- node_tag=node_tag,
- parent=state.parent,
- should_expand=should_expand,
- has_favorite_ancestor=state.has_favorite_ancestor,
- )
- else:
- self._append_spec(
- node=node,
- node_tag=node_tag,
- parent=state.parent,
- leaf=True,
- has_favorite_ancestor=state.has_favorite_ancestor,
- )
-
- 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,
- )
-
- def _reconstruct_file(self) -> None:
- self.call(self.on_reconstruct_file)
-
- def _reconstruct_directory(self) -> None:
- self.call(self.on_reconstruct_directory)
-
- def _on_directory_node_clicked(
- self,
- sender: Sender,
- app_data: Tuple[int, int],
- user_data: Tuple[FileSystemNode, str],
- ) -> None:
- mouse_button, _ = app_data
- node, _ = user_data
- if mouse_button == dpg.mvMouseButton_Right:
- return self._show_directory_context_menu(node)
-
- return None
-
- def _on_reconstruction_node_clicked(
- self,
- sender: Sender,
- app_data: Tuple[int, int],
- user_data: Tuple[FileSystemNode, str],
- ) -> None:
- mouse_button, _ = app_data
- node, node_tag = user_data
- if mouse_button == dpg.mvMouseButton_Left:
- self._logic.request_autoplay(node)
-
- if mouse_button == dpg.mvMouseButton_Right:
- self._show_reconstruction_context_menu(node, node_tag)
+ self.on_load_reconstruction: Optional[PathCallback] = None
+ self.on_reconstruction_remove_requested: Optional[PathCallback] = None
+ self.on_directory_remove_requested: Optional[PathCallback] = None
- def _on_reconstruction_node_double_clicked(
- self,
- sender: Sender,
- app_data: Tuple[int, int],
- user_data: Tuple[FileSystemNode, str],
- ) -> None:
- mouse_button, _ = app_data
- node, _ = user_data
- if mouse_button == dpg.mvMouseButton_Left:
- self._logic.cancel_autoplay()
- self._load_reconstruction(node)
+ @property
+ def refresh_button_label(self) -> str:
+ return self._language_manager["reconstructions.browser.label.refresh_button"]
- def _show_directory_context_menu(self, node: FileSystemNode) -> None:
- if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY:
- return
+ @property
+ def refresh_status_message(self) -> str:
+ return self._language_manager["reconstructions.browser.message.status_refresh"]
- with context_menu():
- self._add_context_menu_text(node)
- self._add_context_menu_details(node)
- self._add_context_menu_path_items(node.filepath)
- self._add_context_menu_remove_directory_item(node)
- self._add_context_menu_favorite_item(node)
+ def _open_reconstruction(self, node: FileSystemNode) -> None:
+ self._load_reconstruction(node)
- def _show_reconstruction_context_menu(
- self,
- node: FileSystemNode,
- node_tag: str,
- ) -> None:
- if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE:
- return
+ def _add_directory_context_menu_items(self, node: FileSystemNode) -> None:
+ self._add_context_menu_remove_directory_item(node)
- with context_menu():
- self._add_context_menu_text(node)
- self._add_context_menu_play_item(node)
- self._add_context_menu_load_reconstruction_item(node)
- self._add_context_menu_remove_reconstruction_item(node)
- self._add_context_menu_sequencer_items(node)
- self._add_context_menu_path_items(node.filepath)
- self._add_context_menu_locate_audio_item(node)
- self._add_context_menu_favorite_item(node)
+ def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None:
+ self._add_context_menu_load_reconstruction_item(node)
+ self._add_context_menu_remove_reconstruction_item(node)
+ self._add_context_menu_sequencer_items(node)
def _add_context_menu_load_reconstruction_item(
self,
@@ -328,8 +127,8 @@ def _add_context_menu_remove_directory_item(
def _on_load_reconstruction(
self,
- sender: Sender,
- app_data: Path,
+ _sender: Sender,
+ _app_data: Path,
user_data: FileSystemNode,
) -> None:
self._load_reconstruction(user_data)
diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/config.py b/src/sampletones_application/ui/panels/reconstruction/instruments/config.py
index 0c8f8e72..76c6e652 100644
--- a/src/sampletones_application/ui/panels/reconstruction/instruments/config.py
+++ b/src/sampletones_application/ui/panels/reconstruction/instruments/config.py
@@ -2,17 +2,17 @@
from typing import Dict, Final, Optional, Tuple
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.general.colors import FeatureColors
+from sampletones_application.layout.general.colors.feature import FeatureColors
+from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_core.constants.enums import FeatureKey, LibraryGeneratorName
from sampletones_core.features import feature_range, supported_features
-from sampletones_shared.types.application import Color
@dataclass(frozen=True)
class FeaturePlotConfig:
feature_key: FeatureKey
label: str
- color: Color
+ color: BaseColor
y_min: float
y_max: float
y_ticks: Optional[Tuple[int, ...]]
@@ -53,7 +53,7 @@ def _feature_labels(language_manager: LanguageManager) -> Dict[FeatureKey, str]:
}
-def _feature_colors(feature_colors: FeatureColors) -> Dict[FeatureKey, Color]:
+def _feature_colors(feature_colors: FeatureColors) -> Dict[FeatureKey, BaseColor]:
return {
FeatureKey.VOLUME: feature_colors.volume,
FeatureKey.ARPEGGIO: feature_colors.arpeggio,
@@ -65,7 +65,7 @@ def _feature_colors(feature_colors: FeatureColors) -> Dict[FeatureKey, Color]:
def _build_plot_configs(
labels: Dict[FeatureKey, str],
- colors: Dict[FeatureKey, Color],
+ colors: Dict[FeatureKey, BaseColor],
) -> Dict[LibraryGeneratorName, Dict[FeatureKey, FeaturePlotConfig]]:
configs: Dict[LibraryGeneratorName, Dict[FeatureKey, FeaturePlotConfig]] = {}
for kind in LibraryGeneratorName:
@@ -77,11 +77,17 @@ def _build_plot_configs(
def _build_kind_plot_configs(
kind: LibraryGeneratorName,
labels: Dict[FeatureKey, str],
- colors: Dict[FeatureKey, Color],
+ colors: Dict[FeatureKey, BaseColor],
) -> Dict[FeatureKey, FeaturePlotConfig]:
kind_configs: Dict[FeatureKey, FeaturePlotConfig] = {}
for feature_key in supported_features(kind):
- kind_configs[feature_key] = _build_plot_config(kind, feature_key, labels, colors)
+ kind_configs[feature_key] = _build_plot_config(
+ kind,
+ feature_key,
+ labels,
+ colors,
+ )
+
return kind_configs
@@ -89,7 +95,7 @@ def _build_plot_config(
kind: LibraryGeneratorName,
feature_key: FeatureKey,
labels: Dict[FeatureKey, str],
- colors: Dict[FeatureKey, Color],
+ colors: Dict[FeatureKey, BaseColor],
) -> FeaturePlotConfig:
data = feature_range(kind, feature_key)
return FeaturePlotConfig(
diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py
index f5f4c693..c33aa6d5 100644
--- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py
+++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py
@@ -4,9 +4,12 @@
import dearpygui.dearpygui as dpg
import numpy as np
+from sampletones_application.categories.context import channel_label, context_label, context_text
+from sampletones_application.categories.elements.global_ import ContextElements
+from sampletones_application.categories.hierarchy import TextType
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.categories.pitch import build_pitch_tooltip
-from sampletones_application.layout.general.colors import FeatureColors
+from sampletones_application.categories.pitch import PitchTooltips
+from sampletones_application.layout.general.colors.feature import FeatureColors
from sampletones_application.layout.graphs import GraphsLayout
from sampletones_application.tags.compose import compose_tag
from sampletones_application.tags.general import (
@@ -14,10 +17,12 @@
SUF_GROUP,
SUF_HANDLER_REGISTRY,
SUF_TEXT,
+ SUF_TOOLTIP,
TAG_GLOBAL_THEME_DEFAULT,
TAG_GLOBAL_THEME_INPUT_INVALID,
TAG_GLOBAL_THEME_INPUT_WARNING,
TAG_GLOBAL_THEME_INSTRUMENT_TABS,
+ TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED,
TAG_GLOBAL_THEME_PANEL_INSTRUMENT,
)
from sampletones_application.tags.graphs import (
@@ -25,13 +30,16 @@
SUF_GRAPH_RAW_DATA,
)
from sampletones_application.tags.reconstructions import (
+ SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE,
SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE,
SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW,
TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT,
TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL,
TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR,
+ TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE,
)
from sampletones_application.ui.elements.button import GUIButton
+from sampletones_application.ui.elements.field import labeled_field
from sampletones_application.ui.elements.fonts.font import Font
from sampletones_application.ui.elements.fonts.registry import FontRegistry
from sampletones_application.ui.elements.graphs.bar import GUIBarGraph
@@ -39,7 +47,10 @@
from sampletones_application.ui.elements.layout.card import card
from sampletones_application.ui.elements.layout.collapse import CollapseAxis
from sampletones_application.ui.elements.panel import GUIPanel
-from sampletones_application.ui.elements.pitch_stepper import GUIPitchStepper, PitchStepperStyle
+from sampletones_application.ui.elements.pitch_stepper import (
+ GUIPitchStepper,
+ PitchStepperStyle,
+)
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.ui.panels.reconstruction.instruments.config import (
FeaturePlotConfig,
@@ -51,18 +62,22 @@
dpg_configure_item,
dpg_set_value,
)
+from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
+from sampletones_application.utils.gui.tooltip import show_tooltip
from sampletones_application.view_model.reconstruction.instruments import (
ReconstructionInstrumentsViewModel,
)
+from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
from sampletones_core.constants.enums import (
FeatureKey,
GeneratorName,
LibraryGeneratorName,
)
-from sampletones_core.constants.general import MAX_PERIOD, MIN_PITCH
from sampletones_core.exporters import Features
-from sampletones_core.features import GENERATOR_KIND, supported_features
-from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS
+from sampletones_core.features import GENERATOR_KIND, resting_reference, supported_features
+from sampletones_core.formats.famitracker.specification.sequences import (
+ MAX_SEQUENCE_ITEMS,
+)
from sampletones_core.utils.pitch_kind import (
PERIOD_VALUE_KIND,
PITCH_VALUE_KIND,
@@ -94,10 +109,13 @@ def __init__(
self.generator_plots: Dict[GeneratorName, Dict[FeatureKey, GUIBarGraph]] = {}
self._pitch_steppers: Dict[GeneratorName, GUIPitchStepper] = {}
+ self._export_buttons: Dict[GeneratorName, GUIButton] = {}
self.tab_bar_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR
self.no_data_message_tag = compose_tag(self.tab_bar_tag, SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE)
self.mouse_item_handler_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, SUF_HANDLER_REGISTRY)
+ self.sample_size_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE
+ self.sample_size_group_tag = compose_tag(self.sample_size_tag, SUF_GROUP)
self._graphs: Dict[str, GUIBarGraph] = {}
self._sequence_lengths: Dict[Tuple[GeneratorName, FeatureKey], int] = {}
@@ -121,22 +139,16 @@ def __init__(
self.on_raw_data_changed: Optional[Callable[[GeneratorName, FeatureKey, np.ndarray], None]] = None
self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"]
- tooltip_template = language_manager["reconstructions.instruments.template.initial_pitch_tooltip_template"]
- self._pitch_tooltip = build_pitch_tooltip(
- language_manager,
- PITCH_VALUE_KIND,
- tooltip_template,
- )
- self._period_tooltip = build_pitch_tooltip(
+ self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE)
+ self._lbl_instrument_size = context_label(language_manager, ContextElements.INSTRUMENT_SIZE)
+ self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES)
+ self._tip_size_bytes = context_text(language_manager, TextType.TOOLTIP, ContextElements.SIZE_BYTES)
+ self._pitch_tooltips = PitchTooltips.build(
language_manager,
- PERIOD_VALUE_KIND,
- tooltip_template,
+ language_manager["reconstructions.instruments.template.initial_pitch_tooltip_template"],
)
self._generator_labels: Dict[GeneratorName, str] = {
- GeneratorName.PULSE1: language_manager["global.context.label.pulse_1"],
- GeneratorName.PULSE2: language_manager["global.context.label.pulse_2"],
- GeneratorName.TRIANGLE: language_manager["global.context.label.triangle"],
- GeneratorName.NOISE: language_manager["global.context.label.noise"],
+ generator_name: channel_label(language_manager, generator_name) for generator_name in GeneratorName.items()
}
super().__init__(
@@ -149,18 +161,20 @@ def __init__(
)
def create_panel(self, parent: str) -> None:
- with card(
- parent,
- self.tag,
- auto_resize_y=False,
- height=-1,
- no_scrollbar=True,
- ):
- with self._collapsible_section(
+ with (
+ card(
+ parent,
+ self.tag,
+ auto_resize_y=False,
+ height=-1,
+ no_scrollbar=True,
+ ),
+ self._collapsible_section(
self._language_manager["reconstructions.instruments.label.section"],
glyph=self._glyphs.headers.instruments,
- ):
- self._create_content()
+ ),
+ ):
+ self._create_content()
self._setup_mouse_event_handler()
@@ -172,6 +186,17 @@ def _create_content(self) -> None:
show=True,
)
+ with dpg.group(
+ tag=self.sample_size_group_tag,
+ parent=self._body_container,
+ show=False,
+ ):
+ self._create_size_field(
+ self._lbl_sample_size,
+ self.sample_size_tag,
+ self.sample_size_group_tag,
+ )
+
with dpg.tab_bar(
tag=self.tab_bar_tag,
parent=self._body_container,
@@ -179,9 +204,44 @@ def _create_content(self) -> None:
):
self._create_tabs_for_generators()
+ def _create_size_field(
+ self,
+ label: str,
+ value_tag: str,
+ parent: str,
+ ) -> None:
+ """Draws a read-only byte figure, styled as the pitch stepper's readout is.
+
+ The figure names how much of the NES data area an export spends, so it reads as
+ information beside the fields that change: the label column aligns with the stepper
+ below it, and the value carries the stepper's own read-only colour and font. A tooltip
+ names the export the figure measures, since the formats spend differently.
+ """
+ with labeled_field(
+ label,
+ self._pitch_stepper_style.dimensions.label_width,
+ parent=parent,
+ ):
+ dpg.add_text(tag=value_tag, default_value="")
+ dpg_set_palette_color(value_tag, self._pitch_stepper_style.value_color)
+ FontRegistry.bind_to_item(value_tag, Font.MONO)
+
+ show_tooltip(
+ value_tag,
+ self._tip_size_bytes,
+ tag=compose_tag(value_tag, SUF_TOOLTIP),
+ )
+
def _get_generator_tab_tag(self, generator_name: GeneratorName) -> str:
return compose_tag(self.tab_bar_tag, generator_name)
+ def _get_instrument_size_tag(self, generator_name: GeneratorName) -> str:
+ return compose_tag(
+ self.tab_bar_tag,
+ generator_name,
+ SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE,
+ )
+
def _get_window_tag(self, tab_tag: str) -> str:
return compose_tag(tab_tag, SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW)
@@ -250,7 +310,7 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None:
):
self.generator_plots[generator_name] = {}
button_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, tab_tag)
- GUIButton(
+ self._export_buttons[generator_name] = GUIButton(
tag=button_tag,
parent=tab_tag,
label=self._language_manager["reconstructions.instruments.label.export_instrument_button"],
@@ -281,11 +341,16 @@ def _create_generator_content(
window_tag: str,
) -> None:
initial_pitch = self._default_initial_pitch(generator_name)
+ self._create_size_field(
+ self._lbl_instrument_size,
+ self._get_instrument_size_tag(generator_name),
+ window_tag,
+ )
self._create_pitch_stepper(generator_name, initial_pitch, window_tag)
self._create_generator_feature_displays(generator_name, window_tag)
def _default_initial_pitch(self, generator_name: GeneratorName) -> int:
- return MAX_PERIOD if generator_name == GeneratorName.NOISE else MIN_PITCH
+ return resting_reference(generator_name)
def _create_generator_feature_displays(
self,
@@ -374,14 +439,64 @@ def update_view(
self,
view_model: ReconstructionInstrumentsViewModel,
) -> None:
+ """Shows a tab per channel, marking the ones standing by.
+
+ Every channel is editable for as long as a reconstruction is open, so writing an
+ envelope into a channel standing by is what puts it in play. A muted tab label and a
+ withheld export say which channels are there.
+ """
is_loaded = view_model.reconstruction_loaded
dpg_configure_item(self.no_data_message_tag, show=not is_loaded)
dpg_configure_item(self.tab_bar_tag, show=is_loaded)
+ dpg_configure_item(self.sample_size_group_tag, show=is_loaded)
+ self._update_sizes(view_model.footprint)
for generator_name in GeneratorName.items():
tab_tag = self._get_generator_tab_tag(generator_name)
- is_available = generator_name in view_model.available_generators
- dpg_configure_item(tab_tag, show=is_available)
+ dpg_configure_item(tab_tag, show=is_loaded)
+ self._apply_playing_state(
+ generator_name,
+ generator_name in view_model.playing_generators,
+ )
+
+ def _apply_playing_state(
+ self,
+ generator_name: GeneratorName,
+ is_playing: bool,
+ ) -> None:
+ """Marks one channel's tab as playing or standing by.
+
+ The muted theme reaches the tab label alone; the tab's body carries its own text colour,
+ so a channel standing by stays as readable to edit as one that plays.
+ """
+ theme_tag = TAG_GLOBAL_THEME_INSTRUMENT_TABS if is_playing else TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED
+ ThemeRegistry.get(theme_tag).bind_to_item(self._get_generator_tab_tag(generator_name))
+
+ export_button = self._export_buttons.get(generator_name)
+ if export_button is not None:
+ export_button.set_enabled(is_playing)
+
+ def _update_sizes(
+ self,
+ footprint: Optional[SampleFootprintViewModel],
+ ) -> None:
+ """Writes the byte figures the loaded reconstruction occupies, the sample's and each channel's.
+
+ A channel standing by is written by no export, so it reads as the nothing it costs.
+ """
+ if footprint is None:
+ return
+
+ dpg_set_value(self.sample_size_tag, self._format_size(footprint.total_bytes))
+ for generator_name in GeneratorName.items():
+ instrument_bytes = footprint.bytes_for(generator_name)
+ dpg_set_value(
+ self._get_instrument_size_tag(generator_name),
+ self._format_size(instrument_bytes if instrument_bytes is not None else 0),
+ )
+
+ def _format_size(self, byte_count: int) -> str:
+ return self._tpl_size_bytes.format(bytes=byte_count)
def update_feature_data(
self,
@@ -456,7 +571,7 @@ def _create_pitch_stepper(
if is_noise
else self._language_manager["reconstructions.instruments.label.initial_pitch"]
),
- tooltip=self._period_tooltip if is_noise else self._pitch_tooltip,
+ tooltip=self._pitch_tooltips.for_kind(kind),
status_message=(
self._language_manager["reconstructions.instruments.message.status_input_period"]
if is_noise
@@ -480,7 +595,7 @@ def _on_pitch_value_changed(
) -> None:
self.call(self.on_pitch_value_changed, generator_name, value)
- def _on_mouse_move(self, sender: Sender, app_data: Tuple[int, int]) -> None:
+ def _on_mouse_move(self, _sender: Sender, _app_data: Tuple[int, int]) -> None:
tab = dpg.get_value(self.tab_bar_tag)
if not tab:
self.call(self.on_reconstruction_instrument_hovered, None)
@@ -671,8 +786,8 @@ def _sequence_status_message(
self,
generator_name: GeneratorName,
feature_key: FeatureKey,
- *args: Any,
- **kwargs: Any,
+ *_args: Any,
+ **_kwargs: Any,
) -> str:
"""Describes the sequence input, naming the export limit once a sequence passes it."""
item_count = self._sequence_lengths.get((generator_name, feature_key), 0)
diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py
index f65a3f9c..d1913233 100644
--- a/src/sampletones_application/ui/panels/reconstruction/plot.py
+++ b/src/sampletones_application/ui/panels/reconstruction/plot.py
@@ -2,15 +2,10 @@
import dearpygui.dearpygui as dpg
+from sampletones_application.categories.context import channel_label
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.layout.graphs import GraphsLayout
from sampletones_application.tags.compose import compose_tag
-from sampletones_application.tags.general import (
- TAG_GLOBAL_THEME_CHANNEL_NOISE,
- TAG_GLOBAL_THEME_CHANNEL_PULSE1,
- TAG_GLOBAL_THEME_CHANNEL_PULSE2,
- TAG_GLOBAL_THEME_CHANNEL_TRIANGLE,
-)
from sampletones_application.tags.reconstructions import (
PRE_RECONSTRUCTION_GENERATOR,
SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE,
@@ -23,6 +18,7 @@
from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value
from sampletones_application.utils.gui.tooltip import show_tooltip
@@ -34,13 +30,6 @@
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import MessageCallback
-_GENERATOR_THEME_TAGS = {
- GeneratorName.PULSE1: TAG_GLOBAL_THEME_CHANNEL_PULSE1,
- GeneratorName.PULSE2: TAG_GLOBAL_THEME_CHANNEL_PULSE2,
- GeneratorName.TRIANGLE: TAG_GLOBAL_THEME_CHANNEL_TRIANGLE,
- GeneratorName.NOISE: TAG_GLOBAL_THEME_CHANNEL_NOISE,
-}
-
class GUIReconstructionPlotPanel(GUIPanel):
def __init__(
@@ -86,17 +75,23 @@ def create_panel(self, parent: str) -> None:
self._create_tooltips()
def update_view(self, view_model: ReconstructionViewModel) -> None:
+ """Offers a checkbox for each channel that plays, ticked where the reader keeps it on.
+
+ The channels an edit puts in play arrive already selected and one switched off by hand
+ arrives as it was left, so the boxes report what plays without overruling a choice.
+ """
for generator_name in GeneratorName:
tag = self._get_generator_checkbox_tag(generator_name)
- is_available = generator_name in view_model.available_generators
+ is_playing = generator_name in view_model.playing_generators
+ is_selected = generator_name in view_model.selected_generators
dpg_configure_item(
tag,
- enabled=is_available,
- default_value=is_available,
+ enabled=is_playing,
+ default_value=is_selected,
)
- dpg_set_value(tag, is_available)
- if is_available:
- ThemeRegistry.get(_GENERATOR_THEME_TAGS[generator_name]).bind_to_item(tag)
+ dpg_set_value(tag, is_selected)
+ if is_playing:
+ ThemeRegistry.get(CHANNEL_THEME_TAGS[generator_name]).bind_to_item(tag)
else:
dpg.bind_item_theme(tag, 0)
@@ -161,10 +156,8 @@ def _create_waveform_display(self) -> None:
def _create_generator_checkboxes(self) -> None:
generator_labels = {
- GeneratorName.PULSE1: self._language_manager["global.context.label.pulse_1"],
- GeneratorName.PULSE2: self._language_manager["global.context.label.pulse_2"],
- GeneratorName.TRIANGLE: self._language_manager["global.context.label.triangle"],
- GeneratorName.NOISE: self._language_manager["global.context.label.noise"],
+ generator_name: channel_label(self._language_manager, generator_name)
+ for generator_name in GeneratorName.items()
}
with dpg.group(
@@ -200,7 +193,7 @@ def _create_message_function_for_generator_checkbox(
tag = self._get_generator_checkbox_tag(generator_name)
name = generator_name.capitalized
- def message_function(*args: Any, **kwargs: Any) -> str:
+ def message_function(*_args: Any, **_kwargs: Any) -> str:
if not dpg.is_item_enabled(tag):
return self._language_manager[
"reconstructions.instruments.message.status_generator_not_available"
@@ -229,9 +222,23 @@ def _read_selected_generators(self) -> List[GeneratorName]:
return selected_generators
+ def toggle_generator(self, generator_name: GeneratorName) -> None:
+ """Switches one generator's slice in and out of the waveform and of what plays.
+
+ This is the gesture a click on the generator's checkbox makes, reached by the key the
+ channel answers to. A generator the loaded reconstruction holds none of keeps the
+ checkbox its disabled state already shows.
+ """
+ tag = self._get_generator_checkbox_tag(generator_name)
+ if not dpg.is_item_enabled(tag):
+ return
+
+ dpg_set_value(tag, not dpg.get_value(tag))
+ self._on_generator_checkbox_changed()
+
def _on_generator_checkbox_changed(self) -> None:
selected_generators = self._read_selected_generators()
self.call(self.on_generators_changed, selected_generators)
- def _on_autoscale_changed(self, sender: Sender, app_data: bool) -> None:
+ def _on_autoscale_changed(self, _sender: Sender, app_data: bool) -> None:
self.waveform_display.set_autoscale(app_data)
diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py
index 7e25579c..9bfe14e3 100644
--- a/src/sampletones_application/ui/panels/sequencer/browser.py
+++ b/src/sampletones_application/ui/panels/sequencer/browser.py
@@ -1,10 +1,9 @@
-from typing import Any, Dict, Optional, Tuple
-
-import dearpygui.dearpygui as dpg
+from typing import AbstractSet
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.behavior import SchedulingBehavior
-from sampletones_application.tags.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
+)
from sampletones_application.tags.sequencer import (
TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
TAG_SEQUENCER_BROWSER_GROUP_CONTROLS,
@@ -13,32 +12,27 @@
TAG_SEQUENCER_BROWSER_TREE,
TAG_SEQUENCER_BROWSER_WINDOW_TREE,
)
-from sampletones_application.ui.elements.button import GUIButton
-from sampletones_application.ui.elements.context_menu import context_menu
-from sampletones_application.ui.elements.layout.collapse import CollapseAxis
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.ui.elements.tree.colors import TreeColors
-from sampletones_application.ui.elements.tree.handler import NodeHandler
from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
-from sampletones_application.ui.elements.tree.state import TreeNodeState
-from sampletones_application.ui.elements.tree.tree import GUITreePanel
-from sampletones_application.ui.themes.registry import ThemeRegistry
-from sampletones_application.utils.gui.dpg import dpg_configure_item
-from sampletones_application.utils.parallelization.thread import concurrent
-from sampletones_core.structures.tree import (
- FileSystemNode,
- NodeType,
- Tree,
- TreeNode,
- TreeTraversal,
- traverse,
+from sampletones_application.ui.elements.tree.tags import FileBrowserTags
+from sampletones_application.ui.panels.shared.browser import (
+ GUIReconstructionBrowserPanel,
)
-from sampletones_shared.types.application import Sender
-from sampletones_shared.types.callback import VoidCallback
+from sampletones_core.structures.tree import FileSystemNode, Tree
+
+class GUISequencerBrowserPanel(GUIReconstructionBrowserPanel):
+ """The Sequencer tab's browser, whose reconstructions become the song's samples."""
-class GUISequencerBrowserPanel(GUITreePanel):
- _MONOSPACE_CONFIG_NODES: bool = True
+ _tags: FileBrowserTags = FileBrowserTags(
+ panel=TAG_SEQUENCER_BROWSER_PANEL,
+ tree=TAG_SEQUENCER_BROWSER_TREE,
+ window_tree=TAG_SEQUENCER_BROWSER_WINDOW_TREE,
+ group_tree=TAG_SEQUENCER_BROWSER_GROUP_TREE,
+ group_controls=TAG_SEQUENCER_BROWSER_GROUP_CONTROLS,
+ button_refresh=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
+ )
def __init__(
self,
@@ -49,221 +43,35 @@ def __init__(
language_manager: LanguageManager,
status_bar: GUIStatusBar,
colors: TreeColors,
- initial_collapsed: bool = False,
+ initial_collapsed: bool,
+ initial_favorites_only: bool,
+ initial_expanded_rows: AbstractSet[str],
) -> None:
self._language_manager = language_manager
- self.on_refresh_tree: Optional[VoidCallback] = None
-
- self._lbl_reconstructions = language_manager["sequencer.browser.label.reconstructions_tree"]
-
- self._node_handlers: Dict[NodeType, NodeHandler]
super().__init__(
tree=tree,
- tag=TAG_SEQUENCER_BROWSER_PANEL,
- tree_tag=TAG_SEQUENCER_BROWSER_TREE,
tree_logic=tree_logic,
scheduling=scheduling,
- search_label=language_manager["global.browser.label.search"],
language_manager=language_manager,
status_bar=status_bar,
colors=colors,
- )
-
- self._enable_horizontal_collapse(
initial_collapsed=initial_collapsed,
- side=CollapseAxis.HORIZONTAL_LEFT,
- )
-
- def create_panel(self, parent: str) -> None:
- self._setup_handlers()
- with dpg.child_window(
- tag=self.tag,
- width=self.width,
- height=self.height,
- parent=parent,
- border=False,
- ):
- with self._collapsible_section(
- self._lbl_reconstructions,
- glyph=self._glyphs.headers.reconstruction,
- ):
- self._create_buttons()
- dpg.add_separator()
- self._create_tree_window()
-
- self._create_detail_tooltip(TAG_SEQUENCER_BROWSER_WINDOW_TREE)
- self.rebuild_tree()
-
- def _setup_handlers(self) -> None:
- self._node_handlers = {
- NodeType.DIRECTORY: NodeHandler(
- tag=self._get_node_handler_tag(NodeType.DIRECTORY),
- node_type=NodeType.DIRECTORY,
- item_click_callback=self._on_directory_node_clicked,
- status_bar_callback=self._create_status_bar_message_function_for_directory_node(),
- ),
- NodeType.FILE: NodeHandler(
- tag=self._get_node_handler_tag(NodeType.FILE),
- node_type=NodeType.FILE,
- item_click_callback=self._on_reconstruction_node_clicked,
- item_double_click_callback=self._on_reconstruction_node_double_clicked,
- status_bar_callback=self._create_status_bar_message_function_for_reconstruction_node(),
- ),
- }
-
- super()._setup_handlers()
-
- def _create_buttons(self) -> None:
- with dpg.group(tag=TAG_SEQUENCER_BROWSER_GROUP_CONTROLS):
- GUIButton(
- tag=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
- label=self._language_manager["sequencer.browser.label.refresh_button"],
- width=-1,
- callback=self.rebuild_tree,
- theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON),
- )
- self._status_bar.bind_to_item(
- TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS,
- self._language_manager["sequencer.browser.message.status_refresh"],
- )
-
- 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.group(tag=TAG_SEQUENCER_BROWSER_GROUP_TREE):
- with dpg.tree_node(
- label=self._lbl_reconstructions,
- tag=self.tree_tag,
- default_open=True,
- ):
- pass
-
- def refresh(self) -> None:
- self.rebuild_tree()
-
- @concurrent(wait=False, method_bound=True)
- def rebuild_tree(self) -> None:
- self._launch_rebuild(
- lambda: self.call(self.on_refresh_tree),
- lambda: self._collect_specs(self.tree_tag),
- root_tag=self.tree_tag,
- )
-
- def _has_relevant_content(self, node: TreeNode) -> bool:
- if node.node_type == NodeType.FILE:
- return True
-
- return bool(node.children)
-
- @traverse(TreeTraversal.BFS)
- def _build_tree_node(
- self,
- node: TreeNode,
- state: TreeNodeState,
- **kwargs: Any,
- ) -> None:
- node_tag = self._generate_node_tag(node)
- if node.node_type == NodeType.ROOT:
- return
-
- if not isinstance(node, FileSystemNode):
- return
-
- is_favorite = self._logic.is_node_favorite(node)
- state.has_favorite_ancestor |= is_favorite
- if node.node_type == NodeType.DIRECTORY:
- should_expand = self._should_expand_node(node)
- self._append_spec(
- node=node,
- node_tag=node_tag,
- parent=state.parent,
- should_expand=should_expand,
- has_favorite_ancestor=state.has_favorite_ancestor,
- )
- else:
- self._append_spec(
- node=node,
- node_tag=node_tag,
- parent=state.parent,
- leaf=True,
- has_favorite_ancestor=state.has_favorite_ancestor,
- )
-
- state.parent = node_tag
-
- 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,
+ initial_favorites_only=initial_favorites_only,
+ initial_expanded_rows=initial_expanded_rows,
)
- def _on_directory_node_clicked(
- self,
- sender: Sender,
- app_data: Tuple[int, int],
- user_data: Tuple[FileSystemNode, str],
- ) -> None:
- mouse_button, _ = app_data
- node, _ = user_data
- if mouse_button == dpg.mvMouseButton_Right:
- return self._show_directory_context_menu(node)
-
- return None
+ @property
+ def refresh_button_label(self) -> str:
+ return self._language_manager["sequencer.browser.label.refresh_button"]
- def _on_reconstruction_node_clicked(
- self,
- sender: Sender,
- app_data: Tuple[int, int],
- user_data: Tuple[FileSystemNode, str],
- ) -> None:
- mouse_button, _ = app_data
- node, node_tag = user_data
- if mouse_button == dpg.mvMouseButton_Left:
- self._logic.request_autoplay(node)
-
- if mouse_button == dpg.mvMouseButton_Right:
- self._show_reconstruction_context_menu(node, node_tag)
-
- def _on_reconstruction_node_double_clicked(
- self,
- sender: Sender,
- app_data: Tuple[int, int],
- user_data: Tuple[FileSystemNode, str],
- ) -> None:
- mouse_button, _ = app_data
- node, _ = user_data
- if mouse_button == dpg.mvMouseButton_Left:
- self._logic.cancel_autoplay()
- self.call(self.on_add_to_sequencer, node.filepath)
-
- def _show_directory_context_menu(self, node: FileSystemNode) -> None:
- if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY:
- return
-
- with context_menu():
- self._add_context_menu_text(node)
- self._add_context_menu_details(node)
- self._add_context_menu_path_items(node.filepath)
- self._add_context_menu_favorite_item(node)
+ @property
+ def refresh_status_message(self) -> str:
+ return self._language_manager["sequencer.browser.message.status_refresh"]
- def _show_reconstruction_context_menu(
- self,
- node: FileSystemNode,
- node_tag: str,
- ) -> None:
- if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE:
- return
+ def _open_reconstruction(self, node: FileSystemNode) -> None:
+ self.call(self.on_add_to_sequencer, node.filepath)
- with context_menu():
- self._add_context_menu_text(node)
- self._add_context_menu_play_item(node)
- self._add_context_menu_sequencer_items(node)
- self._add_context_menu_replace_item(node)
- self._add_context_menu_path_items(node.filepath)
- self._add_context_menu_locate_audio_item(node)
- self._add_context_menu_favorite_item(node)
+ def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None:
+ self._add_context_menu_sequencer_items(node)
+ self._add_context_menu_replace_item(node)
diff --git a/src/sampletones_application/ui/panels/sequencer/columns.py b/src/sampletones_application/ui/panels/sequencer/columns.py
index 0cd8dcf9..7d2ed4ca 100644
--- a/src/sampletones_application/ui/panels/sequencer/columns.py
+++ b/src/sampletones_application/ui/panels/sequencer/columns.py
@@ -1,13 +1,8 @@
-from typing import Final, Optional, Tuple
+from typing import Final, Optional
-from sampletones_application.layout.tabs.sequencer.colors import ChannelColors
-from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor
-from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors
+from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_core.constants.enums import GeneratorName
-from sampletones_shared.types.application import ColorRGBA
-
-COLUMNS: Final[Tuple[Optional[GeneratorName], ...]] = (None,) + tuple(GeneratorName.items())
-SUBCOLUMNS: Final[Tuple[SubColumn, ...]] = tuple(SubColumn)
_LEADING_TABLE_COLUMNS: Final[int] = 2
SAMPLE_TABLE_COLUMN: Final[int] = _LEADING_TABLE_COLUMNS
@@ -20,17 +15,7 @@
HEADER_TABLE_ROWS: Final[int] = HEADER_TABLE_ROW + 1
-def flat_index(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int:
- return COLUMNS.index(generator) * len(SUBCOLUMNS) + SUBCOLUMNS.index(subcolumn)
-
-
-def from_flat(row: int, index: int) -> TrackerCursor:
- index %= len(COLUMNS) * len(SUBCOLUMNS)
- column, sub = divmod(index, len(SUBCOLUMNS))
- return TrackerCursor(row, COLUMNS[column], SUBCOLUMNS[sub])
-
-
-def channel_color(colors: ChannelColors, generator: GeneratorName) -> ColorRGBA:
+def channel_color(colors: ChannelColors, generator: GeneratorName) -> BaseColor:
match generator:
case GeneratorName.PULSE1:
return colors.pulse1
@@ -47,8 +32,8 @@ def tracker_table_column(generator: Optional[GeneratorName]) -> int:
The visual divider between the sample column and the channels occupies a table
column of its own, so the channels sit one slot further right than their
- logical position. The divider is purely visual, so :data:`COLUMNS` covers only
- the cursor-addressable columns.
+ logical position. The divider is purely visual, so :data:`CHANNEL_AXIS` covers
+ only the cursor-addressable columns.
"""
if generator is None:
return SAMPLE_TABLE_COLUMN
diff --git a/src/sampletones_application/ui/panels/sequencer/display.py b/src/sampletones_application/ui/panels/sequencer/display.py
index b53a99d4..4b99693a 100644
--- a/src/sampletones_application/ui/panels/sequencer/display.py
+++ b/src/sampletones_application/ui/panels/sequencer/display.py
@@ -1,15 +1,17 @@
from typing import Dict, Final, Optional, Tuple
from sampletones_application.ui.elements.table.cells import pending_label
-from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor
-from sampletones_application.view_model.sequencer.grid import SequencerCellViewModel
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.view_model.sequencer.tracker import SequencerCellViewModel
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.utils.display import display_id, display_transpose, display_volume
CellKey = Tuple[int, Optional[GeneratorName], SubColumn]
CellValues = Dict[CellKey, str]
+CELL_TITLE_SEPARATOR: Final[str] = " | "
+
_DEFAULT_LABELS: Final[Dict[SubColumn, str]] = {
SubColumn.INSTRUMENT: display_id(None),
SubColumn.TRANSPOSE: display_transpose(None),
@@ -18,10 +20,19 @@
def indexed_label(index: int, label: str) -> str:
- """Joins a formatted index and a label into one display string, e.g. ``"03 Pulse 1"``."""
+ """Joins a formatted index and a label into one display string, e.g. ``"03 Bass"``."""
return f"{display_id(index)} {label}"
+def cell_title(index: int, label: str) -> str:
+ """Names the cell a menu was raised on, e.g. ``"0C | Pulse 1"``.
+
+ Both grids title their cell menus this way: where along the grid the cell sits, then the
+ channel it belongs to, so a menu states its target the same wherever it is opened.
+ """
+ return f"{display_id(index)}{CELL_TITLE_SEPARATOR}{label}"
+
+
def cell_display(cell_view_model: SequencerCellViewModel, subcolumn: SubColumn) -> str:
"""Extract the pre-formatted display string for one subcolumn from a cell view model."""
match subcolumn:
@@ -56,8 +67,15 @@ def subcolumn_label(
is_active = (
cursor is not None and cursor.row == row and cursor.generator == generator and cursor.subcolumn == subcolumn
)
- stored = cell_values.get((row, generator, subcolumn), _DEFAULT_LABELS[subcolumn])
+ stored = cell_values.get(
+ (row, generator, subcolumn),
+ _DEFAULT_LABELS[subcolumn],
+ )
if is_active:
- return pending_label(pending, stored, len(_DEFAULT_LABELS[subcolumn]))
+ return pending_label(
+ pending,
+ stored,
+ len(_DEFAULT_LABELS[subcolumn]),
+ )
return stored
diff --git a/src/sampletones_application/ui/panels/sequencer/grid.py b/src/sampletones_application/ui/panels/sequencer/grid.py
deleted file mode 100644
index 82609334..00000000
--- a/src/sampletones_application/ui/panels/sequencer/grid.py
+++ /dev/null
@@ -1,1329 +0,0 @@
-from typing import Callable, Dict, Final, Optional, Tuple
-
-import dearpygui.dearpygui as dpg
-
-from sampletones_application.categories.elements.sequencer import SequencerGridElements
-from sampletones_application.categories.hierarchy import Page, Panel, TextType
-from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.tabs.sequencer import SequencerLayout
-from sampletones_application.tags.compose import compose_tag
-from sampletones_application.tags.general import SUF_HANDLER_HEADER, SUF_HANDLER_REGISTRY
-from sampletones_application.tags.sequencer import (
- TAG_SEQUENCER_GRID_GROUP_TRACKER,
- TAG_SEQUENCER_GRID_PANEL,
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- TAG_SEQUENCER_GRID_WINDOW_TRACKER,
- TAG_SEQUENCER_THEME_TABLE_PATTERN,
-)
-from sampletones_application.ui.elements.context_menu import (
- add_play_menu_item,
- context_menu,
-)
-from sampletones_application.ui.elements.fonts.font import Font
-from sampletones_application.ui.elements.fonts.registry import FontRegistry
-from sampletones_application.ui.elements.panel import GUIPanel
-from sampletones_application.ui.elements.table.caret import CaretOverlay
-from sampletones_application.ui.elements.table.cells import EditableCells
-from sampletones_application.ui.panels.sequencer import display as tracker_display
-from sampletones_application.ui.panels.sequencer.channels import (
- ChannelMenuLabels,
- ChannelSwitch,
- channel_tooltip,
-)
-from sampletones_application.ui.panels.sequencer.columns import (
- DIVIDER_TABLE_COLUMN,
- HEADER_TABLE_ROW,
- HEADER_TABLE_ROWS,
- SAMPLE_TABLE_COLUMN,
- TRACKER_TABLE_COLUMNS,
- channel_color,
- tracker_table_column,
- tracker_table_row,
-)
-from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues
-from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor
-from sampletones_application.ui.panels.sequencer.input.edit import (
- ClearAction,
- EditAction,
-)
-from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState
-from sampletones_application.ui.themes.inline import (
- create_header_selectable_theme,
- create_selectable_text_theme,
-)
-from sampletones_application.ui.themes.registry import ThemeRegistry
-from sampletones_application.utils.gui.dpg import dpg_delete_children
-from sampletones_application.utils.gui.keyboard import (
- PRIORITY_PANEL,
- KeyEvent,
- KeyRouter,
-)
-from sampletones_application.utils.gui.keyboard.modifiers import (
- CTRL,
- CTRL_SHIFT,
- Modifier,
-)
-from sampletones_application.utils.gui.shortcuts.keys import HEX_KEYS, KEY_PAGE_DOWN, KEY_PAGE_UP, SIGN_KEYS
-from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut
-from sampletones_application.utils.gui.tooltip import show_tooltip
-from sampletones_application.view_model.sequencer.channels import (
- SequencerChannelsViewModel,
-)
-from sampletones_application.view_model.sequencer.grid import (
- SequencerGridViewModel,
- SequencerRowViewModel,
-)
-from sampletones_application.view_model.sequencer.samples import (
- SequencerSamplesViewModel,
-)
-from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.constants.general import MAX_VOLUME
-from sampletones_core.utils.display import NOTE_OFF, display_id
-from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP
-from sampletones_shared.types.application import ColorRGBA, Sender
-from sampletones_shared.types.callback import VoidCallback
-from sampletones_shared.utils.color import with_alpha_fraction
-
-OnClearRowCallback = Callable[[int, Optional[GeneratorName]], None]
-OnClearSubcolumnCallback = Callable[[int, Optional[GeneratorName], SubColumn], None]
-OnSetRowCallback = Callable[[int, Optional[GeneratorName], Optional[str], Optional[int], Optional[int]], None]
-OnSetNoteOffCallback = Callable[[int, Optional[GeneratorName]], None]
-OnCellSelectedCallback = Callable[[int, Optional[GeneratorName]], None]
-OnPlayFromRowCallback = Callable[[int], None]
-OnPlayFromFrameCallback = Callable[[], None]
-OnAdjustCallback = Callable[[int, Optional[GeneratorName], int], None]
-OnChannelMuteToggledCallback = Callable[[GeneratorName], None]
-OnChannelSoloedCallback = Callable[[GeneratorName], None]
-
-
-VOLUME_FINE_STEP: Final[int] = 1
-VOLUME_COARSE_STEP: Final[int] = (MAX_VOLUME + 1) // 4
-
-
-class GUISequencerGridPanel(GUIPanel):
- def __init__(
- self,
- *,
- layout: SequencerLayout,
- language_manager: LanguageManager,
- key_router: KeyRouter,
- initial_collapsed: bool = False,
- ) -> None:
- self._layout = layout
- self._language_manager = language_manager
- self._router = key_router
-
- widths = layout.tracker.subcolumn_widths
- self._subcolumn_widths: Dict[SubColumn, int] = {
- SubColumn.INSTRUMENT: widths.instrument,
- SubColumn.TRANSPOSE: widths.transpose,
- SubColumn.VOLUME: widths.volume,
- }
-
- self._item_handler_tag = compose_tag(TAG_SEQUENCER_GRID_PANEL, SUF_HANDLER_REGISTRY)
- self._cell_handler_tag = compose_tag(TAG_SEQUENCER_GRID_TABLE_TRACKER, SUF_HANDLER_REGISTRY)
- self._header_handler_tag = compose_tag(TAG_SEQUENCER_GRID_TABLE_TRACKER, SUF_HANDLER_HEADER)
-
- self._rows: Dict[Optional[int], Sender] = {}
- self._header_columns: Dict[Sender, Optional[GeneratorName]] = {}
- self._editable_cells: EditableCells[CellKey] = EditableCells()
- self._current_row_count: int = 0
- self._highlighted_row: Optional[int] = None
- self._playing_row: Optional[int] = None
- self._input_state: TrackerInputState = TrackerInputState()
- self._subcolumn_themes: Dict[SubColumn, int] = {}
- self._muted_subcolumn_themes: Dict[SubColumn, int] = {}
- self._row_number_theme: int = 0
- self._header_theme: int = 0
- self._muted_header_theme: int = 0
- self._current_samples: Optional[SequencerSamplesViewModel] = None
- self._current_channels: Optional[SequencerChannelsViewModel] = None
-
- self.on_clear_row: Optional[OnClearRowCallback] = None
- self.on_clear_subcolumn: Optional[OnClearSubcolumnCallback] = None
- self.on_set_row: Optional[OnSetRowCallback] = None
- self.on_set_note_off: Optional[OnSetNoteOffCallback] = None
- self.on_cell_selected: Optional[OnCellSelectedCallback] = None
- self.on_play_from_row: Optional[OnPlayFromRowCallback] = None
- self.on_play_from_frame: Optional[OnPlayFromFrameCallback] = None
- self.on_adjust_transpose: Optional[OnAdjustCallback] = None
- self.on_adjust_volume: Optional[OnAdjustCallback] = None
- self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None
- self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None
- self.on_channels_toggled: Optional[VoidCallback] = None
- self.on_channels_muted: Optional[VoidCallback] = None
- self.on_channels_unmuted: Optional[VoidCallback] = None
-
- self.pattern_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_PATTERN)
-
- 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)
- self._create_channel_switch(language_manager)
-
- self._sc_play_from_here = Shortcut(
- dpg.mvKey_Spacebar,
- CTRL_SHIFT,
- ).get_display_string()
- self._sc_play_from_frame = Shortcut(dpg.mvKey_Spacebar, CTRL).get_display_string()
-
- super().__init__(
- tag=TAG_SEQUENCER_GRID_PANEL,
- height=-1,
- )
- self._enable_vertical_collapse(initial_collapsed=initial_collapsed)
-
- def _load_column_labels(self, language_manager: LanguageManager) -> None:
- """Reads the name each column carries, which its header label and its menu title show."""
- self._lbl_col_row = self._label(language_manager, SequencerGridElements.COLUMN_ROW)
- self._column_labels: Dict[Optional[GeneratorName], str] = {
- None: self._label(language_manager, SequencerGridElements.COLUMN_SAMPLE),
- GeneratorName.PULSE1: self._label(language_manager, SequencerGridElements.COLUMN_PULSE_1),
- GeneratorName.PULSE2: self._label(language_manager, SequencerGridElements.COLUMN_PULSE_2),
- GeneratorName.TRIANGLE: self._label(language_manager, SequencerGridElements.COLUMN_TRIANGLE),
- GeneratorName.NOISE: self._label(language_manager, SequencerGridElements.COLUMN_NOISE),
- }
-
- @staticmethod
- 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:
- return self._label(language_manager, element)
-
- self._lbl_context_play = label(SequencerGridElements.CONTEXT_PLAY)
- self._lbl_context_play_from_frame = label(SequencerGridElements.CONTEXT_PLAY_FROM_FRAME)
- self._lbl_context_note_off = label(SequencerGridElements.CONTEXT_NOTE_OFF)
- self._lbl_context_set_instrument = label(SequencerGridElements.CONTEXT_SET_INSTRUMENT)
- self._lbl_context_no_samples = label(SequencerGridElements.CONTEXT_NO_SAMPLES)
- self._lbl_context_clear_subcolumn = label(SequencerGridElements.CONTEXT_CLEAR_SUBCOLUMN)
- self._lbl_context_clear_cell = label(SequencerGridElements.CONTEXT_CLEAR_CELL)
- self._lbl_context_clear_row = label(SequencerGridElements.CONTEXT_CLEAR_ROW)
- self._lbl_context_transpose_up = label(SequencerGridElements.CONTEXT_TRANSPOSE_UP)
- self._lbl_context_transpose_down = label(SequencerGridElements.CONTEXT_TRANSPOSE_DOWN)
- self._lbl_context_transpose_octave_up = label(SequencerGridElements.CONTEXT_TRANSPOSE_OCTAVE_UP)
- self._lbl_context_transpose_octave_down = label(SequencerGridElements.CONTEXT_TRANSPOSE_OCTAVE_DOWN)
- self._lbl_context_volume_up = label(SequencerGridElements.CONTEXT_VOLUME_UP)
- self._lbl_context_volume_down = label(SequencerGridElements.CONTEXT_VOLUME_DOWN)
- self._lbl_context_volume_up_coarse = label(SequencerGridElements.CONTEXT_VOLUME_UP_COARSE)
- self._lbl_context_volume_down_coarse = label(SequencerGridElements.CONTEXT_VOLUME_DOWN_COARSE)
-
- def _load_header_tooltips(self, language_manager: LanguageManager) -> None:
- """Reads the header tooltips, which name the click gestures the labels carry."""
-
- def tooltip(element: SequencerGridElements) -> str:
- return language_manager[Page.SEQUENCER, Panel.GRID, TextType.TOOLTIP, element]
-
- self._tooltip_header_channel = channel_tooltip(tooltip(SequencerGridElements.HEADER_CHANNEL))
- self._tooltip_header_sample = tooltip(SequencerGridElements.HEADER_SAMPLE)
-
- def _create_channel_switch(self, language_manager: LanguageManager) -> None:
- """Builds the switch a column header's click and menu act through.
-
- The hooks are read at call time, so the switch is built here while they are still unset and
- the coordinator wires them once the panel exists.
- """
- labels = ChannelMenuLabels(
- mute=self._label(language_manager, SequencerGridElements.CONTEXT_MUTE),
- unmute=self._label(language_manager, SequencerGridElements.CONTEXT_UNMUTE),
- solo=self._label(language_manager, SequencerGridElements.CONTEXT_SOLO),
- unsolo=self._label(language_manager, SequencerGridElements.CONTEXT_UNSOLO),
- mute_all=self._label(language_manager, SequencerGridElements.CONTEXT_MUTE_ALL),
- unmute_all=self._label(language_manager, SequencerGridElements.CONTEXT_UNMUTE_ALL),
- )
- self._channel_switch = ChannelSwitch(
- labels=labels,
- on_mute_toggled=lambda generator: self.call(self.on_channel_mute_toggled, generator),
- on_soloed=lambda generator: self.call(self.on_channel_soloed, generator),
- on_toggled=lambda: self.call(self.on_channels_toggled),
- on_muted=lambda: self.call(self.on_channels_muted),
- on_unmuted=lambda: self.call(self.on_channels_unmuted),
- )
-
- def create_panel(self, parent: str) -> None:
- self._setup_handlers()
- self._create_themes()
- self._create_tracker_view(parent)
-
- def _setup_handlers(self) -> None:
- with dpg.item_handler_registry(tag=self._item_handler_tag):
- dpg.add_item_hover_handler(
- parent=self._item_handler_tag,
- callback=self._on_row_hovered,
- )
-
- with dpg.item_handler_registry(tag=self._cell_handler_tag):
- dpg.add_item_clicked_handler(callback=self._on_cell_right_clicked)
-
- with dpg.item_handler_registry(tag=self._header_handler_tag):
- dpg.add_item_clicked_handler(callback=self._on_header_right_clicked)
-
- self._router.register(
- self._on_key_pressed,
- priority=PRIORITY_PANEL,
- active=self._keys_active,
- )
-
- def _create_themes(self) -> None:
- self._create_subcolumn_themes()
- self._create_header_themes()
- self._row_number_theme = create_selectable_text_theme(self._layout.colors.text.row)
-
- def _create_subcolumn_themes(self) -> None:
- """Builds each subcolumn's text theme in its full and its dimmed colour.
-
- The dimmed variant keeps the subcolumn's own hue at reduced alpha, so a silenced
- channel's values stay readable and editable while the others are worked on.
- """
- subcolumn_colors = self._layout.colors.text
- theme_colors = {
- SubColumn.INSTRUMENT: subcolumn_colors.instrument,
- SubColumn.TRANSPOSE: subcolumn_colors.transpose,
- SubColumn.VOLUME: subcolumn_colors.volume,
- }
- fraction = self._layout.tracker.muted_text_fraction
- for subcolumn, color in theme_colors.items():
- self._subcolumn_themes[subcolumn] = create_selectable_text_theme(color)
- self._muted_subcolumn_themes[subcolumn] = create_selectable_text_theme(
- with_alpha_fraction(color, fraction),
- )
-
- def _create_header_themes(self) -> None:
- """Builds the two shades a channel's header label takes: audible and silenced.
-
- Both carry the header's own hover and press washes, so a label reads as the switch it is
- while its text colour reports whether the channel sounds.
- """
- header = self._layout.colors.header
- self._header_theme = create_header_selectable_theme(
- self._layout.colors.label,
- header.hovered,
- header.active,
- )
- self._muted_header_theme = create_header_selectable_theme(
- self._layout.colors.muted.text,
- header.hovered,
- header.active,
- )
-
- def _create_tracker_view(self, parent: str) -> None:
- """Builds the tracker card and the empty table its rows are filled into.
-
- The column labels are carried by a row of widgets (see :meth:`_build_header_row`) that
- ``freeze_rows`` pins at the top, which makes each channel's label a click target for
- muting. ``no_clip`` lets a label wider than its column draw across the boundary the way
- a table header does, so the header keeps the size and position it has always had.
-
- That header row is an ordinary table row, and DearPyGui advances the zebra-stripe
- counter on every ordinary row, so the tracker's own theme
- (``sequencer.theme.table_pattern``) carries ``TableRowBg`` and ``TableRowBgAlt``
- 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,
- ):
- dpg.add_group(tag=TAG_SEQUENCER_GRID_GROUP_TRACKER)
- with dpg.child_window(
- tag=TAG_SEQUENCER_GRID_WINDOW_TRACKER,
- parent=TAG_SEQUENCER_GRID_GROUP_TRACKER,
- border=False,
- width=0,
- height=-1,
- ):
- with dpg.table(
- tag=TAG_SEQUENCER_GRID_TABLE_TRACKER,
- width=0,
- header_row=False,
- resizable=False,
- borders_innerH=False,
- borders_innerV=True,
- borders_outerH=True,
- borders_outerV=True,
- scrollX=False,
- scrollY=True,
- freeze_rows=HEADER_TABLE_ROWS,
- row_background=True,
- policy=dpg.mvTable_SizingFixedFit,
- ):
- FontRegistry.bind_to_item(dpg.last_item(), Font.MONO_BOLD)
- dpg.add_table_column(width_stretch=True)
- dpg.add_table_column(
- width_fixed=True,
- init_width_or_weight=self._layout.table_cells.row,
- no_clip=True,
- )
- dpg.add_table_column(
- width_fixed=True,
- init_width_or_weight=self._layout.table_cells.sample,
- no_clip=True,
- )
- dpg.add_table_column(
- width_fixed=True,
- init_width_or_weight=self._layout.table_cells.divider,
- )
- for _ in GeneratorName.items():
- dpg.add_table_column(
- width_fixed=True,
- init_width_or_weight=self._layout.table_cells.generator,
- no_clip=True,
- )
- dpg.add_table_column(width_stretch=True)
-
- self.pattern_theme.bind_to_item(TAG_SEQUENCER_GRID_TABLE_TRACKER)
-
- def update_grid(self, view_model: SequencerGridViewModel) -> None:
- """Reconciles the tracker body with the visible order frame.
-
- The grid is only torn down and rebuilt when the row count changes; for the
- common in-place edit the changed cell labels are reconfigured one by one.
- Reusing the existing widgets preserves scroll position, the hover row, and
- the edit cursor that a full rebuild would otherwise discard.
- """
- cell_values = self._compute_cell_values(view_model)
- if len(view_model.rows) != self._current_row_count:
- self._rebuild_table(view_model, cell_values)
- else:
- self._editable_cells.reconcile(cell_values, self._render_cell)
-
- def _rebuild_table(
- self,
- view_model: SequencerGridViewModel,
- cell_values: CellValues,
- ) -> None:
- dpg_delete_children(TAG_SEQUENCER_GRID_TABLE_TRACKER, slot=1)
- self._editable_cells.reset(cell_values)
- self._build_table(view_model)
- self._highlight_sample_column()
- self._highlight_header_row()
- self._apply_channel_cues()
- self._update_cursor()
- self._apply_playing_row_highlight()
-
- def _render_cell(self, key: CellKey) -> str:
- row, generator, subcolumn = key
- return tracker_display.subcolumn_label(
- row,
- generator,
- subcolumn,
- cursor=self._input_state.cursor,
- pending=self._input_state.pending,
- cell_values=self._editable_cells.values,
- )
-
- def _highlight_sample_column(self) -> None:
- """Tints the sample column and the rule that separates it from the channels.
-
- These column highlights are static decoration, distinct from the cursor's
- cell/row highlight; reapplying them after each rebuild keeps them in place
- once the rows are replaced.
- """
- dpg.highlight_table_column(
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- SAMPLE_TABLE_COLUMN,
- self._layout.colors.sample.column,
- )
- dpg.highlight_table_column(
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- DIVIDER_TABLE_COLUMN,
- self._layout.colors.sample.divider,
- )
-
- def _highlight_header_row(self) -> None:
- """Gives the widget header row the background a table header carries.
-
- The shade is laid cell by cell so it covers the sample and channel column washes,
- which DearPyGui draws over a row highlight; the header then reads as one band with
- the column tints beginning below it.
- """
- for column in range(TRACKER_TABLE_COLUMNS):
- dpg.highlight_table_cell(
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- HEADER_TABLE_ROW,
- column,
- color=self._layout.colors.header.background,
- )
-
- def _tint_channel_columns(self) -> None:
- """Washes each channel's column with a faint tint of its identity colour.
-
- Reapplied after each rebuild alongside the sample column so the tint survives
- row replacement, giving the tracker the same per-channel identity the order
- table carries in its row labels. A silenced channel trades that identity for a
- neutral dark shade, so its column recedes as a whole.
- """
- for generator in GeneratorName.items():
- dpg.highlight_table_column(
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- tracker_table_column(generator),
- self._channel_column_tint(generator),
- )
-
- def _channel_column_tint(self, generator: GeneratorName) -> ColorRGBA:
- if self._is_muted(generator):
- return self._layout.colors.muted.background
-
- return with_alpha_fraction(
- channel_color(self._layout.colors.channels, generator),
- self._layout.tracker.channel_column_tint,
- )
-
- def _compute_cell_values(
- self,
- view_model: SequencerGridViewModel,
- ) -> CellValues:
- cell_values: CellValues = {}
- for row in view_model.rows:
- cell_values[(row.index, None, SubColumn.INSTRUMENT)] = row.sample_instrument
- cell_values[(row.index, None, SubColumn.TRANSPOSE)] = row.sample_transpose
- cell_values[(row.index, None, SubColumn.VOLUME)] = row.sample_volume
- for generator in GeneratorName.items():
- cell = row.cells[generator]
- for subcolumn in SubColumn:
- cell_values[
- (
- row.index,
- generator,
- subcolumn,
- )
- ] = tracker_display.cell_display(
- cell,
- subcolumn,
- )
-
- return cell_values
-
- def _build_table(self, view_model: SequencerGridViewModel) -> None:
- self._rows = {}
- self._current_row_count = len(view_model.rows)
- self._build_header_row()
- for row in view_model.rows:
- self._build_table_row(row)
-
- def _build_header_row(self) -> None:
- """Builds the header as the table's first row, with each channel label a click target.
-
- A rebuild replaces every row of the table, so the header is raised here, ahead of the
- pattern rows, and lands on the row ``freeze_rows`` pins in place. The cells are
- positional like a pattern row's, so the labels line up with the columns they name.
- """
- self._header_columns = {}
- row_id = dpg.add_table_row(parent=TAG_SEQUENCER_GRID_TABLE_TRACKER)
- self._add_empty_cell(row_id)
- self._add_header_label_cell(row_id)
- self._add_header_selectable(row_id, None)
- self._add_empty_cell(row_id)
- for generator in GeneratorName.items():
- self._add_header_selectable(row_id, generator)
-
- def _add_header_label_cell(self, row_id: Sender) -> None:
- """Places the row-number column's label, which names a column the user reads only."""
- label_cell = dpg.add_table_cell(parent=row_id)
- dpg.add_text(self._lbl_col_row, parent=label_cell)
-
- def _add_header_selectable(
- self,
- row_id: Sender,
- generator: Optional[GeneratorName],
- ) -> None:
- """Places one clickable column label: a channel's mute target, or the master target.
-
- The selectable takes its width from its label, which is what lets a label wider than
- its column draw in full, and it carries its channel so the click knows which column
- it landed on. A tooltip names the gestures the label answers to, and the right-click
- registry opens the same actions as a menu.
- """
- header_cell = dpg.add_table_cell(parent=row_id)
- selectable = dpg.add_selectable(
- parent=header_cell,
- label=self._column_labels[generator],
- user_data=generator,
- callback=self._on_header_clicked,
- )
- dpg.bind_item_handler_registry(selectable, self._header_handler_tag)
- show_tooltip(
- selectable,
- self._tooltip_header_sample if generator is None else self._tooltip_header_channel,
- )
- self._header_columns[selectable] = generator
-
- def _build_table_row(self, row: SequencerRowViewModel) -> None:
- """Builds one tracker row.
-
- The cells are positional, so the empty divider cell after the sample column
- keeps the channel cells aligned with their (shifted) table columns.
- """
- row_id = dpg.add_table_row(
- parent=TAG_SEQUENCER_GRID_TABLE_TRACKER,
- user_data=row.index,
- )
- self._add_empty_cell(row_id)
- self._add_row_number_cell(row_id, row.index)
- self._add_column_cell(row_id, row.index, None)
- self._add_empty_cell(row_id)
- for generator in GeneratorName.items():
- self._add_column_cell(row_id, row.index, generator)
-
- def _add_empty_cell(self, row_id: Sender) -> None:
- empty_cell = dpg.add_table_cell(parent=row_id)
- if dpg.does_item_exist(empty_cell):
- dpg.add_spacer(parent=empty_cell, width=0)
-
- def _add_row_number_cell(self, row_id: Sender, row_index: int) -> None:
- number_cell = dpg.add_table_cell(parent=row_id)
- selectable = dpg.add_selectable(
- parent=number_cell,
- label=display_id(row_index),
- user_data=row_index,
- callback=self._on_row_number_clicked,
- )
- FontRegistry.bind_to_item(selectable, Font.MONO_SMALL)
- dpg.bind_item_theme(selectable, self._row_number_theme)
- dpg.bind_item_handler_registry(selectable, self._item_handler_tag)
- self._rows[row_index] = selectable
-
- def _add_column_cell(
- self,
- row_id: Sender,
- row_index: int,
- generator: Optional[GeneratorName],
- ) -> None:
- font = Font.MONO_BOLD_SMALL if generator is None else Font.MONO_SMALL
- cell = dpg.add_table_cell(parent=row_id)
- group = dpg.add_group(
- horizontal=True,
- horizontal_spacing=0,
- parent=cell,
- )
- for subcolumn in SubColumn:
- self._add_subcolumn_selectable(
- group,
- row_index,
- generator,
- subcolumn,
- font,
- )
-
- def _add_subcolumn_selectable(
- self,
- group: Sender,
- row_index: int,
- generator: Optional[GeneratorName],
- subcolumn: SubColumn,
- font: Font,
- ) -> None:
- key = (row_index, generator, subcolumn)
- selectable = dpg.add_selectable(
- parent=group,
- label=self._render_cell(key),
- width=self._subcolumn_widths[subcolumn],
- user_data=key,
- callback=self._on_cell_clicked,
- )
- FontRegistry.bind_to_item(selectable, font)
- dpg.bind_item_theme(selectable, self._subcolumn_themes[subcolumn])
- dpg.bind_item_handler_registry(selectable, self._cell_handler_tag)
- self._editable_cells.register(key, selectable)
-
- def _update_cursor(self) -> None:
- cursor = self._input_state.cursor
- if cursor is not None:
- if cursor.row < self._current_row_count:
- self._apply_cell_highlight(cursor.row, cursor.generator)
- else:
- self._input_state = TrackerInputState()
-
- self._update_caret()
-
- def deselect_cell(self) -> None:
- cursor = self._input_state.cursor
- if cursor is not None:
- self._remove_cell_highlight(cursor.row, cursor.generator)
- self._input_state = TrackerInputState()
-
- self._update_caret()
-
- def _apply_state(self, new_state: TrackerInputState) -> None:
- old_cursor = self._input_state.cursor
- new_cursor = new_state.cursor
-
- old_pos = (old_cursor.row, old_cursor.generator) if old_cursor is not None else None
- new_pos = (new_cursor.row, new_cursor.generator) if new_cursor is not None else None
-
- if old_pos != new_pos and old_cursor is not None:
- self._remove_cell_highlight(old_cursor.row, old_cursor.generator)
-
- self._input_state = new_state
-
- if old_cursor is not None:
- self._update_cell_display(old_cursor.row, old_cursor.generator)
-
- if new_cursor is not None:
- if old_pos != new_pos:
- self._apply_cell_highlight(new_cursor.row, new_cursor.generator)
- self._update_cell_display(new_cursor.row, new_cursor.generator)
-
- if new_pos != old_pos and new_cursor is not None:
- if self.on_cell_selected is not None:
- self.on_cell_selected(new_cursor.row, new_cursor.generator)
-
- self._update_caret()
-
- def update_samples(self, view_model: SequencerSamplesViewModel) -> None:
- self._current_samples = view_model
-
- def update_channels(self, view_model: SequencerChannelsViewModel) -> None:
- """Shows which channels the song player silences.
-
- The model is kept so a rebuilt table takes the cue again, the way the column tints do,
- and so a table still waiting for its rows picks it up once they arrive.
- """
- self._current_channels = view_model
- self._apply_channel_cues()
-
- def _apply_channel_cues(self) -> None:
- """Marks each silenced channel down its whole column: label, background, and cell text.
-
- The three cues land together because they read as one: the column recedes as a unit
- while its values stay legible, so the channel is visibly out of the mix and still open
- for editing.
- """
- if not dpg.does_item_exist(TAG_SEQUENCER_GRID_TABLE_TRACKER):
- return
-
- self._tint_channel_columns()
- self._bind_header_themes()
- for generator in GeneratorName.items():
- self._bind_channel_cell_themes(generator)
-
- def _bind_header_themes(self) -> None:
- for selectable, generator in self._header_columns.items():
- muted = generator is not None and self._is_muted(generator)
- dpg.bind_item_theme(
- selectable,
- self._muted_header_theme if muted else self._header_theme,
- )
-
- def _bind_channel_cell_themes(self, generator: GeneratorName) -> None:
- themes = self._muted_subcolumn_themes if self._is_muted(generator) else self._subcolumn_themes
- for row_index in range(self._current_row_count):
- for subcolumn in SubColumn:
- cell_id = self._editable_cells.widget((row_index, generator, subcolumn))
- if cell_id is not None:
- dpg.bind_item_theme(cell_id, themes[subcolumn])
-
- def _is_muted(self, generator: GeneratorName) -> bool:
- return self._current_channels is not None and self._current_channels.is_muted(generator)
-
- def set_enabled(self, enabled: bool) -> None:
- dpg.configure_item(TAG_SEQUENCER_GRID_GROUP_TRACKER, enabled=enabled)
-
- def _update_cell_display(
- self,
- row: int,
- generator: Optional[GeneratorName],
- ) -> None:
- for subcolumn in SubColumn:
- key = (row, generator, subcolumn)
- cell_id = self._editable_cells.widget(key)
- if cell_id is not None:
- dpg.configure_item(cell_id, label=self._render_cell(key))
-
- def _update_caret(self) -> None:
- """Arms (or clears) the shared caret box on the active subcolumn cell."""
- cursor = self._input_state.cursor
- if cursor is None:
- CaretOverlay.clear(TAG_SEQUENCER_GRID_TABLE_TRACKER)
- return
-
- key = (cursor.row, cursor.generator, cursor.subcolumn)
- font = Font.MONO_BOLD_SMALL if cursor.generator is None else Font.MONO_SMALL
- CaretOverlay.set_target(
- owner=TAG_SEQUENCER_GRID_TABLE_TRACKER,
- widget=self._editable_cells.widget(key),
- caret_index=len(self._input_state.pending),
- font=font,
- clip_widget=TAG_SEQUENCER_GRID_WINDOW_TRACKER,
- )
-
- def _resolve_sample_id(
- self,
- sample_index: int,
- ) -> Optional[Tuple[int, str]]:
- if not self._current_samples or not self._current_samples.samples:
- return None
-
- samples = self._current_samples.samples
- sample_index = max(0, min(sample_index, len(samples) - 1))
- return sample_index, samples[sample_index].sample_id
-
- def _handle_edit_action(self, action: EditAction) -> None:
- """Commits a single-subcolumn edit.
-
- An :class:`EditAction` only ever carries the subcolumn under the cursor;
- the others are ``None`` meaning "leave unchanged". Forwarding those ``None``
- values lets the downstream partial update preserve the rest of the row.
- """
- row, generator = action.row, action.generator
-
- if action.note_off:
- self._editable_cells.values[(row, generator, SubColumn.INSTRUMENT)] = NOTE_OFF
- self.call(self.on_set_note_off, row, generator)
- return
-
- sample_id: Optional[str] = None
-
- if action.sample_index is not None:
- resolved = self._resolve_sample_id(action.sample_index)
- sample_index = resolved[0] if resolved is not None else None
- sample_id = resolved[1] if resolved is not None else None
- self._editable_cells.values[(row, generator, SubColumn.INSTRUMENT)] = tracker_display.format_committed(
- SubColumn.INSTRUMENT,
- sample_index,
- )
-
- if action.transpose is not None:
- self._editable_cells.values[(row, generator, SubColumn.TRANSPOSE)] = tracker_display.format_committed(
- SubColumn.TRANSPOSE,
- action.transpose,
- )
-
- if action.volume is not None:
- self._editable_cells.values[(row, generator, SubColumn.VOLUME)] = tracker_display.format_committed(
- SubColumn.VOLUME,
- action.volume,
- )
-
- self.call(
- self.on_set_row,
- row,
- generator,
- sample_id,
- action.transpose,
- action.volume,
- )
-
- def _handle_clear_action(self, action: ClearAction) -> None:
- if action.subcolumn is None:
- for subcolumn in SubColumn:
- self._editable_cells.values.pop(
- (action.row, action.generator, subcolumn),
- None,
- )
- self.call(self.on_clear_row, action.row, action.generator)
- else:
- self._editable_cells.values.pop(
- (action.row, action.generator, action.subcolumn),
- None,
- )
- self.call(
- self.on_clear_subcolumn,
- action.row,
- action.generator,
- action.subcolumn,
- )
-
- def _apply_cell_highlight(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- ) -> None:
- table_row = tracker_table_row(row_index)
- dpg.highlight_table_row(
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- table_row,
- color=self._layout.colors.cursor_row,
- )
- column_index = tracker_table_column(generator)
- dpg.highlight_table_cell(
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- table_row,
- column_index,
- color=self._layout.colors.cell_cursor,
- )
-
- def _remove_cell_highlight(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- ) -> None:
- table_row = tracker_table_row(row_index)
- dpg.unhighlight_table_row(
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- table_row,
- )
- col_idx = tracker_table_column(generator)
- dpg.unhighlight_table_cell(
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- table_row,
- col_idx,
- )
-
- def _on_cell_clicked(
- self,
- sender: Sender,
- app_data: bool,
- user_data: Tuple[int, Optional[GeneratorName], SubColumn],
- ) -> None:
- dpg.set_value(sender, False)
- self._committed_state()
- row_index, generator, subcolumn = user_data
- new_state = TrackerInputState(
- cursor=TrackerCursor(row_index, generator, subcolumn),
- pending="",
- )
- self._apply_state(new_state)
-
- def _on_header_clicked(
- self,
- sender: Sender,
- app_data: bool,
- user_data: Optional[GeneratorName],
- ) -> None:
- self._channel_switch.click(sender, user_data)
-
- def _on_header_right_clicked(
- self,
- sender: Sender,
- app_data: Tuple[int, int],
- ) -> None:
- """Opens the channel menu for the right-clicked column header.
-
- The registry reaches the header labels alone, so a click on one of them names its column
- through the map the header row filled in; a label replaced by a rebuild is absent from it.
- """
- mouse_button, clicked_item = app_data
- if mouse_button != dpg.mvMouseButton_Right:
- return
-
- if clicked_item not in self._header_columns:
- return
-
- self._show_header_context_menu(self._header_columns[clicked_item])
-
- 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])
- FontRegistry.bind_to_item(header, Font.MONO_BOLD)
- dpg.add_separator()
- self._channel_switch.add_menu_items(generator, self._current_channels)
-
- def _on_cell_right_clicked(
- self,
- sender: Sender,
- app_data: Tuple[int, int],
- ) -> None:
- """Opens the cell-operations menu for the right-clicked subcolumn.
-
- The menu targets the clicked cell directly and leaves the edit cursor where it is,
- so a right-click inspects a cell while the caret stays put.
- """
- mouse_button, clicked_item = app_data
- if mouse_button != dpg.mvMouseButton_Right:
- return
-
- key = dpg.get_item_user_data(clicked_item)
- if key is None:
- return
-
- row_index, generator, subcolumn = key
- self._show_context_menu(row_index, generator, subcolumn)
-
- def _show_context_menu(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- subcolumn: SubColumn,
- ) -> None:
- with context_menu():
- 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(
- self._lbl_context_play,
- lambda: self.call(self.on_play_from_row, row_index),
- shortcut=self._sc_play_from_here,
- )
- add_play_menu_item(
- self._lbl_context_play_from_frame,
- lambda: self.call(self.on_play_from_frame),
- shortcut=self._sc_play_from_frame,
- )
- dpg.add_separator()
- self._add_instrument_submenu(row_index, generator)
- dpg.add_menu_item(
- label=self._lbl_context_note_off,
- callback=lambda: self.call(self.on_set_note_off, row_index, generator),
- )
- dpg.add_separator()
- self._add_transpose_items(row_index, generator)
- dpg.add_separator()
- self._add_volume_items(row_index, generator)
- dpg.add_separator()
- self._add_clear_items(row_index, generator, subcolumn)
-
- def _add_instrument_submenu(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- ) -> None:
- with dpg.menu(label=self._lbl_context_set_instrument):
- samples = self._current_samples.samples if self._current_samples is not None else ()
- if not samples:
- dpg.add_menu_item(
- label=self._lbl_context_no_samples,
- enabled=False,
- )
- return
-
- for index, sample in enumerate(samples):
- dpg.add_menu_item(
- label=tracker_display.indexed_label(index, sample.name),
- user_data=(row_index, generator, sample.sample_id),
- callback=self._on_set_instrument_menu,
- )
-
- def _add_transpose_items(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- ) -> None:
- for label, delta in (
- (self._lbl_context_transpose_up, SEMITONE_STEP),
- (self._lbl_context_transpose_down, -SEMITONE_STEP),
- (self._lbl_context_transpose_octave_up, OCTAVE_SEMITONES),
- (self._lbl_context_transpose_octave_down, -OCTAVE_SEMITONES),
- ):
- dpg.add_menu_item(
- label=label,
- user_data=(row_index, generator, delta),
- callback=self._on_transpose_menu,
- )
-
- def _add_volume_items(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- ) -> None:
- for label, delta in (
- (self._lbl_context_volume_up, VOLUME_FINE_STEP),
- (self._lbl_context_volume_down, -VOLUME_FINE_STEP),
- (self._lbl_context_volume_up_coarse, VOLUME_COARSE_STEP),
- (self._lbl_context_volume_down_coarse, -VOLUME_COARSE_STEP),
- ):
- dpg.add_menu_item(
- label=label,
- user_data=(row_index, generator, delta),
- callback=self._on_volume_menu,
- )
-
- def _on_set_instrument_menu(
- self,
- sender: Sender,
- app_data: None,
- user_data: Tuple[int, Optional[GeneratorName], str],
- ) -> None:
- row_index, generator, sample_id = user_data
- self.call(self.on_set_row, row_index, generator, sample_id, None, None)
-
- def _on_transpose_menu(
- self,
- sender: Sender,
- app_data: None,
- user_data: Tuple[int, Optional[GeneratorName], int],
- ) -> None:
- row_index, generator, delta = user_data
- self.call(self.on_adjust_transpose, row_index, generator, delta)
-
- def _on_volume_menu(
- self,
- sender: Sender,
- app_data: None,
- user_data: Tuple[int, Optional[GeneratorName], int],
- ) -> None:
- row_index, generator, delta = user_data
- self.call(self.on_adjust_volume, row_index, generator, delta)
-
- def _add_clear_items(
- self,
- row_index: int,
- generator: Optional[GeneratorName],
- subcolumn: SubColumn,
- ) -> None:
- """Builds the three clear levels: the clicked subcolumn, the whole channel cell, the whole row.
-
- The cell and row levels coincide on the sample column, which already clears every channel,
- so the per-channel ``Clear cell`` item is offered only for an actual channel.
- """
- dpg.add_menu_item(
- label=self._lbl_context_clear_subcolumn,
- callback=lambda: self.call(
- self.on_clear_subcolumn,
- row_index,
- generator,
- subcolumn,
- ),
- )
- if generator is not None:
- dpg.add_menu_item(
- label=self._lbl_context_clear_cell,
- callback=lambda: self.call(
- self.on_clear_row,
- row_index,
- generator,
- ),
- )
- dpg.add_menu_item(
- label=self._lbl_context_clear_row,
- callback=lambda: self.call(self.on_clear_row, row_index, None),
- )
-
- def _keys_active(self) -> bool:
- """Whether the grid owns the next key: its cursor is set and no field holds the keyboard.
-
- A focused field keeps the keyboard, so the grid stands down while the user types into an
- input. A modal dialog claims keys at a higher priority in the router, so the grid carries no
- modal check of its own.
- """
- return self._input_state.cursor is not None and not self._router.is_field_focused
-
- def _on_key_pressed(self, event: KeyEvent) -> bool:
- """Applies a tracker key to the active cell, reporting whether the grid consumed it.
-
- A modifier-carrying press belongs to the application's global shortcuts, so the grid
- yields it to the lower-priority scopes and keeps the plain keys for tracker editing.
- Ctrl+Shift+Space is the exception: it plays the song from the cursor's row.
- """
- cursor = self._input_state.cursor
- if cursor is None:
- return False
-
- if event.modifiers == CTRL_SHIFT and event.key == dpg.mvKey_Spacebar:
- self.call(self.on_play_from_row, cursor.row)
- return True
-
- if Modifier.CTRL in event.modifiers:
- return False
-
- match event.key:
- case dpg.mvKey_Up:
- self._move_row(-1)
- case dpg.mvKey_Down:
- self._move_row(1)
- case dpg.mvKey_Left:
- self._move_subcolumn(-1)
- case dpg.mvKey_Right:
- self._move_subcolumn(1)
- case dpg.mvKey_Tab:
- self._move_column(-1 if Modifier.SHIFT in event.modifiers else 1)
- case dpg.mvKey_Home:
- self._jump_to_row(0)
- case dpg.mvKey_End:
- self._jump_to_row(self._current_row_count - 1)
- case _ if event.key == KEY_PAGE_UP:
- self._page(-self._layout.tracker.page_size)
- case _ if event.key == KEY_PAGE_DOWN:
- self._page(self._layout.tracker.page_size)
- case dpg.mvKey_Return:
- self._move_row(1)
- case dpg.mvKey_Delete:
- self._clear_row()
- self._move_row(1)
- case dpg.mvKey_Back:
- self._clear_row()
- self._move_row(-1)
- case dpg.mvKey_Escape:
- if not self._input_state.pending:
- return False
-
- self._apply_state(self._input_state.cancel())
- case _:
- return self._handle_printable_key(event.key)
-
- return True
-
- def _move_row(self, delta: int) -> None:
- self._apply_state(
- self._committed_state().navigate_row(
- delta,
- self._current_row_count,
- )
- )
-
- def _page(self, delta: int) -> None:
- """Moves the cursor a page of rows, then scrolls it back into view."""
- self._move_row(delta)
- self._scroll_cursor_into_view()
-
- def _jump_to_row(self, index: int) -> None:
- self._apply_state(
- self._committed_state().navigate_row(
- index,
- self._current_row_count,
- absolute=True,
- )
- )
- self._scroll_cursor_into_view()
-
- def _move_subcolumn(self, delta: int) -> None:
- self._apply_state(self._committed_state().navigate_subcolumn(delta))
-
- def _move_column(self, delta: int) -> None:
- self._apply_state(self._committed_state().navigate_column_by(delta))
-
- def _scroll_cursor_into_view(self) -> None:
- """Scrolls the tracker so the cursor's row stays on screen after a page or Home/End jump.
-
- The frame's rows all live in one scrolling table, so a jump wider than the visible band
- moves the cursor past it. The scroll is set from the cursor's position within the frame,
- which keeps the row it lands on in view.
- """
- cursor = self._input_state.cursor
- if cursor is None or self._current_row_count <= 1:
- return
-
- if not dpg.does_item_exist(TAG_SEQUENCER_GRID_TABLE_TRACKER):
- return
-
- scroll_max = dpg.get_y_scroll_max(TAG_SEQUENCER_GRID_TABLE_TRACKER)
- if scroll_max <= 0:
- return
-
- fraction = cursor.row / (self._current_row_count - 1)
- dpg.set_y_scroll(TAG_SEQUENCER_GRID_TABLE_TRACKER, fraction * scroll_max)
-
- def _clear_row(self) -> None:
- state, clear_action = self._input_state.clear()
- self._handle_clear_action(clear_action)
- self._apply_state(state)
-
- def _committed_state(self) -> TrackerInputState:
- state, edit_action = self._input_state.commit_partial()
- if edit_action is not None:
- self._handle_edit_action(edit_action)
-
- return state
-
- def _handle_printable_key(self, key: int) -> bool:
- char = HEX_KEYS.get(key) or SIGN_KEYS.get(key)
- if char is None:
- return False
-
- new_state, edit_action = self._input_state.type_char(char)
- if edit_action is not None:
- self._handle_edit_action(edit_action)
- new_state = new_state.navigate_row(1, self._current_row_count)
-
- self._apply_state(new_state)
- return True
-
- def _on_row_number_clicked(
- self,
- sender: Sender,
- app_data: bool,
- user_data: int,
- ) -> None:
- dpg.set_value(sender, False)
- existing = self._input_state.cursor
- generator = existing.generator if existing is not None else None
- subcolumn = existing.subcolumn if existing is not None else SubColumn.INSTRUMENT
- self._apply_state(
- TrackerInputState(
- cursor=TrackerCursor(
- user_data,
- generator,
- subcolumn,
- ),
- pending="",
- )
- )
-
- def _on_row_hovered(self, sender: Sender, app_data: int) -> None:
- if not dpg.does_item_exist(app_data):
- return
-
- row_index = dpg.get_item_user_data(app_data)
- if row_index is not None:
- self._highlighted_row = row_index
-
- def highlight_row(self, row_index: Optional[int] = None) -> None:
- self.unhighlight_row(self._highlighted_row)
- self._highlighted_row = row_index
- if row_index is None:
- return
-
- dpg.highlight_table_row(
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- tracker_table_row(row_index),
- color=self._layout.colors.pattern_highlight,
- )
-
- def unhighlight_row(self, row_index: Optional[int] = None) -> None:
- if row_index is None:
- return
-
- dpg.unhighlight_table_row(
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- tracker_table_row(row_index),
- )
- self._highlighted_row = None
-
- def set_playing_row(self, row_index: Optional[int]) -> None:
- if self._playing_row is not None and self._playing_row < self._live_row_count():
- dpg.unhighlight_table_row(
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- tracker_table_row(self._playing_row),
- )
-
- self._playing_row = row_index
- self._apply_playing_row_highlight()
-
- def _apply_playing_row_highlight(self) -> None:
- """Highlights the playing row when its index lies within the live table.
-
- Position updates arrive on the callback-queue worker thread, so the table may be shorter
- than ``_playing_row`` if the main thread shrank it (a rows-per-pattern change) in between;
- checking the live row count keeps a stale index from reaching DearPyGui.
- """
- if self._playing_row is not None and self._playing_row < self._live_row_count():
- dpg.highlight_table_row(
- TAG_SEQUENCER_GRID_TABLE_TRACKER,
- tracker_table_row(self._playing_row),
- color=self._layout.colors.playback_row,
- )
-
- def _live_row_count(self) -> int:
- """The table's current pattern-row count, read live from DearPyGui.
-
- The cached ``_current_row_count`` reflects the last build on this thread; a concurrent
- rebuild on another thread can leave it stale, so row-index-bounded DearPyGui calls read the
- actual children directly. The count covers the pattern rows that follow the header row,
- so it compares against a pattern row index.
- """
- if not dpg.does_item_exist(TAG_SEQUENCER_GRID_TABLE_TRACKER):
- return 0
-
- rows = dpg.get_item_children(TAG_SEQUENCER_GRID_TABLE_TRACKER, slot=1)
- return len(rows) - HEADER_TABLE_ROWS if rows else 0
diff --git a/src/sampletones_application/ui/panels/sequencer/grid/__init__.py b/src/sampletones_application/ui/panels/sequencer/grid/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/ui/panels/sequencer/grid/gestures.py b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py
new file mode 100644
index 00000000..19d5c84d
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py
@@ -0,0 +1,67 @@
+from typing import Callable, Generic, Optional, Protocol, TypeVar
+
+from sampletones_shared.utils.callbacks import CallbackMixin
+
+RegionT = TypeVar("RegionT")
+CellT = TypeVar("CellT")
+RegionT_co = TypeVar("RegionT_co", covariant=True)
+CellT_co = TypeVar("CellT_co", covariant=True)
+
+
+class BlockTarget(Protocol[RegionT_co, CellT_co]):
+ """What a block gesture acts on: the block it covers, and the cell a pasted block lands at.
+
+ A grid resolves one from whichever cell raised the gesture, so the pair travels together and
+ each gesture reads the half it acts on.
+ """
+
+ @property
+ def region(self) -> RegionT_co: ...
+
+ @property
+ def anchor(self) -> CellT_co: ...
+
+
+class BlockGrid(Protocol[RegionT, CellT]):
+ """What a grid states to the block gestures raised over it.
+
+ The hooks are the grid's own, so the coordinator keeps wiring them where it already does.
+ """
+
+ on_copy_block: Optional[Callable[[RegionT], None]]
+ on_cut_block: Optional[Callable[[RegionT], None]]
+ on_delete_block: Optional[Callable[[RegionT], None]]
+ on_paste_block: Optional[Callable[[CellT], None]]
+ can_paste_block: Optional[Callable[[], bool]]
+
+
+class BlockGestures(CallbackMixin, Generic[RegionT, CellT]):
+ """The four gestures a grid's blocks answer to: copy, cut, paste and delete.
+
+ Three doors raise the same four, and each names the target it acts on: a cell menu and the menu
+ bar's Edit menu name the target they were built for, and a key press names the cursor's.
+ Holding the four here is what has every door fire one implementation.
+ """
+
+ def __init__(self, *, grid: BlockGrid[RegionT, CellT]) -> None:
+ self._grid = grid
+
+ def can_paste(self) -> bool:
+ """Whether a block stands ready for a paste to write."""
+ return self.query(self._grid.can_paste_block, default=False)
+
+ def copy_at(self, target: BlockTarget[RegionT, CellT]) -> None:
+ """Takes what a target covers, leaving the grid as it stands."""
+ self.call(self._grid.on_copy_block, target.region)
+
+ def cut_at(self, target: BlockTarget[RegionT, CellT]) -> None:
+ """Takes what a target covers, and empties it."""
+ self.call(self._grid.on_cut_block, target.region)
+
+ def delete_at(self, target: BlockTarget[RegionT, CellT]) -> None:
+ """Empties what a target covers, the block in hand standing as it is."""
+ self.call(self._grid.on_delete_block, target.region)
+
+ def paste_at(self, target: BlockTarget[RegionT, CellT]) -> None:
+ """Writes the block in hand from a target's own cell, which is where it lands."""
+ self.call(self._grid.on_paste_block, target.anchor)
diff --git a/src/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py b/src/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/ui/panels/sequencer/grid/scroll/axis.py b/src/sampletones_application/ui/panels/sequencer/grid/scroll/axis.py
new file mode 100644
index 00000000..a50db262
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/grid/scroll/axis.py
@@ -0,0 +1,55 @@
+from typing import Protocol
+
+import dearpygui.dearpygui as dpg
+
+
+class ScrollAxis(Protocol):
+ """The axis one table scrolls along, and the pointer coordinate that runs past its edges."""
+
+ def pointer(self) -> float: ...
+
+ def scroll(self) -> float: ...
+
+ def scroll_max(self) -> float: ...
+
+ def set_scroll(self, offset: float) -> None: ...
+
+
+class VerticalScroll:
+ """A table whose rows run down the screen, so the pointer's height names the cell it stands on."""
+
+ def __init__(self, *, table: str) -> None:
+ self._table = table
+
+ def pointer(self) -> float:
+ _, top = dpg.get_mouse_pos(local=False)
+ return float(top)
+
+ def scroll(self) -> float:
+ return float(dpg.get_y_scroll(self._table))
+
+ def scroll_max(self) -> float:
+ return float(dpg.get_y_scroll_max(self._table))
+
+ def set_scroll(self, offset: float) -> None:
+ dpg.set_y_scroll(self._table, offset)
+
+
+class HorizontalScroll:
+ """A table whose columns run across the screen, so the pointer's width names the cell it stands on."""
+
+ def __init__(self, *, table: str) -> None:
+ self._table = table
+
+ def pointer(self) -> float:
+ left, _ = dpg.get_mouse_pos(local=False)
+ return float(left)
+
+ def scroll(self) -> float:
+ return float(dpg.get_x_scroll(self._table))
+
+ def scroll_max(self) -> float:
+ return float(dpg.get_x_scroll_max(self._table))
+
+ def set_scroll(self, offset: float) -> None:
+ dpg.set_x_scroll(self._table, offset)
diff --git a/src/sampletones_application/ui/panels/sequencer/grid/scroll/band.py b/src/sampletones_application/ui/panels/sequencer/grid/scroll/band.py
new file mode 100644
index 00000000..eaf7a34a
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/grid/scroll/band.py
@@ -0,0 +1,15 @@
+from dataclasses import dataclass
+
+
+@dataclass(frozen=True)
+class TravelBand:
+ """Where a grid's cells stand along the axis it scrolls, and how many of them it lays out.
+
+ ``first_edge`` is the leading edge of the first cell in the coordinates the viewport is drawn
+ in, which travels with the scroll: adding the scroll back to it gives the edge the band on
+ screen begins at.
+ """
+
+ first_edge: float
+ cell_extent: float
+ cell_count: int
diff --git a/src/sampletones_application/ui/panels/sequencer/grid/scroll/travel.py b/src/sampletones_application/ui/panels/sequencer/grid/scroll/travel.py
new file mode 100644
index 00000000..99536ae5
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/grid/scroll/travel.py
@@ -0,0 +1,97 @@
+from math import copysign
+from typing import Callable, Final, Optional
+
+from sampletones_application.ui.panels.sequencer.grid.scroll.axis import ScrollAxis
+from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand
+
+TRAVEL_FLOOR_CELLS_PER_SECOND: Final[float] = 6.0
+TRAVEL_CEILING_CELLS_PER_SECOND: Final[float] = 45.0
+TRAVEL_FULL_PACE_OVERSHOOT_CELLS: Final[float] = 5.0
+
+
+class DragTravel:
+ """Carries a grid's view along while a held pointer stands past the band drawn on screen.
+
+ A held pointer keeps reporting for as long as the button is down, wherever it has been carried
+ to, so the travel runs from that report and paces itself by the frame's own duration: the same
+ stretch of grid passes under the pointer however fast the frames arrive. Each step is added to
+ the offset last issued, because a table reports the scroll it was drawn with rather than the one
+ just set — reading it back would have the travel re-issue an offset it has already reached.
+ """
+
+ def __init__(
+ self,
+ *,
+ axis: ScrollAxis,
+ band: Callable[[], Optional[TravelBand]],
+ elapsed: Callable[[], float],
+ ) -> None:
+ self._axis = axis
+ self._band = band
+ self._elapsed = elapsed
+ self._offset: Optional[float] = None
+
+ def advance(self) -> None:
+ """Travels one frame's worth toward whatever the pointer stands past, up to the grid's end.
+
+ A pointer standing within the band leaves the grid where it is, and the drag then reaches
+ the cell it stands on the way it always has. A grid awaiting its first layout states no
+ band, and one that fits on screen has nowhere to travel to.
+ """
+ band = self._band()
+ if band is None:
+ self.rest()
+ return
+
+ scroll_max = self._axis.scroll_max()
+ if scroll_max <= 0.0:
+ self.rest()
+ return
+
+ drawn = self._axis.scroll()
+ overshoot = self._overshoot(band, drawn, scroll_max)
+ if overshoot == 0.0:
+ self.rest()
+ return
+
+ travel = self._pace(overshoot, band.cell_extent) * band.cell_extent * self._elapsed()
+ offset = self._offset if self._offset is not None else drawn
+ self._offset = min(max(offset + copysign(travel, overshoot), 0.0), scroll_max)
+ self._axis.set_scroll(self._offset)
+
+ def rest(self) -> None:
+ """Ends the travel, so the next one sets out from the offset the grid is drawn with."""
+ self._offset = None
+
+ def _overshoot(
+ self,
+ band: TravelBand,
+ drawn: float,
+ scroll_max: float,
+ ) -> float:
+ """How far past the band the pointer stands, reading negative before its near edge.
+
+ The band begins where the first cell's edge stands once the scroll carrying it is added
+ back, and it holds what the grid lays out less what it still has to scroll away.
+ """
+ near = band.first_edge + drawn
+ far = near + band.cell_count * band.cell_extent - scroll_max
+ pointer = self._axis.pointer()
+ if pointer < near:
+ return pointer - near
+
+ if pointer > far:
+ return pointer - far
+
+ return 0.0
+
+ @staticmethod
+ def _pace(overshoot: float, cell_extent: float) -> float:
+ """How many cells a second the travel runs at: a floor at the edge, rising to a ceiling.
+
+ The pace answers how far past the edge the pointer is carried, so a reader nudging the edge
+ creeps along and one reaching well past it covers the grid.
+ """
+ reach = min(abs(overshoot) / (cell_extent * TRAVEL_FULL_PACE_OVERSHOOT_CELLS), 1.0)
+ span = TRAVEL_CEILING_CELLS_PER_SECOND - TRAVEL_FLOOR_CELLS_PER_SECOND
+ return TRAVEL_FLOOR_CELLS_PER_SECOND + span * reach
diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py
new file mode 100644
index 00000000..56aeb838
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py
@@ -0,0 +1,89 @@
+from dataclasses import dataclass
+from typing import Dict, Final, Generic, Mapping, Tuple, TypeVar
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.context import context_label
+from sampletones_application.categories.elements.global_ import ContextElements
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures, BlockTarget
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+
+RegionT = TypeVar("RegionT")
+CellT = TypeVar("CellT")
+
+CLIPBOARD_ACTIONS: Final[Tuple[ContextElements, ...]] = (
+ ContextElements.COPY,
+ ContextElements.CUT,
+ ContextElements.PASTE,
+ ContextElements.DELETE,
+)
+
+
+@dataclass(frozen=True)
+class BlockShortcuts:
+ """The keys one grid answers the clipboard gestures with.
+
+ Delete stands apart from the three: ``Del`` empties a selection while one stands and clears the
+ cell under the cursor otherwise, so the grid resolves it from the selection and its item prints
+ no key.
+ """
+
+ copy: ShortcutId
+ cut: ShortcutId
+ paste: ShortcutId
+
+
+class ClipboardItems(Generic[RegionT, CellT]):
+ """The four items every grid's menus print: copy, cut, paste and delete.
+
+ The words come from the shared context vocabulary and the accelerators from the grid's own
+ three bindings, so a grid states only which keys it answers to and the items read the same in
+ either grid.
+ """
+
+ def __init__(
+ self,
+ *,
+ blocks: BlockGestures[RegionT, CellT],
+ shortcuts: ShortcutSource,
+ block_shortcuts: BlockShortcuts,
+ labels: Mapping[ContextElements, str],
+ ) -> None:
+ self._blocks = blocks
+ self._shortcuts = shortcuts
+ self._block_shortcuts = block_shortcuts
+ self._labels = labels
+
+ @staticmethod
+ def labels(language_manager: LanguageManager) -> Dict[ContextElements, str]:
+ """The words every clipboard item prints, read from the vocabulary each grid shares."""
+ return {element: context_label(language_manager, element) for element in CLIPBOARD_ACTIONS}
+
+ def add_items(self, target: BlockTarget[RegionT, CellT]) -> None:
+ """Builds the four items, acting on the block the actions were raised on.
+
+ Paste is offered once a block has been copied, and it anchors at the target's own cell, so
+ the cell menu lands a block where the pointer is while the keys land it under the cursor.
+ """
+ dpg.add_menu_item(
+ label=self._labels[ContextElements.COPY],
+ shortcut=self._shortcuts.display(self._block_shortcuts.copy),
+ callback=lambda: self._blocks.copy_at(target),
+ )
+ dpg.add_menu_item(
+ label=self._labels[ContextElements.CUT],
+ shortcut=self._shortcuts.display(self._block_shortcuts.cut),
+ callback=lambda: self._blocks.cut_at(target),
+ )
+ dpg.add_menu_item(
+ label=self._labels[ContextElements.PASTE],
+ shortcut=self._shortcuts.display(self._block_shortcuts.paste),
+ enabled=self._blocks.can_paste(),
+ callback=lambda: self._blocks.paste_at(target),
+ )
+ dpg.add_menu_item(
+ label=self._labels[ContextElements.DELETE],
+ callback=lambda: self._blocks.delete_at(target),
+ )
diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/edit.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/edit.py
new file mode 100644
index 00000000..19c429e1
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/edit.py
@@ -0,0 +1,126 @@
+from __future__ import annotations
+
+from typing import Any, Callable, Generic, Mapping, Optional, TypeVar
+
+from sampletones_application.categories.elements.global_ import ContextElements
+from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures, BlockTarget
+from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import BlockShortcuts, ClipboardItems
+from sampletones_application.ui.panels.sequencer.grid.surface.protocol import EditGrid
+from sampletones_application.ui.panels.sequencer.grid.surface.targets import CursorTargets, TargetFactory
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+
+CursorT = TypeVar("CursorT")
+RegionT = TypeVar("RegionT")
+CellT = TypeVar("CellT")
+TargetT = TypeVar("TargetT", bound=BlockTarget[Any, Any])
+
+
+class GridEditSurface(Generic[CursorT, RegionT, CellT, TargetT]):
+ """A sequencer grid as the menu bar's Edit menu and its own keys reach it.
+
+ The menu bar asks the surface for the actions of whichever grid holds the cursor, and the
+ surface asks that grid to build them for the target the cursor names. A key press acts on that
+ same target, so one place resolves what the cursor stands on and every door agrees on it.
+
+ Both grids reach the Edit menu through one implementation, so the menu states what the next key
+ press would.
+ """
+
+ def __init__(
+ self,
+ *,
+ grid: EditGrid[CursorT, RegionT, TargetT],
+ targets: CursorTargets[CursorT, RegionT, TargetT],
+ clipboard: ClipboardItems[RegionT, CellT],
+ blocks: BlockGestures[RegionT, CellT],
+ ) -> None:
+ self._grid = grid
+ self._targets = targets
+ self._clipboard = clipboard
+ self._blocks = blocks
+
+ @classmethod
+ def build(
+ cls,
+ *,
+ grid: EditGrid[CursorT, RegionT, TargetT],
+ blocks: BlockGestures[RegionT, CellT],
+ target: TargetFactory[CursorT, RegionT, TargetT],
+ shortcuts: ShortcutSource,
+ block_shortcuts: BlockShortcuts,
+ labels: Mapping[ContextElements, str],
+ ) -> GridEditSurface[CursorT, RegionT, CellT, TargetT]:
+ """Composes the surface a grid states itself through, from the parts that grid supplies.
+
+ A grid names its own target type, the three keys its clipboard items print and the gestures
+ its hooks answer; the collaborators built from those are the same in either grid.
+ """
+ return cls(
+ grid=grid,
+ targets=CursorTargets(
+ state=grid.input_state,
+ target=target,
+ ),
+ clipboard=ClipboardItems(
+ blocks=blocks,
+ shortcuts=shortcuts,
+ block_shortcuts=block_shortcuts,
+ labels=labels,
+ ),
+ blocks=blocks,
+ )
+
+ def owns_edit_actions(self) -> bool:
+ """Whether the Edit menu states this grid's actions, which it does while the grid owns keys.
+
+ The menu offers what the next press would reach, so one question decides both.
+ """
+ return self._grid.owns_keys()
+
+ def build_edit_actions(self) -> None:
+ """Builds the grid's whole action set for the cell the cursor stands on.
+
+ The menu bar asks while the grid owns the editing gestures, so the cursor names the target
+ the same way a pointer names it on the cell menu.
+ """
+ target = self.cursor_target()
+ if target is not None:
+ self._grid.add_action_items(target)
+
+ def target_at(self, cell: CursorT) -> TargetT:
+ """The cell a set of actions is raised on, paired with the block those actions act on."""
+ return self._targets.at(cell)
+
+ def cursor_target(self) -> Optional[TargetT]:
+ """The target the cursor names, which is what a key press and the Edit menu act on."""
+ return self._targets.at_cursor()
+
+ def add_block_items(self, target: BlockTarget[RegionT, CellT]) -> None:
+ """Builds the clipboard items, acting on the block the actions were raised on."""
+ self._clipboard.add_items(target)
+
+ def copy(self) -> None:
+ self._at_cursor(self._blocks.copy_at)
+
+ def cut(self) -> None:
+ self._at_cursor(self._blocks.cut_at)
+
+ def delete(self) -> None:
+ self._at_cursor(self._blocks.delete_at)
+
+ def paste(self) -> None:
+ self._at_cursor(self._blocks.paste_at)
+
+ def _at_cursor(
+ self,
+ gesture: Callable[[BlockTarget[RegionT, CellT]], None],
+ ) -> None:
+ """Raises a gesture on the cell the cursor stands on, the entry being typed landing first.
+
+ Committing ahead of the gesture is what lets a block carry the value the reader has just
+ finished typing.
+ """
+ self._grid.commit_entry()
+ target = self.cursor_target()
+ if target is not None:
+ gesture(target)
diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/protocol.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/protocol.py
new file mode 100644
index 00000000..a5e84734
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/protocol.py
@@ -0,0 +1,26 @@
+from typing import Protocol, TypeVar
+
+from sampletones_application.ui.panels.sequencer.input.state import GridInputState
+
+CursorT = TypeVar("CursorT")
+RegionT = TypeVar("RegionT")
+TargetT_contra = TypeVar("TargetT_contra", contravariant=True)
+
+
+class EditGrid(Protocol[CursorT, RegionT, TargetT_contra]):
+ """What a grid states to the edit surface built over it.
+
+ The state carries the cursor and the selection a target is resolved from, and the grid states
+ its own actions for a target the surface hands back. Whether the grid owns those gestures at
+ this moment is the question its key scope already answers, so one predicate serves the keyboard
+ and the menu alike.
+ """
+
+ def owns_keys(self) -> bool: ...
+
+ def input_state(self) -> GridInputState[CursorT, RegionT]: ...
+
+ def add_action_items(self, target: TargetT_contra) -> None: ...
+
+ def commit_entry(self) -> None:
+ """Writes the entry being typed into the cell the cursor stands on."""
diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/targets.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/targets.py
new file mode 100644
index 00000000..bd52e15f
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/targets.py
@@ -0,0 +1,54 @@
+from typing import Any, Callable, Generic, Optional, Protocol, TypeVar
+
+from sampletones_application.ui.panels.sequencer.grid.gestures import BlockTarget
+from sampletones_application.ui.panels.sequencer.input.state import GridInputState
+
+CursorT = TypeVar("CursorT")
+RegionT = TypeVar("RegionT")
+TargetT = TypeVar("TargetT", bound=BlockTarget[Any, Any])
+TargetT_co = TypeVar("TargetT_co", covariant=True)
+CursorT_contra = TypeVar("CursorT_contra", contravariant=True)
+RegionT_contra = TypeVar("RegionT_contra", contravariant=True)
+
+
+class TargetFactory(Protocol[CursorT_contra, RegionT_contra, TargetT_co]):
+ """How a grid's own target is built from the pair every target carries."""
+
+ def __call__(self, *, cell: CursorT_contra, region: RegionT_contra) -> TargetT_co: ...
+
+
+class CursorTargets(Generic[CursorT, RegionT, TargetT]):
+ """Which block a cell reaches, in the grid whose state names the selection.
+
+ Every door raises its actions on a target, and all three resolve one the same way: the cell is
+ paired with the block it falls inside. Reading the state afresh on each call is what keeps the
+ pair current, since a grid rebinds a frozen state on every edit.
+ """
+
+ def __init__(
+ self,
+ *,
+ state: Callable[[], GridInputState[CursorT, RegionT]],
+ target: TargetFactory[CursorT, RegionT, TargetT],
+ ) -> None:
+ self._state = state
+ self._target = target
+
+ def at(self, cell: CursorT) -> TargetT:
+ """The cell a set of actions is raised on, paired with the block those actions act on.
+
+ The block is the selection the cell falls inside, or the cell alone, so a menu raised
+ within a selection reaches the whole of it and one raised elsewhere reaches what it names.
+ """
+ return self._target(
+ cell=cell,
+ region=self._state().region_at(cell),
+ )
+
+ def at_cursor(self) -> Optional[TargetT]:
+ """The target the cursor names, which is what a key press and the Edit menu act on."""
+ cursor = self._state().cursor
+ if cursor is None:
+ return None
+
+ return self.at(cursor)
diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py
index 8d64525d..9a340473 100644
--- a/src/sampletones_application/ui/panels/sequencer/history.py
+++ b/src/sampletones_application/ui/panels/sequencer/history.py
@@ -4,7 +4,7 @@
import dearpygui.dearpygui as dpg
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.general.colors import FeatureColors
+from sampletones_application.layout.general.colors.feature import FeatureColors
from sampletones_application.layout.tabs.sequencer import SequencerLayout
from sampletones_application.tags.sequencer import (
TAG_SEQUENCER_HISTORY_BUTTON_REDO,
@@ -21,6 +21,8 @@
from sampletones_application.ui.elements.status import GUIStatusBar
from sampletones_application.ui.themes.registry import ThemeRegistry
from sampletones_application.utils.gui.dpg import dpg_configure_item
+from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
+from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_application.view_model.sequencer.history import (
HistoryEntryViewModel,
HistoryViewModel,
@@ -31,7 +33,6 @@
)
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import VoidCallback
-from sampletones_shared.utils.color import RGBA
EntryWindow = Tuple[HistoryEntryViewModel, ...]
@@ -114,28 +115,30 @@ def _create_window_list(self) -> None:
)
def _create_actions(self) -> None:
- with dpg.group(tag=TAG_SEQUENCER_HISTORY_GROUP_ACTIONS):
- with dpg.table(
+ with (
+ dpg.group(tag=TAG_SEQUENCER_HISTORY_GROUP_ACTIONS),
+ dpg.table(
header_row=False,
policy=dpg.mvTable_SizingStretchSame,
resizable=False,
width=-1,
- ):
- dpg.add_table_column()
- dpg.add_table_column()
- with dpg.table_row():
- GUIButton(
- tag=TAG_SEQUENCER_HISTORY_BUTTON_UNDO,
- label=self._language_manager["sequencer.history.label.undo"],
- callback=self._on_undo_clicked,
- width=-1,
- )
- GUIButton(
- tag=TAG_SEQUENCER_HISTORY_BUTTON_REDO,
- label=self._language_manager["sequencer.history.label.redo"],
- callback=self._on_redo_clicked,
- width=-1,
- )
+ ),
+ ):
+ dpg.add_table_column()
+ dpg.add_table_column()
+ with dpg.table_row():
+ GUIButton(
+ tag=TAG_SEQUENCER_HISTORY_BUTTON_UNDO,
+ label=self._language_manager["sequencer.history.label.undo"],
+ callback=self._on_undo_clicked,
+ width=-1,
+ )
+ GUIButton(
+ tag=TAG_SEQUENCER_HISTORY_BUTTON_REDO,
+ label=self._language_manager["sequencer.history.label.redo"],
+ callback=self._on_redo_clicked,
+ width=-1,
+ )
self._status_bar.bind_to_item(
TAG_SEQUENCER_HISTORY_BUTTON_UNDO,
self._language_manager["sequencer.history.message.status_undo"],
@@ -308,19 +311,20 @@ def _fill_entry_texts(self, group: int, entry: HistoryEntryViewModel) -> None:
color = self._layout.colors.history.future if entry.is_future else self._role_color(segment.role)
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,
- )
- )
+ def _add_text(
+ self,
+ value: str,
+ *,
+ parent: int,
+ color: Optional[BaseColor],
+ ) -> None:
+ text = dpg.add_text(value, parent=parent)
+ if color is not None:
+ dpg_set_palette_color(text, color)
+
FontRegistry.bind_to_item(text, Font.MONO_SMALL)
- def _role_color(self, role: HistoryDetailRole) -> RGBA:
+ def _role_color(self, role: HistoryDetailRole) -> BaseColor:
colors = self._layout.colors
roles = colors.history.roles
text = colors.text
@@ -355,16 +359,16 @@ def _role_color(self, role: HistoryDetailRole) -> RGBA:
def set_enabled(self, enabled: bool) -> None:
dpg_configure_item(TAG_SEQUENCER_HISTORY_GROUP_ACTIONS, enabled=enabled)
- def _on_undo_clicked(self, sender: Sender, app_data: Any) -> None:
+ def _on_undo_clicked(self, _sender: Sender, _app_data: Any) -> None:
self.call(self.on_undo)
- def _on_redo_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,
+ _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/input/cursor.py b/src/sampletones_application/ui/panels/sequencer/input/cursor.py
deleted file mode 100644
index 926e7786..00000000
--- a/src/sampletones_application/ui/panels/sequencer/input/cursor.py
+++ /dev/null
@@ -1,12 +0,0 @@
-from dataclasses import dataclass
-from typing import Optional
-
-from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.enums import GeneratorName
-
-
-@dataclass(frozen=True)
-class TrackerCursor:
- row: int
- generator: Optional[GeneratorName]
- subcolumn: SubColumn
diff --git a/src/sampletones_application/ui/panels/sequencer/input/edit.py b/src/sampletones_application/ui/panels/sequencer/input/edit.py
index 8a2b03d5..3a8a2704 100644
--- a/src/sampletones_application/ui/panels/sequencer/input/edit.py
+++ b/src/sampletones_application/ui/panels/sequencer/input/edit.py
@@ -21,4 +21,3 @@ class ClearAction:
row: int
generator: Optional[GeneratorName]
subcolumn: Optional[SubColumn] = None
- """The subcolumn to clear, or ``None`` to clear the whole row."""
diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py
new file mode 100644
index 00000000..43333b5b
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/input/order.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Final, Optional, Tuple
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.ui.panels.sequencer.input.state import GridInputState
+from sampletones_application.view_model.sequencer.region import OrderRegion
+from sampletones_core.constants.enums import GeneratorName
+
+INDEX_DIGITS: Final[int] = 2
+
+
+@dataclass(frozen=True)
+class OrderCursor:
+ generator: Optional[GeneratorName]
+ position: int
+
+
+def _parse(pending: str) -> Optional[int]:
+ try:
+ return int(pending, 16)
+ except ValueError:
+ return None
+
+
+@dataclass(frozen=True)
+class OrderInputState(GridInputState[OrderCursor, OrderRegion]):
+ """Edit cursor, pending hex entry and selection anchor for the order table.
+
+ The order has no subcolumns, so a cell holds a single pattern index; typing
+ accumulates :data:`INDEX_DIGITS` hex digits and then commits the parsed index.
+ Navigation moves along positions (columns) or channels/master (rows).
+ """
+
+ def _region_between(
+ self,
+ first: OrderCursor,
+ second: OrderCursor,
+ ) -> OrderRegion:
+ first_row = CHANNEL_AXIS.index(first.generator)
+ second_row = CHANNEL_AXIS.index(second.generator)
+ return OrderRegion(
+ first_row=min(first_row, second_row),
+ last_row=max(first_row, second_row),
+ first_position=min(first.position, second.position),
+ last_position=max(first.position, second.position),
+ )
+
+ def _covers(self, region: OrderRegion, cell: OrderCursor) -> bool:
+ return region.covers(cell.generator, cell.position)
+
+ def select_all(self, position_count: int) -> OrderInputState:
+ """Selects the whole order: every channel row, across every position it holds."""
+ return self._select_rows(CHANNEL_AXIS[0], CHANNEL_AXIS[-1], position_count)
+
+ def select_row(
+ self,
+ cell: OrderCursor,
+ position_count: int,
+ ) -> OrderInputState:
+ """Selects the row ``cell`` stands in: that channel, across every position.
+
+ The master row is an ordinary member of the axis here, so selecting it selects a row the
+ way selecting a channel does.
+ """
+ return self._select_rows(cell.generator, cell.generator, position_count)
+
+ def _select_rows(
+ self,
+ first_generator: Optional[GeneratorName],
+ last_generator: Optional[GeneratorName],
+ position_count: int,
+ ) -> OrderInputState:
+ """Selects a run of rows across the whole order, the cursor landing on its far corner."""
+ if position_count == 0:
+ return self
+
+ return self.select_between(
+ OrderCursor(first_generator, 0),
+ OrderCursor(last_generator, position_count - 1),
+ )
+
+ def extend_position(
+ self,
+ value: int,
+ position_count: int,
+ absolute: bool = False,
+ ) -> OrderInputState:
+ """Carries the selection's moving end to another position of the same row."""
+ if self.cursor is None or position_count == 0:
+ return self
+
+ new_position = value if absolute else self.cursor.position + value
+ new_position = max(0, min(new_position, position_count - 1))
+ return self.extend_to(OrderCursor(self.cursor.generator, new_position))
+
+ def extend_channel(self, value: int) -> OrderInputState:
+ """Carries the selection's moving end across the channel axis, stopping at either end.
+
+ A selection covers a run of the table, so the walk stops at the master row and at the last
+ channel rather than wrapping around the way plain navigation does.
+ """
+ if self.cursor is None:
+ return self
+
+ current = CHANNEL_AXIS.index(self.cursor.generator)
+ row = max(0, min(current + value, len(CHANNEL_AXIS) - 1))
+ return self.extend_to(OrderCursor(CHANNEL_AXIS[row], self.cursor.position))
+
+ def navigate_position(
+ self,
+ value: int,
+ position_count: int,
+ absolute: bool = False,
+ ) -> OrderInputState:
+ if self.cursor is None or position_count == 0:
+ return self
+
+ new_position = value if absolute else self.cursor.position + value
+ new_position = max(0, min(new_position, position_count - 1))
+ return OrderInputState(
+ cursor=OrderCursor(self.cursor.generator, new_position),
+ pending="",
+ )
+
+ def navigate_channel(self, value: int) -> OrderInputState:
+ if self.cursor is None:
+ return self
+
+ current = CHANNEL_AXIS.index(self.cursor.generator)
+ new_generator = CHANNEL_AXIS[(current + value) % len(CHANNEL_AXIS)]
+ return OrderInputState(
+ cursor=OrderCursor(new_generator, self.cursor.position),
+ pending="",
+ )
+
+ def type_char(self, char: str) -> Tuple[OrderInputState, Optional[int]]:
+ if self.cursor is None:
+ return self, None
+
+ pending = self.pending + char
+ if len(pending) < INDEX_DIGITS:
+ return OrderInputState(cursor=self.cursor, pending=pending), None
+
+ return self._after_entry(), _parse(pending)
+
+ def commit_partial(self) -> Tuple[OrderInputState, Optional[int]]:
+ if not self.pending or self.cursor is None:
+ return self, None
+
+ return self.reset_pending(), _parse(self.pending.zfill(INDEX_DIGITS))
diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py
index 850c986b..fa27781d 100644
--- a/src/sampletones_application/ui/panels/sequencer/input/state.py
+++ b/src/sampletones_application/ui/panels/sequencer/input/state.py
@@ -1,225 +1,101 @@
-from __future__ import annotations
-
-from typing import Dict, Final, Optional, Tuple
-
-from pydantic.dataclasses import dataclass
-
-from sampletones_application.ui.panels.sequencer.columns import (
- COLUMNS,
- SUBCOLUMNS,
- flat_index,
- from_flat,
-)
-from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor
-from sampletones_application.ui.panels.sequencer.input.edit import (
- ClearAction,
- EditAction,
-)
-from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-from sampletones_core.constants.general import MAX_VOLUME
-from sampletones_shared.constants.symbols import MINUS, PLUS, PLUS_MINUS, SIGNS
-
-DIGIT_COUNT: Final[Dict[SubColumn, int]] = {
- SubColumn.INSTRUMENT: 2,
- SubColumn.TRANSPOSE: 2,
- SubColumn.VOLUME: 1,
-}
-
-
-def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]:
- try:
- match cursor.subcolumn:
- case SubColumn.INSTRUMENT:
- return EditAction(
- row=cursor.row,
- generator=cursor.generator,
- sample_index=int(pending, 16),
- transpose=None,
- volume=None,
- )
- case SubColumn.VOLUME:
- return EditAction(
- row=cursor.row,
- generator=cursor.generator,
- sample_index=None,
- transpose=None,
- volume=min(int(pending, 16), MAX_VOLUME),
- )
- case SubColumn.TRANSPOSE:
- sign = -1 if pending.startswith(MINUS) else 1
- magnitude = pending.lstrip(PLUS_MINUS)
- if not magnitude:
- return None
-
- return EditAction(
- row=cursor.row,
- generator=cursor.generator,
- sample_index=None,
- transpose=sign * int(magnitude, 16),
- volume=None,
- )
- except ValueError:
- return None
-
-
-@dataclass
-class TrackerInputState:
- cursor: Optional[TrackerCursor] = None
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+from typing import Generic, Optional, Self, TypeVar
+
+CursorT = TypeVar("CursorT")
+RegionT = TypeVar("RegionT")
+
+
+@dataclass(frozen=True)
+class GridInputState(ABC, Generic[CursorT, RegionT]):
+ """Edit cursor, pending entry and selection anchor of a sequencer grid.
+
+ The anchor is where a range selection was started; the cursor is its other end, so the two
+ together are the region a block operation acts on. Every plain move builds a state without
+ one, which is what makes a move collapse a selection to the cell it lands in.
+
+ A grid states how a pair of its own cells bounds a block and how a block reaches a cell; the
+ selection rules that follow from those two are stated here and serve every grid.
+ """
+
+ cursor: Optional[CursorT] = None
pending: str = ""
+ anchor: Optional[CursorT] = None
- def reset_pending(self) -> TrackerInputState:
- return TrackerInputState(cursor=self.cursor, pending="")
-
- def navigate_row(
- self,
- value: int,
- row_count: int,
- absolute: bool = False,
- ) -> TrackerInputState:
- if self.cursor is None or row_count == 0:
- return self
-
- new_row = value if absolute else self.cursor.row + value
- new_row = max(0, min(new_row, row_count - 1))
- return TrackerInputState(
- cursor=TrackerCursor(
- new_row,
- self.cursor.generator,
- self.cursor.subcolumn,
- ),
- pending="",
- )
+ @abstractmethod
+ def _region_between(self, first: CursorT, second: CursorT) -> RegionT:
+ """The block a pair of cells bounds, whichever way round the pair stands."""
- def navigate_subcolumn(
- self,
- value: int,
- absolute: bool = False,
- ) -> TrackerInputState:
- if self.cursor is None:
- return self
-
- if absolute:
- new_sub = SUBCOLUMNS[value % len(SUBCOLUMNS)]
- return TrackerInputState(
- cursor=TrackerCursor(
- self.cursor.row,
- self.cursor.generator,
- new_sub,
- ),
- pending="",
- )
-
- current = flat_index(self.cursor.generator, self.cursor.subcolumn)
- return TrackerInputState(
- cursor=from_flat(self.cursor.row, current + value),
- pending="",
- )
+ @abstractmethod
+ def _covers(self, region: RegionT, cell: CursorT) -> bool:
+ """Whether ``region`` reaches ``cell``."""
- def navigate_column_by(self, delta: int) -> TrackerInputState:
- if self.cursor is None:
- return self
+ def reset_pending(self) -> Self:
+ """Drops a partial entry, leaving the cursor and any selection where they stand.
- current_idx = COLUMNS.index(self.cursor.generator)
- next_idx = (current_idx + delta) % len(COLUMNS)
- return TrackerInputState(
- cursor=TrackerCursor(self.cursor.row, COLUMNS[next_idx], self.cursor.subcolumn),
- pending="",
- )
+ The anchor survives because this runs before every move, the extending ones included:
+ each gesture then decides whether to hold the selection or collapse it.
+ """
+ return type(self)(cursor=self.cursor, pending="", anchor=self.anchor)
- def type_char(
- self,
- char: str,
- ) -> Tuple[TrackerInputState, Optional[EditAction]]:
- if self.cursor is None:
- return self, None
-
- if self.cursor.subcolumn is SubColumn.INSTRUMENT and char == MINUS:
- return self.reset_pending(), self._note_off_action(self.cursor)
-
- if self.cursor.subcolumn is SubColumn.TRANSPOSE:
- return self._type_transpose_char(char)
-
- if char in SIGNS:
- return self, None
-
- pending = self.pending + char
- expected = DIGIT_COUNT[self.cursor.subcolumn]
- if len(pending) < expected:
- return TrackerInputState(cursor=self.cursor, pending=pending), None
-
- action = _parse(self.cursor, pending)
- return self.reset_pending(), action
-
- def _note_off_action(self, cursor: TrackerCursor) -> EditAction:
- return EditAction(
- row=cursor.row,
- generator=cursor.generator,
- sample_index=None,
- transpose=None,
- volume=None,
- note_off=True,
- )
+ def collapse(self) -> Self:
+ """Drops the selection, leaving the cursor's own cell as the whole target."""
+ return type(self)(cursor=self.cursor, pending=self.pending)
- def _type_transpose_char(
- self,
- char: str,
- ) -> Tuple[TrackerInputState, Optional[EditAction]]:
- """Drives the signed transpose field: ``[±][H][H]``.
+ @property
+ def region(self) -> Optional[RegionT]:
+ """The block a selection covers, once one has been started."""
+ if self.cursor is None or self.anchor is None:
+ return None
- The first slot is reserved for the sign. A leading sign sets it; a leading
- digit implies ``+``. A sign key pressed later flips the sign in place,
- keeping any digits already entered. The field commits once both magnitude
- digits are in.
+ return self._region_between(self.anchor, self.cursor)
+
+ def region_at(self, cell: CursorT) -> RegionT:
+ """The block a gesture raised on ``cell`` acts on: the selection it stands in, or the cell
+ alone.
+
+ A gesture raised inside a selection acts on the whole of it, which is what a reader who has
+ just dragged a range out expects it to reach; one raised anywhere else acts on the cell it
+ names, which is a block of exactly that cell. A cursor with nothing selected therefore
+ stands on a block of one cell, so copying reaches the cell the reader is working in and
+ needs no selection made first.
"""
- if self.cursor is None:
- return self, None
-
- is_sign = char in SIGNS
- if not self.pending:
- pending = char if is_sign else f"{PLUS}{char}"
- elif is_sign:
- pending = char + self.pending[1:]
- return (
- TrackerInputState(cursor=self.cursor, pending=pending),
- None,
- )
- else:
- pending = self.pending + char
-
- digits = len(pending) - 1
- if digits < DIGIT_COUNT[SubColumn.TRANSPOSE]:
- return TrackerInputState(cursor=self.cursor, pending=pending), None
-
- action = _parse(self.cursor, pending)
- return self.reset_pending(), action
-
- def commit_partial(self) -> Tuple[TrackerInputState, Optional[EditAction]]:
- if not self.pending or self.cursor is None:
- return self, None
-
- if self.cursor.subcolumn is SubColumn.TRANSPOSE:
- action = _parse(self.cursor, self.pending)
- return self.reset_pending(), action
-
- expected = DIGIT_COUNT[self.cursor.subcolumn]
- padded = self.pending.zfill(expected)
- action = _parse(self.cursor, padded)
- return self.reset_pending(), action
-
- def clear(self) -> Tuple[TrackerInputState, ClearAction]:
- action = ClearAction(
- row=self.cursor.row if self.cursor else 0,
- generator=self.cursor.generator if self.cursor else None,
- )
- return self.reset_pending(), action
+ region = self.region
+ if region is not None and self._covers(region, cell):
+ return region
+
+ return self._region_between(cell, cell)
- def clear_subcolumn(self) -> Tuple[TrackerInputState, ClearAction]:
- action = ClearAction(
- row=self.cursor.row if self.cursor else 0,
- generator=self.cursor.generator if self.cursor else None,
- subcolumn=self.cursor.subcolumn if self.cursor else None,
+ def extend_to(self, cursor: CursorT) -> Self:
+ """Carries the moving end of the selection to ``cursor``, anchoring it where it began.
+
+ A selection that has not been started yet takes the cell the cursor stands on as its
+ anchor, so the first extending gesture selects the cell it came from as well as the one
+ it reaches.
+ """
+ return type(self)(
+ cursor=cursor,
+ pending="",
+ anchor=self.anchor if self.anchor is not None else self.cursor,
)
- return self.reset_pending(), action
- def cancel(self) -> TrackerInputState:
- return self.reset_pending()
+ def select_between(self, anchor: CursorT, cursor: CursorT) -> Self:
+ """Stands a selection between two cells, the cursor landing on the second.
+
+ A select gesture names a shape by the two corners bounding it, and leaves the cursor on the
+ far one: the next extending press then grows or shrinks the selection from the edge the
+ reader has just reached.
+ """
+ return type(self)(cursor=cursor, pending="", anchor=anchor)
+
+ def cancel(self) -> Self:
+ """Drops a partial entry and any selection, which is what Escape asks of a grid."""
+ return self.collapse().reset_pending()
+
+ def _after_entry(self) -> Self:
+ """The state a committed entry leaves: the cursor alone, nothing pending and nothing selected.
+
+ Typing writes the one cell the cursor stands on, so it takes the selection down to that
+ cell instead of leaving a range for the next gesture to act on.
+ """
+ return self.collapse().reset_pending()
diff --git a/src/sampletones_application/ui/panels/sequencer/input/target.py b/src/sampletones_application/ui/panels/sequencer/input/target.py
new file mode 100644
index 00000000..20570b4c
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/input/target.py
@@ -0,0 +1,57 @@
+from dataclasses import dataclass
+
+from sampletones_application.ui.panels.sequencer.input.order import OrderCursor
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor
+from sampletones_application.view_model.sequencer.region import (
+ OrderCell,
+ OrderRegion,
+ TrackerCell,
+ TrackerRegion,
+)
+
+
+@dataclass(frozen=True)
+class TrackerTarget:
+ """The tracker cell a set of actions was raised on, and the block those actions act on.
+
+ Both are needed at once: the block decides what the clipboard actions cover, while the cell
+ decides where a pasted block lands and which row and channel the cell-level actions reach.
+ A target keeps the pair together, so a builder handed one prints a whole action set and a key
+ press handed one reaches the same block the menus would.
+ """
+
+ cell: TrackerCursor
+ region: TrackerRegion
+
+ @property
+ def anchor(self) -> TrackerCell:
+ """The cell a pasted block is written from, which is the target's own row and column.
+
+ A block carries the subcolumn offsets it was read at, so the anchor names a row and a
+ column and leaves the rest to the block.
+ """
+ return TrackerCell(
+ row=self.cell.row,
+ generator=self.cell.generator,
+ )
+
+
+@dataclass(frozen=True)
+class OrderTarget:
+ """The order cell a set of actions was raised on, and the block those actions act on.
+
+ Both are needed at once: the block decides what the clipboard actions cover, while the cell
+ decides where a pasted block lands and which frame the frame actions reach.
+ """
+
+ cell: OrderCursor
+ region: OrderRegion
+
+ @property
+ def anchor(self) -> OrderCell:
+ """The cell a pasted block is written from, which is the target's own channel row and
+ position."""
+ return OrderCell(
+ generator=self.cell.generator,
+ position=self.cell.position,
+ )
diff --git a/src/sampletones_application/ui/panels/sequencer/input/tracker.py b/src/sampletones_application/ui/panels/sequencer/input/tracker.py
new file mode 100644
index 00000000..665e0c51
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/input/tracker.py
@@ -0,0 +1,353 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Dict, Final, Optional, Tuple
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.ui.panels.sequencer.input.edit import (
+ ClearAction,
+ EditAction,
+)
+from sampletones_application.ui.panels.sequencer.input.state import GridInputState
+from sampletones_application.view_model.sequencer.region import TrackerRegion
+from sampletones_application.view_model.sequencer.slot import (
+ SLOT_COUNT,
+ SUBCOLUMNS,
+ TrackerSlot,
+ column_slot_base,
+ slot_from_flat,
+)
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_shared.constants.symbols import MINUS, PLUS, PLUS_MINUS, SIGNS
+
+DIGIT_COUNT: Final[Dict[SubColumn, int]] = {
+ SubColumn.INSTRUMENT: 2,
+ SubColumn.TRANSPOSE: 2,
+ SubColumn.VOLUME: 1,
+}
+
+
+@dataclass(frozen=True)
+class TrackerCursor:
+ row: int
+ generator: Optional[GeneratorName]
+ subcolumn: SubColumn
+
+
+def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]:
+ try:
+ match cursor.subcolumn:
+ case SubColumn.INSTRUMENT:
+ return EditAction(
+ row=cursor.row,
+ generator=cursor.generator,
+ sample_index=int(pending, 16),
+ transpose=None,
+ volume=None,
+ )
+ case SubColumn.VOLUME:
+ return EditAction(
+ row=cursor.row,
+ generator=cursor.generator,
+ sample_index=None,
+ transpose=None,
+ volume=min(int(pending, 16), MAX_VOLUME),
+ )
+ case SubColumn.TRANSPOSE:
+ sign = -1 if pending.startswith(MINUS) else 1
+ magnitude = pending.lstrip(PLUS_MINUS)
+ if not magnitude:
+ return None
+
+ return EditAction(
+ row=cursor.row,
+ generator=cursor.generator,
+ sample_index=None,
+ transpose=sign * int(magnitude, 16),
+ volume=None,
+ )
+ except ValueError:
+ return None
+
+
+@dataclass(frozen=True)
+class TrackerInputState(GridInputState[TrackerCursor, TrackerRegion]):
+ """Edit cursor, pending entry and selection anchor for the tracker grid.
+
+ A cell of the grid is a row crossed with a slot — a channel and one of its subcolumns —
+ so a selection reaches across the sample column and the channels alike, and typing drives
+ the subcolumn the cursor stands on.
+ """
+
+ def _region_between(
+ self,
+ first: TrackerCursor,
+ second: TrackerCursor,
+ ) -> TrackerRegion:
+ first_slot = TrackerSlot(first.generator, first.subcolumn).flat_index
+ second_slot = TrackerSlot(second.generator, second.subcolumn).flat_index
+ return TrackerRegion(
+ first_row=min(first.row, second.row),
+ last_row=max(first.row, second.row),
+ first_slot=min(first_slot, second_slot),
+ last_slot=max(first_slot, second_slot),
+ )
+
+ def _covers(self, region: TrackerRegion, cell: TrackerCursor) -> bool:
+ return region.covers(cell.row, TrackerSlot(cell.generator, cell.subcolumn))
+
+ def select_all(self, row_count: int) -> TrackerInputState:
+ """Selects the whole frame: every row of it, across every slot the axis lays out."""
+ return self._select_slots(0, SLOT_COUNT - 1, row_count)
+
+ def select_column(
+ self,
+ cell: TrackerCursor,
+ row_count: int,
+ ) -> TrackerInputState:
+ """Selects the column ``cell`` stands in: every row of it, across that column's subcolumns.
+
+ The sample column is an ordinary member of the axis here, so selecting it selects a column
+ the way selecting a channel does.
+ """
+ base = column_slot_base(cell.generator)
+ return self._select_slots(base, base + len(SUBCOLUMNS) - 1, row_count)
+
+ def select_subcolumn(
+ self,
+ cell: TrackerCursor,
+ row_count: int,
+ ) -> TrackerInputState:
+ """Selects the subcolumn ``cell`` stands in: every row of it, at that one slot."""
+ slot = TrackerSlot(cell.generator, cell.subcolumn).flat_index
+ return self._select_slots(slot, slot, row_count)
+
+ def _select_slots(
+ self,
+ first_slot: int,
+ last_slot: int,
+ row_count: int,
+ ) -> TrackerInputState:
+ """Selects a run of slots down the whole frame, the cursor landing on its far corner."""
+ if row_count == 0:
+ return self
+
+ first = slot_from_flat(first_slot)
+ last = slot_from_flat(last_slot)
+ return self.select_between(
+ TrackerCursor(0, first.generator, first.subcolumn),
+ TrackerCursor(row_count - 1, last.generator, last.subcolumn),
+ )
+
+ def extend_row(
+ self,
+ value: int,
+ row_count: int,
+ absolute: bool = False,
+ ) -> TrackerInputState:
+ """Carries the selection's moving end to another row of the same slot."""
+ if self.cursor is None or row_count == 0:
+ return self
+
+ new_row = value if absolute else self.cursor.row + value
+ new_row = max(0, min(new_row, row_count - 1))
+ return self.extend_to(
+ TrackerCursor(
+ new_row,
+ self.cursor.generator,
+ self.cursor.subcolumn,
+ )
+ )
+
+ def extend_slot(self, value: int) -> TrackerInputState:
+ """Carries the selection's moving end along the flat slot axis, stopping at either end.
+
+ A selection covers a run of the grid, so the walk stops at the first and the last slot
+ rather than wrapping around the way plain navigation does.
+ """
+ if self.cursor is None:
+ return self
+
+ current = TrackerSlot(
+ self.cursor.generator,
+ self.cursor.subcolumn,
+ ).flat_index
+ slot = slot_from_flat(max(0, min(current + value, SLOT_COUNT - 1)))
+ return self.extend_to(
+ TrackerCursor(
+ self.cursor.row,
+ slot.generator,
+ slot.subcolumn,
+ )
+ )
+
+ def navigate_row(
+ self,
+ value: int,
+ row_count: int,
+ absolute: bool = False,
+ ) -> TrackerInputState:
+ if self.cursor is None or row_count == 0:
+ return self
+
+ new_row = value if absolute else self.cursor.row + value
+ new_row = max(0, min(new_row, row_count - 1))
+ return TrackerInputState(
+ cursor=TrackerCursor(
+ new_row,
+ self.cursor.generator,
+ self.cursor.subcolumn,
+ ),
+ pending="",
+ )
+
+ def navigate_subcolumn(
+ self,
+ value: int,
+ absolute: bool = False,
+ ) -> TrackerInputState:
+ """Steps the cursor along the flattened slot axis, wrapping at either end.
+
+ Wrapping is a navigation policy the cursor owns: walking right off the last
+ volume slot lands on the sample column's instrument, so a held arrow key
+ tours the whole row.
+ """
+ if self.cursor is None:
+ return self
+
+ if absolute:
+ new_sub = SUBCOLUMNS[value % len(SUBCOLUMNS)]
+ return TrackerInputState(
+ cursor=TrackerCursor(
+ self.cursor.row,
+ self.cursor.generator,
+ new_sub,
+ ),
+ pending="",
+ )
+
+ current = TrackerSlot(
+ self.cursor.generator,
+ self.cursor.subcolumn,
+ ).flat_index
+ slot = slot_from_flat((current + value) % SLOT_COUNT)
+ return TrackerInputState(
+ cursor=TrackerCursor(
+ self.cursor.row,
+ slot.generator,
+ slot.subcolumn,
+ ),
+ pending="",
+ )
+
+ def navigate_column_by(self, delta: int) -> TrackerInputState:
+ if self.cursor is None:
+ return self
+
+ current_idx = CHANNEL_AXIS.index(self.cursor.generator)
+ next_idx = (current_idx + delta) % len(CHANNEL_AXIS)
+ return TrackerInputState(
+ cursor=TrackerCursor(
+ self.cursor.row,
+ CHANNEL_AXIS[next_idx],
+ self.cursor.subcolumn,
+ ),
+ pending="",
+ )
+
+ def type_char(
+ self,
+ char: str,
+ ) -> Tuple[TrackerInputState, Optional[EditAction]]:
+ if self.cursor is None:
+ return self, None
+
+ if self.cursor.subcolumn is SubColumn.INSTRUMENT and char == MINUS:
+ return self._after_entry(), self._note_off_action(self.cursor)
+
+ if self.cursor.subcolumn is SubColumn.TRANSPOSE:
+ return self._type_transpose_char(char)
+
+ if char in SIGNS:
+ return self, None
+
+ pending = self.pending + char
+ expected = DIGIT_COUNT[self.cursor.subcolumn]
+ if len(pending) < expected:
+ return TrackerInputState(cursor=self.cursor, pending=pending), None
+
+ action = _parse(self.cursor, pending)
+ return self._after_entry(), action
+
+ def _note_off_action(self, cursor: TrackerCursor) -> EditAction:
+ return EditAction(
+ row=cursor.row,
+ generator=cursor.generator,
+ sample_index=None,
+ transpose=None,
+ volume=None,
+ note_off=True,
+ )
+
+ def _type_transpose_char(
+ self,
+ char: str,
+ ) -> Tuple[TrackerInputState, Optional[EditAction]]:
+ """Drives the signed transpose field: ``[±][H][H]``.
+
+ The first slot is reserved for the sign. A leading sign sets it; a leading
+ digit implies ``+``. A sign key pressed later flips the sign in place,
+ keeping any digits already entered. The field commits once both magnitude
+ digits are in.
+ """
+ if self.cursor is None:
+ return self, None
+
+ is_sign = char in SIGNS
+ if not self.pending:
+ pending = char if is_sign else f"{PLUS}{char}"
+ elif is_sign:
+ pending = char + self.pending[1:]
+ return (
+ TrackerInputState(cursor=self.cursor, pending=pending),
+ None,
+ )
+ else:
+ pending = self.pending + char
+
+ digits = len(pending) - 1
+ if digits < DIGIT_COUNT[SubColumn.TRANSPOSE]:
+ return TrackerInputState(cursor=self.cursor, pending=pending), None
+
+ action = _parse(self.cursor, pending)
+ return self._after_entry(), action
+
+ def commit_partial(self) -> Tuple[TrackerInputState, Optional[EditAction]]:
+ if not self.pending or self.cursor is None:
+ return self, None
+
+ if self.cursor.subcolumn is SubColumn.TRANSPOSE:
+ action = _parse(self.cursor, self.pending)
+ return self.reset_pending(), action
+
+ expected = DIGIT_COUNT[self.cursor.subcolumn]
+ padded = self.pending.zfill(expected)
+ action = _parse(self.cursor, padded)
+ return self.reset_pending(), action
+
+ def clear(self) -> Tuple[TrackerInputState, ClearAction]:
+ action = ClearAction(
+ row=self.cursor.row if self.cursor else 0,
+ generator=self.cursor.generator if self.cursor else None,
+ )
+ return self.reset_pending(), action
+
+ def clear_subcolumn(self) -> Tuple[TrackerInputState, ClearAction]:
+ action = ClearAction(
+ row=self.cursor.row if self.cursor else 0,
+ generator=self.cursor.generator if self.cursor else None,
+ subcolumn=self.cursor.subcolumn if self.cursor else None,
+ )
+ return self.reset_pending(), action
diff --git a/src/sampletones_application/ui/panels/sequencer/module.py b/src/sampletones_application/ui/panels/sequencer/module.py
index 3364659d..13a8e54d 100644
--- a/src/sampletones_application/ui/panels/sequencer/module.py
+++ b/src/sampletones_application/ui/panels/sequencer/module.py
@@ -26,10 +26,14 @@
from sampletones_application.view_model.sequencer.settings import (
SequencerSettingsViewModel,
)
-from sampletones_core.constants.general import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY
+from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY
from sampletones_shared.constants.project import (
MAX_ROWS_PER_PATTERN,
+ MAX_SPEED,
+ MAX_TEMPO,
MIN_ROWS_PER_PATTERN,
+ MIN_SPEED,
+ MIN_TEMPO,
)
from sampletones_shared.types.application import Sender
@@ -80,7 +84,10 @@ def create_panel(self, parent: str) -> None:
def _create_module_options(self) -> None:
settings = self._initial_settings
with dpg.group(tag=TAG_SEQUENCER_MODULE_GROUP_OPTIONS):
- with labeled_field(self._language_manager["sequencer.module.label.nes_frequency"], self._label_width):
+ with labeled_field(
+ self._language_manager["sequencer.module.label.nes_frequency"],
+ self._label_width,
+ ):
dpg.add_input_int(
default_value=settings.nes_frequency,
tag=TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY,
@@ -90,7 +97,10 @@ def _create_module_options(self) -> None:
max_clamped=True,
width=self._input_width,
)
- with labeled_field(self._language_manager["sequencer.module.label.rows"], self._label_width):
+ with labeled_field(
+ self._language_manager["sequencer.module.label.rows"],
+ self._label_width,
+ ):
dpg.add_input_int(
default_value=settings.rows_per_pattern,
tag=TAG_SEQUENCER_MODULE_INPUT_ROWS,
@@ -100,23 +110,29 @@ def _create_module_options(self) -> None:
max_clamped=True,
width=self._input_width,
)
- with labeled_field(self._language_manager["sequencer.module.label.tempo"], self._label_width):
+ with labeled_field(
+ self._language_manager["sequencer.module.label.tempo"],
+ self._label_width,
+ ):
dpg.add_input_int(
default_value=settings.tempo,
tag=TAG_SEQUENCER_MODULE_INPUT_TEMPO,
- min_value=self._layout.tempo.min,
- max_value=self._layout.tempo.max,
+ min_value=MIN_TEMPO,
+ max_value=MAX_TEMPO,
min_clamped=True,
max_clamped=True,
width=self._input_width,
callback=self._on_tempo_input,
)
- with labeled_field(self._language_manager["sequencer.module.label.speed"], self._label_width):
+ with labeled_field(
+ self._language_manager["sequencer.module.label.speed"],
+ self._label_width,
+ ):
dpg.add_input_int(
default_value=settings.speed,
tag=TAG_SEQUENCER_MODULE_INPUT_SPEED,
- min_value=self._layout.speed.min,
- max_value=self._layout.speed.max,
+ min_value=MIN_SPEED,
+ max_value=MAX_SPEED,
min_clamped=True,
max_clamped=True,
width=self._input_width,
@@ -192,25 +208,25 @@ def _commit_on_finish(
dpg.bind_item_handler_registry(input_tag, handler_tag)
- def _on_nes_frequency_input(self, sender: Sender, app_data: int) -> None:
+ 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)),
)
- def _on_rows_per_pattern_input(self, sender: Sender, app_data: int) -> None:
+ 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)),
)
- def _on_tempo_input(self, sender: Sender, app_data: int) -> None:
+ 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)),
)
- def _on_speed_input(self, sender: Sender, app_data: int) -> None:
+ 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)),
diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py
index a70a272c..0be02c15 100644
--- a/src/sampletones_application/ui/panels/sequencer/order.py
+++ b/src/sampletones_application/ui/panels/sequencer/order.py
@@ -1,14 +1,21 @@
-from typing import Callable, Dict, Final, Optional, Tuple
+from typing import Callable, Dict, Final, FrozenSet, Optional, Set, Tuple
import dearpygui.dearpygui as dpg
from sampletones_application.categories.elements.sequencer import SequencerOrderElements
from sampletones_application.categories.hierarchy import Page, Panel, TextType
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.general.plus_minus_buttons import PlusMinusButtonsLayout
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.layout.general.plus_minus_buttons import (
+ PlusMinusButtonsLayout,
+)
from sampletones_application.layout.tabs.sequencer import SequencerLayout
from sampletones_application.tags.compose import compose_tag
-from sampletones_application.tags.general import SUF_HANDLER_HEADER, SUF_HANDLER_REGISTRY
+from sampletones_application.tags.general import (
+ SUF_HANDLER_DRAG,
+ SUF_HANDLER_HEADER,
+ SUF_HANDLER_REGISTRY,
+)
from sampletones_application.tags.sequencer import (
TAG_SEQUENCER_ORDER_BUTTON_PAIR,
TAG_SEQUENCER_ORDER_PANEL,
@@ -30,18 +37,33 @@
)
from sampletones_application.ui.elements.table.caret import CaretOverlay
from sampletones_application.ui.elements.table.cells import EditableCells, pending_label
+from sampletones_application.ui.elements.table.selection import TableSelection
from sampletones_application.ui.panels.sequencer.channels import (
ChannelMenuLabels,
ChannelSwitch,
channel_tooltip,
)
from sampletones_application.ui.panels.sequencer.columns import channel_color
-from sampletones_application.ui.panels.sequencer.order_input import (
+from sampletones_application.ui.panels.sequencer.display import cell_title
+from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures
+from sampletones_application.ui.panels.sequencer.grid.scroll.axis import (
+ HorizontalScroll,
+)
+from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand
+from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel
+from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import (
+ BlockShortcuts,
+ ClipboardItems,
+)
+from sampletones_application.ui.panels.sequencer.grid.surface.edit import (
+ GridEditSurface,
+)
+from sampletones_application.ui.panels.sequencer.input.order import (
INDEX_DIGITS,
- ORDER_ROWS,
OrderCursor,
OrderInputState,
)
+from sampletones_application.ui.panels.sequencer.input.target import OrderTarget
from sampletones_application.ui.themes.inline import (
create_header_selectable_theme,
create_selectable_text_theme,
@@ -50,26 +72,31 @@
from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item
from sampletones_application.utils.gui.keyboard import (
PRIORITY_PANEL,
+ ActivePredicate,
KeyEvent,
KeyRouter,
)
-from sampletones_application.utils.gui.keyboard.modifiers import ALT, CTRL, SHIFT, Modifier
-from sampletones_application.utils.gui.shortcuts.keys import HEX_KEYS
-from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut
+from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS
+from sampletones_application.utils.gui.keyboard.modifiers import (
+ Modifier,
+ capture_modifiers,
+)
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
from sampletones_application.utils.gui.tooltip import show_tooltip
+from sampletones_application.utils.palette.colors.faded import FadedColor
from sampletones_application.view_model.sequencer.channels import (
SequencerChannelsViewModel,
)
from sampletones_application.view_model.sequencer.move import MoveDirection
from sampletones_application.view_model.sequencer.order import (
- SequencerOrderGridViewModel,
+ SequencerOrderTrackerViewModel,
)
+from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.utils.display import display_id
-from sampletones_shared.constants.symbols import MINUS, PLUS
from sampletones_shared.types.application import ColorRGBA, Sender
from sampletones_shared.types.callback import VoidCallback
-from sampletones_shared.utils.color import with_alpha_fraction
OrderKey = Tuple[Optional[GeneratorName], int]
@@ -81,6 +108,17 @@
OnSetMasterEntryCallback = Callable[[int, Optional[int]], None]
OnChannelMuteToggledCallback = Callable[[GeneratorName], None]
OnChannelSoloedCallback = Callable[[GeneratorName], None]
+OnBlockRegionCallback = Callable[[OrderRegion], None]
+OnPasteBlockCallback = Callable[[OrderCell], None]
+CanPasteBlockQuery = Callable[[], bool]
+OrderEditSurface = GridEditSurface[OrderCursor, OrderRegion, OrderCell, OrderTarget]
+
+MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = {
+ ShortcutId.ORDER_MOVE_FRAME_LEFT: MoveDirection.PREVIOUS,
+ ShortcutId.ORDER_MOVE_FRAME_RIGHT: MoveDirection.NEXT,
+ ShortcutId.ORDER_MOVE_FRAME_TO_START: MoveDirection.FIRST,
+ ShortcutId.ORDER_MOVE_FRAME_TO_END: MoveDirection.LAST,
+}
MASTER_TABLE_ROW: Final[int] = 0
DIVIDER_TABLE_ROW: Final[int] = 1
@@ -106,21 +144,36 @@ def __init__(
plus_minus_layout: PlusMinusButtonsLayout,
language_manager: LanguageManager,
key_router: KeyRouter,
+ tab_active: ActivePredicate,
+ shortcut_source: ShortcutSource,
initial_collapsed: bool = False,
) -> None:
self._layout = layout
self._plus_minus_layout = plus_minus_layout
self._router = key_router
+ self._tab_active = tab_active
+ self._shortcuts = shortcut_source
self._buttons: Optional[GUIPlusMinusButtons] = None
self._position_count: int = 0
self._order: EditableCells[OrderKey] = EditableCells()
self._input_state: OrderInputState = OrderInputState()
+ self._selection: TableSelection[OrderKey] = TableSelection(
+ cells=self._order,
+ cell_at=self._cell_at,
+ covered=self._selected_cells,
+ )
+ self._travel: DragTravel = DragTravel(
+ axis=HorizontalScroll(table=TAG_SEQUENCER_ORDER_TABLE),
+ band=self._travel_band,
+ elapsed=dpg.get_delta_time,
+ )
self._highlighted: Optional[OrderCursor] = None
self._highlighted_column: Optional[int] = None
self._current_position: Optional[int] = None
self._playing_position: Optional[int] = None
self._cell_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_REGISTRY)
self._label_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_HEADER)
+ self._drag_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_DRAG)
self._label_rows: Dict[Sender, Optional[GeneratorName]] = {}
self._entry_theme: int = 0
self._muted_entry_theme: int = 0
@@ -132,6 +185,7 @@ def __init__(
self.on_frame_selected: Optional[OnFrameSelectedCallback] = None
self.on_remove_requested: Optional[OnRemoveCallback] = None
self.on_duplicate_requested: Optional[OnFrameActionCallback] = None
+ self.on_clone_requested: Optional[OnFrameActionCallback] = None
self.on_insert_requested: Optional[OnFrameActionCallback] = None
self.on_clear_requested: Optional[OnFrameActionCallback] = None
self.on_play_from_requested: Optional[OnFrameActionCallback] = None
@@ -139,20 +193,36 @@ def __init__(
self.on_set_order_entry: Optional[OnSetOrderEntryCallback] = None
self.on_set_master_entry: Optional[OnSetMasterEntryCallback] = None
self.on_cell_selected: Optional[VoidCallback] = None
+ self.on_copy_block: Optional[OnBlockRegionCallback] = None
+ self.on_cut_block: Optional[OnBlockRegionCallback] = None
+ self.on_delete_block: Optional[OnBlockRegionCallback] = None
+ self.on_paste_block: Optional[OnPasteBlockCallback] = None
+ self.can_paste_block: Optional[CanPasteBlockQuery] = None
self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None
self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None
self.on_channels_toggled: Optional[VoidCallback] = None
self.on_channels_muted: Optional[VoidCallback] = None
self.on_channels_unmuted: Optional[VoidCallback] = None
+ self._blocks: BlockGestures[OrderRegion, OrderCell] = BlockGestures(grid=self)
+ self._surface: OrderEditSurface = GridEditSurface.build(
+ grid=self,
+ blocks=self._blocks,
+ target=OrderTarget,
+ shortcuts=shortcut_source,
+ block_shortcuts=BlockShortcuts(
+ copy=ShortcutId.ORDER_COPY_BLOCK,
+ cut=ShortcutId.ORDER_CUT_BLOCK,
+ paste=ShortcutId.ORDER_PASTE_BLOCK,
+ ),
+ labels=ClipboardItems.labels(language_manager),
+ )
self._lbl_order = self._label(language_manager, SequencerOrderElements.ORDER_TEXT)
self._load_row_labels(language_manager)
self._load_context_labels(language_manager)
self._load_label_tooltips(language_manager)
self._create_channel_switch(language_manager)
- self._load_shortcut_hints()
-
super().__init__(
tag=TAG_SEQUENCER_ORDER_PANEL,
)
@@ -181,7 +251,10 @@ def label(element: SequencerOrderElements) -> str:
return self._label(language_manager, element)
self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY)
+ self._lbl_context_select_all = label(SequencerOrderElements.CONTEXT_SELECT_ALL)
+ self._lbl_context_select_row = label(SequencerOrderElements.CONTEXT_SELECT_ROW)
self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE)
+ self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE)
self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT)
self._lbl_context_clear = label(SequencerOrderElements.CONTEXT_CLEAR)
self._lbl_context_remove = label(SequencerOrderElements.CONTEXT_REMOVE)
@@ -190,18 +263,6 @@ def label(element: SequencerOrderElements) -> str:
self._lbl_context_move_start = label(SequencerOrderElements.CONTEXT_MOVE_START)
self._lbl_context_move_end = label(SequencerOrderElements.CONTEXT_MOVE_END)
- def _load_shortcut_hints(self) -> None:
- """Spells the accelerator each frame-operation menu item shows beside its label."""
- self._sc_play_from_frame = Shortcut(dpg.mvKey_Spacebar, CTRL).get_display_string()
- self._sc_move_left = Shortcut(dpg.mvKey_Left, ALT).get_display_string()
- self._sc_move_right = Shortcut(dpg.mvKey_Right, ALT).get_display_string()
- self._sc_move_start = Shortcut(dpg.mvKey_Home, ALT).get_display_string()
- self._sc_move_end = Shortcut(dpg.mvKey_End, ALT).get_display_string()
- self._sc_duplicate = Shortcut(dpg.mvKey_D, CTRL).get_display_string()
- self._sc_insert = PLUS
- self._sc_remove = MINUS
- self._sc_clear = Shortcut(dpg.mvKey_Delete, SHIFT).get_display_string()
-
def _load_label_tooltips(self, language_manager: LanguageManager) -> None:
"""Reads the row-label tooltips, which name the click gestures the labels carry."""
@@ -236,15 +297,17 @@ def _create_channel_switch(self, language_manager: LanguageManager) -> None:
def create_panel(self, parent: str) -> None:
self._create_entry_themes()
- with self._collapsible_card(
- parent,
- self._lbl_order,
- glyph=self._glyphs.headers.order,
+ with (
+ self._collapsible_card(
+ parent,
+ self._lbl_order,
+ glyph=self._glyphs.headers.order,
+ ),
+ dpg.group(tag=self.tag),
):
- with dpg.group(tag=self.tag):
- self._create_button_row()
- self._create_order_window()
- self._register_handlers()
+ self._create_button_row()
+ self._create_order_window()
+ self._register_handlers()
def _create_entry_themes(self) -> None:
"""Colours every pattern entry, in the shade its channel sounds and the shade it is silenced.
@@ -257,9 +320,9 @@ 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,
+ FadedColor(
+ color=colors.text.order,
+ fraction=self._layout.tracker.muted_text_fraction,
),
)
self._label_theme = create_header_selectable_theme(
@@ -304,11 +367,18 @@ def _register_handlers(self) -> None:
with dpg.item_handler_registry(tag=self._cell_handler_tag):
dpg.add_item_clicked_handler(callback=self._on_cell_right_clicked)
+ dpg.add_item_active_handler(callback=self._on_cell_held)
with dpg.item_handler_registry(tag=self._label_handler_tag):
dpg.add_item_clicked_handler(callback=self._on_label_right_clicked)
- def update_order(self, view_model: SequencerOrderGridViewModel) -> None:
+ with dpg.handler_registry(tag=self._drag_handler_tag):
+ dpg.add_mouse_click_handler(
+ button=dpg.mvMouseButton_Left,
+ callback=self._on_pointer_pressed,
+ )
+
+ def update_order(self, view_model: SequencerOrderTrackerViewModel) -> None:
"""Reconciles the order table; rebuilds only when the position count changes."""
cell_values = self._compute_cell_values(view_model)
if view_model.position_count != self._position_count:
@@ -358,6 +428,7 @@ def deselect_cell(self) -> None:
self._clear_cursor_highlight()
self._clear_column_highlight()
self._input_state = OrderInputState()
+ self._selection.repaint()
self._update_caret()
if cursor is not None:
@@ -415,7 +486,7 @@ def _is_muted(self, generator: GeneratorName) -> bool:
def _compute_cell_values(
self,
- view_model: SequencerOrderGridViewModel,
+ view_model: SequencerOrderTrackerViewModel,
) -> Dict[OrderKey, str]:
cell_values: Dict[OrderKey, str] = {}
for position in range(view_model.position_count):
@@ -430,7 +501,7 @@ def _compute_cell_values(
def _rebuild_table(
self,
- view_model: SequencerOrderGridViewModel,
+ view_model: SequencerOrderTrackerViewModel,
cell_values: Dict[OrderKey, str],
) -> None:
"""Recreates the whole table when the position count changes.
@@ -439,11 +510,15 @@ def _rebuild_table(
state (and any highlight keyed by column index) dangling, which corrupted
the heap. Replacing the table item wholesale sidesteps that: the cursor and
column highlights die with the old table, so nothing references freed
- columns.
+ columns. The selected cells go with them, and :meth:`_restore_cursor` brings the
+ cursor back on its own — a table of another width is a table a region no longer
+ describes.
"""
dpg_delete_item(TAG_SEQUENCER_ORDER_TABLE)
self._highlighted = None
self._highlighted_column = None
+ self._selection.reset()
+ self._travel.rest()
self._order.reset(cell_values)
self._position_count = view_model.position_count
self._build_table(view_model.position_count)
@@ -482,7 +557,7 @@ def _build_table(self, position_count: int) -> None:
)
self._label_rows = {}
- for generator in ORDER_ROWS:
+ for generator in CHANNEL_AXIS:
self._build_row(generator, position_count)
if generator is None:
self._build_divider_row(position_count)
@@ -491,6 +566,30 @@ def _build_table(self, position_count: int) -> None:
self._highlight_master_row(position_count)
self._apply_channel_cues()
+ def repaint(self) -> None:
+ """Issues every tint the table holds as its own state.
+
+ DearPyGui keeps a row, column or cell highlight on the table rather than on an item,
+ so a colour reaches it only by being pushed again. Gathering the pushes here gives
+ the palette one call to make and keeps a rebuilt table and a recoloured one identical.
+ """
+ if not dpg.does_item_exist(TAG_SEQUENCER_ORDER_TABLE):
+ return
+
+ self._apply_column_backgrounds()
+ self._highlight_master_row(self._position_count)
+ self._apply_channel_cues()
+ self._repaint_highlights()
+
+ def _repaint_highlights(self) -> None:
+ if self._highlighted_column is not None:
+ position = self._highlighted_column
+ focused = self._input_state.cursor is not None and self._input_state.cursor.position == position
+ self._apply_column_highlight(position, focused=focused)
+
+ if self._highlighted is not None:
+ self._apply_cursor_highlight(self._highlighted)
+
def _apply_column_backgrounds(self) -> None:
"""Tints the label column like the header row.
@@ -501,7 +600,7 @@ def _apply_column_backgrounds(self) -> None:
dpg.highlight_table_column(
TAG_SEQUENCER_ORDER_TABLE,
0,
- self._layout.colors.order.label,
+ self._layout.colors.order.label.rgba,
)
def _highlight_master_row(self, position_count: int) -> None:
@@ -517,7 +616,7 @@ def _highlight_master_row(self, position_count: int) -> None:
TAG_SEQUENCER_ORDER_TABLE,
DIVIDER_TABLE_ROW,
column,
- color=self._layout.colors.order.master_divider,
+ color=self._layout.colors.order.master_divider.rgba,
)
def _highlight_master_cell_at(self, column: int) -> None:
@@ -525,7 +624,7 @@ def _highlight_master_cell_at(self, column: int) -> None:
TAG_SEQUENCER_ORDER_TABLE,
MASTER_TABLE_ROW,
column,
- color=self._layout.colors.order.master,
+ color=self._layout.colors.order.master.rgba,
)
def _tint_channel_rows(self) -> None:
@@ -546,12 +645,13 @@ def _tint_channel_rows(self) -> None:
def _channel_row_tint(self, generator: GeneratorName) -> ColorRGBA:
if self._is_muted(generator):
- return self._layout.colors.muted.background
+ return self._layout.colors.muted.background.rgba
- return with_alpha_fraction(
- channel_color(self._layout.colors.channels, generator),
- self._layout.tracker.channel_column_tint,
- )
+ channel = channel_color(self._layout.colors.channels, generator)
+ return FadedColor(
+ color=channel,
+ fraction=self._layout.tracker.channel_column_tint,
+ ).rgba
def _apply_column_highlight(self, position: int, *, focused: bool) -> None:
if focused:
@@ -561,7 +661,7 @@ def _apply_column_highlight(self, position: int, *, focused: bool) -> None:
else:
color = self._layout.colors.order.column_current
- dpg.highlight_table_column(TAG_SEQUENCER_ORDER_TABLE, position + 1, color)
+ dpg.highlight_table_column(TAG_SEQUENCER_ORDER_TABLE, position + 1, color.rgba)
self._highlighted_column = position
def set_playing_position(self, position: Optional[int]) -> None:
@@ -659,17 +759,31 @@ def _render_cell(self, key: OrderKey) -> str:
def _table_row(self, generator: Optional[GeneratorName]) -> int:
if generator is None:
return MASTER_TABLE_ROW
- return ORDER_ROWS.index(generator) + 1
+ return CHANNEL_AXIS.index(generator) + 1
def _apply_cursor_highlight(self, cursor: OrderCursor) -> None:
dpg.highlight_table_cell(
TAG_SEQUENCER_ORDER_TABLE,
self._table_row(cursor.generator),
cursor.position + 1,
- color=self._layout.colors.cell_cursor,
+ color=self._layout.colors.cell_cursor.rgba,
)
self._highlighted = cursor
+ def _selected_cells(self) -> FrozenSet[OrderKey]:
+ """Every cell the selection covers, clipped to the positions the table holds."""
+ region = self._input_state.region
+ if region is None:
+ return frozenset()
+
+ keys: Set[OrderKey] = set()
+ for generator in region.generators:
+ for position in region.positions:
+ if position < self._position_count:
+ keys.add((generator, position))
+
+ return frozenset(keys)
+
def _clear_cursor_highlight(self) -> None:
if self._highlighted is None:
return
@@ -736,6 +850,7 @@ def _apply_state(
if old is None or old.position != new.position:
self.call(self.on_frame_selected, new.position)
+ self._selection.repaint()
self._update_caret()
self._refresh_remove_enabled()
@@ -765,23 +880,139 @@ def _update_caret(self) -> None:
def _on_cell_clicked(
self,
sender: Sender,
- app_data: bool,
+ _app_data: bool,
user_data: OrderKey,
) -> None:
- dpg.set_value(sender, False)
- self._committed_state()
+ """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held.
+
+ A drag that comes back to the cell it started from ends on a click, and the selection takes
+ that click as the end of the drag, so the range dragged out stands and the cursor with it.
+ """
+ if self._selection.claims_click(sender, user_data):
+ return
+
+ state = self._committed_state()
generator, position = user_data
- self._apply_state(OrderInputState(cursor=OrderCursor(generator, position)))
+ cursor = OrderCursor(generator, position)
+ if Modifier.SHIFT in capture_modifiers():
+ self._apply_state(state.extend_to(cursor))
+ return
+
+ self._apply_state(OrderInputState(cursor=cursor))
+
+ def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None:
+ """Carries the selection to the cell under a held pointer, which is what drags a range out.
+
+ The gesture states how far the pointer has carried: a plain drag anchors at the cell the
+ press landed on, and one whose press held Shift carries the selection already standing.
+
+ A pointer held past an edge travels the table first, so the reach that follows reads the
+ positions the travel has brought into view.
+ """
+ self._travel.advance()
+ reach = self._selection.hold(app_data)
+ if reach is None:
+ return
+
+ state = self._committed_state()
+ if not reach.extends:
+ state = OrderInputState(cursor=OrderCursor(*reach.origin))
+
+ self._apply_state(state.extend_to(OrderCursor(*reach.reached)))
+
+ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None:
+ """Drops the gesture a finished drag left behind, so this press selects on its own.
+
+ A press is where a gesture ends rather than the release before it, because the release
+ reaches this panel ahead of the click the cell itself reports: a drag that comes back to
+ the cell it started from would otherwise have its selection taken down by its own click.
+ """
+ self._selection.drop_gesture()
+ self._travel.rest()
+
+ def _travel_band(self) -> Optional[TravelBand]:
+ """Where the order's positions stand, which is the band a drag held beside them travels across.
+
+ Two positions state the pitch the columns are laid out at, so an order holding one of them
+ travels nowhere — there is nothing beside it to reach.
+ """
+ first = self._cell_left(0)
+ following = self._cell_left(1)
+ if first is None or following is None:
+ return None
+
+ return TravelBand(
+ first_edge=first,
+ cell_extent=following - first,
+ cell_count=self._position_count,
+ )
+
+ def _cell_at(self) -> Optional[OrderKey]:
+ """The cell the pointer stands on, clamped to the table the order lays out.
+
+ A drag that runs past an edge reads as the edge itself, so carrying the pointer beyond
+ the last channel or the last position selects up to it rather than stopping there.
+ """
+ left, top = dpg.get_mouse_pos(local=False)
+ position = self._position_at(left)
+ if position is None:
+ return None
+
+ return (self._generator_at(top), position)
+
+ def _generator_at(self, top: float) -> Optional[GeneratorName]:
+ """Which channel row stands at a height, the master row reading ``None``.
+
+ The master row stands apart from the channels beneath it, so the walk asks each row where
+ it was drawn and takes the first one reaching past the pointer.
+ """
+ for generator in CHANNEL_AXIS:
+ widget = self._order.widget((generator, 0))
+ if widget is None:
+ continue
+
+ _, row_top = dpg.get_item_rect_min(widget)
+ _, row_height = dpg.get_item_rect_size(widget)
+ if top < row_top + row_height:
+ return generator
+
+ return CHANNEL_AXIS[-1]
+
+ def _position_at(self, left: float) -> Optional[int]:
+ """Which position stands at a width, counted from the first cell's left edge.
+
+ Every position column is the same width, so the count is arithmetic once two of them
+ state the pitch; an order of a single position holds every width there is.
+ """
+ first = self._cell_left(0)
+ if first is None:
+ return None
+
+ following = self._cell_left(1)
+ if following is None:
+ return 0
+
+ position = int((left - first) // (following - first))
+ return max(0, min(position, self._position_count - 1))
+
+ def _cell_left(self, position: int) -> Optional[float]:
+ """Where a position column's cells begin, in the coordinates the viewport is drawn in."""
+ widget = self._order.widget((None, position))
+ if widget is None:
+ return None
+
+ cell_left, _ = dpg.get_item_rect_min(widget)
+ return float(cell_left)
def _on_cell_right_clicked(
self,
- sender: Sender,
+ _sender: Sender,
app_data: Tuple[int, int],
) -> None:
- """Opens the frame-operations menu for the right-clicked frame.
+ """Opens the frame-operations menu for the right-clicked cell.
- The menu acts on the clicked frame directly and leaves the edit cursor (and, while
- following playback, the playhead) where it is — right-clicking should not seek.
+ The menu acts on the clicked cell and its frame directly, and leaves the edit cursor (and,
+ while following playback, the playhead) where it is — right-clicking should not seek.
"""
mouse_button, clicked_item = app_data
if mouse_button != dpg.mvMouseButton_Right:
@@ -791,20 +1022,20 @@ def _on_cell_right_clicked(
if key is None:
return
- _, position = key
- self._show_context_menu(position)
+ generator, position = key
+ self._show_context_menu(generator, position)
def _on_label_clicked(
self,
sender: Sender,
- app_data: bool,
+ _app_data: bool,
user_data: Optional[GeneratorName],
) -> None:
self._channel_switch.click(sender, user_data)
def _on_label_right_clicked(
self,
- sender: Sender,
+ _sender: Sender,
app_data: Tuple[int, int],
) -> None:
"""Opens the channel menu for the right-clicked row label.
@@ -827,177 +1058,361 @@ def _show_channel_menu(self, generator: Optional[GeneratorName]) -> None:
header = dpg.add_text(self._row_labels[generator])
FontRegistry.bind_to_item(header, Font.MONO_BOLD)
dpg.add_separator()
- self._channel_switch.add_menu_items(generator, self._current_channels)
+ self._channel_switch.add_menu_items(
+ generator,
+ self._current_channels,
+ )
- def _show_context_menu(self, position: int) -> None:
+ def _show_context_menu(
+ self,
+ generator: Optional[GeneratorName],
+ position: int,
+ ) -> None:
+ target = self._surface.target_at(OrderCursor(generator, position))
with context_menu():
- header = dpg.add_text(display_id(position))
+ header = dpg.add_text(
+ cell_title(
+ position,
+ self._row_labels[generator],
+ )
+ )
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_from_requested, position),
- shortcut=self._sc_play_from_frame,
- )
- dpg.add_separator()
- dpg.add_menu_item(
- label=self._lbl_context_duplicate,
- shortcut=self._sc_duplicate,
- callback=lambda: self.call(self.on_duplicate_requested, position),
- )
- dpg.add_menu_item(
- label=self._lbl_context_insert,
- shortcut=self._sc_insert,
- callback=lambda: self.call(self.on_insert_requested, position),
- )
- dpg.add_menu_item(
- label=self._lbl_context_clear,
- shortcut=self._sc_clear,
- callback=lambda: self.call(self.on_clear_requested, position),
- )
- dpg.add_menu_item(
- label=self._lbl_context_remove,
- shortcut=self._sc_remove,
- callback=lambda: self.call(self.on_remove_requested, position),
+ shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME),
)
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_action_items(target)
+
+ @property
+ def edit_surface(self) -> OrderEditSurface:
+ """This table as the menu bar's Edit menu reaches it."""
+ return self._surface
+
+ def input_state(self) -> OrderInputState:
+ """Where the cursor stands and what it has selected, which a target is resolved from."""
+ return self._input_state
+
+ def owns_keys(self) -> bool:
+ """Whether the table owns the next key, which is also what the Edit menu asks."""
+ return self._keys_active()
+
+ def add_action_items(self, target: OrderTarget) -> None:
+ """Builds every action an order cell offers, in the order each menu prints them.
+
+ The table states its actions once, and whoever asks for them decides where they are shown:
+ the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the
+ cursor stands on. An action added here reaches both.
+ """
+ self._add_select_items(target.cell)
+ dpg.add_separator()
+ self._surface.add_block_items(target)
+ dpg.add_separator()
+ self._add_frame_items(target.cell.position)
+ dpg.add_separator()
+ self._add_move_items(target.cell.position)
+
+ def _add_select_items(self, cell: OrderCursor) -> None:
+ """Builds the two shapes a selection takes, the whole order and one row of it.
+
+ Each item fires the gesture its key fires, on the cell the menu names: a row selected from
+ a cell menu is the row that cell stands in, and one selected from the menu bar is the row
+ the cursor stands in.
+ """
+ dpg.add_menu_item(
+ label=self._lbl_context_select_all,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ALL),
+ callback=lambda: self._select_shape(
+ ShortcutId.ORDER_SELECT_ALL,
+ cell,
+ ),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_context_select_row,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ROW),
+ callback=lambda: self._select_shape(
+ ShortcutId.ORDER_SELECT_ROW,
+ cell,
+ ),
+ )
+
+ def _add_frame_items(self, position: int) -> None:
+ """Builds the frame operations, each acting on the whole frame the target cell sits in."""
+ dpg.add_menu_item(
+ label=self._lbl_context_duplicate,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME),
+ callback=lambda: self.call(self.on_duplicate_requested, position),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_context_clone,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_CLONE_FRAME),
+ callback=lambda: self.call(self.on_clone_requested, position),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_context_insert,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_INSERT_FRAME),
+ callback=lambda: self.call(self.on_insert_requested, position),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_context_clear,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_CLEAR_FRAME),
+ callback=lambda: self.call(self.on_clear_requested, position),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_context_remove,
+ shortcut=self._shortcuts.display(ShortcutId.ORDER_REMOVE_FRAME),
+ callback=lambda: self.call(self.on_remove_requested, position),
+ )
+
+ def _add_move_items(self, position: int) -> None:
+ """Builds the four moves a frame can make, in the order they walk the song."""
+ self._add_move_item(
+ self._lbl_context_move_left,
+ ShortcutId.ORDER_MOVE_FRAME_LEFT,
+ position,
+ )
+ self._add_move_item(
+ self._lbl_context_move_right,
+ ShortcutId.ORDER_MOVE_FRAME_RIGHT,
+ position,
+ )
+ self._add_move_item(
+ self._lbl_context_move_start,
+ ShortcutId.ORDER_MOVE_FRAME_TO_START,
+ position,
+ )
+ self._add_move_item(
+ self._lbl_context_move_end,
+ ShortcutId.ORDER_MOVE_FRAME_TO_END,
+ position,
+ )
def _add_move_item(
self,
label: str,
- shortcut: str,
+ shortcut_id: ShortcutId,
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)
+ """Adds a move item, greyed out (disabled) when the move would have no effect.
+
+ The action names both the direction it moves and the accelerator it prints, so the item a
+ reader sees is the one the key press performs.
+ """
+ target = MOVE_DIRECTIONS[shortcut_id].target(
+ position,
+ self._position_count,
+ )
dpg.add_menu_item(
label=label,
- shortcut=shortcut,
+ shortcut=self._shortcuts.display(shortcut_id),
enabled=target is not None,
- callback=lambda: self.call(self.on_move_requested, position, target),
+ callback=lambda: self.call(
+ self.on_move_requested,
+ position,
+ target,
+ ),
)
def _keys_active(self) -> bool:
- """Whether the order table owns the next key: its cursor is set and no field holds the keyboard.
+ """Whether the order table owns the next key: its tab is in front, its cursor is set, and
+ no field holds the keyboard.
- A focused field keeps the keyboard, so the table stands down while the user types into an
- input. A modal dialog claims keys at a higher priority in the router, so the table carries no
- modal check of its own.
+ The table keeps its cursor while another tab is worked on, so the tab in front is what
+ decides whether a press reaches it. A focused field keeps the keyboard, so the table stands
+ down while the user types into an input. A modal dialog claims keys at a higher priority in
+ the router, so the table carries no modal check of its own.
"""
- return self._input_state.cursor is not None and not self._router.is_field_focused
+ return self._tab_active() and self._input_state.cursor is not None and not self._router.is_field_focused
def _on_key_pressed(self, event: KeyEvent) -> bool:
"""Applies an order key to the active cell, reporting whether the table consumed it.
- Alt drives the frame moves and Ctrl+D duplicates; any other modifier press belongs to the
- application's global shortcuts, so the table yields it and keeps the plain keys for editing.
+ The scheme says which press each order action answers to; a press the order category
+ leaves unnamed goes to cell entry, which keeps the hex digits and hands the rest to the
+ application's global shortcuts.
"""
cursor = self._input_state.cursor
if cursor is None:
return False
- if Modifier.ALT in event.modifiers:
- return self._handle_alt_move(event.key, cursor.position)
+ shortcut_id = self._shortcuts.action(ShortcutCategory.ORDER, event)
+ if shortcut_id is None:
+ return self._type_character(event)
- if Modifier.CTRL in event.modifiers:
- if event.key == dpg.mvKey_D:
- self.call(self.on_duplicate_requested, cursor.position)
- return True
- return False
+ if self._move_cursor(shortcut_id):
+ return True
- match event.key:
- case dpg.mvKey_Plus | dpg.mvKey_Add:
- self.call(self.on_insert_requested, cursor.position)
- case dpg.mvKey_Minus | dpg.mvKey_Subtract:
- self._on_remove_clicked()
- case dpg.mvKey_Left:
+ if self._extend_selection(shortcut_id):
+ return True
+
+ if self._select_shape(shortcut_id, cursor):
+ return True
+
+ if self._block_action(shortcut_id):
+ return True
+
+ if self._edit_cell(shortcut_id):
+ return True
+
+ return self._act_on_frame(shortcut_id, cursor.position)
+
+ def _move_cursor(self, shortcut_id: ShortcutId) -> bool:
+ """Moves the edit cursor over the table, reporting whether the action was one of its moves."""
+ match shortcut_id:
+ case ShortcutId.ORDER_PREVIOUS_POSITION:
self._move_position(-1)
- case dpg.mvKey_Right:
+ case ShortcutId.ORDER_NEXT_POSITION:
self._move_position(1)
- case dpg.mvKey_Up:
+ case ShortcutId.ORDER_PREVIOUS_CHANNEL:
self._move_channel(-1)
- case dpg.mvKey_Down:
+ case ShortcutId.ORDER_NEXT_CHANNEL:
self._move_channel(1)
- case dpg.mvKey_Home:
+ case ShortcutId.ORDER_FIRST_POSITION:
self._jump_position(0)
- case dpg.mvKey_End:
+ case ShortcutId.ORDER_LAST_POSITION:
self._jump_position(self._position_count - 1)
- case dpg.mvKey_Return:
+ case _:
+ return False
+
+ return True
+
+ def _extend_selection(self, shortcut_id: ShortcutId) -> bool:
+ """Grows or shrinks the selected block, reporting whether the action was one of its reaches.
+
+ Each reach moves the end the cursor holds while the anchor stays where the selection began,
+ so the same keys that move the cursor select with Shift held.
+ """
+ match shortcut_id:
+ case ShortcutId.ORDER_EXTEND_SELECTION_UP:
+ self._extend_channel(-1)
+ case ShortcutId.ORDER_EXTEND_SELECTION_DOWN:
+ self._extend_channel(1)
+ case ShortcutId.ORDER_EXTEND_SELECTION_LEFT:
+ self._extend_position(-1)
+ case ShortcutId.ORDER_EXTEND_SELECTION_RIGHT:
+ self._extend_position(1)
+ case ShortcutId.ORDER_EXTEND_SELECTION_TO_FIRST_POSITION:
+ self._extend_to_position(0)
+ case ShortcutId.ORDER_EXTEND_SELECTION_TO_LAST_POSITION:
+ self._extend_to_position(self._position_count - 1)
+ case _:
+ return False
+
+ return True
+
+ def _select_shape(
+ self,
+ shortcut_id: ShortcutId,
+ cell: OrderCursor,
+ ) -> bool:
+ """Selects a rectangle of the table, reporting whether the action was one of its shapes.
+
+ A press names its shape from the cell the cursor stands on, which is the cell the menu
+ items name as well, so a key and an item select the same block.
+ """
+ match shortcut_id:
+ case ShortcutId.ORDER_SELECT_ALL:
+ self._select_all()
+ case ShortcutId.ORDER_SELECT_ROW:
+ self._select_row(cell)
+ case _:
+ return False
+
+ return True
+
+ def _select_all(self) -> None:
+ self._apply_state(
+ self._committed_state().select_all(
+ self._position_count,
+ )
+ )
+
+ def _select_row(self, cell: OrderCursor) -> None:
+ self._apply_state(
+ self._committed_state().select_row(
+ cell,
+ self._position_count,
+ )
+ )
+
+ def _block_action(self, shortcut_id: ShortcutId) -> bool:
+ """Acts on the selected block, reporting whether the action was one of its gestures.
+
+ Delete is a block gesture only while a selection stands: with one it empties every cell the
+ selection covers and keeps it, and with none it falls through to clearing the cell under
+ the cursor, the meaning that key already carries.
+ """
+ match shortcut_id:
+ case ShortcutId.ORDER_COPY_BLOCK:
+ self._surface.copy()
+ case ShortcutId.ORDER_CUT_BLOCK:
+ self._surface.cut()
+ case ShortcutId.ORDER_CLEAR_CELL if self._input_state.region is not None:
+ self._surface.delete()
+ case ShortcutId.ORDER_PASTE_BLOCK:
+ self._surface.paste()
+ case _:
+ return False
+
+ return True
+
+ def _edit_cell(self, shortcut_id: ShortcutId) -> bool:
+ """Empties the cell under the cursor or drops a partial entry, reporting whether the action
+ was one of the cell edits.
+
+ A cancel with nothing typed and nothing selected leaves the press to the application, so
+ Escape stops playback while the table holds a cursor.
+ """
+ match shortcut_id:
+ case ShortcutId.ORDER_CLEAR_CELL:
+ self._clear_cell()
self._move_position(1)
- case dpg.mvKey_Delete:
- if Modifier.SHIFT in event.modifiers:
- self.call(self.on_clear_requested, cursor.position)
- else:
- self._clear_cell()
- self._move_position(1)
- case dpg.mvKey_Back:
+ case ShortcutId.ORDER_CLEAR_PREVIOUS_CELL:
self._clear_cell()
self._move_position(-1)
- case dpg.mvKey_Insert:
- self._on_add_clicked()
- case dpg.mvKey_Escape:
- if not self._input_state.pending:
+ case ShortcutId.ORDER_CANCEL_ENTRY:
+ if not self._input_state.pending and self._input_state.anchor is None:
return False
self._apply_state(self._input_state.cancel())
case _:
- return self._handle_printable_key(event.key)
+ return False
return True
- def _handle_alt_move(self, key: int, position: int) -> bool:
- """Moves the selected frame left/right/to-start/to-end on Alt + arrow / Home / End.
+ def _act_on_frame(self, shortcut_id: ShortcutId, position: int) -> bool:
+ """Adds, removes or moves a whole frame, reporting whether the action was one of them.
- Returns whether the key was an Alt move gesture, so a boundary with nowhere to go still
- counts as consumed and stays out of the global shortcuts.
+ A move with nowhere to go still counts as consumed, so a press at either boundary stays
+ out of the global shortcuts.
"""
- direction = self._alt_move_direction(key)
- if direction is None:
- return False
+ direction = MOVE_DIRECTIONS.get(shortcut_id)
+ if direction is not None:
+ target = direction.target(position, self._position_count)
+ if target is not None:
+ self.call(self.on_move_requested, position, target)
- target = direction.target(position, self._position_count)
- if target is not None:
- self.call(self.on_move_requested, position, target)
+ return True
- return True
-
- def _alt_move_direction(self, key: int) -> Optional[MoveDirection]:
- match key:
- case dpg.mvKey_Left:
- return MoveDirection.PREVIOUS
- case dpg.mvKey_Right:
- return MoveDirection.NEXT
- case dpg.mvKey_Home:
- return MoveDirection.FIRST
- case dpg.mvKey_End:
- return MoveDirection.LAST
+ match shortcut_id:
+ case ShortcutId.ORDER_ADD_FRAME:
+ self._on_add_clicked()
+ case ShortcutId.ORDER_INSERT_FRAME:
+ self.call(self.on_insert_requested, position)
+ case ShortcutId.ORDER_REMOVE_FRAME:
+ self._on_remove_clicked()
+ case ShortcutId.ORDER_DUPLICATE_FRAME:
+ self.call(self.on_duplicate_requested, position)
+ case ShortcutId.ORDER_CLONE_FRAME:
+ self.call(self.on_clone_requested, position)
+ case ShortcutId.ORDER_CLEAR_FRAME:
+ self.call(self.on_clear_requested, position)
case _:
- return None
+ return False
+
+ return True
def _move_position(self, delta: int) -> None:
self._apply_state(
@@ -1019,6 +1434,26 @@ def _jump_position(self, index: int) -> None:
def _move_channel(self, delta: int) -> None:
self._apply_state(self._committed_state().navigate_channel(delta))
+ def _extend_position(self, delta: int) -> None:
+ self._apply_state(
+ self._committed_state().extend_position(
+ delta,
+ self._position_count,
+ ),
+ )
+
+ def _extend_to_position(self, index: int) -> None:
+ self._apply_state(
+ self._committed_state().extend_position(
+ index,
+ self._position_count,
+ absolute=True,
+ ),
+ )
+
+ def _extend_channel(self, delta: int) -> None:
+ self._apply_state(self._committed_state().extend_channel(delta))
+
def _committed_state(self) -> OrderInputState:
state, index = self._input_state.commit_partial()
if index is not None:
@@ -1026,8 +1461,24 @@ def _committed_state(self) -> OrderInputState:
return state
- def _handle_printable_key(self, key: int) -> bool:
- char = HEX_KEYS.get(key)
+ def commit_entry(self) -> None:
+ """Writes the entry being typed into the cell the cursor stands on.
+
+ A block gesture takes this first, so what it lifts out carries the index the reader has
+ just finished typing.
+ """
+ self._apply_state(self._committed_state())
+
+ def _type_character(self, event: KeyEvent) -> bool:
+ """Types a hex digit into the cell under the cursor, reporting whether the press was one.
+
+ A press holding Ctrl or Alt is an application gesture, so cell entry reads the plain keys
+ and leaves the rest to the global shortcuts.
+ """
+ if Modifier.CTRL in event.modifiers or Modifier.ALT in event.modifiers:
+ return False
+
+ char = HEX_KEYS.get(event.key)
if char is None:
return False
diff --git a/src/sampletones_application/ui/panels/sequencer/order_input.py b/src/sampletones_application/ui/panels/sequencer/order_input.py
deleted file mode 100644
index dcabcfc1..00000000
--- a/src/sampletones_application/ui/panels/sequencer/order_input.py
+++ /dev/null
@@ -1,86 +0,0 @@
-from __future__ import annotations
-
-from typing import Final, Optional, Tuple
-
-from pydantic.dataclasses import dataclass
-
-from sampletones_core.constants.enums import GeneratorName
-
-INDEX_DIGITS: Final[int] = 2
-ORDER_ROWS: Final[Tuple[Optional[GeneratorName], ...]] = (None,) + tuple(GeneratorName.items())
-"""Order-table rows top to bottom: the master row (``None``) then the four channels."""
-
-
-@dataclass(frozen=True)
-class OrderCursor:
- generator: Optional[GeneratorName]
- position: int
-
-
-def _parse(pending: str) -> Optional[int]:
- try:
- return int(pending, 16)
- except ValueError:
- return None
-
-
-@dataclass
-class OrderInputState:
- """Edit cursor and pending hex entry for the order table.
-
- The order has no subcolumns, so a cell holds a single pattern index; typing
- accumulates :data:`INDEX_DIGITS` hex digits and then commits the parsed index.
- Navigation moves along positions (columns) or channels/master (rows).
- """
-
- cursor: Optional[OrderCursor] = None
- pending: str = ""
-
- def reset_pending(self) -> OrderInputState:
- return OrderInputState(cursor=self.cursor, pending="")
-
- def navigate_position(
- self,
- value: int,
- position_count: int,
- absolute: bool = False,
- ) -> OrderInputState:
- if self.cursor is None or position_count == 0:
- return self
-
- new_position = value if absolute else self.cursor.position + value
- new_position = max(0, min(new_position, position_count - 1))
- return OrderInputState(
- cursor=OrderCursor(self.cursor.generator, new_position),
- pending="",
- )
-
- def navigate_channel(self, value: int) -> OrderInputState:
- if self.cursor is None:
- return self
-
- current = ORDER_ROWS.index(self.cursor.generator)
- new_generator = ORDER_ROWS[(current + value) % len(ORDER_ROWS)]
- return OrderInputState(
- cursor=OrderCursor(new_generator, self.cursor.position),
- pending="",
- )
-
- def type_char(self, char: str) -> Tuple[OrderInputState, Optional[int]]:
- if self.cursor is None:
- return self, None
-
- pending = self.pending + char
- if len(pending) < INDEX_DIGITS:
- return OrderInputState(cursor=self.cursor, pending=pending), None
-
- return self.reset_pending(), _parse(pending)
-
- def commit_partial(self) -> Tuple[OrderInputState, Optional[int]]:
- if not self.pending or self.cursor is None:
- return self, None
-
- return self.reset_pending(), _parse(self.pending.zfill(INDEX_DIGITS))
-
- def cancel(self) -> OrderInputState:
- return self.reset_pending()
diff --git a/src/sampletones_application/ui/panels/sequencer/rows.py b/src/sampletones_application/ui/panels/sequencer/rows.py
new file mode 100644
index 00000000..c34b19d4
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/rows.py
@@ -0,0 +1,80 @@
+from dataclasses import dataclass
+from typing import Optional
+
+from sampletones_application.layout.tabs.sequencer.colors.colors import SequencerColors
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.layered import LayeredColor
+from sampletones_application.view_model.sequencer.settings import (
+ SequencerSettingsViewModel,
+)
+
+
+@dataclass(frozen=True)
+class RowCues:
+ """The pattern rows the tracker's moving marks stand on."""
+
+ cursor: Optional[int]
+ playing: Optional[int]
+
+
+def group_color(
+ row_index: int,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+) -> Optional[BaseColor]:
+ """The emphasis a row takes from the group the project's metre opens on it.
+
+ The second highlight marks the bar and the first the beat, so a row opening a bar takes
+ the stronger of the two shades even where a beat opens there as well. A row inside a beat
+ keeps the zebra stripe it already has, and a highlight of one marks every row.
+ """
+ if row_index % settings.second_highlight == 0:
+ return colors.rows.bar
+
+ if row_index % settings.first_highlight == 0:
+ return colors.rows.beat
+
+ return None
+
+
+def cue_color(
+ row_index: int,
+ cues: RowCues,
+ colors: SequencerColors,
+) -> Optional[BaseColor]:
+ """The mark a row carries while the song plays or the cursor rests on it.
+
+ The playing row outranks the cursor row, so a passing playhead stays legible over the
+ row being edited; the cursor keeps its cell mark either way.
+ """
+ if cues.playing == row_index:
+ return colors.playback_row
+
+ if cues.cursor == row_index:
+ return colors.cursor_row
+
+ return None
+
+
+def row_background(
+ row_index: int,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+ cues: RowCues,
+) -> Optional[BaseColor]:
+ """The colour a pattern row's background carries, group and cue taken together.
+
+ DearPyGui offers one row background above the zebra stripe, so the row's standing
+ emphasis and whatever mark is passing over it arrive as a single shade: the cue is
+ composed over the group the row belongs to. A plain row with no mark on it returns
+ ``None``, leaving the stripe as it is.
+ """
+ group = group_color(row_index, settings, colors)
+ cue = cue_color(row_index, cues, colors)
+ if group is None:
+ return cue
+
+ if cue is None:
+ return group
+
+ return LayeredColor(base=group, overlay=cue)
diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py
index ab464a27..6814c5b0 100644
--- a/src/sampletones_application/ui/panels/sequencer/samples.py
+++ b/src/sampletones_application/ui/panels/sequencer/samples.py
@@ -1,7 +1,14 @@
-from typing import Callable, Final, List, Optional, Tuple
+from dataclasses import dataclass
+from typing import Callable, Dict, Final, List, Optional, Tuple
import dearpygui.dearpygui as dpg
+from sampletones_application.categories.context import channel_label, context_label, context_text
+from sampletones_application.categories.elements.global_ import ContextElements
+from sampletones_application.categories.elements.sequencer import (
+ SequencerInstrumentsElements,
+)
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.layout.tabs.sequencer import SequencerLayout
from sampletones_application.tags.compose import compose_tag
@@ -14,6 +21,7 @@
TAG_SEQUENCER_INSTRUMENTS_WINDOW,
)
from sampletones_application.ui.elements.context_menu import (
+ add_detail_items,
add_play_menu_item,
context_menu,
)
@@ -25,41 +33,91 @@
from sampletones_application.utils.gui.frame import FrameCallbackManager
from sampletones_application.utils.gui.keyboard import (
PRIORITY_PANEL,
+ ActivePredicate,
KeyEvent,
KeyRouter,
)
-from sampletones_application.utils.gui.keyboard.modifiers import Modifier
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.utils.palette.colors.base import BaseColor
from sampletones_application.view_model.sequencer.move import MoveDirection
from sampletones_application.view_model.sequencer.samples import (
SampleEntryViewModel,
SampleSelection,
SequencerSamplesViewModel,
)
-from sampletones_core.utils.display import display_id, display_sample_label
+from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.utils.display import display_id
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import StringCallback
FROZEN_HEADER_ROWS: Final[int] = 1
+@dataclass(frozen=True)
+class SampleMove:
+ """One of the four moves, as its key press and its menu item each name it."""
+
+ element: SequencerInstrumentsElements
+ shortcut: ShortcutId
+ direction: MoveDirection
+
+
+SAMPLE_MOVES: Final[Tuple[SampleMove, ...]] = (
+ SampleMove(
+ element=SequencerInstrumentsElements.CONTEXT_MOVE_UP,
+ shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_UP,
+ direction=MoveDirection.PREVIOUS,
+ ),
+ SampleMove(
+ element=SequencerInstrumentsElements.CONTEXT_MOVE_DOWN,
+ shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_DOWN,
+ direction=MoveDirection.NEXT,
+ ),
+ SampleMove(
+ element=SequencerInstrumentsElements.CONTEXT_MOVE_TOP,
+ shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_TOP,
+ direction=MoveDirection.FIRST,
+ ),
+ SampleMove(
+ element=SequencerInstrumentsElements.CONTEXT_MOVE_BOTTOM,
+ shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_BOTTOM,
+ direction=MoveDirection.LAST,
+ ),
+)
+
+MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = {move.shortcut: move.direction for move in SAMPLE_MOVES}
+
+
class GUISequencerSamplesPanel(GUIPanel):
def __init__(
self,
*,
layout: SequencerLayout,
+ detail_color: BaseColor,
language_manager: LanguageManager,
key_router: KeyRouter,
+ tab_active: ActivePredicate,
+ shortcut_source: ShortcutSource,
initial_collapsed: bool = False,
) -> None:
self._language_manager = language_manager
self._layout = layout
+ self._detail_color = detail_color
self._router = key_router
+ self._tab_active = tab_active
+ self._shortcuts = shortcut_source
self._row_handler_tag = compose_tag(TAG_SEQUENCER_INSTRUMENTS_TABLE, SUF_HANDLER_REGISTRY)
self._rename_handler_tag = compose_tag(TAG_SEQUENCER_INSTRUMENTS_INPUT_RENAME, SUF_HANDLER_REGISTRY)
self._selected_sample_id: Optional[str] = None
self._selected_row: Optional[int] = None
self._editing_sample_id: Optional[str] = None
self._entries: Tuple[SampleEntryViewModel, ...] = ()
+ self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE)
+ self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES)
+ self._tip_size_bytes = context_text(language_manager, TextType.TOOLTIP, ContextElements.SIZE_BYTES)
+ self.sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] = None
self.on_sample_selected: Optional[StringCallback] = None
self.on_sample_edit_requested: Optional[StringCallback] = None
self.on_loop_changed: Optional[Callable[[str, bool], None]] = None
@@ -79,7 +137,7 @@ def __init__(
def create_panel(self, parent: str) -> None:
with self._collapsible_card(
parent,
- self._language_manager["sequencer.instruments.label.instruments_text"],
+ self._label(self._language_manager, SequencerInstrumentsElements.INSTRUMENTS_TEXT),
glyph=self._glyphs.headers.samples,
):
self._create_samples_table()
@@ -105,13 +163,14 @@ def _create_key_handler(self) -> None:
)
def _create_samples_table(self) -> None:
- with dpg.child_window(
- tag=TAG_SEQUENCER_INSTRUMENTS_WINDOW,
- border=False,
- width=-1,
- height=-1,
- ):
- with dpg.table(
+ with (
+ dpg.child_window(
+ tag=TAG_SEQUENCER_INSTRUMENTS_WINDOW,
+ border=False,
+ width=-1,
+ height=-1,
+ ),
+ dpg.table(
tag=TAG_SEQUENCER_INSTRUMENTS_TABLE,
width=-1,
height=-1,
@@ -125,22 +184,32 @@ def _create_samples_table(self) -> None:
freeze_rows=FROZEN_HEADER_ROWS,
row_background=True,
policy=dpg.mvTable_SizingFixedFit,
- ):
- dpg.add_table_column(
- label=self._language_manager["sequencer.instruments.label.column_id"],
- width_fixed=True,
- init_width_or_weight=self._layout.table_cells.instrument.id,
- )
- dpg.add_table_column(
- label=self._language_manager["sequencer.instruments.label.column_name"],
- width_stretch=True,
- init_width_or_weight=self._layout.table_cells.instrument.name,
- )
- dpg.add_table_column(
- label=self._language_manager["sequencer.instruments.label.column_loop"],
- width_fixed=True,
- init_width_or_weight=self._layout.table_cells.instrument.loop,
- )
+ ),
+ ):
+ dpg.add_table_column(
+ label=self._label(
+ self._language_manager,
+ SequencerInstrumentsElements.COLUMN_ID,
+ ),
+ width_fixed=True,
+ init_width_or_weight=self._layout.table_cells.instrument.id,
+ )
+ dpg.add_table_column(
+ label=self._label(
+ self._language_manager,
+ SequencerInstrumentsElements.COLUMN_NAME,
+ ),
+ width_stretch=True,
+ init_width_or_weight=self._layout.table_cells.instrument.name,
+ )
+ dpg.add_table_column(
+ label=self._label(
+ self._language_manager,
+ SequencerInstrumentsElements.COLUMN_LOOP,
+ ),
+ width_fixed=True,
+ init_width_or_weight=self._layout.table_cells.instrument.loop,
+ )
ThemeRegistry.get(TAG_SEQUENCER_INSTRUMENTS_THEME_ROW).bind_to_item(TAG_SEQUENCER_INSTRUMENTS_TABLE)
def update_view(self, view_model: SequencerSamplesViewModel) -> None:
@@ -163,18 +232,36 @@ def _rebuild(self) -> None:
if self._selected_row is None:
self._selected_sample_id = None
- def _build_sample_row(self, position: int, entry: SampleEntryViewModel) -> None:
+ def _build_sample_row(
+ self,
+ position: int,
+ entry: SampleEntryViewModel,
+ ) -> None:
row_id = dpg.add_table_row(parent=TAG_SEQUENCER_INSTRUMENTS_TABLE)
self._build_id_cell(row_id, position, entry)
self._build_name_cell(row_id, position, entry)
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,
- )
+ self._highlight_selected_row(position)
+
+ def _highlight_selected_row(self, position: int) -> None:
+ dpg.highlight_table_row(
+ TAG_SEQUENCER_INSTRUMENTS_TABLE,
+ position,
+ color=self._layout.colors.cell_cursor.rgba,
+ )
+
+ def repaint(self) -> None:
+ """Issues the selected row's tint again so it takes the palette now in place.
+
+ DearPyGui keeps a row highlight on the table rather than on an item, so the colour
+ reaches it only by being pushed again.
+ """
+ if self._selected_row is None or not dpg.does_item_exist(TAG_SEQUENCER_INSTRUMENTS_TABLE):
+ return
+
+ self._highlight_selected_row(self._selected_row)
def _build_id_cell(
self,
@@ -252,7 +339,7 @@ def _build_loop_cell(
def _on_sample_selected(
self,
sender: Sender,
- app_data: bool,
+ _app_data: bool,
user_data: Tuple[int, str],
) -> None:
position, sample_id = user_data
@@ -265,11 +352,7 @@ def _on_sample_selected(
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,
- )
+ self._highlight_selected_row(position)
self.call(self.on_sample_selected, sample_id)
@property
@@ -312,50 +395,65 @@ def deselect(self) -> None:
def _keys_active(self) -> bool:
"""Whether the samples panel owns the next key.
- While a name is being edited the panel keeps the keyboard so Escape can cancel the rename.
- Otherwise it acts only when a sample is selected and no field holds the keyboard; a modal
+ The panel answers only while its tab is in front, since a selection outlives a move to
+ another tab. There, a name being edited keeps the keyboard so Escape can cancel the rename;
+ otherwise the panel acts when a sample is selected and no field holds the keyboard. A modal
dialog claims keys at a higher priority in the router, so the panel needs no modal check.
"""
+ if not self._tab_active():
+ return False
+
if self._editing_sample_id is not None:
return True
return self._selected_sample_id is not None and not self._router.is_field_focused
def _on_key_pressed(self, event: KeyEvent) -> bool:
- """Applies a samples key to the selected sample, reporting whether the panel consumed it."""
+ """Applies a samples key to the selected sample, reporting whether the panel consumed it.
+
+ The scheme says which press each samples action answers to; a press the samples category
+ leaves unnamed goes to the application's global shortcuts.
+ """
+ shortcut_id = self._shortcuts.action(ShortcutCategory.SAMPLES, event)
if self._editing_sample_id is not None:
- if event.key == dpg.mvKey_Escape:
- self._cancel_rename()
- return True
- return False
+ return self._cancel_edit(shortcut_id)
sample_id = self._selected_sample_id
- if sample_id is None:
+ if sample_id is None or shortcut_id is None:
return False
- if Modifier.CTRL in event.modifiers:
- return False
+ if self._move_sample(shortcut_id):
+ return True
- if Modifier.ALT in event.modifiers:
- return self._handle_alt_move(event.key)
+ match shortcut_id:
+ case ShortcutId.SAMPLES_REMOVE_SAMPLE:
+ self.call(self.on_remove_requested, sample_id)
+ case ShortcutId.SAMPLES_RENAME_SAMPLE:
+ self._start_rename(sample_id)
+ case _:
+ return False
- if event.key == dpg.mvKey_Delete:
- self.call(self.on_remove_requested, sample_id)
- return True
+ return True
- if event.key == dpg.mvKey_F2:
- self._start_rename(sample_id)
- return True
+ def _cancel_edit(self, shortcut_id: Optional[ShortcutId]) -> bool:
+ """Drops the name being edited, reporting whether the press was the cancel.
- return False
+ A rename in progress keeps every other key for the input, so typing a name reaches the
+ field rather than the panel.
+ """
+ if shortcut_id is not ShortcutId.SAMPLES_CANCEL_RENAME:
+ return False
+
+ self._cancel_rename()
+ return True
- def _handle_alt_move(self, key: int) -> bool:
- """Moves the selected sample up/down/to-top/to-bottom on Alt + arrow / Home / End.
+ def _move_sample(self, shortcut_id: ShortcutId) -> bool:
+ """Moves the selected sample up, down, to the top or to the bottom of the list.
- Returns whether the key was an Alt move gesture, so a boundary with nowhere to go still
+ Returns whether the action was one of the moves, so a boundary with nowhere to go still
counts as consumed and stays out of the global shortcuts.
"""
- direction = self._alt_move_direction(key)
+ direction = MOVE_DIRECTIONS.get(shortcut_id)
if direction is None or self._selected_sample_id is None or self._selected_row is None:
return False
@@ -365,19 +463,6 @@ def _handle_alt_move(self, key: int) -> bool:
return True
- def _alt_move_direction(self, key: int) -> Optional[MoveDirection]:
- match key:
- case dpg.mvKey_Up:
- return MoveDirection.PREVIOUS
- case dpg.mvKey_Down:
- return MoveDirection.NEXT
- case dpg.mvKey_Home:
- return MoveDirection.FIRST
- case dpg.mvKey_End:
- return MoveDirection.LAST
- case _:
- return None
-
def _start_rename(self, sample_id: str) -> None:
"""Turns the sample's name cell into a focused text input."""
if self._entry_for(sample_id) is None:
@@ -409,23 +494,27 @@ def _cancel_rename(self) -> None:
self._editing_sample_id = None
self._rebuild()
- def _on_rename_enter(self, sender: Sender, app_data: str) -> None:
+ def _on_rename_enter(self, _sender: Sender, _app_data: str) -> None:
self._commit_rename()
- def _on_rename_deactivated(self, sender: Sender, app_data: int) -> None:
+ def _on_rename_deactivated(self, _sender: Sender, _app_data: int) -> None:
self._commit_rename()
def _on_loop_toggled(
self,
- sender: Sender,
+ _sender: Sender,
app_data: bool,
user_data: str,
) -> None:
- self.call(self.on_loop_changed, user_data, app_data)
+ self.call(
+ self.on_loop_changed,
+ user_data,
+ app_data,
+ )
def _on_sample_double_clicked(
self,
- sender: Sender,
+ _sender: Sender,
app_data: List[int],
) -> None:
clicked_item = app_data[1]
@@ -436,7 +525,7 @@ def _on_sample_double_clicked(
def _on_sample_clicked(
self,
- sender: Sender,
+ _sender: Sender,
app_data: Tuple[int, int],
) -> None:
mouse_button, clicked_item = app_data
@@ -458,77 +547,139 @@ def _show_context_menu(self, position: int, sample_id: str) -> None:
if entry is None:
return
+ target = SampleSelection(
+ sample_id=sample_id,
+ position=position,
+ name=entry.name,
+ )
with context_menu():
- header = dpg.add_text(display_sample_label(position, entry.name))
+ header = dpg.add_text(target.label)
FontRegistry.bind_to_item(header, Font.MONO_BOLD)
+ add_detail_items(
+ self._footprint_items(sample_id),
+ color=self._detail_color,
+ tooltip=self._tip_size_bytes,
+ )
dpg.add_separator()
add_play_menu_item(
- self._language_manager["global.context.label.play"],
+ context_label(self._language_manager, ContextElements.PLAY),
lambda: self.call(
self.on_play_requested,
sample_id,
),
)
- dpg.add_menu_item(
- label=self._language_manager["sequencer.instruments.label.context_edit"],
- callback=lambda: self.call(self.on_sample_edit_requested, sample_id),
- )
- dpg.add_menu_item(
- label=self._language_manager["sequencer.instruments.label.context_rename"],
- callback=lambda: self._start_rename(sample_id),
- )
- dpg.add_menu_item(
- label=self._language_manager["sequencer.instruments.label.context_duplicate"],
- callback=lambda: self.call(self.on_duplicate_requested, sample_id),
- )
dpg.add_separator()
- dpg.add_menu_item(
- label=self._language_manager["sequencer.instruments.label.context_remove"],
- callback=lambda: self.call(self.on_remove_requested, sample_id),
- )
- dpg.add_separator()
- count = len(self._entries)
- self._add_move_item(
- self._language_manager["sequencer.instruments.label.context_move_up"],
- sample_id,
- position,
- count,
- MoveDirection.PREVIOUS,
- )
- self._add_move_item(
- self._language_manager["sequencer.instruments.label.context_move_down"],
- sample_id,
- position,
- count,
- MoveDirection.NEXT,
- )
- self._add_move_item(
- self._language_manager["sequencer.instruments.label.context_move_top"],
- sample_id,
- position,
- count,
- MoveDirection.FIRST,
- )
- self._add_move_item(
- self._language_manager["sequencer.instruments.label.context_move_bottom"],
- sample_id,
- position,
- count,
- MoveDirection.LAST,
- )
+ self.add_action_items(target)
+
+ def _footprint_items(self, sample_id: str) -> List[Tuple[str, str]]:
+ """The byte figures the menu prints for a sample: its total, then each channel that plays.
+
+ The figures are asked for as the menu opens, so they name what the sample occupies at the
+ moment a reader looks. A channel standing by is written by no export, so it costs nothing
+ and the menu names the channels that do.
+ """
+ footprint = self.query(self.sample_footprint, sample_id, default=None)
+ if footprint is None:
+ return []
+
+ items = [(self._lbl_sample_size, self._format_size(footprint.total_bytes))]
+ for generator_name in GeneratorName.items():
+ instrument_bytes = footprint.bytes_for(generator_name)
+ if instrument_bytes is not None:
+ items.append(
+ (
+ channel_label(self._language_manager, generator_name),
+ self._format_size(instrument_bytes),
+ )
+ )
+
+ return items
+
+ def _format_size(self, byte_count: int) -> str:
+ return self._tpl_size_bytes.format(bytes=byte_count)
+
+ def owns_edit_actions(self) -> bool:
+ """Whether the Edit menu states this panel's actions, which it does while it holds a sample.
+
+ The menu offers what the next press would reach, so the key scope decides it, and the
+ selection those keys act on is the one the actions are built for.
+ """
+ return self._keys_active() and self.selection is not None
+
+ def build_edit_actions(self) -> None:
+ """Builds the panel's whole action set for the sample the selection holds."""
+ selection = self.selection
+ if selection is not None:
+ self.add_action_items(selection)
+
+ def add_action_items(self, target: SampleSelection) -> None:
+ """Builds every action a sample offers, in the order each menu prints them.
+
+ The panel states its actions once, and whoever asks for them decides where they are shown:
+ the row menu asks for the sample a pointer landed on, and the menu bar asks for the one the
+ selection holds. An action added here reaches both, printing the key it answers to.
+ """
+ dpg.add_menu_item(
+ label=self._label(
+ self._language_manager,
+ SequencerInstrumentsElements.CONTEXT_EDIT,
+ ),
+ callback=lambda: self.call(self.on_sample_edit_requested, target.sample_id),
+ )
+ dpg.add_menu_item(
+ label=self._label(
+ self._language_manager,
+ SequencerInstrumentsElements.CONTEXT_RENAME,
+ ),
+ shortcut=self._shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE),
+ callback=lambda: self._start_rename(target.sample_id),
+ )
+ dpg.add_menu_item(
+ label=self._label(
+ self._language_manager,
+ SequencerInstrumentsElements.CONTEXT_DUPLICATE,
+ ),
+ callback=lambda: self.call(self.on_duplicate_requested, target.sample_id),
+ )
+ dpg.add_separator()
+ dpg.add_menu_item(
+ label=self._label(
+ self._language_manager,
+ SequencerInstrumentsElements.CONTEXT_REMOVE,
+ ),
+ shortcut=self._shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE),
+ callback=lambda: self.call(self.on_remove_requested, target.sample_id),
+ )
+ dpg.add_separator()
+ for move in SAMPLE_MOVES:
+ self._add_move_item(move, target)
def _add_move_item(
self,
- label: str,
- sample_id: str,
- position: int,
- count: int,
- direction: MoveDirection,
+ move: SampleMove,
+ target: SampleSelection,
) -> None:
- """Add a move item, greyed out (disabled) when the move would have no effect."""
- target = direction.target(position, count)
+ """Builds one move item, offered while the move carries the sample somewhere new."""
+ position = move.direction.target(target.position, len(self._entries))
dpg.add_menu_item(
- label=label,
- enabled=target is not None,
- callback=lambda: self.call(self.on_move_requested, sample_id, target),
+ label=self._label(self._language_manager, move.element),
+ shortcut=self._shortcuts.display(move.shortcut),
+ enabled=position is not None,
+ callback=lambda: self.call(
+ self.on_move_requested,
+ target.sample_id,
+ position,
+ ),
)
+
+ @staticmethod
+ def _label(
+ language_manager: LanguageManager,
+ element: SequencerInstrumentsElements,
+ ) -> str:
+ return language_manager[
+ Page.SEQUENCER,
+ Panel.INSTRUMENTS,
+ TextType.LABEL,
+ element,
+ ]
diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py
new file mode 100644
index 00000000..8427560d
--- /dev/null
+++ b/src/sampletones_application/ui/panels/sequencer/tracker.py
@@ -0,0 +1,1988 @@
+from typing import Callable, Dict, Final, FrozenSet, Optional, Set, Tuple
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.elements.sequencer import (
+ SequencerTrackerElements,
+)
+from sampletones_application.categories.hierarchy import Page, Panel, TextType
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.tabs.sequencer import SequencerLayout
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import (
+ SUF_HANDLER_DRAG,
+ SUF_HANDLER_HEADER,
+ SUF_HANDLER_REGISTRY,
+)
+from sampletones_application.tags.sequencer import (
+ TAG_SEQUENCER_THEME_TABLE_PATTERN,
+ TAG_SEQUENCER_TRACKER_GROUP,
+ TAG_SEQUENCER_TRACKER_PANEL,
+ TAG_SEQUENCER_TRACKER_TABLE,
+ TAG_SEQUENCER_TRACKER_WINDOW,
+)
+from sampletones_application.ui.elements.context_menu import (
+ add_play_menu_item,
+ context_menu,
+)
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.elements.panel import GUIPanel
+from sampletones_application.ui.elements.table.caret import CaretOverlay
+from sampletones_application.ui.elements.table.cells import EditableCells
+from sampletones_application.ui.elements.table.selection import TableSelection
+from sampletones_application.ui.panels.sequencer import display as tracker_display
+from sampletones_application.ui.panels.sequencer.channels import (
+ ChannelMenuLabels,
+ ChannelSwitch,
+ channel_tooltip,
+)
+from sampletones_application.ui.panels.sequencer.columns import (
+ DIVIDER_TABLE_COLUMN,
+ HEADER_TABLE_ROW,
+ HEADER_TABLE_ROWS,
+ SAMPLE_TABLE_COLUMN,
+ TRACKER_TABLE_COLUMNS,
+ channel_color,
+ tracker_table_column,
+ tracker_table_row,
+)
+from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues
+from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures
+from sampletones_application.ui.panels.sequencer.grid.scroll.axis import VerticalScroll
+from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand
+from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel
+from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import (
+ BlockShortcuts,
+ ClipboardItems,
+)
+from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface
+from sampletones_application.ui.panels.sequencer.input.edit import (
+ ClearAction,
+ EditAction,
+)
+from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background
+from sampletones_application.ui.themes.inline import (
+ create_header_selectable_theme,
+ create_label_selectable_theme,
+ create_selectable_text_theme,
+)
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.utils.gui.dpg import dpg_delete_children
+from sampletones_application.utils.gui.frame import FrameCallbackManager
+from sampletones_application.utils.gui.keyboard import (
+ PRIORITY_PANEL,
+ ActivePredicate,
+ KeyEvent,
+ KeyRouter,
+)
+from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS, SIGN_KEYS
+from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.utils.gui.tooltip import show_tooltip
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.faded import FadedColor
+from sampletones_application.utils.palette.colors.layered import LayeredColor
+from sampletones_application.view_model.sequencer.channels import (
+ SequencerChannelsViewModel,
+)
+from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion
+from sampletones_application.view_model.sequencer.samples import (
+ SequencerSamplesViewModel,
+)
+from sampletones_application.view_model.sequencer.settings import (
+ SequencerSettingsViewModel,
+)
+from sampletones_application.view_model.sequencer.slot import (
+ SLOT_COUNT,
+ TrackerSlot,
+ slot_from_flat,
+)
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_application.view_model.sequencer.tracker import (
+ SequencerRowViewModel,
+ SequencerTrackerViewModel,
+)
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.project.song_position import SongPosition
+from sampletones_core.utils.display import NOTE_OFF, display_id
+from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP
+from sampletones_shared.types.application import ColorRGBA, Sender
+from sampletones_shared.types.callback import VoidCallback
+
+OnClearRowCallback = Callable[[int, Optional[GeneratorName]], None]
+OnClearSubcolumnCallback = Callable[[int, Optional[GeneratorName], SubColumn], None]
+OnSetRowCallback = Callable[[int, Optional[GeneratorName], Optional[str], Optional[int], Optional[int]], None]
+OnSetNoteOffCallback = Callable[[int, Optional[GeneratorName]], None]
+OnCellSelectedCallback = VoidCallback
+OnPlayFromRowCallback = Callable[[int], None]
+OnPlayFromFrameCallback = VoidCallback
+OnAdjustCallback = Callable[[TrackerRegion, int], None]
+OnChannelMuteToggledCallback = Callable[[GeneratorName], None]
+OnChannelSoloedCallback = Callable[[GeneratorName], None]
+OnBlockRegionCallback = Callable[[TrackerRegion], None]
+OnPasteBlockCallback = Callable[[TrackerCell], None]
+CanPasteBlockQuery = Callable[[], bool]
+TrackerEditSurface = GridEditSurface[TrackerCursor, TrackerRegion, TrackerCell, TrackerTarget]
+
+
+VOLUME_FINE_STEP: Final[int] = 1
+VOLUME_COARSE_STEP: Final[int] = (MAX_VOLUME + 1) // 4
+PLAYHEAD_PAINT_FRAMES: Final[int] = 1
+
+AdjustAction = Tuple[SequencerTrackerElements, ShortcutId, int]
+AdjustMenuCallback = Callable[[Sender, None, Tuple[TrackerRegion, int]], None]
+
+TRANSPOSE_ACTIONS: Final[Tuple[AdjustAction, ...]] = (
+ (
+ SequencerTrackerElements.CONTEXT_TRANSPOSE_UP,
+ ShortcutId.TRACKER_TRANSPOSE_UP,
+ SEMITONE_STEP,
+ ),
+ (
+ SequencerTrackerElements.CONTEXT_TRANSPOSE_DOWN,
+ ShortcutId.TRACKER_TRANSPOSE_DOWN,
+ -SEMITONE_STEP,
+ ),
+ (
+ SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_UP,
+ ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP,
+ OCTAVE_SEMITONES,
+ ),
+ (
+ SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_DOWN,
+ ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN,
+ -OCTAVE_SEMITONES,
+ ),
+)
+
+VOLUME_ACTIONS: Final[Tuple[AdjustAction, ...]] = (
+ (
+ SequencerTrackerElements.CONTEXT_VOLUME_UP,
+ ShortcutId.TRACKER_VOLUME_UP,
+ VOLUME_FINE_STEP,
+ ),
+ (
+ SequencerTrackerElements.CONTEXT_VOLUME_DOWN,
+ ShortcutId.TRACKER_VOLUME_DOWN,
+ -VOLUME_FINE_STEP,
+ ),
+ (
+ SequencerTrackerElements.CONTEXT_VOLUME_UP_COARSE,
+ ShortcutId.TRACKER_VOLUME_UP_COARSE,
+ VOLUME_COARSE_STEP,
+ ),
+ (
+ SequencerTrackerElements.CONTEXT_VOLUME_DOWN_COARSE,
+ ShortcutId.TRACKER_VOLUME_DOWN_COARSE,
+ -VOLUME_COARSE_STEP,
+ ),
+)
+
+
+def _steps(actions: Tuple[AdjustAction, ...]) -> Dict[ShortcutId, int]:
+ return {shortcut_id: delta for _, shortcut_id, delta in actions}
+
+
+TRANSPOSE_STEPS: Final[Dict[ShortcutId, int]] = _steps(TRANSPOSE_ACTIONS)
+VOLUME_STEPS: Final[Dict[ShortcutId, int]] = _steps(VOLUME_ACTIONS)
+
+
+class GUISequencerTrackerPanel(GUIPanel):
+ def __init__(
+ self,
+ initial_settings: SequencerSettingsViewModel,
+ *,
+ layout: SequencerLayout,
+ language_manager: LanguageManager,
+ key_router: KeyRouter,
+ tab_active: ActivePredicate,
+ shortcut_source: ShortcutSource,
+ initial_collapsed: bool = False,
+ ) -> None:
+ self._layout = layout
+ self._settings = initial_settings
+ self._language_manager = language_manager
+ self._router = key_router
+ self._tab_active = tab_active
+ self._shortcuts = shortcut_source
+
+ widths = layout.tracker.subcolumn_widths
+ self._subcolumn_widths: Dict[SubColumn, int] = {
+ SubColumn.INSTRUMENT: widths.instrument,
+ SubColumn.TRANSPOSE: widths.transpose,
+ SubColumn.VOLUME: widths.volume,
+ }
+
+ self._item_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_PANEL, SUF_HANDLER_REGISTRY)
+ self._cell_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_REGISTRY)
+ self._header_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_HEADER)
+ self._drag_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_DRAG)
+
+ self._rows: Dict[Optional[int], Sender] = {}
+ self._header_columns: Dict[Sender, Optional[GeneratorName]] = {}
+ self._editable_cells: EditableCells[CellKey] = EditableCells()
+ self._current_row_count: int = 0
+ self._highlighted_row: Optional[int] = None
+ self._displayed_frame: Optional[int] = None
+ self._playing_frame: Optional[int] = None
+ self._playing_row: Optional[int] = None
+ self._painted_row: Optional[int] = None
+ self._follows_playing_row: bool = False
+ self._input_state: TrackerInputState = TrackerInputState()
+ self._selection: TableSelection[CellKey] = TableSelection(
+ cells=self._editable_cells,
+ cell_at=self._cell_at,
+ covered=self._selected_cells,
+ )
+ self._travel: DragTravel = DragTravel(
+ axis=VerticalScroll(table=TAG_SEQUENCER_TRACKER_TABLE),
+ band=self._travel_band,
+ elapsed=dpg.get_delta_time,
+ )
+ self._subcolumn_themes: Dict[SubColumn, int] = {}
+ self._muted_subcolumn_themes: Dict[SubColumn, int] = {}
+ self._row_number_theme: int = 0
+ self._header_theme: int = 0
+ self._muted_header_theme: int = 0
+ self._column_label_theme: int = 0
+ self._current_samples: Optional[SequencerSamplesViewModel] = None
+ self._current_channels: Optional[SequencerChannelsViewModel] = None
+
+ self.on_clear_row: Optional[OnClearRowCallback] = None
+ self.on_clear_subcolumn: Optional[OnClearSubcolumnCallback] = None
+ self.on_set_row: Optional[OnSetRowCallback] = None
+ self.on_set_note_off: Optional[OnSetNoteOffCallback] = None
+ self.on_cell_selected: Optional[OnCellSelectedCallback] = None
+ self.on_play_from_row: Optional[OnPlayFromRowCallback] = None
+ self.on_play_from_frame: Optional[OnPlayFromFrameCallback] = None
+ self.on_adjust_transpose: Optional[OnAdjustCallback] = None
+ self.on_adjust_volume: Optional[OnAdjustCallback] = None
+ self.on_copy_block: Optional[OnBlockRegionCallback] = None
+ self.on_cut_block: Optional[OnBlockRegionCallback] = None
+ self.on_delete_block: Optional[OnBlockRegionCallback] = None
+ self.on_paste_block: Optional[OnPasteBlockCallback] = None
+ self.can_paste_block: Optional[CanPasteBlockQuery] = None
+ self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None
+ self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None
+ self.on_channels_toggled: Optional[VoidCallback] = None
+ self.on_channels_muted: Optional[VoidCallback] = None
+ self.on_channels_unmuted: Optional[VoidCallback] = None
+
+ self._blocks: BlockGestures[TrackerRegion, TrackerCell] = BlockGestures(grid=self)
+ self._surface: TrackerEditSurface = GridEditSurface.build(
+ grid=self,
+ blocks=self._blocks,
+ target=TrackerTarget,
+ shortcuts=shortcut_source,
+ block_shortcuts=BlockShortcuts(
+ copy=ShortcutId.TRACKER_COPY_BLOCK,
+ cut=ShortcutId.TRACKER_CUT_BLOCK,
+ paste=ShortcutId.TRACKER_PASTE_BLOCK,
+ ),
+ labels=ClipboardItems.labels(language_manager),
+ )
+ self.pattern_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_PATTERN)
+
+ self._lbl_tracker = self._label(
+ language_manager,
+ SequencerTrackerElements.TRACKER_TEXT,
+ )
+ self._load_column_labels(language_manager)
+ self._load_context_labels(language_manager)
+ self._load_header_tooltips(language_manager)
+ self._create_channel_switch(language_manager)
+
+ super().__init__(
+ tag=TAG_SEQUENCER_TRACKER_PANEL,
+ height=-1,
+ )
+ self._enable_vertical_collapse(initial_collapsed=initial_collapsed)
+
+ def _load_column_labels(self, language_manager: LanguageManager) -> None:
+ """Reads the name each column carries, which its header label and its menu title show."""
+ self._lbl_col_row = self._label(language_manager, SequencerTrackerElements.COLUMN_ROW)
+ self._column_labels: Dict[Optional[GeneratorName], str] = {
+ None: self._label(language_manager, SequencerTrackerElements.COLUMN_SAMPLE),
+ GeneratorName.PULSE1: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_1),
+ GeneratorName.PULSE2: self._label(language_manager, SequencerTrackerElements.COLUMN_PULSE_2),
+ GeneratorName.TRIANGLE: self._label(language_manager, SequencerTrackerElements.COLUMN_TRIANGLE),
+ GeneratorName.NOISE: self._label(language_manager, SequencerTrackerElements.COLUMN_NOISE),
+ }
+
+ @staticmethod
+ def _label(
+ language_manager: LanguageManager,
+ element: SequencerTrackerElements,
+ ) -> str:
+ return language_manager[
+ Page.SEQUENCER,
+ Panel.TRACKER,
+ TextType.LABEL,
+ element,
+ ]
+
+ def _load_context_labels(self, language_manager: LanguageManager) -> None:
+ def label(element: SequencerTrackerElements) -> str:
+ return self._label(language_manager, element)
+
+ self._lbl_context_play = label(SequencerTrackerElements.CONTEXT_PLAY)
+ self._lbl_context_play_from_frame = label(SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME)
+ self._lbl_context_select_all = label(SequencerTrackerElements.CONTEXT_SELECT_ALL)
+ self._lbl_context_select_column = label(SequencerTrackerElements.CONTEXT_SELECT_COLUMN)
+ self._lbl_context_select_subcolumn = label(SequencerTrackerElements.CONTEXT_SELECT_SUBCOLUMN)
+ self._lbl_context_note_off = label(SequencerTrackerElements.CONTEXT_NOTE_OFF)
+ self._lbl_context_set_instrument = label(SequencerTrackerElements.CONTEXT_SET_INSTRUMENT)
+ self._lbl_context_no_samples = label(SequencerTrackerElements.CONTEXT_NO_SAMPLES)
+ self._lbl_context_clear_subcolumn = label(SequencerTrackerElements.CONTEXT_CLEAR_SUBCOLUMN)
+ self._lbl_context_clear_cell = label(SequencerTrackerElements.CONTEXT_CLEAR_CELL)
+ self._lbl_context_clear_row = label(SequencerTrackerElements.CONTEXT_CLEAR_ROW)
+ self._lbl_adjust: Dict[SequencerTrackerElements, str] = {
+ element: label(element) for element, _, _ in (*TRANSPOSE_ACTIONS, *VOLUME_ACTIONS)
+ }
+
+ def _load_header_tooltips(self, language_manager: LanguageManager) -> None:
+ """Reads the header tooltips, which name the click gestures the labels carry."""
+
+ def tooltip(element: SequencerTrackerElements) -> str:
+ return language_manager[Page.SEQUENCER, Panel.TRACKER, TextType.TOOLTIP, element]
+
+ self._tooltip_header_channel = channel_tooltip(tooltip(SequencerTrackerElements.HEADER_CHANNEL))
+ self._tooltip_header_sample = tooltip(SequencerTrackerElements.HEADER_SAMPLE)
+
+ def _create_channel_switch(self, language_manager: LanguageManager) -> None:
+ """Builds the switch a column header's click and menu act through.
+
+ The hooks are read at call time, so the switch is built here while they are still unset and
+ the coordinator wires them once the panel exists.
+ """
+ labels = ChannelMenuLabels(
+ mute=self._label(language_manager, SequencerTrackerElements.CONTEXT_MUTE),
+ unmute=self._label(language_manager, SequencerTrackerElements.CONTEXT_UNMUTE),
+ solo=self._label(language_manager, SequencerTrackerElements.CONTEXT_SOLO),
+ unsolo=self._label(language_manager, SequencerTrackerElements.CONTEXT_UNSOLO),
+ mute_all=self._label(language_manager, SequencerTrackerElements.CONTEXT_MUTE_ALL),
+ unmute_all=self._label(language_manager, SequencerTrackerElements.CONTEXT_UNMUTE_ALL),
+ )
+ self._channel_switch = ChannelSwitch(
+ labels=labels,
+ on_mute_toggled=lambda generator: self.call(self.on_channel_mute_toggled, generator),
+ on_soloed=lambda generator: self.call(self.on_channel_soloed, generator),
+ on_toggled=lambda: self.call(self.on_channels_toggled),
+ on_muted=lambda: self.call(self.on_channels_muted),
+ on_unmuted=lambda: self.call(self.on_channels_unmuted),
+ )
+
+ def create_panel(self, parent: str) -> None:
+ self._setup_handlers()
+ self._create_themes()
+ self._create_tracker_view(parent)
+
+ def _setup_handlers(self) -> None:
+ with dpg.item_handler_registry(tag=self._item_handler_tag):
+ dpg.add_item_hover_handler(
+ parent=self._item_handler_tag,
+ callback=self._on_row_hovered,
+ )
+
+ with dpg.item_handler_registry(tag=self._cell_handler_tag):
+ dpg.add_item_clicked_handler(callback=self._on_cell_right_clicked)
+ dpg.add_item_active_handler(callback=self._on_cell_held)
+
+ with dpg.item_handler_registry(tag=self._header_handler_tag):
+ dpg.add_item_clicked_handler(callback=self._on_header_right_clicked)
+
+ with dpg.handler_registry(tag=self._drag_handler_tag):
+ dpg.add_mouse_click_handler(
+ button=dpg.mvMouseButton_Left,
+ callback=self._on_pointer_pressed,
+ )
+
+ self._router.register(
+ self._on_key_pressed,
+ priority=PRIORITY_PANEL,
+ active=self._keys_active,
+ )
+
+ def _create_themes(self) -> None:
+ self._create_subcolumn_themes()
+ self._create_header_themes()
+ self._row_number_theme = create_selectable_text_theme(self._layout.colors.text.row)
+
+ def _create_subcolumn_themes(self) -> None:
+ """Builds each subcolumn's text theme in its full and its dimmed colour.
+
+ The dimmed variant keeps the subcolumn's own hue at reduced alpha, so a silenced
+ channel's values stay readable and editable while the others are worked on.
+ """
+ subcolumn_colors = self._layout.colors.text
+ theme_colors = {
+ SubColumn.INSTRUMENT: subcolumn_colors.instrument,
+ SubColumn.TRANSPOSE: subcolumn_colors.transpose,
+ SubColumn.VOLUME: subcolumn_colors.volume,
+ }
+ fraction = self._layout.tracker.muted_text_fraction
+ for subcolumn, color in theme_colors.items():
+ self._subcolumn_themes[subcolumn] = create_selectable_text_theme(color)
+ self._muted_subcolumn_themes[subcolumn] = create_selectable_text_theme(
+ FadedColor(
+ color=color,
+ fraction=fraction,
+ ),
+ )
+
+ def _create_header_themes(self) -> None:
+ """Builds the two shades a channel's header label takes: audible and silenced.
+
+ Both carry the header's own hover and press washes, so a label reads as the switch it is
+ while its text colour reports whether the channel sounds.
+ """
+ header = self._layout.colors.header
+ self._header_theme = create_header_selectable_theme(
+ self._layout.colors.label,
+ header.hovered,
+ header.active,
+ )
+ self._muted_header_theme = create_header_selectable_theme(
+ self._layout.colors.muted.text,
+ header.hovered,
+ header.active,
+ )
+ self._column_label_theme = create_label_selectable_theme(self._layout.colors.label)
+
+ def _create_tracker_view(self, parent: str) -> None:
+ """Builds the tracker card and the empty table its rows are filled into.
+
+ The column labels are carried by a row of widgets (see :meth:`_build_header_row`) that
+ ``freeze_rows`` pins at the top, which makes each channel's label a click target for
+ muting. ``no_clip`` lets a label wider than its column draw across the boundary the way
+ a table header does, so the header keeps the size and position it has always had.
+
+ The pattern stands on one even ground: the tracker's own theme
+ (``sequencer.theme.table_pattern``) gives ``TableRowBg`` and ``TableRowBgAlt`` the same
+ shade, leaving the row background free to carry the beat and bar grouping that tells a
+ tracker's rows apart (see :meth:`_row_background`).
+ """
+ with self._collapsible_card(
+ parent,
+ self._lbl_tracker,
+ glyph=self._glyphs.headers.tracker,
+ ):
+ dpg.add_group(tag=TAG_SEQUENCER_TRACKER_GROUP)
+ with (
+ dpg.child_window(
+ tag=TAG_SEQUENCER_TRACKER_WINDOW,
+ parent=TAG_SEQUENCER_TRACKER_GROUP,
+ border=False,
+ width=0,
+ height=-1,
+ ),
+ dpg.table(
+ tag=TAG_SEQUENCER_TRACKER_TABLE,
+ width=0,
+ header_row=False,
+ resizable=False,
+ borders_innerH=False,
+ borders_innerV=True,
+ borders_outerH=True,
+ borders_outerV=True,
+ scrollX=False,
+ scrollY=True,
+ freeze_rows=HEADER_TABLE_ROWS,
+ row_background=True,
+ policy=dpg.mvTable_SizingFixedFit,
+ ),
+ ):
+ FontRegistry.bind_to_item(dpg.last_item(), Font.MONO_BOLD)
+ dpg.add_table_column(width_stretch=True)
+ dpg.add_table_column(
+ width_fixed=True,
+ init_width_or_weight=self._layout.table_cells.row,
+ no_clip=True,
+ )
+ dpg.add_table_column(
+ width_fixed=True,
+ init_width_or_weight=self._layout.table_cells.sample,
+ no_clip=True,
+ )
+ dpg.add_table_column(
+ width_fixed=True,
+ init_width_or_weight=self._layout.table_cells.divider,
+ )
+ for _ in GeneratorName.items():
+ dpg.add_table_column(
+ width_fixed=True,
+ init_width_or_weight=self._layout.table_cells.generator,
+ no_clip=True,
+ )
+ dpg.add_table_column(width_stretch=True)
+
+ self.pattern_theme.bind_to_item(TAG_SEQUENCER_TRACKER_TABLE)
+
+ def update_tracker(self, view_model: SequencerTrackerViewModel) -> None:
+ """Reconciles the tracker body with the visible order frame.
+
+ The grid is only torn down and rebuilt when the row count changes; for the
+ common in-place edit the changed cell labels are reconfigured one by one.
+ Reusing the existing widgets preserves scroll position, the hover row, and
+ the edit cursor that a full rebuild would otherwise discard.
+ """
+ cell_values = self._compute_cell_values(view_model)
+ self._show_frame(view_model.frame_index)
+ if len(view_model.rows) != self._current_row_count:
+ self._rebuild_table(view_model, cell_values)
+ else:
+ self._editable_cells.reconcile(cell_values, self._render_cell)
+
+ def _show_frame(self, frame_index: int) -> None:
+ """Records the order frame the grid stands on, and settles the playhead's mark for it.
+
+ The mark reads as the sounding row of the pattern on screen, so it belongs to the frame the
+ playhead sounds: a frame arriving at the grid takes the mark while playback stands on it,
+ and hands it back as the reader moves on to another frame.
+ """
+ if frame_index == self._displayed_frame:
+ return
+
+ self._displayed_frame = frame_index
+ self._paint_playhead()
+
+ def _rebuild_table(
+ self,
+ view_model: SequencerTrackerViewModel,
+ cell_values: CellValues,
+ ) -> None:
+ """Replaces the table body, and re-reveals the sounding row once the new body has laid out.
+
+ A table repopulated this frame reports the scroll extent of the body it replaced, so the
+ reveal is repeated a frame later, when DearPyGui has measured the rows now in it.
+
+ The frame the grid stands on has a row count of its own, so a selection is taken down to
+ its cursor: the cells it covered belong to the body being replaced.
+ """
+ dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1)
+ self._input_state = self._input_state.collapse()
+ self._selection.reset()
+ self._travel.rest()
+ self._editable_cells.reset(cell_values)
+ self._build_table(view_model)
+ self.repaint()
+ FrameCallbackManager.set_frame_callback(self._reveal_playing_row)
+
+ def repaint(self) -> None:
+ """Issues every tint the table holds as its own state.
+
+ DearPyGui keeps a row, column or cell highlight on the table rather than on an item,
+ so a colour reaches it only by being pushed again. Gathering the pushes here gives
+ the palette one call to make and keeps a rebuilt table and a recoloured one identical.
+ """
+ if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE):
+ return
+
+ self._highlight_sample_column()
+ self._highlight_header_row()
+ self._apply_channel_cues()
+ self._apply_row_backgrounds()
+ self._update_cursor()
+
+ def update_settings(self, view_model: SequencerSettingsViewModel) -> None:
+ """Takes the metre the project states, retinting the rows its highlights now open."""
+ self._settings = view_model
+ self._apply_row_backgrounds()
+
+ def _row_background(self, row_index: int) -> Optional[BaseColor]:
+ """The colour a pattern row's background carries under the marks standing on it now."""
+ cursor = self._input_state.cursor
+ return row_background(
+ row_index,
+ self._settings,
+ self._layout.colors,
+ RowCues(
+ cursor=cursor.row if cursor is not None else None,
+ playing=self._painted_row,
+ ),
+ )
+
+ def _draw_row(
+ self,
+ row_index: int,
+ color: Optional[BaseColor],
+ ) -> None:
+ """Gives one pattern row the background colour it resolved to.
+
+ Position updates arrive on the callback-queue worker thread, so the table may be shorter
+ than the row asked for if the main thread shrank it (a rows-per-pattern change) in between;
+ checking the live row count keeps a stale index from reaching DearPyGui.
+ """
+ if not 0 <= row_index < self._live_row_count():
+ return
+
+ table_row = tracker_table_row(row_index)
+ if color is None:
+ dpg.unhighlight_table_row(
+ TAG_SEQUENCER_TRACKER_TABLE,
+ table_row,
+ )
+ else:
+ dpg.highlight_table_row(
+ TAG_SEQUENCER_TRACKER_TABLE,
+ table_row,
+ color=color.rgba,
+ )
+
+ def _paint_row(self, row_index: int) -> None:
+ """Draws a row in the colour its group and the marks on it resolve to."""
+ self._draw_row(row_index, self._row_background(row_index))
+
+ def _paint_hovered_row(self, row_index: int) -> None:
+ """Draws a row with the hover shade over the background it already carries."""
+ background = self._row_background(row_index)
+ highlight = self._layout.colors.pattern_highlight
+ self._draw_row(
+ row_index,
+ highlight if background is None else LayeredColor(base=background, overlay=highlight),
+ )
+
+ def _apply_row_backgrounds(self) -> None:
+ """Draws every live pattern row, which is how the beat and bar grouping reaches the table."""
+ for row_index in range(self._live_row_count()):
+ self._paint_row(row_index)
+
+ def _render_cell(self, key: CellKey) -> str:
+ row, generator, subcolumn = key
+ return tracker_display.subcolumn_label(
+ row,
+ generator,
+ subcolumn,
+ cursor=self._input_state.cursor,
+ pending=self._input_state.pending,
+ cell_values=self._editable_cells.values,
+ )
+
+ def _highlight_sample_column(self) -> None:
+ """Tints the sample column and the rule that separates it from the channels.
+
+ These column highlights are static decoration, distinct from the cursor's
+ cell/row highlight; reapplying them after each rebuild keeps them in place
+ once the rows are replaced.
+ """
+ dpg.highlight_table_column(
+ TAG_SEQUENCER_TRACKER_TABLE,
+ SAMPLE_TABLE_COLUMN,
+ self._layout.colors.sample.column.rgba,
+ )
+ dpg.highlight_table_column(
+ TAG_SEQUENCER_TRACKER_TABLE,
+ DIVIDER_TABLE_COLUMN,
+ self._layout.colors.sample.divider.rgba,
+ )
+
+ def _highlight_header_row(self) -> None:
+ """Gives the widget header row the background a table header carries.
+
+ The shade is laid cell by cell so it covers the sample and channel column washes,
+ which DearPyGui draws over a row highlight; the header then reads as one band with
+ the column tints beginning below it.
+ """
+ for column in range(TRACKER_TABLE_COLUMNS):
+ dpg.highlight_table_cell(
+ TAG_SEQUENCER_TRACKER_TABLE,
+ HEADER_TABLE_ROW,
+ column,
+ color=self._layout.colors.header.background.rgba,
+ )
+
+ def _tint_channel_columns(self) -> None:
+ """Washes each channel's column with a faint tint of its identity colour.
+
+ Reapplied after each rebuild alongside the sample column so the tint survives
+ row replacement, giving the tracker the same per-channel identity the order
+ table carries in its row labels. A silenced channel trades that identity for a
+ neutral dark shade, so its column recedes as a whole.
+ """
+ for generator in GeneratorName.items():
+ dpg.highlight_table_column(
+ TAG_SEQUENCER_TRACKER_TABLE,
+ tracker_table_column(generator),
+ self._channel_column_tint(generator),
+ )
+
+ def _channel_column_tint(self, generator: GeneratorName) -> ColorRGBA:
+ if self._is_muted(generator):
+ return self._layout.colors.muted.background.rgba
+
+ channel = channel_color(self._layout.colors.channels, generator)
+ return FadedColor(
+ color=channel,
+ fraction=self._layout.tracker.channel_column_tint,
+ ).rgba
+
+ def _compute_cell_values(
+ self,
+ view_model: SequencerTrackerViewModel,
+ ) -> CellValues:
+ cell_values: CellValues = {}
+ for row in view_model.rows:
+ cell_values[(row.index, None, SubColumn.INSTRUMENT)] = row.sample_instrument
+ cell_values[(row.index, None, SubColumn.TRANSPOSE)] = row.sample_transpose
+ cell_values[(row.index, None, SubColumn.VOLUME)] = row.sample_volume
+ for generator in GeneratorName.items():
+ cell = row.cells[generator]
+ for subcolumn in SubColumn:
+ cell_values[
+ (
+ row.index,
+ generator,
+ subcolumn,
+ )
+ ] = tracker_display.cell_display(
+ cell,
+ subcolumn,
+ )
+
+ return cell_values
+
+ def _build_table(self, view_model: SequencerTrackerViewModel) -> None:
+ self._rows = {}
+ self._current_row_count = len(view_model.rows)
+ self._build_header_row()
+ for row in view_model.rows:
+ self._build_table_row(row)
+
+ def _build_header_row(self) -> None:
+ """Builds the header as the table's first row, with each channel label a click target.
+
+ A rebuild replaces every row of the table, so the header is raised here, ahead of the
+ pattern rows, and lands on the row ``freeze_rows`` pins in place. The cells are
+ positional like a pattern row's, so the labels line up with the columns they name.
+ """
+ self._header_columns = {}
+ row_id = dpg.add_table_row(parent=TAG_SEQUENCER_TRACKER_TABLE)
+ self._add_empty_cell(row_id)
+ self._add_header_label_cell(row_id)
+ self._add_header_selectable(row_id, None)
+ self._add_empty_cell(row_id)
+ for generator in GeneratorName.items():
+ self._add_header_selectable(row_id, generator)
+
+ def _add_header_label_cell(self, row_id: Sender) -> None:
+ """Places the row-number column's label, which names a column the user reads only.
+
+ It is laid out as a selectable like the labels beside it, so it takes the header's
+ height and sits on their line; its own theme leaves it reading as text.
+ """
+ label_cell = dpg.add_table_cell(parent=row_id)
+ label = dpg.add_selectable(
+ parent=label_cell,
+ label=self._lbl_col_row,
+ height=self._layout.tracker.header_height,
+ )
+ dpg.bind_item_theme(label, self._column_label_theme)
+
+ def _add_header_selectable(
+ self,
+ row_id: Sender,
+ generator: Optional[GeneratorName],
+ ) -> None:
+ """Places one clickable column label: a channel's mute target, or the master target.
+
+ The selectable takes its width from its label, which is what lets a label wider than
+ its column draw in full, and it carries its channel so the click knows which column
+ it landed on. A tooltip names the gestures the label answers to, and the right-click
+ registry opens the same actions as a menu.
+ """
+ header_cell = dpg.add_table_cell(parent=row_id)
+ selectable = dpg.add_selectable(
+ parent=header_cell,
+ label=self._column_labels[generator],
+ height=self._layout.tracker.header_height,
+ user_data=generator,
+ callback=self._on_header_clicked,
+ )
+ dpg.bind_item_handler_registry(selectable, self._header_handler_tag)
+ show_tooltip(
+ selectable,
+ self._tooltip_header_sample if generator is None else self._tooltip_header_channel,
+ )
+ self._header_columns[selectable] = generator
+
+ def _build_table_row(self, row: SequencerRowViewModel) -> None:
+ """Builds one tracker row.
+
+ The cells are positional, so the empty divider cell after the sample column
+ keeps the channel cells aligned with their (shifted) table columns.
+ """
+ row_id = dpg.add_table_row(
+ parent=TAG_SEQUENCER_TRACKER_TABLE,
+ user_data=row.index,
+ )
+ self._add_empty_cell(row_id)
+ self._add_row_number_cell(row_id, row.index)
+ self._add_column_cell(row_id, row.index, None)
+ self._add_empty_cell(row_id)
+ for generator in GeneratorName.items():
+ self._add_column_cell(row_id, row.index, generator)
+
+ def _add_empty_cell(self, row_id: Sender) -> None:
+ empty_cell = dpg.add_table_cell(parent=row_id)
+ if dpg.does_item_exist(empty_cell):
+ dpg.add_spacer(parent=empty_cell, width=0)
+
+ def _add_row_number_cell(self, row_id: Sender, row_index: int) -> None:
+ number_cell = dpg.add_table_cell(parent=row_id)
+ selectable = dpg.add_selectable(
+ parent=number_cell,
+ label=display_id(row_index),
+ height=self._layout.tracker.row_height,
+ user_data=row_index,
+ callback=self._on_row_number_clicked,
+ )
+ FontRegistry.bind_to_item(selectable, Font.MONO_SMALL)
+ dpg.bind_item_theme(selectable, self._row_number_theme)
+ dpg.bind_item_handler_registry(selectable, self._item_handler_tag)
+ self._rows[row_index] = selectable
+
+ def _add_column_cell(
+ self,
+ row_id: Sender,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ ) -> None:
+ font = Font.MONO_BOLD_SMALL if generator is None else Font.MONO_SMALL
+ cell = dpg.add_table_cell(parent=row_id)
+ group = dpg.add_group(
+ horizontal=True,
+ horizontal_spacing=0,
+ parent=cell,
+ )
+ for subcolumn in SubColumn:
+ self._add_subcolumn_selectable(
+ group,
+ row_index,
+ generator,
+ subcolumn,
+ font,
+ )
+
+ def _add_subcolumn_selectable(
+ self,
+ group: Sender,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ subcolumn: SubColumn,
+ font: Font,
+ ) -> None:
+ key = (row_index, generator, subcolumn)
+ selectable = dpg.add_selectable(
+ parent=group,
+ label=self._render_cell(key),
+ width=self._subcolumn_widths[subcolumn],
+ height=self._layout.tracker.row_height,
+ user_data=key,
+ callback=self._on_cell_clicked,
+ )
+ FontRegistry.bind_to_item(selectable, font)
+ dpg.bind_item_theme(selectable, self._subcolumn_themes[subcolumn])
+ dpg.bind_item_handler_registry(selectable, self._cell_handler_tag)
+ self._editable_cells.register(key, selectable)
+
+ def _update_cursor(self) -> None:
+ cursor = self._input_state.cursor
+ if cursor is not None:
+ if cursor.row < self._current_row_count:
+ self._apply_cell_highlight(cursor.row, cursor.generator)
+ else:
+ self._input_state = TrackerInputState()
+
+ self._selection.repaint()
+ self._update_caret()
+
+ def deselect_cell(self) -> None:
+ cursor = self._input_state.cursor
+ if cursor is not None:
+ self._input_state = TrackerInputState()
+ self._remove_cell_highlight(cursor.row, cursor.generator)
+ self._selection.repaint()
+
+ self._update_caret()
+
+ def _apply_state(self, new_state: TrackerInputState) -> None:
+ old_cursor = self._input_state.cursor
+ new_cursor = new_state.cursor
+
+ old_pos = (old_cursor.row, old_cursor.generator) if old_cursor is not None else None
+ new_pos = (new_cursor.row, new_cursor.generator) if new_cursor is not None else None
+
+ self._input_state = new_state
+
+ if old_pos != new_pos and old_cursor is not None:
+ self._remove_cell_highlight(old_cursor.row, old_cursor.generator)
+
+ if old_cursor is not None:
+ self._update_cell_display(old_cursor.row, old_cursor.generator)
+
+ if new_cursor is not None:
+ if old_pos != new_pos:
+ self._apply_cell_highlight(new_cursor.row, new_cursor.generator)
+ self._update_cell_display(new_cursor.row, new_cursor.generator)
+
+ if new_pos != old_pos and new_cursor is not None:
+ self.call(self.on_cell_selected)
+
+ self._selection.repaint()
+ self._update_caret()
+
+ def update_samples(self, view_model: SequencerSamplesViewModel) -> None:
+ self._current_samples = view_model
+
+ def update_channels(self, view_model: SequencerChannelsViewModel) -> None:
+ """Shows which channels the song player silences.
+
+ The model is kept so a rebuilt table takes the cue again, the way the column tints do,
+ and so a table still waiting for its rows picks it up once they arrive.
+ """
+ self._current_channels = view_model
+ self._apply_channel_cues()
+
+ def _apply_channel_cues(self) -> None:
+ """Marks each silenced channel down its whole column: label, background, and cell text.
+
+ The three cues land together because they read as one: the column recedes as a unit
+ while its values stay legible, so the channel is visibly out of the mix and still open
+ for editing.
+ """
+ if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE):
+ return
+
+ self._tint_channel_columns()
+ self._bind_header_themes()
+ for generator in GeneratorName.items():
+ self._bind_channel_cell_themes(generator)
+
+ def _bind_header_themes(self) -> None:
+ for selectable, generator in self._header_columns.items():
+ muted = generator is not None and self._is_muted(generator)
+ dpg.bind_item_theme(
+ selectable,
+ self._muted_header_theme if muted else self._header_theme,
+ )
+
+ def _bind_channel_cell_themes(self, generator: GeneratorName) -> None:
+ themes = self._muted_subcolumn_themes if self._is_muted(generator) else self._subcolumn_themes
+ for row_index in range(self._current_row_count):
+ for subcolumn in SubColumn:
+ cell_id = self._editable_cells.widget((row_index, generator, subcolumn))
+ if cell_id is not None:
+ dpg.bind_item_theme(cell_id, themes[subcolumn])
+
+ def _is_muted(self, generator: GeneratorName) -> bool:
+ return self._current_channels is not None and self._current_channels.is_muted(generator)
+
+ def set_enabled(self, enabled: bool) -> None:
+ dpg.configure_item(TAG_SEQUENCER_TRACKER_GROUP, enabled=enabled)
+
+ def _update_cell_display(
+ self,
+ row: int,
+ generator: Optional[GeneratorName],
+ ) -> None:
+ for subcolumn in SubColumn:
+ key = (row, generator, subcolumn)
+ cell_id = self._editable_cells.widget(key)
+ if cell_id is not None:
+ dpg.configure_item(cell_id, label=self._render_cell(key))
+
+ def _update_caret(self) -> None:
+ """Arms (or clears) the shared caret box on the active subcolumn cell."""
+ cursor = self._input_state.cursor
+ if cursor is None:
+ CaretOverlay.clear(TAG_SEQUENCER_TRACKER_TABLE)
+ return
+
+ key = (cursor.row, cursor.generator, cursor.subcolumn)
+ font = Font.MONO_BOLD_SMALL if cursor.generator is None else Font.MONO_SMALL
+ CaretOverlay.set_target(
+ owner=TAG_SEQUENCER_TRACKER_TABLE,
+ widget=self._editable_cells.widget(key),
+ caret_index=len(self._input_state.pending),
+ font=font,
+ clip_widget=TAG_SEQUENCER_TRACKER_WINDOW,
+ )
+
+ def _resolve_sample_id(
+ self,
+ sample_index: int,
+ ) -> Optional[Tuple[int, str]]:
+ if not self._current_samples or not self._current_samples.samples:
+ return None
+
+ samples = self._current_samples.samples
+ sample_index = max(0, min(sample_index, len(samples) - 1))
+ return sample_index, samples[sample_index].sample_id
+
+ def _handle_edit_action(self, action: EditAction) -> None:
+ """Commits a single-subcolumn edit.
+
+ An :class:`EditAction` only ever carries the subcolumn under the cursor;
+ the others are ``None`` meaning "leave unchanged". Forwarding those ``None``
+ values lets the downstream partial update preserve the rest of the row.
+ """
+ row, generator = action.row, action.generator
+
+ if action.note_off:
+ self._editable_cells.values[(row, generator, SubColumn.INSTRUMENT)] = NOTE_OFF
+ self.call(self.on_set_note_off, row, generator)
+ return
+
+ sample_id: Optional[str] = None
+
+ if action.sample_index is not None:
+ resolved = self._resolve_sample_id(action.sample_index)
+ sample_index = resolved[0] if resolved is not None else None
+ sample_id = resolved[1] if resolved is not None else None
+ self._editable_cells.values[(row, generator, SubColumn.INSTRUMENT)] = tracker_display.format_committed(
+ SubColumn.INSTRUMENT,
+ sample_index,
+ )
+
+ if action.transpose is not None:
+ self._editable_cells.values[(row, generator, SubColumn.TRANSPOSE)] = tracker_display.format_committed(
+ SubColumn.TRANSPOSE,
+ action.transpose,
+ )
+
+ if action.volume is not None:
+ self._editable_cells.values[(row, generator, SubColumn.VOLUME)] = tracker_display.format_committed(
+ SubColumn.VOLUME,
+ action.volume,
+ )
+
+ self.call(
+ self.on_set_row,
+ row,
+ generator,
+ sample_id,
+ action.transpose,
+ action.volume,
+ )
+
+ def _handle_clear_action(self, action: ClearAction) -> None:
+ if action.subcolumn is None:
+ for subcolumn in SubColumn:
+ self._editable_cells.values.pop(
+ (action.row, action.generator, subcolumn),
+ None,
+ )
+ self.call(self.on_clear_row, action.row, action.generator)
+ else:
+ self._editable_cells.values.pop(
+ (action.row, action.generator, action.subcolumn),
+ None,
+ )
+ self.call(
+ self.on_clear_subcolumn,
+ action.row,
+ action.generator,
+ action.subcolumn,
+ )
+
+ def _apply_cell_highlight(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ ) -> None:
+ """Marks the cursor: its cell on the cell layer, its row through the row background."""
+ self._paint_row(row_index)
+ dpg.highlight_table_cell(
+ TAG_SEQUENCER_TRACKER_TABLE,
+ tracker_table_row(row_index),
+ tracker_table_column(generator),
+ color=self._layout.colors.cell_cursor.rgba,
+ )
+
+ def _selected_cells(self) -> FrozenSet[CellKey]:
+ """Every cell the selection covers, clipped to the rows the shown frame holds.
+
+ A region names rows of the grid rather than widgets, so a row past the end of a shorter
+ frame is left out: the selection reaches as far as the pattern does.
+ """
+ region = self._input_state.region
+ if region is None:
+ return frozenset()
+
+ keys: Set[CellKey] = set()
+ for row_index in region.rows:
+ if row_index >= self._current_row_count:
+ continue
+
+ for slot in region.slots:
+ keys.add((row_index, slot.generator, slot.subcolumn))
+
+ return frozenset(keys)
+
+ def _remove_cell_highlight(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ ) -> None:
+ """Clears the cursor cell and returns its row to the background the row itself carries.
+
+ The input state names the row the cursor stands on, so it is updated before this runs
+ and the row resolves to what it looks like once the cursor has left.
+ """
+ dpg.unhighlight_table_cell(
+ TAG_SEQUENCER_TRACKER_TABLE,
+ tracker_table_row(row_index),
+ tracker_table_column(generator),
+ )
+ self._paint_row(row_index)
+
+ def _on_cell_clicked(
+ self,
+ sender: Sender,
+ _app_data: bool,
+ user_data: Tuple[int, Optional[GeneratorName], SubColumn],
+ ) -> None:
+ """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held.
+
+ A drag that comes back to the cell it started from ends on a click, and the selection takes
+ that click as the end of the drag, so the range dragged out stands and the cursor with it.
+ """
+ if self._selection.claims_click(sender, user_data):
+ return
+
+ state = self._committed_state()
+ row_index, generator, subcolumn = user_data
+ cursor = TrackerCursor(row_index, generator, subcolumn)
+ if Modifier.SHIFT in capture_modifiers():
+ self._apply_state(state.extend_to(cursor))
+ return
+
+ self._apply_state(TrackerInputState(cursor=cursor, pending=""))
+
+ def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None:
+ """Carries the selection to the cell under a held pointer, which is what drags a range out.
+
+ The gesture states how far the pointer has carried: a plain drag anchors at the cell the
+ press landed on, and one whose press held Shift carries the selection already standing.
+
+ A pointer held past an edge travels the grid first, so the reach that follows reads the rows
+ the travel has brought into view.
+ """
+ self._travel.advance()
+ reach = self._selection.hold(app_data)
+ if reach is None:
+ return
+
+ state = self._committed_state()
+ if not reach.extends:
+ state = TrackerInputState(cursor=TrackerCursor(*reach.origin))
+
+ self._apply_state(state.extend_to(TrackerCursor(*reach.reached)))
+
+ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None:
+ """Drops the gesture a finished drag left behind, so this press selects on its own.
+
+ A press is where a gesture ends rather than the release before it, because the release
+ reaches this panel ahead of the click the cell itself reports: a drag that comes back to
+ the cell it started from would otherwise have its selection taken down by its own click.
+ """
+ self._selection.drop_gesture()
+ self._travel.rest()
+
+ def _travel_band(self) -> Optional[TravelBand]:
+ """Where the frame's rows stand, which is the band a drag held below them travels across."""
+ first = self._row_top(0)
+ if first is None or self._current_row_count == 0:
+ return None
+
+ return TravelBand(
+ first_edge=first,
+ cell_extent=self._layout.tracker.row_height,
+ cell_count=self._current_row_count,
+ )
+
+ def _cell_at(self) -> Optional[CellKey]:
+ """The cell the pointer stands on, clamped to the grid the shown frame lays out.
+
+ A drag that runs past an edge reads as the edge itself, so carrying the pointer beyond
+ the last row or the last column selects up to it rather than stopping where the grid ends.
+ """
+ left, top = dpg.get_mouse_pos(local=False)
+ row_index = self._row_at(top)
+ slot = self._slot_at(left)
+ if row_index is None or slot is None:
+ return None
+
+ return (row_index, slot.generator, slot.subcolumn)
+
+ def _row_at(self, top: float) -> Optional[int]:
+ """Which pattern row stands at a height, counted from the first row's top edge.
+
+ Every row is the height the layout states, so the count is arithmetic: the rows the
+ grid holds are evenly pitched whether or not they are scrolled into view.
+ """
+ first = self._row_top(0)
+ if first is None or self._current_row_count == 0:
+ return None
+
+ row_index = int((top - first) // self._layout.tracker.row_height)
+ return max(0, min(row_index, self._current_row_count - 1))
+
+ def _slot_at(self, left: float) -> Optional[TrackerSlot]:
+ """Which subcolumn stands at a width, taken from where the first row's cells are drawn.
+
+ The subcolumns differ in width and the columns stand apart, so the walk asks each cell
+ where it was drawn and takes the first one reaching past the pointer.
+ """
+ if self._current_row_count == 0:
+ return None
+
+ for index in range(SLOT_COUNT):
+ slot = slot_from_flat(index)
+ widget = self._editable_cells.widget((0, slot.generator, slot.subcolumn))
+ if widget is None:
+ return None
+
+ cell_left, _ = dpg.get_item_rect_min(widget)
+ cell_width, _ = dpg.get_item_rect_size(widget)
+ if left < cell_left + cell_width:
+ return slot
+
+ return slot_from_flat(SLOT_COUNT - 1)
+
+ def _on_header_clicked(
+ self,
+ sender: Sender,
+ _app_data: bool,
+ user_data: Optional[GeneratorName],
+ ) -> None:
+ self._channel_switch.click(sender, user_data)
+
+ def _on_header_right_clicked(
+ self,
+ _sender: Sender,
+ app_data: Tuple[int, int],
+ ) -> None:
+ """Opens the channel menu for the right-clicked column header.
+
+ The registry reaches the header labels alone, so a click on one of them names its column
+ through the map the header row filled in; a label replaced by a rebuild is absent from it.
+ """
+ mouse_button, clicked_item = app_data
+ if mouse_button != dpg.mvMouseButton_Right:
+ return
+
+ if clicked_item not in self._header_columns:
+ return
+
+ self._show_header_context_menu(self._header_columns[clicked_item])
+
+ 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])
+ FontRegistry.bind_to_item(header, Font.MONO_BOLD)
+ dpg.add_separator()
+ self._channel_switch.add_menu_items(generator, self._current_channels)
+
+ def _on_cell_right_clicked(
+ self,
+ _sender: Sender,
+ app_data: Tuple[int, int],
+ ) -> None:
+ """Opens the cell-operations menu for the right-clicked subcolumn.
+
+ The menu targets the clicked cell directly and leaves the edit cursor where it is,
+ so a right-click inspects a cell while the caret stays put.
+ """
+ mouse_button, clicked_item = app_data
+ if mouse_button != dpg.mvMouseButton_Right:
+ return
+
+ key = dpg.get_item_user_data(clicked_item)
+ if key is None:
+ return
+
+ row_index, generator, subcolumn = key
+ self._show_context_menu(row_index, generator, subcolumn)
+
+ def _show_context_menu(
+ self,
+ row_index: int,
+ generator: Optional[GeneratorName],
+ subcolumn: SubColumn,
+ ) -> None:
+ target = self._surface.target_at(TrackerCursor(row_index, generator, subcolumn))
+ with context_menu():
+ header = dpg.add_text(
+ tracker_display.cell_title(row_index, self._column_labels[generator]),
+ )
+ 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_from_row, row_index),
+ shortcut=self._shortcuts.display(ShortcutId.TRACKER_PLAY_FROM_ROW),
+ )
+ add_play_menu_item(
+ self._lbl_context_play_from_frame,
+ lambda: self.call(self.on_play_from_frame),
+ shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME),
+ )
+ dpg.add_separator()
+ self.add_action_items(target)
+
+ @property
+ def edit_surface(self) -> TrackerEditSurface:
+ """This grid as the menu bar's Edit menu reaches it."""
+ return self._surface
+
+ def input_state(self) -> TrackerInputState:
+ """Where the cursor stands and what it has selected, which a target is resolved from."""
+ return self._input_state
+
+ def owns_keys(self) -> bool:
+ """Whether the grid owns the next key, which is also what the Edit menu asks."""
+ return self._keys_active()
+
+ def add_action_items(self, target: TrackerTarget) -> None:
+ """Builds every action a tracker cell offers, in the order each menu prints them.
+
+ The grid states its actions once, and whoever asks for them decides where they are shown:
+ the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the
+ cursor stands on. An action added here reaches both.
+ """
+ self._add_select_items(target.cell)
+ dpg.add_separator()
+ self._surface.add_block_items(target)
+ dpg.add_separator()
+ self._add_instrument_submenu(target.cell)
+ dpg.add_menu_item(
+ label=self._lbl_context_note_off,
+ callback=lambda: self.call(self.on_set_note_off, target.cell.row, target.cell.generator),
+ )
+ dpg.add_separator()
+ self._add_transpose_items(target)
+ dpg.add_separator()
+ self._add_volume_items(target)
+ dpg.add_separator()
+ self._add_clear_items(target.cell)
+
+ def _add_select_items(self, cell: TrackerCursor) -> None:
+ """Builds the three shapes a selection takes, from the whole frame down to one subcolumn.
+
+ Each item fires the gesture its key fires, on the cell the menu names: a column selected
+ from a cell menu is the column that cell stands in, and one selected from the menu bar is
+ the column the cursor stands in.
+ """
+ dpg.add_menu_item(
+ label=self._lbl_context_select_all,
+ shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_ALL),
+ callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_ALL, cell),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_context_select_column,
+ shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_COLUMN),
+ callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_COLUMN, cell),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_context_select_subcolumn,
+ shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_SUBCOLUMN),
+ callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_SUBCOLUMN, cell),
+ )
+
+ def _add_instrument_submenu(self, cell: TrackerCursor) -> None:
+ with dpg.menu(label=self._lbl_context_set_instrument):
+ samples = self._current_samples.samples if self._current_samples is not None else ()
+ if not samples:
+ dpg.add_menu_item(
+ label=self._lbl_context_no_samples,
+ enabled=False,
+ )
+ return
+
+ for index, sample in enumerate(samples):
+ dpg.add_menu_item(
+ label=tracker_display.indexed_label(index, sample.name),
+ user_data=(cell.row, cell.generator, sample.sample_id),
+ callback=self._on_set_instrument_menu,
+ )
+
+ def _add_transpose_items(self, target: TrackerTarget) -> None:
+ self._add_adjust_items(target, TRANSPOSE_ACTIONS, self._on_transpose_menu)
+
+ def _add_volume_items(self, target: TrackerTarget) -> None:
+ self._add_adjust_items(target, VOLUME_ACTIONS, self._on_volume_menu)
+
+ def _add_adjust_items(
+ self,
+ target: TrackerTarget,
+ actions: Tuple[AdjustAction, ...],
+ callback: AdjustMenuCallback,
+ ) -> None:
+ """Builds one axis of adjustment items, each shifting the cells its target covers.
+
+ An adjustment acts on whole cells, so it reaches the columns the target's block covers and
+ the rows it spans: a nudge with a selection standing moves all of it, and one on a cell
+ alone moves that cell. Each item prints the key it answers to, since the action states its
+ label, its binding and its step in one entry.
+ """
+ for element, shortcut_id, delta in actions:
+ dpg.add_menu_item(
+ label=self._lbl_adjust[element],
+ shortcut=self._shortcuts.display(shortcut_id),
+ user_data=(target.region, delta),
+ callback=callback,
+ )
+
+ def _on_set_instrument_menu(
+ self,
+ _sender: Sender,
+ _app_data: None,
+ user_data: Tuple[int, Optional[GeneratorName], str],
+ ) -> None:
+ row_index, generator, sample_id = user_data
+ self.call(self.on_set_row, row_index, generator, sample_id, None, None)
+
+ def _on_transpose_menu(
+ self,
+ _sender: Sender,
+ _app_data: None,
+ user_data: Tuple[TrackerRegion, int],
+ ) -> None:
+ region, delta = user_data
+ self.call(self.on_adjust_transpose, region, delta)
+
+ def _on_volume_menu(
+ self,
+ _sender: Sender,
+ _app_data: None,
+ user_data: Tuple[TrackerRegion, int],
+ ) -> None:
+ region, delta = user_data
+ self.call(self.on_adjust_volume, region, delta)
+
+ def _add_clear_items(self, cell: TrackerCursor) -> None:
+ """Builds the three clear levels: the target's subcolumn, its whole channel cell, its whole row.
+
+ The cell and row levels coincide on the sample column, which already clears every channel,
+ so the per-channel ``Clear cell`` item is offered only for an actual channel.
+ """
+ dpg.add_menu_item(
+ label=self._lbl_context_clear_subcolumn,
+ callback=lambda: self.call(
+ self.on_clear_subcolumn,
+ cell.row,
+ cell.generator,
+ cell.subcolumn,
+ ),
+ )
+ if cell.generator is not None:
+ dpg.add_menu_item(
+ label=self._lbl_context_clear_cell,
+ callback=lambda: self.call(
+ self.on_clear_row,
+ cell.row,
+ cell.generator,
+ ),
+ )
+ dpg.add_menu_item(
+ label=self._lbl_context_clear_row,
+ callback=lambda: self.call(self.on_clear_row, cell.row, None),
+ )
+
+ def _keys_active(self) -> bool:
+ """Whether the grid owns the next key: its tab is in front, its cursor is set, and no
+ field holds the keyboard.
+
+ The grid keeps its cursor while another tab is worked on, so the tab in front is what
+ decides whether a press reaches it. A focused field keeps the keyboard, so the grid stands
+ down while the user types into an input. A modal dialog claims keys at a higher priority in
+ the router, so the grid carries no modal check of its own.
+ """
+ return self._tab_active() and self._input_state.cursor is not None and not self._router.is_field_focused
+
+ def _on_key_pressed(self, event: KeyEvent) -> bool:
+ """Applies a tracker key to the active cell, reporting whether the grid consumed it.
+
+ The scheme says which press each tracker action answers to; a press the tracker category
+ leaves unnamed goes to cell entry, which keeps the note and hex keys and hands the rest to
+ the application's global shortcuts.
+ """
+ cursor = self._input_state.cursor
+ if cursor is None:
+ return False
+
+ shortcut_id = self._shortcuts.action(ShortcutCategory.TRACKER, event)
+ if shortcut_id is None:
+ return self._type_character(event)
+
+ if shortcut_id is ShortcutId.TRACKER_PLAY_FROM_ROW:
+ self.call(self.on_play_from_row, cursor.row)
+ return True
+
+ if self._move_cursor(shortcut_id):
+ return True
+
+ if self._extend_selection(shortcut_id):
+ return True
+
+ if self._select_shape(shortcut_id, cursor):
+ return True
+
+ if self._block_action(shortcut_id):
+ return True
+
+ if self._adjust_action(shortcut_id):
+ return True
+
+ return self._edit_row(shortcut_id)
+
+ def _move_cursor(self, shortcut_id: ShortcutId) -> bool:
+ """Moves the edit cursor over the grid, reporting whether the action was one of its moves."""
+ match shortcut_id:
+ case ShortcutId.TRACKER_PREVIOUS_ROW:
+ self._move_row(-1)
+ case ShortcutId.TRACKER_NEXT_ROW:
+ self._move_row(1)
+ case ShortcutId.TRACKER_PREVIOUS_SUBCOLUMN:
+ self._move_subcolumn(-1)
+ case ShortcutId.TRACKER_NEXT_SUBCOLUMN:
+ self._move_subcolumn(1)
+ case ShortcutId.TRACKER_PREVIOUS_COLUMN:
+ self._move_column(-1)
+ case ShortcutId.TRACKER_NEXT_COLUMN:
+ self._move_column(1)
+ case ShortcutId.TRACKER_FIRST_ROW:
+ self._jump_to_row(0)
+ case ShortcutId.TRACKER_LAST_ROW:
+ self._jump_to_row(self._current_row_count - 1)
+ case ShortcutId.TRACKER_PAGE_UP:
+ self._page(-self._layout.tracker.page_size)
+ case ShortcutId.TRACKER_PAGE_DOWN:
+ self._page(self._layout.tracker.page_size)
+ case _:
+ return False
+
+ return True
+
+ def _extend_selection(self, shortcut_id: ShortcutId) -> bool:
+ """Grows or shrinks the selected block, reporting whether the action was one of its reaches.
+
+ Each reach moves the end the cursor holds while the anchor stays where the selection began,
+ so the same keys that move the cursor select with Shift held.
+ """
+ match shortcut_id:
+ case ShortcutId.TRACKER_EXTEND_SELECTION_UP:
+ self._extend_row(-1)
+ case ShortcutId.TRACKER_EXTEND_SELECTION_DOWN:
+ self._extend_row(1)
+ case ShortcutId.TRACKER_EXTEND_SELECTION_LEFT:
+ self._extend_slot(-1)
+ case ShortcutId.TRACKER_EXTEND_SELECTION_RIGHT:
+ self._extend_slot(1)
+ case ShortcutId.TRACKER_EXTEND_SELECTION_TO_FIRST_ROW:
+ self._extend_to_row(0)
+ case ShortcutId.TRACKER_EXTEND_SELECTION_TO_LAST_ROW:
+ self._extend_to_row(self._current_row_count - 1)
+ case _:
+ return False
+
+ return True
+
+ def _select_shape(
+ self,
+ shortcut_id: ShortcutId,
+ cell: TrackerCursor,
+ ) -> bool:
+ """Selects a rectangle of the grid, reporting whether the action was one of its shapes.
+
+ A press names its shape from the cell the cursor stands on, which is the cell the menu
+ items name as well, so a key and an item select the same block.
+ """
+ match shortcut_id:
+ case ShortcutId.TRACKER_SELECT_ALL:
+ self._select_all()
+ case ShortcutId.TRACKER_SELECT_COLUMN:
+ self._select_column(cell)
+ case ShortcutId.TRACKER_SELECT_SUBCOLUMN:
+ self._select_subcolumn(cell)
+ case _:
+ return False
+
+ return True
+
+ def _select_all(self) -> None:
+ self._select(self._committed_state().select_all(self._current_row_count))
+
+ def _select_column(self, cell: TrackerCursor) -> None:
+ self._select(self._committed_state().select_column(cell, self._current_row_count))
+
+ def _select_subcolumn(self, cell: TrackerCursor) -> None:
+ self._select(self._committed_state().select_subcolumn(cell, self._current_row_count))
+
+ def _select(self, new_state: TrackerInputState) -> None:
+ """Stands a selected shape, revealing the row its cursor landed on.
+
+ A shape ends at the frame's last row, so the reveal carries the grid to the end the cursor
+ now holds — the same landing a Shift+End reach makes.
+ """
+ self._apply_state(new_state)
+ self._scroll_cursor_into_view()
+
+ def _block_action(self, shortcut_id: ShortcutId) -> bool:
+ """Acts on the selected block, reporting whether the action was one of its gestures.
+
+ Delete is a block gesture only while a selection stands: with one it empties what the
+ selection covers and keeps it, and with none it falls through to clearing the cell under
+ the cursor, the meaning that key already carries.
+ """
+ match shortcut_id:
+ case ShortcutId.TRACKER_COPY_BLOCK:
+ self._surface.copy()
+ case ShortcutId.TRACKER_CUT_BLOCK:
+ self._surface.cut()
+ case ShortcutId.TRACKER_CLEAR_ROW if self._input_state.region is not None:
+ self._surface.delete()
+ case ShortcutId.TRACKER_PASTE_BLOCK:
+ self._surface.paste()
+ case _:
+ return False
+
+ return True
+
+ def _adjust_action(self, shortcut_id: ShortcutId) -> bool:
+ """Shifts the covered cells' transpose or volume, reporting whether the action was one of
+ the two axes.
+
+ A press acts on the block the cursor stands in, which is the selection while one covers it
+ and the cursor's own cell otherwise — the target the menus resolve as well, so a key and a
+ menu item reach the same cells.
+ """
+ transpose_step = TRANSPOSE_STEPS.get(shortcut_id)
+ if transpose_step is not None:
+ self._adjust_at_cursor(self.on_adjust_transpose, transpose_step)
+ return True
+
+ volume_step = VOLUME_STEPS.get(shortcut_id)
+ if volume_step is not None:
+ self._adjust_at_cursor(self.on_adjust_volume, volume_step)
+ return True
+
+ return False
+
+ def _adjust_at_cursor(
+ self,
+ hook: Optional[OnAdjustCallback],
+ delta: int,
+ ) -> None:
+ """Raises an adjustment on the block the cursor stands in, the entry being typed landing first.
+
+ Committing ahead of the shift is what lets a nudge carry the value the reader has just
+ finished typing, the rule the block gestures follow as well.
+ """
+ self.commit_entry()
+ target = self._surface.cursor_target()
+ if target is not None:
+ self.call(hook, target.region, delta)
+
+ def _edit_row(self, shortcut_id: ShortcutId) -> bool:
+ """Empties the cell under the cursor or drops a partial entry, reporting whether the action
+ was one of the cell edits.
+
+ A cancel with nothing typed and nothing selected leaves the press to the application, so
+ Escape stops playback while the grid holds a cursor.
+ """
+ match shortcut_id:
+ case ShortcutId.TRACKER_CLEAR_ROW:
+ self._clear_row()
+ self._move_row(1)
+ case ShortcutId.TRACKER_CLEAR_PREVIOUS_ROW:
+ self._clear_row()
+ self._move_row(-1)
+ case ShortcutId.TRACKER_CANCEL_ENTRY:
+ if not self._input_state.pending and self._input_state.anchor is None:
+ return False
+
+ self._apply_state(self._input_state.cancel())
+ case _:
+ return False
+
+ return True
+
+ def _move_row(self, delta: int) -> None:
+ self._apply_state(
+ self._committed_state().navigate_row(
+ delta,
+ self._current_row_count,
+ )
+ )
+
+ def _page(self, delta: int) -> None:
+ """Moves the cursor a page of rows, then scrolls it back into view."""
+ self._move_row(delta)
+ self._scroll_cursor_into_view()
+
+ def _jump_to_row(self, index: int) -> None:
+ self._apply_state(
+ self._committed_state().navigate_row(
+ index,
+ self._current_row_count,
+ absolute=True,
+ )
+ )
+ self._scroll_cursor_into_view()
+
+ def _extend_row(self, delta: int) -> None:
+ self._apply_state(
+ self._committed_state().extend_row(
+ delta,
+ self._current_row_count,
+ )
+ )
+
+ def _extend_to_row(self, index: int) -> None:
+ self._apply_state(
+ self._committed_state().extend_row(
+ index,
+ self._current_row_count,
+ absolute=True,
+ )
+ )
+ self._scroll_cursor_into_view()
+
+ def _extend_slot(self, delta: int) -> None:
+ self._apply_state(self._committed_state().extend_slot(delta))
+
+ def _move_subcolumn(self, delta: int) -> None:
+ self._apply_state(self._committed_state().navigate_subcolumn(delta))
+
+ def _move_column(self, delta: int) -> None:
+ self._apply_state(self._committed_state().navigate_column_by(delta))
+
+ def _scroll_cursor_into_view(self) -> None:
+ """Scrolls the tracker so the cursor's row stays on screen after a page or Home/End jump."""
+ cursor = self._input_state.cursor
+ if cursor is not None:
+ self._scroll_row_into_view(cursor.row)
+
+ def _scroll_row_into_view(self, row_index: int) -> None:
+ """Scrolls the tracker so the given row rests within the visible band.
+
+ The frame's rows all live in one scrolling table, so a row outside the band is reached by
+ setting the scroll from that row's position within the frame: the first row rests at the
+ top of the band, the last at the bottom, and the rows between drift across it. This is how
+ a jump of the edit cursor lands.
+ """
+ scroll_max = self._scroll_extent()
+ if scroll_max is None:
+ return
+
+ fraction = row_index / (self._current_row_count - 1)
+ dpg.set_y_scroll(TAG_SEQUENCER_TRACKER_TABLE, fraction * scroll_max)
+
+ def _scroll_row_to_band_top(self, row_index: int) -> None:
+ """Scrolls the tracker so the given row heads the visible band.
+
+ A playhead read from one place is a playhead that stays easy to read, so the sounding row
+ is carried to the top of the band by the height of the rows above it, and the rows it is
+ about to reach fill the band beneath it. The rows closing a frame have nothing behind them
+ left to scroll into place: there the grid rests at its end and the playhead walks down the
+ band to meet it.
+ """
+ scroll_max = self._scroll_extent()
+ offset = self._row_offset(row_index)
+ if scroll_max is None or offset is None:
+ return
+
+ dpg.set_y_scroll(TAG_SEQUENCER_TRACKER_TABLE, min(offset, scroll_max))
+
+ def _scroll_extent(self) -> Optional[float]:
+ """How far the grid scrolls, once there is a built table with a frame too tall to fit it."""
+ if self._current_row_count <= 1:
+ return None
+
+ if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE):
+ return None
+
+ scroll_max = dpg.get_y_scroll_max(TAG_SEQUENCER_TRACKER_TABLE)
+ return scroll_max if scroll_max > 0 else None
+
+ def _row_offset(self, row_index: int) -> Optional[float]:
+ """How far down the frame a row stands, measured from the first row to it.
+
+ The rows report where they were last drawn, so the distance between two of them is the
+ scroll that brings the lower one to where the upper one stands, and the distance from the
+ first row is the scroll that carries a row to the head of the band. Reading it off the rows
+ holds whatever height they take and however tall the header above them stands. A grid
+ awaiting its first layout measures nothing, and its rows are placed by the report that
+ follows.
+ """
+ first = self._row_top(0)
+ row = self._row_top(row_index)
+ if first is None or row is None:
+ return None
+
+ return row - first
+
+ def _row_top(self, row_index: int) -> Optional[float]:
+ """Where a pattern row's top edge stands, in the coordinates the viewport is drawn in."""
+ row = self._rows.get(row_index)
+ if row is None or not dpg.does_item_exist(row):
+ return None
+
+ _, top = dpg.get_item_rect_min(row)
+ return float(top)
+
+ def _clear_row(self) -> None:
+ state, clear_action = self._input_state.clear()
+ self._handle_clear_action(clear_action)
+ self._apply_state(state)
+
+ def _committed_state(self) -> TrackerInputState:
+ state, edit_action = self._input_state.commit_partial()
+ if edit_action is not None:
+ self._handle_edit_action(edit_action)
+
+ return state
+
+ def commit_entry(self) -> None:
+ """Writes the entry being typed into the cell the cursor stands on.
+
+ A block gesture takes this first, so what it lifts out carries the value the reader has
+ just finished typing.
+ """
+ self._apply_state(self._committed_state())
+
+ def _type_character(self, event: KeyEvent) -> bool:
+ """Types a note, digit or sign into the cell under the cursor, reporting whether the press
+ was one.
+
+ A press holding Ctrl or Alt is an application gesture, so cell entry reads the plain keys
+ and leaves the rest to the global shortcuts.
+ """
+ if Modifier.CTRL in event.modifiers or Modifier.ALT in event.modifiers:
+ return False
+
+ char = HEX_KEYS.get(event.key) or SIGN_KEYS.get(event.key)
+ if char is None:
+ return False
+
+ new_state, edit_action = self._input_state.type_char(char)
+ if edit_action is not None:
+ self._handle_edit_action(edit_action)
+ new_state = new_state.navigate_row(1, self._current_row_count)
+
+ self._apply_state(new_state)
+ return True
+
+ def _on_row_number_clicked(
+ self,
+ sender: Sender,
+ _app_data: bool,
+ user_data: int,
+ ) -> None:
+ dpg.set_value(sender, False)
+ existing = self._input_state.cursor
+ generator = existing.generator if existing is not None else None
+ subcolumn = existing.subcolumn if existing is not None else SubColumn.INSTRUMENT
+ self._apply_state(
+ TrackerInputState(
+ cursor=TrackerCursor(
+ user_data,
+ generator,
+ subcolumn,
+ ),
+ pending="",
+ )
+ )
+
+ def _on_row_hovered(self, _sender: Sender, app_data: int) -> None:
+ if not dpg.does_item_exist(app_data):
+ return
+
+ row_index = dpg.get_item_user_data(app_data)
+ if row_index is not None:
+ self._highlighted_row = row_index
+
+ def highlight_row(self, row_index: Optional[int] = None) -> None:
+ """Marks the row the pointer rests on, over the background that row already carries."""
+ self.unhighlight_row(self._highlighted_row)
+ self._highlighted_row = row_index
+ if row_index is None:
+ return
+
+ self._paint_hovered_row(row_index)
+
+ def unhighlight_row(self, row_index: Optional[int] = None) -> None:
+ """Returns a hovered row to the background its group and the marks on it give it."""
+ if row_index is None:
+ return
+
+ self._highlighted_row = None
+ self._paint_row(row_index)
+
+ def set_row_following(self, following: bool) -> None:
+ """Whether the grid keeps the sounding row within the visible band as playback advances."""
+ self._follows_playing_row = following
+
+ def set_playing_position(self, position: Optional[SongPosition]) -> None:
+ """Moves the playhead to the position playback reached, mark and grid arriving together.
+
+ The position carries the order frame with the row, which is what tells the grid whether the
+ row it would mark belongs to the pattern it shows. A row's mark is drawn on the very next
+ frame while the grid answers a scroll on the frame after that, so a mark drawn as the row is
+ reported stands a row clear of the band's head until the grid catches up — a step down and
+ back on every row. Holding the mark until the frame its scroll lands on carries the two as
+ one.
+ """
+ self._playing_frame = position.order_position if position is not None else None
+ self._playing_row = position.row_index if position is not None else None
+ self._reveal_playing_row()
+ FrameCallbackManager.set_frame_callback(self._paint_playhead, PLAYHEAD_PAINT_FRAMES)
+
+ @property
+ def _playhead_row(self) -> Optional[int]:
+ """The row the mark stands on: the sounding row, while the grid shows the frame it sounds."""
+ if self._playing_frame == self._displayed_frame:
+ return self._playing_row
+
+ return None
+
+ def _paint_playhead(self) -> None:
+ """Draws the mark on the row the playhead has reached, clearing the row it came from."""
+ previous = self._painted_row
+ self._painted_row = self._playhead_row
+ if previous is not None and previous != self._painted_row:
+ self._paint_row(previous)
+
+ if self._painted_row is not None:
+ self._paint_row(self._painted_row)
+
+ def _reveal_playing_row(self) -> None:
+ """Carries the sounding row to the head of the band while the grid follows the playhead."""
+ row_index = self._playhead_row
+ if self._follows_playing_row and row_index is not None:
+ self._scroll_row_to_band_top(row_index)
+
+ def _live_row_count(self) -> int:
+ """The table's current pattern-row count, read live from DearPyGui.
+
+ The cached ``_current_row_count`` reflects the last build on this thread; a concurrent
+ rebuild on another thread can leave it stale, so row-index-bounded DearPyGui calls read the
+ actual children directly. The count covers the pattern rows that follow the header row,
+ so it compares against a pattern row index.
+ """
+ if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE):
+ return 0
+
+ rows = dpg.get_item_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1)
+ return len(rows) - HEADER_TABLE_ROWS if rows else 0
diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py
new file mode 100644
index 00000000..35652a6b
--- /dev/null
+++ b/src/sampletones_application/ui/panels/shared/browser.py
@@ -0,0 +1,333 @@
+from abc import abstractmethod
+from typing import AbstractSet, Any, Optional, Tuple
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.behavior.scheduling.scheduling import (
+ SchedulingBehavior,
+)
+from sampletones_application.tags.general import (
+ TAG_GLOBAL_THEME_DEFAULT,
+ TAG_GLOBAL_THEME_FILE_WAVE,
+)
+from sampletones_application.ui.elements.context_menu import add_detail_items, context_menu
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel
+from sampletones_application.ui.elements.tree.colors import TreeColors
+from sampletones_application.ui.elements.tree.handler import NodeHandler
+from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol
+from sampletones_application.ui.elements.tree.state import TreeNodeState
+from sampletones_core.structures.tree import (
+ FileSystemNode,
+ NodeType,
+ Tree,
+ TreeNode,
+ TreeTraversal,
+ traverse,
+)
+from sampletones_shared.types.application import Sender
+from sampletones_shared.types.callback import VoidCallback
+
+
+class GUIReconstructionBrowserPanel(GUIFileBrowserPanel):
+ """Shared skeleton of the reconstructions browser in the Sequencer and Reconstruction tabs.
+
+ Reads the tree both tabs share into rows, colours the ones the browser invents, and routes node
+ clicks to the subclass through :meth:`_open_reconstruction`. The subclass names its widgets and
+ its refresh control, and adds the items its context menus offer.
+
+ Reconstructions carry favorites, so this browser offers the control showing them alone and opens
+ in the mode the session left it in. It holds the shape the reader unfolded as well, so a rebuild
+ — a refresh, a change of mode — brings the rows back standing as they were left, and so does the
+ next run of the application.
+ """
+
+ _MONOSPACE_CONFIG_NODES: bool = True
+ _OFFERS_FAVORITES_FILTER: bool = True
+ _REMEMBERS_EXPANSION: bool = True
+
+ def __init__(
+ self,
+ tree: Tree,
+ tree_logic: TreeLogicProtocol,
+ *,
+ scheduling: SchedulingBehavior,
+ language_manager: LanguageManager,
+ status_bar: GUIStatusBar,
+ colors: TreeColors,
+ initial_collapsed: bool,
+ initial_favorites_only: bool,
+ initial_expanded_rows: AbstractSet[str],
+ ) -> None:
+ self._language_manager = language_manager
+ self.on_refresh_tree: Optional[VoidCallback] = None
+
+ super().__init__(
+ tree=tree,
+ tree_logic=tree_logic,
+ scheduling=scheduling,
+ search_label=language_manager["global.browser.label.search"],
+ language_manager=language_manager,
+ status_bar=status_bar,
+ colors=colors,
+ initial_collapsed=initial_collapsed,
+ initial_favorites_only=initial_favorites_only,
+ initial_expanded_rows=initial_expanded_rows,
+ )
+
+ @property
+ def section_label(self) -> str:
+ return self._language_manager["global.browser.label.browser"]
+
+ @property
+ def section_glyph(self) -> str:
+ return self._glyphs.headers.reconstruction
+
+ def _refresh_model(self) -> None:
+ self.call(self.on_refresh_tree)
+
+ def _setup_handlers(self) -> None:
+ self._node_handlers = {
+ NodeType.GROUP: NodeHandler(
+ tag=self._get_node_handler_tag(NodeType.GROUP),
+ node_type=NodeType.GROUP,
+ item_click_callback=self._on_container_node_clicked,
+ status_bar_callback=self._create_status_bar_message_function_for_expandable_node(),
+ ),
+ NodeType.SAMPLE: NodeHandler(
+ tag=self._get_node_handler_tag(NodeType.SAMPLE),
+ node_type=NodeType.SAMPLE,
+ item_click_callback=self._on_container_node_clicked,
+ status_bar_callback=self._create_status_bar_message_function_for_expandable_node(),
+ ),
+ **self._create_file_system_handlers(
+ on_directory_clicked=self._on_directory_node_clicked,
+ on_file_clicked=self._on_reconstruction_node_clicked,
+ on_file_double_clicked=self._on_reconstruction_node_double_clicked,
+ file_status_message=self._create_status_bar_message_function_for_reconstruction_node(),
+ ),
+ }
+
+ super()._setup_handlers()
+
+ def _has_relevant_content(self, node: TreeNode) -> bool:
+ if node.node_type == NodeType.FILE:
+ return True
+
+ return bool(node.children)
+
+ @traverse(TreeTraversal.BFS)
+ def _build_tree_node(
+ self,
+ node: TreeNode,
+ state: TreeNodeState,
+ **kwargs: Any,
+ ) -> None:
+ node_tag = self._generate_node_tag(node)
+ match node.node_type:
+ case NodeType.ROOT:
+ return
+ case NodeType.GROUP | NodeType.SAMPLE:
+ self._append_spec(
+ node=node,
+ node_tag=node_tag,
+ parent=state.parent,
+ should_expand=self._should_expand_node(node),
+ )
+ state.parent = node_tag
+ return
+
+ if not isinstance(node, FileSystemNode):
+ return
+
+ self._mark_favorite_ancestry(node, state)
+ if node.node_type == NodeType.DIRECTORY:
+ should_expand = self._should_expand_node(node)
+ self._append_spec(
+ node=node,
+ node_tag=node_tag,
+ parent=state.parent,
+ should_expand=should_expand,
+ has_favorite_ancestor=state.has_favorite_ancestor,
+ )
+ else:
+ self._append_spec(
+ node=node,
+ node_tag=node_tag,
+ parent=state.parent,
+ leaf=True,
+ has_favorite_ancestor=state.has_favorite_ancestor,
+ )
+
+ state.parent = node_tag
+
+ def _resolve_other_theme_tag(self, node: TreeNode) -> str:
+ """Selects the colour of a row the browser invents: a plain group, or a sample in wave colour.
+
+ A sample row names the audio a set of reconstructions was made from, so it reads in the
+ colour audio files carry elsewhere in the application.
+ """
+ match node.node_type:
+ case NodeType.GROUP:
+ return TAG_GLOBAL_THEME_DEFAULT
+ case NodeType.SAMPLE:
+ return TAG_GLOBAL_THEME_FILE_WAVE
+
+ return super()._resolve_other_theme_tag(node)
+
+ def _on_container_node_clicked(
+ self,
+ _sender: Sender,
+ app_data: Tuple[int, int],
+ user_data: Tuple[TreeNode, str],
+ ) -> None:
+ mouse_button, _ = app_data
+ node, _ = user_data
+ if mouse_button == dpg.mvMouseButton_Right:
+ self._show_container_context_menu(node)
+
+ def _on_directory_node_clicked(
+ self,
+ _sender: Sender,
+ app_data: Tuple[int, int],
+ user_data: Tuple[FileSystemNode, str],
+ ) -> None:
+ mouse_button, _ = app_data
+ node, _ = user_data
+ if mouse_button == dpg.mvMouseButton_Right:
+ self._show_directory_context_menu(node)
+
+ def _on_reconstruction_node_clicked(
+ self,
+ _sender: Sender,
+ app_data: Tuple[int, int],
+ user_data: Tuple[FileSystemNode, str],
+ ) -> None:
+ mouse_button, _ = app_data
+ node, node_tag = user_data
+ if mouse_button == dpg.mvMouseButton_Left:
+ self._logic.request_autoplay(node)
+
+ if mouse_button == dpg.mvMouseButton_Right:
+ self._show_reconstruction_context_menu(node, node_tag)
+
+ def _on_reconstruction_node_double_clicked(
+ self,
+ _sender: Sender,
+ app_data: Tuple[int, int],
+ user_data: Tuple[FileSystemNode, str],
+ ) -> None:
+ """Opens the double-clicked reconstruction, dropping the preview the click before it queued.
+
+ A single click queues an autoplay preview, and the second click of a double click means the
+ reader wants the file itself, so the preview is dropped before the subclass opens it.
+ """
+ mouse_button, _ = app_data
+ node, _ = user_data
+ if mouse_button == dpg.mvMouseButton_Left:
+ self._logic.cancel_autoplay()
+ self._open_reconstruction(node)
+
+ def _show_container_context_menu(self, node: TreeNode) -> None:
+ """Offers what a row the browser invents can answer: what it gathers, and how it folds.
+
+ A group or a sample stands for a facet of the reconstructions below it, so its menu reads the
+ subtree — how many reconstructions it gathers, the rows folding under it, the label the tree
+ shows it by, and for a sample the audio its reconstructions were made from.
+ """
+ if node.node_type not in (NodeType.GROUP, NodeType.SAMPLE):
+ return
+
+ with context_menu():
+ self._add_context_menu_text(node)
+ self._add_context_menu_reconstruction_count(node)
+ self._add_context_menu_expansion_items(node)
+ self._add_context_menu_copy_name_item(node)
+ self._add_context_menu_sample_audio_item(node)
+
+ def _add_context_menu_reconstruction_count(self, node: TreeNode) -> None:
+ """States how many reconstructions the row gathers, which is what the row stands for."""
+ count = sum(1 for descendant in node.descendants if descendant.node_type == NodeType.FILE)
+ add_detail_items(
+ [(self._language_manager["global.context.label.detail_reconstructions"], str(count))],
+ color=self._colors.muted,
+ )
+
+ def _add_context_menu_expansion_items(self, node: TreeNode) -> None:
+ dpg.add_separator()
+ dpg.add_menu_item(
+ label=self._language_manager["global.context.label.expand_all"],
+ callback=lambda: self._set_subtree_expanded(node, expanded=True),
+ )
+ dpg.add_menu_item(
+ label=self._language_manager["global.context.label.collapse_all"],
+ callback=lambda: self._set_subtree_expanded(node, expanded=False),
+ )
+
+ def _add_context_menu_copy_name_item(self, node: TreeNode) -> None:
+ """Offers the label the tree reads the row by, which for a folded chain names every level."""
+ dpg.add_separator()
+ dpg.add_menu_item(
+ label=self._language_manager["global.context.label.copy_name"],
+ callback=lambda: dpg.set_clipboard_text(str(node.name)),
+ )
+
+ def _add_context_menu_sample_audio_item(self, node: TreeNode) -> None:
+ """Offers the audio behind a sample row, through any one reconstruction gathered under it.
+
+ Every reconstruction under one sample was made from the same audio, so the first of them
+ answers for the row.
+ """
+ if node.node_type != NodeType.SAMPLE:
+ return
+
+ reconstruction = self._first_reconstruction_below(node)
+ if reconstruction is None:
+ return
+
+ dpg.add_separator()
+ self._add_context_menu_locate_audio_item(reconstruction)
+
+ def _first_reconstruction_below(self, node: TreeNode) -> Optional[FileSystemNode]:
+ for descendant in node.descendants:
+ if isinstance(descendant, FileSystemNode) and descendant.node_type == NodeType.FILE:
+ return descendant
+
+ return None
+
+ def _show_directory_context_menu(self, node: FileSystemNode) -> None:
+ if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY:
+ return
+
+ with context_menu():
+ self._add_context_menu_text(node)
+ self._add_context_menu_details(node)
+ self._add_context_menu_path_items(node.filepath)
+ self._add_directory_context_menu_items(node)
+ self._add_context_menu_favorite_item(node)
+
+ def _show_reconstruction_context_menu(
+ self,
+ node: FileSystemNode,
+ _node_tag: str,
+ ) -> None:
+ if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE:
+ return
+
+ with context_menu():
+ self._add_context_menu_text(node)
+ self._add_context_menu_play_item(node)
+ self._add_reconstruction_context_menu_items(node)
+ self._add_context_menu_path_items(node.filepath)
+ self._add_context_menu_locate_audio_item(node)
+ self._add_context_menu_favorite_item(node)
+
+ def _add_directory_context_menu_items(self, node: FileSystemNode) -> None:
+ pass
+
+ @abstractmethod
+ def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None: ...
+
+ @abstractmethod
+ def _open_reconstruction(self, node: FileSystemNode) -> None: ...
diff --git a/src/sampletones_application/ui/resources/items.py b/src/sampletones_application/ui/resources/items.py
index d4a8c2ba..d85b185f 100644
--- a/src/sampletones_application/ui/resources/items.py
+++ b/src/sampletones_application/ui/resources/items.py
@@ -1,6 +1,6 @@
from enum import Enum
-from sampletones_core.paths import (
+from sampletones_shared.paths.resources import (
FONT_ICON,
FONT_MONO_BOLD,
FONT_MONO_REGULAR,
diff --git a/src/sampletones_application/ui/resources/resources.py b/src/sampletones_application/ui/resources/resources.py
index e644d7de..b841967f 100644
--- a/src/sampletones_application/ui/resources/resources.py
+++ b/src/sampletones_application/ui/resources/resources.py
@@ -3,7 +3,7 @@
IconResource,
)
from sampletones_application.ui.resources.loader import ResourceLoader
-from sampletones_core.paths import FONT_DIRECTORY, ICON_DIRECTORY
+from sampletones_shared.paths.resources import FONT_DIRECTORY, ICON_DIRECTORY
icon_loader = ResourceLoader(ICON_DIRECTORY)
font_loader = ResourceLoader(FONT_DIRECTORY)
diff --git a/src/sampletones_application/ui/themes/channels.py b/src/sampletones_application/ui/themes/channels.py
new file mode 100644
index 00000000..c3786554
--- /dev/null
+++ b/src/sampletones_application/ui/themes/channels.py
@@ -0,0 +1,16 @@
+from typing import Dict, Final
+
+from sampletones_application.tags.general import (
+ TAG_GLOBAL_THEME_CHANNEL_NOISE,
+ TAG_GLOBAL_THEME_CHANNEL_PULSE1,
+ TAG_GLOBAL_THEME_CHANNEL_PULSE2,
+ TAG_GLOBAL_THEME_CHANNEL_TRIANGLE,
+)
+from sampletones_core.constants.enums import GeneratorName
+
+CHANNEL_THEME_TAGS: Final[Dict[GeneratorName, str]] = {
+ GeneratorName.PULSE1: TAG_GLOBAL_THEME_CHANNEL_PULSE1,
+ GeneratorName.PULSE2: TAG_GLOBAL_THEME_CHANNEL_PULSE2,
+ GeneratorName.TRIANGLE: TAG_GLOBAL_THEME_CHANNEL_TRIANGLE,
+ GeneratorName.NOISE: TAG_GLOBAL_THEME_CHANNEL_NOISE,
+}
diff --git a/src/sampletones_application/ui/themes/dpg_constants.py b/src/sampletones_application/ui/themes/dpg_constants.py
index e7e4a0ea..5eed2b85 100644
--- a/src/sampletones_application/ui/themes/dpg_constants.py
+++ b/src/sampletones_application/ui/themes/dpg_constants.py
@@ -32,6 +32,7 @@
"Header": dpg.mvThemeCol_Header,
"HeaderActive": dpg.mvThemeCol_HeaderActive,
"HeaderHovered": dpg.mvThemeCol_HeaderHovered,
+ "InputTextCursor": dpg.mvThemeCol_InputTextCursor,
"MenuBarBg": dpg.mvThemeCol_MenuBarBg,
"PopupBg": dpg.mvThemeCol_PopupBg,
"ScrollbarBg": dpg.mvThemeCol_ScrollbarBg,
@@ -63,10 +64,27 @@
}
PLOTS_COLOR_MAP: Final[Dict[str, int]] = {
+ "AxisBg": dpg.mvPlotCol_AxisBg,
+ "AxisBgActive": dpg.mvPlotCol_AxisBgActive,
+ "AxisBgHovered": dpg.mvPlotCol_AxisBgHovered,
+ "AxisGrid": dpg.mvPlotCol_AxisGrid,
+ "AxisText": dpg.mvPlotCol_AxisText,
+ "AxisTick": dpg.mvPlotCol_AxisTick,
+ "Crosshairs": dpg.mvPlotCol_Crosshairs,
+ "ErrorBar": dpg.mvPlotCol_ErrorBar,
"Fill": dpg.mvPlotCol_Fill,
"FrameBg": dpg.mvPlotCol_FrameBg,
+ "InlayText": dpg.mvPlotCol_InlayText,
+ "LegendBg": dpg.mvPlotCol_LegendBg,
+ "LegendBorder": dpg.mvPlotCol_LegendBorder,
+ "LegendText": dpg.mvPlotCol_LegendText,
"Line": dpg.mvPlotCol_Line,
+ "MarkerFill": dpg.mvPlotCol_MarkerFill,
+ "MarkerOutline": dpg.mvPlotCol_MarkerOutline,
"PlotBg": dpg.mvPlotCol_PlotBg,
+ "PlotBorder": dpg.mvPlotCol_PlotBorder,
+ "Selection": dpg.mvPlotCol_Selection,
+ "TitleText": dpg.mvPlotCol_TitleText,
}
CORE_STYLE_MAP: Final[Dict[str, int]] = {
@@ -85,6 +103,7 @@
"PopupRounding": dpg.mvStyleVar_PopupRounding,
"ScrollbarRounding": dpg.mvStyleVar_ScrollbarRounding,
"ScrollbarSize": dpg.mvStyleVar_ScrollbarSize,
+ "SelectableTextAlign": dpg.mvStyleVar_SelectableTextAlign,
"TabRounding": dpg.mvStyleVar_TabRounding,
"WindowBorderSize": dpg.mvStyleVar_WindowBorderSize,
"WindowPadding": dpg.mvStyleVar_WindowPadding,
@@ -92,7 +111,17 @@
}
PLOTS_STYLE_MAP: Final[Dict[str, int]] = {
+ "FillAlpha": dpg.mvPlotStyleVar_FillAlpha,
+ "LegendInnerPadding": dpg.mvPlotStyleVar_LegendInnerPadding,
+ "LegendPadding": dpg.mvPlotStyleVar_LegendPadding,
"LineWeight": dpg.mvPlotStyleVar_LineWeight,
+ "MajorGridSize": dpg.mvPlotStyleVar_MajorGridSize,
+ "MajorTickSize": dpg.mvPlotStyleVar_MajorTickSize,
+ "MarkerSize": dpg.mvPlotStyleVar_MarkerSize,
+ "MinorAlpha": dpg.mvPlotStyleVar_MinorAlpha,
+ "MinorGridSize": dpg.mvPlotStyleVar_MinorGridSize,
+ "PlotBorderSize": dpg.mvPlotStyleVar_PlotBorderSize,
+ "PlotPadding": dpg.mvPlotStyleVar_PlotPadding,
}
CATEGORY_MAP: Final[Dict[str, int]] = {
diff --git a/src/sampletones_application/ui/themes/inline.py b/src/sampletones_application/ui/themes/inline.py
index 17db911d..de518002 100644
--- a/src/sampletones_application/ui/themes/inline.py
+++ b/src/sampletones_application/ui/themes/inline.py
@@ -2,18 +2,20 @@
import dearpygui.dearpygui as dpg
-from sampletones_shared.types.application import ColorRGBA
+from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.faded import FadedColor
-def create_selectable_text_theme(color: ColorRGBA) -> int:
+def create_selectable_text_theme(color: BaseColor) -> int:
"""Builds a theme colouring selectable text, leaving its other colours to the global theme."""
return _create_selectable_theme({dpg.mvThemeCol_Text: color})
def create_header_selectable_theme(
- text_color: ColorRGBA,
- hovered_color: ColorRGBA,
- active_color: ColorRGBA,
+ text_color: BaseColor,
+ hovered_color: BaseColor,
+ active_color: BaseColor,
) -> int:
"""Builds a theme for a selectable that carries a table column's label.
@@ -30,7 +32,25 @@ def create_header_selectable_theme(
)
-def _create_selectable_theme(colors: Dict[int, ColorRGBA]) -> int:
+def create_label_selectable_theme(color: BaseColor) -> int:
+ """Builds a theme for a selectable that carries a label rather than a gesture.
+
+ Every header wash takes the label's own colour at zero alpha, so the cell reads as plain
+ text while it keeps the layout a selectable lays out with, which is what lets it line up
+ with the clickable labels beside it.
+ """
+ washed_out = FadedColor(color=color, fraction=0.0)
+ return _create_selectable_theme(
+ {
+ dpg.mvThemeCol_Text: color,
+ dpg.mvThemeCol_Header: washed_out,
+ dpg.mvThemeCol_HeaderHovered: washed_out,
+ dpg.mvThemeCol_HeaderActive: washed_out,
+ },
+ )
+
+
+def _create_selectable_theme(colors: Dict[int, BaseColor]) -> int:
"""Builds a theme carrying ``colors`` for a selectable in both enabled states.
DearPyGui resolves an item against the theme component that matches the
@@ -46,7 +66,7 @@ def _create_selectable_theme(colors: Dict[int, ColorRGBA]) -> int:
enabled_state=enabled_state,
):
for key, color in colors.items():
- dpg.add_theme_color(
+ dpg_add_palette_theme_color(
key,
color,
category=dpg.mvThemeCat_Core,
@@ -64,13 +84,12 @@ def create_vertical_spacer_theme() -> int:
below the group's top. The group stacks only vertically, so zeroing both axes leaves its
layout unchanged apart from that gap.
"""
- with dpg.theme() as theme:
- with dpg.theme_component(dpg.mvAll):
- dpg.add_theme_style(
- dpg.mvStyleVar_ItemSpacing,
- 0,
- 0,
- category=dpg.mvThemeCat_Core,
- )
+ with dpg.theme() as theme, dpg.theme_component(dpg.mvAll):
+ dpg.add_theme_style(
+ dpg.mvStyleVar_ItemSpacing,
+ 0,
+ 0,
+ category=dpg.mvThemeCat_Core,
+ )
return cast(int, theme)
diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py
index 8aead166..61ed7573 100644
--- a/src/sampletones_application/ui/themes/loader.py
+++ b/src/sampletones_application/ui/themes/loader.py
@@ -27,13 +27,9 @@
ThemeValue,
)
from sampletones_application.ui.themes.theme import Theme
-from sampletones_application.utils.palette import (
- ColorSource,
- Palette,
- PaletteReference,
-)
-from sampletones_core.paths import EXT_FILE_YAML
-from sampletones_shared.types.application import ColorRGBA
+from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
from sampletones_shared.utils.serialization import load_yaml
_BASE_THEME_NAME: Final[str] = "default"
@@ -47,9 +43,9 @@ class ThemeLoader:
describes both item states and stays authoritative wherever it is bound.
"""
- def __init__(self, theme_directory: Path, palette: Palette) -> None:
+ def __init__(self, theme_directory: Path, palette_source: PaletteSource) -> None:
self._directory = theme_directory
- self._palette = palette
+ self._context: Dict[str, PaletteSource] = {PALETTE_SOURCE_CONTEXT_KEY: palette_source}
def load_all(self) -> List[Theme]:
specs = self._load_specs()
@@ -76,7 +72,7 @@ def _load_specs(self) -> List[ThemeSpec]:
if not isinstance(raw, dict):
raise TypeError(f"Theme file {path} must contain a mapping, got {type(raw)}")
- specs.append(ThemeSpec.model_validate(raw))
+ specs.append(ThemeSpec.model_validate(raw, context=self._context))
return specs
@@ -134,7 +130,7 @@ def _entry_to_runtime(self, entry: ThemeEntrySpec) -> ThemeValue:
if isinstance(entry, ThemeColorEntrySpec):
return ThemeColor(
key=self._resolve_color_key(entry.key, entry.category),
- color=self._resolve_color(entry.value),
+ color=entry.value,
category=category,
)
@@ -145,12 +141,6 @@ def _entry_to_runtime(self, entry: ThemeEntrySpec) -> ThemeValue:
category=category,
)
- def _resolve_color(self, value: ColorSource) -> ColorRGBA:
- if isinstance(value, PaletteReference):
- return self._palette.resolve(value)
-
- return value
-
@classmethod
def _entry_key(
cls,
diff --git a/src/sampletones_application/ui/themes/setup.py b/src/sampletones_application/ui/themes/setup.py
index ed5031fb..5cbc3749 100644
--- a/src/sampletones_application/ui/themes/setup.py
+++ b/src/sampletones_application/ui/themes/setup.py
@@ -2,9 +2,9 @@
from sampletones_application.ui.themes.loader import ThemeLoader
from sampletones_application.ui.themes.registry import ThemeRegistry
-from sampletones_application.utils.palette import Palette
+from sampletones_application.utils.palette.source import PaletteSource
-def setup_themes(theme_directory: Path, palette: Palette) -> None:
- for theme in ThemeLoader(theme_directory, palette).load_all():
+def setup_themes(theme_directory: Path, palette_source: PaletteSource) -> None:
+ for theme in ThemeLoader(theme_directory, palette_source).load_all():
ThemeRegistry.register(theme)
diff --git a/src/sampletones_application/ui/themes/spec.py b/src/sampletones_application/ui/themes/spec.py
index 5014a51d..3eeb6371 100644
--- a/src/sampletones_application/ui/themes/spec.py
+++ b/src/sampletones_application/ui/themes/spec.py
@@ -4,13 +4,13 @@
from pydantic import BaseModel, Field
-from sampletones_application.utils.palette import ColorSource
+from sampletones_application.utils.palette.colors.written import WrittenColor
class ThemeColorEntrySpec(BaseModel, frozen=True):
type: Literal["color"]
key: str
- value: ColorSource
+ value: WrittenColor
category: str = "Core"
diff --git a/src/sampletones_application/ui/themes/style.py b/src/sampletones_application/ui/themes/style.py
index adec2c96..4b65108f 100644
--- a/src/sampletones_application/ui/themes/style.py
+++ b/src/sampletones_application/ui/themes/style.py
@@ -3,7 +3,7 @@
import dearpygui.dearpygui as dpg
-from sampletones_shared.types.application import Color
+from sampletones_application.utils.palette.colors.base import BaseColor
@dataclass(frozen=True, kw_only=True)
@@ -14,7 +14,7 @@ class ThemeValue:
@dataclass(frozen=True, kw_only=True)
class ThemeColor(ThemeValue):
- color: Color
+ color: BaseColor
@dataclass(frozen=True, kw_only=True)
diff --git a/src/sampletones_application/ui/themes/theme.py b/src/sampletones_application/ui/themes/theme.py
index c6abddd3..05db2472 100644
--- a/src/sampletones_application/ui/themes/theme.py
+++ b/src/sampletones_application/ui/themes/theme.py
@@ -11,7 +11,8 @@
ThemeStyle,
ThemeValue,
)
-from sampletones_shared.types.application import Color
+from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color
+from sampletones_shared.types.application import ColorRGBA
class Theme:
@@ -39,12 +40,15 @@ def _index(items: ThemeItems) -> ThemeDictionary:
return dictionary
- def create(self, *, override: bool = False) -> None:
- if not override and dpg.does_item_exist(self.tag):
- return
+ def create(self) -> None:
+ """Builds the DearPyGui theme once, registering each colour item it fills.
- if override and dpg.does_item_exist(self.tag):
- dpg.delete_item(self.tag)
+ DearPyGui copies a colour into the item at the call that fills it, so each one is
+ handed over through the palette bindings, which repaint the theme in place when
+ another palette is activated.
+ """
+ if dpg.does_item_exist(self.tag):
+ return
with dpg.theme(tag=self.tag):
for parameter, values in self._items.items.items():
@@ -54,7 +58,7 @@ def create(self, *, override: bool = False) -> None:
):
for item in values:
if isinstance(item, ThemeColor):
- dpg.add_theme_color(
+ dpg_add_palette_theme_color(
item.key,
item.color,
category=item.category,
@@ -97,7 +101,8 @@ def get_color(
*,
enabled_state: bool = True,
category: int = dpg.mvThemeCat_Core,
- ) -> Optional[Color]:
+ ) -> Optional[ColorRGBA]:
+ """The value a theme colour carries under the active palette."""
theme_item = self.get(
item_type,
key,
@@ -106,7 +111,7 @@ def get_color(
is_style=False,
)
if isinstance(theme_item, ThemeColor):
- return theme_item.color
+ return theme_item.color.rgba
return None
diff --git a/src/sampletones_application/utils/callbacks/queue.py b/src/sampletones_application/utils/callbacks/queue.py
index b80e72cd..d8c2d6e4 100644
--- a/src/sampletones_application/utils/callbacks/queue.py
+++ b/src/sampletones_application/utils/callbacks/queue.py
@@ -3,7 +3,7 @@
import heapq
import threading
import time
-from typing import Any, List
+from typing import Any, ClassVar, List
from sampletones_application.utils.callbacks.priority import CallbackPriority
from sampletones_application.utils.callbacks.task import CallbackTask
@@ -34,11 +34,11 @@ class CallbackQueue(metaclass=NonInstantiableMeta):
through its class methods.
"""
- _callbacks: List[CallbackTask] = []
- _lock: threading.Lock = threading.Lock()
- _frame_counter: int = 0
- _insertion_counter: int = 0
- _stopped: bool = False
+ _callbacks: ClassVar[List[CallbackTask]] = []
+ _lock: ClassVar[threading.Lock] = threading.Lock()
+ _frame_counter: ClassVar[int] = 0
+ _insertion_counter: ClassVar[int] = 0
+ _stopped: ClassVar[bool] = False
@classmethod
def start(cls) -> None:
diff --git a/src/sampletones_application/utils/file_dialogs/backends/portal/client.py b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py
index 2a7beea7..ba7ea4b9 100644
--- a/src/sampletones_application/utils/file_dialogs/backends/portal/client.py
+++ b/src/sampletones_application/utils/file_dialogs/backends/portal/client.py
@@ -13,8 +13,12 @@
from jeepney.io.blocking import DBusConnection, open_dbus_connection, unwrap_msg
from jeepney.low_level import Message
-from sampletones_application.utils.file_dialogs.backends.portal.parent import parent_window_handle
-from sampletones_application.utils.file_dialogs.backends.portal.response import ChooserResult
+from sampletones_application.utils.file_dialogs.backends.portal.parent import (
+ parent_window_handle,
+)
+from sampletones_application.utils.file_dialogs.backends.portal.response import (
+ ChooserResult,
+)
from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant
SESSION_BUS: Final[str] = "SESSION"
@@ -110,16 +114,19 @@ def call(
),
)
- with open_dbus_connection(bus=SESSION_BUS) as connection:
- with connection.filter(response_rule) as signals, connection.filter(owner_rule, queue=signals):
- connection.send_and_get_reply(message_bus.AddMatch(response_rule))
- connection.send_and_get_reply(message_bus.AddMatch(owner_rule))
- (handle,) = cast(Tuple[str], unwrap_msg(connection.send_and_get_reply(request)))
- return self._answer(
- connection,
- signals,
- handle,
- )
+ with (
+ open_dbus_connection(bus=SESSION_BUS) as connection,
+ connection.filter(response_rule) as signals,
+ connection.filter(owner_rule, queue=signals),
+ ):
+ connection.send_and_get_reply(message_bus.AddMatch(response_rule))
+ connection.send_and_get_reply(message_bus.AddMatch(owner_rule))
+ (handle,) = cast(Tuple[str], unwrap_msg(connection.send_and_get_reply(request)))
+ return self._answer(
+ connection,
+ signals,
+ handle,
+ )
@staticmethod
def _response_rule() -> MatchRule:
diff --git a/src/sampletones_application/utils/file_dialogs/filter.py b/src/sampletones_application/utils/file_dialogs/filter.py
index beec2bdb..1b6679f2 100644
--- a/src/sampletones_application/utils/file_dialogs/filter.py
+++ b/src/sampletones_application/utils/file_dialogs/filter.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
from dataclasses import dataclass
from itertools import chain
from typing import Iterable, Optional, Tuple
@@ -21,7 +23,7 @@ def for_extensions(
cls,
name: str,
extensions: Iterable[str],
- ) -> "FileFilter":
+ ) -> FileFilter:
"""
Returns the type matching ``extensions``, shown under ``name``.
diff --git a/src/sampletones_application/utils/frame_limiter.py b/src/sampletones_application/utils/frame_limiter.py
index f8971964..9e841a2b 100644
--- a/src/sampletones_application/utils/frame_limiter.py
+++ b/src/sampletones_application/utils/frame_limiter.py
@@ -11,9 +11,14 @@ class FrameLimiter:
"""
def __init__(self, max_fps: int) -> None:
- self._frame_budget: float = 1.0 / max_fps if max_fps > 0 else 0.0
+ self._frame_budget: float = self._budget_for(max_fps)
self._last_tick: Optional[float] = None
+ def set_max_fps(self, max_fps: int) -> None:
+ """Paces the following frames at ``max_fps``, timing the first of them from now."""
+ self._frame_budget = self._budget_for(max_fps)
+ self._last_tick = None
+
def tick(self) -> None:
if self._frame_budget <= 0.0:
return
@@ -26,3 +31,7 @@ def tick(self) -> None:
now += remaining
self._last_tick = now
+
+ @staticmethod
+ def _budget_for(max_fps: int) -> float:
+ return 1.0 / max_fps if max_fps > 0 else 0.0
diff --git a/src/sampletones_application/utils/gui/clipboard.py b/src/sampletones_application/utils/gui/clipboard.py
index df31e3c6..9b26290c 100644
--- a/src/sampletones_application/utils/gui/clipboard.py
+++ b/src/sampletones_application/utils/gui/clipboard.py
@@ -1,10 +1,29 @@
import threading
+from typing import Protocol, cast
import dearpygui.dearpygui as dpg
from sampletones_application.utils.gui.dpg import dpg_configure_item
+class TextClipboard(Protocol):
+ """The clipboard the desktop shares between applications, as text going out and coming back."""
+
+ def read(self) -> str: ...
+
+ def write(self, text: str) -> None: ...
+
+
+class SystemTextClipboard:
+ """The desktop's clipboard, reached through the one DearPyGui holds for the viewport."""
+
+ def read(self) -> str:
+ return cast(str, dpg.get_clipboard_text())
+
+ def write(self, text: str) -> None:
+ dpg.set_clipboard_text(text)
+
+
def copy_to_clipboard(
text: str,
label: str,
@@ -12,7 +31,7 @@ def copy_to_clipboard(
*,
copied_label: str,
) -> None:
- dpg.set_clipboard_text(text)
+ SystemTextClipboard().write(text)
dpg_configure_item(button_tag, label=copied_label)
diff --git a/src/sampletones_application/utils/gui/dialog_navigation/navigator.py b/src/sampletones_application/utils/gui/dialog_navigation/navigator.py
index 3f957ae9..ba4bb99a 100644
--- a/src/sampletones_application/utils/gui/dialog_navigation/navigator.py
+++ b/src/sampletones_application/utils/gui/dialog_navigation/navigator.py
@@ -6,7 +6,8 @@
from sampletones_application.utils.gui.dialog_navigation.stop import FocusStop
from sampletones_application.utils.gui.frame import FrameCallbackManager
from sampletones_application.utils.gui.keyboard import KeyEvent, KeyRouter
-from sampletones_application.utils.gui.keyboard.modifiers import Modifier
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
from sampletones_shared.types.callback import VoidCallback
@@ -27,12 +28,14 @@ def __init__(
stops: List[FocusStop],
on_escape: VoidCallback,
key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
initial_index: int = 0,
) -> None:
self._window_tag = window_tag
self._ring = FocusRing(stops, initial_index)
self._on_escape = on_escape
self._router = key_router
+ self._shortcuts = shortcut_source
self._disposed = False
def install(self) -> None:
@@ -56,15 +59,17 @@ def _focus_initial(self) -> None:
self._ring.focus_initial()
def handle_key(self, event: KeyEvent) -> None:
- """Routes Tab/Enter/Escape to the focus ring, disposing once the dialog has vanished."""
+ """Routes the dialog actions to the focus ring, disposing once the dialog has vanished."""
if not dpg.does_item_exist(self._window_tag):
self.dispose()
return
- match event.key:
- case dpg.mvKey_Tab:
- self._ring.cycle(-1 if Modifier.SHIFT in event.modifiers else 1)
- case dpg.mvKey_Return:
+ match self._shortcuts.action(ShortcutCategory.DIALOG, event):
+ case ShortcutId.DIALOG_NEXT_CONTROL:
+ self._ring.cycle(1)
+ case ShortcutId.DIALOG_PREVIOUS_CONTROL:
+ self._ring.cycle(-1)
+ case ShortcutId.DIALOG_ACTIVATE:
self._ring.activate_focused()
- case dpg.mvKey_Escape:
+ case ShortcutId.DIALOG_CANCEL:
self._on_escape()
diff --git a/src/sampletones_application/utils/gui/dialogs.py b/src/sampletones_application/utils/gui/dialogs.py
index d0811238..9cc89a16 100644
--- a/src/sampletones_application/utils/gui/dialogs.py
+++ b/src/sampletones_application/utils/gui/dialogs.py
@@ -42,6 +42,8 @@
dpg_delete_item,
)
from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
from sampletones_shared.types.callback import Callback, StringCallback, VoidCallback
_TEMPLATE_PLACEHOLDER: Pattern[str] = re.compile(r"\{(\w+)\}")
@@ -62,6 +64,7 @@ def _install_navigation(
stops: List[FocusStop],
on_escape: VoidCallback,
key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
initial_index: int = 0,
) -> DialogKeyboardNavigator:
"""Builds and installs the keyboard navigator that claims the keyboard for ``window_tag``."""
@@ -70,6 +73,7 @@ def _install_navigation(
stops=stops,
on_escape=on_escape,
key_router=key_router,
+ shortcut_source=shortcut_source,
initial_index=initial_index,
)
navigator.install()
@@ -85,6 +89,7 @@ def _show_modal_dialog(
width: int,
height: int,
key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
modal: bool = True,
) -> None:
ok_button_tag = compose_tag(tag, SUF_BUTTON_OK)
@@ -124,6 +129,7 @@ def close() -> None:
stops=[FocusStop.button(ok_button_tag, close)],
on_escape=close,
key_router=key_router,
+ shortcut_source=shortcut_source,
)
@@ -135,10 +141,12 @@ def __init__(
language_manager: LanguageManager,
status_bar: GUIStatusBar,
key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
) -> None:
self._language_manager = language_manager
self._status_bar = status_bar
self._router = key_router
+ self._shortcuts = shortcut_source
self._default_width = layout.dialogs.default.width
self._default_height = layout.dialogs.default.height
self._error_width = layout.dialogs.error.width
@@ -159,11 +167,6 @@ def __init__(
self._lbl_cancel = language_manager["global.dialog.label.cancel"]
self._lbl_traceback_show = language_manager["global.traceback.label.show"]
- @property
- def default_wrap(self) -> int:
- """Text wrap width matching the default dialog width, for caller-built content."""
- return self._default_wrap
-
def show_modal(
self,
tag: str,
@@ -180,6 +183,7 @@ def show_modal(
content,
ok_label=self._lbl_ok,
key_router=self._router,
+ shortcut_source=self._shortcuts,
width=width if width is not None else self._default_width,
height=height if height is not None else self._default_height,
modal=modal,
@@ -204,6 +208,7 @@ def content(parent: str) -> None:
content=content,
ok_label=self._lbl_ok,
key_router=self._router,
+ shortcut_source=self._shortcuts,
width=self._default_width,
height=self._default_height,
modal=modal,
@@ -244,12 +249,12 @@ def content(parent: str) -> None:
wrap=self._recovery_wrap,
)
for property_name in properties:
- dpg.add_text(
+ property_text = dpg.add_text(
f"- {property_name}",
parent=parent,
wrap=self._recovery_wrap,
- color=self._col_text_highlight,
)
+ dpg_set_palette_color(property_text, self._col_text_highlight)
dpg.add_text(
self._language_manager["global.dialog.message.configuration_recovery_path_prefix"],
@@ -273,6 +278,7 @@ def content(parent: str) -> None:
content=content,
ok_label=self._lbl_ok,
key_router=self._router,
+ shortcut_source=self._shortcuts,
width=self._recovery_width,
height=self._recovery_height,
modal=False,
@@ -348,17 +354,17 @@ def close() -> None:
group_tag = compose_tag(tag, SUF_GROUP)
with dpg.group(tag=group_tag, parent=tag):
- dpg.add_text(
- f"{str(type(exception).__name__)}: ",
+ name_text = dpg.add_text(
+ f"{type(exception).__name__!s}: ",
parent=group_tag,
- color=self._col_text_error,
)
- dpg.add_text(
+ dpg_set_palette_color(name_text, self._col_text_error)
+ message_text = dpg.add_text(
str(exception),
parent=group_tag,
wrap=self._error_wrap,
- color=self._col_text_error,
)
+ dpg_set_palette_color(message_text, self._col_text_error)
traceback = GUITraceback(
parent=tag,
@@ -404,6 +410,7 @@ def content(_: None) -> None:
],
on_escape=close,
key_router=self._router,
+ shortcut_source=self._shortcuts,
initial_index=1,
)
center_when_settled(tag)
@@ -413,12 +420,12 @@ def show_file_not_found(self, filepath: Path, message: str) -> None:
def content(parent: str) -> None:
dpg.add_text(message, parent=parent, wrap=self._error_wrap)
- dpg.add_text(
+ path_text = dpg.add_text(
str(filepath),
parent=parent,
- color=self._col_path,
wrap=self._error_wrap,
)
+ dpg_set_palette_color(path_text, self._col_path)
_show_modal_dialog(
tag=tag,
@@ -426,6 +433,7 @@ def content(parent: str) -> None:
content=content,
ok_label=self._lbl_ok,
key_router=self._router,
+ shortcut_source=self._shortcuts,
width=self._error_width,
height=self._default_height,
)
@@ -447,6 +455,9 @@ def show_confirmation(
) -> None:
"""Modal confirmation. ``on_confirm``/``on_cancel`` run on the respective choice.
+ The title bar's close button reads as the negative choice, so every way out of the
+ prompt reaches the caller and a dialog waiting behind it hears the answer.
+
``cancel_label`` names the negative button; it falls back to the shared Cancel label.
When ``opt_out_label`` is given, a checkbox is shown; if it is ticked when the user
confirms, ``on_opt_out`` runs as well — letting the caller suppress future prompts.
@@ -528,7 +539,7 @@ def buttons(_: None) -> None:
modal=True,
min_size=(self._default_width, self._confirmation_height),
no_resize=True,
- on_close=close,
+ on_close=_on_cancel,
):
_bind_dialog_theme(tag)
content(tag)
@@ -541,6 +552,7 @@ def buttons(_: None) -> None:
],
on_escape=_on_cancel,
key_router=self._router,
+ shortcut_source=self._shortcuts,
initial_index=1,
)
center_when_settled(tag)
@@ -643,6 +655,7 @@ def buttons(_: None) -> None:
],
on_escape=_on_cancel,
key_router=self._router,
+ shortcut_source=self._shortcuts,
initial_index=2,
)
center_when_settled(tag)
@@ -663,6 +676,7 @@ def content(parent: str) -> None:
content=content,
ok_label=self._lbl_ok,
key_router=self._router,
+ shortcut_source=self._shortcuts,
width=self._error_width,
height=self._default_height,
modal=False,
@@ -696,6 +710,7 @@ def content(parent: str) -> None:
content=content,
ok_label=self._lbl_ok,
key_router=self._router,
+ shortcut_source=self._shortcuts,
width=self._error_width,
height=self._default_height,
modal=False,
diff --git a/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py
index a8ff4820..4f1c9a31 100644
--- a/src/sampletones_application/utils/gui/dpg.py
+++ b/src/sampletones_application/utils/gui/dpg.py
@@ -1,10 +1,15 @@
import functools
+from contextlib import contextmanager
from typing import (
Any,
Callable,
Concatenate,
+ Final,
+ Iterator,
+ List,
Optional,
ParamSpec,
+ Tuple,
TypeVar,
cast,
)
@@ -13,11 +18,13 @@
from sampletones_application.ui.elements.button import GUIButton
from sampletones_shared.types.application import Sender
-from sampletones_shared.types.callback import Callback
+from sampletones_shared.types.callback import Callback, VoidCallback
P = ParamSpec("P")
R = TypeVar("R")
+SLOT_ITEMS: Final[int] = 1
+
def dpg_wrapper(
button_function: Optional[Callback] = None,
@@ -55,10 +62,43 @@ def dpg_delete_item(tag: Sender, /, *args: Any, **kwargs: Any) -> None:
dpg.delete_item(tag, *args, **kwargs)
-def dpg_delete_children(tag: Sender, /, *args: Any, **kwargs: Any) -> None:
+@contextmanager
+def dpg_container(tag: Sender) -> Iterator[None]:
+ """Makes ``tag`` the container parentless items land in for the length of the block.
+
+ A builder that states its items without naming a parent can then be pointed at any container,
+ which is how one set of items is built into the menu, popup or panel that asked for it.
+ """
+ dpg.push_container_stack(tag)
+ try:
+ yield
+ finally:
+ dpg.pop_container_stack()
+
+
+def dpg_delete_children(tag: Sender, /, *_args: Any, **kwargs: Any) -> None:
dpg_delete_item(tag, children_only=True, **kwargs)
+def dpg_item_children(tag: Sender) -> Tuple[Sender, ...]:
+ """The items the container holds, in the order they are drawn."""
+ children = cast(List[Sender], dpg.get_item_children(tag, SLOT_ITEMS))
+ return tuple(children)
+
+
+def dpg_append_items(tag: Sender, build: VoidCallback) -> Tuple[Sender, ...]:
+ """Runs ``build`` with ``tag`` open as the container, reporting the items it left there.
+
+ What one build stated is known by what the container gained, so a caller that rebuilds a
+ section takes exactly those items away again and leaves the rest of the container standing.
+ """
+ standing = len(dpg_item_children(tag))
+ with dpg_container(tag):
+ build()
+
+ return dpg_item_children(tag)[standing:]
+
+
def dpg_bind_item_theme(
tag: Sender,
theme_tag: Sender,
@@ -84,7 +124,7 @@ def dpg_get_item_parent(
"""
try:
parent: Optional[Sender] = dpg.get_item_parent(tag, *args, **kwargs)
- except Exception:
+ except Exception: # TODO: unsafe broad exception
return None
return parent
@@ -103,7 +143,7 @@ def dpg_set_item_callback(
*args: Any,
**kwargs: Any,
) -> None:
- dpg.set_item_callback(tag, callback=callback, *args, **kwargs)
+ dpg.set_item_callback(tag, *args, callback=callback, **kwargs)
@dpg_wrapper(button_function=GUIButton.set_item_label)
@@ -114,7 +154,7 @@ def dpg_set_item_label(
*args: Any,
**kwargs: Any,
) -> None:
- dpg.set_item_label(tag, label=label, *args, **kwargs)
+ dpg.set_item_label(tag, *args, label=label, **kwargs)
@dpg_wrapper(button_function=GUIButton.get_item_label)
diff --git a/src/sampletones_application/utils/gui/frame.py b/src/sampletones_application/utils/gui/frame.py
index af4285e1..9542c396 100644
--- a/src/sampletones_application/utils/gui/frame.py
+++ b/src/sampletones_application/utils/gui/frame.py
@@ -3,7 +3,7 @@
import heapq
import threading
from dataclasses import dataclass
-from typing import List
+from typing import ClassVar, List
import dearpygui.dearpygui as dpg
@@ -21,8 +21,8 @@ def __lt__(self, other: FrameCallback) -> bool:
class FrameCallbackManager(metaclass=NonInstantiableMeta):
- _callbacks: List[FrameCallback] = []
- _lock = threading.Lock()
+ _callbacks: ClassVar[List[FrameCallback]] = []
+ _lock: ClassVar[threading.Lock] = threading.Lock()
@classmethod
def set_frame_callback(
diff --git a/src/sampletones_application/utils/gui/keyboard/__init__.py b/src/sampletones_application/utils/gui/keyboard/__init__.py
index 1708d343..1d6e00ca 100644
--- a/src/sampletones_application/utils/gui/keyboard/__init__.py
+++ b/src/sampletones_application/utils/gui/keyboard/__init__.py
@@ -1,17 +1,21 @@
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
from sampletones_application.utils.gui.keyboard.event import KeyEvent
from sampletones_application.utils.gui.keyboard.router import (
PRIORITY_MODAL,
PRIORITY_PANEL,
PRIORITY_SHORTCUT,
+ ActivePredicate,
KeyRouter,
ModalKeyHandler,
)
__all__ = [
- "KeyEvent",
- "KeyRouter",
- "ModalKeyHandler",
"PRIORITY_MODAL",
"PRIORITY_PANEL",
"PRIORITY_SHORTCUT",
+ "ActivePredicate",
+ "KeyCombination",
+ "KeyEvent",
+ "KeyRouter",
+ "ModalKeyHandler",
]
diff --git a/src/sampletones_application/utils/gui/keyboard/capture.py b/src/sampletones_application/utils/gui/keyboard/capture.py
new file mode 100644
index 00000000..1bbb001e
--- /dev/null
+++ b/src/sampletones_application/utils/gui/keyboard/capture.py
@@ -0,0 +1,75 @@
+from typing import Optional, Tuple
+
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.utils.gui.keyboard.keys import is_named_key
+from sampletones_application.utils.gui.keyboard.modifiers import is_modifier_key
+from sampletones_application.utils.gui.keyboard.router import KeyRouter
+from sampletones_shared.types.callback import Callback, VoidCallback
+from sampletones_shared.utils.callbacks import CallbackMixin
+
+
+class KeyCapture(CallbackMixin):
+ """Reads one combination straight from the keyboard, for an editor assigning keys by press.
+
+ While it listens it sits on top of the router's modal stack, above the dialog that armed it, so
+ every press reaches here and the dialog's own navigation keys stay out of the way of a reader
+ pressing Tab or Enter as the combination they want. Listening ends with the first press that
+ names a key: the cancel combination the capture was given reports nothing, anything else reports
+ the combination it spells.
+
+ A press the key table names none of leaves the capture listening, and so does a modifier held on
+ its own: a modifier is what a combination is reached with, and a binding is kept as the name its
+ keys read under, so the reader presses again and the combination they meant is the one read.
+
+ Args:
+ key_router: The router whose modal stack the capture claims while it listens.
+ cancel: The combinations that end the capture, which a dialog reads from its own scheme.
+ """
+
+ def __init__(
+ self,
+ *,
+ key_router: KeyRouter,
+ cancel: Tuple[KeyCombination, ...],
+ ) -> None:
+ self._router = key_router
+ self._cancel = cancel
+ self._listening = False
+
+ self.on_captured: Optional[Callback] = None
+ self.on_cancelled: Optional[VoidCallback] = None
+
+ @property
+ def is_listening(self) -> bool:
+ """Whether the capture holds the keyboard, waiting for the press to read."""
+ return self._listening
+
+ def start(self) -> None:
+ """Takes the keyboard, leaving a capture already listening as it stands."""
+ if self._listening:
+ return
+
+ self._listening = True
+ self._router.push_modal(self)
+
+ def stop(self) -> None:
+ """Gives the keyboard back to the dialog beneath, once per claim."""
+ if not self._listening:
+ return
+
+ self._listening = False
+ self._router.pop_modal()
+
+ def handle_key(self, event: KeyEvent) -> None:
+ """Reads the press, reporting the combination it names once one arrives."""
+ if is_modifier_key(event.key) or not is_named_key(event.key):
+ return
+
+ combination = KeyCombination(event.key, event.modifiers)
+ self.stop()
+ if combination in self._cancel:
+ self.call(self.on_cancelled)
+ return
+
+ self.call(self.on_captured, combination)
diff --git a/src/sampletones_application/utils/gui/keyboard/combination.py b/src/sampletones_application/utils/gui/keyboard/combination.py
new file mode 100644
index 00000000..828e9ef8
--- /dev/null
+++ b/src/sampletones_application/utils/gui/keyboard/combination.py
@@ -0,0 +1,86 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Final, Set
+
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.utils.gui.keyboard.keys import (
+ is_named_key,
+ key_code,
+ key_display,
+)
+from sampletones_application.utils.gui.keyboard.modifiers import (
+ MODIFIER_NAMES,
+ NO_MODIFIERS,
+ Modifier,
+ ModifierSet,
+ modifiers_display,
+)
+
+COMBINATION_SEPARATOR: Final[str] = "+"
+
+
+@dataclass(frozen=True)
+class KeyCombination:
+ """A key together with the modifiers a press holds to reach it.
+
+ One combination answers both questions asked of a binding: how it reads wherever it is shown,
+ and whether a given press is the one it names. Writing it out and reading it back arrive at the
+ same combination, so a binding declared in code and one written in configuration are one value.
+ """
+
+ key: int
+ modifiers: ModifierSet = NO_MODIFIERS
+
+ @property
+ def is_writable(self) -> bool:
+ """Whether the combination reads back as itself once written down.
+
+ A combination is built from whatever code a press reports, while a binding is kept as text,
+ so an entry a scheme can hold is one whose key the table names.
+ """
+ return is_named_key(self.key)
+
+ def matches(self, event: KeyEvent) -> bool:
+ """Whether ``event`` is a press of this combination.
+
+ Args:
+ event: The press to test, carrying the modifiers held as it fired.
+
+ Returns:
+ bool: True while the event names this key under exactly these modifiers.
+ """
+ return event.key == self.key and event.modifiers == self.modifiers
+
+ def display(self) -> str:
+ """The combination as it reads, its modifiers in canonical order ahead of the key."""
+ return COMBINATION_SEPARATOR.join((*modifiers_display(self.modifiers), key_display(self.key)))
+
+ @classmethod
+ def parse(cls, text: str) -> KeyCombination:
+ """The combination a written form such as ``"Ctrl+Shift+Z"`` names.
+
+ Leading parts that name a modifier are read as modifiers and everything after them is the
+ key, so a key written with the separator itself keeps it: ``"Ctrl++"`` reads as Ctrl and the
+ plus key.
+
+ Args:
+ text: A combination as :meth:`display` writes it, in any capitalisation.
+
+ Returns:
+ KeyCombination: The combination the text names.
+
+ Raises:
+ KeyError: If the part left after the modifiers names no key.
+ """
+ parts = text.split(COMBINATION_SEPARATOR)
+ modifiers: Set[Modifier] = set()
+ index = 0
+ while index < len(parts) - 1 and parts[index].casefold() in MODIFIER_NAMES:
+ modifiers.add(MODIFIER_NAMES[parts[index].casefold()])
+ index += 1
+
+ return cls(
+ key=key_code(COMBINATION_SEPARATOR.join(parts[index:])),
+ modifiers=frozenset(modifiers),
+ )
diff --git a/src/sampletones_application/utils/gui/keyboard/focus/consumption.py b/src/sampletones_application/utils/gui/keyboard/focus/consumption.py
index c684429c..4634ab14 100644
--- a/src/sampletones_application/utils/gui/keyboard/focus/consumption.py
+++ b/src/sampletones_application/utils/gui/keyboard/focus/consumption.py
@@ -3,6 +3,7 @@
import dearpygui.dearpygui as dpg
from sampletones_application.utils.gui.keyboard.focus.kind import FieldKind
+from sampletones_application.utils.gui.keyboard.keys import FUNCTION_KEYS
from sampletones_application.utils.gui.keyboard.modifiers import Modifier, ModifierSet
EDITING_KEYS: Final[FrozenSet[int]] = frozenset(
@@ -38,23 +39,6 @@
frozenset({Modifier.CTRL, Modifier.SHIFT}): frozenset({dpg.mvKey_Z}),
}
-FUNCTION_KEYS: Final[FrozenSet[int]] = frozenset(
- {
- dpg.mvKey_F1,
- dpg.mvKey_F2,
- dpg.mvKey_F3,
- dpg.mvKey_F4,
- dpg.mvKey_F5,
- dpg.mvKey_F6,
- dpg.mvKey_F7,
- dpg.mvKey_F8,
- dpg.mvKey_F9,
- dpg.mvKey_F10,
- dpg.mvKey_F11,
- dpg.mvKey_F12,
- }
-)
-
def field_consumes_key(kind: FieldKind, key: int, modifiers: ModifierSet) -> bool:
"""Whether a focused field of ``kind`` acts on this key, so a matching shortcut yields to it.
diff --git a/src/sampletones_application/utils/gui/keyboard/keys.py b/src/sampletones_application/utils/gui/keyboard/keys.py
new file mode 100644
index 00000000..5042625a
--- /dev/null
+++ b/src/sampletones_application/utils/gui/keyboard/keys.py
@@ -0,0 +1,183 @@
+from typing import Dict, Final, FrozenSet
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_shared.constants.symbols import HEXADECIMAL, MINUS, PLUS
+
+KEY_PAGE_UP: Final[int] = 517
+KEY_PAGE_DOWN: Final[int] = 518
+KEY_LEFT_SUPER: Final[int] = 530
+KEY_RIGHT_SUPER: Final[int] = 534
+KEY_QUOTE: Final[int] = 596
+KEY_SEMICOLON: Final[int] = 601
+KEY_PLUS: Final[int] = 602
+KEY_TILDE: Final[int] = 606
+KEY_MODIFIER_CTRL: Final[int] = 663
+KEY_MODIFIER_SHIFT: Final[int] = 664
+KEY_MODIFIER_ALT: Final[int] = 665
+KEY_MODIFIER_SUPER: Final[int] = 666
+
+UNKNOWN_KEY: Final[str] = "?"
+
+LETTER_COUNT: Final[int] = 26
+DIGIT_COUNT: Final[int] = 10
+FUNCTION_KEY_COUNT: Final[int] = 24
+
+LETTER_NAMES: Final[Dict[int, str]] = {dpg.mvKey_A + offset: chr(ord("A") + offset) for offset in range(LETTER_COUNT)}
+DIGIT_NAMES: Final[Dict[int, str]] = {dpg.mvKey_0 + offset: str(offset) for offset in range(DIGIT_COUNT)}
+FUNCTION_KEY_NAMES: Final[Dict[int, str]] = {
+ dpg.mvKey_F1 + offset: f"F{offset + 1}" for offset in range(FUNCTION_KEY_COUNT)
+}
+KEYPAD_DIGIT_NAMES: Final[Dict[int, str]] = {
+ dpg.mvKey_NumPad0 + offset: f"Num{offset}" for offset in range(DIGIT_COUNT)
+}
+
+FUNCTION_KEYS: Final[FrozenSet[int]] = frozenset(FUNCTION_KEY_NAMES)
+
+KEY_DISPLAY_NAMES: Final[Dict[int, str]] = {
+ **LETTER_NAMES,
+ **DIGIT_NAMES,
+ **FUNCTION_KEY_NAMES,
+ **KEYPAD_DIGIT_NAMES,
+ dpg.mvKey_Escape: "Esc",
+ dpg.mvKey_Return: "Enter",
+ dpg.mvKey_Tab: "Tab",
+ dpg.mvKey_Spacebar: "Space",
+ dpg.mvKey_Back: "Backspace",
+ dpg.mvKey_Delete: "Del",
+ dpg.mvKey_Insert: "Ins",
+ dpg.mvKey_Home: "Home",
+ dpg.mvKey_End: "End",
+ KEY_PAGE_UP: "PgUp",
+ KEY_PAGE_DOWN: "PgDn",
+ dpg.mvKey_Up: "Up",
+ dpg.mvKey_Down: "Down",
+ dpg.mvKey_Left: "Left",
+ dpg.mvKey_Right: "Right",
+ dpg.mvKey_Menu: "Menu",
+ dpg.mvKey_CapsLock: "CapsLock",
+ dpg.mvKey_ScrollLock: "ScrollLock",
+ dpg.mvKey_NumLock: "NumLock",
+ dpg.mvKey_Print: "PrintScreen",
+ dpg.mvKey_Pause: "Pause",
+ dpg.mvKey_Comma: "Comma",
+ dpg.mvKey_Period: "Period",
+ dpg.mvKey_Slash: "Slash",
+ dpg.mvKey_Backslash: "Backslash",
+ dpg.mvKey_Open_Brace: "LeftBracket",
+ dpg.mvKey_Close_Brace: "RightBracket",
+ KEY_SEMICOLON: "Semicolon",
+ KEY_QUOTE: "Quote",
+ KEY_TILDE: "Tilde",
+ dpg.mvKey_Minus: "Minus",
+ KEY_PLUS: "Plus",
+ dpg.mvKey_Subtract: "NumMinus",
+ dpg.mvKey_Add: "NumPlus",
+ dpg.mvKey_Decimal: "NumDot",
+ dpg.mvKey_Divide: "NumSlash",
+ dpg.mvKey_Multiply: "NumStar",
+ dpg.mvKey_NumPadEnter: "NumEnter",
+ dpg.mvKey_NumPadEqual: "NumEqual",
+}
+
+KEY_NAME_ALIASES: Final[Dict[str, str]] = {
+ PLUS: "Plus",
+ "=": "Plus",
+ "Equal": "Plus",
+ MINUS: "Minus",
+ f"Num{PLUS}": "NumPlus",
+ f"Num{MINUS}": "NumMinus",
+ "Add": "NumPlus",
+ "Subtract": "NumMinus",
+ ",": "Comma",
+ ".": "Period",
+ "/": "Slash",
+ "\\": "Backslash",
+ ";": "Semicolon",
+ "'": "Quote",
+ "`": "Tilde",
+ "~": "Tilde",
+ "[": "LeftBracket",
+ "]": "RightBracket",
+ "Grave": "Tilde",
+ "Apostrophe": "Quote",
+ "Escape": "Esc",
+ "Return": "Enter",
+ "Delete": "Del",
+ "Insert": "Ins",
+ "Back": "Backspace",
+ "Spacebar": "Space",
+ "PageUp": "PgUp",
+ "PageDown": "PgDn",
+ "PrtScr": "PrintScreen",
+}
+
+_CANONICAL_KEY_CODES: Final[Dict[str, int]] = {name.casefold(): key for key, name in KEY_DISPLAY_NAMES.items()}
+
+KEY_CODES: Final[Dict[str, int]] = {
+ **_CANONICAL_KEY_CODES,
+ **{alias.casefold(): _CANONICAL_KEY_CODES[name.casefold()] for alias, name in KEY_NAME_ALIASES.items()},
+}
+
+
+HEX_KEYS: Final[Dict[int, str]] = {dpg.mvKey_0 + offset: HEXADECIMAL[offset] for offset in range(DIGIT_COUNT)} | {
+ dpg.mvKey_A + offset: HEXADECIMAL[DIGIT_COUNT + offset] for offset in range(len(HEXADECIMAL) - DIGIT_COUNT)
+}
+
+SIGN_KEYS: Final[Dict[int, str]] = {
+ dpg.mvKey_Minus: MINUS,
+ dpg.mvKey_Subtract: MINUS,
+ KEY_PLUS: PLUS,
+ dpg.mvKey_Add: PLUS,
+}
+
+
+def is_named_key(key: int) -> bool:
+ """Whether the table names the key, which is what lets a binding on it be written down.
+
+ A press reports whatever code the keyboard sends, while a binding is kept as the name its keys
+ read under, so an editor assigns the keys the table answers for.
+
+ Args:
+ key: The key code a press carries.
+
+ Returns:
+ bool: True while the key carries a name :func:`key_code` reads back into it.
+ """
+ return key in KEY_DISPLAY_NAMES
+
+
+def key_display(key: int) -> str:
+ """The name a key reads under, falling back to a placeholder for a key the table omits.
+
+ Args:
+ key: The key code a press carries.
+
+ Returns:
+ str: The name the key shows wherever a combination is displayed.
+ """
+ return KEY_DISPLAY_NAMES.get(key, UNKNOWN_KEY)
+
+
+def key_code(name: str) -> int:
+ """The key a written name stands for, however the name is capitalised.
+
+ Reading a name back into a code is what lets a binding be written down, so a configured
+ combination and a declared one arrive at the same key. A key answers to the name it displays
+ under and to the further spellings a reader is likely to write, so ``Plus``, ``+`` and ``=``
+ all reach the one key that carries them.
+
+ Args:
+ name: A key name as :func:`key_display` writes it, or an accepted spelling of one.
+
+ Returns:
+ int: The key code the name stands for.
+
+ Raises:
+ KeyError: If the table holds no key under that name.
+ """
+ key = KEY_CODES.get(name.casefold())
+ if key is None:
+ raise KeyError(f"No key carries the name {name!r}")
+
+ return key
diff --git a/src/sampletones_application/utils/gui/keyboard/modifiers.py b/src/sampletones_application/utils/gui/keyboard/modifiers.py
index 61a75b8e..0cab9693 100644
--- a/src/sampletones_application/utils/gui/keyboard/modifiers.py
+++ b/src/sampletones_application/utils/gui/keyboard/modifiers.py
@@ -1,12 +1,23 @@
-from enum import Enum
+from enum import StrEnum
from typing import Dict, Final, FrozenSet, Tuple
import dearpygui.dearpygui as dpg
+from sampletones_application.utils.gui.keyboard.keys import (
+ KEY_LEFT_SUPER,
+ KEY_MODIFIER_ALT,
+ KEY_MODIFIER_CTRL,
+ KEY_MODIFIER_SHIFT,
+ KEY_MODIFIER_SUPER,
+ KEY_RIGHT_SUPER,
+)
+from sampletones_shared.utils.system.system import System
-class Modifier(Enum):
+
+class Modifier(StrEnum):
"""A modifier key a press carries, declared in the order a combination displays them."""
+ SUPER = "Super"
CTRL = "Ctrl"
ALT = "Alt"
SHIFT = "Shift"
@@ -18,16 +29,65 @@ class Modifier(Enum):
CTRL: Final[ModifierSet] = frozenset({Modifier.CTRL})
ALT: Final[ModifierSet] = frozenset({Modifier.ALT})
SHIFT: Final[ModifierSet] = frozenset({Modifier.SHIFT})
+SUPER: Final[ModifierSet] = frozenset({Modifier.SUPER})
CTRL_ALT: Final[ModifierSet] = frozenset({Modifier.CTRL, Modifier.ALT})
CTRL_SHIFT: Final[ModifierSet] = frozenset({Modifier.CTRL, Modifier.SHIFT})
CTRL_ALT_SHIFT: Final[ModifierSet] = frozenset({Modifier.CTRL, Modifier.ALT, Modifier.SHIFT})
+MODIFIER_NAMES: Final[Dict[str, Modifier]] = {
+ "ctrl": Modifier.CTRL,
+ "control": Modifier.CTRL,
+ "alt": Modifier.ALT,
+ "opt": Modifier.ALT,
+ "option": Modifier.ALT,
+ "shift": Modifier.SHIFT,
+ "super": Modifier.SUPER,
+ "cmd": Modifier.SUPER,
+ "command": Modifier.SUPER,
+ "meta": Modifier.SUPER,
+ "win": Modifier.SUPER,
+}
+
+SUPER_DISPLAY_NAMES: Final[Dict[System, str]] = {
+ System.LINUX: "Super",
+ System.WINDOWS: "Win",
+ System.MACOS: "Cmd",
+}
+
MODIFIER_KEYS: Final[Dict[Modifier, Tuple[int, int]]] = {
+ Modifier.SUPER: (KEY_LEFT_SUPER, KEY_RIGHT_SUPER),
Modifier.CTRL: (dpg.mvKey_LControl, dpg.mvKey_RControl),
Modifier.ALT: (dpg.mvKey_LAlt, dpg.mvKey_RAlt),
Modifier.SHIFT: (dpg.mvKey_LShift, dpg.mvKey_RShift),
}
+RESERVED_MODIFIER_KEYS: Final[Dict[Modifier, int]] = {
+ Modifier.SUPER: KEY_MODIFIER_SUPER,
+ Modifier.CTRL: KEY_MODIFIER_CTRL,
+ Modifier.ALT: KEY_MODIFIER_ALT,
+ Modifier.SHIFT: KEY_MODIFIER_SHIFT,
+}
+
+MODIFIER_KEY_CODES: Final[FrozenSet[int]] = frozenset(
+ key for keys in MODIFIER_KEYS.values() for key in keys
+) | frozenset(RESERVED_MODIFIER_KEYS.values())
+
+
+def is_modifier_key(key: int) -> bool:
+ """Whether a press carries a modifier rather than the key a combination is built around.
+
+ A modifier reaches a handler twice: under the key that carries it, and under the code ImGui
+ reserves for the modifier itself. Both answer here, so an editor waiting for a combination keeps
+ listening while either arrives.
+
+ Args:
+ key: The key code a press carries.
+
+ Returns:
+ bool: True while the press is a modifier being held.
+ """
+ return key in MODIFIER_KEY_CODES
+
def capture_modifiers() -> ModifierSet:
"""The modifiers held at the moment of the call, as DearPyGui reports their keys.
@@ -37,10 +97,24 @@ def capture_modifiers() -> ModifierSet:
return frozenset(modifier for modifier, keys in MODIFIER_KEYS.items() if any(dpg.is_key_down(key) for key in keys))
+def modifier_display(modifier: Modifier) -> str:
+ """The name a modifier reads under on the platform in use.
+
+ One key wears three names across the platforms — Command on macOS, Windows on Windows, Super on
+ Linux — so a combination reads the way the keyboard in front of the reader is labelled. Every
+ spelling stays readable everywhere through :data:`MODIFIER_NAMES`, which lets a scheme written
+ for one platform be read on another.
+ """
+ if modifier is Modifier.SUPER:
+ return SUPER_DISPLAY_NAMES[System.current()]
+
+ return modifier.value
+
+
def modifiers_display(modifiers: ModifierSet) -> Tuple[str, ...]:
- """The display names of ``modifiers`` in the conventional Ctrl, Alt, Shift order.
+ """The display names of ``modifiers`` in the conventional Super, Ctrl, Alt, Shift order.
Ordering by the declaration of :class:`Modifier` gives one combination one spelling wherever it
is shown, whatever order the caller named its modifiers in.
"""
- return tuple(modifier.value for modifier in Modifier if modifier in modifiers)
+ return tuple(modifier_display(modifier) for modifier in Modifier if modifier in modifiers)
diff --git a/src/sampletones_application/utils/gui/keyboard/router.py b/src/sampletones_application/utils/gui/keyboard/router.py
index f9837e73..fe80f556 100644
--- a/src/sampletones_application/utils/gui/keyboard/router.py
+++ b/src/sampletones_application/utils/gui/keyboard/router.py
@@ -118,5 +118,5 @@ def _route_modal(self, event: KeyEvent) -> bool:
self._modal_stack[-1].handle_key(event)
return True
- def _dispatch(self, sender: Sender, app_data: int) -> None:
+ def _dispatch(self, _sender: Sender, app_data: int) -> None:
self.route(KeyEvent.capture(app_data))
diff --git a/src/sampletones_application/utils/gui/palette/__init__.py b/src/sampletones_application/utils/gui/palette/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/utils/gui/palette/binding.py b/src/sampletones_application/utils/gui/palette/binding.py
new file mode 100644
index 00000000..30c99e1a
--- /dev/null
+++ b/src/sampletones_application/utils/gui/palette/binding.py
@@ -0,0 +1,37 @@
+from dataclasses import dataclass
+from typing import Final, Tuple, Union
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_shared.types.application import Sender
+
+ArgumentKey = Tuple[Sender, str]
+
+COLOR_ARGUMENT: Final[str] = "color"
+
+
+@dataclass(frozen=True)
+class ArgumentBinding:
+ """A colour DearPyGui copied into one of an item's arguments."""
+
+ item: Sender
+ color: BaseColor
+ argument: str
+
+ def push(self) -> None:
+ dpg.configure_item(self.item, **{self.argument: self.color.rgba})
+
+
+@dataclass(frozen=True)
+class ThemeColorBinding:
+ """A colour DearPyGui copied into a theme colour item."""
+
+ item: Sender
+ color: BaseColor
+
+ def push(self) -> None:
+ dpg.set_value(self.item, self.color.rgba)
+
+
+PaletteBinding = Union[ArgumentBinding, ThemeColorBinding]
diff --git a/src/sampletones_application/utils/gui/palette/dpg.py b/src/sampletones_application/utils/gui/palette/dpg.py
new file mode 100644
index 00000000..6adbcf42
--- /dev/null
+++ b/src/sampletones_application/utils/gui/palette/dpg.py
@@ -0,0 +1,51 @@
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.utils.gui.palette.binding import COLOR_ARGUMENT
+from sampletones_application.utils.gui.palette.palette import PaletteBindings
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_shared.types.application import Sender
+
+
+def dpg_set_palette_color(
+ item: Sender,
+ color: BaseColor,
+ *,
+ argument: str = COLOR_ARGUMENT,
+) -> None:
+ """Colours an item so it follows the palette, in place of passing ``color=`` to DearPyGui.
+
+ Args:
+ item: Item to colour.
+ color: Token the colour is read from, kept for the next palette in place.
+ argument: Name of the item's colour argument, for an item that carries more than one.
+ """
+ PaletteBindings.bind(
+ item,
+ color,
+ argument=argument,
+ )
+
+
+def dpg_add_palette_theme_color(
+ key: int,
+ color: BaseColor,
+ *,
+ category: int = dpg.mvThemeCat_Core,
+) -> Sender:
+ """Adds a theme colour that follows the palette, inside an open theme component.
+
+ Args:
+ key: Theme colour constant the value fills, such as ``dpg.mvThemeCol_Text``.
+ color: Token the colour is read from, kept for the next palette in place.
+ category: Theme category the constant belongs to.
+
+ Returns:
+ Sender: The theme colour item, which repaints every widget bound to the theme.
+ """
+ item: Sender = dpg.add_theme_color(
+ key,
+ color.rgba,
+ category=category,
+ )
+ PaletteBindings.bind_theme_color(item, color)
+ return item
diff --git a/src/sampletones_application/utils/gui/palette/palette.py b/src/sampletones_application/utils/gui/palette/palette.py
new file mode 100644
index 00000000..e060e4ac
--- /dev/null
+++ b/src/sampletones_application/utils/gui/palette/palette.py
@@ -0,0 +1,82 @@
+from itertools import chain
+from typing import ClassVar, Dict, Iterator
+
+import dearpygui.dearpygui as dpg
+
+from sampletones_application.utils.gui.palette.binding import (
+ COLOR_ARGUMENT,
+ ArgumentBinding,
+ ArgumentKey,
+ PaletteBinding,
+ ThemeColorBinding,
+)
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_shared.types.application import Sender
+
+
+class PaletteBindings:
+ """Every colour DearPyGui holds a copy of, and the token each copy came from.
+
+ DearPyGui reads an item's colour argument and a theme colour item once, at the call that
+ fills it, so those copies keep the shade of the palette that was active then. Handing a
+ colour over through this registry keeps the :class:`BaseColor` alongside the copy, and
+ :meth:`apply` hands DearPyGui the value each token carries now.
+
+ One argument of one item holds one colour, so binding it again replaces what is recorded
+ for it: an item recoloured on every hover stays a single entry.
+ """
+
+ _arguments: ClassVar[Dict[ArgumentKey, ArgumentBinding]] = {}
+ _theme_colors: ClassVar[Dict[Sender, ThemeColorBinding]] = {}
+
+ @classmethod
+ def bind(
+ cls,
+ item: Sender,
+ color: BaseColor,
+ *,
+ argument: str = COLOR_ARGUMENT,
+ ) -> None:
+ """Colours one of an item's arguments now, and keeps the token behind it."""
+ binding = ArgumentBinding(
+ item=item,
+ color=color,
+ argument=argument,
+ )
+ binding.push()
+ cls._arguments[item, argument] = binding
+
+ @classmethod
+ def bind_theme_color(cls, item: Sender, color: BaseColor) -> None:
+ """Keeps the token behind a theme colour item the caller has just filled."""
+ cls._theme_colors[item] = ThemeColorBinding(
+ item=item,
+ color=color,
+ )
+
+ @classmethod
+ def apply(cls) -> None:
+ """Hands DearPyGui the value every registered token carries now.
+
+ Bindings whose item has since been deleted are dropped, so the registry tracks the
+ items that are alive and a long session's worth of transient widgets leaves nothing
+ behind.
+ """
+ cls._arguments = {key: binding for key, binding in cls._arguments.items() if cls._is_live(binding)}
+ cls._theme_colors = {key: binding for key, binding in cls._theme_colors.items() if cls._is_live(binding)}
+ for binding in cls.bindings():
+ binding.push()
+
+ @classmethod
+ def bindings(cls) -> Iterator[PaletteBinding]:
+ """Every colour copy the registry currently tracks."""
+ return chain(cls._arguments.values(), cls._theme_colors.values())
+
+ @classmethod
+ def clear(cls) -> None:
+ cls._arguments.clear()
+ cls._theme_colors.clear()
+
+ @staticmethod
+ def _is_live(binding: PaletteBinding) -> bool:
+ return bool(dpg.does_item_exist(binding.item))
diff --git a/src/sampletones_application/utils/gui/shortcuts/catalog.py b/src/sampletones_application/utils/gui/shortcuts/catalog.py
new file mode 100644
index 00000000..73f503a8
--- /dev/null
+++ b/src/sampletones_application/utils/gui/shortcuts/catalog.py
@@ -0,0 +1,79 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, Tuple
+
+from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME
+from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
+from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
+
+
+@dataclass(frozen=True)
+class ShortcutCatalog:
+ """The keybinding schemes a build ships, indexed by name so a stored preference selects one.
+
+ A scheme file is named after the scheme it holds, which makes the name a reader
+ states in a preference the same name they find on disk.
+ """
+
+ schemes: Dict[str, ShortcutScheme]
+
+ @classmethod
+ def load(cls, directory: Path) -> ShortcutCatalog:
+ """Load every scheme the directory holds, ordered by name.
+
+ Raises:
+ SystemError: when the directory holds no scheme, or omits the default one.
+ ValueError: when a scheme's name differs from its file stem.
+ """
+ schemes: Dict[str, ShortcutScheme] = {}
+ for path in sorted(directory.glob(f"*{EXT_FILE_YAML}")):
+ scheme = ShortcutScheme.load(path)
+ if scheme.name != path.stem:
+ raise ValueError(f"Keybinding file '{path}' holds scheme {scheme.name!r}; the two names must match")
+
+ schemes[scheme.name] = scheme
+
+ if not schemes:
+ raise SystemError(f"Keybinding directory '{directory}' holds no scheme")
+
+ if DEFAULT_SCHEME_NAME not in schemes:
+ raise SystemError(
+ f"Keybinding directory '{directory}' omits the default scheme {DEFAULT_SCHEME_NAME!r}. "
+ f"Available schemes: {sorted(schemes)}"
+ )
+
+ return cls(schemes=dict(sorted(schemes.items())))
+
+ @property
+ def names(self) -> Tuple[str, ...]:
+ return tuple(self.schemes)
+
+ @property
+ def default(self) -> ShortcutScheme:
+ return self.schemes[DEFAULT_SCHEME_NAME]
+
+ def get(self, name: str) -> ShortcutScheme:
+ """The scheme of the given name.
+
+ Raises:
+ KeyError: when the catalog holds no scheme of that name.
+ """
+ if name not in self.schemes:
+ raise KeyError(f"Unknown keybinding scheme {name!r}. Available schemes: {sorted(self.schemes)}")
+
+ return self.schemes[name]
+
+ def select(self, name: str) -> ShortcutScheme:
+ """The scheme a stored preference names, falling back to the default.
+
+ A preference outlives the build that wrote it, so a name a later build stopped
+ shipping resolves to the default and the application keeps its keys.
+ """
+ if name not in self.schemes:
+ logger.warning(f"Unknown keybinding scheme {name!r}, falling back to {DEFAULT_SCHEME_NAME!r}")
+ return self.default
+
+ return self.schemes[name]
diff --git a/src/sampletones_application/utils/gui/shortcuts/draft.py b/src/sampletones_application/utils/gui/shortcuts/draft.py
new file mode 100644
index 00000000..94a4f725
--- /dev/null
+++ b/src/sampletones_application/utils/gui/shortcuts/draft.py
@@ -0,0 +1,156 @@
+from __future__ import annotations
+
+from dataclasses import dataclass, replace
+from typing import Dict, Mapping, Optional, Tuple
+
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
+
+
+@dataclass(frozen=True)
+class ShortcutDraft:
+ """The keys a reader is giving the actions, held apart from the ones the application runs under.
+
+ An editor works on a draft and hands a scheme over once, which leaves the keys in force steady
+ while Escape, Tab and Enter are themselves being rebound. A draft is kept as the actions the
+ reader touched and what they gave them, since an override replaces a whole binding: every other
+ action answers the scheme the build ships, and the touched entries are what a session stores.
+ """
+
+ base: ShortcutScheme
+ stored: Dict[ShortcutId, Optional[KeyCombination]]
+ edits: Dict[ShortcutId, Optional[KeyCombination]]
+
+ @classmethod
+ def open(
+ cls,
+ base: ShortcutScheme,
+ overrides: Mapping[str, Optional[str]],
+ ) -> ShortcutDraft:
+ """A draft of the scheme a build ships, opened on the keys a session stores.
+
+ The stored preference is read through the scheme, so a draft starts from bindings that
+ already resolve and an entry a later build stopped carrying stays behind with the rest of
+ the preference in place.
+
+ Args:
+ base: The scheme as the build ships it, which the draft states its edits against.
+ overrides: The combination each rebound action answers to, keyed by the action's name.
+
+ Returns:
+ ShortcutDraft: The draft holding what the session stores and that alone.
+ """
+ preferred = base.with_overrides(overrides)
+ stored: Dict[ShortcutId, Optional[KeyCombination]] = {
+ shortcut_id: preferred.shortcut(shortcut_id).combination
+ for shortcut_id in ShortcutId
+ if preferred.shortcut(shortcut_id) != base.shortcut(shortcut_id)
+ }
+
+ return cls(
+ base=base,
+ stored=stored,
+ edits=dict(stored),
+ )
+
+ @property
+ def is_dirty(self) -> bool:
+ """Whether the draft holds keys the session has yet to store."""
+ return self.edits != self.stored
+
+ def combination(self, shortcut_id: ShortcutId) -> Optional[KeyCombination]:
+ """The keys an action answers to as the draft stands, ``None`` while it is unbound."""
+ if shortcut_id in self.edits:
+ return self.edits[shortcut_id]
+
+ return self.base.shortcut(shortcut_id).combination
+
+ def claimant(
+ self,
+ shortcut_id: ShortcutId,
+ combination: KeyCombination,
+ ) -> Optional[ShortcutId]:
+ """The action holding ``combination`` in the category ``shortcut_id`` belongs to.
+
+ An editor asks before it assigns, so a reader is told which action they are taking the keys
+ from and the assignment stays theirs to confirm.
+
+ Args:
+ shortcut_id: The action the combination is meant for, whose category answers it.
+ combination: The keys to look up.
+
+ Returns:
+ Optional[ShortcutId]: The action the combination reaches, ``None`` while it is free for
+ the asking action to take.
+ """
+ for other in ShortcutId:
+ if other is shortcut_id or other.category is not shortcut_id.category:
+ continue
+
+ if combination in self._claimed(other):
+ return other
+
+ return None
+
+ def assign(
+ self,
+ shortcut_id: ShortcutId,
+ combination: KeyCombination,
+ ) -> ShortcutDraft:
+ """The draft with an action answering ``combination``, taken from whichever action holds it.
+
+ Leaving the holder unbound in the same step is what keeps every scheme a draft produces
+ valid, since one combination reaches one action within a category. An edit is held to the
+ keys the table names, which is what lets every draft be written down and read back.
+
+ Raises:
+ KeyError: when the combination is built on a key the table names none of.
+ """
+ if not combination.is_writable:
+ raise KeyError(f"The key {combination.key} carries no name a binding is written under")
+
+ claimant = self.claimant(shortcut_id, combination)
+ edits: Dict[ShortcutId, Optional[KeyCombination]] = {
+ **self.edits,
+ shortcut_id: combination,
+ }
+ if claimant is not None:
+ edits[claimant] = None
+
+ return replace(self, edits=edits)
+
+ def clear(self, shortcut_id: ShortcutId) -> ShortcutDraft:
+ """The draft with an action left unbound, its keys free for another action to take."""
+ return replace(self, edits={**self.edits, shortcut_id: None})
+
+ def reset(self) -> ShortcutDraft:
+ """The draft with every action back on the keys the scheme ships."""
+ return replace(self, edits={})
+
+ def scheme(self) -> ShortcutScheme:
+ """The scheme the draft describes, ready for the application to resolve its keys against.
+
+ Raises:
+ KeyError: when an edit names a key the key table holds none of.
+ """
+ return self.base.with_bindings(self.edits)
+
+ def overrides(self) -> Dict[str, Optional[str]]:
+ """The edits as a stored preference writes them, keyed by each action's name."""
+ return {
+ shortcut_id.value: None if combination is None else combination.display()
+ for shortcut_id, combination in self.edits.items()
+ }
+
+ def _claimed(self, shortcut_id: ShortcutId) -> Tuple[KeyCombination, ...]:
+ """Every combination an action answers to as the draft stands.
+
+ An action the reader touched answers the one combination they gave it, while the rest answer
+ the aliases the scheme ships beside their combination, which an assignment has to take too.
+ """
+ if shortcut_id not in self.edits:
+ return self.base.shortcut(shortcut_id).combinations()
+
+ combination = self.edits[shortcut_id]
+ return () if combination is None else (combination,)
diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py
index 966fd475..dde05a07 100644
--- a/src/sampletones_application/utils/gui/shortcuts/ids.py
+++ b/src/sampletones_application/utils/gui/shortcuts/ids.py
@@ -1,55 +1,217 @@
-from enum import Enum
-from typing import Dict, Final
+from enum import Enum, StrEnum
+from typing import Dict, Final, Self, Tuple
+from sampletones_application.categories.hierarchy import Tab
+from sampletones_application.constants.playback import FollowMode
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.trackers.format import TrackerFormat
+class ShortcutCategory(StrEnum):
+ """The scope that answers an action's keys.
+
+ Each category is a separate keyboard context, so one combination means one thing inside a
+ category and is free to mean something else in another: Escape cancels a pending entry in the
+ tracker and stops playback everywhere else. ``DIALOG`` is structural — Tab, Enter and Escape
+ are how a modal is operated at all, which marks them as the set a keybindings editor leaves
+ in place.
+ """
+
+ APPLICATION = "application"
+ ORDER = "order"
+ TRACKER = "tracker"
+ SAMPLES = "samples"
+ DIALOG = "dialog"
+
+
class ShortcutId(Enum):
- NEW_PROJECT = "NewProject"
- OPEN_PROJECT = "OpenProject"
- SAVE_PROJECT = "SaveProject"
- SAVE_PROJECT_AS = "SaveProjectAs"
- PROJECT_PROPERTIES = "ProjectProperties"
- EXPORT_PROJECT_FAMITRACKER = "ExportProjectFamiTracker"
- EXPORT_PROJECT_BITPHASE = "ExportProjectBitphase"
- CLOSE_PROJECT = "CloseProject"
- EXIT = "Exit"
- UNDO = "Undo"
- REDO = "Redo"
- RECONSTRUCT_FILE = "ReconstructFile"
- RECONSTRUCT_DIRECTORY = "ReconstructDirectory"
- LOAD_GENERATION_SETTINGS = "LoadGenerationSettings"
- SAVE_GENERATION_SETTINGS = "SaveGenerationSettings"
- OPEN_RECONSTRUCTION = "OpenReconstruction"
- SAVE_RECONSTRUCTION = "SaveReconstruction"
- SAVE_RECONSTRUCTION_AS = "SaveReconstructionAs"
- CLOSE_RECONSTRUCTION = "CloseReconstruction"
- EXPORT_RECONSTRUCTION_WAV = "ExportReconstructionWav"
- EXPORT_INSTRUMENTS_FAMITRACKER = "ExportInstrumentsFamiTracker"
- EXPORT_INSTRUMENTS_BITPHASE_PRESET = "ExportInstrumentsBitphasePreset"
- ADD_RECONSTRUCTION_TO_SEQUENCER = "AddReconstructionToSequencer"
- OPEN_RECONSTRUCTION_IN_EXPLORER = "OpenReconstructionInExplorer"
- LOCATE_ORIGINAL_AUDIO = "LocateOriginalAudio"
- PLAY = "Play"
- PLAY_FROM_START = "PlayFromStart"
- PLAY_FROM_FRAME = "PlayFromFrame"
- STOP = "Stop"
- TOGGLE_AUTOPLAY = "ToggleAutoplay"
- TOGGLE_FOLLOW_PLAYBACK = "ToggleFollowPlayback"
- TOGGLE_LOOP_SONG = "ToggleLoopSong"
- TOGGLE_CHANNEL_PULSE_1 = "ToggleChannelPulse1"
- TOGGLE_CHANNEL_PULSE_2 = "ToggleChannelPulse2"
- TOGGLE_CHANNEL_TRIANGLE = "ToggleChannelTriangle"
- TOGGLE_CHANNEL_NOISE = "ToggleChannelNoise"
- UNMUTE_ALL_CHANNELS = "UnmuteAllChannels"
- AUDIO_SETTINGS = "AudioSettings"
- TOGGLE_ADVANCED_SETTINGS = "ToggleAdvancedSettings"
- TOGGLE_FULLSCREEN = "ToggleFullscreen"
- ABOUT_DIALOG = "AboutDialog"
- NEXT_TAB = "NextTab"
- PREVIOUS_TAB = "PreviousTab"
+ """Every named action a key press reaches, each declaring the category it belongs to.
+
+ An id is the one name an action answers to: the scheme binds combinations to it, a menu asks
+ it for the accelerator to print, and a panel asks it whether a press was meant for it. The
+ value is the name a keybinding file writes; the category is code, since it follows from which
+ scope handles the action rather than from a reader's preference.
+ """
+
+ category: ShortcutCategory
+
+ def __new__(cls, value: str, category: ShortcutCategory) -> Self:
+ member = object.__new__(cls)
+ member._value_ = value
+ member.category = category
+ return member
+
+ NEW_PROJECT = ("NewProject", ShortcutCategory.APPLICATION)
+ OPEN_PROJECT = ("OpenProject", ShortcutCategory.APPLICATION)
+ SAVE_PROJECT = ("SaveProject", ShortcutCategory.APPLICATION)
+ SAVE_PROJECT_AS = ("SaveProjectAs", ShortcutCategory.APPLICATION)
+ PROJECT_PROPERTIES = ("ProjectProperties", ShortcutCategory.APPLICATION)
+ EXPORT_PROJECT_FAMITRACKER = ("ExportProjectFamiTracker", ShortcutCategory.APPLICATION)
+ EXPORT_PROJECT_BITPHASE = ("ExportProjectBitphase", ShortcutCategory.APPLICATION)
+ RENDER_SONG = ("RenderSong", ShortcutCategory.APPLICATION)
+ CLOSE_PROJECT = ("CloseProject", ShortcutCategory.APPLICATION)
+ EXIT = ("Exit", ShortcutCategory.APPLICATION)
+ UNDO = ("Undo", ShortcutCategory.APPLICATION)
+ REDO = ("Redo", ShortcutCategory.APPLICATION)
+ RECONSTRUCT_FILE = ("ReconstructFile", ShortcutCategory.APPLICATION)
+ RECONSTRUCT_DIRECTORY = ("ReconstructDirectory", ShortcutCategory.APPLICATION)
+ LOAD_GENERATION_SETTINGS = ("LoadGenerationSettings", ShortcutCategory.APPLICATION)
+ SAVE_GENERATION_SETTINGS = ("SaveGenerationSettings", ShortcutCategory.APPLICATION)
+ OPEN_RECONSTRUCTION = ("OpenReconstruction", ShortcutCategory.APPLICATION)
+ SAVE_RECONSTRUCTION = ("SaveReconstruction", ShortcutCategory.APPLICATION)
+ SAVE_RECONSTRUCTION_AS = ("SaveReconstructionAs", ShortcutCategory.APPLICATION)
+ CLOSE_RECONSTRUCTION = ("CloseReconstruction", ShortcutCategory.APPLICATION)
+ EXPORT_RECONSTRUCTION_WAV = ("ExportReconstructionWav", ShortcutCategory.APPLICATION)
+ EXPORT_INSTRUMENTS_FAMITRACKER = ("ExportInstrumentsFamiTracker", ShortcutCategory.APPLICATION)
+ EXPORT_INSTRUMENTS_BITPHASE_PRESET = ("ExportInstrumentsBitphasePreset", ShortcutCategory.APPLICATION)
+ ADD_RECONSTRUCTION_TO_SEQUENCER = ("AddReconstructionToSequencer", ShortcutCategory.APPLICATION)
+ OPEN_RECONSTRUCTION_IN_EXPLORER = ("OpenReconstructionInExplorer", ShortcutCategory.APPLICATION)
+ LOCATE_ORIGINAL_AUDIO = ("LocateOriginalAudio", ShortcutCategory.APPLICATION)
+ PLAY = ("Play", ShortcutCategory.APPLICATION)
+ PLAY_FROM_START = ("PlayFromStart", ShortcutCategory.APPLICATION)
+ PLAY_FROM_FRAME = ("PlayFromFrame", ShortcutCategory.APPLICATION)
+ STOP = ("Stop", ShortcutCategory.APPLICATION)
+ TOGGLE_AUTOPLAY = ("ToggleAutoplay", ShortcutCategory.APPLICATION)
+ FOLLOW_ROWS = ("FollowRows", ShortcutCategory.APPLICATION)
+ FOLLOW_PATTERNS = ("FollowPatterns", ShortcutCategory.APPLICATION)
+ FOLLOW_OFF = ("FollowOff", ShortcutCategory.APPLICATION)
+ TOGGLE_LOOP_SONG = ("ToggleLoopSong", ShortcutCategory.APPLICATION)
+ TOGGLE_CHANNEL_PULSE_1 = ("ToggleChannelPulse1", ShortcutCategory.APPLICATION)
+ TOGGLE_CHANNEL_PULSE_2 = ("ToggleChannelPulse2", ShortcutCategory.APPLICATION)
+ TOGGLE_CHANNEL_TRIANGLE = ("ToggleChannelTriangle", ShortcutCategory.APPLICATION)
+ TOGGLE_CHANNEL_NOISE = ("ToggleChannelNoise", ShortcutCategory.APPLICATION)
+ UNMUTE_ALL_CHANNELS = ("UnmuteAllChannels", ShortcutCategory.APPLICATION)
+ AUDIO_SETTINGS = ("AudioSettings", ShortcutCategory.APPLICATION)
+ DISPLAY_SETTINGS = ("DisplaySettings", ShortcutCategory.APPLICATION)
+ KEYBOARD_SETTINGS = ("KeyboardSettings", ShortcutCategory.APPLICATION)
+ TOGGLE_ADVANCED_SETTINGS = ("ToggleAdvancedSettings", ShortcutCategory.APPLICATION)
+ TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = (
+ "ToggleAutoExpandFavoriteReconstructions",
+ ShortcutCategory.APPLICATION,
+ )
+ TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES = (
+ "ToggleAutoExpandFavoriteDirectories",
+ ShortcutCategory.APPLICATION,
+ )
+ TOGGLE_FULLSCREEN = ("ToggleFullscreen", ShortcutCategory.APPLICATION)
+ ABOUT_DIALOG = ("AboutDialog", ShortcutCategory.APPLICATION)
+ NEXT_TAB = ("NextTab", ShortcutCategory.APPLICATION)
+ PREVIOUS_TAB = ("PreviousTab", ShortcutCategory.APPLICATION)
+ SELECT_TAB_MAIN = ("SelectTabMain", ShortcutCategory.APPLICATION)
+ SELECT_TAB_RECONSTRUCTIONS = ("SelectTabReconstructions", ShortcutCategory.APPLICATION)
+ SELECT_TAB_SEQUENCER = ("SelectTabSequencer", ShortcutCategory.APPLICATION)
+ SELECT_TAB_INSTRUCTIONS = ("SelectTabInstructions", ShortcutCategory.APPLICATION)
+
+ ORDER_PREVIOUS_POSITION = ("OrderPreviousPosition", ShortcutCategory.ORDER)
+ ORDER_NEXT_POSITION = ("OrderNextPosition", ShortcutCategory.ORDER)
+ ORDER_PREVIOUS_CHANNEL = ("OrderPreviousChannel", ShortcutCategory.ORDER)
+ ORDER_NEXT_CHANNEL = ("OrderNextChannel", ShortcutCategory.ORDER)
+ ORDER_FIRST_POSITION = ("OrderFirstPosition", ShortcutCategory.ORDER)
+ ORDER_LAST_POSITION = ("OrderLastPosition", ShortcutCategory.ORDER)
+ ORDER_EXTEND_SELECTION_UP = ("OrderExtendSelectionUp", ShortcutCategory.ORDER)
+ ORDER_EXTEND_SELECTION_DOWN = ("OrderExtendSelectionDown", ShortcutCategory.ORDER)
+ ORDER_EXTEND_SELECTION_LEFT = ("OrderExtendSelectionLeft", ShortcutCategory.ORDER)
+ ORDER_EXTEND_SELECTION_RIGHT = ("OrderExtendSelectionRight", ShortcutCategory.ORDER)
+ ORDER_EXTEND_SELECTION_TO_FIRST_POSITION = (
+ "OrderExtendSelectionToFirstPosition",
+ ShortcutCategory.ORDER,
+ )
+ ORDER_EXTEND_SELECTION_TO_LAST_POSITION = (
+ "OrderExtendSelectionToLastPosition",
+ ShortcutCategory.ORDER,
+ )
+ ORDER_SELECT_ALL = ("OrderSelectAll", ShortcutCategory.ORDER)
+ ORDER_SELECT_ROW = ("OrderSelectRow", ShortcutCategory.ORDER)
+ ORDER_COPY_BLOCK = ("OrderCopyBlock", ShortcutCategory.ORDER)
+ ORDER_CUT_BLOCK = ("OrderCutBlock", ShortcutCategory.ORDER)
+ ORDER_PASTE_BLOCK = ("OrderPasteBlock", ShortcutCategory.ORDER)
+ ORDER_MOVE_FRAME_LEFT = ("OrderMoveFrameLeft", ShortcutCategory.ORDER)
+ ORDER_MOVE_FRAME_RIGHT = ("OrderMoveFrameRight", ShortcutCategory.ORDER)
+ ORDER_MOVE_FRAME_TO_START = ("OrderMoveFrameToStart", ShortcutCategory.ORDER)
+ ORDER_MOVE_FRAME_TO_END = ("OrderMoveFrameToEnd", ShortcutCategory.ORDER)
+ ORDER_ADD_FRAME = ("OrderAddFrame", ShortcutCategory.ORDER)
+ ORDER_INSERT_FRAME = ("OrderInsertFrame", ShortcutCategory.ORDER)
+ ORDER_REMOVE_FRAME = ("OrderRemoveFrame", ShortcutCategory.ORDER)
+ ORDER_DUPLICATE_FRAME = ("OrderDuplicateFrame", ShortcutCategory.ORDER)
+ ORDER_CLONE_FRAME = ("OrderCloneFrame", ShortcutCategory.ORDER)
+ ORDER_CLEAR_FRAME = ("OrderClearFrame", ShortcutCategory.ORDER)
+ ORDER_CLEAR_CELL = ("OrderClearCell", ShortcutCategory.ORDER)
+ ORDER_CLEAR_PREVIOUS_CELL = ("OrderClearPreviousCell", ShortcutCategory.ORDER)
+ ORDER_CANCEL_ENTRY = ("OrderCancelEntry", ShortcutCategory.ORDER)
+ TRACKER_PREVIOUS_ROW = ("TrackerPreviousRow", ShortcutCategory.TRACKER)
+ TRACKER_NEXT_ROW = ("TrackerNextRow", ShortcutCategory.TRACKER)
+ TRACKER_PREVIOUS_SUBCOLUMN = ("TrackerPreviousSubcolumn", ShortcutCategory.TRACKER)
+ TRACKER_NEXT_SUBCOLUMN = ("TrackerNextSubcolumn", ShortcutCategory.TRACKER)
+ TRACKER_PREVIOUS_COLUMN = ("TrackerPreviousColumn", ShortcutCategory.TRACKER)
+ TRACKER_NEXT_COLUMN = ("TrackerNextColumn", ShortcutCategory.TRACKER)
+ TRACKER_FIRST_ROW = ("TrackerFirstRow", ShortcutCategory.TRACKER)
+ TRACKER_LAST_ROW = ("TrackerLastRow", ShortcutCategory.TRACKER)
+ TRACKER_EXTEND_SELECTION_UP = ("TrackerExtendSelectionUp", ShortcutCategory.TRACKER)
+ TRACKER_EXTEND_SELECTION_DOWN = ("TrackerExtendSelectionDown", ShortcutCategory.TRACKER)
+ TRACKER_EXTEND_SELECTION_LEFT = ("TrackerExtendSelectionLeft", ShortcutCategory.TRACKER)
+ TRACKER_EXTEND_SELECTION_RIGHT = ("TrackerExtendSelectionRight", ShortcutCategory.TRACKER)
+ TRACKER_EXTEND_SELECTION_TO_FIRST_ROW = (
+ "TrackerExtendSelectionToFirstRow",
+ ShortcutCategory.TRACKER,
+ )
+ TRACKER_EXTEND_SELECTION_TO_LAST_ROW = (
+ "TrackerExtendSelectionToLastRow",
+ ShortcutCategory.TRACKER,
+ )
+ TRACKER_SELECT_ALL = ("TrackerSelectAll", ShortcutCategory.TRACKER)
+ TRACKER_SELECT_COLUMN = ("TrackerSelectColumn", ShortcutCategory.TRACKER)
+ TRACKER_SELECT_SUBCOLUMN = ("TrackerSelectSubcolumn", ShortcutCategory.TRACKER)
+ TRACKER_COPY_BLOCK = ("TrackerCopyBlock", ShortcutCategory.TRACKER)
+ TRACKER_CUT_BLOCK = ("TrackerCutBlock", ShortcutCategory.TRACKER)
+ TRACKER_PASTE_BLOCK = ("TrackerPasteBlock", ShortcutCategory.TRACKER)
+ TRACKER_TRANSPOSE_UP = ("TrackerTransposeUp", ShortcutCategory.TRACKER)
+ TRACKER_TRANSPOSE_DOWN = ("TrackerTransposeDown", ShortcutCategory.TRACKER)
+ TRACKER_TRANSPOSE_OCTAVE_UP = ("TrackerTransposeOctaveUp", ShortcutCategory.TRACKER)
+ TRACKER_TRANSPOSE_OCTAVE_DOWN = ("TrackerTransposeOctaveDown", ShortcutCategory.TRACKER)
+ TRACKER_VOLUME_UP = ("TrackerVolumeUp", ShortcutCategory.TRACKER)
+ TRACKER_VOLUME_DOWN = ("TrackerVolumeDown", ShortcutCategory.TRACKER)
+ TRACKER_VOLUME_UP_COARSE = ("TrackerVolumeUpCoarse", ShortcutCategory.TRACKER)
+ TRACKER_VOLUME_DOWN_COARSE = ("TrackerVolumeDownCoarse", ShortcutCategory.TRACKER)
+ TRACKER_PAGE_UP = ("TrackerPageUp", ShortcutCategory.TRACKER)
+ TRACKER_PAGE_DOWN = ("TrackerPageDown", ShortcutCategory.TRACKER)
+ TRACKER_CLEAR_ROW = ("TrackerClearRow", ShortcutCategory.TRACKER)
+ TRACKER_CLEAR_PREVIOUS_ROW = ("TrackerClearPreviousRow", ShortcutCategory.TRACKER)
+ TRACKER_CANCEL_ENTRY = ("TrackerCancelEntry", ShortcutCategory.TRACKER)
+ TRACKER_PLAY_FROM_ROW = ("TrackerPlayFromRow", ShortcutCategory.TRACKER)
+
+ SAMPLES_RENAME_SAMPLE = ("SamplesRenameSample", ShortcutCategory.SAMPLES)
+ SAMPLES_REMOVE_SAMPLE = ("SamplesRemoveSample", ShortcutCategory.SAMPLES)
+ SAMPLES_MOVE_SAMPLE_UP = ("SamplesMoveSampleUp", ShortcutCategory.SAMPLES)
+ SAMPLES_MOVE_SAMPLE_DOWN = ("SamplesMoveSampleDown", ShortcutCategory.SAMPLES)
+ SAMPLES_MOVE_SAMPLE_TO_TOP = ("SamplesMoveSampleToTop", ShortcutCategory.SAMPLES)
+ SAMPLES_MOVE_SAMPLE_TO_BOTTOM = ("SamplesMoveSampleToBottom", ShortcutCategory.SAMPLES)
+ SAMPLES_CANCEL_RENAME = ("SamplesCancelRename", ShortcutCategory.SAMPLES)
+
+ DIALOG_NEXT_CONTROL = ("DialogNextControl", ShortcutCategory.DIALOG)
+ DIALOG_PREVIOUS_CONTROL = ("DialogPreviousControl", ShortcutCategory.DIALOG)
+ DIALOG_ACTIVATE = ("DialogActivate", ShortcutCategory.DIALOG)
+ DIALOG_CANCEL = ("DialogCancel", ShortcutCategory.DIALOG)
+
+
+SHORTCUT_IDS_BY_NAME: Final[Dict[str, ShortcutId]] = {shortcut_id.value: shortcut_id for shortcut_id in ShortcutId}
+
+EDITABLE_SHORTCUT_CATEGORIES: Final[Tuple[ShortcutCategory, ...]] = tuple(
+ category for category in ShortcutCategory if category is not ShortcutCategory.DIALOG
+)
+
+FOLLOW_MODE_SHORTCUT_IDS: Final[Dict[FollowMode, ShortcutId]] = {
+ FollowMode.ROWS: ShortcutId.FOLLOW_ROWS,
+ FollowMode.PATTERNS: ShortcutId.FOLLOW_PATTERNS,
+ FollowMode.OFF: ShortcutId.FOLLOW_OFF,
+}
+
+TAB_SHORTCUT_IDS: Final[Dict[Tab, ShortcutId]] = {
+ Tab.MAIN: ShortcutId.SELECT_TAB_MAIN,
+ Tab.RECONSTRUCTIONS: ShortcutId.SELECT_TAB_RECONSTRUCTIONS,
+ Tab.SEQUENCER: ShortcutId.SELECT_TAB_SEQUENCER,
+ Tab.INSTRUCTIONS: ShortcutId.SELECT_TAB_INSTRUCTIONS,
+}
CHANNEL_SHORTCUT_IDS: Final[Dict[GeneratorName, ShortcutId]] = {
GeneratorName.PULSE1: ShortcutId.TOGGLE_CHANNEL_PULSE_1,
diff --git a/src/sampletones_application/utils/gui/shortcuts/keys.py b/src/sampletones_application/utils/gui/shortcuts/keys.py
deleted file mode 100644
index c5d20779..00000000
--- a/src/sampletones_application/utils/gui/shortcuts/keys.py
+++ /dev/null
@@ -1,87 +0,0 @@
-from typing import Dict, Final
-
-import dearpygui.dearpygui as dpg
-
-from sampletones_shared.constants.symbols import HEXADECIMAL, MINUS, PLUS
-
-KEY_PAGE_UP: Final = 517
-KEY_PAGE_DOWN: Final = 518
-
-
-KEY_DISPLAY_NAMES: Dict[int, str] = {
- dpg.mvKey_A: "A",
- dpg.mvKey_B: "B",
- dpg.mvKey_C: "C",
- dpg.mvKey_D: "D",
- dpg.mvKey_E: "E",
- dpg.mvKey_F: "F",
- dpg.mvKey_G: "G",
- dpg.mvKey_H: "H",
- dpg.mvKey_I: "I",
- dpg.mvKey_J: "J",
- dpg.mvKey_K: "K",
- dpg.mvKey_L: "L",
- dpg.mvKey_M: "M",
- dpg.mvKey_N: "N",
- dpg.mvKey_O: "O",
- dpg.mvKey_P: "P",
- dpg.mvKey_Q: "Q",
- dpg.mvKey_R: "R",
- dpg.mvKey_S: "S",
- dpg.mvKey_T: "T",
- dpg.mvKey_U: "U",
- dpg.mvKey_V: "V",
- dpg.mvKey_W: "W",
- dpg.mvKey_X: "X",
- dpg.mvKey_Y: "Y",
- dpg.mvKey_Z: "Z",
- dpg.mvKey_0: "0",
- dpg.mvKey_1: "1",
- dpg.mvKey_2: "2",
- dpg.mvKey_3: "3",
- dpg.mvKey_4: "4",
- dpg.mvKey_5: "5",
- dpg.mvKey_6: "6",
- dpg.mvKey_7: "7",
- dpg.mvKey_8: "8",
- dpg.mvKey_9: "9",
- dpg.mvKey_F1: "F1",
- dpg.mvKey_F2: "F2",
- dpg.mvKey_F3: "F3",
- dpg.mvKey_F4: "F4",
- dpg.mvKey_F5: "F5",
- dpg.mvKey_F6: "F6",
- dpg.mvKey_F7: "F7",
- dpg.mvKey_F8: "F8",
- dpg.mvKey_F9: "F9",
- dpg.mvKey_F10: "F10",
- dpg.mvKey_F11: "F11",
- dpg.mvKey_F12: "F12",
- dpg.mvKey_Escape: "Esc",
- dpg.mvKey_Return: "Enter",
- dpg.mvKey_Tab: "Tab",
- dpg.mvKey_Spacebar: "Space",
- dpg.mvKey_Back: "Backspace",
- dpg.mvKey_Delete: "Del",
- dpg.mvKey_Insert: "Ins",
- dpg.mvKey_Home: "Home",
- dpg.mvKey_End: "End",
- KEY_PAGE_UP: "PgUp",
- KEY_PAGE_DOWN: "PgDn",
- dpg.mvKey_Up: "Up",
- dpg.mvKey_Down: "Down",
- dpg.mvKey_Left: "Left",
- dpg.mvKey_Right: "Right",
-}
-
-
-HEX_KEYS: Final[Dict[int, str]] = {dpg.mvKey_0 + i: HEXADECIMAL[i] for i in range(10)} | {
- dpg.mvKey_A + i: HEXADECIMAL[10 + i] for i in range(6)
-}
-
-SIGN_KEYS: Final[Dict[int, str]] = {
- dpg.mvKey_Minus: MINUS,
- dpg.mvKey_Subtract: MINUS,
- dpg.mvKey_Plus: PLUS,
- dpg.mvKey_Add: PLUS,
-}
diff --git a/src/sampletones_application/utils/gui/shortcuts/manager.py b/src/sampletones_application/utils/gui/shortcuts/manager.py
index c5cb4a34..9cb13728 100644
--- a/src/sampletones_application/utils/gui/shortcuts/manager.py
+++ b/src/sampletones_application/utils/gui/shortcuts/manager.py
@@ -1,7 +1,8 @@
-from typing import Any, Dict, List, Tuple
+from typing import Any, Dict, List, Optional, Tuple
import dearpygui.dearpygui as dpg
+from sampletones_application.utils.gui.dpg import dpg_configure_item
from sampletones_application.utils.gui.keyboard import (
PRIORITY_SHORTCUT,
KeyEvent,
@@ -10,73 +11,105 @@
)
from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import Callback
class ShortcutManager:
- def __init__(self, *, key_router: KeyRouter) -> None:
- self._router = key_router
- self._shortcuts: Dict[ShortcutId, Tuple[Shortcut, Callback]] = {}
- self._aliases: Dict[ShortcutId, List[Shortcut]] = {}
- self._bindings_by_key: Dict[int, List[Tuple[Shortcut, Callback]]] = {}
+ """Dispatches a key press to the action it fires, reading each action's keys from the scheme.
+
+ An action is registered under the id it answers to together with the call it makes; which
+ combination reaches it is the keybinding scheme's to say, so a rebind changes the keys without
+ touching a registration.
+ """
- def register(
+ def __init__(
self,
- shortcut_id: ShortcutId,
- shortcut: Shortcut,
- callback: Callback,
+ *,
+ key_router: KeyRouter,
+ shortcut_source: ShortcutSource,
) -> None:
- self._shortcuts[shortcut_id] = (shortcut, callback)
+ self._router = key_router
+ self._source = shortcut_source
+ self._callbacks: Dict[ShortcutId, Callback] = {}
+ self._bindings_by_key: Dict[int, List[Tuple[Shortcut, Callback]]] = {}
+ self._menu_items: Dict[Sender, ShortcutId] = {}
+
+ def register(self, shortcut_id: ShortcutId, callback: Callback) -> None:
+ """Names the call an action makes when its combination is pressed or its menu item chosen."""
+ self._callbacks[shortcut_id] = callback
- def register_alias(
+ def add_menu_item(
self,
shortcut_id: ShortcutId,
- shortcut: Shortcut,
+ *,
+ callback: Optional[Callback] = None,
+ **kwargs: Any,
) -> None:
- """Binds an additional key combination to an already registered action.
+ """Adds the menu item an action is chosen from, printing the combination that also fires it.
- The primary shortcut keeps the action's display string in menus and
- tooltips; an alias extends only the key handling, so one action honours
- several conventional combinations.
- """
- self._aliases.setdefault(shortcut_id, []).append(shortcut)
+ The item is kept under the action it stands for, so a later rebind reaches the accelerator
+ already on screen. An item states a call of its own where it carries a state to show: the
+ check beside it reads one surface, and the call it makes is the one that switches that
+ surface.
- def add_menu_item(self, shortcut_id: ShortcutId, **kwargs: Any) -> None:
- shortcut, callback = self._shortcuts[shortcut_id]
- dpg.add_menu_item(
- callback=lambda s, a, u: callback(),
- shortcut=shortcut.get_display_string(),
+ Args:
+ shortcut_id: The action the item stands for, which decides the accelerator it prints.
+ callback: The call the item makes, defaulting to the one registered for the action.
+ kwargs: The DearPyGui properties of the item, its label and check state among them.
+ """
+ chosen = self._callbacks[shortcut_id] if callback is None else callback
+ item: Sender = dpg.add_menu_item(
+ callback=lambda s, a, u: chosen(),
+ shortcut=self._source.display(shortcut_id),
**kwargs,
)
+ self._menu_items[item] = shortcut_id
def bind_all(self) -> None:
"""Registers the shortcut scope with the key router.
Bindings are indexed by key so a press resolves in one lookup. A modal dialog claims keys
at a higher priority, so this scope handles a press whenever no dialog holds the keyboard.
+ The scope is claimed the once here, which leaves a rebind to re-read the keys in place.
"""
- self._bindings_by_key = {}
- for shortcut_id, (shortcut, callback) in self._shortcuts.items():
- self._add_binding(shortcut, callback)
- for alias in self._aliases.get(shortcut_id, []):
- self._add_binding(alias, callback)
-
+ self._index_bindings()
self._router.register(
self._dispatch,
priority=PRIORITY_SHORTCUT,
active=lambda: True,
)
- def _add_binding(self, shortcut: Shortcut, callback: Callback) -> None:
- if shortcut.key is None:
- return
+ def rebind(self) -> None:
+ """Reads every action's keys again, once another scheme is the one in place.
+
+ A registration names the action it fires, so a rebind catches up the copies of the keys:
+ the index a press resolves through and the accelerators already on screen.
+ """
+ self._index_bindings()
+ self._refresh_menu_items()
+
+ def _refresh_menu_items(self) -> None:
+ """Prints each menu item's accelerator again, so a menu shows the keys that reach it."""
+ for item, shortcut_id in self._menu_items.items():
+ dpg_configure_item(item, shortcut=self._source.display(shortcut_id))
- self._bindings_by_key.setdefault(shortcut.key, []).append(
- (
- shortcut,
- callback,
+ def _index_bindings(self) -> None:
+ """Reads each registered action's combinations from the scheme and indexes them by key."""
+ self._bindings_by_key = {}
+ for shortcut_id, callback in self._callbacks.items():
+ self._add_binding(self._source.shortcut(shortcut_id), callback)
+
+ def _add_binding(self, shortcut: Shortcut, callback: Callback) -> None:
+ """Indexes the binding under each key any of its combinations names."""
+ for key in sorted({combination.key for combination in shortcut.combinations()}):
+ self._bindings_by_key.setdefault(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
@@ -86,12 +119,14 @@ def _dispatch(self, event: KeyEvent) -> bool:
while Ctrl+Space and Escape still reach playback and Stop from the same field.
"""
for shortcut, callback in self._bindings_by_key.get(event.key, ()):
- if event.modifiers == shortcut.modifiers:
- if not shortcut.field_transparent and self._field_consumes(event):
- return False
+ if not shortcut.matches(event):
+ continue
+
+ if not shortcut.field_transparent and self._field_consumes(event):
+ return False
- callback()
- return True
+ callback()
+ return True
return False
diff --git a/src/sampletones_application/utils/gui/shortcuts/scheme.py b/src/sampletones_application/utils/gui/shortcuts/scheme.py
new file mode 100644
index 00000000..38f0d439
--- /dev/null
+++ b/src/sampletones_application/utils/gui/shortcuts/scheme.py
@@ -0,0 +1,284 @@
+from __future__ import annotations
+
+from functools import cached_property
+from pathlib import Path
+from typing import Dict, List, Mapping, Optional, Self
+
+from pydantic import BaseModel, model_validator
+
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.utils.gui.shortcuts.ids import (
+ SHORTCUT_IDS_BY_NAME,
+ ShortcutCategory,
+ ShortcutId,
+)
+from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut
+from sampletones_application.utils.gui.shortcuts.written import WrittenShortcut
+from sampletones_shared.logger import logger
+from sampletones_shared.utils.serialization import load_yaml
+
+
+class ShortcutScheme(BaseModel, frozen=True):
+ """A named set of keybindings, one entry per action the application names.
+
+ The scheme is where a combination is decided: an action declares the id it answers to, and the
+ scheme alone says which keys reach it. Every action is answered here, so the combination a menu
+ prints, the one a panel acts on and the one a reader edits are the same entry.
+ """
+
+ name: str
+ bindings: Dict[ShortcutId, WrittenShortcut]
+
+ @cached_property
+ def shortcuts(self) -> Dict[ShortcutId, Shortcut]:
+ """Every action's binding, read out of its written form once."""
+ return {shortcut_id: written.resolve() for shortcut_id, written in self.bindings.items()}
+
+ @cached_property
+ def claims(self) -> Dict[ShortcutCategory, Dict[KeyCombination, ShortcutId]]:
+ """The action each combination reaches, indexed by the category that answers it.
+
+ A press resolves in one lookup, since a combination names a single action within a
+ category while another category is free to give it to an action of its own.
+ """
+ claims: Dict[ShortcutCategory, Dict[KeyCombination, ShortcutId]] = {
+ category: {} for category in ShortcutCategory
+ }
+ for shortcut_id, shortcut in self.shortcuts.items():
+ for combination in shortcut.combinations():
+ claims[shortcut_id.category].setdefault(
+ combination,
+ shortcut_id,
+ )
+
+ return claims
+
+ @model_validator(mode="after")
+ def _read_bindings(self) -> Self:
+ """Reads every entry at load, so a scheme in use answers each action with keys that resolve
+ and with one action per combination.
+
+ Raises:
+ SystemError: when an action goes unanswered, or two actions of one category claim the
+ same combination.
+ KeyError: when a written combination names a key the key table holds none of.
+ """
+ self._require_every_action_answered()
+ self._require_one_action_per_combination()
+ return self
+
+ def shortcut(self, shortcut_id: ShortcutId) -> Shortcut:
+ """The binding that answers an action, the combinations it names ready to match a press."""
+ return self.shortcuts[shortcut_id]
+
+ def claimant(
+ self,
+ category: ShortcutCategory,
+ combination: KeyCombination,
+ ) -> Optional[ShortcutId]:
+ """The action of a category a combination reaches.
+
+ An editor asks before it assigns, so a reader is told which action they are taking the keys
+ from.
+
+ Args:
+ category: The scope asking, which decides what the combination means there.
+ combination: The keys to resolve, the modifiers held with them included.
+
+ Returns:
+ Optional[ShortcutId]: The action the category binds the combination to, ``None`` while
+ the category leaves it unclaimed.
+ """
+ return self.claims[category].get(combination)
+
+ def action(
+ self,
+ category: ShortcutCategory,
+ event: KeyEvent,
+ ) -> Optional[ShortcutId]:
+ """The action of a category a press reaches.
+
+ Args:
+ category: The scope asking, which decides what the press means there.
+ event: The press to resolve, carrying the modifiers held as it fired.
+
+ Returns:
+ Optional[ShortcutId]: The action the category binds the press to, ``None`` while the
+ category leaves it unnamed.
+ """
+ return self.claimant(
+ category,
+ KeyCombination(
+ event.key,
+ event.modifiers,
+ ),
+ )
+
+ def with_binding(
+ self,
+ shortcut_id: ShortcutId,
+ combination: Optional[KeyCombination],
+ ) -> ShortcutScheme:
+ """The scheme with one action answering ``combination``, as it stands for every other entry.
+
+ Args:
+ shortcut_id: The action being given keys.
+ combination: The keys it answers to, ``None`` leaving it unbound.
+
+ Returns:
+ ShortcutScheme: The scheme every action resolves against once the binding is read.
+
+ Raises:
+ SystemError: when another action of the same category already answers the combination.
+ KeyError: when the combination names a key the key table holds none of.
+ """
+ return self.with_bindings({shortcut_id: combination})
+
+ def with_bindings(
+ self,
+ combinations: Mapping[ShortcutId, Optional[KeyCombination]],
+ ) -> ShortcutScheme:
+ """The scheme as the named actions answer the combinations given, read in one step.
+
+ A named action answers the combination stated and that alone, so the aliases the scheme
+ shipped it with go with the keys they extended. Reading the whole set at once is what lets
+ two actions trade combinations, each arriving at keys the other is leaving.
+
+ Args:
+ combinations: The keys each named action answers to, ``None`` leaving an action unbound.
+
+ Returns:
+ ShortcutScheme: The scheme every action resolves against once the bindings are read.
+
+ Raises:
+ SystemError: when two actions of one category are left answering one combination.
+ KeyError: when a combination names a key the key table holds none of.
+ """
+ entries: Dict[ShortcutId, WrittenShortcut] = {
+ shortcut_id: self.bindings[shortcut_id].rebound(
+ None if combination is None else combination.display(),
+ )
+ for shortcut_id, combination in combinations.items()
+ }
+
+ return ShortcutScheme(
+ name=self.name,
+ bindings={**self.bindings, **entries},
+ )
+
+ def with_overrides(
+ self,
+ overrides: Mapping[str, Optional[str]],
+ ) -> ShortcutScheme:
+ """The scheme as a reader rebound it, each entry giving one action the keys it names.
+
+ An override names its action the way a keybinding file writes it, which lets a preference
+ outlive the build that stored it. The set is read at once, so entries that pass combinations
+ between them arrive together; where the whole leaves the scheme unresolvable, the entries are
+ read one at a time and each that stands aside costs only itself.
+
+ Args:
+ overrides: The combination each rebound action answers to, keyed by the action's name.
+
+ Returns:
+ ShortcutScheme: The scheme every action resolves against once the overrides are read.
+ """
+ if not overrides:
+ return self
+
+ try:
+ return self.with_bindings(self._read_overrides(overrides))
+ except (KeyError, SystemError) as exception:
+ logger.warning(f"Keybindings overrides read one entry at a time: {exception}")
+ return self._rebound_each(overrides)
+
+ def rebound(self, name: str, combination: Optional[str]) -> ShortcutScheme:
+ """The scheme as one stored preference rebinds it, read the way a preference is read.
+
+ An entry takes effect while it names an action this build carries, a key the table holds and
+ a combination its category has room for; anything else is reported and the scheme is
+ returned as it stands, so one unreadable preference costs only itself.
+
+ Args:
+ name: The action the entry rebinds, named the way a keybinding file writes it.
+ combination: The keys it answers to, ``None`` leaving the action unbound.
+
+ Returns:
+ ShortcutScheme: The scheme the entry leaves in place.
+ """
+ shortcut_id = SHORTCUT_IDS_BY_NAME.get(name)
+ if shortcut_id is None:
+ logger.warning(f"Keybinding override names unknown action {name!r}, keeping the scheme's own keys")
+ return self
+
+ try:
+ return self.with_binding(
+ shortcut_id,
+ None if combination is None else KeyCombination.parse(combination),
+ )
+ except (KeyError, SystemError) as exception:
+ logger.warning(f"Keybinding override giving {name!r} the combination {combination!r} left out: {exception}")
+ return self
+
+ @classmethod
+ def load(cls, path: Path) -> ShortcutScheme:
+ """Load the scheme a keybinding file holds.
+
+ Raises:
+ TypeError: when the file holds a value other than a mapping.
+ SystemError: when the file is not available.
+ """
+ try:
+ raw = load_yaml(path)
+ except OSError as exception:
+ raise SystemError(f"Keybinding file '{path}' not found") from exception
+
+ if not isinstance(raw, dict):
+ raise TypeError(f"Keybinding file '{path}' must contain a mapping, got {type(raw)}")
+
+ return cls.model_validate(raw)
+
+ def _read_overrides(
+ self,
+ overrides: Mapping[str, Optional[str]],
+ ) -> Dict[ShortcutId, Optional[KeyCombination]]:
+ """Every override as the action and the combination it names.
+
+ Raises:
+ KeyError: when an entry names an action this build carries none of, or a key the table
+ holds none of.
+ """
+ return {
+ SHORTCUT_IDS_BY_NAME[name]: None if combination is None else KeyCombination.parse(combination)
+ for name, combination in overrides.items()
+ }
+
+ def _rebound_each(self, overrides: Mapping[str, Optional[str]]) -> ShortcutScheme:
+ """The scheme as every override that stands rebinds it, read one entry at a time."""
+ scheme = self
+ for name, combination in overrides.items():
+ scheme = scheme.rebound(name, combination)
+
+ return scheme
+
+ def _require_every_action_answered(self) -> None:
+ unanswered: List[str] = [shortcut_id.value for shortcut_id in ShortcutId if shortcut_id not in self.bindings]
+ if unanswered:
+ raise SystemError(f"Keybinding scheme {self.name!r} leaves actions unanswered: {unanswered}")
+
+ def _require_one_action_per_combination(self) -> None:
+ """Checks each action against the index, which holds the first claimant of a combination.
+
+ An action the index answers with someone else is the second to claim that combination
+ within its category, which leaves the press ambiguous.
+ """
+ for shortcut_id, shortcut in self.shortcuts.items():
+ for combination in shortcut.combinations():
+ claimant = self.claims[shortcut_id.category][combination]
+ if claimant is not shortcut_id:
+ raise SystemError(
+ f"Keybinding scheme {self.name!r} gives {combination.display()} to both "
+ f"{claimant.value!r} and {shortcut_id.value!r}, "
+ f"which share the {shortcut_id.category} category"
+ )
diff --git a/src/sampletones_application/utils/gui/shortcuts/shortcut.py b/src/sampletones_application/utils/gui/shortcuts/shortcut.py
index 5d20f1cd..c990e3e9 100644
--- a/src/sampletones_application/utils/gui/shortcuts/shortcut.py
+++ b/src/sampletones_application/utils/gui/shortcuts/shortcut.py
@@ -1,35 +1,47 @@
from dataclasses import dataclass
-from typing import Optional
+from typing import Final, Optional, Tuple
-from sampletones_application.utils.gui.keyboard.modifiers import (
- NO_MODIFIERS,
- ModifierSet,
- modifiers_display,
-)
-from sampletones_application.utils.gui.shortcuts.keys import KEY_DISPLAY_NAMES
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+
+NO_COMBINATION: Final[str] = ""
+NO_ALIASES: Final[Tuple[KeyCombination, ...]] = ()
@dataclass(frozen=True)
class Shortcut:
- """A key plus its required modifiers, and whether it fires while a field is focused.
+ """The binding of one action: the combination that fires it, the further ones that also do, and
+ whether it fires while a field is focused.
+
+ The primary combination is the one the action displays in menus and tooltips; an alias extends
+ only the key handling, so one action answers several conventional combinations. An action holds
+ a binding record whether or not a combination is assigned to it, so a menu lists it either way
+ and the keybindings options have an entry to fill.
- ``field_transparent`` shortcuts (e.g. switching tabs) outrank text entry and fire even
- while an input owns the keyboard; the rest stay behind field focus so their keys reach
- the field.
+ ``field_transparent`` shortcuts (e.g. switching tabs) outrank text entry and fire even while an
+ input owns the keyboard; the rest stay behind field focus so their keys reach the field.
"""
- key: Optional[int] = None
- modifiers: ModifierSet = NO_MODIFIERS
+ combination: Optional[KeyCombination]
+ aliases: Tuple[KeyCombination, ...] = NO_ALIASES
field_transparent: bool = False
- def get_display_string(self) -> str:
- if self.key is None:
- return ""
+ def combinations(self) -> Tuple[KeyCombination, ...]:
+ """Every combination that fires the action, the one it displays first."""
+ primary = () if self.combination is None else (self.combination,)
+ return (*primary, *self.aliases)
+
+ def matches(self, event: KeyEvent) -> bool:
+ """Whether ``event`` is a press of any combination bound to the action.
- return "+".join((*modifiers_display(self.modifiers), self._key_to_string()))
+ Args:
+ event: The press to test, carrying the modifiers held as it fired.
- def _key_to_string(self) -> str:
- if self.key is None:
- return ""
+ Returns:
+ bool: True while any bound combination names the event.
+ """
+ return any(combination.matches(event) for combination in self.combinations())
- return KEY_DISPLAY_NAMES.get(self.key, "?")
+ def display(self) -> str:
+ """The combination as it reads in a menu, empty while the action carries none."""
+ return NO_COMBINATION if self.combination is None else self.combination.display()
diff --git a/src/sampletones_application/utils/gui/shortcuts/source.py b/src/sampletones_application/utils/gui/shortcuts/source.py
new file mode 100644
index 00000000..b52d1ce7
--- /dev/null
+++ b/src/sampletones_application/utils/gui/shortcuts/source.py
@@ -0,0 +1,54 @@
+from typing import Optional
+
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId
+from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
+from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut
+from sampletones_shared.types.callback import Callback
+from sampletones_shared.utils.callbacks import CallbackMixin
+
+
+class ShortcutSource(CallbackMixin):
+ """The scheme every action resolves its keys against, and the one place it changes.
+
+ A menu asks it what accelerator to print and a dispatcher asks it what a press means, so
+ activating another scheme rebinds the whole application from one call. Whatever has already
+ read a combination is refreshed by the listener on ``on_bindings_changed``.
+ """
+
+ def __init__(self, scheme: ShortcutScheme) -> None:
+ self._scheme = scheme
+ self.on_bindings_changed: Optional[Callback] = None
+
+ @property
+ def scheme(self) -> ShortcutScheme:
+ return self._scheme
+
+ def shortcut(self, shortcut_id: ShortcutId) -> Shortcut:
+ """The binding the scheme in place gives an action."""
+ return self._scheme.shortcut(shortcut_id)
+
+ def display(self, shortcut_id: ShortcutId) -> str:
+ """The combination an action reads under, as a menu or a tooltip prints it."""
+ return self.shortcut(shortcut_id).display()
+
+ def action(self, category: ShortcutCategory, event: KeyEvent) -> Optional[ShortcutId]:
+ """The action a scope's press means under the scheme in place.
+
+ A panel asks with its own category, so the press it acts on follows the scheme rather than
+ a combination written into the handler.
+ """
+ return self._scheme.action(category, event)
+
+ def activate(self, scheme: ShortcutScheme) -> None:
+ """Make ``scheme`` the one every action resolves its keys against.
+
+ Announces the change once the swap is in place, so the listener reads the new combinations
+ as it rebinds. Activating the scheme already in place leaves both the keys and the listener
+ untouched.
+ """
+ if scheme == self._scheme:
+ return
+
+ self._scheme = scheme
+ self.call(self.on_bindings_changed, scheme)
diff --git a/src/sampletones_application/utils/gui/shortcuts/written.py b/src/sampletones_application/utils/gui/shortcuts/written.py
new file mode 100644
index 00000000..035b2a8f
--- /dev/null
+++ b/src/sampletones_application/utils/gui/shortcuts/written.py
@@ -0,0 +1,50 @@
+from __future__ import annotations
+
+from typing import Final, Optional, Tuple
+
+from pydantic import BaseModel
+
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut
+
+NO_WRITTEN_ALIASES: Final[Tuple[str, ...]] = ()
+
+
+class WrittenShortcut(BaseModel, frozen=True):
+ """One action's binding as a keybinding file spells it out.
+
+ The combination is written the way it reads on screen — ``"Ctrl+Shift+Z"``, ``"PgDn"``,
+ ``"Num+"`` — so a reader assigns a key in the terms the menus already show them. An entry
+ states its combination even where the action carries none, which keeps every action visible
+ in the file and gives the keybindings options an entry to fill.
+ """
+
+ combination: Optional[str]
+ aliases: Tuple[str, ...] = NO_WRITTEN_ALIASES
+ field_transparent: bool = False
+
+ def rebound(self, combination: Optional[str]) -> WrittenShortcut:
+ """The entry as a reader rebound it, answering the combination they named and that alone.
+
+ The reader states one combination, which is the whole of what reaches the action; the field
+ transparency stays, since it follows from the action's role rather than from its keys.
+
+ Args:
+ combination: The keys the action answers to, ``None`` leaving it unbound.
+ """
+ return WrittenShortcut(
+ combination=combination,
+ field_transparent=self.field_transparent,
+ )
+
+ def resolve(self) -> Shortcut:
+ """The binding the entry names, read into the combinations a press is matched against.
+
+ Raises:
+ KeyError: when a written combination names a key the key table holds none of.
+ """
+ return Shortcut(
+ combination=None if self.combination is None else KeyCombination.parse(self.combination),
+ aliases=tuple(KeyCombination.parse(alias) for alias in self.aliases),
+ field_transparent=self.field_transparent,
+ )
diff --git a/src/sampletones_application/utils/gui/staging.py b/src/sampletones_application/utils/gui/staging.py
index e3b872f7..f4f31af1 100644
--- a/src/sampletones_application/utils/gui/staging.py
+++ b/src/sampletones_application/utils/gui/staging.py
@@ -3,6 +3,7 @@
import dearpygui.dearpygui as dpg
+from sampletones_application.utils.gui.dpg import dpg_container
from sampletones_shared.types.application import Sender
@@ -25,11 +26,8 @@ def staged_container(stage: Sender) -> Iterator[None]:
Items created with an explicit parent still honour that parent; the stage
captures the parentless ones.
"""
- dpg.push_container_stack(stage)
- try:
+ with dpg_container(stage):
yield
- finally:
- dpg.pop_container_stack()
def attach_staged_item(item: Sender, parent: Sender) -> None:
diff --git a/src/sampletones_application/utils/monitors.py b/src/sampletones_application/utils/monitors.py
new file mode 100644
index 00000000..686a8be2
--- /dev/null
+++ b/src/sampletones_application/utils/monitors.py
@@ -0,0 +1,112 @@
+from typing import List, Optional, Self
+
+from pydantic import BaseModel, Field
+from screeninfo import Monitor, ScreenInfoError, get_monitors
+
+from sampletones_shared.display import Resolution
+from sampletones_shared.logger import logger
+
+
+class MonitorArea(BaseModel, frozen=True):
+ """The rectangle a monitor occupies, and how much of it a window may take.
+
+ A window keeps a margin of the monitor free so its decoration frame stays on screen, which
+ makes :attr:`usable_width` and :attr:`usable_height` the size a window is fitted to and the
+ ceiling a selectable resolution is measured against. The fraction is carried with the area,
+ so the window layout that sets the margin decides it for every area it builds.
+ """
+
+ x: int
+ y: int
+ width: int = Field(ge=1)
+ height: int = Field(ge=1)
+ usable_ratio: float = Field(gt=0.0, le=1.0)
+
+ @property
+ def usable_width(self) -> int:
+ return int(self.width * self.usable_ratio)
+
+ @property
+ def usable_height(self) -> int:
+ return int(self.height * self.usable_ratio)
+
+ @classmethod
+ def of(cls, monitor: Monitor, usable_ratio: float) -> Self:
+ return cls(
+ x=int(monitor.x),
+ y=int(monitor.y),
+ width=int(monitor.width),
+ height=int(monitor.height),
+ usable_ratio=usable_ratio,
+ )
+
+ @classmethod
+ def assumed(cls, monitor: Resolution, usable_ratio: float) -> Self:
+ """The area a window is fitted to against a monitor size the caller assumes."""
+ return cls(
+ x=0,
+ y=0,
+ width=monitor.width,
+ height=monitor.height,
+ usable_ratio=usable_ratio,
+ )
+
+
+def available_monitors() -> List[Monitor]:
+ """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 a caller falls back to assumed dimensions.
+ """
+ try:
+ return get_monitors()
+ except ScreenInfoError as exception:
+ logger.warning(f"No monitor information available: {exception}")
+ return []
+
+
+def monitor_for_window(
+ x: int,
+ y: int,
+ width: int,
+ height: int,
+) -> Optional[Monitor]:
+ """The monitor a window overlaps most, or nothing while the platform reports none."""
+ monitors = available_monitors()
+ if not monitors:
+ return None
+
+ return max(
+ monitors,
+ key=lambda monitor: _overlap(monitor, x, y, width, height),
+ )
+
+
+def monitor_area_for_window(
+ x: int,
+ y: int,
+ width: int,
+ height: int,
+ *,
+ usable_ratio: float,
+ fallback_monitor: Resolution,
+) -> MonitorArea:
+ """The area of the monitor a window sits on, the given size where the platform reports none."""
+ monitor = monitor_for_window(x, y, width, height)
+ if monitor is None:
+ return MonitorArea.assumed(fallback_monitor, usable_ratio)
+
+ return MonitorArea.of(monitor, usable_ratio)
+
+
+def _overlap(
+ monitor: Monitor,
+ x: int,
+ y: int,
+ width: int,
+ height: int,
+) -> int:
+ overlap_width = max(0, min(x + width, monitor.x + monitor.width) - max(x, monitor.x))
+ overlap_height = max(0, min(y + height, monitor.y + monitor.height) - max(y, monitor.y))
+ return int(overlap_width * overlap_height)
diff --git a/src/sampletones_application/utils/palette.py b/src/sampletones_application/utils/palette.py
deleted file mode 100644
index 51aad154..00000000
--- a/src/sampletones_application/utils/palette.py
+++ /dev/null
@@ -1,135 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Annotated, Any, Dict, Final, Mapping, Optional, Union
-
-from pydantic import BaseModel, BeforeValidator, Field, ValidationInfo, model_validator
-
-from sampletones_shared.types.application import ColorRGBA
-from sampletones_shared.utils.color import RGBA, parse_hex_color, with_alpha_fraction
-from sampletones_shared.utils.serialization import load_yaml
-
-REFERENCE_PREFIX: Final[str] = "."
-ALPHA_SEPARATOR: Final[str] = "/"
-PALETTE_CONTEXT_KEY: Final[str] = "palette"
-
-
-class PaletteReference(BaseModel, frozen=True):
- """A colour entry's reference to a named palette colour.
-
- Written in YAML as ``.token`` or ``.token/alpha`` where ``alpha`` is a fraction
- in ``[0, 1]`` that overrides the token's own alpha. The leading ``.`` marks the
- value as a reference and keeps it distinct from a ``#rrggbb`` literal, so a
- colour field accepts either form in the same slot.
- """
-
- token: str
- alpha: Optional[float] = None
-
- @model_validator(mode="before")
- @classmethod
- def _from_string(cls, value: Any) -> object:
- if isinstance(value, str):
- return _parse_reference(value)
-
- return value
-
-
-def _parse_reference(value: str) -> Dict[str, object]:
- text = value.strip()
- if not text.startswith(REFERENCE_PREFIX):
- raise ValueError(f"Palette reference must start with {REFERENCE_PREFIX!r}, got {value!r}")
-
- token, separator, alpha_text = text[len(REFERENCE_PREFIX) :].partition(ALPHA_SEPARATOR)
- if not token:
- raise ValueError(f"Palette reference must name a token, got {value!r}")
-
- parsed: Dict[str, object] = {"token": token}
- if separator:
- alpha = float(alpha_text)
- if not 0.0 <= alpha <= 1.0:
- raise ValueError(f"Palette reference alpha must lie within [0, 1], got {alpha} in {value!r}")
-
- parsed["alpha"] = alpha
-
- return parsed
-
-
-class Palette(BaseModel, frozen=True):
- """A named set of semantic colour tokens shared across a theme set and the layout.
-
- Colour fields reference these tokens by name so a colour is defined once and
- reused everywhere, and swapping the palette restyles every theme and layout entry
- that resolves against it.
- """
-
- name: str
- colors: Dict[str, RGBA]
-
- def resolve(self, reference: PaletteReference) -> ColorRGBA:
- """Resolve a reference to a concrete RGBA tuple.
-
- Applies the reference's alpha override when present, keeping the token's red,
- green, and blue channels.
-
- Raises:
- KeyError: when the palette holds no token of the referenced name.
- """
- if reference.token not in self.colors:
- raise KeyError(
- f"Palette {self.name!r} has no colour token {REFERENCE_PREFIX}{reference.token!r}. "
- f"Known tokens: {sorted(self.colors)}"
- )
-
- color = self.colors[reference.token]
- if reference.alpha is None:
- return color
-
- return with_alpha_fraction(color, reference.alpha)
-
- @classmethod
- def load(cls, path: Path) -> Palette:
- """Load the palette that colour references resolve against.
-
- Raises:
- TypeError: when the palette file holds a value other than a mapping.
- SystemError: when the file is not available.
- """
- try:
- raw = load_yaml(path)
- except OSError as exception:
- raise SystemError(f"Palette file '{path}' not found") from exception
-
- if not isinstance(raw, dict):
- raise TypeError(f"Palette file '{path}' must contain a mapping, got {type(raw)}")
-
- return Palette.model_validate(raw)
-
-
-ColorSource = Annotated[Union[PaletteReference, RGBA], Field(union_mode="left_to_right")]
-
-
-def _palette_from_context(info: ValidationInfo) -> Palette:
- context = info.context
- if not isinstance(context, Mapping) or PALETTE_CONTEXT_KEY not in context:
- raise ValueError(f"Resolving a palette reference requires a {PALETTE_CONTEXT_KEY!r} validation context")
-
- palette = context[PALETTE_CONTEXT_KEY]
- if not isinstance(palette, Palette):
- raise TypeError(f"Validation context {PALETTE_CONTEXT_KEY!r} must be a Palette, got {type(palette)}")
-
- return palette
-
-
-def _resolve_palette_color(value: Any, info: ValidationInfo) -> object:
- if isinstance(value, str):
- text = value.strip()
- if text.startswith(REFERENCE_PREFIX):
- return _palette_from_context(info).resolve(PaletteReference.model_validate(text))
-
- return parse_hex_color(text)
-
- return value
-
-
-PaletteColor = Annotated[ColorRGBA, BeforeValidator(_resolve_palette_color)]
diff --git a/src/sampletones_application/utils/palette/__init__.py b/src/sampletones_application/utils/palette/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/utils/palette/catalog.py b/src/sampletones_application/utils/palette/catalog.py
new file mode 100644
index 00000000..7b1d7c99
--- /dev/null
+++ b/src/sampletones_application/utils/palette/catalog.py
@@ -0,0 +1,80 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, Final, Tuple
+
+from sampletones_application.utils.palette.palette import Palette
+from sampletones_shared.logger import logger
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
+
+DEFAULT_PALETTE_NAME: Final[str] = "studio"
+
+
+@dataclass(frozen=True)
+class PaletteCatalog:
+ """The palettes a build ships, indexed by name so a stored preference selects one.
+
+ A palette file is named after the palette it holds, which makes the name a reader
+ states in a preference the same name they find on disk.
+ """
+
+ palettes: Dict[str, Palette]
+
+ @classmethod
+ def load(cls, directory: Path) -> PaletteCatalog:
+ """Load every palette the directory holds, ordered by name.
+
+ Raises:
+ SystemError: when the directory holds no palette, or omits the default one.
+ ValueError: when a palette's name differs from its file stem.
+ """
+ palettes: Dict[str, Palette] = {}
+ for path in sorted(directory.glob(f"*{EXT_FILE_YAML}")):
+ palette = Palette.load(path)
+ if palette.name != path.stem:
+ raise ValueError(f"Palette file '{path}' holds palette {palette.name!r}; the two names must match")
+
+ palettes[palette.name] = palette
+
+ if not palettes:
+ raise SystemError(f"Palette directory '{directory}' holds no palette")
+
+ if DEFAULT_PALETTE_NAME not in palettes:
+ raise SystemError(
+ f"Palette directory '{directory}' omits the default palette {DEFAULT_PALETTE_NAME!r}. "
+ f"Available palettes: {sorted(palettes)}"
+ )
+
+ return cls(palettes=dict(sorted(palettes.items())))
+
+ @property
+ def names(self) -> Tuple[str, ...]:
+ return tuple(self.palettes)
+
+ @property
+ def default(self) -> Palette:
+ return self.palettes[DEFAULT_PALETTE_NAME]
+
+ def get(self, name: str) -> Palette:
+ """The palette of the given name.
+
+ Raises:
+ KeyError: when the catalog holds no palette of that name.
+ """
+ if name not in self.palettes:
+ raise KeyError(f"Unknown palette {name!r}. Available palettes: {sorted(self.palettes)}")
+
+ return self.palettes[name]
+
+ def select(self, name: str) -> Palette:
+ """The palette a stored preference names, falling back to the default.
+
+ A preference outlives the build that wrote it, so a name a later build stopped
+ shipping resolves to the default and the application keeps its appearance.
+ """
+ if name not in self.palettes:
+ logger.warning(f"Unknown palette {name!r}, falling back to {DEFAULT_PALETTE_NAME!r}")
+ return self.default
+
+ return self.palettes[name]
diff --git a/src/sampletones_application/utils/palette/colors/__init__.py b/src/sampletones_application/utils/palette/colors/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_application/utils/palette/colors/base.py b/src/sampletones_application/utils/palette/colors/base.py
new file mode 100644
index 00000000..c8db5633
--- /dev/null
+++ b/src/sampletones_application/utils/palette/colors/base.py
@@ -0,0 +1,25 @@
+from abc import ABC, abstractmethod
+from dataclasses import dataclass
+
+from sampletones_shared.types.application import ColorRGBA
+
+
+@dataclass(frozen=True)
+class BaseColor(ABC):
+ """A colour read at the moment it is drawn with.
+
+ A colour keeps the form it was given rather than a value of its own, and :attr:`rgba`
+ answers with what that form reads under the palette active right now, so the same object
+ gives a new colour once another palette is activated. Consumers hold the colour and read
+ :attr:`rgba` where they hand it to DearPyGui.
+
+ Each form is a frozen dataclass carrying what it was written or composed from, which makes
+ a colour hashable by that form and lets a theme cache key on the shade it holds. A form
+ composed from other colours reads them through this same property, so a shade taken from a
+ token follows a palette swap along with the colour it came from.
+ """
+
+ @property
+ @abstractmethod
+ def rgba(self) -> ColorRGBA:
+ """The colour's value under the active palette."""
diff --git a/src/sampletones_application/utils/palette/colors/blended.py b/src/sampletones_application/utils/palette/colors/blended.py
new file mode 100644
index 00000000..cc77ad78
--- /dev/null
+++ b/src/sampletones_application/utils/palette/colors/blended.py
@@ -0,0 +1,19 @@
+from dataclasses import dataclass
+
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_shared.types.application import ColorRGBA
+from sampletones_shared.utils.color import blend
+
+
+@dataclass(frozen=True)
+class BlendedColor(BaseColor):
+ """A colour carried as a point on the gradient between two others, channel by channel."""
+
+ start: BaseColor
+ end: BaseColor
+ fraction: float
+
+ @property
+ def rgba(self) -> ColorRGBA:
+ """Both ends' values under the active palette, mixed at the carried fraction."""
+ return blend(self.start.rgba, self.end.rgba, self.fraction)
diff --git a/src/sampletones_application/utils/palette/colors/faded.py b/src/sampletones_application/utils/palette/colors/faded.py
new file mode 100644
index 00000000..a25f7797
--- /dev/null
+++ b/src/sampletones_application/utils/palette/colors/faded.py
@@ -0,0 +1,18 @@
+from dataclasses import dataclass
+
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_shared.types.application import ColorRGBA
+from sampletones_shared.utils.color import with_alpha_fraction
+
+
+@dataclass(frozen=True)
+class FadedColor(BaseColor):
+ """A colour carried at a fraction of full opacity, keeping its red, green and blue."""
+
+ color: BaseColor
+ fraction: float
+
+ @property
+ def rgba(self) -> ColorRGBA:
+ """The carried colour's value under the active palette, at the carried opacity."""
+ return with_alpha_fraction(self.color.rgba, self.fraction)
diff --git a/src/sampletones_application/utils/palette/colors/grayscale.py b/src/sampletones_application/utils/palette/colors/grayscale.py
new file mode 100644
index 00000000..b803eef0
--- /dev/null
+++ b/src/sampletones_application/utils/palette/colors/grayscale.py
@@ -0,0 +1,17 @@
+from dataclasses import dataclass
+
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_shared.types.application import ColorRGBA
+from sampletones_shared.utils.color import to_grayscale
+
+
+@dataclass(frozen=True)
+class GrayscaleColor(BaseColor):
+ """A colour carried as the gray of the same luminance, keeping its alpha."""
+
+ color: BaseColor
+
+ @property
+ def rgba(self) -> ColorRGBA:
+ """The carried colour's value under the active palette, desaturated."""
+ return to_grayscale(self.color.rgba)
diff --git a/src/sampletones_application/utils/palette/colors/layered.py b/src/sampletones_application/utils/palette/colors/layered.py
new file mode 100644
index 00000000..84af5593
--- /dev/null
+++ b/src/sampletones_application/utils/palette/colors/layered.py
@@ -0,0 +1,22 @@
+from dataclasses import dataclass
+
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_shared.types.application import ColorRGBA
+from sampletones_shared.utils.color import composite
+
+
+@dataclass(frozen=True)
+class LayeredColor(BaseColor):
+ """A colour carried as one wash drawn over another, kept as the two it was composed from.
+
+ A surface that offers a single tint takes both washes through this form: the pair keeps
+ following the palette, and the value handed over is the shade the two make together.
+ """
+
+ base: BaseColor
+ overlay: BaseColor
+
+ @property
+ def rgba(self) -> ColorRGBA:
+ """Both washes' values under the active palette, the overlay covering the base."""
+ return composite(self.base.rgba, self.overlay.rgba)
diff --git a/src/sampletones_application/utils/palette/colors/literal.py b/src/sampletones_application/utils/palette/colors/literal.py
new file mode 100644
index 00000000..76ae0636
--- /dev/null
+++ b/src/sampletones_application/utils/palette/colors/literal.py
@@ -0,0 +1,16 @@
+from dataclasses import dataclass
+
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_shared.types.application import ColorRGBA
+
+
+@dataclass(frozen=True)
+class LiteralColor(BaseColor):
+ """A colour written as a ``#rrggbb`` value, standing on its own."""
+
+ value: ColorRGBA
+
+ @property
+ def rgba(self) -> ColorRGBA:
+ """The value the colour was written with."""
+ return self.value
diff --git a/src/sampletones_application/utils/palette/colors/named.py b/src/sampletones_application/utils/palette/colors/named.py
new file mode 100644
index 00000000..36b1c1fb
--- /dev/null
+++ b/src/sampletones_application/utils/palette/colors/named.py
@@ -0,0 +1,23 @@
+from dataclasses import dataclass
+
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.reference import PaletteReference
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_shared.types.application import ColorRGBA
+
+
+@dataclass(frozen=True)
+class NamedColor(BaseColor):
+ """A colour written as a palette reference, together with the source that answers it."""
+
+ reference: PaletteReference
+ source: PaletteSource
+
+ @property
+ def rgba(self) -> ColorRGBA:
+ """The value the active palette gives the referenced token.
+
+ Raises:
+ KeyError: when that palette holds no token of the referenced name.
+ """
+ return self.source.palette.resolve(self.reference)
diff --git a/src/sampletones_application/utils/palette/colors/written.py b/src/sampletones_application/utils/palette/colors/written.py
new file mode 100644
index 00000000..be285153
--- /dev/null
+++ b/src/sampletones_application/utils/palette/colors/written.py
@@ -0,0 +1,66 @@
+from typing import Annotated, Final, Mapping
+
+from pydantic import PlainValidator, ValidationInfo
+
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.literal import LiteralColor
+from sampletones_application.utils.palette.colors.named import NamedColor
+from sampletones_application.utils.palette.reference import PaletteReference, is_reference
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_shared.utils.color import parse_hex_color
+
+PALETTE_SOURCE_CONTEXT_KEY: Final[str] = "palette_source"
+
+
+def palette_source_from_context(info: ValidationInfo) -> PaletteSource:
+ """The palette source a colour reference binds to, taken from the validation context.
+
+ Raises:
+ ValueError: when the context omits the palette source entry.
+ TypeError: when the context entry holds a value other than a palette source.
+ """
+ context = info.context
+ if not isinstance(context, Mapping) or PALETTE_SOURCE_CONTEXT_KEY not in context:
+ raise ValueError(f"Resolving a palette reference requires a {PALETTE_SOURCE_CONTEXT_KEY!r} validation context")
+
+ source = context[PALETTE_SOURCE_CONTEXT_KEY]
+ if not isinstance(source, PaletteSource):
+ raise TypeError(
+ f"Validation context {PALETTE_SOURCE_CONTEXT_KEY!r} must be a PaletteSource, got {type(source)}"
+ )
+
+ return source
+
+
+def _written_color(value: object, info: ValidationInfo) -> BaseColor:
+ """The colour a configuration entry spells out, read once so its token answers at load.
+
+ An entry is written as a palette reference (``.token``, optionally ``.token/alpha``) or
+ as a ``#rrggbb`` literal, and is kept in the form it was written. A colour built in code
+ passes through as it stands, which is how a derived shade reaches a field.
+
+ Raises:
+ ValueError: when the entry holds a value of some other kind.
+ KeyError: when the palette in place at load holds no token of the referenced name.
+ """
+ if isinstance(value, BaseColor):
+ return value
+
+ if not isinstance(value, str):
+ raise ValueError(f"A colour is written as a palette reference or a hex literal, got {type(value)}")
+
+ text = value.strip()
+ color: BaseColor
+ if is_reference(text):
+ color = NamedColor(
+ reference=PaletteReference.model_validate(text),
+ source=palette_source_from_context(info),
+ )
+ else:
+ color = LiteralColor(parse_hex_color(text))
+
+ _ = color.rgba
+ return color
+
+
+WrittenColor = Annotated[BaseColor, PlainValidator(_written_color)]
diff --git a/src/sampletones_application/utils/palette/palette.py b/src/sampletones_application/utils/palette/palette.py
new file mode 100644
index 00000000..b523df96
--- /dev/null
+++ b/src/sampletones_application/utils/palette/palette.py
@@ -0,0 +1,62 @@
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Dict
+
+from pydantic import BaseModel
+
+from sampletones_application.utils.palette.reference import REFERENCE_PREFIX, PaletteReference
+from sampletones_shared.types.application import ColorRGBA
+from sampletones_shared.utils.color import RGBA, with_alpha_fraction
+from sampletones_shared.utils.serialization import load_yaml
+
+
+class Palette(BaseModel, frozen=True):
+ """A named set of semantic colour tokens shared across a theme set and the layout.
+
+ Colour fields reference these tokens by name so a colour is defined once and
+ reused everywhere, and swapping the palette restyles every theme and layout entry
+ that resolves against it.
+ """
+
+ name: str
+ colors: Dict[str, RGBA]
+
+ def resolve(self, reference: PaletteReference) -> ColorRGBA:
+ """Resolve a reference to a concrete RGBA tuple.
+
+ Applies the reference's alpha override when present, keeping the token's red,
+ green, and blue channels.
+
+ Raises:
+ KeyError: when the palette holds no token of the referenced name.
+ """
+ if reference.token not in self.colors:
+ raise KeyError(
+ f"Palette {self.name!r} has no colour token {REFERENCE_PREFIX}{reference.token!r}. "
+ f"Known tokens: {sorted(self.colors)}"
+ )
+
+ color = self.colors[reference.token]
+ if reference.alpha is None:
+ return color
+
+ return with_alpha_fraction(color, reference.alpha)
+
+ @classmethod
+ def load(cls, path: Path) -> Palette:
+ """Load the palette that colour references resolve against.
+
+ Raises:
+ TypeError: when the palette file holds a value other than a mapping.
+ SystemError: when the file is not available.
+ """
+ try:
+ raw = load_yaml(path)
+ except OSError as exception:
+ raise SystemError(f"Palette file '{path}' not found") from exception
+
+ if not isinstance(raw, dict):
+ raise TypeError(f"Palette file '{path}' must contain a mapping, got {type(raw)}")
+
+ return Palette.model_validate(raw)
diff --git a/src/sampletones_application/utils/palette/reference.py b/src/sampletones_application/utils/palette/reference.py
new file mode 100644
index 00000000..1e9438bd
--- /dev/null
+++ b/src/sampletones_application/utils/palette/reference.py
@@ -0,0 +1,57 @@
+from typing import Any, Dict, Final, Optional
+
+from pydantic import BaseModel, model_validator
+
+REFERENCE_PREFIX: Final[str] = "."
+ALPHA_SEPARATOR: Final[str] = "/"
+
+
+class PaletteReference(BaseModel, frozen=True):
+ """A colour entry's reference to a named palette colour.
+
+ Written in YAML as ``.token`` or ``.token/alpha`` where ``alpha`` is a fraction
+ in ``[0, 1]`` that overrides the token's own alpha. The leading ``.`` marks the
+ value as a reference and keeps it distinct from a ``#rrggbb`` literal, so a
+ colour field accepts either form in the same slot.
+ """
+
+ token: str
+ alpha: Optional[float] = None
+
+ @model_validator(mode="before")
+ @classmethod
+ def _from_string(cls, value: Any) -> object:
+ if isinstance(value, str):
+ return parse_reference(value)
+
+ return value
+
+
+def parse_reference(value: str) -> Dict[str, object]:
+ """Split a written reference into the fields :class:`PaletteReference` validates.
+
+ Raises:
+ ValueError: when the text lacks the reference prefix, names no token, or
+ carries an alpha outside ``[0, 1]``.
+ """
+ text = value.strip()
+ if not text.startswith(REFERENCE_PREFIX):
+ raise ValueError(f"Palette reference must start with {REFERENCE_PREFIX!r}, got {value!r}")
+
+ token, separator, alpha_text = text[len(REFERENCE_PREFIX) :].partition(ALPHA_SEPARATOR)
+ if not token:
+ raise ValueError(f"Palette reference must name a token, got {value!r}")
+
+ parsed: Dict[str, object] = {"token": token}
+ if separator:
+ alpha = float(alpha_text)
+ if not 0.0 <= alpha <= 1.0:
+ raise ValueError(f"Palette reference alpha must lie within [0, 1], got {alpha} in {value!r}")
+
+ parsed["alpha"] = alpha
+
+ return parsed
+
+
+def is_reference(value: str) -> bool:
+ return value.strip().startswith(REFERENCE_PREFIX)
diff --git a/src/sampletones_application/utils/palette/source.py b/src/sampletones_application/utils/palette/source.py
new file mode 100644
index 00000000..19a1fe7e
--- /dev/null
+++ b/src/sampletones_application/utils/palette/source.py
@@ -0,0 +1,36 @@
+from typing import Optional
+
+from sampletones_application.utils.palette.palette import Palette
+from sampletones_shared.types.callback import Callback
+from sampletones_shared.utils.callbacks import CallbackMixin
+
+
+class PaletteSource(CallbackMixin):
+ """The palette every colour token resolves against, and the one place it changes.
+
+ A :class:`BaseColor` keeps the token it was written as and reads its value from
+ here, so activating another palette gives every colour in the application a new
+ value with no reload and no re-injection. Whatever DearPyGui has already copied is
+ repainted by the listener on ``on_palette_changed``.
+ """
+
+ def __init__(self, palette: Palette) -> None:
+ self._palette = palette
+ self.on_palette_changed: Optional[Callback] = None
+
+ @property
+ def palette(self) -> Palette:
+ return self._palette
+
+ def activate(self, palette: Palette) -> None:
+ """Make ``palette`` the one every colour token resolves against.
+
+ Announces the change once the swap is in place, so the listener reads the new
+ colours as it repaints. Activating the palette already in place leaves both the
+ colours and the listener untouched.
+ """
+ if palette == self._palette:
+ return
+
+ self._palette = palette
+ self.call(self.on_palette_changed, palette)
diff --git a/src/sampletones_application/utils/parallelization/thread.py b/src/sampletones_application/utils/parallelization/thread.py
index 1f4fe32b..d13f101c 100644
--- a/src/sampletones_application/utils/parallelization/thread.py
+++ b/src/sampletones_application/utils/parallelization/thread.py
@@ -3,7 +3,7 @@
import threading
import time
from functools import wraps
-from typing import Any, Callable, Final, List, Optional, Set, cast
+from typing import Any, Callable, ClassVar, Final, List, Optional, Set, cast
from sampletones_shared.logger import logger
from sampletones_shared.types.callback import CallbackT, VoidCallback
@@ -21,8 +21,8 @@ class BackgroundWorkCancelled(Exception):
class SingleThreadExecutor:
- _live_threads: Set[threading.Thread] = set()
- _live_threads_lock = threading.Lock()
+ _live_threads: ClassVar[Set[threading.Thread]] = set()
+ _live_threads_lock: ClassVar[threading.Lock] = threading.Lock()
_shutdown = threading.Event()
def __init__(self) -> None:
@@ -44,9 +44,7 @@ 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(
@@ -108,6 +106,7 @@ def join_all(cls, timeout: Optional[float] = None) -> None:
if remaining <= 0.0:
cls._report_surviving_workers(live_threads)
return
+
thread.join(remaining)
@classmethod
@@ -138,6 +137,7 @@ def wrapper(self: Any, *args: Any, **kwargs: Any) -> None:
def task() -> None:
if SingleThreadExecutor.is_shutting_down():
return
+
try:
function(self, *args, **kwargs)
except BackgroundWorkCancelled:
diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py
index c599abe1..15c808fb 100644
--- a/src/sampletones_application/view_model/reconstruction/instruments.py
+++ b/src/sampletones_application/view_model/reconstruction/instruments.py
@@ -1,10 +1,19 @@
-from typing import FrozenSet
+from typing import FrozenSet, Optional
from pydantic import BaseModel
+from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
from sampletones_core.constants.enums import GeneratorName
class ReconstructionInstrumentsViewModel(BaseModel, frozen=True):
+ """What the instruments panel renders: every channel, and which of them play.
+
+ A reconstruction holds a tab per channel whatever it sounds, so a channel standing by stays
+ editable and giving it an envelope puts it in play. :attr:`playing_generators` is what the
+ panel reads to mark the standing-by tabs and to offer their export.
+ """
+
reconstruction_loaded: bool
- available_generators: FrozenSet[GeneratorName]
+ playing_generators: FrozenSet[GeneratorName]
+ footprint: Optional[SampleFootprintViewModel]
diff --git a/src/sampletones_application/view_model/reconstruction/reconstruction.py b/src/sampletones_application/view_model/reconstruction/reconstruction.py
index b2005950..7c48cf0e 100644
--- a/src/sampletones_application/view_model/reconstruction/reconstruction.py
+++ b/src/sampletones_application/view_model/reconstruction/reconstruction.py
@@ -33,8 +33,16 @@ class ReconstructionPathViewModel(BaseModel, frozen=True):
class ReconstructionViewModel(BaseModel, frozen=True):
+ """What the reconstruction view renders, including which channels the waveform offers.
+
+ A channel plays once its instruction stream describes a frame, which is what makes its
+ generator checkbox reachable; :attr:`selected_generators` is the subset the reader keeps
+ switched on, so a channel switched off by hand stays off across an edit.
+ """
+
reconstruction_loaded: bool
- available_generators: FrozenSet[GeneratorName]
+ playing_generators: FrozenSet[GeneratorName]
+ selected_generators: FrozenSet[GeneratorName]
reconstruction_file: ReconstructionPathViewModel
original_audio: ReconstructionPathViewModel
diff --git a/src/sampletones_application/view_model/sequencer/aggregate.py b/src/sampletones_application/view_model/sequencer/aggregate.py
index edd67d55..69892e6a 100644
--- a/src/sampletones_application/view_model/sequencer/aggregate.py
+++ b/src/sampletones_application/view_model/sequencer/aggregate.py
@@ -1,6 +1,7 @@
from typing import Set
from sampletones_shared.constants.symbols import MIXED
+from sampletones_shared.utils.agreement import Agreement
def aggregate_labels(values: Set[str], *, default: str) -> str:
@@ -10,10 +11,4 @@ def aggregate_labels(values: Set[str], *, default: str) -> str:
``default`` (no relevant cells), a single shared value is shown verbatim, and
any disagreement collapses to :data:`MIXED`.
"""
- if not values:
- return default
-
- if len(values) == 1:
- return next(iter(values))
-
- return MIXED
+ return Agreement.collapse(values).resolve(absent=default, mixed=MIXED)
diff --git a/src/sampletones_application/view_model/sequencer/order.py b/src/sampletones_application/view_model/sequencer/order.py
index 1f9b1aaf..8a224670 100644
--- a/src/sampletones_application/view_model/sequencer/order.py
+++ b/src/sampletones_application/view_model/sequencer/order.py
@@ -23,7 +23,7 @@ class SequencerOrderViewModel(BaseModel, frozen=True):
entries: Tuple[OrderEntryViewModel, ...]
-class SequencerOrderGridViewModel(BaseModel, frozen=True):
+class SequencerOrderTrackerViewModel(BaseModel, frozen=True):
"""The whole arrangement: order positions (columns) across channels (rows).
The master row summarises each position across channels — the horizontal analog
diff --git a/src/sampletones_application/view_model/sequencer/region.py b/src/sampletones_application/view_model/sequencer/region.py
new file mode 100644
index 00000000..04249d7b
--- /dev/null
+++ b/src/sampletones_application/view_model/sequencer/region.py
@@ -0,0 +1,142 @@
+from typing import Optional, Self, Tuple
+
+from pydantic import BaseModel, Field, model_validator
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.view_model.sequencer.slot import (
+ SLOT_COUNT,
+ TrackerSlot,
+ slot_from_flat,
+)
+from sampletones_core.constants.enums import GeneratorName
+
+
+class TrackerCell(BaseModel, frozen=True):
+ """The tracker cell a block is written from: a row, and the column it starts in.
+
+ A block carries the subcolumn offsets it was read at, so the cell it is anchored to names a
+ row and a column while the block supplies the rest. That is why a cell states no subcolumn:
+ the anchor decides where a block lands, and the block decides which kind of value goes where.
+ """
+
+ row: int = Field(ge=0)
+ generator: Optional[GeneratorName]
+
+
+class OrderCell(BaseModel, frozen=True):
+ """The order cell a block is written from: a channel row, and the position it starts in."""
+
+ generator: Optional[GeneratorName]
+ position: int = Field(ge=0)
+
+
+class GridRegion(BaseModel, frozen=True):
+ """The rows a selection covers, shared by both sequencer grids.
+
+ Both bounds are inclusive, so a region always covers the cell it was started from and the
+ smallest one covers exactly that cell. A producer orders the bounds it was given, which is
+ what makes a selection dragged upwards name the same region as one dragged down to the same
+ pair of cells.
+ """
+
+ first_row: int = Field(ge=0)
+ last_row: int = Field(ge=0)
+
+ @model_validator(mode="after")
+ def _validate_rows(self) -> Self:
+ if self.last_row < self.first_row:
+ raise ValueError(f"A region's rows end at {self.last_row}, before they begin at {self.first_row}")
+
+ return self
+
+ @property
+ def rows(self) -> range:
+ return range(self.first_row, self.last_row + 1)
+
+ def covers_row(self, row: int) -> bool:
+ return self.first_row <= row <= self.last_row
+
+
+class TrackerRegion(GridRegion, frozen=True):
+ """A rectangle of the tracker grid: pattern rows crossed with a run of slots.
+
+ The slots are indices into the flattened axis :data:`SLOT_COUNT` spans, so one region reaches
+ across the sample column and the channels alike and names the subcolumn it begins and ends on.
+ That is what lets a selection start midway through a cell: its edges are subcolumns.
+ """
+
+ first_slot: int = Field(ge=0, lt=SLOT_COUNT)
+ last_slot: int = Field(ge=0, lt=SLOT_COUNT)
+
+ @model_validator(mode="after")
+ def _validate_slots(self) -> Self:
+ if self.last_slot < self.first_slot:
+ raise ValueError(f"A region's slots end at {self.last_slot}, before they begin at {self.first_slot}")
+
+ return self
+
+ @property
+ def slots(self) -> Tuple[TrackerSlot, ...]:
+ """The slots the region covers, each as the column and subcolumn it addresses."""
+ return tuple(slot_from_flat(index) for index in range(self.first_slot, self.last_slot + 1))
+
+ @property
+ def columns(self) -> Tuple[Optional[GeneratorName], ...]:
+ """The columns the region reaches, each named once and in the order the axis lays them out.
+
+ A region names its edges as subcolumns, while a gesture acting on whole cells — a transpose
+ or a volume shift — reaches the columns behind them. The sample column reads ``None``, as it
+ does everywhere the axis is read.
+ """
+ return tuple(dict.fromkeys(slot.generator for slot in self.slots))
+
+ def covers(self, row: int, slot: TrackerSlot) -> bool:
+ """Whether a cell of the grid falls inside the rectangle.
+
+ This is what a gesture raised on a cell asks to learn which block it belongs to: one
+ landing inside a selection acts on the whole of it, and one landing outside acts alone.
+ """
+ return self.covers_row(row) and self.first_slot <= slot.flat_index <= self.last_slot
+
+
+class OrderRegion(GridRegion, frozen=True):
+ """A rectangle of the order table: channel rows crossed with a run of positions.
+
+ The rows are indices into :data:`CHANNEL_AXIS`, so row ``0`` is the master row and the
+ channels follow it in the order the table lays them out.
+ """
+
+ first_row: int = Field(ge=0, lt=len(CHANNEL_AXIS))
+ last_row: int = Field(ge=0, lt=len(CHANNEL_AXIS))
+ first_position: int = Field(ge=0)
+ last_position: int = Field(ge=0)
+
+ @model_validator(mode="after")
+ def _validate_positions(self) -> Self:
+ if self.last_position < self.first_position:
+ raise ValueError(
+ f"A region's positions end at {self.last_position}, before they begin at {self.first_position}"
+ )
+
+ return self
+
+ @property
+ def positions(self) -> range:
+ return range(self.first_position, self.last_position + 1)
+
+ @property
+ def generators(self) -> Tuple[Optional[GeneratorName], ...]:
+ """The rows the region covers, each as the channel it addresses, master reading ``None``."""
+ return tuple(CHANNEL_AXIS[row] for row in self.rows)
+
+ def covers(
+ self,
+ generator: Optional[GeneratorName],
+ position: int,
+ ) -> bool:
+ """Whether a cell of the table falls inside the rectangle.
+
+ This is what a gesture raised on a cell asks to learn which block it belongs to: one
+ landing inside a selection acts on the whole of it, and one landing outside acts alone.
+ """
+ return self.covers_row(CHANNEL_AXIS.index(generator)) and position in self.positions
diff --git a/src/sampletones_application/view_model/sequencer/settings.py b/src/sampletones_application/view_model/sequencer/settings.py
index 18f41d1b..ae414e45 100644
--- a/src/sampletones_application/view_model/sequencer/settings.py
+++ b/src/sampletones_application/view_model/sequencer/settings.py
@@ -6,3 +6,5 @@ class SequencerSettingsViewModel(BaseModel, frozen=True):
tempo: int
speed: int
rows_per_pattern: int
+ first_highlight: int
+ second_highlight: int
diff --git a/src/sampletones_application/view_model/sequencer/slot.py b/src/sampletones_application/view_model/sequencer/slot.py
new file mode 100644
index 00000000..f4902ea3
--- /dev/null
+++ b/src/sampletones_application/view_model/sequencer/slot.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from typing import Final, Optional, Tuple
+
+from pydantic.dataclasses import dataclass
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+
+SUBCOLUMNS: Final[Tuple[SubColumn, ...]] = tuple(SubColumn)
+SLOT_COUNT: Final[int] = len(CHANNEL_AXIS) * len(SUBCOLUMNS)
+
+
+@dataclass(frozen=True)
+class TrackerSlot:
+ """One addressable cell of the tracker grid: a column paired with a subcolumn.
+
+ The grid lays the sample column and the four channels out along
+ :data:`CHANNEL_AXIS`, each holding the same :data:`SUBCOLUMNS`, so a slot reads
+ equally as that pair and as a single index into the flattened axis. Both
+ readings are load-bearing: navigation and range selection walk the flat index,
+ while an edit addresses the column and the subcolumn it lands in.
+ """
+
+ generator: Optional[GeneratorName]
+ subcolumn: SubColumn
+
+ @property
+ def flat_index(self) -> int:
+ return column_slot_base(self.generator) + SUBCOLUMNS.index(self.subcolumn)
+
+
+def column_slot_base(generator: Optional[GeneratorName]) -> int:
+ """The flat index of ``generator``'s first subcolumn.
+
+ Every base is a multiple of ``len(SUBCOLUMNS)``, which is what keeps an offset
+ measured from one column's base addressing the same kind of subcolumn at any
+ other column it is replayed against.
+ """
+ return CHANNEL_AXIS.index(generator) * len(SUBCOLUMNS)
+
+
+def slot_from_flat(index: int) -> TrackerSlot:
+ """Reads a flat index back as the column and subcolumn it addresses.
+
+ The mapping is exact over the axis, so a caller that walks off either end is
+ asking for a slot the grid has no cell for: navigation wraps its index before
+ calling, and a range selection clips to the axis.
+
+ Raises:
+ IndexError: if ``index`` lies outside ``0`` up to :data:`SLOT_COUNT`.
+ """
+ if not 0 <= index < SLOT_COUNT:
+ raise IndexError(f"Tracker slot index out of range: {index}")
+
+ column, subcolumn = divmod(index, len(SUBCOLUMNS))
+ return TrackerSlot(CHANNEL_AXIS[column], SUBCOLUMNS[subcolumn])
diff --git a/src/sampletones_application/view_model/sequencer/song_player.py b/src/sampletones_application/view_model/sequencer/song_player.py
index 238a97a3..6901de17 100644
--- a/src/sampletones_application/view_model/sequencer/song_player.py
+++ b/src/sampletones_application/view_model/sequencer/song_player.py
@@ -2,12 +2,14 @@
from pydantic import BaseModel
+from sampletones_application.constants.playback import FollowMode
+
class SongPlayerViewModel(BaseModel, frozen=True):
is_loaded: bool
is_playing: bool
is_paused: bool
- follow_playback: bool
+ follow_mode: FollowMode
order_position: int
row_index: int
error: Optional[str] = None
diff --git a/src/sampletones_application/view_model/sequencer/grid.py b/src/sampletones_application/view_model/sequencer/tracker.py
similarity index 54%
rename from src/sampletones_application/view_model/sequencer/grid.py
rename to src/sampletones_application/view_model/sequencer/tracker.py
index d6b38872..54012c51 100644
--- a/src/sampletones_application/view_model/sequencer/grid.py
+++ b/src/sampletones_application/view_model/sequencer/tracker.py
@@ -4,7 +4,11 @@
from sampletones_application.view_model.sequencer.aggregate import aggregate_labels
from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.utils.display import NOTE_OFF, display_id, display_transpose, display_volume
+from sampletones_core.utils.display import (
+ display_id,
+ display_transpose,
+ display_volume,
+)
class SequencerCellViewModel(BaseModel, frozen=True):
@@ -12,7 +16,7 @@ class SequencerCellViewModel(BaseModel, frozen=True):
The columns are produced by :mod:`sampletones_core.utils.display`, the single
source of tracker cell formatting (sample position, transpose, volume). The
- grid renders :attr:`label`, the combined cell text.
+ tracker grid renders :attr:`label`, the combined cell text.
"""
instrument: str
@@ -36,56 +40,43 @@ class SequencerRowViewModel(BaseModel, frozen=True):
@property
def subcolumn_generators(self) -> FrozenSet[GeneratorName]:
- """Channels the sample column's transpose/volume span.
+ """Channels every sample column summary spans.
- Transpose and volume exist independently of an instrument, so on a row with
- no sample they fall back to every channel; otherwise they track the
- sample's channels exactly like the instrument does.
+ A sample governs the channels its reconstruction covers, so its subcolumns
+ summarise exactly those. Transpose and volume exist independently of an
+ instrument, so a row with no sample spans every channel.
"""
return self.relevant_generators or frozenset(self.cells)
@property
def sample_instrument(self) -> str:
- """The sample column's note value.
-
- A referenced sample wins: the column shows its position (or :data:`MIXED` when the sample
- spans more channels than it occupies here). With no sample present, the column reads ``--``
- only when every channel is a note-off; any other mix — including a half-cut row of some
- note-off and some blank — reads as empty.
- """
- if self.relevant_generators:
- return self._aggregate(self.relevant_generators, lambda cell: cell.instrument, display_id(None))
-
- if self.cells and all(cell.instrument == NOTE_OFF for cell in self.cells.values()):
- return NOTE_OFF
-
- return display_id(None)
+ return self._aggregate(lambda cell: cell.instrument, display_id(None))
@property
def sample_transpose(self) -> str:
- return self._aggregate(self.subcolumn_generators, lambda cell: cell.transpose, display_transpose(None))
+ return self._aggregate(lambda cell: cell.transpose, display_transpose(None))
@property
def sample_volume(self) -> str:
- return self._aggregate(self.subcolumn_generators, lambda cell: cell.volume, display_volume(None))
+ return self._aggregate(lambda cell: cell.volume, display_volume(None))
def _aggregate(
self,
- generators: FrozenSet[GeneratorName],
select: Callable[[SequencerCellViewModel], str],
default: str,
) -> str:
- """Summarise one subcolumn across the given channels.
+ """Summarise one subcolumn across the channels the sample column spans.
- The summary holds a value only when every channel agrees on it, so a sample
- missing from one of its channels (an empty cell there) reads as
- :data:`MIXED`. With no channels the empty default is shown.
+ The summary holds a value only where every channel agrees on it, so
+ :data:`MIXED` marks each way they can differ: a sample missing from one of
+ its channels, a transpose set on some of them, or a row cut on some and
+ blank on the rest. A row with no cells at all shows the empty default.
"""
- values: Set[str] = {select(self.cells[generator]) for generator in generators}
+ values: Set[str] = {select(self.cells[generator]) for generator in self.subcolumn_generators}
return aggregate_labels(values, default=default)
-class SequencerGridViewModel(BaseModel, frozen=True):
+class SequencerTrackerViewModel(BaseModel, frozen=True):
"""The tracker view for a single order frame across the four channels.
Each channel plays its ``order[frame_index]`` pattern; the grid shows those
diff --git a/src/sampletones_application/view_model/shared/display_settings.py b/src/sampletones_application/view_model/shared/display_settings.py
new file mode 100644
index 00000000..804c2d00
--- /dev/null
+++ b/src/sampletones_application/view_model/shared/display_settings.py
@@ -0,0 +1,248 @@
+from __future__ import annotations
+
+from typing import Dict, Tuple
+
+from pydantic import BaseModel
+
+from sampletones_application.view_model.shared.nearest import nearest_offered
+from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution
+
+
+def available_resolutions(
+ resolutions: Tuple[Resolution, ...],
+ *,
+ min_width: int,
+ min_height: int,
+ max_width: int,
+ max_height: int,
+) -> Tuple[Resolution, ...]:
+ """The sizes a window may open at within the given bounds, in the order they are offered.
+
+ A size is offered when it meets the window's minimum and stays inside the bound the caller
+ measures against — the usable area of the monitor the window sits on — so the window opens
+ at the size that was picked and comes back at it after a restart. The monitor's own
+ resolution is reached by going fullscreen.
+
+ Args:
+ resolutions: The sizes the build offers, in the order a combo shows them.
+ min_width: Narrowest width the window opens at.
+ min_height: Shortest height the window opens at.
+ max_width: Widest width the window is given room for.
+ max_height: Tallest height the window is given room for.
+
+ Returns:
+ Tuple[Resolution, ...]: The sizes to offer, or the window minimum alone where the bounds
+ leave room for none of them.
+ """
+ offered = tuple(
+ resolution
+ for resolution in resolutions
+ if resolution.reaches(min_width, min_height) and resolution.fits_within(max_width, max_height)
+ )
+ if offered:
+ return offered
+
+ return (Resolution(width=min_width, height=min_height),)
+
+
+def resolution_labels(resolutions: Tuple[Resolution, ...]) -> Tuple[str, ...]:
+ return tuple(str(resolution) for resolution in resolutions)
+
+
+def frame_rate_label(frame_rate: int, *, unlimited_label: str) -> str:
+ """The label a frame rate shows under, naming zero as the unlimited setting."""
+ return unlimited_label if frame_rate == UNLIMITED_FRAME_RATE else str(frame_rate)
+
+
+def frame_rate_labels(frame_rates: Tuple[int, ...], *, unlimited_label: str) -> Tuple[str, ...]:
+ return tuple(frame_rate_label(frame_rate, unlimited_label=unlimited_label) for frame_rate in frame_rates)
+
+
+def nearest_frame_rate(max_fps: int, frame_rates: Tuple[int, ...]) -> int:
+ """The offered frame rate a stored preference selects, the closest one it lies between.
+
+ Raises:
+ ValueError: when no frame rate is offered.
+ """
+ return nearest_offered(max_fps, frame_rates)
+
+
+def nearest_resolution(
+ width: int,
+ height: int,
+ resolutions: Tuple[Resolution, ...],
+) -> Resolution:
+ """The offered size a window of the given dimensions selects, the closest one by area.
+
+ Raises:
+ ValueError: when no size is offered.
+ """
+ if not resolutions:
+ raise ValueError("Selecting a resolution requires at least one offered size")
+
+ return min(
+ resolutions,
+ key=lambda resolution: (
+ abs(resolution.width - width) + abs(resolution.height - height),
+ resolution.width,
+ resolution.height,
+ ),
+ )
+
+
+class WindowMode(BaseModel, frozen=True):
+ """How the window presents itself on its monitor: the size it takes and the frame around it.
+
+ The three settings decide together whether the window stays usable, so they travel as one
+ value: a change to any of them is what a revert countdown guards, and restoring the mode
+ puts all three back at once. Each ``with_`` method answers with the mode carrying one
+ setting changed, leaving the mode it was asked of intact.
+ """
+
+ resolution: Resolution
+ borderless: bool
+ fullscreen: bool
+
+ def with_resolution(self, resolution: Resolution) -> WindowMode:
+ return self.model_copy(update={"resolution": resolution})
+
+ def with_borderless(self, borderless: bool) -> WindowMode:
+ return self.model_copy(update={"borderless": borderless})
+
+ def with_fullscreen(self, fullscreen: bool) -> WindowMode:
+ return self.model_copy(update={"fullscreen": fullscreen})
+
+
+class DisplaySettings(BaseModel, frozen=True):
+ """Everything the display settings offer, as one value to compare, snapshot and restore.
+
+ The dialog edits a copy and applies it live while the session keeps the values it opened
+ with, so a snapshot taken at that moment restores the appearance a user came in with. Each
+ ``with_`` method answers with the settings carrying one entry changed.
+ """
+
+ palette: str
+ window: WindowMode
+ vsync: bool
+ frame_rate: int
+
+ def with_palette(self, palette: str) -> DisplaySettings:
+ return self.model_copy(update={"palette": palette})
+
+ def with_window(self, window: WindowMode) -> DisplaySettings:
+ return self.model_copy(update={"window": window})
+
+ def with_vsync(self, vsync: bool) -> DisplaySettings:
+ return self.model_copy(update={"vsync": vsync})
+
+ def with_frame_rate(self, frame_rate: int) -> DisplaySettings:
+ return self.model_copy(update={"frame_rate": frame_rate})
+
+
+class DisplaySettingsViewModel(BaseModel, frozen=True):
+ """What the display settings dialog draws: the options offered, and the selection standing.
+
+ The sizes are those the window's monitor leaves room for, so the offer follows the screen the
+ window sits on. Each combo reads its labels here and reports a chosen label back, keeping the
+ projection between a label and the value it stands for in one place.
+ """
+
+ settings: DisplaySettings
+ resolutions: Tuple[Resolution, ...]
+ frame_rates: Tuple[int, ...]
+ palettes: Tuple[str, ...]
+
+ @classmethod
+ def build(
+ cls,
+ settings: DisplaySettings,
+ *,
+ resolutions: Tuple[Resolution, ...],
+ frame_rates: Tuple[int, ...],
+ palettes: Tuple[str, ...],
+ min_width: int,
+ min_height: int,
+ max_width: int,
+ max_height: int,
+ ) -> DisplaySettingsViewModel:
+ """Offers what the given bounds leave room for, with the standing selection snapped onto it.
+
+ A window sized between two offered entries — restored from a session, or dragged to a size
+ of its own — selects the nearest one, so the combos always show the state that is in force.
+
+ Args:
+ settings: The display state in force.
+ resolutions: The sizes the build offers.
+ frame_rates: The frame rates the build offers.
+ palettes: The palettes the build ships, in the order they are offered.
+ min_width: Narrowest width the window opens at.
+ min_height: Shortest height the window opens at.
+ max_width: Widest width the window's monitor leaves room for.
+ max_height: Tallest height the window's monitor leaves room for.
+ """
+ offered = available_resolutions(
+ resolutions,
+ min_width=min_width,
+ min_height=min_height,
+ max_width=max_width,
+ max_height=max_height,
+ )
+ selected = WindowMode(
+ resolution=nearest_resolution(
+ settings.window.resolution.width,
+ settings.window.resolution.height,
+ offered,
+ ),
+ borderless=settings.window.borderless,
+ fullscreen=settings.window.fullscreen,
+ )
+ return cls(
+ settings=DisplaySettings(
+ palette=settings.palette,
+ window=selected,
+ vsync=settings.vsync,
+ frame_rate=nearest_frame_rate(settings.frame_rate, frame_rates),
+ ),
+ resolutions=offered,
+ frame_rates=frame_rates,
+ palettes=palettes,
+ )
+
+ @property
+ def window_controls_enabled(self) -> bool:
+ """Whether the size and frame controls apply: a fullscreen window takes its whole monitor."""
+ return not self.settings.window.fullscreen
+
+ @property
+ def resolution_items(self) -> Tuple[str, ...]:
+ return resolution_labels(self.resolutions)
+
+ @property
+ def current_resolution_item(self) -> str:
+ return str(self.settings.window.resolution)
+
+ def frame_rate_items(self, unlimited_label: str) -> Tuple[str, ...]:
+ return frame_rate_labels(self.frame_rates, unlimited_label=unlimited_label)
+
+ def current_frame_rate_item(self, unlimited_label: str) -> str:
+ return frame_rate_label(self.settings.frame_rate, unlimited_label=unlimited_label)
+
+ def resolution_for_item(self, item: str) -> Resolution:
+ """The size the given label stands for.
+
+ Raises:
+ KeyError: when no offered size carries that label.
+ """
+ offered: Dict[str, Resolution] = {str(resolution): resolution for resolution in self.resolutions}
+ return offered[item]
+
+ def frame_rate_for_item(self, item: str, unlimited_label: str) -> int:
+ """The frame rate the given label stands for.
+
+ Raises:
+ KeyError: when no offered rate carries that label.
+ """
+ offered: Dict[str, int] = {
+ frame_rate_label(frame_rate, unlimited_label=unlimited_label): frame_rate for frame_rate in self.frame_rates
+ }
+ return offered[item]
diff --git a/src/sampletones_application/view_model/shared/footprint.py b/src/sampletones_application/view_model/shared/footprint.py
new file mode 100644
index 00000000..b8578a31
--- /dev/null
+++ b/src/sampletones_application/view_model/shared/footprint.py
@@ -0,0 +1,71 @@
+from typing import Dict, Optional, Self, Tuple
+
+from pydantic import BaseModel
+
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.formats.famitracker.footprint import (
+ InstrumentFootprint,
+ total_footprint,
+)
+
+
+class InstrumentSizeViewModel(BaseModel, frozen=True):
+ """The bytes one channel's instrument occupies once a tracker compiles it.
+
+ The measurement is carried as it was taken, both regions intact, so a display naming the
+ whole and one naming a region read the same figure.
+ """
+
+ generator: GeneratorName
+ footprint: InstrumentFootprint
+
+ @property
+ def total_bytes(self) -> int:
+ """The bytes this channel's instrument occupies, its two regions together."""
+ return self.footprint.total_bytes
+
+
+class SampleFootprintViewModel(BaseModel, frozen=True):
+ """The byte sizes a sample's instruments occupy, one entry per channel it covers.
+
+ A sample exports one instrument per channel its reconstruction covers, so a display reads
+ :attr:`total_bytes` for the sample as a whole and :meth:`bytes_for` for a single channel.
+ Both the instruments panel and the samples menu read their figures from here, so the two
+ name the same size for the same sample.
+ """
+
+ instruments: Tuple[InstrumentSizeViewModel, ...]
+
+ @classmethod
+ def from_footprints(
+ cls,
+ footprints: Dict[GeneratorName, InstrumentFootprint],
+ ) -> Self:
+ """Collects measured channels in the generators' own order, so displays list them alike."""
+ return cls(
+ instruments=tuple(
+ InstrumentSizeViewModel(
+ generator=generator_name,
+ footprint=footprints[generator_name],
+ )
+ for generator_name in GeneratorName.items()
+ if generator_name in footprints
+ ),
+ )
+
+ @property
+ def total_bytes(self) -> int:
+ """The bytes the whole sample occupies, its instruments summed region by region.
+
+ The sum is the measurement's own, so a sample's figure and a channel's are arrived at
+ the same way.
+ """
+ return total_footprint(instrument.footprint for instrument in self.instruments).total_bytes
+
+ def bytes_for(self, generator: GeneratorName) -> Optional[int]:
+ """The bytes one channel's instrument occupies, where the sample covers that channel."""
+ for instrument in self.instruments:
+ if instrument.generator == generator:
+ return instrument.total_bytes
+
+ return None
diff --git a/src/sampletones_application/view_model/shared/keybindings.py b/src/sampletones_application/view_model/shared/keybindings.py
new file mode 100644
index 00000000..eb272731
--- /dev/null
+++ b/src/sampletones_application/view_model/shared/keybindings.py
@@ -0,0 +1,58 @@
+from __future__ import annotations
+
+from typing import Optional, Tuple
+
+from pydantic import BaseModel
+
+
+class KeybindingRow(BaseModel, frozen=True):
+ """One action as the keybindings dialog lists it: its name, its label, and the keys it answers.
+
+ An action travels under the name a keybinding file writes it by, which is the identity a stored
+ preference is keyed by as well, so a row states which action it stands for without the view
+ reaching into the shortcut vocabulary.
+ """
+
+ action: str
+ label: str
+ combination: str
+
+ def matches(self, text: str) -> bool:
+ """Whether the row answers a filter, which reads both what it is called and what it answers.
+
+ Args:
+ text: What the reader typed, matched in any capitalisation.
+
+ Returns:
+ bool: True while the label or the combination holds the text, and for an empty filter.
+ """
+ wanted = text.strip().casefold()
+ return wanted in self.label.casefold() or wanted in self.combination.casefold()
+
+
+class KeybindingGroup(BaseModel, frozen=True):
+ """The actions of one scope, under the name a reader finds that scope by.
+
+ A scope is a keyboard context of its own, so grouping by it is what tells a reader that the
+ same combination reaching two rows is two separate keys rather than a clash. The scope travels
+ under its own name beside the label, which lets a view address a group whatever it is called.
+ """
+
+ category: str
+ label: str
+ rows: Tuple[KeybindingRow, ...]
+
+
+class KeybindingsViewModel(BaseModel, frozen=True):
+ """What the keybindings dialog draws: the actions listed, the selection standing, and its state.
+
+ The dialog edits a draft the owner holds, so what shows here is the draft rather than the keys
+ the application is running under; the two meet when the reader confirms.
+ """
+
+ groups: Tuple[KeybindingGroup, ...]
+ schemes: Tuple[str, ...]
+ scheme: str
+ selected: Optional[str]
+ combination: str
+ message: str
diff --git a/src/sampletones_application/view_model/shared/menu.py b/src/sampletones_application/view_model/shared/menu.py
index 92a09c74..d93d2d93 100644
--- a/src/sampletones_application/view_model/shared/menu.py
+++ b/src/sampletones_application/view_model/shared/menu.py
@@ -1,11 +1,13 @@
from pydantic import BaseModel
+from sampletones_application.constants.playback import FollowMode
from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel
class MenuBarViewModel(BaseModel, frozen=True):
channels: SequencerChannelsViewModel
project_open: bool
+ operation_active: bool
reconstruction_loaded: bool
reconstruction_saveable: bool
reconstruction_in_project: bool
@@ -21,10 +23,12 @@ class MenuBarViewModel(BaseModel, frozen=True):
player_paused: bool
stop_enabled: bool
autoplay: bool
- follow_playback: bool
+ follow_mode: FollowMode
loop_song: bool
fullscreen: bool
advanced_settings: bool
+ auto_expand_favorite_reconstructions: bool
+ auto_expand_favorite_directories: bool
@property
def undo_enabled(self) -> bool:
@@ -34,6 +38,11 @@ def undo_enabled(self) -> bool:
def redo_enabled(self) -> bool:
return self.project_open and self.can_redo
+ @property
+ def render_enabled(self) -> bool:
+ """Rendering the song needs a song to render, while the application is free to run one."""
+ return self.project_open and not self.operation_active
+
@property
def add_to_sequencer_enabled(self) -> bool:
"""Adding the loaded reconstruction needs an open project that does not already hold it."""
diff --git a/src/sampletones_application/view_model/shared/nearest.py b/src/sampletones_application/view_model/shared/nearest.py
new file mode 100644
index 00000000..485020cc
--- /dev/null
+++ b/src/sampletones_application/view_model/shared/nearest.py
@@ -0,0 +1,24 @@
+from typing import Tuple
+
+
+def nearest_offered(value: int, offered: Tuple[int, ...]) -> int:
+ """The offered number a standing choice selects: the closest one, the smaller where two tie.
+
+ A choice outlives the list that was offered when it was made — a frame rate a build has
+ since dropped, a sample rate the newly chosen format encodes nothing near — so snapping it
+ onto the offer keeps a combo showing a value that is in force.
+
+ Args:
+ value: The number a choice stands at.
+ offered: The numbers on offer.
+
+ Returns:
+ int: The offered number the choice selects.
+
+ Raises:
+ ValueError: when nothing is offered.
+ """
+ if not offered:
+ raise ValueError("Selecting a value requires at least one offered number")
+
+ return min(offered, key=lambda candidate: (abs(candidate - value), candidate))
diff --git a/src/sampletones_application/view_model/shared/project_properties.py b/src/sampletones_application/view_model/shared/project_properties.py
index 6e3daa82..a223175f 100644
--- a/src/sampletones_application/view_model/shared/project_properties.py
+++ b/src/sampletones_application/view_model/shared/project_properties.py
@@ -7,11 +7,13 @@
class ProjectPropertiesViewModel(BaseModel, frozen=True):
- """The project info the properties dialog renders and offers for editing."""
+ """The project info and metre the properties dialog renders and offers for editing."""
title: str
author: str
comment: str
+ first_highlight: int
+ second_highlight: int
created: datetime
modified: datetime
diff --git a/src/sampletones_application/view_model/shared/render.py b/src/sampletones_application/view_model/shared/render.py
new file mode 100644
index 00000000..5e8928c7
--- /dev/null
+++ b/src/sampletones_application/view_model/shared/render.py
@@ -0,0 +1,301 @@
+from enum import StrEnum
+from pathlib import Path
+from typing import Final, FrozenSet, Optional, Self, Tuple
+
+from pydantic import BaseModel
+
+from sampletones_application.view_model.shared.nearest import nearest_offered
+from sampletones_application.view_model.shared.percent import format_percent
+from sampletones_core.audio.writers import (
+ DEFAULT_AUDIO_DEPTH,
+ MP3_SAMPLE_RATES,
+ AudioDepth,
+ AudioFormat,
+ AudioOutputSpec,
+ Mp3OutputSpec,
+ WaveOutputSpec,
+ capability_of,
+ default_mp3_bitrate,
+ mp3_bitrates,
+)
+from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE
+from sampletones_core.parallelization import ETAEstimator
+
+
+class RenderPhase(StrEnum):
+ IDLE = "idle"
+ CONFIGURING = "configuring"
+ RENDERING = "rendering"
+ CANCELLING = "cancelling"
+ COMPLETED = "completed"
+ CANCELLED = "cancelled"
+ FAILED = "failed"
+
+
+ACTIVE_PHASES: Final[FrozenSet[RenderPhase]] = frozenset(
+ {
+ RenderPhase.CONFIGURING,
+ RenderPhase.RENDERING,
+ RenderPhase.CANCELLING,
+ }
+)
+
+
+def build_spec(
+ audio_format: AudioFormat,
+ sample_rate: int,
+ *,
+ depth: Optional[AudioDepth],
+ bitrate: Optional[int],
+) -> AudioOutputSpec:
+ """The specification a set of standing choices states for ``audio_format``.
+
+ Each choice is snapped onto what the container accepts: the rate becomes the offered one
+ nearest it, a depth the format stores is kept, and a bitrate stays where the rate's own
+ ladder reaches it. A choice the format leaves behind falls back to what it opens on, so
+ moving between containers always arrives at a specification the encoder writes.
+
+ Args:
+ audio_format: The container the audio is written into.
+ sample_rate: The rate the choices stand at.
+ depth: The form each stored sample takes, where one was chosen.
+ bitrate: The kilobits each encoded second holds, where one was chosen.
+
+ Returns:
+ AudioOutputSpec: The specification for that format.
+ """
+ match audio_format:
+ case AudioFormat.WAVE:
+ capability = capability_of(AudioFormat.WAVE)
+ return WaveOutputSpec(
+ sample_rate=nearest_offered(sample_rate, capability.sample_rates),
+ depth=depth if depth is not None and capability.supports_depth(depth) else DEFAULT_AUDIO_DEPTH,
+ )
+ case AudioFormat.MP3:
+ rate = nearest_offered(sample_rate, MP3_SAMPLE_RATES)
+ return Mp3OutputSpec(
+ sample_rate=rate,
+ bitrate=bitrate if bitrate in mp3_bitrates(rate) else default_mp3_bitrate(rate),
+ )
+
+
+class SongRenderSettings(BaseModel, frozen=True):
+ """The choices a render is made under: what the file is written as, and at what level.
+
+ Each ``with_`` method answers with the settings carrying one choice changed and the others
+ reconciled against what that choice leaves possible, so every value held here is one the
+ encoder accepts. The reconciliation runs in one place because the offers depend on each
+ other: a container encodes its own set of rates, and each rate offers the bitrates its MPEG
+ version defines.
+ """
+
+ spec: AudioOutputSpec
+ normalize: bool
+
+ @classmethod
+ def initial(cls, audio_format: AudioFormat) -> Self:
+ """The choices a dialog opens on: ``audio_format`` at the usual rate, rendered at unity."""
+ return cls(
+ spec=build_spec(
+ audio_format,
+ DEFAULT_SAMPLE_RATE,
+ depth=DEFAULT_AUDIO_DEPTH,
+ bitrate=None,
+ ),
+ normalize=False,
+ )
+
+ @property
+ def depth(self) -> Optional[AudioDepth]:
+ """The form each stored sample takes, where the format stores samples directly."""
+ match self.spec:
+ case WaveOutputSpec() as wave:
+ return wave.depth
+ case Mp3OutputSpec():
+ return None
+
+ @property
+ def bitrate(self) -> Optional[int]:
+ """The kilobits each encoded second holds, where the format encodes to a bitrate."""
+ match self.spec:
+ case WaveOutputSpec():
+ return None
+ case Mp3OutputSpec() as mp3:
+ return mp3.bitrate
+
+ def with_format(self, audio_format: AudioFormat) -> Self:
+ """The settings written as ``audio_format``, at the nearest rate it encodes."""
+ return self._with_spec(
+ build_spec(
+ audio_format,
+ self.spec.sample_rate,
+ depth=self.depth,
+ bitrate=self.bitrate,
+ )
+ )
+
+ def with_sample_rate(self, sample_rate: int) -> Self:
+ """The settings written at ``sample_rate``, keeping the quality it reaches there."""
+ return self._with_spec(
+ build_spec(
+ self.spec.audio_format,
+ sample_rate,
+ depth=self.depth,
+ bitrate=self.bitrate,
+ )
+ )
+
+ def with_depth(self, depth: AudioDepth) -> Self:
+ """The settings storing each sample as ``depth``."""
+ return self._with_spec(
+ build_spec(
+ self.spec.audio_format,
+ self.spec.sample_rate,
+ depth=depth,
+ bitrate=self.bitrate,
+ )
+ )
+
+ def with_bitrate(self, bitrate: int) -> Self:
+ """The settings encoding each second to ``bitrate`` kilobits."""
+ return self._with_spec(
+ build_spec(
+ self.spec.audio_format,
+ self.spec.sample_rate,
+ depth=self.depth,
+ bitrate=bitrate,
+ )
+ )
+
+ def with_normalize(self, normalize: bool) -> Self:
+ """The settings scaled so the loudest sample reaches full scale, or left at unity."""
+ return self.model_copy(update={"normalize": normalize})
+
+ def _with_spec(self, spec: AudioOutputSpec) -> Self:
+ return self.model_copy(update={"spec": spec})
+
+
+class SongRenderViewModel(BaseModel, frozen=True):
+ """What the render dialog draws: the options this installation offers, the choices standing,
+ and how far a running render has got.
+
+ The setup and the progress are two faces of one dialog, so the phase decides which is shown
+ and the derived flags are read rather than stored. The offers narrow with the choices — the
+ rates a container encodes, the bitrates a rate reaches — so a combo repopulates from here as
+ soon as the choice above it changes.
+
+ Attributes:
+ phase: Where the render stands, from the dialog opening to the outcome it reports.
+ formats: The containers this installation writes, in the order they are offered.
+ depths: The forms this installation stores the chosen container's samples in.
+ settings: The choices the dialog is standing at.
+ destination: The file a render writes.
+ total_samples: The samples the whole song holds at the chosen rate.
+ status_text: What the running pass is doing, and how long it has left.
+ progress: How far the running pass has got, from 0 to 1.
+ """
+
+ phase: RenderPhase
+ formats: Tuple[AudioFormat, ...]
+ depths: Tuple[AudioDepth, ...]
+ settings: SongRenderSettings
+ destination: Path
+ total_samples: int
+ status_text: str
+ progress: float
+
+ @property
+ def spec(self) -> AudioOutputSpec:
+ return self.settings.spec
+
+ @property
+ def sample_rates(self) -> Tuple[int, ...]:
+ """The rates the chosen container encodes, lowest first."""
+ return self.spec.capability.sample_rates
+
+ @property
+ def bitrates(self) -> Tuple[int, ...]:
+ """The bitrates the chosen rate encodes at, for a container that offers a bitrate."""
+ if self.spec.capability.stores_samples:
+ return ()
+
+ return mp3_bitrates(self.spec.sample_rate)
+
+ @property
+ def stores_samples(self) -> bool:
+ """Whether the chosen container stores samples, which is what gives it a depth to choose."""
+ return self.spec.capability.stores_samples
+
+ def sample_rate_labels(self, template: str) -> Tuple[str, ...]:
+ """The rates on offer, each as ``template`` states it."""
+ return tuple(template.format(rate=sample_rate) for sample_rate in self.sample_rates)
+
+ def sample_rate_label(self, template: str) -> str:
+ """The chosen rate, as ``template`` states it."""
+ return template.format(rate=self.spec.sample_rate)
+
+ def bitrate_labels(self, template: str) -> Tuple[str, ...]:
+ """The bitrates on offer, each as ``template`` states it."""
+ return tuple(template.format(bitrate=bitrate) for bitrate in self.bitrates)
+
+ def bitrate_label(self, template: str) -> str:
+ """The chosen bitrate, as ``template`` states it, for a container that offers one."""
+ bitrate = self.settings.bitrate
+ return template.format(bitrate=bitrate) if bitrate is not None else ""
+
+ @property
+ def duration_seconds(self) -> float:
+ """How long the song plays for, in seconds."""
+ return self.total_samples / self.spec.sample_rate
+
+ @property
+ def duration_label(self) -> str:
+ """The length the render is projected to run to, as the dialog states it."""
+ return ETAEstimator.format_duration(self.duration_seconds)
+
+ @property
+ def progress_overlay(self) -> str:
+ """The percentage label rendered over the progress bar, derived from the fraction."""
+ return format_percent(self.progress)
+
+ @property
+ def is_active(self) -> bool:
+ return self.phase in ACTIVE_PHASES
+
+ @property
+ def setup_visible(self) -> bool:
+ return self.phase == RenderPhase.CONFIGURING
+
+ @property
+ def progress_visible(self) -> bool:
+ return self.phase != RenderPhase.CONFIGURING
+
+ @property
+ def depth_visible(self) -> bool:
+ """Whether the depth is chosen here, which a container storing samples is what offers."""
+ return self.stores_samples
+
+ @property
+ def bitrate_visible(self) -> bool:
+ """Whether the bitrate is chosen here, which a container encoding to one is what offers."""
+ return not self.stores_samples
+
+ @property
+ def depth_enabled(self) -> bool:
+ """Whether the depth takes an edit: offered by the container, while the setup is showing."""
+ return self.setup_visible and self.depth_visible
+
+ @property
+ def bitrate_enabled(self) -> bool:
+ """Whether the bitrate takes an edit: offered by the container, while the setup is showing."""
+ return self.setup_visible and self.bitrate_visible
+
+ @property
+ def render_enabled(self) -> bool:
+ """Whether a render starts from here: a song with something to write, still being set up."""
+ return self.phase == RenderPhase.CONFIGURING and self.total_samples > 0
+
+ @property
+ def cancel_enabled(self) -> bool:
+ """Whether a running render still takes a stop, which one already stopping has taken."""
+ return self.phase == RenderPhase.RENDERING
diff --git a/src/sampletones_application/viewport.py b/src/sampletones_application/viewport.py
index 3cc14810..6c740dd5 100644
--- a/src/sampletones_application/viewport.py
+++ b/src/sampletones_application/viewport.py
@@ -1,38 +1,30 @@
import sys
-from typing import Final, List, Optional, Tuple
+from typing import Tuple
import dearpygui.dearpygui as dpg
-from screeninfo import Monitor, ScreenInfoError, get_monitors
from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.layout.general.window import WindowLayout
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_application.utils.monitors import MonitorArea, monitor_area_for_window
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
-_FALLBACK_SCREEN_WIDTH: Final[int] = 1920
-_FALLBACK_SCREEN_HEIGHT: Final[int] = 1080
-
class ViewportManager:
def __init__(
self,
session_manager: SessionManager,
theme: Theme,
+ window: WindowLayout,
*,
- min_width: int,
- min_height: int,
- vsync: bool,
on_fullscreen_state_changed: VoidCallback,
) -> None:
self._session_manager = session_manager
self._theme = theme
- self._min_width = min_width
- self._min_height = min_height
- self._vsync = vsync
+ self._window = window
self._on_fullscreen_state_changed = on_fullscreen_state_changed
def create_viewport(self) -> None:
@@ -54,17 +46,50 @@ def create_viewport(self) -> None:
title=SAMPLETONES_NAME,
width=window_width,
height=window_height,
- min_width=self._min_width,
- min_height=self._min_height,
+ min_width=self._window.min_width,
+ min_height=self._window.min_height,
small_icon=str(icon_file_path),
large_icon=str(icon_file_path),
x_pos=window_x,
y_pos=window_y,
- decorated=True,
+ decorated=not self._session_manager.borderless,
disable_close=True,
- vsync=self._vsync,
+ vsync=self._session_manager.vsync,
)
+ self.refresh_clear_color()
+
+ def set_resolution(self, width: int, height: int) -> None:
+ """Resizes the window, holding it at the configured minimum."""
+ dpg.set_viewport_width(max(self._window.min_width, width))
+ dpg.set_viewport_height(max(self._window.min_height, height))
+
+ def set_borderless(self, borderless: bool) -> None:
+ """Shows or hides the system's title bar and frame around the window."""
+ dpg.set_viewport_decorated(not borderless)
+
+ def set_vsync(self, vsync: bool) -> None:
+ """Sets whether the render loop waits for the monitor's refresh."""
+ dpg.set_viewport_vsync(vsync)
+
+ @property
+ def resolution(self) -> Tuple[int, int]:
+ """The size the window is showing at right now."""
+ return dpg.get_viewport_width(), dpg.get_viewport_height()
+
+ @property
+ def monitor_area(self) -> MonitorArea:
+ """The area of the monitor the window currently sits on, and the room it leaves a window."""
+ viewport_x, viewport_y = dpg.get_viewport_pos()
+ width, height = self.resolution
+ return self._monitor_area(int(viewport_x), int(viewport_y), width, height)
+
+ def refresh_clear_color(self) -> None:
+ """Paints the area around the windows in the main theme's background colour.
+
+ DearPyGui holds the clear colour outside the theme system, so it is issued again
+ whenever the theme's background answers with a new value.
+ """
color = self._theme.get_color(dpg.mvAll, dpg.mvThemeCol_WindowBg)
assert color is not None, "Background color is not defined in the main theme"
dpg.set_viewport_clear_color(list(color))
@@ -109,25 +134,6 @@ def _persist_fullscreen(self, fullscreen: bool) -> None:
)
self._on_fullscreen_state_changed()
- @staticmethod
- def _get_screen_dimensions() -> Tuple[int, int]:
- """Screen dimensions assumed while the platform reports no monitor, sized for a common desktop."""
- return _FALLBACK_SCREEN_WIDTH, _FALLBACK_SCREEN_HEIGHT
-
- @staticmethod
- def _get_monitors() -> List[Monitor]:
- """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,
x: int,
@@ -137,65 +143,41 @@ def _fit_window_to_monitor(
) -> Tuple[int, int, int, int]:
"""Fit the window to its monitor, hold it at the configured minimum, and clamp it within reserved margins.
- The size is limited to ``_MAX_WINDOW_MONITOR_RATIO`` of the monitor so the title bar and
- side panels stay on screen once the decoration frame is added, and held at ``min_width`` /
- ``min_height`` so even a small requested size opens usably wide. The position is nudged
- inside the resulting margins so every edge lands within the monitor.
+ The size is limited to the monitor's usable area so the title bar and side panels stay on
+ screen once the decoration frame is added, and held at ``min_width`` / ``min_height`` so
+ even a small requested size opens usably wide. The position is nudged inside the resulting
+ margins so every edge lands within the monitor.
"""
- monitor = self._monitor_for_window(x, y, width, height)
- if monitor is not None:
- screen_x = int(monitor.x)
- screen_y = int(monitor.y)
- screen_w = int(monitor.width)
- screen_h = int(monitor.height)
- else:
- screen_x = 0
- screen_y = 0
- screen_w, screen_h = self._get_screen_dimensions()
+ area = self._monitor_area(x, y, width, height)
+ fitted_width = max(self._window.min_width, min(width, area.usable_width))
+ fitted_height = max(self._window.min_height, min(height, area.usable_height))
- usable_w = int(screen_w * _MAX_WINDOW_MONITOR_RATIO)
- usable_h = int(screen_h * _MAX_WINDOW_MONITOR_RATIO)
- fitted_width = max(self._min_width, min(width, usable_w))
- fitted_height = max(self._min_height, min(height, usable_h))
-
- margin_x = (screen_w - usable_w) // 2
- margin_y = (screen_h - usable_h) // 2
+ margin_x = (area.width - area.usable_width) // 2
+ margin_y = (area.height - area.usable_height) // 2
fitted_x = max(
- screen_x + margin_x,
- min(x, screen_x + screen_w - margin_x - fitted_width),
+ area.x + margin_x,
+ min(x, area.x + area.width - margin_x - fitted_width),
)
fitted_y = max(
- screen_y + margin_y,
- min(y, screen_y + screen_h - margin_y - fitted_height),
+ area.y + margin_y,
+ min(y, area.y + area.height - margin_y - fitted_height),
)
return fitted_x, fitted_y, fitted_width, fitted_height
- def _monitor_for_window(
+ def _monitor_area(
self,
x: int,
y: int,
width: int,
height: int,
- ) -> Optional[Monitor]:
- monitors = self._get_monitors()
- if not monitors:
- return None
-
- best_monitor = monitors[0]
- best_overlap = -1
- for monitor in monitors:
- overlap_width = max(
- 0,
- min(x + width, monitor.x + monitor.width) - max(x, monitor.x),
- )
- overlap_height = max(
- 0,
- min(y + height, monitor.y + monitor.height) - max(y, monitor.y),
- )
- overlap = overlap_width * overlap_height
- if overlap > best_overlap:
- best_overlap = overlap
- best_monitor = monitor
-
- return best_monitor
+ ) -> MonitorArea:
+ """The area of the monitor a window of the given geometry sits on, under the layout's policy."""
+ return monitor_area_for_window(
+ x,
+ y,
+ width,
+ height,
+ usable_ratio=self._window.max_monitor_ratio,
+ fallback_monitor=self._window.fallback_monitor,
+ )
diff --git a/src/sampletones_assets/icons/sampletones.ico b/src/sampletones_assets/icons/sampletones.ico
index 7a82dace..5d37a4a2 100644
Binary files a/src/sampletones_assets/icons/sampletones.ico and b/src/sampletones_assets/icons/sampletones.ico differ
diff --git a/src/sampletones_assets/icons/sampletones.png b/src/sampletones_assets/icons/sampletones.png
index 431bd262..3f4bf28f 100644
Binary files a/src/sampletones_assets/icons/sampletones.png and b/src/sampletones_assets/icons/sampletones.png differ
diff --git a/src/sampletones_assets/icons/sampletones.svg b/src/sampletones_assets/icons/sampletones.svg
new file mode 100644
index 00000000..631ddf7d
--- /dev/null
+++ b/src/sampletones_assets/icons/sampletones.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/sampletones_assets/mark/__init__.py b/src/sampletones_assets/mark/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_assets/mark/geometry.py b/src/sampletones_assets/mark/geometry.py
new file mode 100644
index 00000000..5fd855bd
--- /dev/null
+++ b/src/sampletones_assets/mark/geometry.py
@@ -0,0 +1,128 @@
+import itertools
+from dataclasses import dataclass
+from typing import List
+
+from sampletones_assets.mark.specification.point import CubicCurve, Point
+from sampletones_assets.mark.specification.waves import MarkSine, MarkSquare
+
+
+@dataclass(frozen=True)
+class Rectangle:
+ """An axis-aligned box in grid units, the shape one segment of the stepped half fills."""
+
+ left: float
+ top: float
+ right: float
+ bottom: float
+
+
+def _cubic_coordinate(
+ start: float,
+ control_start: float,
+ control_end: float,
+ end: float,
+ progress: float,
+) -> float:
+ remainder = 1.0 - progress
+ return (
+ remainder**3 * start
+ + 3 * remainder**2 * progress * control_start
+ + 3 * remainder * progress**2 * control_end
+ + progress**3 * end
+ )
+
+
+def _cubic_point(
+ start: Point,
+ curve: CubicCurve,
+ progress: float,
+) -> Point:
+ return Point(
+ x=_cubic_coordinate(start.x, curve.control_start.x, curve.control_end.x, curve.end.x, progress),
+ y=_cubic_coordinate(start.y, curve.control_start.y, curve.control_end.y, curve.end.y, progress),
+ )
+
+
+def sine_points(sine: MarkSine, samples: int) -> List[Point]:
+ """The smooth half as a polyline, sampled evenly along every segment.
+
+ Each segment contributes ``samples`` points, ending on its own end point, so the next
+ segment starts where the previous one arrived and the polyline runs unbroken from the
+ wave's start to its handover.
+ """
+ points = [sine.start]
+ position = sine.start
+ for curve in sine.curves:
+ for step in range(1, samples + 1):
+ points.append(_cubic_point(position, curve, step / samples))
+
+ position = curve.end
+
+ return points
+
+
+def _direction(delta: float) -> float:
+ if delta > 0:
+ return 1.0
+
+ if delta < 0:
+ return -1.0
+
+ return 0.0
+
+
+def _segment_rectangle(
+ start: Point,
+ end: Point,
+ *,
+ half_width: float,
+ joined_start: bool,
+ joined_end: bool,
+) -> Rectangle:
+ """The stroke rectangle of one axis-aligned segment.
+
+ A joined end reaches half the stroke width past its corner, so consecutive rectangles
+ fill their right-angle miter; an open end keeps a butt cap.
+ """
+ direction_x = _direction(end.x - start.x)
+ direction_y = _direction(end.y - start.y)
+ start_reach = half_width if joined_start else 0.0
+ end_reach = half_width if joined_end else 0.0
+
+ reached_start = (
+ start.x - direction_x * start_reach,
+ start.y - direction_y * start_reach,
+ )
+ reached_end = (
+ end.x + direction_x * end_reach,
+ end.y + direction_y * end_reach,
+ )
+ across_x = half_width * abs(direction_y)
+ across_y = half_width * abs(direction_x)
+
+ return Rectangle(
+ left=min(reached_start[0], reached_end[0]) - across_x,
+ top=min(reached_start[1], reached_end[1]) - across_y,
+ right=max(reached_start[0], reached_end[0]) + across_x,
+ bottom=max(reached_start[1], reached_end[1]) + across_y,
+ )
+
+
+def square_rectangles(square: MarkSquare, width: float) -> List[Rectangle]:
+ """The stepped half as filled rectangles, one per segment between its corners.
+
+ The rectangles meet at every corner the wave turns at, so the sequence covers the
+ stroke a vector renderer draws with square joins.
+ """
+ segments = list(itertools.pairwise(square.points))
+ final_segment = len(segments) - 1
+ return [
+ _segment_rectangle(
+ start,
+ end,
+ half_width=width / 2,
+ joined_start=index > 0,
+ joined_end=index < final_segment,
+ )
+ for index, (start, end) in enumerate(segments)
+ ]
diff --git a/src/sampletones_assets/mark/mark.yaml b/src/sampletones_assets/mark/mark.yaml
new file mode 100644
index 00000000..34c72068
--- /dev/null
+++ b/src/sampletones_assets/mark/mark.yaml
@@ -0,0 +1,43 @@
+frame:
+ grid: 64
+ corner_radius: 14
+ rim:
+ inset: 1
+ width: 2
+ opacity: 0.14
+
+colors:
+ background:
+ top: "#3a3650"
+ bottom: "#211d30"
+ sine: "#64c8ff"
+ square: "#ffc864"
+ rim: "#cdb6ff"
+
+waves:
+ width: 4
+ sine:
+ start: {x: 8, y: 32}
+ curves:
+ - control_start: {x: 11, y: 16}
+ control_end: {x: 15, y: 16}
+ end: {x: 18, y: 32}
+ - control_start: {x: 21, y: 48}
+ control_end: {x: 25, y: 48}
+ end: {x: 28, y: 32}
+ square:
+ points:
+ - {x: 28, y: 32}
+ - {x: 28, y: 20}
+ - {x: 38, y: 20}
+ - {x: 38, y: 44}
+ - {x: 48, y: 44}
+ - {x: 48, y: 20}
+ - {x: 56, y: 20}
+ - {x: 56, y: 32}
+
+render:
+ supersample: 16
+ curve_samples: 96
+ raster_size: 256
+ windows_sizes: [256, 128, 64, 48, 32, 24, 16]
diff --git a/src/sampletones_assets/mark/paths.py b/src/sampletones_assets/mark/paths.py
new file mode 100644
index 00000000..4b9decd9
--- /dev/null
+++ b/src/sampletones_assets/mark/paths.py
@@ -0,0 +1,7 @@
+from importlib.resources import files
+from pathlib import Path
+from typing import Final
+
+MARK_DIRECTORY: Final[Path] = Path(str(files("sampletones_assets.mark")))
+MARK_PATH: Final[Path] = MARK_DIRECTORY / "mark.yaml"
+TEMPLATE_PATH: Final[Path] = MARK_DIRECTORY / "template.svg"
diff --git a/src/sampletones_assets/mark/raster.py b/src/sampletones_assets/mark/raster.py
new file mode 100644
index 00000000..7c437fe0
--- /dev/null
+++ b/src/sampletones_assets/mark/raster.py
@@ -0,0 +1,116 @@
+from typing import Final, Tuple
+
+from PIL import Image, ImageDraw
+
+from sampletones_assets.mark.geometry import sine_points, square_rectangles
+from sampletones_assets.mark.specification import Mark
+from sampletones_shared.types.application import ColorRGBA
+from sampletones_shared.utils.color import parse_hex_color, with_alpha_fraction
+
+TRANSPARENT: Final[ColorRGBA] = (0, 0, 0, 0)
+OPAQUE: Final[int] = 255
+
+
+class MarkRaster:
+ """Draws the mark into one supersampled image, ready to scale down to each shipped size.
+
+ Drawing happens at the render factor times the design grid and the result is resampled
+ down, which is what keeps the curve edges and the rounded corners smooth at 16 px.
+ """
+
+ def __init__(self, mark: Mark) -> None:
+ self.mark = mark
+
+ @property
+ def scale(self) -> int:
+ """Factor the design grid is drawn at."""
+ return self.mark.render.supersample
+
+ @property
+ def canvas(self) -> int:
+ """Edge length in pixels of the image the mark is drawn into."""
+ return self.mark.frame.grid * self.scale
+
+ @property
+ def corner_radius(self) -> float:
+ """Corner radius of the frame, in the pixels of the drawn image."""
+ return self.mark.frame.corner_radius * self.scale
+
+ @property
+ def frame_box(self) -> Tuple[int, int, int, int]:
+ """The whole image, as the box the frame and its rim are drawn in."""
+ return (0, 0, self.canvas - 1, self.canvas - 1)
+
+ def render(self) -> Image.Image:
+ """The mark drawn at the supersampled resolution, on a transparent ground."""
+ image = self._background()
+ draw = ImageDraw.Draw(image)
+ self._draw_sine(draw)
+ self._draw_square(draw)
+ image.alpha_composite(self._rim())
+ return image
+
+ def _background(self) -> Image.Image:
+ """The frame: a vertical gradient between the two background colours, rounded at its corners."""
+ size = (self.canvas, self.canvas)
+ top = Image.new("RGB", size, self.mark.colors.background.top)
+ bottom = Image.new("RGB", size, self.mark.colors.background.bottom)
+ shaded = Image.composite(bottom, top, Image.linear_gradient("L").resize(size))
+
+ background = Image.new("RGBA", size, TRANSPARENT)
+ background.paste(shaded, mask=self._frame_mask())
+ return background
+
+ def _frame_mask(self) -> Image.Image:
+ mask = Image.new("L", (self.canvas, self.canvas), 0)
+ ImageDraw.Draw(mask).rounded_rectangle(
+ self.frame_box,
+ radius=self.corner_radius,
+ fill=OPAQUE,
+ )
+ return mask
+
+ def _draw_sine(self, draw: ImageDraw.ImageDraw) -> None:
+ """Sweeps a disk of the stroke's half width along the curve.
+
+ The union of densely stamped disks equals a round-capped stroke of the curve, which is
+ what holds the outline smooth along its whole sweep.
+ """
+ radius = self.mark.waves.width * self.scale / 2
+ for point in sine_points(self.mark.waves.sine, self.mark.render.curve_samples):
+ center_x, center_y = point.x * self.scale, point.y * self.scale
+ draw.ellipse(
+ (
+ center_x - radius,
+ center_y - radius,
+ center_x + radius,
+ center_y + radius,
+ ),
+ fill=self.mark.colors.sine,
+ )
+
+ def _draw_square(self, draw: ImageDraw.ImageDraw) -> None:
+ for rectangle in square_rectangles(self.mark.waves.square, self.mark.waves.width):
+ draw.rectangle(
+ (
+ round(rectangle.left * self.scale),
+ round(rectangle.top * self.scale),
+ round(rectangle.right * self.scale) - 1,
+ round(rectangle.bottom * self.scale) - 1,
+ ),
+ fill=self.mark.colors.square,
+ )
+
+ def _rim(self) -> Image.Image:
+ """The hairline along the frame's edge, as a layer to composite over the drawn mark."""
+ overlay = Image.new("RGBA", (self.canvas, self.canvas), TRANSPARENT)
+ ImageDraw.Draw(overlay).rounded_rectangle(
+ self.frame_box,
+ radius=self.corner_radius,
+ outline=self._rim_color(),
+ width=round(self.mark.frame.rim.width * self.scale),
+ )
+ return overlay
+
+ def _rim_color(self) -> ColorRGBA:
+ return with_alpha_fraction(parse_hex_color(self.mark.colors.rim), self.mark.frame.rim.opacity)
diff --git a/src/sampletones_assets/mark/specification/__init__.py b/src/sampletones_assets/mark/specification/__init__.py
new file mode 100644
index 00000000..fa600fe1
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/__init__.py
@@ -0,0 +1,38 @@
+from typing import Self
+
+from pydantic import BaseModel, Field
+
+from sampletones_assets.mark.paths import MARK_PATH
+from sampletones_assets.mark.specification.colors import MarkColors
+from sampletones_assets.mark.specification.frame import MarkFrame
+from sampletones_assets.mark.specification.render import MarkRender
+from sampletones_assets.mark.specification.waves import MarkWaves
+from sampletones_shared.utils.serialization import load_yaml_model
+
+
+class Mark(BaseModel, extra="forbid", frozen=True):
+ """The design definition of the application mark.
+
+ Every shipped icon derives from this one definition — the vector, the raster the
+ application loads, and the multi-resolution Windows icon — so the mark is drawn from a
+ single source and stays the same shape at every size. Coordinates are written on the
+ frame's grid, which keeps the wave edges on whole pixels once the grid is scaled to an
+ icon size.
+ """
+
+ frame: MarkFrame = Field(description="The rounded square the mark sits on.")
+ colors: MarkColors = Field(description="The colours the mark is drawn in.")
+ waves: MarkWaves = Field(description="The wave crossing the frame.")
+ render: MarkRender = Field(description="How the mark is rasterized.")
+
+ @classmethod
+ def load(cls) -> Self:
+ """Load the packaged mark definition.
+
+ Returns:
+ The mark validated from `sampletones_assets/mark/mark.yaml`.
+
+ Raises:
+ TypeError: If the definition file holds anything other than a mapping.
+ """
+ return load_yaml_model(MARK_PATH, cls)
diff --git a/src/sampletones_assets/mark/specification/colors.py b/src/sampletones_assets/mark/specification/colors.py
new file mode 100644
index 00000000..1063ea2b
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/colors.py
@@ -0,0 +1,29 @@
+from typing import Annotated
+
+from pydantic import AfterValidator, BaseModel, Field
+
+from sampletones_shared.utils.color import parse_hex_color
+
+
+def _validate_hex_color(value: str) -> str:
+ parse_hex_color(value)
+ return value
+
+
+HexColor = Annotated[str, AfterValidator(_validate_hex_color)]
+
+
+class MarkBackground(BaseModel, extra="forbid", frozen=True):
+ """The vertical gradient filling the frame."""
+
+ top: HexColor = Field(description="Colour at the top edge of the frame.")
+ bottom: HexColor = Field(description="Colour at the bottom edge of the frame.")
+
+
+class MarkColors(BaseModel, extra="forbid", frozen=True):
+ """The mark's colours, written as the hex strings the vector carries."""
+
+ background: MarkBackground = Field(description="Gradient behind the wave.")
+ sine: HexColor = Field(description="Colour of the smooth half of the wave.")
+ square: HexColor = Field(description="Colour of the stepped half of the wave.")
+ rim: HexColor = Field(description="Colour of the hairline inside the frame's edge.")
diff --git a/src/sampletones_assets/mark/specification/frame.py b/src/sampletones_assets/mark/specification/frame.py
new file mode 100644
index 00000000..4c9111a1
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/frame.py
@@ -0,0 +1,43 @@
+from typing import Self
+
+from pydantic import BaseModel, Field, PositiveFloat, PositiveInt, model_validator
+
+
+class MarkRim(BaseModel, extra="forbid", frozen=True):
+ """The hairline drawn just inside the frame's edge, lifting it off a dark desktop."""
+
+ inset: PositiveFloat = Field(description="Distance the hairline keeps from the frame's edge.")
+ width: PositiveFloat = Field(description="Stroke width of the hairline.")
+ opacity: float = Field(gt=0.0, le=1.0, description="Share of full opacity the hairline is drawn at.")
+
+
+class MarkFrame(BaseModel, extra="forbid", frozen=True):
+ """The rounded square the mark sits on.
+
+ ``grid`` is the edge length every other coordinate is expressed in, so the whole design
+ follows from this one number and scales to any icon size.
+ """
+
+ grid: PositiveInt = Field(description="Edge length of the design grid.")
+ corner_radius: PositiveFloat = Field(description="Radius the frame's corners are rounded to.")
+ rim: MarkRim = Field(description="The hairline inside the frame's edge.")
+
+ @property
+ def rim_radius(self) -> float:
+ """Corner radius the rim follows, keeping it concentric with the frame."""
+ return self.corner_radius - self.rim.inset
+
+ @property
+ def rim_extent(self) -> float:
+ """Edge length of the rim's square, inset on both sides."""
+ return self.grid - 2 * self.rim.inset
+
+ @model_validator(mode="after")
+ def _validate_rounding(self) -> Self:
+ if 2 * self.corner_radius > self.grid:
+ raise ValueError(f"The corner radius {self.corner_radius} must be at most half the grid {self.grid}")
+
+ if self.rim.inset >= self.corner_radius:
+ raise ValueError(f"The rim inset {self.rim.inset} must stay inside the corner radius {self.corner_radius}")
+
+ return self
diff --git a/src/sampletones_assets/mark/specification/point.py b/src/sampletones_assets/mark/specification/point.py
new file mode 100644
index 00000000..db8689f0
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/point.py
@@ -0,0 +1,16 @@
+from pydantic import BaseModel, Field
+
+
+class Point(BaseModel, extra="forbid", frozen=True):
+ """A position on the mark's design grid, in grid units."""
+
+ x: float = Field(description="Distance from the left edge of the grid.")
+ y: float = Field(description="Distance from the top edge of the grid.")
+
+
+class CubicCurve(BaseModel, extra="forbid", frozen=True):
+ """One cubic Bézier segment, starting where the segment before it ended."""
+
+ control_start: Point = Field(description="Control point steering the segment away from its start.")
+ control_end: Point = Field(description="Control point steering the segment into its end.")
+ end: Point = Field(description="Point the segment reaches.")
diff --git a/src/sampletones_assets/mark/specification/render.py b/src/sampletones_assets/mark/specification/render.py
new file mode 100644
index 00000000..2e77b129
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/render.py
@@ -0,0 +1,28 @@
+from typing import Tuple
+
+from pydantic import BaseModel, Field, PositiveInt, field_validator
+
+
+class MarkRender(BaseModel, extra="forbid", frozen=True):
+ """How the mark is turned into pixels.
+
+ Drawing happens at ``supersample`` times the design grid and the result is resampled
+ down to each shipped size, which is what keeps the curve edges and the rounded corners
+ smooth at 16 px.
+ """
+
+ supersample: PositiveInt = Field(description="Factor the design grid is drawn at before it is scaled down.")
+ curve_samples: PositiveInt = Field(description="Points each cubic segment of the smooth half is stamped along.")
+ raster_size: PositiveInt = Field(description="Edge length of the raster the application loads.")
+ windows_sizes: Tuple[PositiveInt, ...] = Field(
+ min_length=1,
+ description="Edge lengths the multi-resolution Windows icon carries.",
+ )
+
+ @field_validator("windows_sizes")
+ @classmethod
+ def _validate_windows_sizes(cls, windows_sizes: Tuple[int, ...]) -> Tuple[int, ...]:
+ if list(windows_sizes) != sorted(set(windows_sizes), reverse=True):
+ raise ValueError("Windows icon sizes must be listed once each, in descending order")
+
+ return windows_sizes
diff --git a/src/sampletones_assets/mark/specification/waves.py b/src/sampletones_assets/mark/specification/waves.py
new file mode 100644
index 00000000..b97a1c35
--- /dev/null
+++ b/src/sampletones_assets/mark/specification/waves.py
@@ -0,0 +1,59 @@
+import itertools
+from typing import Self, Tuple
+
+from pydantic import BaseModel, Field, PositiveFloat, model_validator
+
+from sampletones_assets.mark.specification.point import CubicCurve, Point
+
+
+class MarkSine(BaseModel, extra="forbid", frozen=True):
+ """The smooth half of the wave, as cubic segments running on from the start point."""
+
+ start: Point = Field(description="Point the wave enters the frame at.")
+ curves: Tuple[CubicCurve, ...] = Field(min_length=1, description="Segments the wave follows, in drawing order.")
+
+ @property
+ def end(self) -> Point:
+ """Point the last segment reaches, where the stepped half takes over."""
+ return self.curves[-1].end
+
+
+class MarkSquare(BaseModel, extra="forbid", frozen=True):
+ """The stepped half of the wave, as corners joined by axis-aligned segments."""
+
+ points: Tuple[Point, ...] = Field(min_length=2, description="Corners the wave turns at, in drawing order.")
+
+ @model_validator(mode="after")
+ def _validate_segments_run_along_one_axis(self) -> Self:
+ for start, end in itertools.pairwise(self.points):
+ if start.x != end.x and start.y != end.y:
+ raise ValueError(
+ f"A square wave segment runs along one axis, "
+ f"where ({start.x}, {start.y}) to ({end.x}, {end.y}) turns on both"
+ )
+
+ return self
+
+
+class MarkWaves(BaseModel, extra="forbid", frozen=True):
+ """The single wave the mark carries: one sample entering smooth and leaving stepped.
+
+ Both halves are stroked at the same width, which is what reads them as one continuous
+ wave crossing the frame.
+ """
+
+ width: PositiveFloat = Field(description="Stroke width both halves of the wave are drawn at.")
+ sine: MarkSine = Field(description="The smooth half, entering from the left.")
+ square: MarkSquare = Field(description="The stepped half, leaving to the right.")
+
+ @model_validator(mode="after")
+ def _validate_the_halves_meet(self) -> Self:
+ handover = self.square.points[0]
+ if handover != self.sine.end:
+ raise ValueError(
+ f"The stepped half starts where the smooth half ends, "
+ f"where it starts at ({handover.x}, {handover.y}) "
+ f"and the smooth half ends at ({self.sine.end.x}, {self.sine.end.y})"
+ )
+
+ return self
diff --git a/src/sampletones_assets/mark/suite.py b/src/sampletones_assets/mark/suite.py
new file mode 100644
index 00000000..a464b50a
--- /dev/null
+++ b/src/sampletones_assets/mark/suite.py
@@ -0,0 +1,67 @@
+from pathlib import Path
+from typing import List, Tuple
+
+from PIL import Image
+
+from sampletones_assets.mark.raster import MarkRaster
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.vector import render_vector
+from sampletones_shared.paths.resources import (
+ ICON_UNIX_FILENAME,
+ ICON_VECTOR_FILENAME,
+ ICON_WIN_FILENAME,
+)
+
+
+def _resized(master: Image.Image, size: int) -> Image.Image:
+ return master.resize((size, size), Image.Resampling.LANCZOS)
+
+
+def _write_vector(path: Path, mark: Mark) -> Path:
+ path.write_text(render_vector(mark), encoding="utf-8")
+ return path
+
+
+def _write_raster(path: Path, master: Image.Image, size: int) -> Path:
+ _resized(master, size).save(path)
+ return path
+
+
+def _write_windows_icon(
+ path: Path,
+ master: Image.Image,
+ sizes: Tuple[int, ...],
+) -> Path:
+ """Writes the multi-resolution icon, rendering one frame per declared size.
+
+ Every frame is resampled from the supersampled master, so a 16 px frame carries the
+ detail the design grid puts there.
+ """
+ primary, *appended = (_resized(master, size) for size in sizes)
+ primary.save(
+ path,
+ format="ICO",
+ sizes=[(size, size) for size in sizes],
+ append_images=appended,
+ )
+ return path
+
+
+def write_icon_suite(directory: Path, mark: Mark) -> List[Path]:
+ """Writes the vector, the raster and the Windows icon the application ships.
+
+ Args:
+ directory (Path): Directory receiving the icon files, created where it is missing.
+ mark (Mark): Design definition every file is drawn from.
+
+ Returns:
+ List[Path]: The files written, in the order they were produced.
+ """
+ directory.mkdir(parents=True, exist_ok=True)
+ master = MarkRaster(mark).render()
+
+ return [
+ _write_vector(directory / ICON_VECTOR_FILENAME, mark),
+ _write_raster(directory / ICON_UNIX_FILENAME, master, mark.render.raster_size),
+ _write_windows_icon(directory / ICON_WIN_FILENAME, master, mark.render.windows_sizes),
+ ]
diff --git a/src/sampletones_assets/mark/template.svg b/src/sampletones_assets/mark/template.svg
new file mode 100644
index 00000000..3be72f01
--- /dev/null
+++ b/src/sampletones_assets/mark/template.svg
@@ -0,0 +1,12 @@
+
diff --git a/src/sampletones_assets/mark/vector.py b/src/sampletones_assets/mark/vector.py
new file mode 100644
index 00000000..f9ba412c
--- /dev/null
+++ b/src/sampletones_assets/mark/vector.py
@@ -0,0 +1,71 @@
+import itertools
+from string import Template
+from typing import Dict
+
+from sampletones_assets.mark.paths import TEMPLATE_PATH
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.specification.point import Point
+from sampletones_assets.mark.specification.waves import MarkSine, MarkSquare
+
+
+def _number(value: float) -> str:
+ return f"{value:g}"
+
+
+def _coordinates(point: Point) -> str:
+ return f"{_number(point.x)} {_number(point.y)}"
+
+
+def _sine_path(sine: MarkSine) -> str:
+ commands = [f"M{_coordinates(sine.start)}"]
+ for curve in sine.curves:
+ controls = f"{_coordinates(curve.control_start)} {_coordinates(curve.control_end)}"
+ commands.append(f"C{controls} {_coordinates(curve.end)}")
+
+ return " ".join(commands)
+
+
+def _square_path(square: MarkSquare) -> str:
+ """The stepped half as vertical and horizontal commands, one per segment.
+
+ Each segment turns on a single axis, so it is written as the one coordinate it moves
+ along and the renderer holds the other.
+ """
+ commands = [f"M{_coordinates(square.points[0])}"]
+ for previous, point in itertools.pairwise(square.points):
+ commands.append(f"V{_number(point.y)}" if point.x == previous.x else f"H{_number(point.x)}")
+
+ return " ".join(commands)
+
+
+def _placeholders(mark: Mark) -> Dict[str, str]:
+ return {
+ "grid": _number(mark.frame.grid),
+ "corner_radius": _number(mark.frame.corner_radius),
+ "background_top": mark.colors.background.top,
+ "background_bottom": mark.colors.background.bottom,
+ "sine_path": _sine_path(mark.waves.sine),
+ "sine_color": mark.colors.sine,
+ "square_path": _square_path(mark.waves.square),
+ "square_color": mark.colors.square,
+ "wave_width": _number(mark.waves.width),
+ "rim_inset": _number(mark.frame.rim.inset),
+ "rim_extent": _number(mark.frame.rim_extent),
+ "rim_radius": _number(mark.frame.rim_radius),
+ "rim_color": mark.colors.rim,
+ "rim_opacity": _number(mark.frame.rim.opacity),
+ "rim_width": _number(mark.frame.rim.width),
+ }
+
+
+def render_vector(mark: Mark) -> str:
+ """The mark as a standalone vector, filling the packaged template with its own geometry.
+
+ Coordinates stay on the design grid, which keeps the wave edges on whole pixels when the
+ icon is rasterized at 32 px and 16 px.
+
+ Raises:
+ KeyError: If the template names a placeholder the mark leaves unfilled.
+ """
+ template = Template(TEMPLATE_PATH.read_text(encoding="utf-8"))
+ return template.substitute(_placeholders(mark))
diff --git a/src/sampletones_config/README.md b/src/sampletones_config/README.md
index c82851f9..e54fd965 100644
--- a/src/sampletones_config/README.md
+++ b/src/sampletones_config/README.md
@@ -7,7 +7,7 @@ programmatic role is to be importable so consumers can resolve its directory
The schema that validates each file lives in the **consuming** package:
-- `sampletones_application` — layout, theme, palette, language, behavior, deployment.
+- `sampletones_application` — layout, theme, palettes, language, behavior, deployment.
- `sampletones_core` — calibration.
- `sampletones_shared` — the loader primitives only.
@@ -20,8 +20,10 @@ The data package must not import a schema, and a schema package must not inline
| `application/` | Deployment-time environment knobs | `DeploymentConfig` |
| `behavior/` | Non-visual runtime behavior | `BehaviorConfig` |
| `calibration/` | DSP calibration tuning | `CorpusConfig`, `RefereeConfig` |
+| `keybindings/` | The key combinations each named action answers | `ShortcutScheme` |
| `lang/` | Interface strings (i18n) | `LanguageManager` |
-| `layout/` | UI geometry, dimensions, fonts, palette | `LayoutConfig` |
+| `layout/` | UI geometry, dimensions, fonts | `LayoutConfig` |
+| `palettes/` | The colour sets layout and theme resolve against | `Palette` |
| `theme/` | DearPyGui theme/colour styling | `ThemeSpec` |
The rules for where a value belongs, how the directories nest, and how each domain is
diff --git a/src/sampletones_config/behavior/general.yaml b/src/sampletones_config/behavior/general.yaml
index 4fb8cce4..1d4e14a9 100644
--- a/src/sampletones_config/behavior/general.yaml
+++ b/src/sampletones_config/behavior/general.yaml
@@ -14,8 +14,48 @@ scheduling:
ui:
status_bar_display_time: 2.0
-
-main:
fps_update_interval: 2.0
- vsync: true
- max_fps: 60
+
+display:
+ resolutions:
+ - width: 1024
+ height: 768
+ - width: 1152
+ height: 648
+ - width: 1280
+ height: 720
+ - width: 1280
+ height: 800
+ - width: 1366
+ height: 768
+ - width: 1440
+ height: 900
+ - width: 1600
+ height: 900
+ - width: 1680
+ height: 1050
+ - width: 1920
+ height: 1080
+ - width: 1920
+ height: 1200
+ - width: 2560
+ height: 1080
+ - width: 2560
+ height: 1440
+ - width: 2560
+ height: 1600
+ - width: 3440
+ height: 1440
+ - width: 3840
+ height: 2160
+ frame_rates:
+ - 0
+ - 30
+ - 60
+ - 75
+ - 90
+ - 120
+ - 144
+ - 165
+ - 240
+ revert_countdown_seconds: 10.0
diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml
new file mode 100644
index 00000000..e373d87a
--- /dev/null
+++ b/src/sampletones_config/keybindings/default.yaml
@@ -0,0 +1,149 @@
+name: default
+
+bindings:
+ # project
+ NewProject: {combination: "Ctrl+N"}
+ OpenProject: {combination: "Ctrl+O"}
+ SaveProject: {combination: "Ctrl+S"}
+ SaveProjectAs: {combination: "Ctrl+Shift+S"}
+ ProjectProperties: {combination: "Alt+P"}
+ ExportProjectFamiTracker: {combination: "Ctrl+M"}
+ ExportProjectBitphase: {combination: "Ctrl+B"}
+ RenderSong: {combination: "Ctrl+Shift+E"}
+ CloseProject: {combination: "Ctrl+W"}
+ Exit: {combination: "Alt+F4"}
+
+ # editing
+ Undo: {combination: "Ctrl+Z"}
+ Redo: {combination: "Ctrl+Y", aliases: ["Ctrl+Shift+Z"]}
+
+ # reconstruction
+ ReconstructFile: {combination: "Ctrl+R"}
+ ReconstructDirectory: {combination: "Ctrl+Shift+R"}
+ LoadGenerationSettings: {combination: ~}
+ SaveGenerationSettings: {combination: ~}
+ OpenReconstruction: {combination: "Ctrl+Alt+O"}
+ SaveReconstruction: {combination: "Ctrl+Alt+S"}
+ SaveReconstructionAs: {combination: "Ctrl+Alt+Shift+S"}
+ CloseReconstruction: {combination: "Ctrl+Alt+W"}
+ ExportReconstructionWav: {combination: "Ctrl+E"}
+ ExportInstrumentsFamiTracker: {combination: "Ctrl+I"}
+ ExportInstrumentsBitphasePreset: {combination: ~}
+ AddReconstructionToSequencer: {combination: ~}
+ OpenReconstructionInExplorer: {combination: ~}
+ LocateOriginalAudio: {combination: ~}
+
+ # playback
+ Play: {combination: "Space"}
+ PlayFromStart: {combination: "Shift+Space"}
+ PlayFromFrame: {combination: "Ctrl+Space"}
+ Stop: {combination: "Esc"}
+ ToggleAutoplay: {combination: "Ctrl+P"}
+ FollowRows: {combination: "Ctrl+F"}
+ FollowPatterns: {combination: "Ctrl+Shift+F"}
+ FollowOff: {combination: "Ctrl+Alt+F"}
+ ToggleLoopSong: {combination: "Ctrl+L"}
+ ToggleChannelPulse1: {combination: "1"}
+ ToggleChannelPulse2: {combination: "2"}
+ ToggleChannelTriangle: {combination: "3"}
+ ToggleChannelNoise: {combination: "4"}
+ UnmuteAllChannels: {combination: ~}
+
+ # view
+ AudioSettings: {combination: "Ctrl+U"}
+ DisplaySettings: {combination: "Ctrl+D"}
+ KeyboardSettings: {combination: "Ctrl+K"}
+ ToggleAdvancedSettings: {combination: "Ctrl+Alt+T"}
+ ToggleFullscreen: {combination: "F11"}
+ ToggleAutoExpandFavoriteReconstructions: {combination: ~}
+ ToggleAutoExpandFavoriteDirectories: {combination: ~}
+ AboutDialog: {combination: ~}
+ NextTab: {combination: "Ctrl+PgDn", field_transparent: true}
+ PreviousTab: {combination: "Ctrl+PgUp", field_transparent: true}
+ SelectTabMain: {combination: "F1", field_transparent: true}
+ SelectTabReconstructions: {combination: "F2", field_transparent: true}
+ SelectTabSequencer: {combination: "F3", field_transparent: true}
+ SelectTabInstructions: {combination: "F4", field_transparent: true}
+
+ # order table
+ OrderPreviousPosition: {combination: "Left"}
+ OrderNextPosition: {combination: "Right", aliases: ["Enter"]}
+ OrderPreviousChannel: {combination: "Up"}
+ OrderNextChannel: {combination: "Down"}
+ OrderFirstPosition: {combination: "Home"}
+ OrderLastPosition: {combination: "End"}
+ OrderExtendSelectionUp: {combination: "Shift+Up"}
+ OrderExtendSelectionDown: {combination: "Shift+Down"}
+ OrderExtendSelectionLeft: {combination: "Shift+Left"}
+ OrderExtendSelectionRight: {combination: "Shift+Right"}
+ OrderExtendSelectionToFirstPosition: {combination: "Shift+Home"}
+ OrderExtendSelectionToLastPosition: {combination: "Shift+End"}
+ OrderSelectAll: {combination: "Ctrl+A"}
+ OrderSelectRow: {combination: "Ctrl+Shift+A"}
+ OrderCopyBlock: {combination: "Ctrl+C"}
+ OrderCutBlock: {combination: "Ctrl+X"}
+ OrderPasteBlock: {combination: "Ctrl+V"}
+ OrderMoveFrameLeft: {combination: "Alt+Left"}
+ OrderMoveFrameRight: {combination: "Alt+Right"}
+ OrderMoveFrameToStart: {combination: "Alt+Home"}
+ OrderMoveFrameToEnd: {combination: "Alt+End"}
+ OrderAddFrame: {combination: "Ins"}
+ OrderInsertFrame: {combination: "Plus", aliases: ["NumPlus", "Shift+Plus"]}
+ OrderRemoveFrame: {combination: "Minus", aliases: ["NumMinus"]}
+ OrderDuplicateFrame: {combination: "Ctrl+Ins"}
+ OrderCloneFrame: {combination: "Ctrl+Shift+Ins"}
+ OrderClearFrame: {combination: "Shift+Del"}
+ OrderClearCell: {combination: "Del"}
+ OrderClearPreviousCell: {combination: "Backspace"}
+ OrderCancelEntry: {combination: "Esc"}
+
+ # tracker
+ TrackerPreviousRow: {combination: "Up"}
+ TrackerNextRow: {combination: "Down", aliases: ["Enter"]}
+ TrackerPreviousSubcolumn: {combination: "Left"}
+ TrackerNextSubcolumn: {combination: "Right"}
+ TrackerPreviousColumn: {combination: "Shift+Tab"}
+ TrackerNextColumn: {combination: "Tab"}
+ TrackerFirstRow: {combination: "Home"}
+ TrackerLastRow: {combination: "End"}
+ TrackerExtendSelectionUp: {combination: "Shift+Up"}
+ TrackerExtendSelectionDown: {combination: "Shift+Down"}
+ TrackerExtendSelectionLeft: {combination: "Shift+Left"}
+ TrackerExtendSelectionRight: {combination: "Shift+Right"}
+ TrackerExtendSelectionToFirstRow: {combination: "Shift+Home"}
+ TrackerExtendSelectionToLastRow: {combination: "Shift+End"}
+ TrackerSelectAll: {combination: "Ctrl+A"}
+ TrackerSelectColumn: {combination: "Ctrl+Shift+A"}
+ TrackerSelectSubcolumn: {combination: "Ctrl+Alt+A"}
+ TrackerCopyBlock: {combination: "Ctrl+C"}
+ TrackerCutBlock: {combination: "Ctrl+X"}
+ TrackerPasteBlock: {combination: "Ctrl+V"}
+ TrackerTransposeUp: {combination: "Ctrl+Up"}
+ TrackerTransposeDown: {combination: "Ctrl+Down"}
+ TrackerTransposeOctaveUp: {combination: "Ctrl+Shift+Up"}
+ TrackerTransposeOctaveDown: {combination: "Ctrl+Shift+Down"}
+ TrackerVolumeUp: {combination: "Alt+Up"}
+ TrackerVolumeDown: {combination: "Alt+Down"}
+ TrackerVolumeUpCoarse: {combination: "Alt+Shift+Up"}
+ TrackerVolumeDownCoarse: {combination: "Alt+Shift+Down"}
+ TrackerPageUp: {combination: "PgUp"}
+ TrackerPageDown: {combination: "PgDn"}
+ TrackerClearRow: {combination: "Del"}
+ TrackerClearPreviousRow: {combination: "Backspace"}
+ TrackerCancelEntry: {combination: "Esc"}
+ TrackerPlayFromRow: {combination: "Ctrl+Shift+Space"}
+
+ # samples
+ SamplesRenameSample: {combination: "F2"}
+ SamplesRemoveSample: {combination: "Del"}
+ SamplesMoveSampleUp: {combination: "Alt+Up"}
+ SamplesMoveSampleDown: {combination: "Alt+Down"}
+ SamplesMoveSampleToTop: {combination: "Alt+Home"}
+ SamplesMoveSampleToBottom: {combination: "Alt+End"}
+ SamplesCancelRename: {combination: "Esc"}
+
+ # dialogs
+ DialogNextControl: {combination: "Tab"}
+ DialogPreviousControl: {combination: "Shift+Tab"}
+ DialogActivate: {combination: "Enter"}
+ DialogCancel: {combination: "Esc"}
diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml
new file mode 100644
index 00000000..20773137
--- /dev/null
+++ b/src/sampletones_config/keybindings/macos.yaml
@@ -0,0 +1,149 @@
+name: macos
+
+bindings:
+ # project
+ NewProject: {combination: "Cmd+N"}
+ OpenProject: {combination: "Cmd+O"}
+ SaveProject: {combination: "Cmd+S"}
+ SaveProjectAs: {combination: "Cmd+Shift+S"}
+ ProjectProperties: {combination: "Cmd+Alt+P"}
+ ExportProjectFamiTracker: {combination: "Cmd+M"}
+ ExportProjectBitphase: {combination: "Cmd+B"}
+ RenderSong: {combination: "Cmd+Shift+E"}
+ CloseProject: {combination: "Cmd+W"}
+ Exit: {combination: "Cmd+Q"}
+
+ # editing
+ Undo: {combination: "Cmd+Z"}
+ Redo: {combination: "Cmd+Shift+Z", aliases: ["Cmd+Y"]}
+
+ # reconstruction
+ ReconstructFile: {combination: "Cmd+R"}
+ ReconstructDirectory: {combination: "Cmd+Shift+R"}
+ LoadGenerationSettings: {combination: ~}
+ SaveGenerationSettings: {combination: ~}
+ OpenReconstruction: {combination: "Cmd+Alt+O"}
+ SaveReconstruction: {combination: "Cmd+Alt+S"}
+ SaveReconstructionAs: {combination: "Cmd+Alt+Shift+S"}
+ CloseReconstruction: {combination: "Cmd+Alt+W"}
+ ExportReconstructionWav: {combination: "Cmd+E"}
+ ExportInstrumentsFamiTracker: {combination: "Cmd+I"}
+ ExportInstrumentsBitphasePreset: {combination: ~}
+ AddReconstructionToSequencer: {combination: ~}
+ OpenReconstructionInExplorer: {combination: ~}
+ LocateOriginalAudio: {combination: ~}
+
+ # playback
+ Play: {combination: "Space"}
+ PlayFromStart: {combination: "Shift+Space"}
+ PlayFromFrame: {combination: "Ctrl+Space"}
+ Stop: {combination: "Esc"}
+ ToggleAutoplay: {combination: "Cmd+P"}
+ FollowRows: {combination: "Cmd+F"}
+ FollowPatterns: {combination: "Cmd+Shift+F"}
+ FollowOff: {combination: "Cmd+Alt+F"}
+ ToggleLoopSong: {combination: "Cmd+L"}
+ ToggleChannelPulse1: {combination: "1"}
+ ToggleChannelPulse2: {combination: "2"}
+ ToggleChannelTriangle: {combination: "3"}
+ ToggleChannelNoise: {combination: "4"}
+ UnmuteAllChannels: {combination: ~}
+
+ # view
+ AudioSettings: {combination: "Cmd+U"}
+ DisplaySettings: {combination: "Cmd+D"}
+ KeyboardSettings: {combination: "Cmd+K"}
+ ToggleAdvancedSettings: {combination: "Cmd+Alt+T"}
+ ToggleFullscreen: {combination: "Cmd+Ctrl+F"}
+ ToggleAutoExpandFavoriteReconstructions: {combination: ~}
+ ToggleAutoExpandFavoriteDirectories: {combination: ~}
+ AboutDialog: {combination: ~}
+ NextTab: {combination: "Cmd+Alt+Right", aliases: ["Cmd+PgDn"], field_transparent: true}
+ PreviousTab: {combination: "Cmd+Alt+Left", aliases: ["Cmd+PgUp"], field_transparent: true}
+ SelectTabMain: {combination: "F1", field_transparent: true}
+ SelectTabReconstructions: {combination: "F2", field_transparent: true}
+ SelectTabSequencer: {combination: "F3", field_transparent: true}
+ SelectTabInstructions: {combination: "F4", field_transparent: true}
+
+ # order table
+ OrderPreviousPosition: {combination: "Left"}
+ OrderNextPosition: {combination: "Right", aliases: ["Enter"]}
+ OrderPreviousChannel: {combination: "Up"}
+ OrderNextChannel: {combination: "Down"}
+ OrderFirstPosition: {combination: "Home", aliases: ["Cmd+Left"]}
+ OrderLastPosition: {combination: "End", aliases: ["Cmd+Right"]}
+ OrderExtendSelectionUp: {combination: "Shift+Up"}
+ OrderExtendSelectionDown: {combination: "Shift+Down"}
+ OrderExtendSelectionLeft: {combination: "Shift+Left"}
+ OrderExtendSelectionRight: {combination: "Shift+Right"}
+ OrderExtendSelectionToFirstPosition: {combination: "Shift+Home", aliases: ["Cmd+Shift+Left"]}
+ OrderExtendSelectionToLastPosition: {combination: "Shift+End", aliases: ["Cmd+Shift+Right"]}
+ OrderSelectAll: {combination: "Cmd+A"}
+ OrderSelectRow: {combination: "Cmd+Shift+A"}
+ OrderCopyBlock: {combination: "Cmd+C"}
+ OrderCutBlock: {combination: "Cmd+X"}
+ OrderPasteBlock: {combination: "Cmd+V"}
+ OrderMoveFrameLeft: {combination: "Alt+Left"}
+ OrderMoveFrameRight: {combination: "Alt+Right"}
+ OrderMoveFrameToStart: {combination: "Alt+Home", aliases: ["Cmd+Alt+Left"]}
+ OrderMoveFrameToEnd: {combination: "Alt+End", aliases: ["Cmd+Alt+Right"]}
+ OrderAddFrame: {combination: "Ins", aliases: ["Cmd+Enter"]}
+ OrderInsertFrame: {combination: "Plus", aliases: ["NumPlus", "Shift+Plus"]}
+ OrderRemoveFrame: {combination: "Minus", aliases: ["NumMinus"]}
+ OrderDuplicateFrame: {combination: "Ctrl+Ins", aliases: ["Cmd+Alt+Enter"]}
+ OrderCloneFrame: {combination: "Ctrl+Shift+Ins", aliases: ["Cmd+Alt+Shift+Enter"]}
+ OrderClearFrame: {combination: "Shift+Del", aliases: ["Cmd+Shift+Backspace"]}
+ OrderClearCell: {combination: "Del", aliases: ["Cmd+Backspace"]}
+ OrderClearPreviousCell: {combination: "Backspace"}
+ OrderCancelEntry: {combination: "Esc"}
+
+ # tracker
+ TrackerPreviousRow: {combination: "Up"}
+ TrackerNextRow: {combination: "Down", aliases: ["Enter"]}
+ TrackerPreviousSubcolumn: {combination: "Left"}
+ TrackerNextSubcolumn: {combination: "Right"}
+ TrackerPreviousColumn: {combination: "Shift+Tab"}
+ TrackerNextColumn: {combination: "Tab"}
+ TrackerFirstRow: {combination: "Home", aliases: ["Cmd+Up"]}
+ TrackerLastRow: {combination: "End", aliases: ["Cmd+Down"]}
+ TrackerExtendSelectionUp: {combination: "Shift+Up"}
+ TrackerExtendSelectionDown: {combination: "Shift+Down"}
+ TrackerExtendSelectionLeft: {combination: "Shift+Left"}
+ TrackerExtendSelectionRight: {combination: "Shift+Right"}
+ TrackerExtendSelectionToFirstRow: {combination: "Shift+Home", aliases: ["Cmd+Shift+Up"]}
+ TrackerExtendSelectionToLastRow: {combination: "Shift+End", aliases: ["Cmd+Shift+Down"]}
+ TrackerSelectAll: {combination: "Cmd+A"}
+ TrackerSelectColumn: {combination: "Cmd+Shift+A"}
+ TrackerSelectSubcolumn: {combination: "Cmd+Alt+A"}
+ TrackerCopyBlock: {combination: "Cmd+C"}
+ TrackerCutBlock: {combination: "Cmd+X"}
+ TrackerPasteBlock: {combination: "Cmd+V"}
+ TrackerTransposeUp: {combination: "Cmd+Alt+Up"}
+ TrackerTransposeDown: {combination: "Cmd+Alt+Down"}
+ TrackerTransposeOctaveUp: {combination: "Cmd+Alt+Shift+Up"}
+ TrackerTransposeOctaveDown: {combination: "Cmd+Alt+Shift+Down"}
+ TrackerVolumeUp: {combination: "Cmd+Alt+Right"}
+ TrackerVolumeDown: {combination: "Cmd+Alt+Left"}
+ TrackerVolumeUpCoarse: {combination: "Cmd+Alt+Shift+Right"}
+ TrackerVolumeDownCoarse: {combination: "Cmd+Alt+Shift+Left"}
+ TrackerPageUp: {combination: "PgUp", aliases: ["Alt+Up"]}
+ TrackerPageDown: {combination: "PgDn", aliases: ["Alt+Down"]}
+ TrackerClearRow: {combination: "Del", aliases: ["Cmd+Backspace"]}
+ TrackerClearPreviousRow: {combination: "Backspace"}
+ TrackerCancelEntry: {combination: "Esc"}
+ TrackerPlayFromRow: {combination: "Ctrl+Shift+Space"}
+
+ # samples
+ SamplesRenameSample: {combination: "F2"}
+ SamplesRemoveSample: {combination: "Del", aliases: ["Cmd+Backspace"]}
+ SamplesMoveSampleUp: {combination: "Alt+Up"}
+ SamplesMoveSampleDown: {combination: "Alt+Down"}
+ SamplesMoveSampleToTop: {combination: "Alt+Home", aliases: ["Cmd+Alt+Up"]}
+ SamplesMoveSampleToBottom: {combination: "Alt+End", aliases: ["Cmd+Alt+Down"]}
+ SamplesCancelRename: {combination: "Esc"}
+
+ # dialogs
+ DialogNextControl: {combination: "Tab"}
+ DialogPreviousControl: {combination: "Shift+Tab"}
+ DialogActivate: {combination: "Enter"}
+ DialogCancel: {combination: "Esc"}
diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml
index 84d6ead2..8c1dc25e 100644
--- a/src/sampletones_config/lang/en.yaml
+++ b/src/sampletones_config/lang/en.yaml
@@ -55,9 +55,11 @@ global.dialog.filter.bitphase_preset: "Bitphase instrument preset"
global.dialog.filter.config: "Configuration files"
global.dialog.filter.audio: "Audio files"
global.dialog.filter.wave: "WAV audio"
+global.dialog.filter.mp3: "MP3 audio"
# Global — Dialog messages
global.dialog.message.tree_no_results: "No results found."
+global.dialog.message.tree_no_favorites: "No favorites found."
global.dialog.message.invalid_metadata_error: "Invalid file metadata."
global.dialog.message.reconstruction_no_data: "No reconstruction loaded."
global.dialog.message.reconstruction_saved_successfully: "Reconstruction saved successfully."
@@ -125,19 +127,31 @@ global.traceback.label.hide: "Hide traceback"
# Global — Tree
# =============================================================================
global.browser.label.root: "Root"
+global.browser.label.browser: "Browser"
+global.browser.label.by_configuration: "By configuration"
+global.browser.label.by_sample: "By sample"
global.browser.label.search: "Search"
global.browser.label.filter: "Filter"
global.browser.label.clear_search: "Clear"
+global.browser.label.favorites_only: "Favorites only"
+global.browser.label.collapse_all: "Collapse all"
# =============================================================================
# Global — Context menu
# =============================================================================
global.context.label.play: "Play"
+global.context.label.cut: "Cut"
+global.context.label.copy: "Copy"
+global.context.label.paste: "Paste"
+global.context.label.delete: "Delete"
global.context.label.mark_as_favorite: "Mark as favorite"
global.context.label.unmark_as_favorite: "Unmark as favorite"
global.context.label.copy_filename: "Copy filename to clipboard"
global.context.label.copy_path: "Copy path to clipboard"
+global.context.label.copy_name: "Copy name to clipboard"
global.context.label.open_in_explorer: "Open in explorer"
+global.context.label.expand_all: "Expand all"
+global.context.label.collapse_all: "Collapse all"
global.context.label.add_to_sequencer: "Add to Sequencer"
global.context.template.replace_sample: "Replace {sample}"
global.context.label.locate_original_audio: "Locate original audio"
@@ -152,6 +166,11 @@ global.context.label.detail_spectrum_method: "Generation method"
global.context.label.detail_transformation_gamma: "Transformation gamma"
global.context.label.detail_window_size: "Window size"
global.context.label.detail_configuration: "Configuration"
+global.context.label.detail_reconstructions: "Reconstructions"
+global.context.label.instrument_size: "Instrument size"
+global.context.label.sample_size: "Sample size"
+global.context.template.size_bytes: "{bytes} B"
+global.context.tooltip.size_bytes: "How many bytes this takes as a FamiTracker instrument."
# =============================================================================
# Global — Menu
@@ -165,6 +184,7 @@ global.menu.label.item_file_project_properties: "Project properties..."
global.menu.label.group_file_export: "Export"
global.menu.label.item_file_export_famitracker: "FamiTracker module..."
global.menu.label.item_file_export_bitphase: "Bitphase project..."
+global.menu.label.item_file_render_song: "Render song..."
global.menu.label.item_file_close_project: "Close project"
global.menu.label.item_file_exit: "Exit"
global.menu.label.group_edit: "Edit"
@@ -191,7 +211,10 @@ global.menu.label.item_playback_play_from_start: "Play from start"
global.menu.label.item_playback_play_from_frame: "Play from this frame"
global.menu.label.item_playback_stop: "Stop"
global.menu.label.item_playback_autoplay: "Autoplay"
-global.menu.label.item_playback_follow_playback: "Follow playback"
+global.menu.label.group_playback_follow: "Follow playback"
+global.menu.label.item_playback_follow_rows: "Follow rows"
+global.menu.label.item_playback_follow_patterns: "Follow patterns"
+global.menu.label.item_playback_follow_off: "Don't follow"
global.menu.label.item_playback_loop_song: "Loop song"
global.menu.label.group_playback_channels: "Channels"
global.menu.label.item_playback_unmute_all_channels: "Unmute all channels"
@@ -199,6 +222,11 @@ global.menu.label.item_playback_audio_settings: "Audio settings..."
global.menu.label.group_view: "View"
global.menu.label.item_view_show_advanced_settings: "Show advanced settings"
global.menu.label.item_view_fullscreen: "Fullscreen"
+global.menu.label.group_view_auto_expand_favorites: "Auto-expand favorites"
+global.menu.label.item_view_auto_expand_favorite_reconstructions: "Reconstructions"
+global.menu.label.item_view_auto_expand_favorite_directories: "Directories"
+global.menu.label.item_view_display_settings: "Display settings..."
+global.menu.label.item_view_keyboard_settings: "Keyboard shortcuts..."
global.menu.label.group_help: "Help"
global.menu.label.item_help_about: "About"
global.menu.label.tab_main: "Main"
@@ -210,14 +238,19 @@ global.menu.label.tab_sequencer: "Sequencer"
# Global — Status bar messages
# =============================================================================
global.status.message.path: "Click to open path in file explorer."
+global.status.message.destination: "Click to show the destination in file explorer."
global.status.message.node_reconstruction_no_autoplay: "Double-click to open reconstruction. Right-click to open context menu."
global.status.message.node_reconstruction: "Click to play reconstruction. Double-click to open reconstruction. Right-click to open context menu."
global.status.message.node_library: "Double-click to open instructions library. Right-click to open context menu."
global.status.message.tree_search: "Type query to filter nodes."
global.status.message.clear_search: "Clear the search filter."
+global.status.message.favorites_only: "Show the favorites alone, or the whole tree."
+global.status.message.collapse_all: "Fold every row of the tree away."
global.status.message.input: "Ctrl + click to type value."
global.status.message.combo: "Click to select a value from the list."
global.status.message.node_directory: "Click to {expand_or_collapse}. Right-click to open context menu."
+global.status.message.node_group: "Click to {expand_or_collapse} this group. Right-click to open context menu."
+global.status.message.node_sample: "Click to {expand_or_collapse} the reconstructions of this sample. Right-click to open context menu."
global.status.message.retuning_samples: "Retuning samples..."
# =============================================================================
@@ -250,7 +283,6 @@ global.graph.message.waveform_regenerating: "Regenerating reconstruction..."
# =============================================================================
main.explorer.label.section: "Filesystem"
main.explorer.label.refresh_button: "Refresh"
-main.explorer.label.collapse_all_button: "Collapse all"
main.explorer.label.context_load_reconstruction: "Load reconstruction"
main.explorer.label.context_load_library: "Load instructions library"
main.explorer.label.context_reconstruct_file: "Reconstruct file"
@@ -260,7 +292,6 @@ main.explorer.label.context_set_output_directory: "Set as output directory"
main.explorer.message.status_node_audio_no_autoplay: "Double-click to reconstruct audio. Right-click to open context menu."
main.explorer.message.status_node_audio: "Click to play audio. Double-click to reconstruct audio. Right-click to open context menu."
main.explorer.message.status_refresh: "Rescan the filesystem for audio files."
-main.explorer.message.status_collapse_all: "Collapse every folder in the tree."
main.explorer.message.converter_running_msg: "A conversion is already running. Please wait for it to complete or cancel the current operation before starting a new one."
main.explorer.title.converter_running_dialog: "Conversion in progress"
@@ -343,7 +374,6 @@ main.advanced.message.status_select_output: "Choose the directory for reconstruc
# Reconstructions tab — Browser
# =============================================================================
reconstructions.browser.label.refresh_button: "Refresh reconstructions"
-reconstructions.browser.label.reconstructions_tree: "Reconstructions"
reconstructions.browser.label.context_load_reconstruction: "Load reconstruction"
reconstructions.browser.label.context_remove_reconstruction: "Remove reconstruction"
reconstructions.browser.label.context_remove_directory: "Remove directory"
@@ -417,7 +447,6 @@ reconstructions.instruments.template.initial_pitch_tooltip_template: "Enter the
# Sequencer tab — Browser
# =============================================================================
sequencer.browser.label.refresh_button: "Refresh reconstructions"
-sequencer.browser.label.reconstructions_tree: "Reconstructions"
sequencer.browser.message.status_refresh: "Rescan for available reconstructions."
# =============================================================================
@@ -431,39 +460,42 @@ sequencer.module.label.tempo: "Tempo"
sequencer.module.label.speed: "Speed"
# =============================================================================
-# Sequencer tab — Grid
-# =============================================================================
-sequencer.grid.label.tracker_text: "Tracker"
-sequencer.grid.label.column_row: "Row"
-sequencer.grid.label.column_sample: "Sample"
-sequencer.grid.label.column_pulse_1: "Pulse 1"
-sequencer.grid.label.column_pulse_2: "Pulse 2"
-sequencer.grid.label.column_triangle: "Triangle"
-sequencer.grid.label.column_noise: "Noise"
-sequencer.grid.label.context_play: "Play from here"
-sequencer.grid.label.context_play_from_frame: "Play from this frame"
-sequencer.grid.label.context_note_off: "Note off"
-sequencer.grid.label.context_set_instrument: "Set instrument"
-sequencer.grid.label.context_no_samples: "No samples"
-sequencer.grid.label.context_clear_subcolumn: "Clear subcolumn"
-sequencer.grid.label.context_clear_cell: "Clear cell"
-sequencer.grid.label.context_clear_row: "Clear row"
-sequencer.grid.label.context_transpose_up: "Transpose up"
-sequencer.grid.label.context_transpose_down: "Transpose down"
-sequencer.grid.label.context_transpose_octave_up: "Transpose octave up"
-sequencer.grid.label.context_transpose_octave_down: "Transpose octave down"
-sequencer.grid.label.context_volume_up: "Volume up"
-sequencer.grid.label.context_volume_down: "Volume down"
-sequencer.grid.label.context_volume_up_coarse: "Volume up (coarse)"
-sequencer.grid.label.context_volume_down_coarse: "Volume down (coarse)"
-sequencer.grid.label.context_mute: "Mute"
-sequencer.grid.label.context_unmute: "Unmute"
-sequencer.grid.label.context_solo: "Solo"
-sequencer.grid.label.context_unsolo: "Unsolo"
-sequencer.grid.label.context_mute_all: "Mute all channels"
-sequencer.grid.label.context_unmute_all: "Unmute all channels"
-sequencer.grid.tooltip.header_channel: "Click to mute or unmute this channel.\n{modifier}+click to solo it, right-click for channel actions."
-sequencer.grid.tooltip.header_sample: "Click to mute every channel, or to bring them all back.\nRight-click for channel actions."
+# Sequencer tab — Tracker
+# =============================================================================
+sequencer.tracker.label.tracker_text: "Tracker"
+sequencer.tracker.label.column_row: "Row"
+sequencer.tracker.label.column_sample: "Sample"
+sequencer.tracker.label.column_pulse_1: "Pulse 1"
+sequencer.tracker.label.column_pulse_2: "Pulse 2"
+sequencer.tracker.label.column_triangle: "Triangle"
+sequencer.tracker.label.column_noise: "Noise"
+sequencer.tracker.label.context_play: "Play from here"
+sequencer.tracker.label.context_play_from_frame: "Play from this frame"
+sequencer.tracker.label.context_select_all: "Select all"
+sequencer.tracker.label.context_select_column: "Select column"
+sequencer.tracker.label.context_select_subcolumn: "Select subcolumn"
+sequencer.tracker.label.context_note_off: "Note off"
+sequencer.tracker.label.context_set_instrument: "Set instrument"
+sequencer.tracker.label.context_no_samples: "No samples"
+sequencer.tracker.label.context_clear_subcolumn: "Clear subcolumn"
+sequencer.tracker.label.context_clear_cell: "Clear cell"
+sequencer.tracker.label.context_clear_row: "Clear row"
+sequencer.tracker.label.context_transpose_up: "Transpose up"
+sequencer.tracker.label.context_transpose_down: "Transpose down"
+sequencer.tracker.label.context_transpose_octave_up: "Transpose octave up"
+sequencer.tracker.label.context_transpose_octave_down: "Transpose octave down"
+sequencer.tracker.label.context_volume_up: "Volume up"
+sequencer.tracker.label.context_volume_down: "Volume down"
+sequencer.tracker.label.context_volume_up_coarse: "Volume up (coarse)"
+sequencer.tracker.label.context_volume_down_coarse: "Volume down (coarse)"
+sequencer.tracker.label.context_mute: "Mute"
+sequencer.tracker.label.context_unmute: "Unmute"
+sequencer.tracker.label.context_solo: "Solo"
+sequencer.tracker.label.context_unsolo: "Unsolo"
+sequencer.tracker.label.context_mute_all: "Mute all channels"
+sequencer.tracker.label.context_unmute_all: "Unmute all channels"
+sequencer.tracker.tooltip.header_channel: "Click to mute or unmute this channel.\n{modifier}+click to solo it, right-click for channel actions."
+sequencer.tracker.tooltip.header_sample: "Click to mute every channel, or to bring them all back.\nRight-click for channel actions."
# =============================================================================
# Sequencer tab — Order
@@ -475,7 +507,10 @@ sequencer.order.label.row_pulse_2: "Pulse 2"
sequencer.order.label.row_triangle: "Triangle"
sequencer.order.label.row_noise: "Noise"
sequencer.order.label.context_play: "Play from this frame"
+sequencer.order.label.context_select_all: "Select all"
+sequencer.order.label.context_select_row: "Select row"
sequencer.order.label.context_duplicate: "Duplicate"
+sequencer.order.label.context_clone: "Clone"
sequencer.order.label.context_insert: "Insert frame"
sequencer.order.label.context_clear: "Clear frame"
sequencer.order.label.context_remove: "Remove"
@@ -521,9 +556,13 @@ sequencer.history.label.clear_row: "Clear row"
sequencer.history.label.clear_subcolumn: "Clear column"
sequencer.history.label.adjust_transpose: "Adjust transpose"
sequencer.history.label.adjust_volume: "Adjust volume"
+sequencer.history.label.cut_block: "Cut selection"
+sequencer.history.label.paste_block: "Paste selection"
+sequencer.history.label.delete_block: "Delete selection"
sequencer.history.label.add_frame: "Add frame"
sequencer.history.label.remove_frame: "Remove frame"
sequencer.history.label.duplicate_frame: "Duplicate frame"
+sequencer.history.label.clone_frame: "Clone frame"
sequencer.history.label.clear_frame: "Clear frame"
sequencer.history.label.move_frame: "Move frame"
sequencer.history.label.set_order_entry: "Set order entry"
@@ -636,9 +675,210 @@ settings.audio.template.sample_rate_label: "{rate} Hz"
settings.audio.template.master_gain_db: "{decibels:+.1f} dB"
settings.audio.message.master_gain_silent: "-∞ dB"
settings.audio.title.window_title: "Audio settings"
+settings.display.title.window_title: "Display settings"
+settings.display.title.section_window: "Window"
+settings.display.title.section_pacing: "Frame pacing"
+settings.display.title.section_appearance: "Appearance"
+settings.display.title.countdown: "Keep these settings?"
+settings.display.title.discard_confirmation: "Discard display settings"
+settings.display.label.resolution: "Resolution"
+settings.display.label.borderless: "Borderless window"
+settings.display.label.fullscreen: "Fullscreen"
+settings.display.label.vsync: "Vertical sync"
+settings.display.label.frame_rate: "Frame rate limit"
+settings.display.label.theme: "Theme"
+settings.display.label.unlimited_frame_rate: "Unlimited"
+settings.display.label.keep_button: "Keep"
+settings.display.label.revert_button: "Revert"
+settings.display.label.discard_button: "Discard"
+settings.display.label.keep_editing_button: "Keep editing"
+settings.display.message.countdown: "Keep the window the way it looks now? It goes back to how it was when the count reaches zero."
+settings.display.message.discard_confirmation: "Discard the changes to the display settings?"
+settings.display.template.countdown_remaining: "Reverting in {seconds} s"
+settings.render.title.window_title: "Render song"
+settings.render.title.destination_dialog: "Save rendered song"
+settings.render.title.rendered: "Song rendered"
+settings.render.label.format: "Format"
+settings.render.label.sample_rate: "Sample rate"
+settings.render.label.depth: "Bit depth"
+settings.render.label.bitrate: "Bitrate"
+settings.render.label.normalize: "Normalize peak"
+settings.render.label.duration: "Length"
+settings.render.label.destination: "File"
+settings.render.label.browse_button: "Browse..."
+settings.render.label.render_button: "Render"
+settings.render.label.format_wave: "WAV"
+settings.render.label.format_mp3: "MP3"
+settings.render.label.depth_pcm_u8: "8-bit PCM"
+settings.render.label.depth_pcm_16: "16-bit PCM"
+settings.render.label.depth_pcm_24: "24-bit PCM"
+settings.render.label.depth_pcm_32: "32-bit PCM"
+settings.render.label.depth_float_32: "32-bit float"
+settings.render.template.sample_rate: "{rate} Hz"
+settings.render.template.bitrate: "{bitrate} kbps"
+settings.render.message.status_synthesis: "Rendering the song..."
+settings.render.message.status_encoding: "Writing the file..."
+settings.render.message.status_cancelling: "Stopping the render..."
+settings.render.message.status_cancelled: "Render cancelled."
+settings.render.message.status_completed: "Render complete."
+settings.render.message.status_failed: "Render failed."
+settings.render.message.rendered: "The song was rendered successfully."
+settings.render.message.render_failed: "Failed to render the song."
+settings.keybindings.title.window_title: "Keyboard shortcuts"
+settings.keybindings.title.application: "Application"
+settings.keybindings.title.order: "Order list"
+settings.keybindings.title.tracker: "Tracker"
+settings.keybindings.title.samples: "Samples"
+settings.keybindings.title.reassign_confirmation: "Combination in use"
+settings.keybindings.title.reset_confirmation: "Restore the shipped keys"
+settings.keybindings.title.discard_confirmation: "Discard keyboard shortcuts"
+settings.keybindings.label.scheme: "Scheme"
+settings.keybindings.label.filter: "Filter"
+settings.keybindings.label.action: "Action"
+settings.keybindings.label.shortcut: "Shortcut"
+settings.keybindings.label.unbound: "Unassigned"
+settings.keybindings.label.clear_button: "Clear"
+settings.keybindings.label.reset_button: "Reset to defaults"
+settings.keybindings.label.reassign_button: "Reassign"
+settings.keybindings.label.discard_button: "Discard"
+settings.keybindings.label.keep_editing_button: "Keep editing"
+settings.keybindings.message.capturing: "Press a combination..."
+settings.keybindings.message.reset_confirmation: "Give every action the keys this scheme ships with?"
+settings.keybindings.message.discard_confirmation: "Discard the changes to the keyboard shortcuts?"
+settings.keybindings.template.reassign_confirmation: "{combination} is assigned to {holder}. Give it to {action} instead?"
+settings.keybindings.template.unreadable_combination: "{combination} names no key on the keyboard."
+settings.keybindings.label.new_project: "New project"
+settings.keybindings.label.open_project: "Open project"
+settings.keybindings.label.save_project: "Save project"
+settings.keybindings.label.save_project_as: "Save project as"
+settings.keybindings.label.project_properties: "Project properties"
+settings.keybindings.label.export_project_famitracker: "Export project to FamiTracker"
+settings.keybindings.label.export_project_bitphase: "Export project to Bitphase"
+settings.keybindings.label.render_song: "Render song to an audio file"
+settings.keybindings.label.close_project: "Close project"
+settings.keybindings.label.exit: "Exit"
+settings.keybindings.label.undo: "Undo"
+settings.keybindings.label.redo: "Redo"
+settings.keybindings.label.reconstruct_file: "Reconstruct a file"
+settings.keybindings.label.reconstruct_directory: "Reconstruct a directory"
+settings.keybindings.label.load_generation_settings: "Load generation settings"
+settings.keybindings.label.save_generation_settings: "Save generation settings"
+settings.keybindings.label.open_reconstruction: "Open reconstruction"
+settings.keybindings.label.save_reconstruction: "Save reconstruction"
+settings.keybindings.label.save_reconstruction_as: "Save reconstruction as"
+settings.keybindings.label.close_reconstruction: "Close reconstruction"
+settings.keybindings.label.export_reconstruction_wav: "Export reconstruction to WAV"
+settings.keybindings.label.export_instruments_famitracker: "Export instruments to FamiTracker"
+settings.keybindings.label.export_instruments_bitphase_preset: "Export instruments to a Bitphase preset"
+settings.keybindings.label.add_reconstruction_to_sequencer: "Add reconstruction to the sequencer"
+settings.keybindings.label.open_reconstruction_in_explorer: "Show reconstruction in the file manager"
+settings.keybindings.label.locate_original_audio: "Locate the original audio"
+settings.keybindings.label.play: "Play or pause"
+settings.keybindings.label.play_from_start: "Play from the start"
+settings.keybindings.label.play_from_frame: "Play from the current frame"
+settings.keybindings.label.stop: "Stop"
+settings.keybindings.label.toggle_autoplay: "Autoplay"
+settings.keybindings.label.follow_rows: "Follow the playing row"
+settings.keybindings.label.follow_patterns: "Follow the playing pattern"
+settings.keybindings.label.follow_off: "Leave the view in place"
+settings.keybindings.label.toggle_loop_song: "Loop the song"
+settings.keybindings.label.toggle_channel_pulse_1: "Mute pulse 1"
+settings.keybindings.label.toggle_channel_pulse_2: "Mute pulse 2"
+settings.keybindings.label.toggle_channel_triangle: "Mute triangle"
+settings.keybindings.label.toggle_channel_noise: "Mute noise"
+settings.keybindings.label.unmute_all_channels: "Unmute every channel"
+settings.keybindings.label.audio_settings: "Audio settings"
+settings.keybindings.label.display_settings: "Display settings"
+settings.keybindings.label.keyboard_settings: "Keyboard shortcuts"
+settings.keybindings.label.toggle_advanced_settings: "Advanced settings"
+settings.keybindings.label.toggle_auto_expand_favorite_reconstructions: "Auto-expand favorite reconstructions"
+settings.keybindings.label.toggle_auto_expand_favorite_directories: "Auto-expand favorite directories"
+settings.keybindings.label.toggle_fullscreen: "Fullscreen"
+settings.keybindings.label.about_dialog: "About"
+settings.keybindings.label.next_tab: "Next tab"
+settings.keybindings.label.previous_tab: "Previous tab"
+settings.keybindings.label.select_tab_main: "Go to the Main tab"
+settings.keybindings.label.select_tab_reconstructions: "Go to the Reconstruction tab"
+settings.keybindings.label.select_tab_sequencer: "Go to the Sequencer tab"
+settings.keybindings.label.select_tab_instructions: "Go to the Instructions tab"
+settings.keybindings.label.order_previous_position: "Previous position"
+settings.keybindings.label.order_next_position: "Next position"
+settings.keybindings.label.order_previous_channel: "Previous channel"
+settings.keybindings.label.order_next_channel: "Next channel"
+settings.keybindings.label.order_first_position: "First position"
+settings.keybindings.label.order_last_position: "Last position"
+settings.keybindings.label.order_extend_selection_up: "Extend selection up"
+settings.keybindings.label.order_extend_selection_down: "Extend selection down"
+settings.keybindings.label.order_extend_selection_left: "Extend selection left"
+settings.keybindings.label.order_extend_selection_right: "Extend selection right"
+settings.keybindings.label.order_extend_selection_to_first_position: "Extend selection to the first position"
+settings.keybindings.label.order_extend_selection_to_last_position: "Extend selection to the last position"
+settings.keybindings.label.order_select_all: "Select the whole order"
+settings.keybindings.label.order_select_row: "Select the current row"
+settings.keybindings.label.order_copy_block: "Copy selection"
+settings.keybindings.label.order_cut_block: "Cut selection"
+settings.keybindings.label.order_paste_block: "Paste selection"
+settings.keybindings.label.order_move_frame_left: "Move frame left"
+settings.keybindings.label.order_move_frame_right: "Move frame right"
+settings.keybindings.label.order_move_frame_to_start: "Move frame to the start"
+settings.keybindings.label.order_move_frame_to_end: "Move frame to the end"
+settings.keybindings.label.order_add_frame: "Add frame"
+settings.keybindings.label.order_insert_frame: "Insert frame"
+settings.keybindings.label.order_remove_frame: "Remove frame"
+settings.keybindings.label.order_duplicate_frame: "Duplicate frame"
+settings.keybindings.label.order_clone_frame: "Clone frame"
+settings.keybindings.label.order_clear_frame: "Clear frame"
+settings.keybindings.label.order_clear_cell: "Clear cell"
+settings.keybindings.label.order_clear_previous_cell: "Clear the previous cell"
+settings.keybindings.label.order_cancel_entry: "Cancel entry"
+settings.keybindings.label.tracker_previous_row: "Previous row"
+settings.keybindings.label.tracker_next_row: "Next row"
+settings.keybindings.label.tracker_previous_subcolumn: "Previous subcolumn"
+settings.keybindings.label.tracker_next_subcolumn: "Next subcolumn"
+settings.keybindings.label.tracker_previous_column: "Previous column"
+settings.keybindings.label.tracker_next_column: "Next column"
+settings.keybindings.label.tracker_first_row: "First row"
+settings.keybindings.label.tracker_last_row: "Last row"
+settings.keybindings.label.tracker_extend_selection_up: "Extend selection up"
+settings.keybindings.label.tracker_extend_selection_down: "Extend selection down"
+settings.keybindings.label.tracker_extend_selection_left: "Extend selection left"
+settings.keybindings.label.tracker_extend_selection_right: "Extend selection right"
+settings.keybindings.label.tracker_extend_selection_to_first_row: "Extend selection to the first row"
+settings.keybindings.label.tracker_extend_selection_to_last_row: "Extend selection to the last row"
+settings.keybindings.label.tracker_select_all: "Select the whole frame"
+settings.keybindings.label.tracker_select_column: "Select the current column"
+settings.keybindings.label.tracker_select_subcolumn: "Select the current subcolumn"
+settings.keybindings.label.tracker_copy_block: "Copy selection"
+settings.keybindings.label.tracker_cut_block: "Cut selection"
+settings.keybindings.label.tracker_paste_block: "Paste selection"
+settings.keybindings.label.tracker_transpose_up: "Transpose up"
+settings.keybindings.label.tracker_transpose_down: "Transpose down"
+settings.keybindings.label.tracker_transpose_octave_up: "Transpose octave up"
+settings.keybindings.label.tracker_transpose_octave_down: "Transpose octave down"
+settings.keybindings.label.tracker_volume_up: "Volume up"
+settings.keybindings.label.tracker_volume_down: "Volume down"
+settings.keybindings.label.tracker_volume_up_coarse: "Volume up (coarse)"
+settings.keybindings.label.tracker_volume_down_coarse: "Volume down (coarse)"
+settings.keybindings.label.tracker_page_up: "Page up"
+settings.keybindings.label.tracker_page_down: "Page down"
+settings.keybindings.label.tracker_clear_row: "Clear row"
+settings.keybindings.label.tracker_clear_previous_row: "Clear the previous row"
+settings.keybindings.label.tracker_cancel_entry: "Cancel entry"
+settings.keybindings.label.tracker_play_from_row: "Play from the current row"
+settings.keybindings.label.samples_rename_sample: "Rename sample"
+settings.keybindings.label.samples_remove_sample: "Remove sample"
+settings.keybindings.label.samples_move_sample_up: "Move sample up"
+settings.keybindings.label.samples_move_sample_down: "Move sample down"
+settings.keybindings.label.samples_move_sample_to_top: "Move sample to the top"
+settings.keybindings.label.samples_move_sample_to_bottom: "Move sample to the bottom"
+settings.keybindings.label.samples_cancel_rename: "Cancel renaming"
settings.properties.title.window_title: "Project properties"
settings.properties.label.title: "Title"
settings.properties.label.author: "Author"
settings.properties.label.comment: "Comment"
+settings.properties.label.first_highlight: "First highlight"
+settings.properties.label.second_highlight: "Second highlight"
+settings.properties.tooltip.first_highlight: "Rows per beat. The tempo counts these."
+settings.properties.tooltip.second_highlight: "Rows per bar. These group the beats."
settings.properties.label.created: "Created"
settings.properties.label.modified: "Modified"
diff --git a/src/sampletones_config/layout/fonts.yaml b/src/sampletones_config/layout/fonts.yaml
index 38c32e67..afc296a7 100644
--- a/src/sampletones_config/layout/fonts.yaml
+++ b/src/sampletones_config/layout/fonts.yaml
@@ -3,11 +3,14 @@ sans:
small: 22
medium: 23
large: 25
+ title: 34
mono:
small: 19
medium: 22
large: 26
+ title: 30
icon:
small: 20
medium: 27
large: 33
+ title: 45
diff --git a/src/sampletones_config/layout/general/dialogs.yaml b/src/sampletones_config/layout/general/dialogs.yaml
index b7b4b2c1..48e41eab 100644
--- a/src/sampletones_config/layout/general/dialogs.yaml
+++ b/src/sampletones_config/layout/general/dialogs.yaml
@@ -8,9 +8,14 @@ recovery:
width: 640
height: 120
confirmation:
- height: 130
+ height: 100
text_input:
height: 104
traceback:
width: 0
height: 400
+about:
+ width: 480
+ height: 210
+ logo: 56
+ padding: 40
diff --git a/src/sampletones_config/layout/general/responsive.yaml b/src/sampletones_config/layout/general/responsive.yaml
index c4409429..4cd9d8f4 100644
--- a/src/sampletones_config/layout/general/responsive.yaml
+++ b/src/sampletones_config/layout/general/responsive.yaml
@@ -1,3 +1,3 @@
baseline_viewport_width: 1280
baseline_viewport_height: 800
-max_stack_height: 1200
+max_graph_height: 350
diff --git a/src/sampletones_config/layout/general/window.yaml b/src/sampletones_config/layout/general/window.yaml
index f081b59c..f6e07302 100644
--- a/src/sampletones_config/layout/general/window.yaml
+++ b/src/sampletones_config/layout/general/window.yaml
@@ -4,3 +4,7 @@ min_width: 1280
min_height: 800
position_x: 200
fullscreen: false
+max_monitor_ratio: 0.9
+fallback_monitor:
+ width: 1920
+ height: 1080
diff --git a/src/sampletones_config/layout/graphs/spectrum.yaml b/src/sampletones_config/layout/graphs/spectrum.yaml
index 9dfc6d6f..38a4ac1a 100644
--- a/src/sampletones_config/layout/graphs/spectrum.yaml
+++ b/src/sampletones_config/layout/graphs/spectrum.yaml
@@ -1,3 +1,3 @@
max_display_bins: 512
color_dim: .spectrum_dim
-color_bright: .white
+color_bright: .contrast
diff --git a/src/sampletones_config/layout/project_properties/root.yaml b/src/sampletones_config/layout/project_properties/root.yaml
index 1e624be5..f6044b8c 100644
--- a/src/sampletones_config/layout/project_properties/root.yaml
+++ b/src/sampletones_config/layout/project_properties/root.yaml
@@ -1,3 +1,3 @@
-label_width: 90
+label_width: 140
input_width: -1
comment_height: 160
diff --git a/src/sampletones_config/layout/settings/audio.yaml b/src/sampletones_config/layout/settings/audio.yaml
new file mode 100644
index 00000000..3bed2400
--- /dev/null
+++ b/src/sampletones_config/layout/settings/audio.yaml
@@ -0,0 +1,7 @@
+window:
+ width: 600
+ height: 0
+master_gain:
+ slider_width: -65
+ label_color: .text_default
+ clip_color: .text_error
diff --git a/src/sampletones_config/layout/settings/display.yaml b/src/sampletones_config/layout/settings/display.yaml
new file mode 100644
index 00000000..3b53ce89
--- /dev/null
+++ b/src/sampletones_config/layout/settings/display.yaml
@@ -0,0 +1,6 @@
+window:
+ width: 460
+ height: 0
+countdown:
+ width: 360
+ height: 0
diff --git a/src/sampletones_config/layout/settings/keybindings.yaml b/src/sampletones_config/layout/settings/keybindings.yaml
new file mode 100644
index 00000000..165bfc38
--- /dev/null
+++ b/src/sampletones_config/layout/settings/keybindings.yaml
@@ -0,0 +1,5 @@
+window:
+ width: 620
+ height: 0
+list_height: 420
+action_width: 320
diff --git a/src/sampletones_config/layout/settings/master_gain.yaml b/src/sampletones_config/layout/settings/master_gain.yaml
deleted file mode 100644
index 1f61d938..00000000
--- a/src/sampletones_config/layout/settings/master_gain.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-slider_width: -65
-label_color: .text_default
-clip_color: .text_error
diff --git a/src/sampletones_config/layout/settings/render.yaml b/src/sampletones_config/layout/settings/render.yaml
new file mode 100644
index 00000000..3703a5ca
--- /dev/null
+++ b/src/sampletones_config/layout/settings/render.yaml
@@ -0,0 +1,3 @@
+window:
+ width: 540
+ height: 0
diff --git a/src/sampletones_config/layout/settings/window.yaml b/src/sampletones_config/layout/settings/window.yaml
deleted file mode 100644
index e81fbbbe..00000000
--- a/src/sampletones_config/layout/settings/window.yaml
+++ /dev/null
@@ -1,2 +0,0 @@
-width: 600
-height: 0
diff --git a/src/sampletones_config/layout/tabs/sequencer/colors.yaml b/src/sampletones_config/layout/tabs/sequencer/colors.yaml
index e60a452d..88b61313 100644
--- a/src/sampletones_config/layout/tabs/sequencer/colors.yaml
+++ b/src/sampletones_config/layout/tabs/sequencer/colors.yaml
@@ -2,7 +2,10 @@ pattern_highlight: .pattern_highlight
cell_cursor: .cell_cursor
cursor_row: .cursor_row
playback_row: .playback_row
-label: .white
+label: .contrast
+rows:
+ beat: .tracker_beat_row
+ bar: .tracker_bar_row
order:
label: .order_label
master: .order_master
@@ -14,8 +17,8 @@ sample:
divider: .sample_divider
header:
background: .table_header
- hovered: .white/0.25
- active: .white/0.4
+ hovered: .overlay/0.25
+ active: .overlay/0.4
muted:
background: .channel_muted
text: .text_disabled
diff --git a/src/sampletones_config/layout/tabs/sequencer/speed.yaml b/src/sampletones_config/layout/tabs/sequencer/speed.yaml
deleted file mode 100644
index 5a95e49a..00000000
--- a/src/sampletones_config/layout/tabs/sequencer/speed.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-min: 1
-max: 31
-default: 6
diff --git a/src/sampletones_config/layout/tabs/sequencer/tempo.yaml b/src/sampletones_config/layout/tabs/sequencer/tempo.yaml
deleted file mode 100644
index 8f1d0255..00000000
--- a/src/sampletones_config/layout/tabs/sequencer/tempo.yaml
+++ /dev/null
@@ -1,3 +0,0 @@
-min: 32
-max: 255
-default: 150
diff --git a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml
index 34c6001a..ffc48a35 100644
--- a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml
+++ b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml
@@ -1,5 +1,7 @@
rows: 64
page_size: 16
+row_height: 29
+header_height: 30
channel_column_tint: 0.09
muted_text_fraction: 0.45
subcolumn_widths:
diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml
new file mode 100644
index 00000000..1bf4156f
--- /dev/null
+++ b/src/sampletones_config/palettes/dark.yaml
@@ -0,0 +1,195 @@
+name: dark
+
+colors:
+ ground: "#1b1b1d"
+ tab_strip: "#232324"
+ recess: "#252526"
+ surface: "#2d2d30"
+ surface_alt: "#37373d"
+ surface_accent: "#2a3a4a"
+ menu: "#1f1f21"
+ status_bar: "#1f1f21"
+ popup: "#252526"
+ frame: "#3c3c3c"
+ frame_hovered: "#464647"
+ frame_active: "#4a4a4b"
+ border: "#3f3f43"
+ separator: "#4d4d52"
+ plot_background: "#1e1e20"
+ well: "#212123"
+
+ table_header: "#2f3a46"
+ table_row: "#2a2a2c"
+ table_row_alt: "#323236"
+ table_border: "#4a4a50"
+
+ cool: "#569cd6"
+ cool_hover: "#6fb0e6"
+ cool_active: "#3f83bb"
+ cool_muted: "#33475c"
+
+ accent: "#4fa6ff"
+ accent_hover: "#6fb8ff"
+ accent_active: "#3585d6"
+ accent_muted: "#3a5570"
+ on_accent: "#0b1520"
+
+ primary: "#0e639c"
+ primary_hover: "#1177bb"
+ primary_active: "#0a4d7a"
+ primary_muted: "#4a5560"
+ on_primary: "#ffffff"
+
+ secondary: "#2f2f33"
+ secondary_hover: "#3a3a40"
+ secondary_active: "#46464d"
+ secondary_disabled: "#27272a"
+
+ danger: "#a1443c"
+ danger_hover: "#b85850"
+ danger_active: "#853830"
+ on_danger: "#f2f2f4"
+
+ dialog_surface: "#2c2c30"
+ dialog_title: "#333a44"
+
+ channel_pulse1: "#f09256"
+ channel_pulse2: "#f2d15f"
+ channel_triangle: "#7fc3f2"
+ channel_noise: "#b4b8c0"
+ channel_pulse1_soft: "#e0bda0"
+ channel_pulse2_soft: "#dcd3a4"
+ channel_triangle_soft: "#aecadd"
+ channel_noise_soft: "#c4c6cc"
+
+ tab: "#232325"
+ tab_hovered: "#2d2d31"
+ tab_active: "#33404e"
+
+ selection: "#264f78"
+ selection_hovered: "#2f5f8e"
+ selection_active: "#3a70a4"
+
+ scrollbar_hovered: "#4a4a52"
+
+ button: "#45454c"
+ button_hovered: "#54545d"
+ button_active: "#62626c"
+ button_disabled: "#2f2f33"
+
+ player_surface: "#232328"
+ player_border: "#4fa6ff"
+ player_button: "#313138"
+ player_button_hovered: "#3d3d45"
+ player_button_active: "#4a4a53"
+ player_button_disabled: "#282830"
+
+ text: "#e6e6e8"
+ text_muted: "#9a9aa0"
+ text_disabled: "#75757b"
+ text_trace: "#c0c0c4"
+
+ contrast: "#ffffff"
+ overlay: "#ffffff"
+ transparent: "#00000000"
+ border_strong: "#56565e"
+ input_invalid: "#c0504a64"
+ input_warning: "#c0884a64"
+
+ control: "#3c3c42"
+ control_hovered: "#48484f"
+ control_active: "#54545c"
+ control_border: "#6c6c76"
+
+ plot_zero_line: "#c8c8cc"
+ plot_grid: "#313136"
+ plot_axis_text: "#9a9aa0"
+ plot_border: "#3f3f43"
+ plot_legend_bg: "#26262a"
+
+ file_wave: "#4fa6ff"
+ file_library: "#89d185"
+ file_reconstruction: "#dcdcaa"
+
+ favorite: "#ffd76e"
+ favorite_child: "#ddd2ac"
+
+ library_generator: "#cbe6cb"
+ library_group: "#c8e4e6"
+ library_instruction: "#cccccf"
+ library_root: "#dadade"
+
+ text_default: "#d4d4d4"
+ text_inactive: "#808085"
+ text_error: "#f14c4c"
+ text_highlight: "#e2c08d"
+
+ button_flat: "#303036"
+ button_flat_active: "#50505a"
+ button_flat_hovered: "#3f3f47"
+ button_flat_light: "#38383f"
+
+ background_default: "#242426"
+ background_dark: "#1b1b1d"
+ background_light: "#2d2d30"
+ background_menu: "#2f2f33"
+ background_invalid: "#c0202064"
+
+ properties_header: "#2f3a46"
+ properties_row: "#1f1f22"
+ properties_row_alt: "#26262a"
+ properties_border: "#3f3f45"
+ properties_label: "#9cdcfe"
+ properties_value: "#cbcbd0"
+
+ path_link: "#4fa6ff"
+ path_link_hover: "#7cbcff"
+
+ header_library: "#89d185"
+ header_reconstruction: "#4fa6ff"
+
+ feature_volume: "#7ee787"
+ feature_arpeggio: "#f0a35e"
+ feature_pitch: "#6fb8ff"
+ feature_duty_cycle: "#e6c26a"
+
+ caret_fill: "#4fa6ff55"
+ caret_border: "#7cc4ffff"
+
+ graph_bar: "#4fa6ff"
+ waveform_sample: "#4fa6ff"
+ waveform_reconstruction: "#e6a34d"
+ waveform_overlay: "#ffffff22"
+ spectrum_dim: "#2a2a30"
+
+ tracker_beat_row: "#ffffff14"
+ tracker_bar_row: "#ffffff28"
+
+ block_selection: "#b98af360"
+ pattern_highlight: "#ffffff40"
+ cell_cursor: "#4fa6ffb0"
+ cursor_row: "#4fa6ff3a"
+ playback_row: "#5ec46e42"
+
+ order_label: "#2f3a46"
+ order_master: "#4fa6ff18"
+ order_master_divider: "#4fa6ff2e"
+ order_column_current: "#ffffff20"
+ order_column_playing: "#5ec46e34"
+
+ sample_column: "#4fa6ff2c"
+ sample_divider: "#4fa6ff2e"
+
+ channel_muted: "#0a0a0a18"
+
+ history_future: "#82828aff"
+ history_channel: "#6fb8ffff"
+ history_value: "#d0d0d4ff"
+ history_separator: "#6c6c74ff"
+
+ tracker_reference: "#e0c860ff"
+ tracker_transpose: "#c0c0c4ff"
+ tracker_volume: "#7ee787ff"
+ tracker_frame: "#4fa6ffff"
+ tracker_row: "#9a9aa2ff"
+ tracker_order: "#c4d0e0ff"
diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml
new file mode 100644
index 00000000..d8a97bca
--- /dev/null
+++ b/src/sampletones_config/palettes/light.yaml
@@ -0,0 +1,195 @@
+name: light
+
+colors:
+ ground: "#c2c7d0"
+ tab_strip: "#b3b9c4"
+ recess: "#d6dae2"
+ surface: "#e7e8ea"
+ surface_alt: "#ffffff"
+ surface_accent: "#d8e4f2"
+ menu: "#c8ccd6"
+ status_bar: "#c8ccd6"
+ popup: "#ffffff"
+ frame: "#ffffff"
+ frame_hovered: "#eaeef4"
+ frame_active: "#dce2ec"
+ border: "#7f8794"
+ separator: "#69717e"
+ plot_background: "#ffffff"
+ well: "#eff5ff"
+
+ table_header: "#c6cbd5"
+ table_row: "#ffffff"
+ table_row_alt: "#eef1f5"
+ table_border: "#8b93a0"
+
+ cool: "#0a5aa8"
+ cool_hover: "#0f6ec6"
+ cool_active: "#074480"
+ cool_muted: "#a9c6e2"
+
+ accent: "#0b4c8c"
+ accent_hover: "#1163ac"
+ accent_active: "#073a6c"
+ accent_muted: "#9dbcd9"
+ on_accent: "#ffffff"
+
+ primary: "#0b4c8c"
+ primary_hover: "#1163ac"
+ primary_active: "#073a6c"
+ primary_muted: "#a3aab6"
+ on_primary: "#ffffff"
+
+ secondary: "#d2d7e0"
+ secondary_hover: "#bfc6d2"
+ secondary_active: "#aab3c2"
+ secondary_disabled: "#e3e6ec"
+
+ danger: "#a82820"
+ danger_hover: "#c33a30"
+ danger_active: "#851f18"
+ on_danger: "#ffffff"
+
+ dialog_surface: "#f2f4f8"
+ dialog_title: "#c6cbd5"
+
+ channel_pulse1: "#a8410a"
+ channel_pulse2: "#7a5c00"
+ channel_triangle: "#0f5288"
+ channel_noise: "#4a4f59"
+ channel_pulse1_soft: "#8c6440"
+ channel_pulse2_soft: "#75683c"
+ channel_triangle_soft: "#456c8c"
+ channel_noise_soft: "#767b85"
+
+ tab: "#b9bfca"
+ tab_hovered: "#a9b0bd"
+ tab_active: "#f7f8fa"
+
+ selection: "#b9d3ec"
+ selection_hovered: "#a2c4e6"
+ selection_active: "#88b3de"
+
+ scrollbar_hovered: "#79808d"
+
+ button: "#bcc4d2"
+ button_hovered: "#a9b3c4"
+ button_active: "#95a1b5"
+ button_disabled: "#d8dce3"
+
+ player_surface: "#d4dae4"
+ player_border: "#0b4c8c"
+ player_button: "#bfc7d5"
+ player_button_hovered: "#acb6c7"
+ player_button_active: "#98a4b8"
+ player_button_disabled: "#dce0e7"
+
+ text: "#0a0c10"
+ text_muted: "#414852"
+ text_disabled: "#767d88"
+ text_trace: "#2c323b"
+
+ contrast: "#000000"
+ overlay: "#000000"
+ transparent: "#00000000"
+ border_strong: "#5a6270"
+ input_invalid: "#c0504a48"
+ input_warning: "#c0884a48"
+
+ control: "#ffffff"
+ control_hovered: "#e6ecf4"
+ control_active: "#d2dbe8"
+ control_border: "#5a6270"
+
+ plot_zero_line: "#69717e"
+ plot_grid: "#ccd2db"
+ plot_axis_text: "#414852"
+ plot_border: "#7f8794"
+ plot_legend_bg: "#f2f4f8"
+
+ file_wave: "#0a5aa8"
+ file_library: "#146c2a"
+ file_reconstruction: "#3a3a9c"
+
+ favorite: "#8a6000"
+ favorite_child: "#75663c"
+
+ library_generator: "#194c26"
+ library_group: "#125a60"
+ library_instruction: "#3d434d"
+ library_root: "#20252c"
+
+ text_default: "#12161c"
+ text_inactive: "#6e7580"
+ text_error: "#a82820"
+ text_highlight: "#7a5200"
+
+ button_flat: "#ccd2dc"
+ button_flat_active: "#9fa9ba"
+ button_flat_hovered: "#b8c0ce"
+ button_flat_light: "#c2c9d6"
+
+ background_default: "#e6e9ef"
+ background_dark: "#c2c7d0"
+ background_light: "#ffffff"
+ background_menu: "#d9dde5"
+ background_invalid: "#c0202038"
+
+ properties_header: "#c6cbd5"
+ properties_row: "#ffffff"
+ properties_row_alt: "#eef1f5"
+ properties_border: "#8b93a0"
+ properties_label: "#073a6c"
+ properties_value: "#161b22"
+
+ path_link: "#0a5aa8"
+ path_link_hover: "#073a6c"
+
+ header_library: "#1f6e34"
+ header_reconstruction: "#3a3a9c"
+
+ feature_volume: "#16702e"
+ feature_arpeggio: "#a8410a"
+ feature_pitch: "#0a5aa8"
+ feature_duty_cycle: "#7a5c00"
+
+ caret_fill: "#0b4c8c58"
+ caret_border: "#073a6cff"
+
+ graph_bar: "#0a5aa8"
+ waveform_sample: "#0a5aa8"
+ waveform_reconstruction: "#c04a10"
+ waveform_overlay: "#00000020"
+ spectrum_dim: "#dfe4ec"
+
+ tracker_beat_row: "#00000016"
+ tracker_bar_row: "#0000002c"
+
+ block_selection: "#6b4ea840"
+ pattern_highlight: "#00000018"
+ cell_cursor: "#0b4c8c60"
+ cursor_row: "#0b4c8c24"
+ playback_row: "#16702e48"
+
+ order_label: "#c6cbd5"
+ order_master: "#0a5aa818"
+ order_master_divider: "#0a5aa838"
+ order_column_current: "#0000001a"
+ order_column_playing: "#16702e34"
+
+ sample_column: "#0a5aa826"
+ sample_divider: "#0a5aa838"
+
+ channel_muted: "#7f879418"
+
+ history_future: "#7c838fff"
+ history_channel: "#0a5aa8ff"
+ history_value: "#1d222aff"
+ history_separator: "#8f97a4ff"
+
+ tracker_reference: "#7a5200ff"
+ tracker_transpose: "#3d434dff"
+ tracker_volume: "#16702eff"
+ tracker_frame: "#0a5aa8ff"
+ tracker_row: "#5f6773ff"
+ tracker_order: "#28303dff"
diff --git a/src/sampletones_config/layout/palette.yaml b/src/sampletones_config/palettes/studio.yaml
similarity index 79%
rename from src/sampletones_config/layout/palette.yaml
rename to src/sampletones_config/palettes/studio.yaml
index b29f3647..371f5ee4 100644
--- a/src/sampletones_config/layout/palette.yaml
+++ b/src/sampletones_config/palettes/studio.yaml
@@ -1,7 +1,6 @@
name: studio
colors:
- # surfaces and neutrals
ground: "#1c1b26"
tab_strip: "#24232f"
recess: "#2f2e3c"
@@ -19,26 +18,22 @@ colors:
plot_background: "#201d2a"
well: "#1f2636"
- # tables
table_header: "#46415c"
table_row: "#323042"
table_row_alt: "#3c394e"
table_border: "#585472"
- # cool secondary (functional chrome: input focus, secondary emphasis)
cool: "#7fa8ea"
cool_hover: "#94b8f0"
cool_active: "#6690d4"
cool_muted: "#40506e"
- # accent
accent: "#b98af3"
accent_hover: "#a180ce"
accent_active: "#7b629e"
accent_muted: "#685386"
on_accent: "#17131f"
- # buttons
primary: "#8f6fc0"
primary_hover: "#a689d4"
primary_active: "#7d64ac"
@@ -50,17 +45,14 @@ colors:
secondary_active: "#46516a"
secondary_disabled: "#2b303d"
- # danger (destructive actions: cancel, abort)
danger: "#a85555"
danger_hover: "#bd6a6a"
danger_active: "#8e4747"
on_danger: "#f1f1f5"
- # dialog windows (elevated surface + accent border/title)
dialog_surface: "#313547"
dialog_title: "#34405e"
- # channels: pulses orange, triangle blue, noise grey
channel_pulse1: "#f09256"
channel_pulse2: "#f2d15f"
channel_triangle: "#8cc1ed"
@@ -70,26 +62,21 @@ colors:
channel_triangle_soft: "#b9cedf"
channel_noise_soft: "#cbcace"
- # tabs
tab: "#24283a"
tab_hovered: "#2f3a56"
tab_active: "#3c4a72"
- # selection (tree, list, menu highlight)
selection: "#423d58"
selection_hovered: "#4d4669"
selection_active: "#574f7c"
- # scrollbar
scrollbar_hovered: "#4a4759"
- # buttons
button: "#576793"
button_hovered: "#687aac"
button_active: "#778abe"
button_disabled: "#363a48"
- # player
player_surface: "#242a44"
player_border: "#4a90d9"
player_button: "#313b61"
@@ -97,58 +84,57 @@ colors:
player_button_active: "#4a5896"
player_button_disabled: "#252e4d"
- # text
text: "#e7e7f0"
text_muted: "#9497ab"
text_disabled: "#767a8e"
text_trace: "#c0c0c0"
- # emphasis and overlays
- white: "#ffffff"
+ contrast: "#ffffff"
+ overlay: "#ffffff"
transparent: "#00000000"
border_strong: "#565f78"
input_invalid: "#c0504a64"
input_warning: "#c0884a64"
- # plot lines
+ control: "#514c68"
+ control_hovered: "#5c5676"
+ control_active: "#665f83"
+ control_border: "#6f6a8a"
+
plot_zero_line: "#c8c8c8"
+ plot_grid: "#39344a"
+ plot_axis_text: "#9497ab"
+ plot_border: "#464a5e"
+ plot_legend_bg: "#2a2636"
- # file-tree nodes
file_wave: "#64c8ff"
file_library: "#96ff96"
file_reconstruction: "#b4b4ff"
- file_muted: "#b4b4b4"
- # favourites
favorite: "#ffd76e"
favorite_child: "#e7dbb7"
- # instruction-library nodes
library_generator: "#d2e8d2"
library_group: "#d2e8e8"
library_instruction: "#d2d2d2"
library_root: "#dcdcdc"
- # layout: content text
text_default: "#dcdcdc"
text_inactive: "#828282"
text_error: "#ff6464"
text_highlight: "#ffcf6e"
- # layout: flat control buttons
button_flat: "#363648"
button_flat_active: "#5a5a78"
button_flat_hovered: "#48486c"
button_flat_light: "#404060"
- # layout: background fills
background_default: "#242424"
background_dark: "#1c1c1c"
background_light: "#2c2c2c"
background_menu: "#323232"
background_invalid: "#c0202064"
- # layout: properties table
properties_header: "#2d3241"
properties_row: "#1e2028"
properties_row_alt: "#262834"
@@ -156,58 +142,51 @@ colors:
properties_label: "#8ca0c8"
properties_value: "#c8cddc"
- # layout: path links
path_link: "#6496ff"
path_link_hover: "#96c8ff"
- # layout: section headers
header_library: "#96d2a0"
header_reconstruction: "#c8a0ff"
- # layout: instruction features
feature_volume: "#64ff64"
feature_arpeggio: "#ff9664"
feature_pitch: "#64c8ff"
feature_duty_cycle: "#ffc864"
- # layout: caret overlay
caret_fill: "#8888ff80"
caret_border: "#88bbffff"
- # layout: graphs and waveforms
graph_bar: "#64c8ff"
waveform_sample: "#64c8ff"
waveform_reconstruction: "#ffc864"
waveform_overlay: "#ffffff20"
spectrum_dim: "#1c1c1c"
- # layout: tracker cursor and playback
+ tracker_beat_row: "#ffffff14"
+ tracker_bar_row: "#ffffff26"
+
+ block_selection: "#b98af360"
pattern_highlight: "#ffffff40"
cell_cursor: "#66bbffa0"
cursor_row: "#ffffff18"
playback_row: "#64dc6440"
- # layout: order table
order_label: "#2d3241"
order_master: "#22ccff18"
order_master_divider: "#22ccff24"
order_column_current: "#ffffff20"
order_column_playing: "#64dc6430"
- # layout: sample column
sample_column: "#22ccff2c"
sample_divider: "#22ccff24"
- # layout: muted channel column
channel_muted: "#0a081210"
- # layout: history detail
history_future: "#808080ff"
history_channel: "#88bbffff"
history_value: "#d0d0d0ff"
history_separator: "#707070ff"
- # layout: tracker text (instrument and sample share the reference yellow)
tracker_reference: "#e0c860ff"
tracker_transpose: "#c0c0c0ff"
tracker_volume: "#64dc64ff"
diff --git a/src/sampletones_config/theme/converter.yaml b/src/sampletones_config/theme/converter.yaml
index 49868711..f036c38b 100644
--- a/src/sampletones_config/theme/converter.yaml
+++ b/src/sampletones_config/theme/converter.yaml
@@ -6,7 +6,7 @@ components:
entries:
- type: color
key: Text
- value: .white
+ value: .contrast
- type: color
key: TextDisabled
value: .text_muted
diff --git a/src/sampletones_config/theme/default.yaml b/src/sampletones_config/theme/default.yaml
index 9a503ff8..fc61e366 100644
--- a/src/sampletones_config/theme/default.yaml
+++ b/src/sampletones_config/theme/default.yaml
@@ -10,6 +10,9 @@ components:
- type: color
key: TextDisabled
value: .text_muted
+ - type: color
+ key: InputTextCursor
+ value: .text
- type: color
key: WindowBg
value: .ground
@@ -105,6 +108,70 @@ components:
key: PlotBg
category: Plots
value: .plot_background
+ - type: color
+ key: PlotBorder
+ category: Plots
+ value: .plot_border
+ - type: color
+ key: AxisGrid
+ category: Plots
+ value: .plot_grid
+ - type: color
+ key: AxisTick
+ category: Plots
+ value: .plot_grid
+ - type: color
+ key: AxisText
+ category: Plots
+ value: .plot_axis_text
+ - type: color
+ key: AxisBg
+ category: Plots
+ value: .transparent
+ - type: color
+ key: AxisBgHovered
+ category: Plots
+ value: .overlay/0.08
+ - type: color
+ key: AxisBgActive
+ category: Plots
+ value: .overlay/0.16
+ - type: color
+ key: TitleText
+ category: Plots
+ value: .text
+ - type: color
+ key: InlayText
+ category: Plots
+ value: .text_muted
+ - type: color
+ key: LegendBg
+ category: Plots
+ value: .plot_legend_bg
+ - type: color
+ key: LegendBorder
+ category: Plots
+ value: .plot_border
+ - type: color
+ key: LegendText
+ category: Plots
+ value: .text
+ - type: color
+ key: Selection
+ category: Plots
+ value: .accent/0.35
+ - type: color
+ key: Crosshairs
+ category: Plots
+ value: .plot_axis_text
+ - type: style
+ key: PlotBorderSize
+ category: Plots
+ x: 1
+ - type: style
+ key: MinorAlpha
+ category: Plots
+ x: 0.4
- type: style
key: WindowPadding
x: 8
@@ -217,20 +284,92 @@ components:
- type: color
key: Text
value: .text
+ - type: color
+ key: FrameBg
+ value: .control
+ - type: color
+ key: FrameBgHovered
+ value: .control_hovered
+ - type: color
+ key: FrameBgActive
+ value: .control_active
+ - type: color
+ key: Border
+ value: .control_border
+ - type: color
+ key: CheckMark
+ value: .accent
+ - type: style
+ key: FrameBorderSize
+ x: 1
- item_type: RadioButton
enabled: false
entries:
- type: color
key: Text
value: .text_muted
+ - type: color
+ key: FrameBg
+ value: .button_disabled
+ - type: color
+ key: FrameBgHovered
+ value: .button_disabled
+ - type: color
+ key: FrameBgActive
+ value: .button_disabled
+ - type: color
+ key: Border
+ value: .border
+ - type: color
+ key: CheckMark
+ value: .text_disabled
+ - type: style
+ key: FrameBorderSize
+ x: 1
- item_type: Checkbox
entries:
- type: color
key: Text
value: .text
+ - type: color
+ key: FrameBg
+ value: .control
+ - type: color
+ key: FrameBgHovered
+ value: .control_hovered
+ - type: color
+ key: FrameBgActive
+ value: .control_active
+ - type: color
+ key: Border
+ value: .control_border
+ - type: color
+ key: CheckMark
+ value: .accent
+ - type: style
+ key: FrameBorderSize
+ x: 1
- item_type: Checkbox
enabled: false
entries:
- type: color
key: Text
value: .text_muted
+ - type: color
+ key: FrameBg
+ value: .button_disabled
+ - type: color
+ key: FrameBgHovered
+ value: .button_disabled
+ - type: color
+ key: FrameBgActive
+ value: .button_disabled
+ - type: color
+ key: Border
+ value: .border
+ - type: color
+ key: CheckMark
+ value: .text_disabled
+ - type: style
+ key: FrameBorderSize
+ x: 1
diff --git a/src/sampletones_config/theme/graphs/indicator.yaml b/src/sampletones_config/theme/graphs/indicator.yaml
index 2e122007..f51896d4 100644
--- a/src/sampletones_config/theme/graphs/indicator.yaml
+++ b/src/sampletones_config/theme/graphs/indicator.yaml
@@ -7,7 +7,7 @@ components:
- type: color
key: Line
category: Plots
- value: .white
+ value: .contrast
- type: style
key: LineWeight
category: Plots
diff --git a/src/sampletones_config/theme/graphs/overlay.yaml b/src/sampletones_config/theme/graphs/overlay.yaml
index 31a967cf..d50e180b 100644
--- a/src/sampletones_config/theme/graphs/overlay.yaml
+++ b/src/sampletones_config/theme/graphs/overlay.yaml
@@ -7,4 +7,4 @@ components:
- type: color
key: Fill
category: Plots
- value: .white/0.125
+ value: .overlay/0.125
diff --git a/src/sampletones_config/theme/instrument_tabs.yaml b/src/sampletones_config/theme/instruments/tabs.yaml
similarity index 100%
rename from src/sampletones_config/theme/instrument_tabs.yaml
rename to src/sampletones_config/theme/instruments/tabs.yaml
diff --git a/src/sampletones_config/theme/instruments/tabs_muted.yaml b/src/sampletones_config/theme/instruments/tabs_muted.yaml
new file mode 100644
index 00000000..b6d0c214
--- /dev/null
+++ b/src/sampletones_config/theme/instruments/tabs_muted.yaml
@@ -0,0 +1,21 @@
+name: instrument_tabs_muted
+tag: global.theme.instrument_tabs_muted
+
+components:
+ - item_type: All
+ entries:
+ - type: color
+ key: Text
+ value: .text_muted
+ - type: color
+ key: Tab
+ value: .recess
+ - type: color
+ key: TabHovered
+ value: .ground/0.75
+ - type: color
+ key: TabSelected
+ value: .ground
+ - type: color
+ key: TabDimmedSelected
+ value: .recess
diff --git a/src/sampletones_config/theme/nodes/files/not_expanded_directory.yaml b/src/sampletones_config/theme/nodes/files/not_expanded_directory.yaml
deleted file mode 100644
index f09d7ae1..00000000
--- a/src/sampletones_config/theme/nodes/files/not_expanded_directory.yaml
+++ /dev/null
@@ -1,9 +0,0 @@
-name: node_file_not_expanded_directory
-tag: global.theme.file_not_expanded_directory
-
-components:
- - item_type: TreeNode
- entries:
- - type: color
- key: Text
- value: .file_muted
diff --git a/src/sampletones_config/theme/panel/instrument.yaml b/src/sampletones_config/theme/panel/instrument.yaml
index 4e003dd0..d3355cdb 100644
--- a/src/sampletones_config/theme/panel/instrument.yaml
+++ b/src/sampletones_config/theme/panel/instrument.yaml
@@ -7,3 +7,6 @@ components:
- type: color
key: ChildBg
value: .recess
+ - type: color
+ key: Text
+ value: .text
diff --git a/src/sampletones_config/theme/tables/instruments_row.yaml b/src/sampletones_config/theme/tables/instruments_row.yaml
index d85edb47..276e8883 100644
--- a/src/sampletones_config/theme/tables/instruments_row.yaml
+++ b/src/sampletones_config/theme/tables/instruments_row.yaml
@@ -6,7 +6,7 @@ components:
entries:
- type: color
key: HeaderHovered
- value: .white/0.25
+ value: .overlay/0.25
- type: color
key: HeaderActive
value: .transparent
diff --git a/src/sampletones_config/theme/tables/order.yaml b/src/sampletones_config/theme/tables/order.yaml
index 6dbf4ee1..71a8af6e 100644
--- a/src/sampletones_config/theme/tables/order.yaml
+++ b/src/sampletones_config/theme/tables/order.yaml
@@ -6,7 +6,12 @@ components:
entries:
- type: color
key: HeaderHovered
- value: .white/0.25
+ value: .overlay/0.25
- type: color
key: HeaderActive
value: .transparent
+ - item_type: Selectable
+ entries:
+ - type: color
+ key: Header
+ value: .block_selection
diff --git a/src/sampletones_config/theme/tables/pattern.yaml b/src/sampletones_config/theme/tables/pattern.yaml
index b5eecfa3..1b0d5512 100644
--- a/src/sampletones_config/theme/tables/pattern.yaml
+++ b/src/sampletones_config/theme/tables/pattern.yaml
@@ -7,21 +7,34 @@ components:
- type: style
key: CellPadding
x: 3
- y: 4
+ y: 0
- type: color
key: HeaderHovered
- value: .white/0.25
+ value: .overlay/0.25
- type: color
key: HeaderActive
value: .transparent
- type: color
key: TableRowBg
- value: .table_row_alt
+ value: .table_row
- type: color
key: TableRowBgAlt
value: .table_row
+ - item_type: Selectable
+ entries:
+ - type: style
+ key: ItemSpacing
+ x: 0
+ y: 0
+ - type: style
+ key: SelectableTextAlign
+ x: 0
+ y: 0.5
+ - type: color
+ key: Header
+ value: .block_selection
- item_type: All
entries:
- type: color
key: Text
- value: .white
+ value: .contrast
diff --git a/src/sampletones_core/audio/__init__.py b/src/sampletones_core/audio/__init__.py
index b0e51bb7..436ef3c6 100644
--- a/src/sampletones_core/audio/__init__.py
+++ b/src/sampletones_core/audio/__init__.py
@@ -11,6 +11,7 @@
normalize,
quantize,
resample,
+ silence,
to_mono,
)
from .validation import (
@@ -20,25 +21,26 @@
)
__all__ = [
- "CurrentDevice",
+ "CHANNELS",
+ "FORMAT",
"AudioDevice",
"AudioDeviceManager",
+ "CurrentDevice",
"active_frame_level",
"amplitude_to_decibels",
"clip_audio",
"clip_audio_inplace",
- "read_wave",
- "load_audio",
- "write_wave",
- "to_mono",
- "resample",
"interpolate",
+ "load_audio",
"minmax_decimate",
"normalize",
"quantize",
+ "read_wave",
+ "resample",
+ "silence",
+ "to_mono",
"validate_audio_array",
- "validate_sample_rate",
"validate_buffer_size",
- "CHANNELS",
- "FORMAT",
+ "validate_sample_rate",
+ "write_wave",
]
diff --git a/src/sampletones_core/audio/device.py b/src/sampletones_core/audio/device.py
index 8397dc12..4c8db5c8 100644
--- a/src/sampletones_core/audio/device.py
+++ b/src/sampletones_core/audio/device.py
@@ -31,7 +31,7 @@ def default(cls) -> Self:
class AudioDevice(BaseModel):
"""
- Model representing a sounddevice audio device.
+ Model representing an audio device available for output selection.
"""
model_config = ConfigDict(extra="forbid", frozen=True)
diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py
index 71966535..66530df5 100644
--- a/src/sampletones_core/audio/manager.py
+++ b/src/sampletones_core/audio/manager.py
@@ -92,6 +92,7 @@ def __init__(self) -> None:
self._stop: bool = False
self._playback_thread: Optional[threading.Thread] = None
+ self._stream_owners: Dict[pyaudio.Stream, VoidCallback] = {}
self._lock: threading.Lock = threading.Lock()
self._resume_event: threading.Event = threading.Event()
self._resume_event.set()
@@ -109,7 +110,13 @@ def reinitialize(self) -> None:
Reinitialize the PyAudio instance.
Creates a new PyAudio instance if none exists, or terminates the existing
- instance and creates a new one. Stops any active playback before reinitializing.
+ instance and creates a new one. Active playback is stopped and every handed-out
+ output stream is released first, since terminating closes any stream still open
+ while its owner writes to it.
+
+ Raises:
+ PlaybackError: If an output stream is still held after its owner was asked to
+ release it, which leaves the running instance in place.
"""
with _capture_stderr_to_logger():
if self._pyaudio is None:
@@ -118,6 +125,9 @@ def reinitialize(self) -> None:
return
self.stop()
+ if not self._release_output_streams():
+ raise PlaybackError("An output stream is still held; the audio backend stays as it is")
+
self._pyaudio.terminate()
self._pyaudio = pyaudio.PyAudio()
logger.debug("AudioDeviceManager reinitialized")
@@ -227,7 +237,7 @@ def _initialize_default_device(self) -> None:
device = self._devices[device_index]
self._device_index = device_index
self._sample_rate = device.default_sample_rate
- except IOError:
+ except OSError:
logger.warning("No default output device found")
@property
@@ -419,12 +429,18 @@ def configure_device(
Stops any active playback and switches to the specified device and sample rate.
If the sample rate is not supported, falls back to the first supported rate for that device.
+ A stream handed out to a streaming source was opened on the device and rate in force at
+ the time, so the new settings reach it only once it is wound down; the source is asked to
+ hand the output back before the switch, and playback resumes under the new settings.
+
Args:
device_index: Index of the device to configure.
sample_rate: Desired sample rate in Hz.
Raises:
ValueError: If the device index is not found.
+ PlaybackError: If a handed-out stream survives its release, which leaves the device
+ and rate as they stand.
"""
if device_index not in self._devices:
raise ValueError(f"Device with index {device_index} not found")
@@ -438,6 +454,10 @@ def configure_device(
sample_rate = fallback_rate
self.stop()
+ self.call(self.on_acquire_output)
+ if not self._release_output_streams():
+ raise PlaybackError("An output stream is still held; the audio device stays as it is")
+
self.device_index = device_index
self.sample_rate = sample_rate
logger.info(f"Audio device configured: '{self.device_name}' (index={device_index}, sample_rate={sample_rate})")
@@ -744,16 +764,21 @@ def open_output_stream(
*,
sample_rate: int,
buffer_size: int,
+ release: VoidCallback,
) -> pyaudio.Stream:
"""Open a blocking output stream for caller-managed streaming playback.
- The caller owns the stream's lifetime and must close it when done. Any buffer
- playback owned by this manager is stopped first, since the output device allows
- only a single open stream at a time.
+ The caller drives the stream from a thread of its own and hands it back through
+ ``close_output_stream`` once that thread finishes. Until then the manager counts the
+ stream as outstanding and calls ``release`` whenever it needs the backend free, so a
+ stream is torn down by the thread that writes to it. Any buffer playback owned by this
+ manager is stopped first, since the output device allows only a single open stream at a
+ time.
Args:
sample_rate: Sample rate in Hz.
buffer_size: Frames per buffer (controls write granularity).
+ release: Winds the caller's writing down; returns once the stream is handed back.
Raises:
PlaybackError: If PyAudio is not initialized.
@@ -762,7 +787,7 @@ def open_output_stream(
raise PlaybackError("PyAudio not initialized; call reinitialize() first")
self.stop()
- return self._pyaudio.open(
+ stream = self._pyaudio.open(
format=FORMAT,
channels=CHANNELS,
rate=sample_rate,
@@ -770,15 +795,61 @@ def open_output_stream(
output_device_index=self._device_index,
frames_per_buffer=buffer_size,
)
+ with self._lock:
+ self._stream_owners[stream] = release
+
+ return stream
+
+ def close_output_stream(self, stream: pyaudio.Stream) -> None:
+ """Take a handed-out stream back and close it.
+
+ Called by the owner from the thread that wrote to the stream, once that writing has
+ finished. Returning the stream is what tells the manager the backend is free again.
+ """
+ with self._lock:
+ self._stream_owners.pop(stream, None)
+
+ stream.stop_stream()
+ stream.close()
def terminate(self) -> None:
"""
Clean up and terminate the audio device manager.
- Stops any active playback and terminates the PyAudio instance.
- Should be called once you are finished with the manager.
+ Stops any active playback, releases every handed-out output stream, and terminates
+ the PyAudio instance. A stream that survives its release leaves the instance running,
+ since terminating closes any open stream and the owning thread would go on writing to
+ freed memory. Should be called once you are finished with the manager.
"""
- if self._pyaudio is not None:
- self.stop()
- self._pyaudio.terminate()
- self._pyaudio = None
+ if self._pyaudio is None:
+ return
+
+ self.stop()
+ if not self._release_output_streams():
+ logger.error("AudioDeviceManager: an output stream is still held; PyAudio left running")
+ return
+
+ self._pyaudio.terminate()
+ self._pyaudio = None
+
+ def _release_output_streams(self) -> bool:
+ """Ask each streaming owner to wind down; report whether every stream was released.
+
+ An owner writes to its stream from its own thread, so only that owner can bring the
+ writing to a stop. A release both stops the writer and hands the stream back through
+ ``close_output_stream``, which is what leaves the instance safe to terminate.
+ """
+ with self._lock:
+ releases = list(self._stream_owners.values())
+
+ for release in releases:
+ release()
+
+ with self._lock:
+ outstanding = len(self._stream_owners)
+
+ if outstanding:
+ logger.error(f"AudioDeviceManager: {outstanding} output stream(s) outlived their release")
+ return False
+
+ return True
diff --git a/src/sampletones_core/audio/processing.py b/src/sampletones_core/audio/processing.py
index 99518d48..53f5bfe8 100644
--- a/src/sampletones_core/audio/processing.py
+++ b/src/sampletones_core/audio/processing.py
@@ -14,6 +14,19 @@
from .validation import validate_audio_array
+def silence(samples: int) -> np.ndarray:
+ """
+ Build a buffer of the given length holding no sound.
+
+ Args:
+ samples: How many samples the buffer spans.
+
+ Returns:
+ A float32 array of zeros, ready to be mixed into or written over.
+ """
+ return np.zeros(samples, dtype=np.float32)
+
+
def clip_audio(audio: np.ndarray) -> np.ndarray:
"""
Clip audio samples to the valid range [-1.0, 1.0].
diff --git a/src/sampletones_core/audio/writers/__init__.py b/src/sampletones_core/audio/writers/__init__.py
new file mode 100644
index 00000000..25d33237
--- /dev/null
+++ b/src/sampletones_core/audio/writers/__init__.py
@@ -0,0 +1,44 @@
+from .bitrate import (
+ MP3_LADDERS,
+ MP3_SAMPLE_RATES,
+ default_mp3_bitrate,
+ mp3_bitrates,
+ mp3_compression_level,
+)
+from .capability import FORMAT_CAPABILITIES, FormatCapability, capability_of
+from .format import (
+ AUDIO_DEPTHS,
+ DEFAULT_AUDIO_DEPTH,
+ DEFAULT_AUDIO_FORMAT,
+ AudioDepth,
+ AudioFormat,
+)
+from .protocol import AudioWriter
+from .selection import available_audio_formats, available_depths, open_audio_writer
+from .soundfile import SoundFileAudioWriter
+from .spec import AudioOutputSpec, AudioOutputSpecBase, Mp3OutputSpec, WaveOutputSpec
+
+__all__ = [
+ "AUDIO_DEPTHS",
+ "DEFAULT_AUDIO_DEPTH",
+ "DEFAULT_AUDIO_FORMAT",
+ "FORMAT_CAPABILITIES",
+ "MP3_LADDERS",
+ "MP3_SAMPLE_RATES",
+ "AudioDepth",
+ "AudioFormat",
+ "AudioOutputSpec",
+ "AudioOutputSpecBase",
+ "AudioWriter",
+ "FormatCapability",
+ "Mp3OutputSpec",
+ "SoundFileAudioWriter",
+ "WaveOutputSpec",
+ "available_audio_formats",
+ "available_depths",
+ "capability_of",
+ "default_mp3_bitrate",
+ "mp3_bitrates",
+ "mp3_compression_level",
+ "open_audio_writer",
+]
diff --git a/src/sampletones_core/audio/writers/bitrate.py b/src/sampletones_core/audio/writers/bitrate.py
new file mode 100644
index 00000000..1891cdec
--- /dev/null
+++ b/src/sampletones_core/audio/writers/bitrate.py
@@ -0,0 +1,117 @@
+from typing import Final, Mapping, Tuple
+
+MPEG_1_LADDER: Final[Mapping[int, float]] = {
+ 320: 0.05,
+ 256: 0.19,
+ 224: 0.33,
+ 192: 0.44,
+ 160: 0.55,
+ 128: 0.65,
+ 112: 0.72,
+ 96: 0.78,
+ 80: 0.83,
+ 64: 0.88,
+ 56: 0.91,
+ 48: 0.94,
+ 40: 0.97,
+ 32: 0.99,
+}
+
+MPEG_2_LADDER: Final[Mapping[int, float]] = {
+ 160: 0.02,
+ 144: 0.10,
+ 128: 0.21,
+ 112: 0.31,
+ 96: 0.42,
+ 80: 0.52,
+ 64: 0.62,
+ 56: 0.68,
+ 48: 0.73,
+ 40: 0.78,
+ 32: 0.84,
+ 24: 0.89,
+ 16: 0.94,
+ 8: 0.98,
+}
+
+MPEG_2_5_LADDER: Final[Mapping[int, float]] = {
+ 64: 0.03,
+ 56: 0.13,
+ 48: 0.27,
+ 40: 0.41,
+ 32: 0.56,
+ 24: 0.70,
+ 16: 0.84,
+ 8: 0.96,
+}
+
+MP3_LADDERS: Final[Mapping[int, Mapping[int, float]]] = {
+ 8000: MPEG_2_5_LADDER,
+ 16000: MPEG_2_LADDER,
+ 22050: MPEG_2_LADDER,
+ 44100: MPEG_1_LADDER,
+ 48000: MPEG_1_LADDER,
+}
+
+MP3_SAMPLE_RATES: Final[Tuple[int, ...]] = tuple(sorted(MP3_LADDERS))
+PREFERRED_MP3_BITRATE: Final[int] = 192
+
+
+def mp3_bitrates(sample_rate: int) -> Tuple[int, ...]:
+ """The bitrates MP3 encodes at ``sample_rate``, highest first.
+
+ Each MPEG audio version defines its own ladder of bitrates and covers its own set of sample
+ rates, so the choice on offer narrows as the rate drops: the full ladder up to 320 kbps at
+ 44100 and 48000 Hz, a ladder topping out at 160 kbps at 16000 and 22050 Hz, and one topping
+ out at 64 kbps at 8000 Hz.
+
+ Args:
+ sample_rate: The rate the file is written at.
+
+ Returns:
+ Tuple[int, ...]: The bitrates in kbps, highest first.
+
+ Raises:
+ KeyError: If MP3 does not encode at ``sample_rate``.
+ """
+ return tuple(MP3_LADDERS[sample_rate])
+
+
+def mp3_compression_level(sample_rate: int, bitrate: int) -> float:
+ """The encoder setting that reaches ``bitrate`` at ``sample_rate``.
+
+ libsndfile asks for MP3 quality as a compression level between 0 and 1 and turns that into a
+ rung on the ladder its MPEG version defines, so the level standing for a given bitrate depends
+ on the sample rate as well. Each level here sits in the middle of the band that selects its
+ rung, which leaves room either side for the rounding an encoder build applies.
+
+ Args:
+ sample_rate: The rate the file is written at.
+ bitrate: The bitrate in kbps, one of those :func:`mp3_bitrates` reports.
+
+ Returns:
+ float: The compression level to open the file with.
+
+ Raises:
+ KeyError: If MP3 does not encode at ``sample_rate``, or does not reach ``bitrate`` there.
+ """
+ return MP3_LADDERS[sample_rate][bitrate]
+
+
+def default_mp3_bitrate(sample_rate: int) -> int:
+ """The bitrate a render starts at: the preferred one where the rate reaches it, else its best.
+
+ Args:
+ sample_rate: The rate the file is written at.
+
+ Returns:
+ int: The bitrate in kbps.
+
+ Raises:
+ KeyError: If MP3 does not encode at ``sample_rate``.
+ """
+ bitrates = mp3_bitrates(sample_rate)
+ return next(
+ (bitrate for bitrate in bitrates if bitrate <= PREFERRED_MP3_BITRATE),
+ bitrates[-1],
+ )
diff --git a/src/sampletones_core/audio/writers/capability.py b/src/sampletones_core/audio/writers/capability.py
new file mode 100644
index 00000000..924a95a3
--- /dev/null
+++ b/src/sampletones_core/audio/writers/capability.py
@@ -0,0 +1,68 @@
+from dataclasses import dataclass
+from typing import Final, Mapping, Tuple
+
+from sampletones_core.constants.audio import SAMPLE_RATES
+from sampletones_shared.paths.extensions import EXT_FILE_MP3, EXT_FILE_WAVE
+
+from .bitrate import MP3_SAMPLE_RATES
+from .format import AUDIO_DEPTHS, AudioDepth, AudioFormat
+
+
+@dataclass(frozen=True)
+class FormatCapability:
+ """What one container holds, as the format itself defines it.
+
+ A chooser reads this to offer the settings a format accepts, and a specification is checked
+ against it before a file is opened, so a combination the encoder would reject is caught while
+ it is still a request.
+
+ Attributes:
+ extension: The suffix a file of this format carries.
+ sample_rates: The rates the format encodes, lowest first.
+ depths: The sample forms the format stores, coarsest first; empty where the format sets
+ its own and offers a bitrate instead.
+ """
+
+ extension: str
+ sample_rates: Tuple[int, ...]
+ depths: Tuple[AudioDepth, ...]
+
+ @property
+ def stores_samples(self) -> bool:
+ """Whether the format stores samples directly, which is what gives it a depth to choose."""
+ return bool(self.depths)
+
+ def supports_sample_rate(self, sample_rate: int) -> bool:
+ return sample_rate in self.sample_rates
+
+ def supports_depth(self, depth: AudioDepth) -> bool:
+ return depth in self.depths
+
+
+FORMAT_CAPABILITIES: Final[Mapping[AudioFormat, FormatCapability]] = {
+ AudioFormat.WAVE: FormatCapability(
+ extension=EXT_FILE_WAVE,
+ sample_rates=tuple(SAMPLE_RATES),
+ depths=AUDIO_DEPTHS,
+ ),
+ AudioFormat.MP3: FormatCapability(
+ extension=EXT_FILE_MP3,
+ sample_rates=MP3_SAMPLE_RATES,
+ depths=(),
+ ),
+}
+
+
+def capability_of(audio_format: AudioFormat) -> FormatCapability:
+ """What ``audio_format`` holds.
+
+ Args:
+ audio_format: The container to describe.
+
+ Returns:
+ FormatCapability: The settings that format accepts.
+
+ Raises:
+ KeyError: If the format has no entry in the registry.
+ """
+ return FORMAT_CAPABILITIES[audio_format]
diff --git a/src/sampletones_core/audio/writers/format.py b/src/sampletones_core/audio/writers/format.py
new file mode 100644
index 00000000..1ef86d98
--- /dev/null
+++ b/src/sampletones_core/audio/writers/format.py
@@ -0,0 +1,49 @@
+from enum import StrEnum
+from typing import Final, Mapping, Tuple
+
+
+class AudioFormat(StrEnum):
+ """The container a rendered song is written into."""
+
+ WAVE = "wave"
+ MP3 = "mp3"
+
+
+class AudioDepth(StrEnum):
+ """The form each sample takes in a file that stores samples directly.
+
+ The integer depths quantize the signal to a fixed number of steps, coarsest first; the float
+ depth stores the rendered value as it stands. Eight bits gives 256 steps across the range, the
+ grain a chip render is often chosen for.
+ """
+
+ PCM_U8 = "pcm_u8"
+ PCM_16 = "pcm_16"
+ PCM_24 = "pcm_24"
+ PCM_32 = "pcm_32"
+ FLOAT_32 = "float_32"
+
+ @property
+ def bits(self) -> int:
+ """The bits one stored sample occupies."""
+ return DEPTH_BITS[self]
+
+
+DEPTH_BITS: Final[Mapping[AudioDepth, int]] = {
+ AudioDepth.PCM_U8: 8,
+ AudioDepth.PCM_16: 16,
+ AudioDepth.PCM_24: 24,
+ AudioDepth.PCM_32: 32,
+ AudioDepth.FLOAT_32: 32,
+}
+
+AUDIO_DEPTHS: Final[Tuple[AudioDepth, ...]] = (
+ AudioDepth.PCM_U8,
+ AudioDepth.PCM_16,
+ AudioDepth.PCM_24,
+ AudioDepth.PCM_32,
+ AudioDepth.FLOAT_32,
+)
+
+DEFAULT_AUDIO_FORMAT: Final[AudioFormat] = AudioFormat.WAVE
+DEFAULT_AUDIO_DEPTH: Final[AudioDepth] = AudioDepth.PCM_16
diff --git a/src/sampletones_core/audio/writers/protocol.py b/src/sampletones_core/audio/writers/protocol.py
new file mode 100644
index 00000000..61200307
--- /dev/null
+++ b/src/sampletones_core/audio/writers/protocol.py
@@ -0,0 +1,30 @@
+from types import TracebackType
+from typing import Optional, Protocol, Self, Type
+
+import numpy as np
+
+
+class AudioWriter(Protocol):
+ """A file open for audio, taking it a chunk at a time for the length of a ``with`` block.
+
+ Writing incrementally is what lets a render of any length report its progress and answer a
+ cancel: the caller hands over each chunk as it is produced, and the whole song never has to
+ exist in memory at once. Leaving the block finalizes the file, whether the render finished or
+ stopped partway, so the destination is a complete file of whatever was written.
+ """
+
+ def __enter__(self) -> Self: ...
+
+ def __exit__(
+ self,
+ exception_type: Optional[Type[BaseException]],
+ exception: Optional[BaseException],
+ traceback: Optional[TracebackType],
+ ) -> None: ...
+
+ def write(self, chunk: np.ndarray) -> None:
+ """Appends one chunk of mono float32 audio to the file.
+
+ Args:
+ chunk: The samples to append, in the range [-1, 1].
+ """
diff --git a/src/sampletones_core/audio/writers/selection.py b/src/sampletones_core/audio/writers/selection.py
new file mode 100644
index 00000000..9713342f
--- /dev/null
+++ b/src/sampletones_core/audio/writers/selection.py
@@ -0,0 +1,80 @@
+from pathlib import Path
+from typing import Mapping, Tuple
+
+import soundfile
+
+from sampletones_shared.exceptions import UnsupportedAudioFormatError
+
+from .capability import capability_of
+from .format import AudioDepth, AudioFormat
+from .protocol import AudioWriter
+from .soundfile import CONTAINERS, FIXED_SUBTYPES, SUBTYPES, SoundFileAudioWriter
+from .spec import AudioOutputSpec
+
+
+def available_audio_formats() -> Tuple[AudioFormat, ...]:
+ """The formats this installation writes, in the order a chooser offers them.
+
+ libsndfile is built with a codec set that varies by platform and packaging, and the MP3 encoder
+ in particular is present only where it was compiled in. Asking the library what it holds keeps
+ a chooser honest about the machine it is running on.
+
+ Returns:
+ Tuple[AudioFormat, ...]: The formats that can be written here.
+ """
+ containers = soundfile.available_formats()
+ return tuple(audio_format for audio_format in AudioFormat if _is_writable(audio_format, containers))
+
+
+def available_depths(audio_format: AudioFormat) -> Tuple[AudioDepth, ...]:
+ """The depths this installation stores ``audio_format`` samples at, coarsest first.
+
+ Args:
+ audio_format: The container to describe.
+
+ Returns:
+ Tuple[AudioDepth, ...]: The depths the format declares that the encoder also writes; empty
+ for a format that sets its own and offers a bitrate instead.
+ """
+ container = CONTAINERS[audio_format]
+ return tuple(
+ depth
+ for depth in capability_of(audio_format).depths
+ if soundfile.check_format(
+ container,
+ SUBTYPES[depth],
+ )
+ )
+
+
+def open_audio_writer(path: Path, spec: AudioOutputSpec) -> AudioWriter:
+ """Opens a writer for ``path`` in the format ``spec`` states.
+
+ The writer is a context manager: entering it opens the file and leaving it finalizes what was
+ written.
+
+ Args:
+ path: Where the file is written.
+ spec: The format, rate, and quality it is written at.
+
+ Returns:
+ AudioWriter: A writer ready to be entered.
+
+ Raises:
+ UnsupportedAudioFormatError: If this installation does not write the requested format.
+ """
+ if spec.audio_format not in available_audio_formats():
+ raise UnsupportedAudioFormatError(f"This installation does not write {spec.audio_format} files")
+
+ return SoundFileAudioWriter(path, spec)
+
+
+def _is_writable(audio_format: AudioFormat, containers: Mapping[str, str]) -> bool:
+ container = CONTAINERS[audio_format]
+ if container not in containers:
+ return False
+
+ if capability_of(audio_format).stores_samples:
+ return bool(available_depths(audio_format))
+
+ return bool(soundfile.check_format(container, FIXED_SUBTYPES[audio_format]))
diff --git a/src/sampletones_core/audio/writers/soundfile.py b/src/sampletones_core/audio/writers/soundfile.py
new file mode 100644
index 00000000..b1e0d633
--- /dev/null
+++ b/src/sampletones_core/audio/writers/soundfile.py
@@ -0,0 +1,116 @@
+from pathlib import Path
+from types import TracebackType
+from typing import Any, Dict, Final, Mapping, Optional, Self, Type
+
+import numpy as np
+import soundfile
+
+from sampletones_shared.exceptions import AudioWriteError
+
+from .bitrate import mp3_compression_level
+from .format import AudioDepth, AudioFormat
+from .spec import AudioOutputSpec, Mp3OutputSpec, WaveOutputSpec
+
+CONTAINERS: Final[Mapping[AudioFormat, str]] = {
+ AudioFormat.WAVE: "WAV",
+ AudioFormat.MP3: "MP3",
+}
+
+SUBTYPES: Final[Mapping[AudioDepth, str]] = {
+ AudioDepth.PCM_U8: "PCM_U8",
+ AudioDepth.PCM_16: "PCM_16",
+ AudioDepth.PCM_24: "PCM_24",
+ AudioDepth.PCM_32: "PCM_32",
+ AudioDepth.FLOAT_32: "FLOAT",
+}
+
+MP3_SUBTYPE: Final[str] = "MPEG_LAYER_III"
+
+FIXED_SUBTYPES: Final[Mapping[AudioFormat, str]] = {
+ AudioFormat.MP3: MP3_SUBTYPE,
+}
+
+CONSTANT_BITRATE_MODE: Final[str] = "CONSTANT"
+WRITE_MODE: Final[str] = "w"
+CHANNELS: Final[int] = 1
+
+
+def encoding_arguments(spec: AudioOutputSpec) -> Dict[str, Any]:
+ """The libsndfile settings that write ``spec``.
+
+ This is where the encoder's vocabulary is spoken: eight-bit WAV is unsigned where the deeper
+ integer forms are signed, the float form is named for its width alone, and MP3 takes its
+ quality as a compression level rather than a bitrate.
+
+ Args:
+ spec: The format, rate, and quality the file is written at.
+
+ Returns:
+ Dict[str, Any]: Keyword arguments for opening a ``soundfile.SoundFile`` for writing.
+ """
+ match spec:
+ case WaveOutputSpec(depth=depth):
+ return {
+ "format": CONTAINERS[AudioFormat.WAVE],
+ "subtype": SUBTYPES[depth],
+ }
+ case Mp3OutputSpec(sample_rate=sample_rate, bitrate=bitrate):
+ return {
+ "format": CONTAINERS[AudioFormat.MP3],
+ "subtype": MP3_SUBTYPE,
+ "bitrate_mode": CONSTANT_BITRATE_MODE,
+ "compression_level": mp3_compression_level(sample_rate, bitrate),
+ }
+
+
+class SoundFileAudioWriter:
+ """Writes rendered audio to a file through libsndfile.
+
+ Holds the file open for the length of a ``with`` block and appends each chunk as it arrives,
+ so a render streams to disk while it is being produced.
+
+ Attributes:
+ path: Where the file is written.
+ spec: The format, rate, and quality it is written at.
+ """
+
+ def __init__(self, path: Path, spec: AudioOutputSpec) -> None:
+ self.path = path
+ self.spec = spec
+ self._file: Optional[soundfile.SoundFile] = None
+
+ def __enter__(self) -> Self:
+ self._file = soundfile.SoundFile(
+ self.path,
+ mode=WRITE_MODE,
+ samplerate=self.spec.sample_rate,
+ channels=CHANNELS,
+ **encoding_arguments(self.spec),
+ )
+ return self
+
+ def __exit__(
+ self,
+ exception_type: Optional[Type[BaseException]],
+ exception: Optional[BaseException],
+ traceback: Optional[TracebackType],
+ ) -> None:
+ opened, self._file = self._file, None
+ if opened is not None:
+ opened.close()
+
+ def write(self, chunk: np.ndarray) -> None:
+ """Appends one chunk of mono float32 audio to the file.
+
+ Args:
+ chunk: The samples to append, in the range [-1, 1]. Values outside it are held at the
+ range's edge by the integer depths and kept as they stand by the float depth.
+
+ Raises:
+ AudioWriteError: If the file is not open, which is to say the call is outside the
+ ``with`` block that owns it.
+ """
+ if self._file is None:
+ raise AudioWriteError(f"No file open at '{self.path}'; write within the writer's context")
+
+ self._file.write(chunk)
diff --git a/src/sampletones_core/audio/writers/spec.py b/src/sampletones_core/audio/writers/spec.py
new file mode 100644
index 00000000..574ea35a
--- /dev/null
+++ b/src/sampletones_core/audio/writers/spec.py
@@ -0,0 +1,85 @@
+from typing import Literal, Self, Union
+
+from pydantic import BaseModel, ConfigDict, Field, model_validator
+
+from sampletones_core.constants.audio import MAX_SAMPLE_RATE, MIN_SAMPLE_RATE
+
+from .bitrate import default_mp3_bitrate, mp3_bitrates
+from .capability import FormatCapability, capability_of
+from .format import DEFAULT_AUDIO_DEPTH, AudioDepth, AudioFormat
+
+
+class AudioOutputSpecBase(BaseModel):
+ """What every request to write audio states, whatever the container.
+
+ The rate is checked against the format's capability on construction, so a specification that
+ exists is one the encoder accepts.
+ """
+
+ model_config = ConfigDict(extra="forbid", frozen=True)
+
+ audio_format: AudioFormat = Field(..., description="The container the audio is written into.")
+ sample_rate: int = Field(
+ ...,
+ ge=MIN_SAMPLE_RATE,
+ le=MAX_SAMPLE_RATE,
+ description="The samples the written audio holds each second.",
+ )
+
+ @property
+ def capability(self) -> FormatCapability:
+ return capability_of(self.audio_format)
+
+ @property
+ def extension(self) -> str:
+ return self.capability.extension
+
+ @model_validator(mode="after")
+ def _validate_sample_rate(self) -> Self:
+ if not self.capability.supports_sample_rate(self.sample_rate):
+ raise ValueError(f"{self.audio_format} does not encode at {self.sample_rate} Hz")
+
+ return self
+
+
+class WaveOutputSpec(AudioOutputSpecBase):
+ """A WAV file, which stores each sample at a chosen depth."""
+
+ audio_format: Literal[AudioFormat.WAVE] = AudioFormat.WAVE
+ depth: AudioDepth = Field(
+ default=DEFAULT_AUDIO_DEPTH,
+ description="The form each stored sample takes.",
+ )
+
+ @model_validator(mode="after")
+ def _validate_depth(self) -> Self:
+ if not self.capability.supports_depth(self.depth):
+ raise ValueError(f"WAV does not store samples as {self.depth}")
+
+ return self
+
+
+class Mp3OutputSpec(AudioOutputSpecBase):
+ """An MP3 file, which encodes to a chosen bitrate rather than storing samples.
+
+ The bitrates on offer depend on the sample rate, since each MPEG audio version defines its own
+ ladder, so the pair is validated together.
+ """
+
+ audio_format: Literal[AudioFormat.MP3] = AudioFormat.MP3
+ bitrate: int = Field(..., description="The kilobits the encoded audio holds each second.")
+
+ @classmethod
+ def at(cls, sample_rate: int) -> Self:
+ """A specification at ``sample_rate`` and the bitrate a render starts at there."""
+ return cls(sample_rate=sample_rate, bitrate=default_mp3_bitrate(sample_rate))
+
+ @model_validator(mode="after")
+ def _validate_bitrate(self) -> Self:
+ if self.bitrate not in mp3_bitrates(self.sample_rate):
+ raise ValueError(f"MP3 at {self.sample_rate} Hz does not encode at {self.bitrate} kbps")
+
+ return self
+
+
+AudioOutputSpec = Union[WaveOutputSpec, Mp3OutputSpec]
diff --git a/src/sampletones_core/calibration/__init__.py b/src/sampletones_core/calibration/__init__.py
index e77b2537..e63aed34 100644
--- a/src/sampletones_core/calibration/__init__.py
+++ b/src/sampletones_core/calibration/__init__.py
@@ -8,24 +8,30 @@
from .referee.protocol import Referee
from .referee.zimtohrli import ZimtohrliReferee, find_zimtohrli
from .report import write_csv, write_markdown
-from .runner import CalibrationRow, CalibrationVariant, build_variants, ensure_library, evaluate_variants
+from .runner import (
+ CalibrationRow,
+ CalibrationVariant,
+ build_variants,
+ ensure_library,
+ evaluate_variants,
+)
__all__ = [
+ "CalibrationRow",
+ "CalibrationVariant",
"CorpusConfig",
- "RefereeConfig",
"CorpusItem",
- "build_corpus",
- "write_corpus",
- "Referee",
"MultiResolutionAuditoryReferee",
+ "Referee",
+ "RefereeConfig",
"ZimtohrliReferee",
+ "build_corpus",
"build_referees",
- "find_zimtohrli",
- "CalibrationVariant",
- "CalibrationRow",
"build_variants",
"ensure_library",
"evaluate_variants",
+ "find_zimtohrli",
+ "write_corpus",
"write_csv",
"write_markdown",
]
diff --git a/src/sampletones_core/calibration/corpus/writer.py b/src/sampletones_core/calibration/corpus/writer.py
index f7367a6b..43ecee39 100644
--- a/src/sampletones_core/calibration/corpus/writer.py
+++ b/src/sampletones_core/calibration/corpus/writer.py
@@ -2,7 +2,7 @@
from typing import Dict, List
from sampletones_core.audio.io import write_wave
-from sampletones_core.paths import EXT_FILE_WAVE
+from sampletones_shared.paths.extensions import EXT_FILE_WAVE
from sampletones_shared.utils.system.paths import get_filename
from .item import CorpusItem
diff --git a/src/sampletones_core/calibration/paths.py b/src/sampletones_core/calibration/paths.py
index 27aed45a..751bbb53 100644
--- a/src/sampletones_core/calibration/paths.py
+++ b/src/sampletones_core/calibration/paths.py
@@ -1,7 +1,7 @@
from pathlib import Path
from typing import Final
-from sampletones_shared.paths import CONFIG_DIRECTORY
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
CALIBRATION_CONFIG_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "calibration"
REFEREE_CONFIG_PATH: Final[Path] = CALIBRATION_CONFIG_DIRECTORY / "referee.yaml"
diff --git a/src/sampletones_core/calibration/runner.py b/src/sampletones_core/calibration/runner.py
index b8c1d12d..a4036c9a 100644
--- a/src/sampletones_core/calibration/runner.py
+++ b/src/sampletones_core/calibration/runner.py
@@ -5,6 +5,7 @@
import numpy as np
from sampletones_core.configs import Config
+from sampletones_core.constants.enums import SpectrumMethod
from sampletones_core.fft import Window
from sampletones_core.library import InstructionLibrary
from sampletones_core.reconstructions import Reconstructor
@@ -32,7 +33,7 @@ class CalibrationRow:
def build_variants(
base: Config,
- methods: List[str],
+ methods: List[SpectrumMethod],
perceptual_exponents: List[float],
temporal_weights: List[float],
) -> List[CalibrationVariant]:
@@ -59,7 +60,7 @@ def build_variants(
for method in methods:
for exponent in perceptual_exponents:
for temporal_weight in swept_temporal:
- label = f"{method}-pe{exponent:g}"
+ label = f"{method.value}-pe{exponent:g}"
generation = base.generation.model_copy(
update={
"metric": base.generation.metric.model_copy(update={"perceptual_exponent": exponent}),
@@ -84,7 +85,12 @@ def build_variants(
"generation": generation,
}
)
- variants.append(CalibrationVariant(label=label, config=config))
+ variants.append(
+ CalibrationVariant(
+ label=label,
+ config=config,
+ )
+ )
return variants
diff --git a/src/sampletones_core/configs/__init__.py b/src/sampletones_core/configs/__init__.py
index ae48f9e3..275c68c7 100644
--- a/src/sampletones_core/configs/__init__.py
+++ b/src/sampletones_core/configs/__init__.py
@@ -10,12 +10,12 @@
from .library import InstructionsLibraryConfig
__all__ = [
+ "CalculationConfig",
"Config",
+ "DecoderConfig",
"GeneralConfig",
"GenerationConfig",
"InstructionsLibraryConfig",
- "CalculationConfig",
- "WeightsConfig",
"MetricConfig",
- "DecoderConfig",
+ "WeightsConfig",
]
diff --git a/src/sampletones_core/configs/config.py b/src/sampletones_core/configs/config.py
index e1a1c8f3..2dafc796 100644
--- a/src/sampletones_core/configs/config.py
+++ b/src/sampletones_core/configs/config.py
@@ -1,14 +1,14 @@
from __future__ import annotations
from pathlib import Path
-from typing import List, Self
+from typing import Dict, List, Optional, Self
from pydantic import ConfigDict, Field
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.data import DataModel
from sampletones_core.data.metadata import Metadata
-from sampletones_core.paths import CONFIG_PATH
+from sampletones_shared.paths.user import CONFIG_PATH
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.serialization import load_json, save_json
from sampletones_shared.utils.system.paths import to_path
@@ -63,6 +63,37 @@ def save(self, path: Pathlike) -> None:
config_dict = self.model_dump()
save_json(path, config_dict)
+ def with_library(
+ self,
+ *,
+ nes_frequency: Optional[int] = None,
+ sample_rate: Optional[int] = None,
+ ) -> Self:
+ """A copy running at the given engine and audio rates, keeping every other setting.
+
+ The rates a generator is built with decide how many samples one engine tick spans, so a
+ caller driving the engine at rates of its own — a render at a chosen output rate, a
+ reconstruction retuned to a project's frequency — asks for a configuration here rather
+ than editing the one it was handed.
+
+ Args:
+ nes_frequency: The engine ticks consumed each second, or ``None`` to keep the current
+ value.
+ sample_rate: The samples the audio holds each second, or ``None`` to keep the current
+ value.
+
+ Returns:
+ Self: The configuration at those rates.
+ """
+ updates: Dict[str, int] = {}
+ if nes_frequency is not None:
+ updates["nes_frequency"] = nes_frequency
+
+ if sample_rate is not None:
+ updates["sample_rate"] = sample_rate
+
+ return self.model_copy(update={"library": self.library.model_copy(update=updates)})
+
@property
def max_workers(self) -> int:
return self.general.max_workers
diff --git a/src/sampletones_core/configs/display.py b/src/sampletones_core/configs/display.py
index 6d3d00ed..04a1750d 100644
--- a/src/sampletones_core/configs/display.py
+++ b/src/sampletones_core/configs/display.py
@@ -1,9 +1,12 @@
-from typing import Dict, Final
+from collections import Counter
+from typing import Dict, Final, Sequence, Tuple
-from sampletones_core.constants.enums import SpectrumMethod
+from sampletones_core.constants.enums import GeneratorName, SpectrumMethod
+from sampletones_shared.constants.symbols import HASH
DISPLAY_SEPARATOR: Final[str] = "·"
GAMMA_PREFIX: Final[str] = "γ"
+GENERATOR_SEPARATOR: Final[str] = ", "
DISPLAY_HASH_LENGTH: Final[int] = 7
HERTZ_UNIT: Final[str] = "Hz"
@@ -37,5 +40,57 @@ def format_spectrum_method(method: SpectrumMethod) -> str:
return SPECTRUM_METHOD_LABELS[method]
+def format_transformation_gamma(transformation_gamma: int) -> str:
+ """Marks a transformation gamma with ``γ`` (e.g. ``γ0``)."""
+ return f"{GAMMA_PREFIX}{transformation_gamma}"
+
+
+def format_generators(generators: Sequence[GeneratorName]) -> str:
+ """Renders the generators a reconstruction was built with, in the order it names them (e.g. ``Pulse 1, Noise``)."""
+ return GENERATOR_SEPARATOR.join(generator.capitalized for generator in generators)
+
+
+def format_frequencies(sample_rate: int, nes_frequency: int) -> str:
+ """Renders the rates a reconstruction runs at, audio before frame (e.g. ``44.1 kHz·30 Hz``)."""
+ return DISPLAY_SEPARATOR.join(
+ [
+ format_sample_rate(sample_rate),
+ format_nes_frequency(nes_frequency),
+ ],
+ )
+
+
+def format_transformation(
+ spectrum_method: SpectrumMethod,
+ transformation_gamma: int,
+) -> str:
+ """Renders the spectrum a library was built from, method before gamma (e.g. ``FFT·γ0``)."""
+ return DISPLAY_SEPARATOR.join(
+ [
+ format_spectrum_method(spectrum_method),
+ format_transformation_gamma(transformation_gamma),
+ ],
+ )
+
+
def short_hash(config_hash: str) -> str:
return config_hash[:DISPLAY_HASH_LENGTH]
+
+
+def disambiguated_display_name(name: str, config_hash: str) -> str:
+ """Appends the short config hash, marked with ``#``, so colliding names stay distinct."""
+ return f"{name}{DISPLAY_SEPARATOR}{HASH}{short_hash(config_hash)}"
+
+
+def unique_display_names(entries: Sequence[Tuple[str, str]]) -> Tuple[str, ...]:
+ """Answers labels that tell one group of siblings apart, given ``(name, config hash)`` pairs.
+
+ A name held by a single entry stands as it is. A name shared by several entries takes the short
+ config hash on every one of them, so each sibling states the configuration that distinguishes
+ it. The answer is index-aligned with ``entries``.
+ """
+ occurrences = Counter(name for name, _ in entries)
+ return tuple(
+ name if occurrences[name] == 1 else disambiguated_display_name(name, config_hash)
+ for name, config_hash in entries
+ )
diff --git a/src/sampletones_core/configs/general.py b/src/sampletones_core/configs/general.py
index 210696a6..b95c8a5f 100644
--- a/src/sampletones_core/configs/general.py
+++ b/src/sampletones_core/configs/general.py
@@ -10,7 +10,7 @@
)
from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH
from sampletones_core.data import DataModel
-from sampletones_core.paths import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY
+from sampletones_shared.paths.user import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY
class GeneralConfig(DataModel):
diff --git a/src/sampletones_core/configs/library.py b/src/sampletones_core/configs/library.py
index 785c7b02..0cdfa86f 100644
--- a/src/sampletones_core/configs/library.py
+++ b/src/sampletones_core/configs/library.py
@@ -11,14 +11,16 @@
from sampletones_core.constants.general import (
A4_FREQUENCY,
A4_PITCH,
- DEFAULT_NES_FREQUENCY,
LIMIT_MAX_PITCH,
- MAX_NES_FREQUENCY,
MIN_FREQUENCY,
- MIN_NES_FREQUENCY,
)
from sampletones_core.constants.spectrum import BINS_PER_OCTAVE, CQT_CUTOFF_FREQUENCY
from sampletones_core.data import DataModel
+from sampletones_shared.constants.nes import (
+ DEFAULT_NES_FREQUENCY,
+ MAX_NES_FREQUENCY,
+ MIN_NES_FREQUENCY,
+)
class InstructionsLibraryConfig(DataModel):
@@ -28,7 +30,7 @@ class InstructionsLibraryConfig(DataModel):
default=DEFAULT_NES_FREQUENCY,
ge=MIN_NES_FREQUENCY,
le=MAX_NES_FREQUENCY,
- description="Instruction change rate in Hz; the default equals half of the NTSC frame rate.",
+ description="Instruction change rate in Hz; the default is the NTSC frame rate.",
validation_alias=AliasChoices(
"change_rate",
"nes_frequency",
diff --git a/src/sampletones_core/constants/general.py b/src/sampletones_core/constants/general.py
index a1396a88..aca1463b 100644
--- a/src/sampletones_core/constants/general.py
+++ b/src/sampletones_core/constants/general.py
@@ -1,11 +1,5 @@
from typing import Final, Tuple
-# NES limits
-
-DEFAULT_NES_FREQUENCY: Final[int] = 30
-MIN_NES_FREQUENCY: Final[int] = 15
-MAX_NES_FREQUENCY: Final[int] = 300
-
# Pitches and frequencies
APU_CLOCK: Final[float] = 1789773.0
@@ -42,9 +36,10 @@
# Instruction parameters ranges
+SILENT_VOLUME: Final[int] = 0
MIN_VOLUME: Final[int] = 1
MAX_VOLUME: Final[int] = 15
-VOLUME_RANGE: Final[range] = range(0, MAX_VOLUME + 1)
+VOLUME_RANGE: Final[range] = range(MAX_VOLUME + 1)
MAX_DUTY_CYCLE: Final[int] = 3
# Channel-specific constants
diff --git a/src/sampletones_core/data/__init__.py b/src/sampletones_core/data/__init__.py
index 76234b97..ccdf63d1 100644
--- a/src/sampletones_core/data/__init__.py
+++ b/src/sampletones_core/data/__init__.py
@@ -1,7 +1,8 @@
-from .metadata import Metadata
+from .metadata import Metadata, MetadataContract
from .model import DataModel
__all__ = [
"DataModel",
"Metadata",
+ "MetadataContract",
]
diff --git a/src/sampletones_core/data/metadata.py b/src/sampletones_core/data/metadata.py
index 1208f209..aa8da0c0 100644
--- a/src/sampletones_core/data/metadata.py
+++ b/src/sampletones_core/data/metadata.py
@@ -1,6 +1,7 @@
from __future__ import annotations
-from typing import Self
+from dataclasses import dataclass
+from typing import Self, Type
from pydantic import ConfigDict, Field
@@ -10,6 +11,9 @@
SAMPLETONES_RECONSTRUCTION_DATA_VERSION,
SAMPLETONES_VERSION,
)
+from sampletones_shared.deployment.version import compare_versions
+from sampletones_shared.exceptions import InvalidMetadataError
+from sampletones_shared.exceptions.version import IncompatibleVersionError
from .model import DataModel
@@ -25,3 +29,41 @@ class Metadata(DataModel):
@classmethod
def default(cls) -> Self:
return cls()
+
+
+@dataclass(frozen=True)
+class MetadataContract:
+ """The terms a stored file is read under: the data version a build accepts, and what refusing one means.
+
+ A format states its contract once and holds every file it opens against it, so a file written
+ by another application or at another data version is refused with an error naming the format
+ that refused it.
+ """
+
+ label: str
+ expected_version: str
+ error: Type[IncompatibleVersionError]
+
+ def validate(self, metadata: Metadata, actual_version: str) -> None:
+ """Holds what a file states about itself against the build reading it.
+
+ Args:
+ metadata: What the file states about its writer.
+ actual_version: The data version the file was written at.
+
+ Raises:
+ InvalidMetadataError: If the metadata names an application other than SampleToNES.
+ IncompatibleVersionError: Of this contract's type, if the file's version departs from
+ the one this build accepts.
+ """
+ if metadata.application_name != SAMPLETONES_NAME:
+ raise InvalidMetadataError(
+ f"Metadata application name mismatch: expected {SAMPLETONES_NAME}, got {metadata.application_name}."
+ )
+
+ if compare_versions(actual_version, self.expected_version) != 0:
+ raise self.error(
+ f"{self.label} version mismatch: expected {self.expected_version}, got {actual_version}.",
+ expected_version=self.expected_version,
+ actual_version=actual_version,
+ )
diff --git a/src/sampletones_core/data/model.py b/src/sampletones_core/data/model.py
index 50112c9c..82ca2711 100644
--- a/src/sampletones_core/data/model.py
+++ b/src/sampletones_core/data/model.py
@@ -139,13 +139,13 @@ def _pack_value(self, value: Any, annotation: Any, field_name: str) -> Any:
return self._pack_value(value, optional_inner, field_name)
- return self._pack_union(value, field_name)
+ return self._pack_union(value)
if isinstance(annotation, TypeVar):
- return self._pack_union(value, field_name)
+ return self._pack_union(value)
if get_origin(annotation) is list:
- return self._pack_list(value, annotation, field_name)
+ return self._pack_list(value, field_name)
if issubclass(annotation, DataModel):
return value.serialize_inner()
@@ -182,16 +182,28 @@ def _unpack_value(
if raw is None:
return None
- return cls._unpack_value(raw, optional_inner, field_name, validation, fast)
+ return cls._unpack_value(
+ raw,
+ optional_inner,
+ field_name,
+ validation,
+ fast,
+ )
- return cls._unpack_union(raw, field_name)
+ return cls._unpack_union(raw)
if isinstance(annotation, TypeVar):
- return cls._unpack_union(raw, field_name)
+ return cls._unpack_union(raw)
if get_origin(annotation) is list:
list_class = get_args(annotation)[0]
- return cls._unpack_list(raw, field_name, list_class, validation, fast)
+ return cls._unpack_list(
+ raw,
+ field_name,
+ list_class,
+ validation,
+ fast,
+ )
if issubclass(annotation, DataModel):
return annotation.deserialize_inner(raw, validation, fast=fast)
@@ -207,7 +219,11 @@ def _unpack_value(
raise DeserializationError(f"Unsupported field type {annotation} for field '{field_name}'")
- def _pack_list(self, collection: List[Any], annotation: Any, field_name: str) -> List[Any]:
+ def _pack_list(
+ self,
+ collection: List[Any],
+ field_name: str,
+ ) -> List[Any]:
if not collection:
return []
@@ -238,7 +254,14 @@ def _unpack_list(
return []
if issubclass(element_class, DataModel):
- return [element_class.deserialize_inner(item, validation, fast=fast) for item in raw_list]
+ return [
+ element_class.deserialize_inner(
+ item,
+ validation,
+ fast=fast,
+ )
+ for item in raw_list
+ ]
if issubclass(element_class, (str, StrEnum)):
return [cls._deserialize_string(item, element_class) for item in raw_list]
@@ -277,7 +300,11 @@ def _unpack_array(cls, raw: bytes, field_name: str) -> np.ndarray:
return array
@classmethod
- def _deserialize_string(cls, raw: Union[str, bytes], string_class: type) -> Union[str, StrEnum]:
+ def _deserialize_string(
+ cls,
+ raw: Union[str, bytes],
+ string_class: Type[Union[str, bytes]],
+ ) -> Union[str, StrEnum]:
if isinstance(raw, bytes):
try:
string = raw.decode("utf-8")
@@ -291,7 +318,7 @@ def _deserialize_string(cls, raw: Union[str, bytes], string_class: type) -> Unio
return string
- def _pack_union(self, value: Any, field_name: str) -> SerializedData:
+ def _pack_union(self, value: Any) -> SerializedData:
union_map: Optional[Dict[int, Type[DataModel]]] = self.__class__.union_map()
if union_map is None:
raise SerializationError(f"No union map defined for {self.__class__.__name__}")
@@ -308,7 +335,7 @@ def _pack_union(self, value: Any, field_name: str) -> SerializedData:
return {"_type": tag, "_data": value.serialize_inner()}
@classmethod
- def _unpack_union(cls, raw: SerializedData, field_name: str) -> Any:
+ def _unpack_union(cls, raw: SerializedData) -> Any:
union_map = cls.union_map()
if union_map is None:
raise DeserializationError(f"No union map defined for {cls.__name__}")
diff --git a/src/sampletones_core/exporters/__init__.py b/src/sampletones_core/exporters/__init__.py
index 4f629747..f8a841f0 100644
--- a/src/sampletones_core/exporters/__init__.py
+++ b/src/sampletones_core/exporters/__init__.py
@@ -7,15 +7,15 @@
from .types import ExporterClass, ExporterT, ExporterTypeUnion, ExporterUnion
__all__ = [
- "Exporter",
- "PulseExporter",
- "TriangleExporter",
- "NoiseExporter",
- "INSTRUCTION_TO_EXPORTER_MAP",
"GENERATOR_NAME_TO_EXPORTER_MAP",
- "ExporterT",
+ "INSTRUCTION_TO_EXPORTER_MAP",
+ "Exporter",
"ExporterClass",
- "ExporterUnion",
+ "ExporterT",
"ExporterTypeUnion",
+ "ExporterUnion",
"Features",
+ "NoiseExporter",
+ "PulseExporter",
+ "TriangleExporter",
]
diff --git a/src/sampletones_core/exporters/exporter.py b/src/sampletones_core/exporters/exporter.py
index 3999e668..154e0d61 100644
--- a/src/sampletones_core/exporters/exporter.py
+++ b/src/sampletones_core/exporters/exporter.py
@@ -1,9 +1,10 @@
from abc import ABC, abstractmethod
-from typing import Dict, Final, Generic, List, Optional, Union, cast
+from typing import ClassVar, Dict, Generic, Iterable, List, Optional, Union, cast
import numpy as np
from sampletones_core.constants.enums import FeatureKey
+from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS
from sampletones_core.generators import GeneratorTypeUnion
from sampletones_core.instructions import (
InstructionFields,
@@ -15,8 +16,6 @@
from .feature import Features
-EMPTY_ENVELOPE_VALUE: Final[int] = 0
-
class Exporter(ABC, Generic[InstructionT]):
"""
@@ -32,24 +31,32 @@ class Exporter(ABC, Generic[InstructionT]):
the reverse.
"""
- _ATTRIBUTE_MAP: Dict[FeatureKey, InstructionFields]
+ _ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]]
def to_features(
self,
instructions: List[InstructionT],
initial_pitch: int,
+ held_features: Iterable[FeatureKey],
) -> Features:
"""Converts an instruction sequence into its :class:`Features`.
+ An instruction states every dimension of its frame, so the dimensions the instrument
+ leaves to the channel are named alongside the sequence and come back with empty
+ envelopes: what the frames carry for them is the value the channel held.
+
Args:
instructions: The channel's per-frame instructions.
initial_pitch: Reference pitch the arpeggio envelope is measured against.
+ held_features: The dimensions the channel governs.
Returns:
Features: The envelope representation of the sequence.
"""
feature_map = self.get_feature_map(instructions, initial_pitch)
- return self.from_feature_map_to_features(feature_map)
+ features = self.from_feature_map_to_features(feature_map)
+ features.leave_to_channel(held_features)
+ return features
@staticmethod
def from_feature_map_to_features(feature_map: FeatureMap) -> Features:
@@ -115,7 +122,10 @@ def from_features(cls, features: Features) -> List[InstructionT]:
Walks the envelopes frame by frame and assembles one instruction per frame. Every
envelope is read relative to itself — a dimension trimmed shorter than the sequence
holds its own final value over the remaining frames — so the arpeggio stays an
- offset from ``initial_pitch`` for the whole sequence.
+ offset from ``initial_pitch`` for the whole sequence. A dimension the instrument
+ leaves to the channel carries no item, and every frame states the value a channel
+ holds for it from the start of a song, which is what the sequence sounds like played
+ on its own.
Args:
features: The envelope representation of a channel.
@@ -139,12 +149,76 @@ def from_features(cls, features: Features) -> List[InstructionT]:
if not attribute:
continue
- instruction_dictionary[attribute] = int(hold(array, index, default=EMPTY_ENVELOPE_VALUE))
+ instruction_dictionary[attribute] = int(
+ hold(
+ array,
+ index,
+ default=CHANNEL_FEATURE_DEFAULTS[key],
+ )
+ )
instructions.append(cls._features_dictionary_to_instruction(instruction_dictionary, initial_pitch))
return instructions
+ @classmethod
+ def feature_values(
+ cls,
+ instruction: InstructionT,
+ initial_pitch: int,
+ ) -> Dict[FeatureKey, int]:
+ """The envelope values one frame states.
+
+ A frame that sounds states every dimension the channel reads, each in the terms its
+ envelope is written in. A silent frame states its level alone, leaving the rest to the
+ channel, which is how a sequence holds its pitch and timbre across a rest.
+
+ Reading the frame as a sequence of one is what keeps this the same reading `to_features`
+ gives it, so a frame played in a song carries the values its envelopes show.
+
+ Args:
+ instruction: The frame to read.
+ initial_pitch: Reference pitch the arpeggio value is measured against.
+
+ Returns:
+ Dict[FeatureKey, int]: The value the frame states for each dimension it names.
+ """
+ if not instruction.on:
+ return {FeatureKey.VOLUME: 0}
+
+ feature_map = cls.get_feature_map([instruction], initial_pitch)
+ return {
+ key: int(value[0]) for key, value in feature_map.items() if isinstance(value, np.ndarray) and value.size
+ }
+
+ @classmethod
+ def instruction_from_values(
+ cls,
+ values: Dict[FeatureKey, int],
+ initial_pitch: int,
+ ) -> InstructionT:
+ """The frame a row of envelope values describes.
+
+ This is the single-frame form of `from_features`: values arrive in envelope terms and
+ come back as the instruction a generator sounds, with the arpeggio measured against
+ ``initial_pitch``. Dimensions this channel reads nothing from are passed over, so one
+ set of values serves every channel.
+
+ Args:
+ values: The value each dimension carries for one frame.
+ initial_pitch: Reference pitch the arpeggio value is measured against.
+
+ Returns:
+ InstructionT: The frame those values describe.
+ """
+ dictionary: Dict[str, Union[bool, int]] = {}
+ for key, value in values.items():
+ attribute = cls._remap_feature_key(key)
+ if attribute is not None:
+ dictionary[attribute] = value
+
+ return cls._features_dictionary_to_instruction(dictionary, initial_pitch)
+
@classmethod
@abstractmethod
def _features_dictionary_to_instruction(
diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py
index 33634cce..54f1bba4 100644
--- a/src/sampletones_core/exporters/feature.py
+++ b/src/sampletones_core/exporters/feature.py
@@ -1,6 +1,6 @@
from __future__ import annotations
-from typing import Any, Dict, List, Optional, Tuple, cast
+from typing import Any, Dict, Iterable, List, Optional, Tuple, cast
import numpy as np
from pydantic import BaseModel, ConfigDict
@@ -15,9 +15,11 @@ class Features(BaseModel):
Each field is the frame-by-frame envelope for one dimension — volume, arpeggio,
pitch, hi-pitch, and duty cycle — alongside the ``initial_pitch`` the arpeggio
- envelope is relative to. An optional dimension is absent when the channel does not
- use it. The mapping interface (subscript, ``get``, ``keys``/``items``/``values``,
- ``in``) exposes the envelopes keyed by :class:`FeatureKey`, passing over absent ones.
+ envelope is relative to. A dimension the channel offers is an array, ``None`` for
+ one it lacks; an array of no items marks a dimension the instrument leaves to the
+ channel, which keeps the value it holds. The mapping interface (subscript, ``get``,
+ ``keys``/``items``/``values``, ``in``) exposes the envelopes keyed by
+ :class:`FeatureKey`, listing the dimensions the channel offers.
Attributes:
initial_pitch: Reference pitch the arpeggio envelope is measured against.
@@ -106,3 +108,36 @@ def frame_count(self) -> int:
"""The frame count the envelopes describe, taken from the longest populated dimension."""
arrays = (self.volume, self.arpeggio, self.pitch, self.hi_pitch, self.duty_cycle)
return max((len(array) for array in arrays if array is not None), default=0)
+
+ @property
+ def has_frames(self) -> bool:
+ """Whether the envelopes describe a frame, which is what a channel plays.
+
+ Every dimension left to the channel leaves an instrument describing nothing, so this
+ is what tells a channel that sounds from one that stands by: an export writes the
+ instruments that have frames, and the driver stores only those.
+ """
+ return self.frame_count > 0
+
+ @property
+ def held_features(self) -> Tuple[FeatureKey, ...]:
+ """The dimensions the channel governs, whose envelopes carry no item.
+
+ An instrument writes the dimensions it describes and leaves the rest to the channel,
+ which keeps the value it already holds for as long as the instrument sounds. These
+ are the dimensions it leaves, listed in the order the model declares them.
+ """
+ return tuple(key for key, value in self.items() if isinstance(value, np.ndarray) and value.size == 0)
+
+ def leave_to_channel(self, feature_keys: Iterable[FeatureKey]) -> None:
+ """Empties the envelope of each named dimension the channel offers, so the channel governs it.
+
+ The dimensions a channel offers are the ones it can hold a value for, so the record acts
+ on those and leaves the shape of the features as the channel defines it.
+
+ Args:
+ feature_keys: The dimensions the instrument leaves to the channel.
+ """
+ for feature_key in feature_keys:
+ if feature_key in self:
+ self[feature_key] = np.array([], dtype=np.int8)
diff --git a/src/sampletones_core/exporters/implementation/noise.py b/src/sampletones_core/exporters/implementation/noise.py
index 5fe7867a..6beec153 100644
--- a/src/sampletones_core/exporters/implementation/noise.py
+++ b/src/sampletones_core/exporters/implementation/noise.py
@@ -1,4 +1,4 @@
-from typing import Dict, List, Tuple, Union
+from typing import ClassVar, Dict, List, Tuple, Union
import numpy as np
@@ -16,7 +16,7 @@
class NoiseExporter(Exporter[NoiseInstruction]):
- _ATTRIBUTE_MAP: Dict[FeatureKey, InstructionFields] = {
+ _ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]] = {
FeatureKey.VOLUME: "volume",
FeatureKey.ARPEGGIO: "period",
FeatureKey.DUTY_CYCLE: "short",
diff --git a/src/sampletones_core/exporters/implementation/pulse.py b/src/sampletones_core/exporters/implementation/pulse.py
index 00a4c99c..98781026 100644
--- a/src/sampletones_core/exporters/implementation/pulse.py
+++ b/src/sampletones_core/exporters/implementation/pulse.py
@@ -1,4 +1,4 @@
-from typing import Dict, List, Tuple, Union
+from typing import ClassVar, Dict, List, Tuple, Union
import numpy as np
@@ -18,7 +18,7 @@
class PulseExporter(Exporter[PulseInstruction]):
- _ATTRIBUTE_MAP: Dict[FeatureKey, InstructionFields] = {
+ _ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]] = {
FeatureKey.VOLUME: "volume",
FeatureKey.ARPEGGIO: "pitch",
FeatureKey.DUTY_CYCLE: "duty_cycle",
diff --git a/src/sampletones_core/exporters/implementation/triangle.py b/src/sampletones_core/exporters/implementation/triangle.py
index 4f69ab58..1c7eb7d4 100644
--- a/src/sampletones_core/exporters/implementation/triangle.py
+++ b/src/sampletones_core/exporters/implementation/triangle.py
@@ -1,4 +1,4 @@
-from typing import Dict, List, Tuple, Union
+from typing import ClassVar, Dict, List, Tuple, Union
import numpy as np
@@ -18,7 +18,7 @@
class TriangleExporter(Exporter[TriangleInstruction]):
- _ATTRIBUTE_MAP: Dict[FeatureKey, InstructionFields] = {
+ _ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]] = {
FeatureKey.VOLUME: "volume",
FeatureKey.ARPEGGIO: "pitch",
}
diff --git a/src/sampletones_core/exporters/lengths.py b/src/sampletones_core/exporters/lengths.py
index 64f6144d..fc5ff151 100644
--- a/src/sampletones_core/exporters/lengths.py
+++ b/src/sampletones_core/exporters/lengths.py
@@ -11,6 +11,15 @@ def _resize(items: Tuple[int, ...], length: int) -> Tuple[int, ...]:
return items[:length] + items[-1:] * (length - len(items))
+def _limited_length(length: int, limit: Optional[int]) -> int:
+ """Brings a length within what the target format stores, reporting what that drops."""
+ if limit is None or length <= limit:
+ return length
+
+ logger.warning(f"Instrument envelope of {length} items keeps its first {limit}, the most the format holds")
+ return limit
+
+
def _common_length(lengths: List[int], loop: bool, limit: Optional[int]) -> int:
"""Chooses the length every populated dimension of an instrument shares.
@@ -28,12 +37,37 @@ def _common_length(lengths: List[int], loop: bool, limit: Optional[int]) -> int:
Returns:
int: The shared item count, at most ``limit`` where one applies.
"""
- length = min(lengths) if loop else max(lengths)
- if limit is None or length <= limit:
- return length
+ return _limited_length(min(lengths) if loop else max(lengths), limit)
- logger.warning(f"Instrument envelope of {length} items keeps its first {limit}, the most the format holds")
- return limit
+
+def limit_lengths(
+ items_by_kind: Dict[EnvelopeKey, Tuple[int, ...]],
+ *,
+ limit: int,
+) -> Dict[EnvelopeKey, Tuple[int, ...]]:
+ """Keeps each dimension's opening items, as many as the target format stores.
+
+ Every dimension stands at its own length, which is what a player that sustains an
+ exhausted envelope's final value reads: the envelope describes the frames it covers
+ and the last value it wrote governs the rest.
+
+ Args:
+ items_by_kind: The per-dimension item tuples, empty for a dimension the channel
+ leaves unused.
+ limit: The most items the target format stores.
+
+ Returns:
+ Dict[EnvelopeKey, Tuple[int, ...]]: The items with every dimension within the limit.
+ """
+ return {
+ kind: items[
+ : _limited_length(
+ len(items),
+ limit,
+ )
+ ]
+ for kind, items in items_by_kind.items()
+ }
def equalize_lengths(
diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py
index 6b87f51e..2d361869 100644
--- a/src/sampletones_core/exporters/slices.py
+++ b/src/sampletones_core/exporters/slices.py
@@ -59,10 +59,10 @@ def slot(self) -> InstrumentSlot:
def iterate_sample_slices(project: Project) -> Iterator[SampleSlice]:
"""Walks every generator slice of every sample in instrument-table order.
- A sample contributes one slice per channel its reconstruction covers, so it yields
- one to four. Slices are numbered in sample order, then channel order, which fixes
- the instrument numbering every tracker format builds on. Each sample's features are
- exported once, so a caller reads a reconstruction's envelopes at a single cost.
+ A sample contributes one slice per channel that plays, so it yields one to four. Slices
+ are numbered in sample order, then channel order, which fixes the instrument numbering
+ every tracker format builds on. Each sample's features are exported once, so a caller
+ reads a reconstruction's envelopes at a single cost.
Args:
project: The project whose samples are exported.
@@ -74,8 +74,8 @@ def iterate_sample_slices(project: Project) -> Iterator[SampleSlice]:
for sample in project.samples:
features_by_generator = sample.reconstruction.export()
for generator in GeneratorName.items():
- features = features_by_generator.get(generator)
- if features is None:
+ features = features_by_generator[generator]
+ if not features.has_frames:
continue
yield SampleSlice(
diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py
index 1b593140..d14b4b0c 100644
--- a/src/sampletones_core/features/__init__.py
+++ b/src/sampletones_core/features/__init__.py
@@ -1,19 +1,29 @@
from .spec import (
+ CHANNEL_FEATURE_DEFAULTS,
FEATURE_DIMENSION_ORDER,
GENERATOR_FEATURE_RANGES,
GENERATOR_KIND,
+ RESTING_REFERENCE_PERIOD,
+ RESTING_REFERENCE_PITCH,
FeatureRange,
feature_range,
+ resting_held_features,
+ resting_reference,
supported_features,
supports,
)
__all__ = [
- "FeatureRange",
+ "CHANNEL_FEATURE_DEFAULTS",
"FEATURE_DIMENSION_ORDER",
"GENERATOR_FEATURE_RANGES",
"GENERATOR_KIND",
- "supported_features",
+ "RESTING_REFERENCE_PERIOD",
+ "RESTING_REFERENCE_PITCH",
+ "FeatureRange",
"feature_range",
+ "resting_held_features",
+ "resting_reference",
+ "supported_features",
"supports",
]
diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py
index 132662b4..dd3c0b0b 100644
--- a/src/sampletones_core/features/spec.py
+++ b/src/sampletones_core/features/spec.py
@@ -1,5 +1,5 @@
from dataclasses import dataclass
-from typing import Dict, Final, Tuple
+from typing import Dict, Final, List, Tuple
from sampletones_core.constants.enums import FeatureKey, GeneratorName, LibraryGeneratorName
from sampletones_core.constants.general import (
@@ -9,6 +9,7 @@
MAX_NOISE_MODE,
MAX_PERIOD,
MAX_VOLUME,
+ NUM_PERIODS,
)
@@ -27,6 +28,19 @@ class FeatureRange:
)
+CHANNEL_FEATURE_DEFAULTS: Final[Dict[FeatureKey, int]] = {
+ FeatureKey.VOLUME: MAX_VOLUME,
+ FeatureKey.ARPEGGIO: 0,
+ FeatureKey.PITCH: 0,
+ FeatureKey.HI_PITCH: 0,
+ FeatureKey.DUTY_CYCLE: 0,
+}
+
+
+RESTING_REFERENCE_PITCH: Final[int] = 60
+RESTING_REFERENCE_PERIOD: Final[int] = NUM_PERIODS // 2
+
+
GENERATOR_FEATURE_RANGES: Final[Dict[LibraryGeneratorName, Dict[FeatureKey, FeatureRange]]] = {
LibraryGeneratorName.PULSE: {
FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME),
@@ -53,12 +67,55 @@ class FeatureRange:
}
-def supported_features(kind: LibraryGeneratorName) -> list[FeatureKey]:
+def resting_reference(generator_name: GeneratorName) -> int:
+ """The reference an arpeggio envelope is measured against while a channel describes no frame.
+
+ A channel with no frames still carries a reference, since the first envelope given to it
+ sounds every frame at that value. Resting mid-range puts a channel added by hand on an
+ audible note, and on a noise period between the extremes.
+
+ Args:
+ generator_name: The channel whose resting reference is read.
+
+ Returns:
+ int: The pitch a tonal channel rests at, or the period the noise channel rests at.
+ """
+ match GENERATOR_KIND[generator_name]:
+ case LibraryGeneratorName.NOISE:
+ return RESTING_REFERENCE_PERIOD
+ case _:
+ return RESTING_REFERENCE_PITCH
+
+
+def resting_held_features(
+ generator_name: GeneratorName,
+) -> Tuple[FeatureKey, ...]:
+ """The dimensions a channel governs while it describes no frame.
+
+ A stream with no frames writes no dimension, so every dimension the channel offers is the
+ channel's to hold. Recording them makes a channel that has always stood by read the same as
+ one edited down to empty envelopes.
+
+ Args:
+ generator_name: The channel whose resting record is read.
+
+ Returns:
+ Tuple[FeatureKey, ...]: The dimensions the channel offers, in dimension order.
+ """
+ return tuple(supported_features(GENERATOR_KIND[generator_name]))
+
+
+def supported_features(
+ kind: LibraryGeneratorName,
+) -> List[FeatureKey]:
ranges = GENERATOR_FEATURE_RANGES[kind]
return [feature for feature in FEATURE_DIMENSION_ORDER if feature in ranges]
-def feature_range(kind: LibraryGeneratorName, feature: FeatureKey) -> FeatureRange:
+def feature_range(
+ kind: LibraryGeneratorName,
+ feature: FeatureKey,
+) -> FeatureRange:
return GENERATOR_FEATURE_RANGES[kind][feature]
diff --git a/src/sampletones_core/fft/__init__.py b/src/sampletones_core/fft/__init__.py
index 4a4ec741..bf912910 100644
--- a/src/sampletones_core/fft/__init__.py
+++ b/src/sampletones_core/fft/__init__.py
@@ -16,20 +16,20 @@
from .window.window import Window
__all__ = [
- "Window",
"CyclicArray",
+ "FFTTransformer",
+ "Fragment",
+ "FragmentedAudio",
+ "Window",
+ "calculate_cqt",
+ "calculate_cqt_frequencies",
"calculate_fft",
"calculate_fft_frequencies",
+ "calculate_n_bins",
"calculate_weights_from_edges",
+ "convert_midpoints_to_edges",
"erb_bandwidth",
"k_weighting",
- "calculate_cqt",
- "calculate_cqt_frequencies",
- "convert_midpoints_to_edges",
- "calculate_n_bins",
"normalize_cqt_energy",
"to_resolution_floored_log_bands",
- "Fragment",
- "FragmentedAudio",
- "FFTTransformer",
]
diff --git a/src/sampletones_core/fft/features/__init__.py b/src/sampletones_core/fft/features/__init__.py
index c131bda3..78a94c37 100644
--- a/src/sampletones_core/fft/features/__init__.py
+++ b/src/sampletones_core/fft/features/__init__.py
@@ -21,9 +21,9 @@ def get_feature_extractor(config: Config, window: Window) -> FeatureExtractor:
__all__ = [
+ "FEATURE_EXTRACTORS",
+ "CQTFeatureExtractor",
"FeatureExtractor",
"WindowedFeatureExtractor",
- "CQTFeatureExtractor",
- "FEATURE_EXTRACTORS",
"get_feature_extractor",
]
diff --git a/src/sampletones_core/fft/fragment/fragment.py b/src/sampletones_core/fft/fragment/fragment.py
index 02c34fa7..1fb29eb0 100644
--- a/src/sampletones_core/fft/fragment/fragment.py
+++ b/src/sampletones_core/fft/fragment/fragment.py
@@ -43,13 +43,13 @@ def stack(cls, fragments: List[Self]) -> Self:
concatenated_windowed_audio = module.stack([fragment.windowed_audio for fragment in fragments])
concatenated_feature = module.stack([fragment.feature.values for fragment in fragments])
- dimensions = map(
- lambda array: array.ndim,
- [
+ dimensions = (
+ array.ndim
+ for array in [
concatenated_audio,
concatenated_windowed_audio,
concatenated_feature,
- ],
+ ]
)
assert all(ndim == 2 for ndim in dimensions), "All concatenated arrays must be 2-dimensional"
diff --git a/src/sampletones_core/fft/window/cyclic.py b/src/sampletones_core/fft/window/cyclic.py
index 0ff8ef78..e836591d 100644
--- a/src/sampletones_core/fft/window/cyclic.py
+++ b/src/sampletones_core/fft/window/cyclic.py
@@ -1,4 +1,4 @@
-from typing import Optional, Union
+from typing import Optional
import numpy as np
from pydantic import ConfigDict, Field, field_serializer
@@ -36,7 +36,7 @@ def get_offset(self, phase: float) -> int:
return round((phase * self.sample_rate) / self.frequency)
- def get_fragment(self, phase: Union[int, float] = 0, length: Optional[int] = None) -> np.ndarray:
+ def get_fragment(self, phase: float = 0, length: Optional[int] = None) -> np.ndarray:
n = len(self.array)
if n == 0:
return np.empty(0, dtype=self.array.dtype)
@@ -49,7 +49,7 @@ def get_fragment(self, phase: Union[int, float] = 0, length: Optional[int] = Non
fragment: np.ndarray = self.array[idx]
return fragment
- def get_windowed_fragment(self, phase: Union[int, float], window: Window) -> np.ndarray:
+ def get_windowed_fragment(self, phase: float, window: Window) -> np.ndarray:
offset = self.get_offset(phase) if isinstance(phase, float) else phase
offset += window.left_offset
fragment: np.ndarray = self.get_fragment(offset, window.size)
diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py
index 50f0ed95..e91dba29 100644
--- a/src/sampletones_core/formats/bitphase/builder.py
+++ b/src/sampletones_core/formats/bitphase/builder.py
@@ -1,16 +1,21 @@
import math
from dataclasses import dataclass
-from typing import Dict, List, Sequence, Tuple
+from typing import Dict, List, Optional, Sequence, Tuple
from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.general import SILENT_VOLUME
from sampletones_core.exporters.slices import iterate_sample_slices
-from sampletones_core.formats.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes
+from sampletones_core.formats.bitphase.envelopes import (
+ ChannelEnvelopes,
+ features_to_envelopes,
+)
from sampletones_core.formats.bitphase.identifiers import format_instrument_id
from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrument
from sampletones_core.formats.bitphase.model.pattern import (
BitphaseChannel,
BitphasePattern,
BitphaseRow,
+ EffectCell,
NoteCell,
)
from sampletones_core.formats.bitphase.model.project import BitphaseProject
@@ -21,13 +26,25 @@
note_index_to_note_cell,
pitch_to_note_index,
)
-from sampletones_core.formats.bitphase.specification.channels import CHANNEL_LABELS, GENERATOR_NAME_TO_CHANNEL_INDEX
+from sampletones_core.formats.bitphase.specification.channels import (
+ CHANNEL_LABELS,
+ GENERATOR_NAME_TO_CHANNEL_INDEX,
+ ChannelIndex,
+)
from sampletones_core.formats.bitphase.specification.chip import (
CPU_FREQUENCIES,
DEFAULT_A4_TUNING,
DEFAULT_CHIP_VARIANT,
+ MAX_INITIAL_SPEED,
+ MIN_INITIAL_SPEED,
+)
+from sampletones_core.formats.bitphase.specification.effects import (
+ NO_EFFECT_PARAMETER,
+ SPEED_EFFECT_DELAY,
+ EffectId,
)
from sampletones_core.formats.bitphase.specification.instruments import (
+ LOOP_FROM_START,
MAX_INSTRUMENT_ID,
MAX_TABLE_ID,
MIN_INSTRUMENT_ID,
@@ -40,6 +57,7 @@
MIN_PATTERN_LENGTH,
NO_VOLUME_CHANGE,
TABLE_COLUMN_OFFSET,
+ VOLUME_OFF,
NoteName,
)
from sampletones_core.formats.bitphase.tuning import generate_tuning_table
@@ -47,6 +65,7 @@
from sampletones_core.project.instruments.note_off import NoteOff
from sampletones_core.project.patterns.row import Row
from sampletones_core.project.project import Project
+from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove
from sampletones_core.trackers.request import InstrumentExport, SampleExport
from sampletones_shared.constants.project import DEFAULT_ROWS_PER_PATTERN, DEFAULT_SPEED
@@ -55,6 +74,11 @@
PREVIEW_REST_PATTERN_ID = FIRST_PATTERN_ID + 1
NO_AUTHOR = ""
+GROOVE_CHANNEL = ChannelIndex.DPCM
+GROOVE_TRIGGER_ROW = 0
+GROOVE_TABLE_NAME = "Groove"
+GROOVE_TABLE_COUNT = 1
+
@dataclass(frozen=True)
class Voice:
@@ -86,22 +110,26 @@ def _build_voice(
generator: GeneratorName,
initial_pitch: int,
envelopes: ChannelEnvelopes,
+ *,
+ maximum_table_id: int,
) -> Voice:
"""Numbers one generator slice and packages it as an instrument-and-table pair.
Instruments and tables are numbered alike, so a pattern cell names the same position
- in both columns.
+ in both columns. The document states how far the table numbering reaches, since a song
+ that carries a groove holds one table of its own above the slices.
Raises:
- ValueError: If the position runs past what a pattern column can name.
+ ValueError: If the position runs past what a pattern column can name, or past the
+ table ids the document leaves to its slices.
"""
number = index + MIN_INSTRUMENT_ID
if number > MAX_INSTRUMENT_ID:
raise ValueError(f"Document exceeds the Bitphase limit of {MAX_INSTRUMENT_ID} instruments")
table_id = index + MIN_TABLE_ID
- if table_id > MAX_TABLE_ID:
- raise ValueError(f"Document exceeds the Bitphase limit of {MAX_TABLE_ID + 1} tables")
+ if table_id > maximum_table_id:
+ raise ValueError(f"Document holds room for {maximum_table_id + 1} slice tables")
return Voice(
number=number,
@@ -253,6 +281,7 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject:
instrument.generator,
loop=instrument.loop,
),
+ maximum_table_id=MAX_TABLE_ID,
)
for index, instrument in enumerate(request.instruments)
]
@@ -264,7 +293,13 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject:
return BitphaseProject(
name=request.name,
author=NO_AUTHOR,
- songs=(_build_song(patterns, speed=PREVIEW_SPEED, nes_frequency=request.nes_frequency),),
+ songs=(
+ _build_song(
+ patterns,
+ speed=PREVIEW_SPEED,
+ nes_frequency=request.nes_frequency,
+ ),
+ ),
pattern_order=order,
tables=tuple(voice.table for voice in voices),
instruments=tuple(voice.instrument for voice in voices),
@@ -288,7 +323,11 @@ def instrument_to_bitphase(request: InstrumentExport) -> BitphaseProject:
return sample_to_bitphase(sample)
-def _build_voice_table(project: Project) -> Tuple[List[Voice], VoiceTable]:
+def _build_voice_table(
+ project: Project,
+ *,
+ maximum_table_id: int,
+) -> Tuple[List[Voice], VoiceTable]:
voices: List[Voice] = []
by_reference: VoiceTable = {}
@@ -304,6 +343,7 @@ def _build_voice_table(project: Project) -> Tuple[List[Voice], VoiceTable]:
sample_slice.generator,
sample_slice.features.initial_pitch,
envelopes,
+ maximum_table_id=maximum_table_id,
)
voices.append(voice)
by_reference[sample_slice.key] = voice
@@ -322,6 +362,23 @@ def _resolve_voice(reference: Instrument, voices: VoiceTable) -> Voice:
return voice
+def _volume_column(volume: Optional[int]) -> int:
+ """Writes a tracker line's volume column as the value Bitphase reads it as.
+
+ Bitphase spends ``0`` on carrying the channel's level forward, so silence holds a value
+ of its own: a line asking for volume ``0`` writes ``VOLUME_OFF`` and the channel falls
+ silent from that line on, while a line naming a level writes it verbatim. Bitphase's own
+ editor prints ``VOLUME_OFF`` as the digit ``0``, so this is the cell a user types there.
+ """
+ if volume is None:
+ return NO_VOLUME_CHANGE
+
+ if volume == SILENT_VOLUME:
+ return VOLUME_OFF
+
+ return volume
+
+
def _row_cell(
row: Row,
channel_generator: GeneratorName,
@@ -332,7 +389,7 @@ def _row_cell(
Raises:
ValueError: If the line references a sample slice that has no instrument.
"""
- volume = row.volume if row.volume is not None else NO_VOLUME_CHANGE
+ volume = _volume_column(row.volume)
cell = BitphaseRow(volume=volume)
match row.command:
@@ -366,12 +423,97 @@ def _channel_rows(
return cells
-def _project_patterns(project: Project, voices: VoiceTable) -> Tuple[BitphasePattern, ...]:
+def _project_groove(project: Project) -> Groove:
+ """Spreads the tempo a project states across the rows of one pattern.
+
+ A Bitphase song holds a speed alone, so the fractional row rate a tempo asks for is
+ carried by a groove: whole tick counts that vary from row to row and average out to the
+ rate, placed by the metre so the longer rows fall on the bar and the beat. The engine's
+ own speed range bounds them, and the groove's mean states the rate it reached.
+ """
+ settings = project.settings
+ return calculate_groove(
+ RowRate.from_settings(settings),
+ Metre.from_settings(settings, rows=project.song.rows_per_pattern),
+ minimum_ticks=MIN_INITIAL_SPEED,
+ maximum_ticks=MAX_INITIAL_SPEED,
+ )
+
+
+def _maximum_slice_table_id(groove: Groove) -> int:
+ """The last table id the document leaves to its slices.
+
+ A groove whose rows differ occupies the table above the last slice, so the slices reach
+ one id less far; a groove whose rows last alike is carried by the song's initial speed
+ and leaves the whole column to them.
+ """
+ if groove.is_uniform:
+ return MAX_TABLE_ID
+
+ return MAX_TABLE_ID - GROOVE_TABLE_COUNT
+
+
+def _groove_table(groove: Groove, table_id: int) -> BitphaseTable:
+ """Writes the groove as the table a speed effect reads one entry per pattern row from."""
+ return BitphaseTable(
+ id=table_id,
+ rows=groove.ticks,
+ loop=LOOP_FROM_START,
+ name=GROOVE_TABLE_NAME,
+ )
+
+
+def _speed_effect(table_id: int) -> EffectCell:
+ """Names the table a row takes its own duration from.
+
+ The parameter states a speed directly where an effect carries no table, so an effect
+ that names one leaves it empty; the delay stays at zero, which is what Bitphase reads
+ on a speed effect.
+ """
+ return EffectCell(
+ effect=int(EffectId.SPEED),
+ delay=SPEED_EFFECT_DELAY,
+ parameter=NO_EFFECT_PARAMETER,
+ table_index=table_id,
+ )
+
+
+def _groove_channel_rows(length: int, table_id: int) -> List[BitphaseRow]:
+ """Rests a channel for a whole pattern beyond the groove trigger its first row carries.
+
+ A speed effect applies from whichever channel holds it, so the groove rides the silent
+ DPCM channel and leaves every sounding channel its own effect column. The table then
+ advances one entry per row from where the trigger placed it, and triggering it again on
+ each pattern's first row keeps every row on the entry that describes it.
+ """
+ rows = [BitphaseRow() for _ in range(length)]
+ rows[GROOVE_TRIGGER_ROW] = BitphaseRow(effects=(_speed_effect(table_id),))
+ return rows
+
+
+def _document_tables(
+ voices: Sequence[Voice],
+ groove_table: Optional[BitphaseTable],
+) -> Tuple[BitphaseTable, ...]:
+ """Gathers the tables a document holds: one per slice, and the groove where it takes one."""
+ tables = tuple(voice.table for voice in voices)
+ if groove_table is None:
+ return tables
+
+ return tables + (groove_table,)
+
+
+def _project_patterns(
+ project: Project,
+ voices: VoiceTable,
+ groove_table: Optional[BitphaseTable],
+) -> Tuple[BitphasePattern, ...]:
"""Flattens the song's per-channel arrangement into whole-pattern order positions.
A SampleToNES order frame points every channel at its own pattern, where a Bitphase
order position names one pattern that spans all channels, so each frame becomes a
- pattern of its own carrying that frame's channels side by side.
+ pattern of its own carrying that frame's channels side by side. Every pattern triggers
+ the groove table it is given, so the tempo holds wherever the order jumps.
"""
song = project.song
length = song.rows_per_pattern
@@ -379,6 +521,12 @@ def _project_patterns(project: Project, voices: VoiceTable) -> Tuple[BitphasePat
for position, frame in enumerate(song.order):
channel_rows = _empty_channels(length)
+ if groove_table is not None:
+ channel_rows[int(GROOVE_CHANNEL)] = _groove_channel_rows(
+ length,
+ groove_table.id,
+ )
+
for generator in GeneratorName.items():
index = frame.get(generator)
if index is None:
@@ -402,7 +550,10 @@ def _project_patterns(project: Project, voices: VoiceTable) -> Tuple[BitphasePat
def project_to_bitphase(project: Project) -> BitphaseProject:
- """Maps a project's samples and song onto the Bitphase document IR.
+ """Maps a project's samples, song and tempo onto the Bitphase document IR.
+
+ The song carries the project's tempo as a groove, which is the initial speed on its own
+ where every row lasts alike and a table the patterns trigger where the rows differ.
Args:
project: The project to write.
@@ -414,16 +565,34 @@ def project_to_bitphase(project: Project) -> BitphaseProject:
ValueError: If the project holds more than Bitphase has room for, or a row
references a sample slice that has no instrument.
"""
- voices, by_reference = _build_voice_table(project)
- patterns = _project_patterns(project, by_reference)
+ groove = _project_groove(project)
+ voices, by_reference = _build_voice_table(
+ project,
+ maximum_table_id=_maximum_slice_table_id(groove),
+ )
+ groove_table = (
+ None
+ if groove.is_uniform
+ else _groove_table(
+ groove,
+ len(voices) + MIN_TABLE_ID,
+ )
+ )
+ patterns = _project_patterns(project, by_reference, groove_table)
settings = project.settings
info = project.info
return BitphaseProject(
name=info.title,
author=info.author,
- songs=(_build_song(patterns, speed=settings.speed, nes_frequency=settings.nes_frequency),),
+ songs=(
+ _build_song(
+ patterns,
+ speed=groove.ticks[GROOVE_TRIGGER_ROW],
+ nes_frequency=settings.nes_frequency,
+ ),
+ ),
pattern_order=tuple(pattern.id for pattern in patterns),
- tables=tuple(voice.table for voice in voices),
+ tables=_document_tables(voices, groove_table),
instruments=tuple(voice.instrument for voice in voices),
)
diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py
index 917e4f3e..abea08b4 100644
--- a/src/sampletones_core/formats/bitphase/envelopes.py
+++ b/src/sampletones_core/formats/bitphase/envelopes.py
@@ -11,6 +11,7 @@
from sampletones_core.formats.bitphase.specification.instruments import (
FLAT_PULSE_WIDTH,
LOOP_FROM_START,
+ MAX_VOLUME_OR_RATE,
NO_TABLE_OFFSET,
NOISE_MODE_LONG,
NOISE_MODE_SHORT,
@@ -70,6 +71,22 @@ def _table_offset(generator: GeneratorName, arpeggio: int) -> int:
return arpeggio
+def _held_volume(frames: int) -> Tuple[int, ...]:
+ """The volume envelope of a slice whose level the channel governs.
+
+ Bitphase combines each row's level with the pattern's volume column, and a full-level
+ row comes out at the column's own level, so an instrument holding one for every frame
+ it describes sounds at whatever level the channel carries.
+
+ Args:
+ frames: The frames the slice describes.
+
+ Returns:
+ Tuple[int, ...]: One full-level item per frame.
+ """
+ return (MAX_VOLUME_OR_RATE,) * frames
+
+
def features_to_envelopes(
features: Features,
generator: GeneratorName,
@@ -80,9 +97,14 @@ def features_to_envelopes(
Volume becomes the instrument's per-tick level, the duty cycle becomes the channel's
waveform field, and the arpeggio becomes the table contour that moves the note. A
- looping slice returns to its first row so it sustains for as long as the note is
- held; a one-shot returns to its last row, which the volume envelope already leaves
- silent, so it rests there once it has played through.
+ slice that leaves its volume to the channel takes a full level for every frame it
+ describes, so the channel governs how loud it sounds. A looping slice returns to its
+ first row so it sustains for as long as the note is held; a one-shot returns to its
+ last row, resting on the level its volume envelope ends with — silence where the
+ slice writes its own, the channel's level where it holds one.
+
+ A slice describing no frame comes back as the one silent row that is the smallest
+ instrument Bitphase plays.
Args:
features: The per-dimension envelopes describing the slice.
@@ -98,18 +120,19 @@ def features_to_envelopes(
FeatureKey.DUTY_CYCLE: features.duty_cycle,
}
items = equalize_lengths({key: _to_items(array) for key, array in arrays.items()}, loop)
+ frames = max(len(values) for values in items.values())
- volumes = items[FeatureKey.VOLUME]
- arpeggios = items[FeatureKey.ARPEGGIO]
- duty_cycles = items[FeatureKey.DUTY_CYCLE]
-
- if not volumes:
+ if not frames:
return ChannelEnvelopes(
rows=(SILENT_ROW,),
table_rows=(NO_TABLE_OFFSET,),
loop=LOOP_FROM_START,
)
+ volumes = items[FeatureKey.VOLUME] or _held_volume(frames)
+ arpeggios = items[FeatureKey.ARPEGGIO]
+ duty_cycles = items[FeatureKey.DUTY_CYCLE]
+
rows = tuple(
NesInstrumentRow(
pulse_width=_pulse_width(generator, duty_cycles[frame] if duty_cycles else FLAT_PULSE_WIDTH),
diff --git a/src/sampletones_core/formats/bitphase/model/pattern.py b/src/sampletones_core/formats/bitphase/model/pattern.py
index b2514f15..98a61c2c 100644
--- a/src/sampletones_core/formats/bitphase/model/pattern.py
+++ b/src/sampletones_core/formats/bitphase/model/pattern.py
@@ -11,6 +11,7 @@
NO_INSTRUMENT_CHANGE,
NO_TABLE_CHANGE,
NO_VOLUME_CHANGE,
+ VOLUME_OFF,
NoteName,
)
@@ -69,9 +70,9 @@ class BitphaseRow(BaseModel):
table: int = Field(default=NO_TABLE_CHANGE, description="Table to attach from this line on.")
volume: int = Field(
default=NO_VOLUME_CHANGE,
- ge=NO_VOLUME_CHANGE,
+ ge=VOLUME_OFF,
le=FULL_VOLUME,
- description="Channel volume from this line on.",
+ description="Channel volume from this line on, where VOLUME_OFF silences the channel.",
)
diff --git a/src/sampletones_core/formats/bitphase/model/table.py b/src/sampletones_core/formats/bitphase/model/table.py
index c2f3d3d0..65243d4e 100644
--- a/src/sampletones_core/formats/bitphase/model/table.py
+++ b/src/sampletones_core/formats/bitphase/model/table.py
@@ -11,11 +11,14 @@
class BitphaseTable(BaseModel):
- """A per-tick semitone contour a pattern cell attaches to a channel.
+ """A list of one value per step, whose meaning the column or effect reading it fixes.
- Playback adds ``rows[position]`` to the channel's note every tick, advancing one
- row per tick, so a table carries the pitch movement a reconstruction's arpeggio
- envelope describes.
+ A pattern's table column reads it as a semitone contour, adding ``rows[position]`` to
+ the channel's note and advancing a row every tick, so a table carries the pitch movement
+ a reconstruction's arpeggio envelope describes. A speed effect reads it as tick counts,
+ advancing a row every pattern line, so a table carries a song's groove.
+
+ Playback returns to ``loop`` once it runs off the end, whichever column drives it.
"""
model_config = BITPHASE_MODEL_CONFIG
@@ -28,7 +31,7 @@ class BitphaseTable(BaseModel):
)
rows: Tuple[int, ...] = Field(
...,
- description="Semitone offset applied on each tick.",
+ description="Value applied on each step.",
)
loop: int = Field(
default=LOOP_FROM_START,
diff --git a/src/sampletones_core/formats/bitphase/specification/effects.py b/src/sampletones_core/formats/bitphase/specification/effects.py
new file mode 100644
index 00000000..04404c64
--- /dev/null
+++ b/src/sampletones_core/formats/bitphase/specification/effects.py
@@ -0,0 +1,16 @@
+from enum import IntEnum
+from typing import Final
+
+
+class EffectId(IntEnum):
+ """Identifier an effect column carries, as the code point of the letter Bitphase prints.
+
+ ``SPEED`` states how many engine ticks the row it sits on lasts, taken from the effect's
+ own parameter or, where the effect names a table, from one table entry per pattern row.
+ """
+
+ SPEED = ord("S")
+
+
+SPEED_EFFECT_DELAY: Final[int] = 0
+NO_EFFECT_PARAMETER: Final[int] = 0
diff --git a/src/sampletones_core/formats/bitphase/specification/patterns.py b/src/sampletones_core/formats/bitphase/specification/patterns.py
index 3601cac7..239098e7 100644
--- a/src/sampletones_core/formats/bitphase/specification/patterns.py
+++ b/src/sampletones_core/formats/bitphase/specification/patterns.py
@@ -35,6 +35,7 @@ class NoteName(IntEnum):
TABLE_COLUMN_OFFSET: Final[int] = 1
NO_VOLUME_CHANGE: Final[int] = 0
+VOLUME_OFF: Final[int] = -1
FULL_VOLUME: Final[int] = 15
MIN_PATTERN_LENGTH: Final[int] = 1
diff --git a/src/sampletones_core/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py
index 5cfa6259..8d797231 100644
--- a/src/sampletones_core/formats/famitracker/builder.py
+++ b/src/sampletones_core/formats/famitracker/builder.py
@@ -1,8 +1,7 @@
-from __future__ import annotations
-
from typing import List, Optional, Tuple
from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.exporters.feature import Features
from sampletones_core.exporters.slices import (
InstrumentSlot,
InstrumentTable,
@@ -22,13 +21,17 @@
pitch_to_note_cell,
resolve_machine,
)
-from sampletones_core.formats.famitracker.sequences.features import features_to_instrument_sequences
+from sampletones_core.formats.famitracker.sequences.features import (
+ features_to_instrument_sequences,
+)
from sampletones_core.formats.famitracker.specification.channels import (
CHANNEL_COUNT_2A03,
GENERATOR_NAME_TO_CHANNEL_ID,
ChannelId,
)
-from sampletones_core.formats.famitracker.specification.instruments import MAX_INSTRUMENTS
+from sampletones_core.formats.famitracker.specification.instruments import (
+ MAX_INSTRUMENTS,
+)
from sampletones_core.formats.famitracker.specification.parameters import (
DEFAULT_COPYRIGHT,
DEFAULT_HIGHLIGHT_FIRST,
@@ -58,6 +61,43 @@
from sampletones_core.project.song import Song
+def build_instrument(
+ index: int,
+ name: str,
+ features: Features,
+ *,
+ loop: bool,
+) -> Instrument2A03:
+ """Builds one FamiTracker instrument from the envelopes of a generator slice.
+
+ The slice's envelopes become the instrument's five 2A03 sequences, so an instrument reaching a
+ ``.fti`` file on its own and one taking a slot in a module are built the same way.
+
+ Args:
+ index: The slot the instrument is numbered under.
+ name: The name FamiTracker lists the instrument by.
+ features: The per-dimension envelopes the sequences are read from.
+ loop: Whether every populated sequence loops from its first item, sustaining a held note.
+
+ Returns:
+ The instrument the envelopes describe.
+ """
+ sequences = features_to_instrument_sequences(
+ volume=features.volume,
+ arpeggio=features.arpeggio,
+ pitch=features.pitch,
+ hi_pitch=features.hi_pitch,
+ duty_cycle=features.duty_cycle,
+ loop=loop,
+ )
+
+ return Instrument2A03(
+ index=index,
+ name=name,
+ sequences=sequences,
+ )
+
+
def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], InstrumentTable]:
"""Builds one FamiTracker instrument per generator slice of every sample.
@@ -72,20 +112,12 @@ def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], Inst
if sample_slice.index >= MAX_INSTRUMENTS:
raise ValueError(f"Module exceeds the FamiTracker limit of {MAX_INSTRUMENTS} instruments")
- features = sample_slice.features
- sequences = features_to_instrument_sequences(
- volume=features.volume,
- arpeggio=features.arpeggio,
- pitch=features.pitch,
- hi_pitch=features.hi_pitch,
- duty_cycle=features.duty_cycle,
- loop=sample_slice.sample.loop,
- )
instruments.append(
- Instrument2A03(
- index=sample_slice.index,
- name=sample_slice.instrument_name,
- sequences=sequences,
+ build_instrument(
+ sample_slice.index,
+ sample_slice.instrument_name,
+ sample_slice.features,
+ loop=sample_slice.sample.loop,
)
)
slots[sample_slice.key] = sample_slice.slot
@@ -94,7 +126,6 @@ def build_instrument_table(project: Project) -> Tuple[List[Instrument2A03], Inst
def _note_and_octave(
- command: Instrument,
transpose: int,
channel_generator: GeneratorName,
slot: InstrumentSlot,
@@ -104,6 +135,7 @@ def _note_and_octave(
cell = period_to_note_cell(base_pitch)
else:
cell = pitch_to_note_cell(base_pitch)
+
return cell.note, cell.octave
@@ -130,7 +162,6 @@ def _row_cell(
)
instrument = slot.index
note, octave = _note_and_octave(
- reference,
row.transpose or 0,
channel_generator,
slot,
@@ -200,6 +231,7 @@ def _channel_patterns(
def _reserved_empty_index(channel: Channel) -> int:
if not channel.patterns:
return DPCM_EMPTY_PATTERN_INDEX
+
return max(channel.patterns) + 1
@@ -218,6 +250,7 @@ def _build_order(song: Song) -> Tuple[OrderFrame, ...]:
for generator in GeneratorName.items():
index = frame.get(generator)
entries.append(index if index is not None else empty_indices[generator])
+
entries.append(DPCM_EMPTY_PATTERN_INDEX)
frames.append(tuple(entries))
@@ -251,7 +284,11 @@ def project_to_module(project: Project) -> FamiTrackerModule:
patterns: List[PatternData] = []
for generator in GeneratorName.items():
patterns.extend(
- _channel_patterns(generator, song.channels[generator], slots),
+ _channel_patterns(
+ generator,
+ song.channels[generator],
+ slots,
+ ),
)
track = Track(
diff --git a/src/sampletones_core/formats/famitracker/footprint.py b/src/sampletones_core/formats/famitracker/footprint.py
new file mode 100644
index 00000000..481ad494
--- /dev/null
+++ b/src/sampletones_core/formats/famitracker/footprint.py
@@ -0,0 +1,131 @@
+from dataclasses import dataclass
+from typing import Dict, Iterable
+
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.exporters.feature import Features
+from sampletones_core.formats.famitracker.model.instrument import Instrument2A03
+from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence
+from sampletones_core.formats.famitracker.sequences.features import (
+ features_to_instrument_sequences,
+)
+from sampletones_core.formats.famitracker.specification.memory import (
+ INSTRUMENT_DEFINITION_BYTES,
+ SEQUENCE_HEADER_BYTES,
+ SEQUENCE_ITEM_BYTES,
+ SEQUENCE_POINTER_BYTES,
+)
+from sampletones_core.reconstructions import Reconstruction
+
+
+@dataclass(frozen=True)
+class InstrumentFootprint:
+ """The bytes an instrument occupies once FamiTracker compiles it into an NSF.
+
+ The two fields are the two regions the driver keeps an instrument in, which FamiTracker's
+ own export log reports side by side: the instrument list and body under ``instrument_bytes``,
+ the sequence chunks the body points at under ``sequence_bytes``. See
+ `docs/formats/famitracker.md` for the layout each figure counts.
+
+ Attributes:
+ instrument_bytes: Bytes the instrument's table entry and body occupy.
+ sequence_bytes: Bytes the instrument's sequences occupy.
+ """
+
+ instrument_bytes: int
+ sequence_bytes: int
+
+ @property
+ def total_bytes(self) -> int:
+ """The whole footprint, the figure a size display names."""
+ return self.instrument_bytes + self.sequence_bytes
+
+
+def sequence_footprint(sequence: InstrumentSequence) -> int:
+ """Measures the bytes one sequence chunk occupies: its four-field header and its items."""
+ return SEQUENCE_HEADER_BYTES + SEQUENCE_ITEM_BYTES * len(sequence.items)
+
+
+def sequences_footprint(
+ sequences: Iterable[InstrumentSequence],
+) -> InstrumentFootprint:
+ """Measures the instrument the given sequences make up.
+
+ A populated sequence earns the instrument a pointer to its chunk and contributes the chunk
+ itself; an empty one is written as a disabled slot the driver stores nothing for, so the
+ populated sequences alone decide both figures.
+ """
+ populated = [sequence for sequence in sequences if sequence.enabled]
+ return InstrumentFootprint(
+ instrument_bytes=INSTRUMENT_DEFINITION_BYTES + SEQUENCE_POINTER_BYTES * len(populated),
+ sequence_bytes=sum(sequence_footprint(sequence) for sequence in populated),
+ )
+
+
+def instrument_footprint(instrument: Instrument2A03) -> InstrumentFootprint:
+ """Measures one built instrument, the form an export writes."""
+ return sequences_footprint(instrument.sequences.values())
+
+
+def features_footprint(
+ features: Features,
+ *,
+ loop: bool,
+) -> InstrumentFootprint:
+ """Measures the instrument a generator slice's envelopes export to.
+
+ The envelopes pass through the same builder an export uses, so the measured item counts are
+ the ones a file carries: brought to one shared length and capped at what a FamiTracker
+ sequence holds.
+
+ Args:
+ features: The per-dimension envelopes describing the slice.
+ loop: Whether the instrument loops while its note is held, which decides the shared length.
+
+ Returns:
+ InstrumentFootprint: The footprint of the instrument those envelopes describe.
+ """
+ sequences = features_to_instrument_sequences(
+ volume=features.volume,
+ arpeggio=features.arpeggio,
+ pitch=features.pitch,
+ hi_pitch=features.hi_pitch,
+ duty_cycle=features.duty_cycle,
+ loop=loop,
+ )
+ return sequences_footprint(sequences.values())
+
+
+def reconstruction_footprints(
+ reconstruction: Reconstruction,
+ *,
+ loop: bool,
+) -> Dict[GeneratorName, InstrumentFootprint]:
+ """Measures one instrument per channel a reconstruction plays.
+
+ An export writes an instrument for each channel that plays, so the result holds an entry
+ per playing channel and :func:`total_footprint` sums them into what the whole sample costs.
+ A channel standing by is written nowhere and therefore measured nowhere.
+
+ Args:
+ reconstruction: The reconstruction whose channels are measured.
+ loop: Whether the sample carrying it loops while its note is held.
+
+ Returns:
+ Dict[GeneratorName, InstrumentFootprint]: The footprint of each playing channel's instrument.
+ """
+ return {
+ generator_name: features_footprint(features, loop=loop)
+ for generator_name, features in reconstruction.export().items()
+ if features.has_frames
+ }
+
+
+def total_footprint(
+ footprints: Iterable[InstrumentFootprint],
+) -> InstrumentFootprint:
+ """Sums footprints region by region, giving what a set of instruments costs together."""
+ measured = list(footprints)
+ return InstrumentFootprint(
+ instrument_bytes=sum(footprint.instrument_bytes for footprint in measured),
+ sequence_bytes=sum(footprint.sequence_bytes for footprint in measured),
+ )
diff --git a/src/sampletones_core/formats/famitracker/notes.py b/src/sampletones_core/formats/famitracker/notes.py
index dc05f0d9..19849bf6 100644
--- a/src/sampletones_core/formats/famitracker/notes.py
+++ b/src/sampletones_core/formats/famitracker/notes.py
@@ -4,8 +4,6 @@
from sampletones_core.formats.famitracker.model.pattern import NoteCell
from sampletones_core.formats.famitracker.specification.parameters import (
ENGINE_SPEED_MACHINE_DEFAULT,
- NTSC_FREQUENCY,
- PAL_FREQUENCY,
Machine,
)
from sampletones_core.formats.famitracker.specification.patterns import (
@@ -14,6 +12,7 @@
NOTE_RANGE,
PITCH_OCTAVE_OFFSET,
)
+from sampletones_shared.constants.nes import NTSC_FREQUENCY, PAL_FREQUENCY
def pitch_to_note_cell(pitch: int) -> NoteCell:
diff --git a/src/sampletones_core/formats/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py
index 75d88510..24e2057f 100644
--- a/src/sampletones_core/formats/famitracker/sequences/features.py
+++ b/src/sampletones_core/formats/famitracker/sequences/features.py
@@ -2,7 +2,7 @@
import numpy as np
-from sampletones_core.exporters.lengths import equalize_lengths
+from sampletones_core.exporters.lengths import equalize_lengths, limit_lengths
from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence
from sampletones_core.formats.famitracker.specification.sequences import (
LOOP_FROM_START,
@@ -18,6 +18,25 @@ def _to_items(array: Optional[np.ndarray]) -> Tuple[int, ...]:
return tuple(int(value) for value in array)
+def _sequence_items(
+ arrays: Dict[SequenceKind, Optional[np.ndarray]],
+ loop: bool,
+) -> Dict[SequenceKind, Tuple[int, ...]]:
+ """Reads the dimensions as the item tuples an instrument stores.
+
+ A looping instrument brings every populated dimension to one length, so its envelopes
+ repeat in step cycle after cycle. A one-shot carries each dimension at the length it
+ was written: a FamiTracker sequence that runs out halts and leaves its final value
+ applied for as long as the note sounds, so the shorter dimensions govern the whole
+ instrument on their own.
+ """
+ items_by_kind = {kind: _to_items(array) for kind, array in arrays.items()}
+ if loop:
+ return equalize_lengths(items_by_kind, loop, limit=MAX_SEQUENCE_ITEMS)
+
+ return limit_lengths(items_by_kind, limit=MAX_SEQUENCE_ITEMS)
+
+
def features_to_instrument_sequences(
*,
volume: np.ndarray,
@@ -29,12 +48,12 @@ def features_to_instrument_sequences(
) -> Dict[SequenceKind, InstrumentSequence]:
"""Builds the five 2A03 sequences from per-dimension envelope arrays.
- Each dimension becomes an :class:`InstrumentSequence`; a dimension passed as
- ``None`` becomes a disabled (empty) sequence. Populated dimensions are brought to a
- common length so they stay in step tick for tick, capped at the ``MAX_SEQUENCE_ITEMS``
- items FamiTracker stores, so a longer reconstruction exports its opening frames and
- the shortening is logged. When ``loop`` is set, every populated sequence loops from
- its first item so the instrument sustains on a held note.
+ Each dimension becomes an :class:`InstrumentSequence`; a dimension passed as ``None``
+ or as an empty envelope becomes a disabled sequence the instrument stores nothing for.
+ Item counts stay within the ``MAX_SEQUENCE_ITEMS`` items FamiTracker holds, so a longer
+ reconstruction exports its opening frames and the shortening is logged. When ``loop``
+ is set, every populated sequence loops from its first item so the instrument sustains
+ on a held note, and the populated dimensions share one length to repeat in step.
"""
arrays: Dict[SequenceKind, Optional[np.ndarray]] = {
SequenceKind.VOLUME: volume,
@@ -44,15 +63,15 @@ def features_to_instrument_sequences(
SequenceKind.DUTY: duty_cycle,
}
- items_by_kind = equalize_lengths(
- {kind: _to_items(array) for kind, array in arrays.items()},
- loop,
- limit=MAX_SEQUENCE_ITEMS,
- )
+ items_by_kind = _sequence_items(arrays, loop)
sequences: Dict[SequenceKind, InstrumentSequence] = {}
for kind, items in items_by_kind.items():
loop_point = LOOP_FROM_START if loop and items else NO_LOOP_POINT
- sequences[kind] = InstrumentSequence(kind=kind, items=items, loop_point=loop_point)
+ sequences[kind] = InstrumentSequence(
+ kind=kind,
+ items=items,
+ loop_point=loop_point,
+ )
return sequences
diff --git a/src/sampletones_core/formats/famitracker/specification/memory.py b/src/sampletones_core/formats/famitracker/specification/memory.py
new file mode 100644
index 00000000..1df6a679
--- /dev/null
+++ b/src/sampletones_core/formats/famitracker/specification/memory.py
@@ -0,0 +1,15 @@
+from typing import Final
+
+INSTRUMENT_POINTER_BYTES: Final[int] = 2
+SEQUENCE_ENABLE_MASK_BYTES: Final[int] = 1
+SEQUENCE_POINTER_BYTES: Final[int] = 2
+INSTRUMENT_DEFINITION_BYTES: Final[int] = INSTRUMENT_POINTER_BYTES + SEQUENCE_ENABLE_MASK_BYTES
+
+SEQUENCE_LENGTH_BYTES: Final[int] = 1
+SEQUENCE_LOOP_POINT_BYTES: Final[int] = 1
+SEQUENCE_RELEASE_POINT_BYTES: Final[int] = 1
+SEQUENCE_SETTING_BYTES: Final[int] = 1
+SEQUENCE_ITEM_BYTES: Final[int] = 1
+SEQUENCE_HEADER_BYTES: Final[int] = (
+ SEQUENCE_LENGTH_BYTES + SEQUENCE_LOOP_POINT_BYTES + SEQUENCE_RELEASE_POINT_BYTES + SEQUENCE_SETTING_BYTES
+)
diff --git a/src/sampletones_core/formats/famitracker/specification/parameters.py b/src/sampletones_core/formats/famitracker/specification/parameters.py
index 91027024..9788b081 100644
--- a/src/sampletones_core/formats/famitracker/specification/parameters.py
+++ b/src/sampletones_core/formats/famitracker/specification/parameters.py
@@ -16,8 +16,6 @@ class Machine(IntEnum):
DEFAULT_HIGHLIGHT_FIRST: Final[int] = 4
DEFAULT_HIGHLIGHT_SECOND: Final[int] = 16
ENGINE_SPEED_MACHINE_DEFAULT: Final[int] = 0
-NTSC_FREQUENCY: Final[int] = 60
-PAL_FREQUENCY: Final[int] = 50
SINGLE_TRACK_COUNT: Final[int] = 1
FIRST_TRACK_INDEX: Final[int] = 0
diff --git a/src/sampletones_core/generators/__init__.py b/src/sampletones_core/generators/__init__.py
index 0c67b560..95edc7d2 100644
--- a/src/sampletones_core/generators/__init__.py
+++ b/src/sampletones_core/generators/__init__.py
@@ -25,23 +25,23 @@
)
__all__ = [
- "Generator",
- "PulseGenerator",
- "TriangleGenerator",
- "NoiseGenerator",
- "get_generators_by_names",
- "get_generators_map",
- "get_remaining_generator_classes",
- "get_generator_by_instruction",
- "LIBRARY_GENERATOR_CLASS_MAP",
"GENERATOR_CLASSES",
"GENERATOR_CLASS_MAP",
- "INSTRUCTION_TO_GENERATOR_MAP",
"GENERATOR_TO_INSTRUCTION_MAP",
+ "INSTRUCTION_TO_GENERATOR_MAP",
+ "LIBRARY_GENERATOR_CLASS_MAP",
"MIXER_LEVELS",
- "GeneratorT",
+ "Generator",
"GeneratorClass",
- "GeneratorUnion",
- "GeneratorTypeUnion",
"GeneratorClassNames",
+ "GeneratorT",
+ "GeneratorTypeUnion",
+ "GeneratorUnion",
+ "NoiseGenerator",
+ "PulseGenerator",
+ "TriangleGenerator",
+ "get_generator_by_instruction",
+ "get_generators_by_names",
+ "get_generators_map",
+ "get_remaining_generator_classes",
]
diff --git a/src/sampletones_core/generators/generator.py b/src/sampletones_core/generators/generator.py
index e211e716..19fc7ced 100644
--- a/src/sampletones_core/generators/generator.py
+++ b/src/sampletones_core/generators/generator.py
@@ -203,7 +203,22 @@ def get_possible_instructions(self) -> List[InstructionT]:
@property
def frame_length(self) -> int:
- return self.config.library.frame_length
+ """The samples the next rendered frame spans.
+
+ The timer holds the length, seeded from the configuration it was built with. Setting it
+ renders the next frame over that many samples instead, which is how a caller driving the
+ engine's ticks gives each tick the span its clock states. Oscillator continuity is carried
+ by the timer's own state, so a frame of a different length resumes exactly where the last
+ one ended.
+ """
+ return self.timer.frame_length
+
+ @frame_length.setter
+ def frame_length(self, value: int) -> None:
+ if value < 1:
+ raise ValueError(f"frame_length must be at least 1, got {value}")
+
+ self.timer.frame_length = value
@classmethod
@abstractmethod
diff --git a/src/sampletones_core/instructions/__init__.py b/src/sampletones_core/instructions/__init__.py
index 3a0d8fc1..7360d224 100644
--- a/src/sampletones_core/instructions/__init__.py
+++ b/src/sampletones_core/instructions/__init__.py
@@ -14,16 +14,16 @@
from .utils import get_instruction_by_type
__all__ = [
+ "INSTRUCTION_CLASS_MAP",
"Instruction",
+ "InstructionClass",
"InstructionData",
- "PulseInstruction",
- "TriangleInstruction",
- "NoiseInstruction",
- "INSTRUCTION_CLASS_MAP",
+ "InstructionFields",
"InstructionT",
- "InstructionClass",
- "InstructionUnion",
"InstructionTypeUnion",
- "InstructionFields",
+ "InstructionUnion",
+ "NoiseInstruction",
+ "PulseInstruction",
+ "TriangleInstruction",
"get_instruction_by_type",
]
diff --git a/src/sampletones_core/library/__init__.py b/src/sampletones_core/library/__init__.py
index 7f3c0f84..735c2497 100644
--- a/src/sampletones_core/library/__init__.py
+++ b/src/sampletones_core/library/__init__.py
@@ -5,10 +5,10 @@
from .library import InstructionLibrary
__all__ = [
- "InstructionLibraryFragment",
+ "InstructionLibrary",
"InstructionLibraryData",
+ "InstructionLibraryFragment",
"InstructionLibraryKey",
- "InstructionLibrary",
"create_key_from_filename",
"get_display_name_from_key",
]
diff --git a/src/sampletones_core/library/creator/__init__.py b/src/sampletones_core/library/creator/__init__.py
index c4b77655..50ca67c7 100644
--- a/src/sampletones_core/library/creator/__init__.py
+++ b/src/sampletones_core/library/creator/__init__.py
@@ -9,7 +9,7 @@
__all__ = [
"InstructionsLibraryCreator",
"generate_instruction",
- "generate_instructions",
"generate_instruction_batch",
+ "generate_instructions",
"generate_single_instruction_task",
]
diff --git a/src/sampletones_core/library/data.py b/src/sampletones_core/library/data.py
index 73dd6cba..c62d07e6 100644
--- a/src/sampletones_core/library/data.py
+++ b/src/sampletones_core/library/data.py
@@ -2,24 +2,19 @@
from functools import cached_property
from pathlib import Path
-from typing import Any, Dict, KeysView, List, Self, Union, ValuesView
+from typing import Any, Dict, Final, KeysView, List, Self, Union, ValuesView
from pydantic import ConfigDict, Field, ValidationError
from sampletones_core.configs import Config, InstructionsLibraryConfig
from sampletones_core.constants.enums import GeneratorClassName
-from sampletones_core.data import DataModel, Metadata
+from sampletones_core.data import DataModel, Metadata, MetadataContract
from sampletones_core.generators import GeneratorClassNames
from sampletones_core.instructions import InstructionUnion
-from sampletones_shared.application import (
- SAMPLETONES_LIBRARY_DATA_VERSION,
- SAMPLETONES_NAME,
-)
-from sampletones_shared.deployment.version import compare_versions
+from sampletones_shared.application import SAMPLETONES_LIBRARY_DATA_VERSION
from sampletones_shared.exceptions import (
IncompatibleLibraryDataVersionError,
InvalidLibraryDataValuesError,
- InvalidMetadataError,
SampleToNESError,
UnhandledLibraryError,
)
@@ -29,6 +24,12 @@
from .fragment import InstructionLibraryFragment
from .item import LibraryItem
+LIBRARY_DATA_CONTRACT: Final[MetadataContract] = MetadataContract(
+ label="Library data",
+ expected_version=SAMPLETONES_LIBRARY_DATA_VERSION,
+ error=IncompatibleLibraryDataVersionError,
+)
+
class InstructionLibraryData(DataModel):
model_config = ConfigDict(arbitrary_types_allowed=True, frozen=True)
@@ -137,18 +138,4 @@ def validate_metadata(metadata: Metadata) -> None:
if not isinstance(metadata, Metadata):
return
- application_metadata = metadata.application_name
- if application_metadata != SAMPLETONES_NAME:
- raise InvalidMetadataError(
- f"Metadata application name mismatch: expected " f"{SAMPLETONES_NAME}, got {application_metadata}."
- )
-
- library_version = metadata.library_data_version
- print(compare_versions(library_version, SAMPLETONES_LIBRARY_DATA_VERSION))
- if compare_versions(library_version, SAMPLETONES_LIBRARY_DATA_VERSION) != 0:
- raise IncompatibleLibraryDataVersionError(
- f"Library data version mismatch: expected "
- f"{SAMPLETONES_LIBRARY_DATA_VERSION}, got {library_version}.",
- expected_version=SAMPLETONES_LIBRARY_DATA_VERSION,
- actual_version=library_version,
- )
+ LIBRARY_DATA_CONTRACT.validate(metadata, metadata.library_data_version)
diff --git a/src/sampletones_core/library/filename/fields.py b/src/sampletones_core/library/filename/fields.py
index 5e050b77..0733a532 100644
--- a/src/sampletones_core/library/filename/fields.py
+++ b/src/sampletones_core/library/filename/fields.py
@@ -7,7 +7,7 @@
from sampletones_core.constants.enums import SpectrumMethod
from sampletones_core.constants.field_aliases import ALIASES
-from sampletones_core.paths import EXT_FILE_LIBRARY
+from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.serialization import HASH_PATTERN
from sampletones_shared.utils.system.paths import get_filename
diff --git a/src/sampletones_core/library/filename/utils.py b/src/sampletones_core/library/filename/utils.py
index 194cd700..5088d498 100644
--- a/src/sampletones_core/library/filename/utils.py
+++ b/src/sampletones_core/library/filename/utils.py
@@ -2,14 +2,12 @@
from sampletones_core.configs.display import (
DISPLAY_SEPARATOR,
- GAMMA_PREFIX,
- format_nes_frequency,
- format_sample_rate,
- format_spectrum_method,
+ format_frequencies,
+ format_transformation,
)
from sampletones_core.library.filename.fields import InstructionsFilenameFields
from sampletones_core.library.key import InstructionLibraryKey
-from sampletones_core.paths import EXT_FILE_LIBRARY
+from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.system.paths import get_filename
@@ -39,12 +37,9 @@ def create_key_from_filename(filename: Pathlike) -> InstructionLibraryKey:
def get_display_name_from_key(key: InstructionLibraryKey) -> str:
nes_frequency = round(key.sample_rate / key.frame_length)
- gamma = f"{GAMMA_PREFIX}{key.transformation_gamma}"
return DISPLAY_SEPARATOR.join(
[
- format_sample_rate(key.sample_rate),
- format_nes_frequency(nes_frequency),
- format_spectrum_method(key.spectrum_method),
- gamma,
+ format_frequencies(key.sample_rate, nes_frequency),
+ format_transformation(key.spectrum_method, key.transformation_gamma),
]
)
diff --git a/src/sampletones_core/library/library.py b/src/sampletones_core/library/library.py
index 496d1691..76b0b5ee 100644
--- a/src/sampletones_core/library/library.py
+++ b/src/sampletones_core/library/library.py
@@ -7,8 +7,8 @@
from sampletones_core.configs import Config
from sampletones_core.fft import Window
-from sampletones_core.paths import LIBRARY_DIRECTORY
from sampletones_shared.logger import logger
+from sampletones_shared.paths.user import LIBRARY_DIRECTORY
from .data import InstructionLibraryData
from .key import InstructionLibraryKey
diff --git a/src/sampletones_core/parallelization/__init__.py b/src/sampletones_core/parallelization/__init__.py
index c3114c3d..748e3b49 100644
--- a/src/sampletones_core/parallelization/__init__.py
+++ b/src/sampletones_core/parallelization/__init__.py
@@ -3,8 +3,8 @@
from .task import TaskProgress, TaskStatus
__all__ = [
- "TaskStatus",
- "TaskProgress",
- "TaskProcessor",
"ETAEstimator",
+ "TaskProcessor",
+ "TaskProgress",
+ "TaskStatus",
]
diff --git a/src/sampletones_core/parallelization/processor.py b/src/sampletones_core/parallelization/processor.py
index 4ce8fbd6..a377bf3c 100644
--- a/src/sampletones_core/parallelization/processor.py
+++ b/src/sampletones_core/parallelization/processor.py
@@ -285,7 +285,7 @@ def _cleanup_pool(self) -> None:
finally:
self._join_pool()
- def _stop_pool(self, timeout: Union[int, float] = STOP_TIMEOUT) -> None:
+ def _stop_pool(self, timeout: float = STOP_TIMEOUT) -> None:
self._notify_progress()
if self.pool is None:
return
diff --git a/src/sampletones_core/parallelization/progress.py b/src/sampletones_core/parallelization/progress.py
index 0fb6d7c4..f9575963 100644
--- a/src/sampletones_core/parallelization/progress.py
+++ b/src/sampletones_core/parallelization/progress.py
@@ -1,6 +1,6 @@
from collections import deque
from time import monotonic
-from typing import Deque, Final, Optional, Tuple, Union
+from typing import Deque, Final, Optional, Tuple
ESTIMATION_MEASUREMENTS_SAMPLES: Final[float] = 0.05
@@ -9,7 +9,7 @@ class ETAEstimator:
def __init__(
self,
total: int,
- ems: Union[float, int] = ESTIMATION_MEASUREMENTS_SAMPLES,
+ ems: float = ESTIMATION_MEASUREMENTS_SAMPLES,
) -> None:
self._total = total
self._ems = self._get_estimation_measurements_samples(ems)
@@ -49,7 +49,7 @@ def format_duration(cls, seconds: Optional[float]) -> str:
return f"{seconds_remaining}s"
- def _get_estimation_measurements_samples(self, ems: Union[float, int]) -> int:
+ def _get_estimation_measurements_samples(self, ems: float) -> int:
if isinstance(ems, float):
ems = round(ems * self._total)
diff --git a/src/sampletones_core/paths.py b/src/sampletones_core/paths.py
deleted file mode 100644
index f0564761..00000000
--- a/src/sampletones_core/paths.py
+++ /dev/null
@@ -1,66 +0,0 @@
-from pathlib import Path
-from typing import Final, Tuple
-
-from platformdirs import user_config_dir, user_data_dir, user_documents_path
-
-from sampletones_shared.application import (
- SAMPLETONES_GROUP,
- SAMPLETONES_NAME,
-)
-
-# User paths
-USER_PATH_DOCUMENTS: Final[Path] = Path(user_documents_path()) / SAMPLETONES_NAME
-USER_PATH_DATA: Final[Path] = Path(user_data_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP))
-USER_PATH_CONFIG: Final[Path] = Path(user_config_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP))
-
-# Application paths
-LIBRARY_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "instructions"
-RECONSTRUCTIONS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "reconstructions"
-PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects"
-CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json"
-APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml"
-
-# File extensions
-EXT_FILE_JSON: Final[str] = ".json"
-EXT_FILE_YAML: Final[str] = ".yaml"
-EXT_FILE_LIBRARY: Final[str] = ".ins"
-EXT_FILE_INSTRUMENT: Final[str] = ".fti"
-EXT_FILE_RECONSTRUCTION: Final[str] = ".stn"
-EXT_FILE_PROJECT: Final[str] = ".stp"
-EXT_FILE_MODULE: Final[str] = ".ftm"
-EXT_FILE_BITPHASE: Final[str] = ".btp"
-EXT_FILE_WAVE: Final[str] = ".wav"
-EXT_FILE_MP3: Final[str] = ".mp3"
-EXT_FILE_FLAC: Final[str] = ".flac"
-EXT_FILE_OGG: Final[str] = ".ogg"
-EXT_FILE_AIFF: Final[str] = ".aiff"
-EXT_FILE_AU: Final[str] = ".au"
-EXT_FILES_AUDIO: Final[Tuple[str, ...]] = (
- EXT_FILE_WAVE,
- EXT_FILE_MP3,
- EXT_FILE_FLAC,
- EXT_FILE_OGG,
- EXT_FILE_AIFF,
- EXT_FILE_AU,
-)
-
-# Assets
-ASSETS_DIRECTORY: Final[str] = "assets"
-
-# Icon filenames
-ICON_DIRECTORY: Final[str] = "icons"
-ICON_WIN_FILENAME: Final[str] = "sampletones.ico"
-ICON_UNIX_FILENAME: Final[str] = "sampletones.png"
-
-# Font paths
-FONT_DIRECTORY: Final[str] = "fonts"
-FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf"
-FONT_SANS_BOLD: Final[str] = "SourceSans3-Bold.ttf"
-FONT_SANS_ITALIC: Final[str] = "SourceSans3-Italic.ttf"
-FONT_MONO_REGULAR: Final[str] = "RobotoMono-Regular.ttf"
-FONT_MONO_BOLD: Final[str] = "RobotoMono-Bold.ttf"
-FONT_ICON: Final[str] = "DejaVuSans.ttf"
-
-PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True)
-LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True)
-RECONSTRUCTIONS_DIRECTORY.mkdir(parents=True, exist_ok=True)
diff --git a/src/sampletones_core/project/__init__.py b/src/sampletones_core/project/__init__.py
index 463c0335..5cf44390 100644
--- a/src/sampletones_core/project/__init__.py
+++ b/src/sampletones_core/project/__init__.py
@@ -9,13 +9,13 @@
from .song import Song
__all__ = [
+ "Channel",
+ "Instrument",
+ "Pattern",
"Project",
"ProjectContainer",
"ProjectInfo",
"ProjectSettings",
- "Song",
- "Channel",
- "Pattern",
"Row",
- "Instrument",
+ "Song",
]
diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py
index c5186a2c..375b93e4 100644
--- a/src/sampletones_core/project/container.py
+++ b/src/sampletones_core/project/container.py
@@ -4,7 +4,6 @@
from pydantic import ValidationError
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
from sampletones_core.project.document import ProjectDocument
from sampletones_core.project.instruments.record import SampleRecord
from sampletones_core.project.instruments.sample import Sample
@@ -27,6 +26,7 @@
NotAValidArchiveError,
UnhandledProjectError,
)
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
from sampletones_shared.types.path import Pathlike
from sampletones_shared.utils.serialization import JSON_INDENT
from sampletones_shared.utils.system.paths import get_filename
diff --git a/src/sampletones_core/project/info.py b/src/sampletones_core/project/info.py
index e8f130e1..aa8ccc53 100644
--- a/src/sampletones_core/project/info.py
+++ b/src/sampletones_core/project/info.py
@@ -1,4 +1,4 @@
-from datetime import datetime, timezone
+from datetime import UTC, datetime
from pydantic import BaseModel, ConfigDict, Field
@@ -13,7 +13,7 @@
def now() -> datetime:
- return datetime.now(timezone.utc)
+ return datetime.now(UTC)
class ProjectInfo(BaseModel):
diff --git a/src/sampletones_core/project/instruments/__init__.py b/src/sampletones_core/project/instruments/__init__.py
index 992ff2ef..576c12de 100644
--- a/src/sampletones_core/project/instruments/__init__.py
+++ b/src/sampletones_core/project/instruments/__init__.py
@@ -3,7 +3,7 @@
from .sample import Sample
__all__ = [
- "Sample",
"Instrument",
+ "Sample",
"SampleRecord",
]
diff --git a/src/sampletones_core/project/instruments/sample.py b/src/sampletones_core/project/instruments/sample.py
index 1243d65c..672abb09 100644
--- a/src/sampletones_core/project/instruments/sample.py
+++ b/src/sampletones_core/project/instruments/sample.py
@@ -1,4 +1,4 @@
-from typing import Any, Self
+from typing import 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: Any) -> bool:
+ def __eq__(self, other: object) -> bool:
return isinstance(other, Sample) and self.id == other.id
def __repr__(self) -> str:
diff --git a/src/sampletones_core/project/patterns/channel.py b/src/sampletones_core/project/patterns/channel.py
index b1bc65a9..eb4f81df 100644
--- a/src/sampletones_core/project/patterns/channel.py
+++ b/src/sampletones_core/project/patterns/channel.py
@@ -62,7 +62,7 @@ def ensure_pattern(self, index: int, length: int) -> Pattern:
return self.patterns[index]
- def duplicate_pattern(self, index: int, *, reserved_indices: AbstractSet[int] = frozenset()) -> int:
+ def clone_pattern(self, index: int, *, reserved_indices: AbstractSet[int] = frozenset()) -> int:
"""Clones the pattern at ``index`` into a fresh index and returns that index.
``reserved_indices`` are extra indices the clone must avoid beyond the pool's
diff --git a/src/sampletones_core/project/patterns/row.py b/src/sampletones_core/project/patterns/row.py
index 76465ea7..54af3b1e 100644
--- a/src/sampletones_core/project/patterns/row.py
+++ b/src/sampletones_core/project/patterns/row.py
@@ -6,6 +6,7 @@
MAX_TRANSPOSE,
MAX_VOLUME,
MIN_TRANSPOSE,
+ SILENT_VOLUME,
)
from sampletones_core.project.instruments.instrument import Instrument
from sampletones_core.project.instruments.note_off import NoteOff
@@ -35,9 +36,9 @@ class Row(BaseModel):
)
volume: Optional[int] = Field(
default=None,
- ge=0,
+ ge=SILENT_VOLUME,
le=MAX_VOLUME,
- description="Volume column, or None for an empty cell.",
+ description="Volume column, where SILENT_VOLUME silences the channel, or None for an empty cell.",
)
def is_empty(self) -> bool:
diff --git a/src/sampletones_core/project/settings.py b/src/sampletones_core/project/settings.py
index f7f79d6a..8d7d0a20 100644
--- a/src/sampletones_core/project/settings.py
+++ b/src/sampletones_core/project/settings.py
@@ -5,16 +5,20 @@
MAX_SAMPLE_RATE,
MIN_SAMPLE_RATE,
)
-from sampletones_core.constants.general import (
+from sampletones_shared.constants.nes import (
DEFAULT_NES_FREQUENCY,
MAX_NES_FREQUENCY,
MIN_NES_FREQUENCY,
)
from sampletones_shared.constants.project import (
+ DEFAULT_FIRST_HIGHLIGHT,
+ DEFAULT_SECOND_HIGHLIGHT,
DEFAULT_SPEED,
DEFAULT_TEMPO,
+ MAX_HIGHLIGHT,
MAX_SPEED,
MAX_TEMPO,
+ MIN_HIGHLIGHT,
MIN_SPEED,
MIN_TEMPO,
)
@@ -47,3 +51,15 @@ class ProjectSettings(BaseModel):
le=MAX_SPEED,
description="Engine ticks per row.",
)
+ first_highlight: int = Field(
+ default=DEFAULT_FIRST_HIGHLIGHT,
+ ge=MIN_HIGHLIGHT,
+ le=MAX_HIGHLIGHT,
+ description="Rows per beat, the unit the tempo is counted in.",
+ )
+ second_highlight: int = Field(
+ default=DEFAULT_SECOND_HIGHLIGHT,
+ ge=MIN_HIGHLIGHT,
+ le=MAX_HIGHLIGHT,
+ description="Rows per bar, the unit that groups beats.",
+ )
diff --git a/src/sampletones_core/project/song.py b/src/sampletones_core/project/song.py
index e1bccc2e..f779f8d0 100644
--- a/src/sampletones_core/project/song.py
+++ b/src/sampletones_core/project/song.py
@@ -91,18 +91,29 @@ def add_pattern(self, generator: GeneratorName) -> int:
reserved_indices=self._referenced_indices(generator),
)
- def duplicate_pattern(self, generator: GeneratorName, index: int) -> int:
+ def clone_pattern(self, generator: GeneratorName, index: int) -> int:
"""Clones ``generator``'s pattern at ``index`` into a free index and returns it.
The clone index clears the channel's pool and every order-referenced index, so
the copy stays independent of any slot the order already plays.
"""
- return self.channels[generator].duplicate_pattern(
+ return self.channels[generator].clone_pattern(
index,
reserved_indices=self._referenced_indices(generator),
)
def duplicate_frame(self, position: int) -> None:
+ """Inserts a frame playing the same patterns directly after ``position``.
+
+ The pattern indices are copied as they stand, so both frames play one shared
+ pattern per channel and an edit to either is heard in both. The copy is a fresh
+ mapping, so assigning a channel a different pattern in one frame leaves the
+ other frame where it was. Silent slots stay silent, and an index whose pattern
+ is not yet materialised is carried across as the reference it is.
+ """
+ self.order.insert(position + 1, dict(self.order[position]))
+
+ def clone_frame(self, position: int) -> None:
"""Inserts an independent copy of the frame directly after ``position``.
Each channel's referenced pattern is cloned into a fresh index within that
@@ -110,17 +121,17 @@ def duplicate_frame(self, position: int) -> None:
the other unchanged. Silent slots stay silent.
"""
source_frame = self.order[position]
- duplicate: Dict[GeneratorName, Optional[int]] = {}
+ clone: Dict[GeneratorName, Optional[int]] = {}
for generator in GeneratorName.items():
index = source_frame.get(generator)
if index is None:
- duplicate[generator] = None
+ clone[generator] = None
continue
self.channels[generator].ensure_pattern(index, self.rows_per_pattern)
- duplicate[generator] = self.duplicate_pattern(generator, index)
+ clone[generator] = self.clone_pattern(generator, index)
- self.order.insert(position + 1, duplicate)
+ self.order.insert(position + 1, clone)
def _referenced_indices(self, generator: GeneratorName) -> Set[int]:
return {index for frame in self.order if (index := frame.get(generator)) is not None}
diff --git a/src/sampletones_core/reconstructions/__init__.py b/src/sampletones_core/reconstructions/__init__.py
index 9a93e75e..4608b549 100644
--- a/src/sampletones_core/reconstructions/__init__.py
+++ b/src/sampletones_core/reconstructions/__init__.py
@@ -17,19 +17,19 @@
from .reconstructor.worker import ReconstructorWorker
__all__ = [
+ "ApproximationData",
+ "CandidateProvider",
+ "Criterion",
+ "CrossCorrelationPhaseAligner",
+ "FragmentReconstructionState",
+ "GreedySelector",
+ "PhaseAligner",
"Reconstruction",
+ "ReconstructionState",
"Reconstructor",
"ReconstructorWorker",
- "Criterion",
"Scorer",
- "CandidateProvider",
- "PhaseAligner",
- "SlidingRmsePhaseAligner",
- "CrossCorrelationPhaseAligner",
"Selector",
- "GreedySelector",
+ "SlidingRmsePhaseAligner",
"ViterbiSelector",
- "FragmentReconstructionState",
- "ReconstructionState",
- "ApproximationData",
]
diff --git a/src/sampletones_core/reconstructions/converter/__init__.py b/src/sampletones_core/reconstructions/converter/__init__.py
index 5b845e21..c8513396 100644
--- a/src/sampletones_core/reconstructions/converter/__init__.py
+++ b/src/sampletones_core/reconstructions/converter/__init__.py
@@ -9,11 +9,11 @@
)
__all__ = [
- "ReconstructionConverter",
"ConfigDirectoryFields",
- "reconstruct_file",
- "get_relative_path",
- "get_output_path",
- "get_audio_files",
+ "ReconstructionConverter",
"filter_files",
+ "get_audio_files",
+ "get_output_path",
+ "get_relative_path",
+ "reconstruct_file",
]
diff --git a/src/sampletones_core/reconstructions/converter/conversion.py b/src/sampletones_core/reconstructions/converter/conversion.py
index c653bcb1..1d3dfe15 100644
--- a/src/sampletones_core/reconstructions/converter/conversion.py
+++ b/src/sampletones_core/reconstructions/converter/conversion.py
@@ -17,9 +17,9 @@ def reconstruct_file(arguments: Tuple[Reconstructor, Path, Path]) -> Path:
if reconstruction is not None:
reconstruction.save(output_path)
del reconstruction
- except KeyboardInterrupt as exception:
+ except KeyboardInterrupt:
logger.info("Reconstruction interrupted by user.")
- raise exception
+ raise
except UnsupportedAudioFormatError:
logger.warning(f"Skipping file due to unsupported audio format: {input_path}")
finally:
diff --git a/src/sampletones_core/reconstructions/converter/paths/__init__.py b/src/sampletones_core/reconstructions/converter/paths/__init__.py
index 436e11cf..32e362fc 100644
--- a/src/sampletones_core/reconstructions/converter/paths/__init__.py
+++ b/src/sampletones_core/reconstructions/converter/paths/__init__.py
@@ -10,8 +10,8 @@
__all__ = [
"ConfigDirectoryFields",
- "get_relative_path",
- "get_output_path",
- "get_audio_files",
"filter_files",
+ "get_audio_files",
+ "get_output_path",
+ "get_relative_path",
]
diff --git a/src/sampletones_core/reconstructions/converter/paths/fields.py b/src/sampletones_core/reconstructions/converter/paths/fields.py
index 4da06eb4..9ff18843 100644
--- a/src/sampletones_core/reconstructions/converter/paths/fields.py
+++ b/src/sampletones_core/reconstructions/converter/paths/fields.py
@@ -5,10 +5,8 @@
from sampletones_core.configs import Config
from sampletones_core.configs.display import (
DISPLAY_SEPARATOR,
- GAMMA_PREFIX,
- format_nes_frequency,
- format_sample_rate,
- format_spectrum_method,
+ format_frequencies,
+ format_transformation,
)
from sampletones_core.constants.enums import (
GENERATOR_ABBREVIATION_PATTERN,
@@ -93,10 +91,8 @@ def directory_name(self) -> str:
def display_name(self) -> str:
return DISPLAY_SEPARATOR.join(
[
- format_sample_rate(self.sr),
- format_nes_frequency(self.nf),
- format_spectrum_method(self.sm),
- f"{GAMMA_PREFIX}{self.tg}",
+ format_frequencies(self.sr, self.nf),
+ format_transformation(self.sm, self.tg),
self.gn,
]
)
diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py
index 481ccc32..3069f11b 100644
--- a/src/sampletones_core/reconstructions/converter/paths/utils.py
+++ b/src/sampletones_core/reconstructions/converter/paths/utils.py
@@ -2,13 +2,13 @@
from typing import List, Tuple
from sampletones_core.configs import Config
-from sampletones_core.paths import (
- EXT_FILE_RECONSTRUCTION,
- EXT_FILES_AUDIO,
-)
from sampletones_core.reconstructions.converter.paths.fields import (
ConfigDirectoryFields,
)
+from sampletones_shared.paths.extensions import (
+ EXT_FILE_RECONSTRUCTION,
+ EXT_FILES_AUDIO,
+)
from sampletones_shared.utils.system.paths import to_path
diff --git a/src/sampletones_core/reconstructions/criterion/alignment.py b/src/sampletones_core/reconstructions/criterion/alignment.py
new file mode 100644
index 00000000..bc1bf35e
--- /dev/null
+++ b/src/sampletones_core/reconstructions/criterion/alignment.py
@@ -0,0 +1,38 @@
+from typing import Tuple
+
+from sampletones_shared.array import xp
+
+
+def align_candidates(
+ reference: xp.ndarray,
+ candidates: xp.ndarray,
+) -> Tuple[xp.ndarray, xp.ndarray]:
+ """Brings a target and its candidates to the shape every loss reads them in.
+
+ A loss scores one target against a stack of candidates, so the target becomes a single row and
+ each candidate a row beside it. A lone candidate is read as a stack of one, which lets a caller
+ score a single approximation through the same path as a whole batch.
+
+ Args:
+ reference: Target values, one dimension.
+ candidates: Candidate values, one candidate per row, or a lone candidate.
+
+ Returns:
+ The target as one row, paired with the candidates as a stack of rows.
+
+ Raises:
+ ValueError: If the reference has more than one dimension.
+ ValueError: If the candidate width departs from the reference length.
+ """
+ reference = xp.asarray(reference)
+ candidates = xp.asarray(candidates)
+
+ if reference.ndim != 1:
+ raise ValueError("reference must be 1D")
+
+ if candidates.ndim == 1:
+ candidates = candidates[None, :]
+ elif candidates.shape[1] != reference.shape[0]:
+ raise ValueError(f"candidate width {candidates.shape[1]} does not match reference length {reference.shape[0]}")
+
+ return reference.reshape((1, -1)), candidates
diff --git a/src/sampletones_core/reconstructions/criterion/spectral.py b/src/sampletones_core/reconstructions/criterion/spectral.py
index e355ae82..8324d3ba 100644
--- a/src/sampletones_core/reconstructions/criterion/spectral.py
+++ b/src/sampletones_core/reconstructions/criterion/spectral.py
@@ -4,6 +4,8 @@
from sampletones_core.constants.enums import SpectralDistance
from sampletones_shared.array import xp
+from .alignment import align_candidates
+
def calculate_spectral_loss(
reference: xp.ndarray,
@@ -39,13 +41,26 @@ def calculate_spectral_loss(
match distance:
case SpectralDistance.SQUARED:
- numerator = xp.sqrt(xp.sum(weights * (candidates - reference) ** 2, axis=-1))
+ numerator = xp.sqrt(
+ xp.sum(
+ weights * (candidates - reference) ** 2,
+ axis=-1,
+ )
+ )
denominator = xp.sqrt(xp.sum(weights * reference**2, axis=-1))
case SpectralDistance.ABSOLUTE:
numerator = xp.sum(weights * xp.abs(candidates - reference), axis=-1)
denominator = xp.sum(weights * reference, axis=-1)
case SpectralDistance.BETA_DIVERGENCE:
- numerator = xp.sum(weights * _beta_divergence(reference, candidates, divergence_beta), axis=-1)
+ numerator = xp.sum(
+ weights
+ * _beta_divergence(
+ reference,
+ candidates,
+ divergence_beta,
+ ),
+ axis=-1,
+ )
denominator = xp.sum(weights * reference, axis=-1)
case _:
raise ValueError(f"Unsupported spectral distance: {distance}")
@@ -58,24 +73,18 @@ def _prepare(
candidates: xp.ndarray,
weights: xp.ndarray,
) -> Tuple[xp.ndarray, xp.ndarray, xp.ndarray]:
- reference = xp.asarray(reference)
- candidates = xp.asarray(candidates)
-
- if reference.ndim != 1:
- raise ValueError("reference must be 1D")
-
- if candidates.ndim == 1:
- candidates = candidates[None, :]
- elif candidates.shape[1] != reference.shape[0]:
- raise ValueError(f"candidate width {candidates.shape[1]} does not match reference length {reference.shape[0]}")
-
+ reference, candidates = align_candidates(reference, candidates)
if weights.ndim == 1:
weights = weights.reshape((1, -1))
- return reference.reshape((1, -1)), candidates, weights
+ return reference, candidates, weights
-def _beta_divergence(reference: xp.ndarray, candidates: xp.ndarray, beta: float) -> xp.ndarray:
+def _beta_divergence(
+ reference: xp.ndarray,
+ candidates: xp.ndarray,
+ beta: float,
+) -> xp.ndarray:
reference = reference + SPECTRUM_FLOOR
candidates = candidates + SPECTRUM_FLOOR
diff --git a/src/sampletones_core/reconstructions/criterion/temporal.py b/src/sampletones_core/reconstructions/criterion/temporal.py
index 82c4d05b..5d7819fb 100644
--- a/src/sampletones_core/reconstructions/criterion/temporal.py
+++ b/src/sampletones_core/reconstructions/criterion/temporal.py
@@ -1,5 +1,7 @@
from sampletones_shared.array import xp
+from .alignment import align_candidates
+
def calculate_temporal_loss(
audio: xp.ndarray,
@@ -27,16 +29,7 @@ def calculate_temporal_loss(
ValueError: If the target has more than one dimension.
ValueError: If the candidate width departs from the target length.
"""
- reference = xp.asarray(audio)
- candidates = xp.asarray(approximation)
-
- if reference.ndim != 1:
- raise ValueError("reference must be 1D")
-
- if candidates.ndim == 1:
- candidates = candidates[None, :]
- elif candidates.shape[1] != reference.shape[0]:
- raise ValueError(f"candidate width {candidates.shape[1]} does not match reference length {reference.shape[0]}")
+ reference, candidates = align_candidates(audio, approximation)
rmse = xp.sqrt(xp.mean(xp.square(candidates - reference), axis=-1))
level = xp.sqrt(xp.mean(xp.square(reference)))
diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py
index be2663e5..7fe2fd89 100644
--- a/src/sampletones_core/reconstructions/reconstruction/instructions.py
+++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py
@@ -1,11 +1,12 @@
from __future__ import annotations
-from typing import List
+from typing import Iterable, List
from pydantic import ConfigDict, Field
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.data import DataModel
+from sampletones_core.features import resting_held_features, resting_reference
from sampletones_core.instructions import InstructionData, InstructionUnion
@@ -24,6 +25,10 @@ class InstructionsItem(DataModel):
...,
description="Reference pitch the generator's arpeggio envelope is measured against",
)
+ held_features: List[FeatureKey] = Field(
+ ...,
+ description="Dimensions the channel governs, keeping the value it holds while the generator sounds",
+ )
@classmethod
def create(
@@ -31,6 +36,7 @@ def create(
generator_name: GeneratorName,
instructions: List[InstructionUnion],
initial_pitch: int,
+ held_features: Iterable[FeatureKey],
) -> InstructionsItem:
return InstructionsItem(
generator_name=generator_name,
@@ -42,4 +48,28 @@ def create(
for instruction in instructions
],
initial_pitch=initial_pitch,
+ held_features=list(held_features),
+ )
+
+ @classmethod
+ def resting(cls, generator_name: GeneratorName) -> InstructionsItem:
+ """The stream a channel carries while it stands by, describing no frame.
+
+ A reconstruction holds one stream per channel, so a channel it leaves silent is
+ present and editable: it rests at the reference its first envelope will sound at,
+ and describing a frame is what puts it back in play. Writing no frame leaves every
+ dimension the channel offers to the channel, which is what an edit clearing the last
+ frame records and what an export of this stream reads back.
+
+ Args:
+ generator_name: The channel the resting stream belongs to.
+
+ Returns:
+ InstructionsItem: The stream of a channel that stands by.
+ """
+ return cls.create(
+ generator_name=generator_name,
+ instructions=[],
+ initial_pitch=resting_reference(generator_name),
+ held_features=resting_held_features(generator_name),
)
diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py
index cd3d9185..b066f129 100644
--- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py
+++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py
@@ -3,15 +3,26 @@
import struct
from functools import cached_property
from pathlib import Path
-from typing import Any, Dict, List, Mapping, Optional, Self, Sequence
+from typing import (
+ Any,
+ Dict,
+ Final,
+ Iterable,
+ List,
+ Mapping,
+ Optional,
+ Self,
+ Sequence,
+ Tuple,
+)
from uuid import uuid4
import numpy as np
from pydantic import ConfigDict, Field, ValidationError, field_serializer
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.data import DataModel, Metadata
+from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.data import DataModel, Metadata, MetadataContract
from sampletones_core.exporters import (
GENERATOR_NAME_TO_EXPORTER_MAP,
INSTRUCTION_TO_EXPORTER_MAP,
@@ -21,14 +32,9 @@
)
from sampletones_core.generators.maps import GENERATOR_CLASSES
from sampletones_core.instructions import InstructionUnion
-from sampletones_shared.application import (
- SAMPLETONES_NAME,
- SAMPLETONES_RECONSTRUCTION_DATA_VERSION,
-)
-from sampletones_shared.deployment.version import compare_versions
+from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION
from sampletones_shared.exceptions import (
IncompatibleReconstructionVersionError,
- InvalidMetadataError,
InvalidReconstructionValuesError,
SampleToNESError,
UnhandledReconstructionError,
@@ -44,6 +50,12 @@
from .approximations import ApproximationsItem
from .instructions import InstructionsItem
+RECONSTRUCTION_DATA_CONTRACT: Final[MetadataContract] = MetadataContract(
+ label="Reconstruction data",
+ expected_version=SAMPLETONES_RECONSTRUCTION_DATA_VERSION,
+ error=IncompatibleReconstructionVersionError,
+)
+
class Reconstruction(DataModel):
model_config = ConfigDict(arbitrary_types_allowed=True)
@@ -86,37 +98,80 @@ class Reconstruction(DataModel):
def approximations(self) -> Dict[GeneratorName, np.ndarray]:
return {item.generator_name: item.approximation for item in self.approximations_data}
+ @cached_property
+ def streams(self) -> Dict[GeneratorName, InstructionsItem]:
+ """The instruction stream each channel carries, in channel order.
+
+ This is where the channel set is made whole: a channel the stored data names a stream
+ for keeps it, and one it names none for rests, which is what a channel standing by
+ carries. Every per-channel view reads from here, so each of them covers the four
+ channels however a reconstruction reached memory.
+ """
+ stored = {item.generator_name: item for item in self.instructions_data}
+ return {
+ generator_name: stored.get(generator_name, InstructionsItem.resting(generator_name))
+ for generator_name in GeneratorName.items()
+ }
+
@cached_property
def instructions(self) -> Dict[GeneratorName, List[InstructionUnion]]:
return {
- item.generator_name: [instruction.instruction for instruction in item.instructions]
- for item in self.instructions_data
+ generator_name: [instruction.instruction for instruction in item.instructions]
+ for generator_name, item in self.streams.items()
}
@cached_property
def initial_pitches(self) -> Dict[GeneratorName, int]:
"""The reference pitch each generator's arpeggio envelope is measured against."""
- return {item.generator_name: item.initial_pitch for item in self.instructions_data}
+ return {generator_name: item.initial_pitch for generator_name, item in self.streams.items()}
+
+ @cached_property
+ def held_features(self) -> Dict[GeneratorName, Tuple[FeatureKey, ...]]:
+ """The dimensions each generator leaves to the channel.
+
+ An instruction states every dimension of its frame, so which of them the instrument
+ itself writes is stated here: the rest are the channel's, and an export leaves their
+ envelopes empty for the player to fill from the value it holds.
+ """
+ return {generator_name: tuple(item.held_features) for generator_name, item in self.streams.items()}
+
+ @cached_property
+ def playing_generators(self) -> Tuple[GeneratorName, ...]:
+ """The channels whose instruction stream describes a frame.
+
+ A reconstruction holds a stream for every channel, so this is what says which of them
+ play: the rest stand by, exporting nothing and costing nothing, while describing a
+ frame is what puts one in play.
+ """
+ return tuple(generator_name for generator_name, item in self.streams.items() if item.instructions)
@staticmethod
def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion:
return INSTRUCTION_TO_EXPORTER_MAP[type(instruction)]
@classmethod
- def _derive_initial_pitch(
+ def _exporter_class(
cls,
generator_name: GeneratorName,
instructions: List[InstructionUnion],
- ) -> int:
- """Chooses the reference pitch a channel's arpeggio envelope is measured against.
+ ) -> ExporterTypeUnion:
+ """The exporter a channel's stream is read through.
- The instruction type selects the exporter, matching how `export` resolves one. A
- channel carrying no instructions takes the exporter its generator name pairs with,
- which reports that exporter's resting reference.
+ The instruction type names the exporter wherever the stream describes a frame; a
+ channel standing by takes the exporter its generator name pairs with.
"""
- exporter_class = (
- cls._get_exporter_class(instructions[0]) if instructions else GENERATOR_NAME_TO_EXPORTER_MAP[generator_name]
- )
+ if not instructions:
+ return GENERATOR_NAME_TO_EXPORTER_MAP[generator_name]
+
+ return cls._get_exporter_class(instructions[0])
+
+ @classmethod
+ def _derive_initial_pitch(cls, instructions: List[InstructionUnion]) -> int:
+ """Chooses the reference pitch the arpeggio envelope of a channel in play is measured against.
+
+ The instruction type selects the exporter, matching how `export` resolves one.
+ """
+ exporter_class = cls._get_exporter_class(instructions[0])
return exporter_class.derive_initial_pitch(instructions) # type: ignore[arg-type]
@classmethod
@@ -131,18 +186,27 @@ def create(
) -> Self:
approximation = np.nan_to_num(approximation, nan=0.0)
approximations_data: List[ApproximationsItem] = [
- ApproximationsItem(generator_name=name, approximation=approximation)
- for name, approximation in approximations.items()
+ ApproximationsItem(
+ generator_name=generator_name,
+ approximation=approximations[generator_name],
+ )
+ for generator_name in GeneratorName.items()
+ if generator_name in approximations
]
instructions_data: List[InstructionsItem] = []
- for generator_name, instructions_list in instructions.items():
- channel_instructions = list(instructions_list)
+ for generator_name in GeneratorName.items():
+ channel_instructions = list(instructions.get(generator_name, ()))
+ if not channel_instructions:
+ instructions_data.append(InstructionsItem.resting(generator_name))
+ continue
+
instructions_data.append(
InstructionsItem.create(
generator_name=generator_name,
instructions=channel_instructions,
- initial_pitch=cls._derive_initial_pitch(generator_name, channel_instructions),
+ initial_pitch=cls._derive_initial_pitch(channel_instructions),
+ held_features=(),
)
)
@@ -186,35 +250,39 @@ def update_generator_data(
instructions: List[InstructionUnion],
partial_approximation: np.ndarray,
initial_pitch: int,
+ held_features: Iterable[FeatureKey],
) -> None:
- """Replaces one generator's instructions, audio, and reference pitch.
+ """Replaces one generator's instructions, audio, reference pitch, and held dimensions.
The reference pitch travels with the instructions it produced, so a later export
- measures the arpeggio against the same base the edit was made from.
+ measures the arpeggio against the same base the edit was made from. The held
+ dimensions travel with them for the same reason: the frames state a value for every
+ dimension, and this is what says which of them the instrument itself wrote.
+
+ The channel keeps its place among the streams however the edit leaves it, so one
+ cleared of every frame stands by and stays editable. Its rendered audio lasts as
+ long as it carries samples, which keeps silence out of the stored waveforms.
"""
partial_approximation = np.trim_zeros(partial_approximation, trim="b")
+ rendered = {name: audio for name, audio in self.approximations.items() if name != generator_name}
+ if partial_approximation.size:
+ rendered[generator_name] = partial_approximation
+
max_length = max(
- len(partial_approximation),
- *(len(np.trim_zeros(audio, trim="b")) for audio in self.approximations.values()),
+ (len(np.trim_zeros(audio, trim="b")) for audio in rendered.values()),
+ default=0,
)
- rendered = {
- name: partial_approximation if name == generator_name else audio
- for name, audio in self.approximations.items()
- }
self.approximations_data = self._build_approximations_data(rendered, max_length)
- self.instructions_data = [
- (
- InstructionsItem.create(
- generator_name=generator_name,
- instructions=instructions,
- initial_pitch=initial_pitch,
- )
- if item.generator_name == generator_name
- else item
- )
- for item in self.instructions_data
- ]
+
+ streams = dict(self.streams)
+ streams[generator_name] = InstructionsItem.create(
+ generator_name=generator_name,
+ instructions=instructions,
+ initial_pitch=initial_pitch,
+ held_features=held_features,
+ )
+ self.instructions_data = [streams[name] for name in GeneratorName.items()]
self._invalidate_derived_caches(self)
self.approximation = self._sum_approximations([item.approximation for item in self.approximations_data])
@@ -222,7 +290,7 @@ def get_generator_instructions(
self,
generator_name: GeneratorName,
) -> List[InstructionUnion]:
- return self.instructions.get(generator_name, [])
+ return self.instructions[generator_name]
def detach_source(self) -> None:
"""Drops the local source-audio location so the reconstruction becomes self-contained.
@@ -246,30 +314,35 @@ def with_nes_frequency(self, nes_frequency: int) -> Reconstruction:
if self.config.nes_frequency == nes_frequency:
return self
- library = self.config.library.model_copy(update={"nes_frequency": nes_frequency})
- config = self.config.model_copy(update={"library": library})
- return self._resynthesized(config)
+ return self._resynthesized(self.config.with_library(nes_frequency=nes_frequency))
def _resynthesized(self, config: Config) -> Reconstruction:
"""Re-renders every generator's approximation from its instructions at ``config``.
Each instruction spans ``config.frame_length`` samples, so re-rendering at a new frame
- length re-times the audio. Per-generator arrays are padded to a common length and summed;
- the mixer weight is baked into each generator's output, so a plain sum reproduces the
- stored approximation shape. Drive is left at unity to match the regeneration path.
+ length re-times the audio. The channels describing frames are rendered, padded to a
+ common length and summed; the mixer weight is baked into each generator's output, so a
+ plain sum reproduces the stored approximation shape. Drive is left at unity to match the
+ regeneration path.
"""
rendered: Dict[GeneratorName, np.ndarray] = {}
for generator_name, instructions in self.instructions.items():
- generator = GENERATOR_CLASSES[generator_name](config, generator_name.value)
- if instructions:
- rendered[generator_name] = np.concatenate(
- [generator(instruction, save=True) for instruction in instructions] # type: ignore[arg-type]
- )
- else:
- rendered[generator_name] = np.zeros(0, dtype=np.float32)
+ if not instructions:
+ continue
+
+ generator = GENERATOR_CLASSES[generator_name](
+ config,
+ generator_name.value,
+ )
+ rendered[generator_name] = np.concatenate(
+ [generator(instruction, save=True) for instruction in instructions] # type: ignore[arg-type]
+ )
max_length = max((len(audio) for audio in rendered.values()), default=0)
- approximations_data = self._build_approximations_data(rendered, max_length)
+ approximations_data = self._build_approximations_data(
+ rendered,
+ max_length,
+ )
approximation = self._sum_approximations([item.approximation for item in approximations_data])
retuned: Reconstruction = self.model_copy(
@@ -300,24 +373,29 @@ def _build_approximations_data(
rendered: Mapping[GeneratorName, np.ndarray],
length: int,
) -> List[ApproximationsItem]:
- """Pads each generator's audio to ``length`` and pairs it with its generator name.
+ """Pads each rendered channel's audio to ``length``, in channel order.
- A shared length lets the per-generator arrays stack and sum into the mixed approximation.
+ A shared length lets the per-generator arrays stack and sum into the mixed approximation,
+ and a fixed order keeps a stored reconstruction reading the same however an edit reached it.
"""
return [
ApproximationsItem(
- generator_name=name,
- approximation=pad(audio, 0, length),
+ generator_name=generator_name,
+ approximation=pad(rendered[generator_name], 0, length),
)
- for name, audio in rendered.items()
+ for generator_name in GeneratorName.items()
+ if generator_name in rendered
]
@staticmethod
def _invalidate_derived_caches(reconstruction: Reconstruction) -> None:
"""Drops the memoized per-generator views so they recompute from their backing data."""
reconstruction.__dict__.pop("approximations", None)
+ reconstruction.__dict__.pop("streams", None)
reconstruction.__dict__.pop("instructions", None)
reconstruction.__dict__.pop("initial_pitches", None)
+ reconstruction.__dict__.pop("held_features", None)
+ reconstruction.__dict__.pop("playing_generators", None)
@classmethod
def load(cls, path: Pathlike, fast: bool = True) -> Reconstruction:
@@ -356,20 +434,10 @@ def validate_metadata(metadata: Metadata) -> None:
if not isinstance(metadata, Metadata):
return
- application_metadata = metadata.application_name
- if application_metadata != SAMPLETONES_NAME:
- raise InvalidMetadataError(
- f"Metadata application name mismatch: expected {SAMPLETONES_NAME}, got {application_metadata}"
- )
-
- reconstruction_version = metadata.reconstruction_data_version
- if compare_versions(reconstruction_version, SAMPLETONES_RECONSTRUCTION_DATA_VERSION) != 0:
- raise IncompatibleReconstructionVersionError(
- f"Reconstruction data version mismatch: expected "
- f"{SAMPLETONES_RECONSTRUCTION_DATA_VERSION}, got {reconstruction_version}.",
- expected_version=SAMPLETONES_RECONSTRUCTION_DATA_VERSION,
- actual_version=reconstruction_version,
- )
+ RECONSTRUCTION_DATA_CONTRACT.validate(
+ metadata,
+ metadata.reconstruction_data_version,
+ )
def _validate_instructions(
self,
@@ -390,19 +458,27 @@ def _validate_instructions(
)
def export(self) -> Dict[GeneratorName, Features]:
- features: Dict[GeneratorName, Features] = {}
- for name, instructions in self.instructions.items():
- if not instructions:
- continue
+ """The envelopes each channel exports, one entry per channel the reconstruction holds.
- exporter_class = self._get_exporter_class(instructions[0])
+ A channel standing by describes no frame, so its envelopes come back empty and every
+ reader tells it from a channel that plays by :attr:`Features.has_frames`.
+
+ Returns:
+ Dict[GeneratorName, Features]: The envelope representation of each channel.
+ """
+ features: Dict[GeneratorName, Features] = {}
+ for name in GeneratorName.items():
+ instructions = self.instructions[name]
+ exporter_class = self._exporter_class(name, instructions)
exporter: ExporterUnion = exporter_class()
- self._validate_instructions(exporter, instructions)
- feature: Features = exporter.to_features(
+ if instructions:
+ self._validate_instructions(exporter, instructions)
+
+ features[name] = exporter.to_features(
instructions, # type: ignore[arg-type]
self.initial_pitches[name],
+ self.held_features[name],
)
- features[name] = feature
return features
diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py b/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py
index be47397c..e28386b8 100644
--- a/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py
+++ b/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py
@@ -12,9 +12,9 @@
}
__all__ = [
- "Selector",
+ "SELECTORS",
"GreedySelector",
- "ViterbiSelector",
"ScoredCandidate",
- "SELECTORS",
+ "Selector",
+ "ViterbiSelector",
]
diff --git a/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py b/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py
index d487b696..8f4d76d9 100644
--- a/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py
+++ b/src/sampletones_core/reconstructions/reconstructor/selector/viterbi.py
@@ -1,3 +1,4 @@
+import itertools
from typing import Dict, List, Tuple
import numpy as np
@@ -107,7 +108,7 @@ def _forward_pass(self, frames: ChannelLattice) -> Tuple[List[List[int]], List[f
costs = [state.cost for state in frames[0]]
backpointers: List[List[int]] = []
- for previous_states, current_states in zip(frames, frames[1:]):
+ for previous_states, current_states in itertools.pairwise(frames):
layer_costs: List[float] = []
layer_backpointers: List[int] = []
for state in current_states:
diff --git a/src/sampletones_core/scripts/library.py b/src/sampletones_core/scripts/library.py
index 3384ca25..9c2c4485 100644
--- a/src/sampletones_core/scripts/library.py
+++ b/src/sampletones_core/scripts/library.py
@@ -35,7 +35,10 @@ def on_completed(
logger.info(f"Library {key.filename} generated successfully")
progress_bar.close()
- def on_progress(task_status: TaskStatus, task_progress: TaskProgress) -> None:
+ def on_progress(
+ task_status: TaskStatus,
+ _task_progress: TaskProgress,
+ ) -> None:
total = creator.total_instructions
if total and total != progress_bar.total:
progress_bar.total = total
@@ -56,7 +59,7 @@ def on_cancelled() -> None:
logger.info("Library generation cancelled by user")
progress_bar.close()
- def on_error(exception: Exception) -> None:
+ def on_error(_exception: Exception) -> None:
progress_bar.close()
creator.set_callbacks(
diff --git a/src/sampletones_core/scripts/reconstruction.py b/src/sampletones_core/scripts/reconstruction.py
index 80b49d75..8037aa25 100644
--- a/src/sampletones_core/scripts/reconstruction.py
+++ b/src/sampletones_core/scripts/reconstruction.py
@@ -12,12 +12,15 @@
get_output_path,
)
from sampletones_core.reconstructions.converter import reconstruct_file as _reconstruct_file
+from sampletones_core.scripts.library import generate_library
from sampletones_shared.logger import logger, null_logger
-from .library import generate_library
-
-def reconstruct_file(input_path: Path, config: Config, output_path: Optional[Path] = None) -> None:
+def reconstruct_file(
+ input_path: Path,
+ config: Config,
+ output_path: Optional[Path] = None,
+) -> None:
if output_path is None:
output_path = get_output_path(config, input_path)
@@ -34,7 +37,11 @@ def reconstruct_file(input_path: Path, config: Config, output_path: Optional[Pat
logger.info(f"Reconstruction file saved to {output_path}")
-def reconstruct_directory(input_path: Path, config: Config, output_path: Optional[Path] = None) -> None:
+def reconstruct_directory(
+ input_path: Path,
+ config: Config,
+ output_path: Optional[Path] = None,
+) -> None:
if output_path is None:
output_path = get_output_path(config, input_path)
@@ -52,11 +59,14 @@ def on_start() -> None:
progress_bar.disable = False
logger.info(f"Starting reconstruction for directory {input_path}")
- def on_completed(path: Path) -> None:
+ def on_completed(_path: Path) -> None:
logger.info(f"Reconstruction directory saved to {output_path}")
progress_bar.close()
- def on_progress(task_status: TaskStatus, task_progress: TaskProgress) -> None:
+ def on_progress(
+ task_status: TaskStatus,
+ task_progress: TaskProgress,
+ ) -> None:
progress_bar.disable = False
total = task_progress.total
if total and total != progress_bar.total:
@@ -81,7 +91,7 @@ def on_cancelled() -> None:
logger.info("Reconstruction cancelled by user")
progress_bar.close()
- def on_error(exception: Exception) -> None:
+ def on_error(_exception: Exception) -> None:
progress_bar.close()
converter = ReconstructionConverter(
diff --git a/src/sampletones_core/structures/__init__.py b/src/sampletones_core/structures/__init__.py
index 765d20ee..7673c875 100644
--- a/src/sampletones_core/structures/__init__.py
+++ b/src/sampletones_core/structures/__init__.py
@@ -5,10 +5,10 @@
from .histogram.interval import Interval
__all__ = [
- "Interval",
- "Histogram",
"BidirectionalHashMap",
- "IndexedCollection",
- "IdentifiedCollection",
+ "Histogram",
"Identifiable",
+ "IdentifiedCollection",
+ "IndexedCollection",
+ "Interval",
]
diff --git a/src/sampletones_core/structures/collection/__init__.py b/src/sampletones_core/structures/collection/__init__.py
index d7507cc1..a894dacf 100644
--- a/src/sampletones_core/structures/collection/__init__.py
+++ b/src/sampletones_core/structures/collection/__init__.py
@@ -4,7 +4,7 @@
__all__ = [
"BidirectionalHashMap",
- "IndexedCollection",
- "IdentifiedCollection",
"Identifiable",
+ "IdentifiedCollection",
+ "IndexedCollection",
]
diff --git a/src/sampletones_core/structures/collection/bidirectional.py b/src/sampletones_core/structures/collection/bidirectional.py
index 11eb4cee..4936337e 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 Any, Dict, Generic, Iterator, Optional, TypeVar, Union, cast
+from typing import Dict, Generic, Iterator, Optional, TypeVar, Union, cast
ValueT = TypeVar("ValueT", bound=Hashable)
BidirectionalMapping = Union[
@@ -77,7 +77,10 @@ def __init__(
if mapping:
self.update(mapping)
- def __getitem__(self, key_or_value: Union[str, ValueT]) -> Optional[Union[str, ValueT]]:
+ def __getitem__(
+ self,
+ key_or_value: Union[str, ValueT],
+ ) -> Optional[Union[str, ValueT]]:
"""
Retrieves a value by string key or a key by value.
@@ -129,7 +132,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: Any) -> bool:
+ def __eq__(self, other: object) -> 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 ae29387d..b767cb4c 100644
--- a/src/sampletones_core/structures/collection/indexed.py
+++ b/src/sampletones_core/structures/collection/indexed.py
@@ -198,9 +198,8 @@ def __setitem__(self, key: Union[int, str], item: T) -> None:
index = self.get_index(key)
item_hash = self.hash(item)
- if item_hash in self._items:
- if self._order.forward(item_hash) != index:
- raise ValueError(f"Item '{item!r}' already exists in IndexedCollection")
+ if item_hash in self._items and self._order.forward(item_hash) != index:
+ raise ValueError(f"Item '{item!r}' already exists in IndexedCollection")
self._unset(index, reindex=False)
self._set(index, item_hash, item, reindex=False)
@@ -214,7 +213,7 @@ def __bool__(self) -> bool:
"""
return len(self._order) > 0
- def __eq__(self, value: Any) -> bool:
+ def __eq__(self, value: object) -> bool:
"""
Checks equality between this collection and another object.
diff --git a/src/sampletones_core/structures/histogram/__init__.py b/src/sampletones_core/structures/histogram/__init__.py
index 2b5eb43d..2ad873db 100644
--- a/src/sampletones_core/structures/histogram/__init__.py
+++ b/src/sampletones_core/structures/histogram/__init__.py
@@ -2,6 +2,6 @@
from .interval import Interval
__all__ = [
- "Interval",
"Histogram",
+ "Interval",
]
diff --git a/src/sampletones_core/structures/histogram/histogram.py b/src/sampletones_core/structures/histogram/histogram.py
index bfc0b7cf..0b13c75c 100644
--- a/src/sampletones_core/structures/histogram/histogram.py
+++ b/src/sampletones_core/structures/histogram/histogram.py
@@ -4,7 +4,6 @@
from functools import cached_property, reduce
from types import ModuleType
from typing import (
- Any,
Dict,
Iterator,
List,
@@ -168,7 +167,7 @@ def _validate(self) -> Histogram:
return self
- def __eq__(self, other: Any) -> bool:
+ def __eq__(self, other: object) -> bool:
"""
Check equality with another histogram.
@@ -355,9 +354,8 @@ def _validate_negative_power(
if cls.get_module(exponent) != module:
raise TypeError("Base and exponent must be of the same array type")
- if isinstance(base, NumericClasses) and isinstance(exponent, NumericClasses):
- if base == 0 and exponent < 0:
- raise ZeroDivisionError("Zero cannot be raised to a negative power")
+ if isinstance(base, NumericClasses) and isinstance(exponent, NumericClasses) and base == 0 and exponent < 0:
+ raise ZeroDivisionError("Zero cannot be raised to a negative power")
if isinstance(base, cls):
base = base.densities
diff --git a/src/sampletones_core/structures/tree/__init__.py b/src/sampletones_core/structures/tree/__init__.py
index 4eae3325..94a9bdcf 100644
--- a/src/sampletones_core/structures/tree/__init__.py
+++ b/src/sampletones_core/structures/tree/__init__.py
@@ -1,17 +1,23 @@
from .arguments import Arguments
-from .node import FileSystemNode, GeneratorNode, LibraryNode, TreeNode
+from .factory import create_directory_node
+from .node import ConfigNode, FileSystemNode, GeneratorNode, LibraryNode, TreeNode
from .traversal import TreeTraversal, traverse
from .tree import Tree
from .type import NodeType
+from .visibility import TreeVisibility, resolve_visibility
__all__ = [
+ "Arguments",
+ "ConfigNode",
+ "FileSystemNode",
+ "GeneratorNode",
+ "LibraryNode",
"NodeType",
"Tree",
"TreeNode",
- "FileSystemNode",
- "LibraryNode",
- "GeneratorNode",
- "Arguments",
"TreeTraversal",
+ "TreeVisibility",
+ "create_directory_node",
+ "resolve_visibility",
"traverse",
]
diff --git a/src/sampletones_core/structures/tree/factory.py b/src/sampletones_core/structures/tree/factory.py
new file mode 100644
index 00000000..3b73a97f
--- /dev/null
+++ b/src/sampletones_core/structures/tree/factory.py
@@ -0,0 +1,39 @@
+from pathlib import Path
+from typing import Optional
+
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+
+from .node import ConfigNode, FileSystemNode, TreeNode
+from .type import NodeType
+
+
+def create_directory_node(
+ directory: Path,
+ *,
+ name: str,
+ config: Optional[ConfigDirectoryFields],
+ parent: Optional[TreeNode],
+) -> FileSystemNode:
+ """Builds the directory node that fits the folder, given the configuration its name states.
+
+ A folder stating a reconstruction configuration becomes a :class:`ConfigNode` carrying those
+ fields; a folder stating none becomes a plain :class:`FileSystemNode`. The caller states the
+ fields it read with :meth:`ConfigDirectoryFields.from_directory_name`, so a caller that already
+ read them — a scan of a reconstructions directory — reads each folder name once, and the choice
+ of node class stays here.
+ """
+ if config is None:
+ return FileSystemNode(
+ name,
+ node_type=NodeType.DIRECTORY,
+ filepath=directory,
+ parent=parent,
+ )
+
+ return ConfigNode(
+ name,
+ node_type=NodeType.DIRECTORY,
+ filepath=directory,
+ config=config,
+ parent=parent,
+ )
diff --git a/src/sampletones_core/structures/tree/node.py b/src/sampletones_core/structures/tree/node.py
index 556f1082..9f6b34c5 100644
--- a/src/sampletones_core/structures/tree/node.py
+++ b/src/sampletones_core/structures/tree/node.py
@@ -7,6 +7,7 @@
from sampletones_core.constants.enums import LibraryGeneratorName
from sampletones_core.library import InstructionLibraryKey
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from .type import NodeType
@@ -45,6 +46,41 @@ def copy(self, parent: Optional[TreeNode] = None) -> FileSystemNode:
)
+class ConfigNode(FileSystemNode):
+ """A filesystem node belonging to a reconstruction configuration, carrying the parsed fields.
+
+ A configuration directory encodes its fields in its name, and both the directory itself and the
+ reconstructions inside it are read as belonging to that configuration. Holding the parsed
+ :class:`ConfigDirectoryFields` on the node lets every reader — labels, tooltips, fonts — state
+ the configuration from the node it already has, whatever the node's own filename says.
+ """
+
+ def __init__(
+ self,
+ name: str,
+ node_type: NodeType,
+ filepath: Path,
+ config: ConfigDirectoryFields,
+ parent: Optional[TreeNode] = None,
+ ) -> None:
+ super().__init__(
+ name,
+ node_type=node_type,
+ filepath=filepath,
+ parent=parent,
+ )
+ self.config = config
+
+ def copy(self, parent: Optional[TreeNode] = None) -> ConfigNode:
+ return ConfigNode(
+ self.name,
+ node_type=self.node_type,
+ filepath=self.filepath,
+ config=self.config,
+ parent=parent,
+ )
+
+
class LibraryNode(TreeNode):
def __init__(
self,
diff --git a/src/sampletones_core/structures/tree/tree.py b/src/sampletones_core/structures/tree/tree.py
index 07881c24..a21897bc 100644
--- a/src/sampletones_core/structures/tree/tree.py
+++ b/src/sampletones_core/structures/tree/tree.py
@@ -1,75 +1,50 @@
-from typing import Callable, Dict, Optional, Sequence
+from typing import Callable, Optional, Tuple, Type, TypeVar
from anytree import PreOrderIter
from .node import TreeNode
+TreeNodeT = TypeVar("TreeNodeT", bound=TreeNode)
+
class Tree:
+ """The rows a view renders, held as one root the whole shape hangs from.
+
+ The tree states which rows exist, what they are called and how they nest, and every view reading
+ it shows that one shape. What a view narrows to is the view's own, so several views share a tree
+ and each of them filters on its own.
+ """
+
def __init__(self, root: Optional[TreeNode] = None) -> None:
self.root = root
- self._filter_query: Optional[str] = None
- self._node_visibility: Dict[TreeNode, bool] = {}
def set_root(self, root: Optional[TreeNode]) -> None:
self.root = root
- self.clear_filter()
def get_root(self) -> Optional[TreeNode]:
return self.root
- def apply_filter(
+ def find_nodes(
self,
- query: str,
- predicate: Callable[[TreeNode, str], bool],
- ) -> None:
- if not self.root:
- self._filter_query = query
- self._node_visibility = {}
- return
-
- if not query:
- self.clear_filter()
- return
-
- self._filter_query = query
- matching_nodes = {node for node in PreOrderIter(self.root) if predicate(node, query)}
-
- if not matching_nodes:
- self._node_visibility = {node: False for node in PreOrderIter(self.root)}
- return
-
- nodes_to_show = set(matching_nodes)
- for node in matching_nodes:
- current = node.parent
- while current is not None:
- nodes_to_show.add(current)
- current = current.parent
-
- for descendant in PreOrderIter(node):
- nodes_to_show.add(descendant)
-
- self._node_visibility = {node: node in nodes_to_show for node in PreOrderIter(self.root)}
-
- def clear_filter(self) -> None:
- self._filter_query = None
- self._node_visibility = {}
-
- def is_filtered(self) -> bool:
- return self._filter_query is not None
-
- def is_node_visible(self, node: TreeNode) -> bool:
- if not self.is_filtered():
- return True
-
- return self._node_visibility.get(node, False)
-
- def collect_leaves(self) -> Sequence[TreeNode]:
- if not self.root:
- return []
-
- leaves = [node for node in PreOrderIter(self.root) if node.is_leaf]
- if self.is_filtered():
- return [leaf for leaf in leaves if self.is_node_visible(leaf)]
-
- return leaves
+ node_class: Type[TreeNodeT],
+ predicate: Callable[[TreeNodeT], bool],
+ ) -> Tuple[TreeNodeT, ...]:
+ """Answers every node of ``node_class`` the predicate accepts, in reading order.
+
+ One thing can stand in several places in a tree — a file listed by its configuration and
+ again by the sample it came from — so a caller acting on a thing rather than on a row asks
+ for all of its nodes at once. Naming the node class keeps the answer typed, so the caller
+ reads the fields that class carries.
+ """
+ if self.root is None:
+ return ()
+
+ return tuple(
+ node
+ for node in PreOrderIter(self.root)
+ if isinstance(
+ node,
+ node_class,
+ )
+ and predicate(node)
+ )
diff --git a/src/sampletones_core/structures/tree/type.py b/src/sampletones_core/structures/tree/type.py
index 64dc38c1..98309ea0 100644
--- a/src/sampletones_core/structures/tree/type.py
+++ b/src/sampletones_core/structures/tree/type.py
@@ -7,5 +7,6 @@ class NodeType(StrEnum):
FILE = "file"
LIBRARY = "library"
GROUP = "group"
+ SAMPLE = "sample"
GENERATOR = "generator"
INSTRUCTION = "instruction"
diff --git a/src/sampletones_core/structures/tree/visibility.py b/src/sampletones_core/structures/tree/visibility.py
new file mode 100644
index 00000000..7c9fbbc1
--- /dev/null
+++ b/src/sampletones_core/structures/tree/visibility.py
@@ -0,0 +1,42 @@
+from dataclasses import dataclass
+from typing import FrozenSet, Iterable
+
+from .node import TreeNode
+
+
+@dataclass(frozen=True)
+class TreeVisibility:
+ """The rows a criterion keeps on screen, held as the rows it named and the rows standing above them.
+
+ A named row stays, and so do the rows leading down to it and the rows it holds: a named file is
+ read under the folders it sits in, and a named folder shows what it gathers. Keeping the named
+ rows and their ancestors alone holds the memory to the size of what was found, and a row below a
+ match is answered from its own path upwards.
+ """
+
+ matches: FrozenSet[TreeNode]
+ ancestors: FrozenSet[TreeNode]
+
+ def is_visible(self, node: TreeNode) -> bool:
+ """Whether the row stays on screen: it was named, it leads to a named row, or one holds it."""
+ if node in self.matches or node in self.ancestors:
+ return True
+
+ return any(ancestor in self.matches for ancestor in node.ancestors)
+
+ def should_expand(self, node: TreeNode) -> bool:
+ """Whether the row stands open, which a named row does and so does every row above one."""
+ return node in self.matches or node in self.ancestors
+
+
+def resolve_visibility(matches: Iterable[TreeNode]) -> TreeVisibility:
+ """The visibility a set of named rows resolves to, read once per pass over the tree.
+
+ Args:
+ matches: The rows a criterion named, in any order.
+ """
+ matched = frozenset(matches)
+ return TreeVisibility(
+ matches=matched,
+ ancestors=frozenset(ancestor for node in matched for ancestor in node.ancestors),
+ )
diff --git a/src/sampletones_core/timers/__init__.py b/src/sampletones_core/timers/__init__.py
index 850c1641..f5e56c4c 100644
--- a/src/sampletones_core/timers/__init__.py
+++ b/src/sampletones_core/timers/__init__.py
@@ -8,8 +8,8 @@
"LFSRTimer",
"PhaseTimer",
"Timer",
- "get_frequency_table",
"TimerT",
- "TimerUnion",
"TimerTypeUnion",
+ "TimerUnion",
+ "get_frequency_table",
]
diff --git a/src/sampletones_core/timers/implementation/lfsr.py b/src/sampletones_core/timers/implementation/lfsr.py
index c8123305..da4c88d5 100644
--- a/src/sampletones_core/timers/implementation/lfsr.py
+++ b/src/sampletones_core/timers/implementation/lfsr.py
@@ -196,13 +196,15 @@ def reset(self) -> None:
def validate(self, initials: Initials) -> None:
initial_lfsr, initial_clock = initials if initials is not None else (None, None)
- if initial_lfsr is not None:
- if not isinstance(initial_lfsr, int) or (initial_lfsr < 1 or initial_lfsr > 0x7FFF):
- raise ValueError("Initial LFSR for LFSRTimer must be between 1 and 0x7FFF")
-
- if initial_clock is not None:
- if not isinstance(initial_clock, float) or (initial_clock < 0.0 or initial_clock >= 1.0):
- raise ValueError("Initial clock for LFSRTimer must be between 0.0 and 1.0")
+ if initial_lfsr is not None and (
+ not isinstance(initial_lfsr, int) or (initial_lfsr < 1 or initial_lfsr > 0x7FFF)
+ ):
+ raise ValueError("Initial LFSR for LFSRTimer must be between 1 and 0x7FFF")
+
+ if initial_clock is not None and (
+ not isinstance(initial_clock, float) or (initial_clock < 0.0 or initial_clock >= 1.0)
+ ):
+ raise ValueError("Initial clock for LFSRTimer must be between 0.0 and 1.0")
def get(self) -> Tuple[int, float]:
return self.lfsr, self.clock
diff --git a/src/sampletones_core/timers/implementation/phase.py b/src/sampletones_core/timers/implementation/phase.py
index 262ac22a..8d6e2f42 100644
--- a/src/sampletones_core/timers/implementation/phase.py
+++ b/src/sampletones_core/timers/implementation/phase.py
@@ -100,9 +100,10 @@ def reset(self) -> None:
def validate(self, initials: Initials) -> None:
(initial_phase,) = initials if initials is not None else (None,)
- if initial_phase is not None:
- if not isinstance(initial_phase, float) or (initial_phase < 0.0 or initial_phase >= 1.0):
- raise ValueError("Initial phase for PhaseTimer must be between 0.0 and 1.0")
+ if initial_phase is not None and (
+ not isinstance(initial_phase, float) or (initial_phase < 0.0 or initial_phase >= 1.0)
+ ):
+ raise ValueError("Initial phase for PhaseTimer must be between 0.0 and 1.0")
def get(self) -> Tuple[float]:
return (self.phase,)
diff --git a/src/sampletones_core/timing/__init__.py b/src/sampletones_core/timing/__init__.py
new file mode 100644
index 00000000..11b4d4ca
--- /dev/null
+++ b/src/sampletones_core/timing/__init__.py
@@ -0,0 +1,15 @@
+from .clock import TickClock
+from .distribution import distribute_by_halving, distribute_proportionally
+from .groove import Groove, calculate_groove
+from .metre import Metre
+from .rate import RowRate
+
+__all__ = [
+ "Groove",
+ "Metre",
+ "RowRate",
+ "TickClock",
+ "calculate_groove",
+ "distribute_by_halving",
+ "distribute_proportionally",
+]
diff --git a/src/sampletones_core/timing/clock.py b/src/sampletones_core/timing/clock.py
new file mode 100644
index 00000000..8c93cb20
--- /dev/null
+++ b/src/sampletones_core/timing/clock.py
@@ -0,0 +1,98 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from fractions import Fraction
+from math import floor
+
+
+@dataclass(frozen=True)
+class TickClock:
+ """The audio samples each engine tick spans, held exact so a tick lasts what the engine holds it for.
+
+ A tick is the interrupt the engine consumes one instruction on, and it lasts
+ ``1 / nes_frequency`` seconds whatever rate the audio is rendered at. Where that duration
+ falls between two samples, giving every tick the same rounded length shifts the tempo by
+ the rounding, and the shift accumulates over a song. Spreading the fractional part across
+ consecutive ticks instead holds the running total on the exact clock, so the tempo a
+ :class:`~sampletones_core.timing.groove.Groove` states is the tempo the audio plays at, at
+ any sample rate.
+
+ This is the rule a groove applies, one level down: a groove spreads a fractional ticks-per-row
+ across a pattern's rows, and a tick clock spreads a fractional samples-per-tick across the
+ ticks themselves. Both answer with whole numbers that sum to the exact total.
+
+ Attributes:
+ samples_per_tick: The exact samples one tick spans.
+ """
+
+ samples_per_tick: Fraction
+
+ def __post_init__(self) -> None:
+ if self.samples_per_tick < 1:
+ raise ValueError(f"samples_per_tick must be at least 1, got {self.samples_per_tick}")
+
+ @classmethod
+ def from_parameters(
+ cls,
+ *,
+ sample_rate: int,
+ nes_frequency: int,
+ ) -> TickClock:
+ """Derives the clock a render at ``sample_rate`` runs the engine's ticks on.
+
+ Args:
+ sample_rate: The audio sample rate in Hz.
+ nes_frequency: The engine tick rate in Hz.
+
+ Returns:
+ TickClock: The exact samples one tick spans at those rates.
+
+ Raises:
+ ValueError: If either rate is below 1, or a tick spans less than one sample.
+ """
+ if sample_rate < 1:
+ raise ValueError(f"sample_rate must be at least 1, got {sample_rate}")
+
+ if nes_frequency < 1:
+ raise ValueError(f"nes_frequency must be at least 1, got {nes_frequency}")
+
+ return cls(samples_per_tick=Fraction(sample_rate, nes_frequency))
+
+ @property
+ def is_exact(self) -> bool:
+ """Whether every tick spans the same whole number of samples."""
+ return self.samples_per_tick.denominator == 1
+
+ def samples_at(self, ticks: int) -> int:
+ """The samples the first ``ticks`` ticks span together.
+
+ Args:
+ ticks: How many ticks have elapsed, at least 0.
+
+ Returns:
+ int: The cumulative sample count, within one sample of the exact duration.
+
+ Raises:
+ ValueError: If ``ticks`` is negative.
+ """
+ if ticks < 0:
+ raise ValueError(f"ticks must be at least 0, got {ticks}")
+
+ return floor(self.samples_per_tick * ticks)
+
+ def frame_length(self, tick_index: int) -> int:
+ """The samples the tick at ``tick_index`` spans.
+
+ Taking the difference of two cumulative counts is what makes a run of frame lengths sum
+ to the exact span of the ticks it covers, however the fraction falls.
+
+ Args:
+ tick_index: The tick's position in the run, counted from 0.
+
+ Returns:
+ int: The tick's length in samples.
+
+ Raises:
+ ValueError: If ``tick_index`` is negative.
+ """
+ return self.samples_at(tick_index + 1) - self.samples_at(tick_index)
diff --git a/src/sampletones_core/timing/distribution.py b/src/sampletones_core/timing/distribution.py
new file mode 100644
index 00000000..53d511ab
--- /dev/null
+++ b/src/sampletones_core/timing/distribution.py
@@ -0,0 +1,77 @@
+from typing import List, Sequence, Tuple
+
+
+def _divide_rounding_up(dividend: int, divisor: int) -> int:
+ """Divides two integers, carrying a fractional result up to the next integer."""
+ return -(-dividend // divisor)
+
+
+def distribute_proportionally(
+ total: int,
+ lengths: Sequence[int],
+) -> Tuple[int, ...]:
+ """Shares a tick total among consecutive spans in proportion to their row counts.
+
+ Each span ends at a boundary rounded up from its exact share, so where a share falls
+ between two integers the surplus tick goes to the earlier span. Over a pattern this
+ puts the longer rows on the earlier, metrically stronger positions.
+
+ Only the floor and the ceiling of the average per row ever appear, which is what lets
+ a caller hold every row within an engine's speed range by bounding ``total`` alone.
+
+ Args:
+ total: The tick count the spans share.
+ lengths: The row count of each span, in order, each at least 1.
+
+ Returns:
+ Tuple[int, ...]: One tick total per span, together summing to ``total``.
+
+ Raises:
+ ValueError: If no span is given, or a span holds fewer than one row.
+ """
+ if not lengths:
+ raise ValueError("At least one span is required to share a tick total")
+
+ if any(length < 1 for length in lengths):
+ raise ValueError(f"Every span must hold at least 1 row, got {tuple(lengths)}")
+
+ rows = sum(lengths)
+ shares: List[int] = []
+ cumulative = 0
+ boundary = 0
+ for length in lengths:
+ cumulative += length
+ previous, boundary = boundary, _divide_rounding_up(total * cumulative, rows)
+ shares.append(boundary - previous)
+
+ return tuple(shares)
+
+
+def distribute_by_halving(total: int, rows: int) -> Tuple[int, ...]:
+ """Shares a tick total among rows by halving the span down to single rows.
+
+ The earlier half takes the extra row where the count is odd and the surplus tick
+ where the share is fractional, so within a beat the longer rows fall on the positions
+ a listener hears as strong: the first row, then the halfway row, then the quarters.
+
+ Args:
+ total: The tick count the rows share.
+ rows: How many rows share it, at least 1.
+
+ Returns:
+ Tuple[int, ...]: One tick count per row, together summing to ``total``.
+
+ Raises:
+ ValueError: If fewer than one row is given.
+ """
+ if rows < 1:
+ raise ValueError(f"rows must be at least 1, got {rows}")
+
+ if rows == 1:
+ return (total,)
+
+ left = _divide_rounding_up(rows, 2)
+ right = rows - left
+ halves = distribute_proportionally(total, (left, right))
+
+ return distribute_by_halving(halves[0], left) + distribute_by_halving(halves[1], right)
diff --git a/src/sampletones_core/timing/groove.py b/src/sampletones_core/timing/groove.py
new file mode 100644
index 00000000..368ff36f
--- /dev/null
+++ b/src/sampletones_core/timing/groove.py
@@ -0,0 +1,125 @@
+from dataclasses import dataclass
+from fractions import Fraction
+from math import floor
+from typing import Final, List, Tuple
+
+from sampletones_core.timing.distribution import (
+ distribute_by_halving,
+ distribute_proportionally,
+)
+from sampletones_core.timing.metre import Metre
+from sampletones_core.timing.rate import RowRate
+
+HALF: Final[Fraction] = Fraction(1, 2)
+
+
+@dataclass(frozen=True)
+class Groove:
+ """The engine ticks each row of a pattern lasts.
+
+ An engine that takes one speed value per row reaches a fractional row rate by varying
+ that value from row to row, which is how a tempo its speed column alone cannot state
+ still comes out right on average. The variation is placed by metre, so the longer rows
+ land on the bar, then the beat, then the subdivisions inside a beat.
+
+ Attributes:
+ ticks: One tick count per pattern row, in order.
+ """
+
+ ticks: Tuple[int, ...]
+
+ @property
+ def total_ticks(self) -> int:
+ """How many engine ticks the whole pattern lasts."""
+ return sum(self.ticks)
+
+ @property
+ def mean_ticks_per_row(self) -> Fraction:
+ """The row rate the groove realizes, which states what a bounded groove reached."""
+ return Fraction(self.total_ticks, len(self.ticks))
+
+ @property
+ def is_uniform(self) -> bool:
+ """Whether every row lasts alike, so a single speed value carries the tempo."""
+ return len(set(self.ticks)) == 1
+
+
+def _pattern_ticks(
+ rate: RowRate,
+ rows: int,
+ *,
+ minimum_ticks: int,
+ maximum_ticks: int,
+) -> int:
+ """Rounds a pattern's exact tick count to the nearest integer within the engine's speed range.
+
+ Rounding once, on the pattern, is what makes the pattern's duration the closest the
+ engine reaches; the metre then decides which rows carry the difference. Bounding the
+ pattern total rather than each row keeps every row inside the range as a consequence,
+ since a proportional split yields only the floor and the ceiling of the average.
+
+ Args:
+ rate: The exact ticks one row lasts.
+ rows: The pattern's row count.
+ minimum_ticks: The fewest ticks the engine holds a row for.
+ maximum_ticks: The most ticks the engine holds a row for.
+
+ Returns:
+ int: The tick count the pattern's rows share.
+ """
+ exact = rate.ticks_per_row * rows
+ return min(
+ max(floor(exact + HALF), rows * minimum_ticks),
+ rows * maximum_ticks,
+ )
+
+
+def calculate_groove(
+ rate: RowRate,
+ metre: Metre,
+ *,
+ minimum_ticks: int,
+ maximum_ticks: int,
+) -> Groove:
+ """Builds the per-row tick counts that carry a row rate across one pattern.
+
+ The pattern's tick total is shared among its bars, each bar's among its beats, and
+ each beat's among its rows by halving — one rule applied at three levels, so the
+ surplus ticks settle on the strongest position each level offers.
+
+ Args:
+ rate: The exact ticks one row lasts.
+ metre: The pattern's length and its beat and bar grouping.
+ minimum_ticks: The fewest ticks the engine holds a row for.
+ maximum_ticks: The most ticks the engine holds a row for.
+
+ Returns:
+ Groove: One tick count per row of the pattern.
+ """
+ total = _pattern_ticks(
+ rate,
+ metre.rows,
+ minimum_ticks=minimum_ticks,
+ maximum_ticks=maximum_ticks,
+ )
+ bars = metre.spans
+ bar_lengths = tuple(sum(beats) for beats in bars)
+
+ ticks: List[int] = []
+ for beats, bar_ticks in zip(
+ bars,
+ distribute_proportionally(
+ total,
+ bar_lengths,
+ ),
+ ):
+ for beat_rows, beat_ticks in zip(
+ beats,
+ distribute_proportionally(
+ bar_ticks,
+ beats,
+ ),
+ ):
+ ticks.extend(distribute_by_halving(beat_ticks, beat_rows))
+
+ return Groove(ticks=tuple(ticks))
diff --git a/src/sampletones_core/timing/metre.py b/src/sampletones_core/timing/metre.py
new file mode 100644
index 00000000..8b80eee9
--- /dev/null
+++ b/src/sampletones_core/timing/metre.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Tuple
+
+from sampletones_core.project.settings import ProjectSettings
+
+
+@dataclass(frozen=True)
+class Metre:
+ """The row grouping a pattern is felt in: its length, its beat, and the bar above it.
+
+ ``first_highlight`` is the beat, the unit an actual tempo is read from, and
+ ``second_highlight`` gathers beats into a bar. The bar is what organizes emphases
+ where the beat divides the pattern unevenly; where both divide it cleanly the bar
+ grouping agrees with the beats on their own.
+
+ The pattern length is the hard limit, so a span reaching past the last row ends
+ there and counts as the shorter span it is. This holds at both levels: a pattern of
+ 60 rows against a 16-row bar carries three whole bars and a 12-row one, and a bar
+ shorter than its beat carries a single beat of the rows that remain.
+
+ Attributes:
+ rows: The pattern's row count.
+ first_highlight: The rows one beat spans.
+ second_highlight: The rows one bar spans.
+ """
+
+ rows: int
+ first_highlight: int
+ second_highlight: int
+
+ def __post_init__(self) -> None:
+ if self.rows < 1:
+ raise ValueError(f"rows must be at least 1, got {self.rows}")
+
+ if self.first_highlight < 1:
+ raise ValueError(f"first_highlight must be at least 1, got {self.first_highlight}")
+
+ if self.second_highlight < 1:
+ raise ValueError(f"second_highlight must be at least 1, got {self.second_highlight}")
+
+ @classmethod
+ def from_settings(cls, settings: ProjectSettings, *, rows: int) -> Metre:
+ """Reads the metre a project states, over a pattern of ``rows`` rows.
+
+ The project holds the two highlights while the song holds the pattern length, so
+ the row count arrives beside the settings.
+ """
+ return cls(
+ rows=rows,
+ first_highlight=settings.first_highlight,
+ second_highlight=settings.second_highlight,
+ )
+
+ @property
+ def spans(self) -> Tuple[Tuple[int, ...], ...]:
+ """The whole grouping, as the beat row counts of each consecutive bar.
+
+ Returns:
+ Tuple[Tuple[int, ...], ...]: One entry per bar, each holding that bar's beat
+ row counts in order, so the entries flattened come to ``rows``.
+ """
+ return tuple(
+ self._divide(bar_rows, self.first_highlight)
+ for bar_rows in self._divide(
+ self.rows,
+ self.second_highlight,
+ )
+ )
+
+ @staticmethod
+ def _divide(rows: int, unit: int) -> Tuple[int, ...]:
+ """Cuts a row span into consecutive units, the final one holding what remains."""
+ whole, remainder = divmod(rows, unit)
+ spans = (unit,) * whole
+ return spans + (remainder,) if remainder else spans
diff --git a/src/sampletones_core/timing/rate.py b/src/sampletones_core/timing/rate.py
new file mode 100644
index 00000000..f0210385
--- /dev/null
+++ b/src/sampletones_core/timing/rate.py
@@ -0,0 +1,77 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from fractions import Fraction
+
+from sampletones_core.project.settings import ProjectSettings
+from sampletones_shared.constants.project import (
+ REFERENCE_NES_FREQUENCY,
+ REFERENCE_TEMPO,
+)
+
+
+@dataclass(frozen=True)
+class RowRate:
+ """How long one tracker row lasts, in engine ticks, as the exact ratio a tempo asks for.
+
+ The engine advances a row once every ``ticks_per_row`` ticks of its ``nes_frequency``
+ interrupt, and ``speed`` states that count directly at ``REFERENCE_TEMPO`` and
+ ``REFERENCE_NES_FREQUENCY``, scaling from there with the tempo and the tick rate.
+
+ The ratio is held exact, since a row rate is fractional for most tempi and the
+ fraction is what a groove distributes across a pattern's rows.
+
+ A row rate reads as a tempo in beats per minute once a metre says how many rows one
+ beat spans::
+
+ beats_per_minute = 60 * nes_frequency / (ticks_per_row * first_highlight)
+
+ which at the four-row beat of common time comes to ``6 * tempo / speed``, the figure
+ a tracker prints. The beat is therefore what turns a row rate into an actual tempo.
+
+ Attributes:
+ ticks_per_row: The exact number of engine ticks one row lasts.
+ """
+
+ ticks_per_row: Fraction
+
+ @classmethod
+ def from_parameters(
+ cls,
+ *,
+ tempo: int,
+ speed: int,
+ nes_frequency: int,
+ ) -> RowRate:
+ """Derives the row rate from the three settings that govern it.
+
+ Args:
+ tempo: The project tempo.
+ speed: Engine ticks per row at the reference tempo and tick rate.
+ nes_frequency: The engine tick rate in Hz.
+
+ Returns:
+ RowRate: The exact ticks one row lasts under those settings.
+ """
+ return cls(
+ ticks_per_row=Fraction(
+ speed * nes_frequency * REFERENCE_TEMPO,
+ tempo * REFERENCE_NES_FREQUENCY,
+ ),
+ )
+
+ @classmethod
+ def from_settings(cls, settings: ProjectSettings) -> RowRate:
+ """Derives the row rate a project plays at.
+
+ Args:
+ settings: The project settings holding the tempo, the speed and the tick rate.
+
+ Returns:
+ RowRate: The exact ticks one row of this project lasts.
+ """
+ return cls.from_parameters(
+ tempo=settings.tempo,
+ speed=settings.speed,
+ nes_frequency=settings.nes_frequency,
+ )
diff --git a/src/sampletones_core/trackers/implementation/bitphase.py b/src/sampletones_core/trackers/implementation/bitphase.py
index 83e40cf5..38b3561e 100644
--- a/src/sampletones_core/trackers/implementation/bitphase.py
+++ b/src/sampletones_core/trackers/implementation/bitphase.py
@@ -8,11 +8,11 @@
sample_to_bitphase,
)
from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset
-from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON
from sampletones_core.trackers.artifact import ExportArtifact
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport
from sampletones_core.trackers.scope import ExportScope
+from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON
from sampletones_shared.utils.system.paths import get_filename
DOCUMENT_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope)
diff --git a/src/sampletones_core/trackers/implementation/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py
index cebbd102..dfe9bee0 100644
--- a/src/sampletones_core/trackers/implementation/famitracker.py
+++ b/src/sampletones_core/trackers/implementation/famitracker.py
@@ -2,13 +2,15 @@
from typing import FrozenSet, List, Optional
from sampletones_core.exporters.truncation import EnvelopeTruncation
+from sampletones_core.formats.famitracker.builder import build_instrument
from sampletones_core.formats.famitracker.export import write_ftm
from sampletones_core.formats.famitracker.instrument import write_fti
-from sampletones_core.formats.famitracker.model.instrument import Instrument2A03
-from sampletones_core.formats.famitracker.sequences.features import features_to_instrument_sequences
-from sampletones_core.formats.famitracker.specification.instruments import STANDALONE_INSTRUMENT_INDEX
-from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS
-from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE
+from sampletones_core.formats.famitracker.specification.instruments import (
+ STANDALONE_INSTRUMENT_INDEX,
+)
+from sampletones_core.formats.famitracker.specification.sequences import (
+ MAX_SEQUENCE_ITEMS,
+)
from sampletones_core.trackers.artifact import ExportArtifact
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.request import (
@@ -17,6 +19,7 @@
SampleExport,
)
from sampletones_core.trackers.scope import ExportScope
+from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE
from sampletones_shared.utils.system.paths import get_filename
SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope)
@@ -46,26 +49,18 @@ def write_instrument(
destination: Path,
request: InstrumentExport,
) -> ExportArtifact:
- features = request.features
- sequences = features_to_instrument_sequences(
- volume=features.volume,
- arpeggio=features.arpeggio,
- pitch=features.pitch,
- hi_pitch=features.hi_pitch,
- duty_cycle=features.duty_cycle,
+ instrument = build_instrument(
+ STANDALONE_INSTRUMENT_INDEX,
+ request.name,
+ request.features,
loop=request.loop,
)
- instrument = Instrument2A03(
- index=STANDALONE_INSTRUMENT_INDEX,
- name=request.name,
- sequences=sequences,
- )
write_fti(destination, instrument)
return ExportArtifact(
paths=(destination,),
truncation=EnvelopeTruncation.measure(
- features.frame_count,
+ request.features.frame_count,
MAX_SEQUENCE_ITEMS,
),
)
@@ -80,7 +75,12 @@ def write_sample(
paths: List[Path] = []
truncations: List[Optional[EnvelopeTruncation]] = []
for instrument in request.instruments:
- filepath = destination.with_name(get_filename(instrument.name, EXT_FILE_INSTRUMENT))
+ filepath = destination.with_name(
+ get_filename(
+ instrument.name,
+ EXT_FILE_INSTRUMENT,
+ )
+ )
artifact = self.write_instrument(filepath, instrument)
paths.extend(artifact.paths)
truncations.append(artifact.truncation)
diff --git a/src/sampletones_core/utils/display.py b/src/sampletones_core/utils/display.py
index c9a3b45b..fe330b6e 100644
--- a/src/sampletones_core/utils/display.py
+++ b/src/sampletones_core/utils/display.py
@@ -70,9 +70,14 @@ def display_volume(value: Optional[int]) -> str:
def display_transpose(value: Optional[int]) -> str:
- if value is None or value == 0:
+ """Render a transpose as a signed two-digit offset, or ``...`` for an empty one.
+
+ An explicit zero reads ``+00``, since a row storing it resets the channel's
+ transpose to the sample's own pitch, while an empty cell keeps whatever
+ transpose is already in force.
+ """
+ if value is None:
return NOTE_BLANK
- sign = PLUS if value > 0 else MINUS
- abs_value = abs(value)
- return f"{sign}{abs_value:02X}"
+ sign = PLUS if value >= 0 else MINUS
+ return f"{sign}{abs(value):02X}"
diff --git a/src/sampletones_shared/array.py b/src/sampletones_shared/array.py
index a4d8c8d3..006271a4 100644
--- a/src/sampletones_shared/array.py
+++ b/src/sampletones_shared/array.py
@@ -43,14 +43,14 @@ def _preload_cuda_libraries() -> None:
except (AttributeError, ImportError, ModuleNotFoundError):
import warnings
- from sampletones_shared.exceptions import CuPyNotInstalledWarning
+ from sampletones_shared.exceptions import CuPyNotInstalledWarning # pylint: disable=ungrouped-imports
def _format_warning_no_location(
message: Union[Warning, str],
category: Type[Warning],
- filename: str,
- lineno: int,
- line: Optional[str] = None,
+ filename: str, # pylint: disable=unused-argument
+ lineno: int, # pylint: disable=unused-argument
+ line: Optional[str] = None, # pylint: disable=unused-argument
) -> str:
return f"{category.__name__}: {message}\n"
@@ -74,8 +74,8 @@ def to_numpy(array: Union[np.ndarray, "xp.ndarray"]) -> np.ndarray:
__all__ = [
- "xp",
- "xp_typing",
"CUPY_AVAILABLE",
"to_numpy",
+ "xp",
+ "xp_typing",
]
diff --git a/src/sampletones_shared/constants/nes.py b/src/sampletones_shared/constants/nes.py
new file mode 100644
index 00000000..5de3bd6c
--- /dev/null
+++ b/src/sampletones_shared/constants/nes.py
@@ -0,0 +1,10 @@
+from typing import Final
+
+# Console refresh rates
+NTSC_FREQUENCY: Final[int] = 60
+PAL_FREQUENCY: Final[int] = 50
+
+# Engine refresh rate
+DEFAULT_NES_FREQUENCY: Final[int] = NTSC_FREQUENCY
+MIN_NES_FREQUENCY: Final[int] = 15
+MAX_NES_FREQUENCY: Final[int] = 300
diff --git a/src/sampletones_shared/constants/project.py b/src/sampletones_shared/constants/project.py
index db32c5b5..88fbac35 100644
--- a/src/sampletones_shared/constants/project.py
+++ b/src/sampletones_shared/constants/project.py
@@ -1,5 +1,7 @@
from typing import Final
+from sampletones_shared.constants.nes import NTSC_FREQUENCY
+
# Project archive layout
PROJECT_DOCUMENT_NAME: Final[str] = "project.json"
RECONSTRUCTIONS_DIRECTORY: Final[str] = "reconstructions"
@@ -19,12 +21,12 @@
# Tick formula calibration
# speed == ticks_per_row at these reference values
REFERENCE_TEMPO: Final[int] = 150
-REFERENCE_NES_FREQUENCY: Final[int] = 60
+REFERENCE_NES_FREQUENCY: Final[int] = NTSC_FREQUENCY
# Song timing
DEFAULT_TEMPO: Final[int] = 150
-MIN_TEMPO: Final[int] = 1
-MAX_TEMPO: Final[int] = 300
+MIN_TEMPO: Final[int] = 32
+MAX_TEMPO: Final[int] = 255
DEFAULT_SPEED: Final[int] = 6
MIN_SPEED: Final[int] = 1
@@ -34,3 +36,9 @@
DEFAULT_ROWS_PER_PATTERN: Final[int] = 64
MIN_ROWS_PER_PATTERN: Final[int] = 1
MAX_ROWS_PER_PATTERN: Final[int] = 256
+
+# Metric highlights
+DEFAULT_FIRST_HIGHLIGHT: Final[int] = 4
+DEFAULT_SECOND_HIGHLIGHT: Final[int] = 16
+MIN_HIGHLIGHT: Final[int] = 1
+MAX_HIGHLIGHT: Final[int] = MAX_ROWS_PER_PATTERN
diff --git a/src/sampletones_shared/constants/symbols.py b/src/sampletones_shared/constants/symbols.py
index 38f9f1e6..05f61f5f 100644
--- a/src/sampletones_shared/constants/symbols.py
+++ b/src/sampletones_shared/constants/symbols.py
@@ -1,6 +1,7 @@
from typing import Final, Tuple
HEXADECIMAL: Final[str] = "0123456789ABCDEF"
+HASH: Final[str] = "#"
DOT: Final[str] = "."
UNDERSCORE: Final[str] = "_"
MIXED: Final[str] = "?"
diff --git a/src/sampletones_shared/display.py b/src/sampletones_shared/display.py
new file mode 100644
index 00000000..8df5d055
--- /dev/null
+++ b/src/sampletones_shared/display.py
@@ -0,0 +1,23 @@
+from typing import Final
+
+from pydantic import BaseModel, Field
+
+UNLIMITED_FRAME_RATE: Final[int] = 0
+
+
+class Resolution(BaseModel, frozen=True, extra="forbid"):
+ """A size in pixels, as a window opens at or a monitor reports."""
+
+ width: int = Field(ge=1)
+ height: int = Field(ge=1)
+
+ def __str__(self) -> str:
+ return f"{self.width}x{self.height}"
+
+ def fits_within(self, max_width: int, max_height: int) -> bool:
+ """Whether the size stays inside the given bound on both axes."""
+ return self.width <= max_width and self.height <= max_height
+
+ def reaches(self, min_width: int, min_height: int) -> bool:
+ """Whether the size meets the given minimum on both axes."""
+ return self.width >= min_width and self.height >= min_height
diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py
index 8d886f31..169cd46b 100644
--- a/src/sampletones_shared/exceptions/__init__.py
+++ b/src/sampletones_shared/exceptions/__init__.py
@@ -1,4 +1,4 @@
-from .audio import PlaybackError, UnsupportedAudioFormatError
+from .audio import AudioWriteError, PlaybackError, UnsupportedAudioFormatError
from .base import SampleToNESError
from .callback import CallbackQueueStop
from .cuda import CuPyNotInstalledWarning
@@ -42,42 +42,43 @@
from .window import WindowError, WindowNotAvailableError
__all__ = [
- "SampleToNESError",
- "LibraryError",
- "NoLibraryDataError",
- "LoadLibraryError",
- "InvalidLibraryDataError",
+ "AudioWriteError",
+ "CallbackQueueStop",
+ "CuPyNotInstalledWarning",
+ "DeserializationError",
+ "FileDialogUnavailableError",
+ "IncompatibleLibraryDataVersionError",
+ "IncompatibleProjectVersionError",
+ "IncompatibleReconstructionVersionError",
+ "IncompleteHistogramRebinningWarning",
+ "IncorrectReconstructionDataError",
"InstructionTypeMismatchError",
+ "InvalidLibraryDataError",
"InvalidLibraryDataValuesError",
- "IncompatibleLibraryDataVersionError",
- "UnhandledLibraryError",
+ "InvalidMetadataError",
+ "InvalidProjectDataValuesError",
+ "InvalidReconstructionError",
+ "InvalidReconstructionValuesError",
+ "LanguageError",
"LibraryDisplayError",
- "UnsupportedAudioFormatError",
+ "LibraryError",
+ "LoadLibraryError",
+ "LoadProjectError",
+ "LoadReconstructionError",
+ "MalformedTextKeyError",
+ "MissingProjectDataFileError",
+ "MissingTextError",
+ "NoFilesToProcessError",
+ "NoLibraryDataError",
+ "NotAValidArchiveError",
"PlaybackError",
"ReconstructionError",
- "LoadReconstructionError",
- "InvalidReconstructionError",
- "InvalidReconstructionValuesError",
- "IncompatibleReconstructionVersionError",
+ "SampleToNESError",
+ "SerializationError",
+ "UnhandledLibraryError",
+ "UnhandledProjectError",
"UnhandledReconstructionError",
- "NoFilesToProcessError",
+ "UnsupportedAudioFormatError",
"WindowError",
"WindowNotAvailableError",
- "SerializationError",
- "DeserializationError",
- "InvalidMetadataError",
- "LoadProjectError",
- "IncompatibleProjectVersionError",
- "NotAValidArchiveError",
- "IncorrectReconstructionDataError",
- "InvalidProjectDataValuesError",
- "MissingProjectDataFileError",
- "UnhandledProjectError",
- "CuPyNotInstalledWarning",
- "CallbackQueueStop",
- "IncompleteHistogramRebinningWarning",
- "FileDialogUnavailableError",
- "LanguageError",
- "MalformedTextKeyError",
- "MissingTextError",
]
diff --git a/src/sampletones_shared/exceptions/audio.py b/src/sampletones_shared/exceptions/audio.py
index 86d14e87..c7ef7969 100644
--- a/src/sampletones_shared/exceptions/audio.py
+++ b/src/sampletones_shared/exceptions/audio.py
@@ -11,3 +11,7 @@ class UnsupportedAudioFormatError(AudioError):
class PlaybackError(AudioError):
"""Base class for exceptions raised during playback."""
+
+
+class AudioWriteError(AudioError):
+ """Exception raised when audio cannot be written to a file."""
diff --git a/src/sampletones_shared/logger/__init__.py b/src/sampletones_shared/logger/__init__.py
index 074be694..12600795 100644
--- a/src/sampletones_shared/logger/__init__.py
+++ b/src/sampletones_shared/logger/__init__.py
@@ -6,9 +6,9 @@
null_logger = NullLogger()
__all__ = [
- "logger",
- "null_logger",
"Logger",
- "NullLogger",
"LoggerProtocol",
+ "NullLogger",
+ "logger",
+ "null_logger",
]
diff --git a/src/sampletones_shared/meta/singleton.pyi b/src/sampletones_shared/meta/singleton.pyi
index 2de24fde..bb3a3e72 100644
--- a/src/sampletones_shared/meta/singleton.pyi
+++ b/src/sampletones_shared/meta/singleton.pyi
@@ -1,15 +1,15 @@
import threading
from typing import Any, Dict, Optional, Tuple, Type, TypeVar
-T = TypeVar("T")
+_T = TypeVar("_T")
class SingletonMeta(type):
_instances: Dict[Type[Any], Any]
_instance_lock: threading.Lock
_lock: threading.Lock
def __init__(cls, name: str, bases: Tuple[Type[Any], ...], namespace: Dict[str, Any]) -> None: ...
- def __call__(self: Type[T], *args: Any, **kwargs: Any) -> T: ...
- def get_instance(self: Type[T]) -> Optional[T]: ...
+ def __call__(self: Type[_T], *args: Any, **kwargs: Any) -> _T: ...
+ def get_instance(self: Type[_T]) -> Optional[_T]: ...
def has_instance(self) -> bool: ...
def clear_instances(self) -> None: ...
def clear_instance(self, target_cls: Type[Any]) -> None: ...
diff --git a/src/sampletones_shared/meta/source/classes.py b/src/sampletones_shared/meta/source/classes.py
new file mode 100644
index 00000000..02bac0a3
--- /dev/null
+++ b/src/sampletones_shared/meta/source/classes.py
@@ -0,0 +1,26 @@
+import ast
+from typing import List
+
+from sampletones_shared.meta.source.nodes import terminal_name
+
+
+def declared_subclasses(tree: ast.Module, base: str) -> List[str]:
+ """The classes a module declares over a named base, wherever in the module they sit.
+
+ A base is matched by the identifier the class states it under, so a module reaching it through
+ `from package import Base` and one writing `package.Base` are both read. This is what lets a
+ check find a family of classes by what they derive from while they live wherever their domain
+ lives.
+
+ Args:
+ tree: Parsed module to read.
+ base: Identifier the base class is spelled by.
+
+ Returns:
+ List[str]: The class names, outermost declarations first.
+ """
+ return [
+ node.name
+ for node in ast.walk(tree)
+ if isinstance(node, ast.ClassDef) and any(terminal_name(parent) == base for parent in node.bases)
+ ]
diff --git a/src/sampletones_shared/meta/source/modules.py b/src/sampletones_shared/meta/source/modules.py
index 47905e26..d2b5efd7 100644
--- a/src/sampletones_shared/meta/source/modules.py
+++ b/src/sampletones_shared/meta/source/modules.py
@@ -8,6 +8,8 @@
SOURCE_PATTERN: Final[str] = "*.py"
SOURCE_ENCODING: Final[str] = "utf-8-sig"
HIDDEN_PREFIX: Final[str] = "."
+PACKAGE_INITIALIZER: Final[str] = "__init__"
+MODULE_SEPARATOR: Final[str] = "."
@dataclass(frozen=True)
@@ -46,6 +48,27 @@ def parse_module(path: Path) -> SourceModule:
)
+def module_name(path: Path, root: Path) -> str:
+ """The dotted name an import statement reaches a source file by.
+
+ A check that finds a module by reading it can then reach the objects it declares, which is what
+ lets a static sweep and a runtime read describe the same module.
+
+ Args:
+ path: Source file under the root.
+ root: Directory imports resolve from, such as the source root.
+
+ Returns:
+ str: The dotted name, where a package's `__init__.py` names the package itself.
+
+ Raises:
+ ValueError: If the file sits outside the root.
+ """
+ relative = path.relative_to(root).with_suffix("")
+ parts = relative.parts[:-1] if relative.name == PACKAGE_INITIALIZER else relative.parts
+ return MODULE_SEPARATOR.join(parts)
+
+
def is_visible(path: Path) -> bool:
"""States whether every component of a path is a visible name."""
return all(not part.startswith(HIDDEN_PREFIX) for part in path.parts)
@@ -55,15 +78,31 @@ def source_paths(roots: Iterable[Path]) -> List[Path]:
"""Every Python file under the given roots, in path order.
The sweep visits visible paths, so a virtual environment or a tooling cache sitting inside a
- root stays aside from a whole-repository run.
+ root stays aside from a whole-repository run. A check built on a sweep that reads nothing
+ reports nothing, which reads as a clean tree, so each root must name a directory and the roots
+ together must hold source to read.
Args:
roots: Directories to search.
Returns:
List[Path]: The paths found, each listed once however many roots hold it.
+
+ Raises:
+ NotADirectoryError: If a root names something other than a directory, such as the
+ `__init__.py` a package resource resolves to.
+ FileNotFoundError: If the roots together hold no Python file.
"""
- found = {path for root in roots for path in root.rglob(SOURCE_PATTERN) if is_visible(path)}
+ directories = list(roots)
+ for root in directories:
+ if not root.is_dir():
+ raise NotADirectoryError(f"The source root {root} names no directory to sweep")
+
+ found = {path for root in directories for path in root.rglob(SOURCE_PATTERN) if is_visible(path)}
+ if not found:
+ listed = ", ".join(str(root) for root in directories)
+ raise FileNotFoundError(f"The source roots hold no {SOURCE_PATTERN} file to read: {listed}")
+
return sorted(found)
@@ -77,6 +116,8 @@ def discover_modules(roots: Iterable[Path]) -> List[SourceModule]:
List[SourceModule]: One entry per file found.
Raises:
+ NotADirectoryError: If a root names something other than a directory.
+ FileNotFoundError: If the roots together hold no Python file.
SyntaxError: If a file holds source Python rejects.
"""
return [parse_module(path) for path in source_paths(roots)]
diff --git a/src/sampletones_shared/meta/source/packages.py b/src/sampletones_shared/meta/source/packages.py
new file mode 100644
index 00000000..f19a3899
--- /dev/null
+++ b/src/sampletones_shared/meta/source/packages.py
@@ -0,0 +1,26 @@
+from pathlib import Path
+
+from sampletones_shared.paths.source import SOURCE_ROOT
+
+
+def package_directory(name: str, *parts: str) -> Path:
+ """The directory a package occupies, named by path rather than by import.
+
+ A source check reads the tree it checks, so taking a package from the source root keeps the
+ check free of importing the code under it and free of the layout of any one installation.
+
+ Args:
+ name: Top-level package name.
+ parts: Subpackage names, innermost last.
+
+ Returns:
+ Path: The directory the package occupies.
+
+ Raises:
+ NotADirectoryError: If the source root holds no directory at that path.
+ """
+ directory = SOURCE_ROOT.joinpath(name, *parts)
+ if not directory.is_dir():
+ raise NotADirectoryError(f"The source root {SOURCE_ROOT} holds no package directory at {directory}")
+
+ return directory
diff --git a/src/sampletones_shared/paths.py b/src/sampletones_shared/paths.py
deleted file mode 100644
index a6ad14b6..00000000
--- a/src/sampletones_shared/paths.py
+++ /dev/null
@@ -1,11 +0,0 @@
-import sys
-from importlib.resources import files
-from pathlib import Path
-from typing import Final, Optional
-
-_BUNDLE_ROOT: Final[Optional[str]] = getattr(sys, "_MEIPASS", None)
-
-CONFIG_DIRECTORY: Final[Path] = (
- Path(_BUNDLE_ROOT) / "config" if _BUNDLE_ROOT is not None else Path(str(files("sampletones_config")))
-)
-REPOSITORY_ROOT: Final[Path] = CONFIG_DIRECTORY.parents[1]
diff --git a/src/sampletones_shared/paths/__init__.py b/src/sampletones_shared/paths/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/src/sampletones_shared/paths/extensions.py b/src/sampletones_shared/paths/extensions.py
new file mode 100644
index 00000000..857c1c91
--- /dev/null
+++ b/src/sampletones_shared/paths/extensions.py
@@ -0,0 +1,24 @@
+from typing import Final, Tuple
+
+EXT_FILE_JSON: Final[str] = ".json"
+EXT_FILE_YAML: Final[str] = ".yaml"
+EXT_FILE_LIBRARY: Final[str] = ".ins"
+EXT_FILE_INSTRUMENT: Final[str] = ".fti"
+EXT_FILE_RECONSTRUCTION: Final[str] = ".stn"
+EXT_FILE_PROJECT: Final[str] = ".stp"
+EXT_FILE_MODULE: Final[str] = ".ftm"
+EXT_FILE_BITPHASE: Final[str] = ".btp"
+EXT_FILE_WAVE: Final[str] = ".wav"
+EXT_FILE_MP3: Final[str] = ".mp3"
+EXT_FILE_FLAC: Final[str] = ".flac"
+EXT_FILE_OGG: Final[str] = ".ogg"
+EXT_FILE_AIFF: Final[str] = ".aiff"
+EXT_FILE_AU: Final[str] = ".au"
+EXT_FILES_AUDIO: Final[Tuple[str, ...]] = (
+ EXT_FILE_WAVE,
+ EXT_FILE_MP3,
+ EXT_FILE_FLAC,
+ EXT_FILE_OGG,
+ EXT_FILE_AIFF,
+ EXT_FILE_AU,
+)
diff --git a/src/sampletones_shared/paths/resources.py b/src/sampletones_shared/paths/resources.py
new file mode 100644
index 00000000..9da9292d
--- /dev/null
+++ b/src/sampletones_shared/paths/resources.py
@@ -0,0 +1,25 @@
+import sys
+from importlib.resources import files
+from pathlib import Path
+from typing import Final, Optional
+
+_BUNDLE_ROOT: Final[Optional[str]] = getattr(sys, "_MEIPASS", None)
+
+CONFIG_DIRECTORY: Final[Path] = (
+ Path(_BUNDLE_ROOT) / "config" if _BUNDLE_ROOT is not None else Path(str(files("sampletones_config")))
+)
+
+ASSETS_DIRECTORY: Final[str] = "assets"
+
+ICON_DIRECTORY: Final[str] = "icons"
+ICON_WIN_FILENAME: Final[str] = "sampletones.ico"
+ICON_UNIX_FILENAME: Final[str] = "sampletones.png"
+ICON_VECTOR_FILENAME: Final[str] = "sampletones.svg"
+
+FONT_DIRECTORY: Final[str] = "fonts"
+FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf"
+FONT_SANS_BOLD: Final[str] = "SourceSans3-Bold.ttf"
+FONT_SANS_ITALIC: Final[str] = "SourceSans3-Italic.ttf"
+FONT_MONO_REGULAR: Final[str] = "RobotoMono-Regular.ttf"
+FONT_MONO_BOLD: Final[str] = "RobotoMono-Bold.ttf"
+FONT_ICON: Final[str] = "DejaVuSans.ttf"
diff --git a/src/sampletones_shared/paths/source.py b/src/sampletones_shared/paths/source.py
new file mode 100644
index 00000000..51dd98fa
--- /dev/null
+++ b/src/sampletones_shared/paths/source.py
@@ -0,0 +1,5 @@
+from pathlib import Path
+from typing import Final
+
+SOURCE_ROOT: Final[Path] = Path(__file__).resolve().parents[2]
+REPOSITORY_ROOT: Final[Path] = SOURCE_ROOT.parent
diff --git a/src/sampletones_shared/paths/user.py b/src/sampletones_shared/paths/user.py
new file mode 100644
index 00000000..6a34673d
--- /dev/null
+++ b/src/sampletones_shared/paths/user.py
@@ -0,0 +1,23 @@
+from pathlib import Path
+from typing import Final
+
+from platformdirs import user_config_dir, user_data_dir, user_documents_path
+
+from sampletones_shared.application import (
+ SAMPLETONES_GROUP,
+ SAMPLETONES_NAME,
+)
+
+USER_PATH_DOCUMENTS: Final[Path] = Path(user_documents_path()) / SAMPLETONES_NAME
+USER_PATH_DATA: Final[Path] = Path(user_data_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP))
+USER_PATH_CONFIG: Final[Path] = Path(user_config_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP))
+
+LIBRARY_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "instructions"
+RECONSTRUCTIONS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "reconstructions"
+PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects"
+CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json"
+APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml"
+
+PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True)
+LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True)
+RECONSTRUCTIONS_DIRECTORY.mkdir(parents=True, exist_ok=True)
diff --git a/src/sampletones_shared/utils/agreement.py b/src/sampletones_shared/utils/agreement.py
new file mode 100644
index 00000000..68abcf84
--- /dev/null
+++ b/src/sampletones_shared/utils/agreement.py
@@ -0,0 +1,59 @@
+from __future__ import annotations
+
+from collections.abc import Hashable
+from dataclasses import dataclass
+from typing import FrozenSet, Generic, Iterable, TypeVar
+
+ValueT = TypeVar("ValueT", bound=Hashable)
+
+
+@dataclass(frozen=True)
+class Agreement(Generic[ValueT]):
+ """Whether a group of sources holds one value in common.
+
+ Three outcomes are kept apart: the group is empty, every source holds the same
+ value, or the sources hold differing ones. Reporting the agreed value separately
+ from the fact of agreement is what lets an absent value count as agreement — a
+ transpose every channel leaves empty is a value they share.
+ """
+
+ distinct: FrozenSet[ValueT]
+
+ @classmethod
+ def collapse(cls, values: Iterable[ValueT]) -> Agreement[ValueT]:
+ return cls(distinct=frozenset(values))
+
+ @property
+ def is_absent(self) -> bool:
+ return not self.distinct
+
+ @property
+ def is_unanimous(self) -> bool:
+ return len(self.distinct) == 1
+
+ @property
+ def is_mixed(self) -> bool:
+ return len(self.distinct) > 1
+
+ @property
+ def value(self) -> ValueT:
+ """The value every source holds.
+
+ Raises:
+ ValueError: if the group is empty or its sources differ, so that no
+ single value describes them.
+ """
+ if not self.is_unanimous:
+ raise ValueError(f"Agreement over {len(self.distinct)} distinct values holds no single value")
+
+ return next(iter(self.distinct))
+
+ def resolve(self, *, absent: ValueT, mixed: ValueT) -> ValueT:
+ """The agreed value, or the stand-in named for the outcome that reached instead."""
+ if self.is_absent:
+ return absent
+
+ if self.is_mixed:
+ return mixed
+
+ return self.value
diff --git a/src/sampletones_shared/utils/color.py b/src/sampletones_shared/utils/color.py
index 35ba9f78..63f2a250 100644
--- a/src/sampletones_shared/utils/color.py
+++ b/src/sampletones_shared/utils/color.py
@@ -33,6 +33,25 @@ def blend(start: ColorRGBA, end: ColorRGBA, fraction: float) -> ColorRGBA:
return (int(channels[0]), int(channels[1]), int(channels[2]), int(channels[3]))
+def composite(base: ColorRGBA, overlay: ColorRGBA) -> ColorRGBA:
+ """Return the colour ``overlay`` makes when it is drawn over ``base``.
+
+ Each colour carries its own alpha, and the result carries the coverage the two reach
+ together, so a pair of translucent washes bound for a single layer reads as it would if
+ the layer held both. A fully transparent pair returns ``base``.
+ """
+ base_channels = np.array(base, dtype=np.float64) / MAX_CHANNEL_VALUE
+ overlay_channels = np.array(overlay, dtype=np.float64) / MAX_CHANNEL_VALUE
+ base_alpha = base_channels[3] * (1.0 - overlay_channels[3])
+ alpha = overlay_channels[3] + base_alpha
+ if alpha == 0.0:
+ return base
+
+ colors = (overlay_channels[:3] * overlay_channels[3] + base_channels[:3] * base_alpha) / alpha
+ channels = np.rint(np.append(colors, alpha) * MAX_CHANNEL_VALUE).astype(int)
+ return (int(channels[0]), int(channels[1]), int(channels[2]), int(channels[3]))
+
+
def to_grayscale(color: ColorRGBA) -> ColorRGBA:
"""Return ``color`` desaturated to its luminance-preserving gray, keeping its alpha.
diff --git a/src/sampletones_shared/utils/system/paths.py b/src/sampletones_shared/utils/system/paths.py
index 30f19867..3b3ce37f 100644
--- a/src/sampletones_shared/utils/system/paths.py
+++ b/src/sampletones_shared/utils/system/paths.py
@@ -93,6 +93,30 @@ def ensure_suffix(path: Path, suffix: str) -> Path:
return path.with_name(f"{path.name}{normalized_suffix}")
+def replace_suffix(path: Path, previous: str, suffix: str) -> Path:
+ """
+ Returns the path carrying ``suffix`` where its name ends with ``previous``.
+
+ A destination follows the format written to it, so choosing another format renames the
+ file the destination points at. The ending is compared case-insensitively, and a name
+ ending in anything else keeps every part of itself and takes the new suffix on the end,
+ the way :func:`ensure_suffix` leaves incidental dots intact (``my.mix`` becomes
+ ``my.mix.mp3``).
+
+ Args:
+ path (Path): The path whose extension follows a change of format.
+ previous (str): The extension the name is expected to end with, leading dot included.
+ suffix (str): The extension the path takes, with or without a leading dot.
+
+ Returns:
+ Path: The path ending with the given suffix.
+ """
+ if previous and path.name.lower().endswith(previous.lower()):
+ return path.with_name(get_filename(path.name[: -len(previous)], suffix))
+
+ return ensure_suffix(path, suffix)
+
+
def shorten_path(path: GeneralPathlike, levels: int = SHORTEN_PATH_LEVELS) -> str:
"""
Shortens a file path for display by keeping the root, first directory, and last few parts.
diff --git a/src/sampletones_shared/utils/text.py b/src/sampletones_shared/utils/text.py
new file mode 100644
index 00000000..1abc9f9e
--- /dev/null
+++ b/src/sampletones_shared/utils/text.py
@@ -0,0 +1,32 @@
+import re
+from typing import Final, Tuple, TypeAlias
+
+NaturalSortKey: TypeAlias = Tuple[Tuple[int, str], ...]
+
+_DIGIT_RUN_PATTERN: Final[re.Pattern[str]] = re.compile(r"(\d+)")
+
+
+def natural_sort_key(text: str) -> NaturalSortKey:
+ """
+ Builds the sort key that orders text the way a reader expects.
+
+ Digit runs compare as the numbers they spell, so `8 kHz` precedes `44.1 kHz`, and the text
+ around them compares case-insensitively, so `Amen` and `amen` sit together. The text itself
+ closes the key, so two labels reading alike keep a fixed order.
+
+ Args:
+ text: The label to order by.
+
+ Returns:
+ A tuple comparing as the reading order of the label.
+
+ Examples:
+ >>> sorted(["44.1 kHz", "8 kHz"], key=natural_sort_key)
+ ['8 kHz', '44.1 kHz']
+ >>> sorted(["track10", "track2"], key=natural_sort_key)
+ ['track2', 'track10']
+ """
+ tokens = tuple(
+ (int(part), "") if part.isdecimal() else (0, part.casefold()) for part in _DIGIT_RUN_PATTERN.split(text)
+ )
+ return tokens + ((0, text),)
diff --git a/src/sampletones_shared/utils/transformations/__init__.py b/src/sampletones_shared/utils/transformations/__init__.py
index 25194c65..4393e787 100644
--- a/src/sampletones_shared/utils/transformations/__init__.py
+++ b/src/sampletones_shared/utils/transformations/__init__.py
@@ -2,7 +2,7 @@
from .transformation import Transformation
__all__ = [
- "Transformation",
"LogMorpher",
"PowerMorpher",
+ "Transformation",
]
diff --git a/src/sampletones_synthesis/frequency.py b/src/sampletones_synthesis/frequency.py
index b8a08cbf..36f46d08 100644
--- a/src/sampletones_synthesis/frequency.py
+++ b/src/sampletones_synthesis/frequency.py
@@ -23,7 +23,7 @@ def _require_hertz(value: Any) -> Any:
return value
-def resolve_frequency(frequency: Union[int, float]) -> float:
+def resolve_frequency(frequency: float) -> float:
"""
Resolve a frequency specification to Hz.
diff --git a/src/sampletones_synthesis/oscillators/exponential_glide.py b/src/sampletones_synthesis/oscillators/exponential_glide.py
index 19fb89ba..31254045 100644
--- a/src/sampletones_synthesis/oscillators/exponential_glide.py
+++ b/src/sampletones_synthesis/oscillators/exponential_glide.py
@@ -30,7 +30,7 @@ def render(
self,
time: np.ndarray,
*,
- generator: np.random.Generator,
+ generator: np.random.Generator, # pylint: disable=unused-argument
) -> np.ndarray:
"""
Render the glide over the time axis.
diff --git a/src/sampletones_synthesis/oscillators/geometric_sweep.py b/src/sampletones_synthesis/oscillators/geometric_sweep.py
index 05d6b803..db207f3c 100644
--- a/src/sampletones_synthesis/oscillators/geometric_sweep.py
+++ b/src/sampletones_synthesis/oscillators/geometric_sweep.py
@@ -26,7 +26,7 @@ def render(
self,
time: np.ndarray,
*,
- generator: np.random.Generator,
+ generator: np.random.Generator, # pylint: disable=unused-argument
) -> np.ndarray:
"""
Render the sweep over the time axis.
diff --git a/src/sampletones_synthesis/oscillators/pulse.py b/src/sampletones_synthesis/oscillators/pulse.py
index 84f02711..8bb5e90d 100644
--- a/src/sampletones_synthesis/oscillators/pulse.py
+++ b/src/sampletones_synthesis/oscillators/pulse.py
@@ -21,7 +21,7 @@ def render(
self,
time: np.ndarray,
*,
- generator: np.random.Generator,
+ generator: np.random.Generator, # pylint: disable=unused-argument
) -> np.ndarray:
"""
Render the pulse over the time axis.
diff --git a/src/sampletones_synthesis/oscillators/sine.py b/src/sampletones_synthesis/oscillators/sine.py
index c93c6b95..3e2e96ed 100644
--- a/src/sampletones_synthesis/oscillators/sine.py
+++ b/src/sampletones_synthesis/oscillators/sine.py
@@ -18,7 +18,7 @@ def render(
self,
time: np.ndarray,
*,
- generator: np.random.Generator,
+ generator: np.random.Generator, # pylint: disable=unused-argument
) -> np.ndarray:
"""
Render the sine over the time axis.
diff --git a/src/sampletones_synthesis/voice/layer.py b/src/sampletones_synthesis/voice/layer.py
index 7bd2603a..31b8429d 100644
--- a/src/sampletones_synthesis/voice/layer.py
+++ b/src/sampletones_synthesis/voice/layer.py
@@ -18,8 +18,14 @@ class Layer(BaseModel):
model_config = ConfigDict(frozen=True, extra="forbid")
oscillator: OscillatorUnion
- envelopes: Tuple[EnvelopeUnion, ...] = Field(description="Multiplicative amplitude shapes applied in order.")
- gain: float = Field(gt=0.0, description="Scale of the unit-level oscillator-envelope product.")
+ envelopes: Tuple[EnvelopeUnion, ...] = Field(
+ ...,
+ description="Multiplicative amplitude shapes applied in order.",
+ )
+ gain: float = Field(
+ gt=0.0,
+ description="Scale of the unit-level oscillator-envelope product.",
+ )
def render(
self,
diff --git a/tests/conftest.py b/tests/conftest.py
index 726178bd..49d3471d 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,29 +1,31 @@
-from pathlib import Path
-from typing import Callable, TypeAlias
+from typing import Callable, Iterator, TypeAlias
-import numpy as np
import pytest
-from sampletones_core.configs import Config
+from sampletones_application.utils.gui.palette.palette import PaletteBindings
from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.instructions import PulseInstruction
from sampletones_core.reconstructions import Reconstruction
+from tests.suite.sequencer import sample_reconstruction
ReconstructionFactory: TypeAlias = Callable[[], Reconstruction]
+@pytest.fixture(autouse=True)
+def palette_bindings() -> Iterator[None]:
+ """Gives each test an empty palette binding registry.
+
+ The registry holds DearPyGui item identifiers and outlives any one context, and a fresh
+ context hands out the same identifiers again, so each test starts from nothing and leaves
+ nothing that a later one could repaint.
+ """
+ PaletteBindings.clear()
+ yield
+ PaletteBindings.clear()
+
+
@pytest.fixture
def reconstruction_factory() -> ReconstructionFactory:
def build() -> Reconstruction:
- length = 64
- instructions = [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)]
- return Reconstruction.create(
- approximation=np.zeros(length, dtype=np.float32),
- approximations={GeneratorName.PULSE1: np.zeros(length, dtype=np.float32)},
- instructions={GeneratorName.PULSE1: instructions},
- config=Config(),
- coefficient=1.0,
- audio_filepath=Path("/dev/null"),
- )
+ return sample_reconstruction([GeneratorName.PULSE1])
return build
diff --git a/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py
index 0123aec8..662e9a07 100644
--- a/tests/integration/assets/reconstruction.py
+++ b/tests/integration/assets/reconstruction.py
@@ -81,11 +81,11 @@ def make_sample(
expected_slices: FrozenSet[GeneratorName],
loop: bool = False,
) -> Sample:
- """Reconstructs ``audio`` into a `Sample`, asserting the covered channel slices."""
+ """Reconstructs ``audio`` into a `Sample`, asserting the channels it plays."""
reconstruction = reconstruct_sample(audio, config, library, tmp_dir=tmp_dir, name=name)
- covered = frozenset(reconstruction.instructions)
- if covered != expected_slices:
- raise AssertionError(f"Sample '{name}' covers {set(covered)}, expected {set(expected_slices)}")
+ played = frozenset(reconstruction.playing_generators)
+ if played != expected_slices:
+ raise AssertionError(f"Sample '{name}' covers {set(played)}, expected {set(expected_slices)}")
return Sample(name=name, reconstruction=reconstruction, loop=loop)
@@ -110,7 +110,10 @@ def load_instrument_catalog(
transformation_gamma=settings["transformation_gamma"],
)
sample_rate = library_config.sample_rate
- library = build_mini_library(Config(library=library_config), per_generator=settings["instructions_per_generator"])
+ library = build_mini_library(
+ Config(library=library_config),
+ per_generator=settings["instructions_per_generator"],
+ )
catalog: Dict[str, Sample] = {}
for entry in spec["instruments"]:
diff --git a/tests/integration/assets/song_loader.py b/tests/integration/assets/song_loader.py
index ce2eda37..03079cdc 100644
--- a/tests/integration/assets/song_loader.py
+++ b/tests/integration/assets/song_loader.py
@@ -14,7 +14,9 @@
RowSpec = Dict[str, Any]
-def _order(order_specs: List[Dict[str, int]]) -> List[Dict[GeneratorName, Optional[int]]]:
+def _order(
+ order_specs: List[Dict[str, int]],
+) -> List[Dict[GeneratorName, Optional[int]]]:
frames: List[Dict[GeneratorName, Optional[int]]] = []
for spec in order_specs:
frames.append({generator: spec.get(generator.value) for generator in GeneratorName.items()})
diff --git a/tests/integration/bitphase/conftest.py b/tests/integration/bitphase/conftest.py
index b55d74ac..d1dd846e 100644
--- a/tests/integration/bitphase/conftest.py
+++ b/tests/integration/bitphase/conftest.py
@@ -4,7 +4,11 @@
import pytest
from tests.integration.output import resolve_output_directory, resolve_output_path
-from tests.integration.paths import BTP_OUTPUT_ENV, DOCUMENT_FILENAME
+from tests.integration.paths import (
+ BTP_OUTPUT_ENV,
+ DOCUMENT_FILENAME,
+ GROOVE_DOCUMENT_FILENAME,
+)
@pytest.fixture(scope="session")
@@ -17,3 +21,9 @@ def btp_output_dir() -> Optional[Path]:
def document_path(btp_output_dir: Optional[Path], tmp_path: Path) -> Path:
"""Where a produced ``.btp`` is written."""
return resolve_output_path(btp_output_dir, tmp_path, DOCUMENT_FILENAME)
+
+
+@pytest.fixture
+def groove_document_path(btp_output_dir: Optional[Path], tmp_path: Path) -> Path:
+ """Where the document carrying a groove is written, beside the one at the song's own tempo."""
+ return resolve_output_path(btp_output_dir, tmp_path, GROOVE_DOCUMENT_FILENAME)
diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py
index 9829d27c..6882361a 100644
--- a/tests/integration/bitphase/test_btp_pipeline.py
+++ b/tests/integration/bitphase/test_btp_pipeline.py
@@ -5,15 +5,26 @@
from sampletones_core.formats.bitphase.btp import write_btp
from sampletones_core.formats.bitphase.builder import project_to_bitphase
-from sampletones_core.formats.bitphase.specification.channels import CHANNEL_COUNT, CHANNEL_LABELS, ChannelIndex
+from sampletones_core.formats.bitphase.specification.channels import (
+ CHANNEL_COUNT,
+ CHANNEL_LABELS,
+ ChannelIndex,
+)
from sampletones_core.formats.bitphase.specification.chip import (
CHIP_TYPE_NES,
CPU_FREQUENCIES,
+ MAX_INITIAL_SPEED,
MAX_TUNING_PERIOD,
+ MIN_INITIAL_SPEED,
MIN_TUNING_PERIOD,
TUNING_TABLE_LENGTH,
ChipVariant,
)
+from sampletones_core.formats.bitphase.specification.effects import (
+ NO_EFFECT_PARAMETER,
+ SPEED_EFFECT_DELAY,
+ EffectId,
+)
from sampletones_core.formats.bitphase.specification.instruments import (
MAX_PULSE_WIDTH,
MAX_VOLUME_OR_RATE,
@@ -29,12 +40,23 @@
NO_INSTRUMENT_CHANGE,
NOTE_RANGE,
TABLE_COLUMN_OFFSET,
+ VOLUME_OFF,
NoteName,
)
from sampletones_core.project.project import Project
-from tests.suite.bitphase import LoadedNote, LoadedProject, LoadedRow, parse_btp
+from sampletones_core.timing import Metre, RowRate, calculate_groove
+from tests.suite.bitphase import (
+ BITPHASE_NO_EFFECTS,
+ LoadedEffect,
+ LoadedNote,
+ LoadedProject,
+ LoadedRow,
+ LoadedTable,
+ parse_btp,
+)
EXPECTED_INSTRUMENT_COUNT: Final[int] = 5
+GROOVE_TEMPO: Final[int] = 210
PLAYED_CHANNELS: Final[List[int]] = [
int(ChannelIndex.SQUARE1),
int(ChannelIndex.SQUARE2),
@@ -53,12 +75,30 @@ def note_index(note: LoadedNote) -> int:
return note.name - int(NoteName.C) + (note.octave - FIRST_OCTAVE) * NOTE_RANGE
+def at_tempo(project: Project, tempo: int) -> Project:
+ """The same project played at another tempo, leaving the session-wide fixture as it is."""
+ return Project(
+ metadata=project.metadata,
+ info=project.info,
+ settings=project.settings.model_copy(update={"tempo": tempo}),
+ samples=project.samples,
+ song=project.song,
+ )
+
+
@pytest.fixture
def document(integration_project: Project, document_path: Path) -> LoadedProject:
write_btp(document_path, project_to_bitphase(integration_project))
return parse_btp(document_path.read_bytes(), list(CHANNEL_LABELS))
+@pytest.fixture
+def groove_document(integration_project: Project, groove_document_path: Path) -> LoadedProject:
+ project = at_tempo(integration_project, GROOVE_TEMPO)
+ write_btp(groove_document_path, project_to_bitphase(project))
+ return parse_btp(groove_document_path.read_bytes(), list(CHANNEL_LABELS))
+
+
class TestBtpPipeline:
"""End-to-end: synthesized + reconstructed samples -> Project -> `.btp` -> load."""
@@ -184,7 +224,83 @@ def test_every_note_lands_inside_the_tuning_table(self, triggers: List[LoadedRow
assert all(MIN_NOTE_INDEX <= index <= MAX_NOTE_INDEX for index in indices)
def test_every_volume_column_stays_within_the_channel_range(self, document: LoadedProject) -> None:
- assert all(0 <= row.volume <= FULL_VOLUME for row in every_row(document))
+ assert all(VOLUME_OFF <= row.volume <= FULL_VOLUME for row in every_row(document))
+
+
+class TestTheGrooveReachesTheFile:
+ """A tempo the speed column cannot state travels as a table of per-row tick counts and a
+ trigger that names it, so the file has to hold the groove the calculator produced and
+ re-trigger it wherever the order takes playback.
+ """
+
+ @pytest.fixture(name="groove_table")
+ def groove_table_fixture(self, groove_document: LoadedProject) -> LoadedTable:
+ return groove_document.tables[-1]
+
+ def test_the_groove_takes_the_table_above_the_slices(
+ self,
+ groove_document: LoadedProject,
+ groove_table: LoadedTable,
+ ) -> None:
+ assert groove_table.id == len(groove_document.instruments)
+
+ def test_the_table_holds_one_entry_per_pattern_row(
+ self,
+ groove_document: LoadedProject,
+ groove_table: LoadedTable,
+ ) -> None:
+ lengths = {pattern.length for pattern in groove_document.songs[0].patterns}
+ assert lengths == {len(groove_table.rows)}
+
+ def test_every_entry_is_a_speed_the_engine_reads(self, groove_table: LoadedTable) -> None:
+ assert all(MIN_INITIAL_SPEED <= ticks <= MAX_INITIAL_SPEED for ticks in groove_table.rows)
+
+ def test_the_table_holds_the_groove_the_project_plays(
+ self,
+ integration_project: Project,
+ groove_table: LoadedTable,
+ ) -> None:
+ project = at_tempo(integration_project, GROOVE_TEMPO)
+ groove = calculate_groove(
+ RowRate.from_settings(project.settings),
+ Metre.from_settings(project.settings, rows=project.song.rows_per_pattern),
+ minimum_ticks=MIN_INITIAL_SPEED,
+ maximum_ticks=MAX_INITIAL_SPEED,
+ )
+ assert groove_table.rows == list(groove.ticks)
+
+ def test_the_song_starts_on_the_ticks_its_first_row_lasts(
+ self,
+ groove_document: LoadedProject,
+ groove_table: LoadedTable,
+ ) -> None:
+ assert groove_document.songs[0].initial_speed == groove_table.rows[0]
+
+ def test_every_pattern_triggers_the_groove_on_its_first_row(
+ self,
+ groove_document: LoadedProject,
+ groove_table: LoadedTable,
+ ) -> None:
+ trigger = LoadedEffect(
+ effect=int(EffectId.SPEED),
+ delay=SPEED_EFFECT_DELAY,
+ parameter=NO_EFFECT_PARAMETER,
+ table_index=groove_table.id,
+ )
+ triggers = [
+ pattern.channels[int(ChannelIndex.DPCM)].rows[0].effects for pattern in groove_document.songs[0].patterns
+ ]
+ assert triggers == [[trigger]] * len(triggers)
+
+ def test_a_tempo_the_speed_column_states_leaves_every_effect_column_empty(
+ self,
+ document: LoadedProject,
+ ) -> None:
+ """The song's own tempo divides into whole ticks, so its document carries the speed
+ and nothing beside it.
+ """
+ assert len(document.tables) == len(document.instruments)
+ assert all(row.effects == list(BITPHASE_NO_EFFECTS) for row in every_row(document))
class TestTheInstrumentRowsArePlayable:
diff --git a/tests/integration/config/module.yaml b/tests/integration/config/module.yaml
index 80b38324..cd248a74 100644
--- a/tests/integration/config/module.yaml
+++ b/tests/integration/config/module.yaml
@@ -2,4 +2,4 @@ title: Drum Demo
author: Integration
tempo: 150
speed: 6
-nes_frequency: 30
+nes_frequency: 60
diff --git a/tests/integration/paths.py b/tests/integration/paths.py
index 2c5d9c57..dc63ef71 100644
--- a/tests/integration/paths.py
+++ b/tests/integration/paths.py
@@ -23,4 +23,5 @@ def _repo_root() -> Path:
FTM_OUTPUT_ENV: Final[str] = "SAMPLETONES_FTM_OUTPUT_DIR"
DOCUMENT_FILENAME: Final[str] = "drums.btp"
+GROOVE_DOCUMENT_FILENAME: Final[str] = "drums-groove.btp"
BTP_OUTPUT_ENV: Final[str] = "SAMPLETONES_BTP_OUTPUT_DIR"
diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py
index 140ca412..10db4394 100644
--- a/tests/integration/sampletones_application/services/conftest.py
+++ b/tests/integration/sampletones_application/services/conftest.py
@@ -36,6 +36,7 @@ def pulse_features(pulse_instructions) -> Features:
return PulseExporter().to_features(
pulse_instructions,
PulseExporter.derive_initial_pitch(pulse_instructions),
+ (),
)
diff --git a/tests/integration/sampletones_application/services/test_regeneration.py b/tests/integration/sampletones_application/services/test_regeneration.py
index acb2d48a..eaa09995 100644
--- a/tests/integration/sampletones_application/services/test_regeneration.py
+++ b/tests/integration/sampletones_application/services/test_regeneration.py
@@ -225,9 +225,18 @@ def check_the_reference_held(context: ArpeggioEditContext) -> None:
label="arpeggio_edit_keeps_the_sample_pitch",
build=build,
steps=[
- ScenarioStep(label="check_the_starting_reference", action=check_the_starting_reference),
- ScenarioStep(label="raise_the_first_frame_an_octave", action=raise_the_first_frame_an_octave),
- ScenarioStep(label="reload_the_edited_features", action=reload_the_edited_features),
+ ScenarioStep(
+ label="check_the_starting_reference",
+ action=check_the_starting_reference,
+ ),
+ ScenarioStep(
+ label="raise_the_first_frame_an_octave",
+ action=raise_the_first_frame_an_octave,
+ ),
+ ScenarioStep(
+ label="reload_the_edited_features",
+ action=reload_the_edited_features,
+ ),
ScenarioStep(label="clear_the_envelope", action=clear_the_envelope),
ScenarioStep(label="check_the_reference_held", action=check_the_reference_held),
],
diff --git a/tests/integration/tooling/__init__.py b/tests/integration/tooling/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/integration/tooling/test_check_commands.py b/tests/integration/tooling/test_check_commands.py
new file mode 100644
index 00000000..a265cea4
--- /dev/null
+++ b/tests/integration/tooling/test_check_commands.py
@@ -0,0 +1,69 @@
+from pathlib import Path
+from typing import Dict, Final, List, Optional
+
+import yaml
+
+from sampletones_shared.paths.source import REPOSITORY_ROOT
+
+PRE_COMMIT_CONFIG: Final[Path] = REPOSITORY_ROOT / ".pre-commit-config.yaml"
+MAKEFILE: Final[Path] = REPOSITORY_ROOT / "Makefile"
+
+FILE_ENCODING: Final[str] = "utf-8"
+LOCAL_REPOSITORY: Final[str] = "local"
+CHECK_SCRIPTS: Final[str] = "scripts/checks/"
+TARGET_PREFIX: Final[str] = "check-"
+SCRIPT_SUFFIX: Final[str] = ".py"
+RECIPE_PREFIX: Final[str] = "\t"
+TARGET_SUFFIX: Final[str] = ":"
+
+
+def check_hooks() -> List[Dict[str, object]]:
+ """Every local hook running one of the check scripts, as the configuration declares it."""
+ config = yaml.safe_load(PRE_COMMIT_CONFIG.read_text(encoding=FILE_ENCODING))
+ return [
+ hook
+ for repository in config["repos"]
+ if repository["repo"] == LOCAL_REPOSITORY
+ for hook in repository["hooks"]
+ if CHECK_SCRIPTS in str(hook["entry"])
+ ]
+
+
+def check_targets() -> Dict[str, str]:
+ """The command each `check-*` target of the Makefile runs, keyed by target name."""
+ targets: Dict[str, str] = {}
+ target: Optional[str] = None
+ for line in MAKEFILE.read_text(encoding=FILE_ENCODING).splitlines():
+ if line.startswith(TARGET_PREFIX) and line.endswith(TARGET_SUFFIX):
+ target = line.removesuffix(TARGET_SUFFIX)
+ elif target is not None and line.startswith(RECIPE_PREFIX):
+ targets[target] = line.strip()
+ target = None
+
+ return targets
+
+
+def script_path(entry: str) -> Path:
+ """The check script an entry runs, taken from the words the entry is written with."""
+ return REPOSITORY_ROOT / next(word for word in entry.split() if word.endswith(SCRIPT_SUFFIX))
+
+
+class TestCheckHooks:
+ def test_the_configuration_declares_a_hook_for_every_check_script(self) -> None:
+ scripts = {path.name for path in (REPOSITORY_ROOT / CHECK_SCRIPTS).glob(f"*{SCRIPT_SUFFIX}")}
+
+ assert {script_path(str(hook["entry"])).name for hook in check_hooks()} == scripts
+
+ def test_every_check_hook_names_a_script_that_is_there(self) -> None:
+ assert all(script_path(str(hook["entry"])).is_file() for hook in check_hooks())
+
+ def test_every_check_hook_sweeps_the_whole_tree(self) -> None:
+ """A hook handed the staged files checks the staged subset, which passes what it never reads."""
+ assert all(hook["pass_filenames"] is False for hook in check_hooks())
+
+
+class TestCheckTargets:
+ def test_each_hook_and_its_make_target_run_the_same_command(self) -> None:
+ commands = {f"{TARGET_PREFIX}{hook['id']}": str(hook["entry"]) for hook in check_hooks()}
+
+ assert check_targets() == commands
diff --git a/tests/suite/application.py b/tests/suite/application.py
index cebdb0aa..e8df8a13 100644
--- a/tests/suite/application.py
+++ b/tests/suite/application.py
@@ -3,12 +3,10 @@
import pytest
-from sampletones_application.layout.behavior import (
- SchedulingBehavior,
- SchedulingDelays,
- SchedulingEmit,
- SchedulingPriorities,
-)
+from sampletones_application.layout.behavior.scheduling.delays import SchedulingDelays
+from sampletones_application.layout.behavior.scheduling.emit import SchedulingEmit
+from sampletones_application.layout.behavior.scheduling.priorities import SchedulingPriorities
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.utils.callbacks.queue import CallbackQueue
from sampletones_application.utils.parallelization.thread import SingleThreadExecutor
from sampletones_shared.types.callback import VoidCallback
diff --git a/tests/suite/base.py b/tests/suite/base.py
index 64d31603..11d79886 100644
--- a/tests/suite/base.py
+++ b/tests/suite/base.py
@@ -1,4 +1,4 @@
-from typing import Sequence, Type
+from typing import ClassVar, Sequence, Type
from sampletones_shared.meta import NonInstantiableMeta
from tests.suite.case import BaseTestCase
@@ -7,4 +7,4 @@
class BaseTestSuite(metaclass=NonInstantiableMeta):
TestCase: Type[BaseTestCase]
- test_cases: Sequence[BaseTestCase]
+ test_cases: ClassVar[Sequence[BaseTestCase]]
diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py
index 584d468d..c511104d 100644
--- a/tests/suite/bitphase.py
+++ b/tests/suite/bitphase.py
@@ -16,6 +16,13 @@
BITPHASE_DEFAULT_CHIP_TYPE: Final[str] = "ay"
BITPHASE_DEFAULT_NOTE_NAME: Final[int] = 0
BITPHASE_DEFAULT_OCTAVE: Final[int] = 0
+BITPHASE_DEFAULT_INSTRUMENT: Final[int] = 0
+BITPHASE_DEFAULT_TABLE: Final[int] = 0
+BITPHASE_DEFAULT_VOLUME: Final[int] = 0
+BITPHASE_DEFAULT_EFFECT: Final[int] = 0
+BITPHASE_DEFAULT_EFFECT_DELAY: Final[int] = 0
+BITPHASE_DEFAULT_EFFECT_PARAMETER: Final[int] = 0
+BITPHASE_NO_EFFECTS: Final[Tuple[None, ...]] = (None,)
BITPHASE_DEFAULT_INSTRUMENT_ID: Final[str] = "01"
BITPHASE_DEFAULT_LOOP: Final[int] = 0
BITPHASE_DEFAULT_TABLE_ID: Final[int] = 0
@@ -32,9 +39,18 @@ class LoadedNote:
octave: int
+@dataclass(frozen=True)
+class LoadedEffect:
+ effect: int
+ delay: int
+ parameter: int
+ table_index: Optional[int]
+
+
@dataclass(frozen=True)
class LoadedRow:
note: LoadedNote
+ effects: List[Optional[LoadedEffect]]
instrument: int
table: int
volume: int
@@ -120,12 +136,33 @@ def _note(data: Optional[Dict[str, Any]]) -> LoadedNote:
)
+def _effect(data: Optional[Dict[str, Any]]) -> Optional[LoadedEffect]:
+ if data is None:
+ return None
+
+ return LoadedEffect(
+ effect=data.get("effect", BITPHASE_DEFAULT_EFFECT),
+ delay=data.get("delay", BITPHASE_DEFAULT_EFFECT_DELAY),
+ parameter=data.get("parameter", BITPHASE_DEFAULT_EFFECT_PARAMETER),
+ table_index=data.get("tableIndex"),
+ )
+
+
+def _effects(data: Optional[List[Optional[Dict[str, Any]]]]) -> List[Optional[LoadedEffect]]:
+ """One entry per effect column, which a row naming none reaches playback holding empty."""
+ if not data:
+ return list(BITPHASE_NO_EFFECTS)
+
+ return [_effect(entry) for entry in data]
+
+
def _row(data: Dict[str, Any]) -> LoadedRow:
return LoadedRow(
note=_note(data.get("note")),
- instrument=data.get("instrument", 0),
- table=data.get("table", 0),
- volume=data.get("volume", 0),
+ effects=_effects(data.get("effects")),
+ instrument=data.get("instrument", BITPHASE_DEFAULT_INSTRUMENT),
+ table=data.get("table", BITPHASE_DEFAULT_TABLE),
+ volume=data.get("volume", BITPHASE_DEFAULT_VOLUME),
)
diff --git a/tests/suite/browser.py b/tests/suite/browser.py
new file mode 100644
index 00000000..d861995d
--- /dev/null
+++ b/tests/suite/browser.py
@@ -0,0 +1,505 @@
+from collections import defaultdict
+from dataclasses import dataclass
+from pathlib import Path
+from textwrap import dedent
+from typing import Dict, Final, List, Mapping, Optional, Sequence, Set, Tuple
+
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
+from sampletones_application.ui.elements.tree.colors import TreeColors
+from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory
+from sampletones_application.ui.elements.tree.filter import TreeFilter
+from sampletones_application.ui.elements.tree.handler import NodeHandler
+from sampletones_application.ui.elements.tree.spec import NodeSpec
+from sampletones_application.ui.elements.tree.tree import GUITreePanel
+from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
+from sampletones_application.utils.palette.colors.literal import LiteralColor
+from sampletones_core.constants.enums import SpectrumMethod
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
+from tests.suite.language import FakeLanguageManager
+
+PANEL_TAG: Final[str] = "sequencer.browser"
+TREE_TAG: Final[str] = "sequencer.browser.tree"
+
+HASH_A: Final[str] = "aaaaaaaa11111111aaaaaaaa11111111"
+HASH_B: Final[str] = "bbbbbbbb22222222bbbbbbbb22222222"
+HASH_C: Final[str] = "cccccccc33333333cccccccc33333333"
+HASH_D: Final[str] = "dddddddd44444444dddddddd44444444"
+HASH_E: Final[str] = "eeeeeeee55555555eeeeeeee55555555"
+HASH_F: Final[str] = "ffffffff66666666ffffffff66666666"
+
+ARCHIVE: Final[str] = "archive"
+STRAY: Final[str] = "stray"
+
+BROWSER_TEXTS: Final[Mapping[str, str]] = {
+ "global.browser.label.root": "Root",
+ "global.browser.label.by_configuration": "By configuration",
+ "global.browser.label.by_sample": "By sample",
+}
+
+TREE_COLORS: Final[TreeColors] = TreeColors(
+ favorite=LiteralColor((240, 200, 80, 255)),
+ node=LiteralColor((200, 200, 200, 255)),
+ muted=LiteralColor((120, 120, 120, 255)),
+ accent=LiteralColor((80, 160, 240, 255)),
+)
+
+OPEN_MARKER: Final[str] = "v"
+CLOSED_MARKER: Final[str] = ">"
+LEAF_MARKER: Final[str] = "-"
+HIDDEN_MARKER: Final[str] = " [hidden]"
+INDENT: Final[str] = " "
+
+
+def as_view(text: str) -> str:
+ """Reads a view written as an indented block in a test, so the expected rows read as they draw."""
+ return dedent(text).strip("\n")
+
+
+WHOLE_TREE: Final[str] = as_view("""
+ > By configuration
+ > 8 kHz·60 Hz·CQT·γ2·P
+ - sweep
+ > 44.1 kHz·30 Hz
+ > CQT·γ0·PTN
+ - beat
+ - solo
+ > FFT·γ0
+ > PT
+ > takes
+ - alt
+ - beat
+ > PTN·#aaaaaaa
+ > drums
+ - kick
+ - snare
+ - beat
+ - melody
+ > PTN·#bbbbbbb
+ > drums
+ - kick
+ - beat
+ - melody
+ > archive
+ > 48 kHz·50 Hz·LogFFT·γ1·TN
+ - song
+ - stray
+ > By sample
+ > beat
+ - 44.1 kHz·30 Hz·CQT·γ0·PTN
+ - 44.1 kHz·30 Hz·FFT·γ0·PT
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ > drums
+ > kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ - snare·44.1 kHz·30 Hz·FFT·γ0·PTN
+ > melody
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ - solo·44.1 kHz·30 Hz·CQT·γ0·PTN
+ - sweep·8 kHz·60 Hz·CQT·γ2·P
+ - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT
+ """)
+
+
+def config_fields(
+ *,
+ sample_rate: int,
+ nes_frequency: int,
+ spectrum_method: SpectrumMethod,
+ transformation_gamma: int,
+ generators: str,
+ config_hash: str,
+) -> ConfigDirectoryFields:
+ return ConfigDirectoryFields(
+ sr=sample_rate,
+ nf=nes_frequency,
+ sm=spectrum_method,
+ tg=transformation_gamma,
+ gn=generators,
+ ch=config_hash,
+ )
+
+
+CONFIG_A: Final[ConfigDirectoryFields] = config_fields(
+ sample_rate=44100,
+ nes_frequency=30,
+ spectrum_method=SpectrumMethod.FFT,
+ transformation_gamma=0,
+ generators="PTN",
+ config_hash=HASH_A,
+)
+CONFIG_B: Final[ConfigDirectoryFields] = config_fields(
+ sample_rate=44100,
+ nes_frequency=30,
+ spectrum_method=SpectrumMethod.FFT,
+ transformation_gamma=0,
+ generators="PTN",
+ config_hash=HASH_B,
+)
+CONFIG_C: Final[ConfigDirectoryFields] = config_fields(
+ sample_rate=44100,
+ nes_frequency=30,
+ spectrum_method=SpectrumMethod.FFT,
+ transformation_gamma=0,
+ generators="PT",
+ config_hash=HASH_C,
+)
+CONFIG_D: Final[ConfigDirectoryFields] = config_fields(
+ sample_rate=44100,
+ nes_frequency=30,
+ spectrum_method=SpectrumMethod.CQT,
+ transformation_gamma=0,
+ generators="PTN",
+ config_hash=HASH_D,
+)
+CONFIG_E: Final[ConfigDirectoryFields] = config_fields(
+ sample_rate=8000,
+ nes_frequency=60,
+ spectrum_method=SpectrumMethod.CQT,
+ transformation_gamma=2,
+ generators="P",
+ config_hash=HASH_E,
+)
+CONFIG_F: Final[ConfigDirectoryFields] = config_fields(
+ sample_rate=48000,
+ nes_frequency=50,
+ spectrum_method=SpectrumMethod.LOG_SPACED_FFT,
+ transformation_gamma=1,
+ generators="TN",
+ config_hash=HASH_F,
+)
+
+TOP_LEVEL_CONFIGURATIONS: Final[Mapping[str, ConfigDirectoryFields]] = {
+ "A": CONFIG_A,
+ "B": CONFIG_B,
+ "C": CONFIG_C,
+ "D": CONFIG_D,
+ "E": CONFIG_E,
+}
+RECONSTRUCTIONS: Final[Mapping[str, Tuple[str, ...]]] = {
+ "A": ("beat", "melody", "drums/kick", "drums/snare"),
+ "B": ("beat", "melody", "drums/kick"),
+ "C": ("beat", "takes/alt"),
+ "D": ("beat", "solo"),
+ "E": ("sweep",),
+}
+
+
+class FakeConfigManager:
+ """Answers the one thing the browser manager asks of the configuration: where to read."""
+
+ def __init__(self, reconstructions_directory: Path) -> None:
+ self._reconstructions_directory = reconstructions_directory
+
+ def get_reconstructions_directory(self) -> Path:
+ return self._reconstructions_directory
+
+
+class FakeTreeLogic:
+ """Answers the favorite questions a browser asks of its logic while it collects its rows."""
+
+ def __init__(
+ self,
+ favorites: Set[Path],
+ *,
+ auto_expand_reconstructions: bool,
+ auto_expand_directories: bool,
+ ) -> None:
+ self._favorites = favorites
+ self._auto_expand_reconstructions = auto_expand_reconstructions
+ self._auto_expand_directories = auto_expand_directories
+
+ def is_node_favorite(self, node: TreeNode) -> bool:
+ return isinstance(node, FileSystemNode) and node.filepath in self._favorites
+
+ def has_favorite_ancestor(self, node: FileSystemNode) -> bool:
+ return any(directory in self._favorites for directory in node.filepath.parents)
+
+ @property
+ def auto_expand_favorite_reconstructions(self) -> bool:
+ return self._auto_expand_reconstructions
+
+ @property
+ def auto_expand_favorite_directories(self) -> bool:
+ return self._auto_expand_directories
+
+
+@dataclass(frozen=True)
+class BrowserCorpus:
+ """A reconstructions directory read into the tree both browser views render.
+
+ ``paths`` names every place a test can star: a configuration directory by its key, a
+ reconstruction by ``"/"``, and the folders standing beside them.
+ """
+
+ tree: Tree
+ paths: Mapping[str, Path]
+
+
+def write_corpus(root: Path) -> Dict[str, Path]:
+ """Writes the corpus the browser tests read, and answers where each part of it landed.
+
+ The layout carries what the browser has to tell apart: two configurations differing by hash
+ alone, a frequency holding several methods beside one holding a single chain, audio shared by
+ every configuration and audio held by one, a configuration directory nested in a plain folder,
+ and a reconstruction sitting outside every configuration directory.
+ """
+ paths: Dict[str, Path] = {}
+ for key, fields in TOP_LEVEL_CONFIGURATIONS.items():
+ directory = root / fields.directory_name
+ paths[key] = directory
+ for relative in RECONSTRUCTIONS[key]:
+ paths[f"{key}/{relative}"] = _write_reconstruction(directory / relative)
+
+ archive = root / ARCHIVE
+ paths[ARCHIVE] = archive
+ paths[f"{ARCHIVE}/F"] = archive / CONFIG_F.directory_name
+ paths[f"{ARCHIVE}/F/song"] = _write_reconstruction(paths[f"{ARCHIVE}/F"] / "song")
+ paths[STRAY] = _write_reconstruction(root / STRAY)
+ return paths
+
+
+def _write_reconstruction(path: Path) -> Path:
+ reconstruction = path.with_suffix(EXT_FILE_RECONSTRUCTION)
+ reconstruction.parent.mkdir(parents=True, exist_ok=True)
+ reconstruction.touch()
+ return reconstruction
+
+
+def build_corpus(root: Path) -> BrowserCorpus:
+ """Writes the corpus and reads it through the real pipeline, so the labels are the real ones."""
+ paths = write_corpus(root)
+ manager = BrowserManager(
+ FakeConfigManager(root), # type: ignore[arg-type]
+ language_manager=FakeLanguageManager(texts=dict(BROWSER_TEXTS)),
+ )
+ manager.refresh_tree()
+ return BrowserCorpus(
+ tree=manager.tree,
+ paths=paths,
+ )
+
+
+def build_browser_panel(
+ corpus: BrowserCorpus,
+ favorites: Set[Path],
+ *,
+ favorites_only: bool,
+ query: str = "",
+ panel_tag: str = PANEL_TAG,
+ auto_expand_reconstructions: bool = False,
+ auto_expand_directories: bool = False,
+ expanded_rows: Optional[Set[str]] = None,
+) -> GUISequencerBrowserPanel:
+ """Builds a browser panel showing the corpus under a filter, with the favorites its logic answers.
+
+ Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box,
+ and the control stands where a browser that has yet to build one leaves it. The pair of
+ auto-expand answers states which stars the mode opens the way down to, as the reader's preference
+ does, and the mode is stated the way a session restores it — so the way down opens once a test
+ asks for the mode through :func:`select_favorites`.
+ """
+ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel)
+ panel.tag = panel_tag
+ panel._expansion = RowExpansionMemory(set() if expanded_rows is None else expanded_rows)
+ panel.tree_tag = TREE_TAG
+ panel.tree = corpus.tree
+ panel._logic = FakeTreeLogic( # type: ignore[assignment]
+ favorites,
+ auto_expand_reconstructions=auto_expand_reconstructions,
+ auto_expand_directories=auto_expand_directories,
+ )
+ panel._language_manager = FakeLanguageManager()
+ panel._colors = TREE_COLORS
+ _state_detail_labels(panel)
+ panel._favorites_checkbox_tag = None
+ panel._favorites_glyph_tag = None
+ panel.on_favorites_filter_changed = None
+ panel._filter = TreeFilter(query=query, favorites_only=favorites_only)
+ panel._auto_expand_pending = False
+ panel._resolve_filter()
+ return panel
+
+
+def _state_detail_labels(panel: GUITreePanel) -> None:
+ """States the labels a row's details read under, which a configuration row asks for by name."""
+ panel._lbl_detail_sample_rate = "sample_rate"
+ panel._lbl_detail_nes_frequency = "nes_frequency"
+ panel._lbl_detail_spectrum_method = "spectrum_method"
+ panel._lbl_detail_transformation_gamma = "transformation_gamma"
+ panel._lbl_detail_window_size = "window_size"
+ panel._lbl_detail_generators = "generators"
+ panel._lbl_detail_configuration = "configuration"
+
+
+def collect_specs(panel: GUITreePanel) -> List[NodeSpec]:
+ """Collects the rows a rebuild would emit, which is the pass running off the main thread."""
+ panel._node_handlers = {
+ node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType
+ }
+ return panel._collect_specs(panel.tree_tag)
+
+
+def render_view(panel: GUITreePanel) -> str:
+ """Renders the view a rebuild would leave on screen: the rows, their nesting and their state.
+
+ Each row reads as its marker and its label, indented under the row holding it: ``v`` a container
+ standing open, ``>`` one standing closed, ``-`` a leaf. A row the search hides is marked, since
+ its widget stands there either way, and a row under a closed container is rendered where it is.
+ """
+ children: Dict[str, List[NodeSpec]] = defaultdict(list)
+ for spec in collect_specs(panel):
+ children[spec.parent_tag].append(spec)
+
+ lines: List[str] = []
+ _render_rows(
+ panel,
+ children,
+ parent_tag=panel.tree_tag,
+ depth=0,
+ lines=lines,
+ )
+ return "\n".join(lines)
+
+
+def _render_rows(
+ panel: GUITreePanel,
+ children: Mapping[str, Sequence[NodeSpec]],
+ *,
+ parent_tag: str,
+ depth: int,
+ lines: List[str],
+) -> None:
+ for spec in children.get(parent_tag, ()):
+ lines.append(f"{INDENT * depth}{_row_marker(spec)} {spec.label}{_row_state(panel, spec)}")
+ _render_rows(
+ panel,
+ children,
+ parent_tag=spec.node_tag,
+ depth=depth + 1,
+ lines=lines,
+ )
+
+
+def _row_marker(spec: NodeSpec) -> str:
+ if spec.leaf:
+ return LEAF_MARKER
+
+ return OPEN_MARKER if spec.should_expand else CLOSED_MARKER
+
+
+def _row_state(panel: GUITreePanel, spec: NodeSpec) -> str:
+ return "" if panel._is_node_visible(spec.node) else HIDDEN_MARKER
+
+
+def nodes_at(corpus: BrowserCorpus, key: str) -> Tuple[FileSystemNode, ...]:
+ """Every row standing for one path, which is what a favorite reaches across the two views."""
+ path = corpus.paths[key]
+ return corpus.tree.find_nodes(FileSystemNode, lambda node: node.filepath == path)
+
+
+def row_named(corpus: BrowserCorpus, label: str) -> TreeNode:
+ """The row reading under this label, which is how a test names a heading the browser wrote."""
+ rows = corpus.tree.find_nodes(TreeNode, lambda node: str(node.name) == label)
+ assert len(rows) == 1
+ return rows[0]
+
+
+def set_row_expanded(
+ panel: GUITreePanel,
+ node: TreeNode,
+ *,
+ expanded: bool,
+) -> None:
+ """Leaves a row standing the way the reader would leave it, which the browser then remembers."""
+ panel._expansion.remember(panel._generate_node_tag(node), expanded=expanded)
+
+
+def set_filter(
+ panel: GUITreePanel,
+ *,
+ favorites_only: bool,
+ query: str = "",
+) -> None:
+ """States what the browser is now asked to show, as a change of the control or the search box."""
+ panel._filter = TreeFilter(query=query, favorites_only=favorites_only)
+ panel._resolve_filter()
+
+
+def select_favorites(panel: GUITreePanel) -> None:
+ """Switches the favorites mode on the way the reader's click does, and resolves the pass it starts.
+
+ Asking to be shown the favorites is what asks the browser to follow a star, so a view showing an
+ opened row is read through this rather than through a mode stated any other way.
+ """
+ panel._state_favorites_only(True)
+ panel._resolve_filter()
+
+
+def deselect_favorites(panel: GUITreePanel) -> None:
+ """Switches the favorites mode off the way the reader's click does, and resolves the pass it starts.
+
+ The pass that reads the mode off is what hands back the rows it opened, so a view showing them
+ folded is read through this.
+ """
+ panel._state_favorites_only(False)
+ panel._resolve_filter()
+
+
+def click_favorites(panel: GUITreePanel, *, favorites_only: bool) -> None:
+ """States the mode the reader's click leaves the control reading, with no pass following it.
+
+ A rebuild the tree is locked against starts nothing, so the click stands as a request and the pass
+ that runs next is what answers it.
+ """
+ panel._state_favorites_only(favorites_only)
+
+
+def resolve_pass(panel: GUITreePanel) -> None:
+ """Resolves the filter afresh, which every pass of a rebuild does before it collects the rows."""
+ panel._resolve_filter()
+
+
+def view(
+ corpus: BrowserCorpus,
+ favorites: Set[Path],
+ *,
+ favorites_only: bool,
+ query: str = "",
+ auto_expand_reconstructions: bool = False,
+ auto_expand_directories: bool = False,
+) -> str:
+ """The view a browser showing the corpus under this filter leaves on screen."""
+ return render_view(
+ build_browser_panel(
+ corpus,
+ favorites,
+ favorites_only=favorites_only,
+ query=query,
+ auto_expand_reconstructions=auto_expand_reconstructions,
+ auto_expand_directories=auto_expand_directories,
+ )
+ )
+
+
+def view_on_selecting_favorites(
+ corpus: BrowserCorpus,
+ favorites: Set[Path],
+ *,
+ auto_expand_reconstructions: bool = False,
+ auto_expand_directories: bool = False,
+) -> str:
+ """The view a browser leaves once the reader switches the favorites mode on."""
+ panel = build_browser_panel(
+ corpus,
+ favorites,
+ favorites_only=False,
+ auto_expand_reconstructions=auto_expand_reconstructions,
+ auto_expand_directories=auto_expand_directories,
+ )
+ select_favorites(panel)
+ return render_view(panel)
diff --git a/tests/suite/case.py b/tests/suite/case.py
index 834e31bf..af1b2d83 100644
--- a/tests/suite/case.py
+++ b/tests/suite/case.py
@@ -13,7 +13,7 @@ class BaseTestCase(metaclass=NonInstantiableMeta):
@dataclass(frozen=True, kw_only=True)
class BaseRegularTestCase(BaseTestCase, metaclass=NonInstantiableMeta):
label: str
- expected: Any
+ expected: Any = None
@dataclass(frozen=True, kw_only=True)
diff --git a/tests/suite/dummy.py b/tests/suite/dummy.py
index 4f2d72a1..77629777 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: Any) -> bool:
+ def __eq__(self, other: object) -> 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: Any) -> bool:
+ def __eq__(self, other: object) -> bool:
if not isinstance(other, CollisionObject):
return False
diff --git a/tests/suite/errors.py b/tests/suite/errors.py
index 9cc5f65b..a79fcedd 100644
--- a/tests/suite/errors.py
+++ b/tests/suite/errors.py
@@ -4,8 +4,10 @@
import pytest
-# Opening a directory for reading raises IsADirectoryError on POSIX and PermissionError on Windows.
-DIRECTORY_READ_ERRORS: Final[Tuple[Type[OSError], ...]] = (IsADirectoryError, PermissionError)
+DIRECTORY_READ_ERRORS: Final[Tuple[Type[OSError], ...]] = (
+ IsADirectoryError,
+ PermissionError,
+)
def _invoke_with_raises(
diff --git a/tests/suite/famitracker.py b/tests/suite/famitracker.py
index 66de2bc7..d1245c51 100644
--- a/tests/suite/famitracker.py
+++ b/tests/suite/famitracker.py
@@ -3,12 +3,17 @@
from typing import Dict, List, Tuple
from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH
-from sampletones_core.formats.famitracker.specification.file import FTM_END_MARKER, FTM_MAGIC
+from sampletones_core.formats.famitracker.specification.file import (
+ FTM_END_MARKER,
+ FTM_MAGIC,
+)
from sampletones_core.formats.famitracker.specification.instruments import (
DPCM_KEY_ASSIGNMENTS,
DPCM_KEY_BYTES,
)
-from sampletones_core.formats.famitracker.specification.sequences import SEQUENCE_COUNT_2A03
+from sampletones_core.formats.famitracker.specification.sequences import (
+ SEQUENCE_COUNT_2A03,
+)
class _Cursor:
@@ -28,16 +33,20 @@ def skip(self, count: int) -> None:
self._offset += count
def read_uint8(self) -> int:
- return struct.unpack(" int:
- return struct.unpack(" int:
- return struct.unpack(" int:
- return struct.unpack(" str:
return self.read(length).rstrip(b"\x00").decode("utf-8")
@@ -50,6 +59,7 @@ def terminated_string(self) -> str:
start = self._offset
while self._data[self._offset] != 0:
self._offset += 1
+
text = self._data[start : self._offset].decode("utf-8")
self._offset += 1
return text
@@ -212,7 +222,12 @@ def _parse_instruments(payload: bytes) -> List[ParsedInstrument]:
cursor.skip(DPCM_KEY_ASSIGNMENTS * DPCM_KEY_BYTES)
name = cursor.counted_string()
instruments.append(
- ParsedInstrument(index=index, instrument_type=instrument_type, sequence_refs=refs, name=name)
+ ParsedInstrument(
+ index=index,
+ instrument_type=instrument_type,
+ sequence_refs=refs,
+ name=name,
+ )
)
return instruments
@@ -261,7 +276,10 @@ def _parse_frames(payload: bytes, channel_count: int) -> ParsedFrames:
)
-def _parse_patterns(payload: bytes, effect_columns_by_channel: Dict[int, int]) -> List[ParsedPattern]:
+def _parse_patterns(
+ payload: bytes,
+ effect_columns_by_channel: Dict[int, int],
+) -> List[ParsedPattern]:
cursor = _Cursor(payload)
patterns: List[ParsedPattern] = []
while cursor.peek(1):
@@ -287,7 +305,15 @@ def _parse_patterns(payload: bytes, effect_columns_by_channel: Dict[int, int]) -
effects=effects,
)
)
- patterns.append(ParsedPattern(track=track, channel=channel, index=index, rows=rows))
+ patterns.append(
+ ParsedPattern(
+ track=track,
+ channel=channel,
+ index=index,
+ rows=rows,
+ )
+ )
+
return patterns
@@ -331,6 +357,4 @@ def parse_ftm(data: bytes) -> ParsedModule:
)
-# The FTI parser lives in test_fti.py; SEQUENCE_COUNT_2A03 is re-exported for tests
-# that assert the instrument body shape.
EXPECTED_SEQUENCE_COUNT = SEQUENCE_COUNT_2A03
diff --git a/tests/suite/grid.py b/tests/suite/grid.py
new file mode 100644
index 00000000..d20ccbb4
--- /dev/null
+++ b/tests/suite/grid.py
@@ -0,0 +1,45 @@
+from typing import Any, Dict, Final
+
+from sampletones_application.categories.elements.global_ import ContextElements
+from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import BlockShortcuts
+from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+
+CLIPBOARD_LABELS: Final[Dict[ContextElements, str]] = {
+ ContextElements.COPY: "Copy",
+ ContextElements.CUT: "Cut",
+ ContextElements.PASTE: "Paste",
+ ContextElements.DELETE: "Delete",
+}
+
+TRACKER_BLOCK_SHORTCUTS: Final[BlockShortcuts] = BlockShortcuts(
+ copy=ShortcutId.TRACKER_COPY_BLOCK,
+ cut=ShortcutId.TRACKER_CUT_BLOCK,
+ paste=ShortcutId.TRACKER_PASTE_BLOCK,
+)
+
+ORDER_BLOCK_SHORTCUTS: Final[BlockShortcuts] = BlockShortcuts(
+ copy=ShortcutId.ORDER_COPY_BLOCK,
+ cut=ShortcutId.ORDER_CUT_BLOCK,
+ paste=ShortcutId.ORDER_PASTE_BLOCK,
+)
+
+
+def attach_edit_surface(
+ panel: Any,
+ block_shortcuts: BlockShortcuts,
+ target: Any,
+) -> None:
+ """Gives a hand-built grid panel the edit surface its menus and its cursor's target run through.
+
+ A case that builds a panel without its constructor supplies the collaborators the panel would
+ have composed, and this is the one that resolves a target and prints the clipboard items.
+ """
+ panel._surface = GridEditSurface.build(
+ grid=panel,
+ blocks=panel._blocks,
+ target=target,
+ shortcuts=panel._shortcuts,
+ block_shortcuts=block_shortcuts,
+ labels=CLIPBOARD_LABELS,
+ )
diff --git a/tests/suite/render.py b/tests/suite/render.py
new file mode 100644
index 00000000..5831f05f
--- /dev/null
+++ b/tests/suite/render.py
@@ -0,0 +1,83 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Callable, List, Optional
+
+from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer
+from sampletones_application.services.render.result import RenderResult
+from sampletones_application.services.result import (
+ ServiceCancelled,
+ ServiceError,
+ ServiceSuccess,
+)
+from sampletones_core.audio.writers import AudioOutputSpec
+
+
+@dataclass(frozen=True)
+class RenderRequest:
+ synthesizer: RowSynthesizer
+ destination: Path
+ spec: AudioOutputSpec
+ normalize: bool
+ total_samples: int
+
+
+class FakeRenderService:
+ """The render service as the logic drives it, holding what it was asked to render.
+
+ Results are delivered through the handler the logic subscribes, so a test walks a render the
+ way the worker reports one.
+ """
+
+ def __init__(self, *, accepts: bool = True) -> None:
+ self.accepts = accepts
+ self.requests: List[RenderRequest] = []
+ self.cancels: int = 0
+ self.shutdowns: int = 0
+ self.running: bool = False
+ self._handler: Optional[Callable[[RenderResult], None]] = None
+
+ def subscribe(self, handler: Callable[[RenderResult], None]) -> None:
+ self._handler = handler
+
+ def start(
+ self,
+ *,
+ synthesizer: RowSynthesizer,
+ destination: Path,
+ spec: AudioOutputSpec,
+ normalize: bool,
+ total_samples: int,
+ ) -> bool:
+ if not self.accepts:
+ return False
+
+ self.requests.append(
+ RenderRequest(
+ synthesizer=synthesizer,
+ destination=destination,
+ spec=spec,
+ normalize=normalize,
+ total_samples=total_samples,
+ )
+ )
+ self.running = True
+ return True
+
+ def cancel(self) -> None:
+ self.cancels += 1
+
+ def is_running(self) -> bool:
+ return self.running
+
+ def shutdown(self) -> None:
+ self.shutdowns += 1
+
+ def emit(self, result: RenderResult) -> None:
+ assert self._handler is not None, "The logic subscribes to the service it is given"
+ self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCancelled))
+ self._handler(result)
+
+ @property
+ def request(self) -> RenderRequest:
+ assert self.requests, "A render was expected to start"
+ return self.requests[-1]
diff --git a/tests/suite/scripts.py b/tests/suite/scripts.py
index 54ebc436..413fa9d7 100644
--- a/tests/suite/scripts.py
+++ b/tests/suite/scripts.py
@@ -1,7 +1,7 @@
import importlib.util
from types import ModuleType
-from sampletones_shared.paths import REPOSITORY_ROOT
+from sampletones_shared.paths.source import REPOSITORY_ROOT
def load_script(relative_path: str) -> ModuleType:
@@ -10,9 +10,10 @@ def load_script(relative_path: str) -> ModuleType:
The scripts under ``scripts/`` are entry points invoked by path from workflows, hooks and the
Makefile, so importing them the same way keeps a test exercising the module the tooling runs.
"""
- path = REPOSITORY_ROOT / relative_path
+ path = REPOSITORY_ROOT / "scripts" / relative_path
spec = importlib.util.spec_from_file_location(path.stem, path)
assert spec is not None and spec.loader is not None
+
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py
new file mode 100644
index 00000000..ff4b13f7
--- /dev/null
+++ b/tests/suite/sequencer.py
@@ -0,0 +1,358 @@
+from pathlib import Path
+from typing import Dict, Final, List, Optional, Sequence, Tuple
+
+import numpy as np
+
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.sequencer.order import OrderBlock, SequencerOrderLogic
+from sampletones_application.logic.sequencer.order.block import BlockKey as OrderBlockKey
+from sampletones_application.logic.sequencer.tracker import BlockNote, SequencerTrackerLogic, TrackerBlock
+from sampletones_application.logic.sequencer.tracker.block import BlockKey
+from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.instructions import (
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
+from sampletones_core.project.instruments.instrument import Instrument
+from sampletones_core.project.instruments.note_off import NoteOff
+from sampletones_core.project.patterns.row import NoteCommand
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.utils.display import (
+ BLANK,
+ NOTE_BLANK,
+ NOTE_OFF,
+ display_id,
+)
+from sampletones_shared.constants.symbols import MINUS, MIXED, PLUS
+
+SAMPLE_LENGTH: Final[int] = 64
+SAMPLE_PITCH: Final[int] = 60
+SAMPLE_VOLUME: Final[int] = 8
+SAMPLE_PERIOD: Final[int] = 4
+SAMPLE_DUTY_CYCLE: Final[int] = 0
+COLUMN_SEPARATOR: Final[str] = "|"
+UNKNOWN_SAMPLE: Final[str] = "!!"
+UNKNOWN_SAMPLE_ID: Final[str] = "a-sample-no-project-holds"
+
+
+def sample_reconstruction(generators: Sequence[GeneratorName]) -> Reconstruction:
+ """A reconstruction carrying one instruction on each of ``generators``.
+
+ The channels a reconstruction covers are what a sample governs in the sequencer, so this is
+ the knob a sequencer test turns: the audio itself is silent, since what is under test is which
+ channels a sample reaches and not how it sounds.
+
+ Each channel carries the instruction its own generator sounds, since the instruction type is
+ what names the exporter a channel is read through — so a reading taken off this reconstruction
+ is the reading the channel gives.
+ """
+ instructions = {generator: [_instruction(generator)] for generator in generators}
+ approximations = {generator: np.zeros(SAMPLE_LENGTH, dtype=np.float32) for generator in generators}
+ return Reconstruction.create(
+ approximation=np.zeros(SAMPLE_LENGTH, dtype=np.float32),
+ approximations=approximations,
+ instructions=instructions,
+ config=Config(),
+ coefficient=1.0,
+ audio_filepath=Path("/dev/null"),
+ )
+
+
+def render_frame(tracker_logic: SequencerTrackerLogic) -> Tuple[str, ...]:
+ """Every row of the frame shown, each read as the four channel cells the grid draws.
+
+ A row is written the way it appears on screen, so an expectation and a screenshot read alike.
+ The sample column is left out because it holds nothing of its own: it summarises these four,
+ and stating it again would pin the summary rather than what a gesture wrote.
+ """
+ grid = tracker_logic.build_grid()
+ return tuple(
+ f" {COLUMN_SEPARATOR} ".join(row.cells[generator].label for generator in GeneratorName.items())
+ for row in grid.rows
+ )
+
+
+def render_slots(
+ controller: ProjectController,
+ frame_index: int,
+) -> str:
+ """The pattern each channel plays at a frame, which is what tells a blank pattern from none.
+
+ A frame renders the same either way, so this is the reading that shows a write materialising a
+ pattern the channel had not held before.
+ """
+ frame = controller.project.song.order[frame_index]
+ return " ".join(display_id(frame.get(generator)) for generator in GeneratorName.items())
+
+
+def render_order(order_logic: SequencerOrderLogic) -> Tuple[str, ...]:
+ """Every channel's row of the order, each read as the pattern indices the table draws.
+
+ A row is written the way it appears on screen, so an expectation and a screenshot read alike.
+ The master row is left out because it holds nothing of its own: it summarises these four, and
+ stating it again would pin the summary rather than what a gesture wrote.
+ """
+ view_model = order_logic.build_order()
+ return tuple(
+ " ".join(view_model.entry_label(generator, position) for position in range(view_model.position_count))
+ for generator in GeneratorName.items()
+ )
+
+
+def parse_order_block(rows: Sequence[str]) -> OrderBlock:
+ """Reads an order block written the way the table draws it, one line per row.
+
+ A ``?`` states that the block says nothing about that cell, which is what leaves it out of the
+ map entirely, while ``..`` states the silence it writes.
+
+ Raises:
+ ValueError: if the rows differ in width.
+ """
+ entries: Dict[OrderBlockKey, Optional[int]] = {}
+ lines = [line.split() for line in rows]
+ widths = {len(tokens) for tokens in lines}
+ if len(widths) != 1:
+ raise ValueError(f"An order block's rows differ in width: {sorted(widths)}")
+
+ for row_offset, tokens in enumerate(lines):
+ for position_offset, token in enumerate(tokens):
+ if token != MIXED:
+ entries[(row_offset, position_offset)] = parse_index(token)
+
+ return OrderBlock(entries=entries)
+
+
+def fill_order(
+ order_logic: SequencerOrderLogic,
+ rows: Sequence[str],
+) -> None:
+ """Writes an order stated the way the table draws it, one channel entry at a time.
+
+ Each entry reaches its own channel, so a setup states the arrangement it wants while the master
+ row's fan-out stays out of it — which leaves the gesture under test the only thing that
+ exercised it. The order grows to hold the positions the statement names.
+ """
+ lines = [line.split() for line in rows]
+ reach = max((len(tokens) for tokens in lines), default=0)
+ for _ in range(reach - order_logic.position_count()):
+ order_logic.append_frame()
+
+ for generator, tokens in zip(GeneratorName.items(), lines):
+ for position, token in enumerate(tokens):
+ order_logic.set_order_entry(generator, position, parse_index(token))
+
+
+def parse_index(token: str) -> Optional[int]:
+ """The pattern index a token names, an empty slot reading as none."""
+ if token == display_id(None):
+ return None
+
+ return int(token, 16)
+
+
+def parse_block(
+ rows: Sequence[str],
+ *,
+ first_subcolumn: SubColumn,
+ sample_ids: Sequence[str],
+) -> TrackerBlock:
+ """Reads a block written the way the grid draws it, one line per row.
+
+ Tokens run from ``first_subcolumn`` and cycle through the subcolumns in order, so a line
+ carries ``|`` at each column boundary it crosses and the bars are held against the subcolumn
+ the block begins on. A ``?`` states that the block says nothing about that cell, which is what
+ leaves it out of the maps entirely.
+
+ A note names its sample by the position the grid prints, resolved through ``sample_ids``;
+ ``!!`` names a sample no project holds.
+
+ Raises:
+ ValueError: if the rows differ in width, or a bar falls where no column boundary does.
+ """
+ first_slot = SUBCOLUMNS.index(first_subcolumn)
+ notes: Dict[BlockKey, Optional[BlockNote]] = {}
+ transposes: Dict[BlockKey, Optional[int]] = {}
+ volumes: Dict[BlockKey, Optional[int]] = {}
+ lines = [_tokens(line, first_slot) for line in rows]
+ widths = {len(tokens) for tokens in lines}
+ if len(widths) != 1:
+ raise ValueError(f"A block's rows differ in width: {sorted(widths)}")
+
+ for row_offset, tokens in enumerate(lines):
+ for offset, token in enumerate(tokens):
+ slot_offset = first_slot + offset
+ key = (row_offset, slot_offset)
+ if token == MIXED:
+ continue
+
+ match SUBCOLUMNS[slot_offset % len(SUBCOLUMNS)]:
+ case SubColumn.INSTRUMENT:
+ notes[key] = parse_note(token, sample_ids)
+ case SubColumn.TRANSPOSE:
+ transposes[key] = parse_transpose(token)
+ case SubColumn.VOLUME:
+ volumes[key] = parse_volume(token)
+
+ return TrackerBlock(
+ notes=notes,
+ transposes=transposes,
+ volumes=volumes,
+ )
+
+
+def fill_frame(
+ tracker_logic: SequencerTrackerLogic,
+ rows: Sequence[str],
+ *,
+ sample_ids: Sequence[str],
+) -> None:
+ """Writes a frame stated the way the grid draws it, one channel cell at a time.
+
+ Each cell reaches its own channel, so a setup states the frame it wants while the sample
+ column's fan-out stays out of it — which leaves the gesture under test the only thing that
+ exercised it.
+ """
+ for row_index, line in enumerate(rows):
+ for generator, cell in zip(GeneratorName.items(), line.split(COLUMN_SEPARATOR)):
+ _fill_cell(
+ tracker_logic,
+ row_index,
+ generator,
+ cell.split(),
+ sample_ids,
+ )
+
+
+def parse_note(
+ token: str,
+ sample_ids: Sequence[str],
+) -> Optional[BlockNote]:
+ """The note a token names: a sample by the position it prints, a cut, or emptiness."""
+ if token == display_id(None):
+ return None
+
+ if token == NOTE_OFF:
+ return NoteOff()
+
+ if token == UNKNOWN_SAMPLE:
+ return UNKNOWN_SAMPLE_ID
+
+ return sample_ids[int(token, 16)]
+
+
+def parse_transpose(token: str) -> Optional[int]:
+ if token == NOTE_BLANK:
+ return None
+
+ magnitude = int(token[1:], 16)
+ return -magnitude if token.startswith(MINUS) else magnitude
+
+
+def parse_volume(token: str) -> Optional[int]:
+ if token == BLANK:
+ return None
+
+ return int(token, 16)
+
+
+def _instruction(generator: GeneratorName) -> InstructionUnion:
+ """The instruction a channel sounds, which is the type its generator and exporter pair with.
+
+ The two pulse channels share the pulse instruction; the triangle and the noise each take their
+ own.
+ """
+ match generator:
+ case GeneratorName.TRIANGLE:
+ return TriangleInstruction(
+ on=True,
+ pitch=SAMPLE_PITCH,
+ )
+ case GeneratorName.NOISE:
+ return NoiseInstruction(
+ on=True,
+ period=SAMPLE_PERIOD,
+ volume=SAMPLE_VOLUME,
+ short=False,
+ )
+ case _:
+ return PulseInstruction(
+ on=True,
+ pitch=SAMPLE_PITCH,
+ volume=SAMPLE_VOLUME,
+ duty_cycle=SAMPLE_DUTY_CYCLE,
+ )
+
+
+def _fill_cell(
+ tracker_logic: SequencerTrackerLogic,
+ row_index: int,
+ generator: GeneratorName,
+ tokens: Sequence[str],
+ sample_ids: Sequence[str],
+) -> None:
+ """Writes the values one channel cell states, passing over a cell that states none.
+
+ A cell is written whole where it carries anything, so the row it lands on materialises exactly
+ once however many of its subcolumns hold a value.
+ """
+ note = parse_note(tokens[0], sample_ids)
+ transpose = parse_transpose(tokens[1])
+ volume = parse_volume(tokens[2])
+ if note is None and transpose is None and volume is None:
+ return
+
+ tracker_logic.set_row(
+ generator,
+ row_index,
+ command=_command(note, generator),
+ transpose=transpose,
+ volume=volume,
+ )
+
+
+def _command(
+ note: Optional[BlockNote],
+ generator: GeneratorName,
+) -> Optional[NoteCommand]:
+ """The command a note becomes in the channel it is written to, which is what carries its pitch."""
+ match note:
+ case NoteOff():
+ return note
+ case str() as sample_id:
+ return Instrument(sample_id=sample_id, generator_name=generator)
+ case None:
+ return None
+
+
+def _tokens(line: str, first_slot: int) -> List[str]:
+ """The values a line states, checked against the columns a block starting at ``first_slot`` spans.
+
+ Raises:
+ ValueError: if a bar falls where no column boundary does.
+ """
+ groups = [group.split() for group in line.split(COLUMN_SEPARATOR)]
+ tokens = [token for group in groups for token in group]
+ widths = [len(group) for group in groups]
+ expected = _column_widths(first_slot, len(tokens))
+ if widths != expected:
+ raise ValueError(f"A block line's columns hold {widths} values where its origin spans {expected}: {line!r}")
+
+ return tokens
+
+
+def _column_widths(first_slot: int, count: int) -> List[int]:
+ """How many values each column a block spans contributes, the first starting part way in."""
+ widths: List[int] = []
+ width = len(SUBCOLUMNS) - first_slot
+ remaining = count
+ while remaining > 0:
+ widths.append(min(width, remaining))
+ remaining -= widths[-1]
+ width = len(SUBCOLUMNS)
+
+ return widths
diff --git a/tests/suite/shortcuts.py b/tests/suite/shortcuts.py
new file mode 100644
index 00000000..c7cf794c
--- /dev/null
+++ b/tests/suite/shortcuts.py
@@ -0,0 +1,26 @@
+from functools import lru_cache
+
+from sampletones_application.paths import KEYBINDINGS_DIRECTORY
+from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog
+from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+
+
+@lru_cache(maxsize=1)
+def shipped_catalog() -> ShortcutCatalog:
+ """Every keybinding scheme the build ships, read once for the whole run."""
+ return ShortcutCatalog.load(KEYBINDINGS_DIRECTORY)
+
+
+def shipped_scheme() -> ShortcutScheme:
+ """The keybinding scheme the build ships as its default."""
+ return shipped_catalog().default
+
+
+def shipped_source() -> ShortcutSource:
+ """A source over the shipped scheme, which is where a panel or a dialog reads its keys.
+
+ Reading the shipped keys keeps a case stating the gesture a user performs, so a rebind that
+ changes what a press means shows up as a failure here.
+ """
+ return ShortcutSource(shipped_scheme())
diff --git a/tests/suite/surface.py b/tests/suite/surface.py
new file mode 100644
index 00000000..052ff2e9
--- /dev/null
+++ b/tests/suite/surface.py
@@ -0,0 +1,114 @@
+from dataclasses import dataclass
+from typing import Callable, Final, List, Optional
+
+from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures
+from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import (
+ ClipboardItems,
+)
+from sampletones_application.ui.panels.sequencer.grid.surface.edit import (
+ GridEditSurface,
+)
+from sampletones_application.ui.panels.sequencer.grid.surface.targets import (
+ CursorTargets,
+)
+from sampletones_application.ui.panels.sequencer.input.state import GridInputState
+from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS
+from tests.suite.shortcuts import shipped_source
+
+CURSOR_CELL: Final[str] = "cursor cell"
+CLICKED_CELL: Final[str] = "clicked cell"
+
+
+@dataclass(frozen=True)
+class Target:
+ """The cell a set of actions was raised on, and the block those actions act on."""
+
+ cell: str
+ region: str
+
+ @classmethod
+ def at(cls, cell: str) -> "Target":
+ """The target a cell resolves to, which is the pair the fake state states for it."""
+ return cls(cell=cell, region=f"{cell} block")
+
+ @property
+ def anchor(self) -> str:
+ return f"{self.cell} anchor"
+
+
+@dataclass(frozen=True)
+class State(GridInputState[str, str]):
+ """A grid's state as the surface reads it: where the cursor stands, and what a cell falls in.
+
+ A cell's own block reads as the cell it was bounded from, so a target names the cell that
+ raised it and the block it resolved to in one readable pair.
+ """
+
+ def _region_between(self, first: str, _second: str) -> str:
+ return f"{first} block"
+
+ def _covers(self, region: str, cell: str) -> bool:
+ return region == f"{cell} block"
+
+
+CURSOR_TARGET: Final[Target] = Target.at(CURSOR_CELL)
+CLICKED_TARGET: Final[Target] = Target.at(CLICKED_CELL)
+
+
+class Grid:
+ """A grid recording what it was asked to do, in the order it was asked.
+
+ The entry it settles, the hooks it announces through and the action sets it was asked to build
+ land in one list, so a case reads both what a gesture reached and when the grid committed what
+ was being typed.
+ """
+
+ def __init__(
+ self,
+ *,
+ cursor: Optional[str] = CURSOR_CELL,
+ owns: bool = True,
+ can_paste: bool = True,
+ ) -> None:
+ self.events: List[str] = []
+ self.cursor = cursor
+ self._owns = owns
+ self.on_copy_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"copy {region}")
+ self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}")
+ self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}")
+ self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}")
+ self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste
+
+ def owns_keys(self) -> bool:
+ return self._owns
+
+ def input_state(self) -> State:
+ return State(cursor=self.cursor)
+
+ def add_action_items(self, target: Target) -> None:
+ self.events.append(f"actions {target.cell}")
+
+ def commit_entry(self) -> None:
+ self.events.append("commit")
+
+ def cursor_targets(self) -> CursorTargets[str, str, Target]:
+ """The target resolver over this grid, which is what each door asks for a cell's block."""
+ return CursorTargets(state=self.input_state, target=Target)
+
+ def clipboard_items(self) -> ClipboardItems[str, str]:
+ """The four items over this grid, printing the tracker's own keys and stand-in words."""
+ return ClipboardItems(
+ blocks=BlockGestures(grid=self),
+ shortcuts=shipped_source(),
+ block_shortcuts=TRACKER_BLOCK_SHORTCUTS,
+ labels=CLIPBOARD_LABELS,
+ )
+
+ def edit_surface(self) -> GridEditSurface[str, str, str, Target]:
+ """The surface over this grid, composed from the collaborators a real panel supplies."""
+ return GridEditSurface(
+ grid=self,
+ targets=self.cursor_targets(),
+ clipboard=self.clipboard_items(),
+ blocks=BlockGestures(grid=self),
+ )
diff --git a/tests/unit/sampletones_application/categories/key/test_grammar.py b/tests/unit/sampletones_application/categories/key/test_grammar.py
index 594ba98b..36030733 100644
--- a/tests/unit/sampletones_application/categories/key/test_grammar.py
+++ b/tests/unit/sampletones_application/categories/key/test_grammar.py
@@ -24,32 +24,116 @@ class TestCase(BaseRegularTestCase):
key: str
expected: Optional[Type[MalformedTextKeyError]]
- test_cases = [
+ test_cases = (
TestCase(label="well_formed_key", key="global.dialog.label.ok", expected=None),
- TestCase(label="element_holding_digits", key="global.context.label.pulse_1", expected=None),
- TestCase(label="element_holding_many_words", key="main.config.tooltip.window_size_input", expected=None),
- TestCase(label="another_page_and_panel", key="sequencer.grid.title.pattern", expected=None),
- TestCase(label="too_few_segments", key="global.dialog.label", expected=MalformedTextKeyError),
- TestCase(label="too_many_segments", key="global.dialog.label.ok.extra", expected=MalformedTextKeyError),
+ TestCase(
+ label="element_holding_digits",
+ key="global.context.label.pulse_1",
+ expected=None,
+ ),
+ TestCase(
+ label="element_holding_many_words",
+ key="main.config.tooltip.window_size_input",
+ expected=None,
+ ),
+ TestCase(
+ label="another_page_and_panel",
+ key="sequencer.tracker.title.pattern",
+ expected=None,
+ ),
+ TestCase(
+ label="too_few_segments",
+ key="global.dialog.label",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="too_many_segments",
+ key="global.dialog.label.ok.extra",
+ expected=MalformedTextKeyError,
+ ),
TestCase(label="single_segment", key="ok", expected=MalformedTextKeyError),
TestCase(label="empty_key", key="", expected=MalformedTextKeyError),
- TestCase(label="empty_segment", key="global..label.ok", expected=MalformedTextKeyError),
- TestCase(label="leading_separator", key=".global.dialog.label", expected=MalformedTextKeyError),
- TestCase(label="trailing_separator", key="global.dialog.label.", expected=MalformedTextKeyError),
- TestCase(label="unknown_page", key="globl.dialog.label.ok", expected=MalformedTextKeyError),
- TestCase(label="unknown_panel", key="global.dialogue.label.ok", expected=MalformedTextKeyError),
- TestCase(label="unknown_text_type", key="global.dialog.lable.ok", expected=MalformedTextKeyError),
- TestCase(label="widget_in_place_of_text_type", key="global.dialog.button.ok", expected=MalformedTextKeyError),
- TestCase(label="uppercase_key", key="GLOBAL.DIALOG.LABEL.OK", expected=MalformedTextKeyError),
- TestCase(label="uppercase_element", key="global.dialog.label.OK", expected=MalformedTextKeyError),
- TestCase(label="whitespace_in_element", key="global.dialog.label.o k", expected=MalformedTextKeyError),
- TestCase(label="surrounding_whitespace", key=" global.dialog.label.ok ", expected=MalformedTextKeyError),
- TestCase(label="template_in_element", key="global.dialog.label.{name}", expected=MalformedTextKeyError),
- TestCase(label="hyphen_in_element", key="global.dialog.label.not-ok", expected=MalformedTextKeyError),
- TestCase(label="doubled_underscore", key="global.dialog.label.not__ok", expected=MalformedTextKeyError),
- TestCase(label="leading_underscore", key="global.dialog.label._ok", expected=MalformedTextKeyError),
- TestCase(label="trailing_underscore", key="global.dialog.label.ok_", expected=MalformedTextKeyError),
- ]
+ TestCase(
+ label="empty_segment",
+ key="global..label.ok",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="leading_separator",
+ key=".global.dialog.label",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="trailing_separator",
+ key="global.dialog.label.",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="unknown_page",
+ key="globl.dialog.label.ok",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="unknown_panel",
+ key="global.dialogue.label.ok",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="unknown_text_type",
+ key="global.dialog.lable.ok",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="widget_in_place_of_text_type",
+ key="global.dialog.button.ok",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="uppercase_key",
+ key="GLOBAL.DIALOG.LABEL.OK",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="uppercase_element",
+ key="global.dialog.label.OK",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="whitespace_in_element",
+ key="global.dialog.label.o k",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="surrounding_whitespace",
+ key=" global.dialog.label.ok ",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="template_in_element",
+ key="global.dialog.label.{name}",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="hyphen_in_element",
+ key="global.dialog.label.not-ok",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="doubled_underscore",
+ key="global.dialog.label.not__ok",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="leading_underscore",
+ key="global.dialog.label._ok",
+ expected=MalformedTextKeyError,
+ ),
+ TestCase(
+ label="trailing_underscore",
+ key="global.dialog.label.ok_",
+ expected=MalformedTextKeyError,
+ ),
+ )
@pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
def test_validate_text_key(self, test_case: TestCase) -> None:
@@ -73,7 +157,10 @@ class TestMalformedTextKeyMessage:
"""A rejection has to say which segment failed and what would satisfy it."""
def test_segment_count_message_counts_the_segments(self) -> None:
- with pytest.raises(MalformedTextKeyError, match=rf"segment count is 3.*exactly {TEXT_KEY_SEGMENT_COUNT}"):
+ with pytest.raises(
+ MalformedTextKeyError,
+ match=rf"segment count is 3.*exactly {TEXT_KEY_SEGMENT_COUNT}",
+ ):
validate_text_key("global.dialog.label")
def test_segment_count_message_spells_the_grammar_out(self) -> None:
@@ -85,15 +172,24 @@ def test_slug_message_names_the_offending_position(self) -> None:
validate_text_key("global.dialog.label.Ok")
def test_unknown_page_message_lists_the_accepted_pages(self) -> None:
- with pytest.raises(MalformedTextKeyError, match=r"segment 1 'globl' must name a page.*global.*settings"):
+ with pytest.raises(
+ MalformedTextKeyError,
+ match=r"segment 1 'globl' must name a page.*global.*settings",
+ ):
validate_text_key("globl.dialog.label.ok")
def test_unknown_panel_message_lists_the_accepted_panels(self) -> None:
- with pytest.raises(MalformedTextKeyError, match=r"segment 2 'dialogue' must name a panel.*dialog"):
+ with pytest.raises(
+ MalformedTextKeyError,
+ match=r"segment 2 'dialogue' must name a panel.*dialog",
+ ):
validate_text_key("global.dialogue.label.ok")
def test_unknown_text_type_message_lists_the_accepted_text_types(self) -> None:
- with pytest.raises(MalformedTextKeyError, match=r"segment 3 'lable' must name a text type.*label.*filter"):
+ with pytest.raises(
+ MalformedTextKeyError,
+ match=r"segment 3 'lable' must name a text type.*label.*filter",
+ ):
validate_text_key("global.dialog.lable.ok")
diff --git a/tests/unit/sampletones_application/categories/key/test_text.py b/tests/unit/sampletones_application/categories/key/test_text.py
index 212039fe..d4dd7618 100644
--- a/tests/unit/sampletones_application/categories/key/test_text.py
+++ b/tests/unit/sampletones_application/categories/key/test_text.py
@@ -13,16 +13,31 @@
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
-CANCEL_KEY: Final[TextKey] = TextKey(Page.GLOBAL, Panel.DIALOG, TextType.LABEL, DialogElements.CANCEL)
+CANCEL_KEY: Final[TextKey] = TextKey(
+ Page.GLOBAL,
+ Panel.DIALOG,
+ TextType.LABEL,
+ DialogElements.CANCEL,
+)
class TestTextKeyComposition:
def test_compose_joins_all_four_parts(self) -> None:
- key = TextKey(Page.GLOBAL, Panel.DIALOG, TextType.LABEL, DialogElements.OK)
+ key = TextKey(
+ Page.GLOBAL,
+ Panel.DIALOG,
+ TextType.LABEL,
+ DialogElements.OK,
+ )
assert key.compose() == "global.dialog.label.ok"
def test_str_matches_compose(self) -> None:
- key = TextKey(Page.GLOBAL, Panel.DIALOG, TextType.TITLE, DialogElements.CANCEL)
+ key = TextKey(
+ Page.GLOBAL,
+ Panel.DIALOG,
+ TextType.TITLE,
+ DialogElements.CANCEL,
+ )
assert str(key) == key.compose()
@@ -32,7 +47,7 @@ class TestCase(BaseRegularTestCase):
key: Union[str, TextKey, TextKeyTuple]
expected: str
- test_cases = [
+ test_cases = (
TestCase(
label="string_passes_through",
key="global.dialog.label.cancel",
@@ -45,11 +60,20 @@ class TestCase(BaseRegularTestCase):
),
TestCase(
label="tuple_composes",
- key=(Page.GLOBAL, Panel.DIALOG, TextType.LABEL, DialogElements.CANCEL),
+ key=(
+ Page.GLOBAL,
+ Panel.DIALOG,
+ TextType.LABEL,
+ DialogElements.CANCEL,
+ ),
expected="global.dialog.label.cancel",
),
- ]
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_compose_text_key(self, test_case: TestCase) -> None:
assert compose_text_key(test_case.key) == test_case.expected
diff --git a/tests/unit/sampletones_application/categories/test_elements.py b/tests/unit/sampletones_application/categories/test_elements.py
new file mode 100644
index 00000000..bcab2359
--- /dev/null
+++ b/tests/unit/sampletones_application/categories/test_elements.py
@@ -0,0 +1,54 @@
+from sampletones_application.categories.elements.settings import (
+ KeybindingActionElements,
+ KeybindingCategoryElements,
+)
+from sampletones_application.utils.gui.shortcuts.ids import (
+ EDITABLE_SHORTCUT_CATEGORIES,
+ ShortcutCategory,
+ ShortcutId,
+)
+
+
+class TestKeybindingActionElements:
+ """The editor lists every action it can rebind, so each one carries a name a reader sees."""
+
+ def test_every_editable_action_carries_an_element(self) -> None:
+ missing = [
+ shortcut_id.name
+ for shortcut_id in ShortcutId
+ if shortcut_id.category in EDITABLE_SHORTCUT_CATEGORIES
+ and shortcut_id.name not in KeybindingActionElements.__members__
+ ]
+
+ assert missing == []
+
+ def test_every_element_names_an_editable_action(self) -> None:
+ editable = {
+ shortcut_id.name for shortcut_id in ShortcutId if shortcut_id.category in EDITABLE_SHORTCUT_CATEGORIES
+ }
+ stray = [element.name for element in KeybindingActionElements if element.name not in editable]
+
+ assert stray == []
+
+ def test_the_dialog_actions_leave_out_the_ones_a_modal_is_operated_by(self) -> None:
+ """Tab, Enter and Escape are how a dialog is used at all, which keeps them off the list."""
+ structural = [shortcut_id.name for shortcut_id in ShortcutId if shortcut_id.category is ShortcutCategory.DIALOG]
+
+ assert all(name not in KeybindingActionElements.__members__ for name in structural)
+
+
+class TestKeybindingCategoryElements:
+ def test_every_editable_category_carries_an_element(self) -> None:
+ missing = [
+ category.name
+ for category in EDITABLE_SHORTCUT_CATEGORIES
+ if category.name not in KeybindingCategoryElements.__members__
+ ]
+
+ assert missing == []
+
+ def test_every_element_names_an_editable_category(self) -> None:
+ editable = {category.name for category in EDITABLE_SHORTCUT_CATEGORIES}
+ stray = [element.name for element in KeybindingCategoryElements if element.name not in editable]
+
+ assert stray == []
diff --git a/tests/unit/sampletones_application/categories/test_pitch.py b/tests/unit/sampletones_application/categories/test_pitch.py
index e1c317f8..2d1ef7ee 100644
--- a/tests/unit/sampletones_application/categories/test_pitch.py
+++ b/tests/unit/sampletones_application/categories/test_pitch.py
@@ -1,7 +1,7 @@
import pytest
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.categories.pitch import build_pitch_tooltip
+from sampletones_application.categories.pitch import PitchTooltips
from sampletones_application.paths import LANG_EN
from sampletones_core.utils.pitch_kind import PERIOD_VALUE_KIND, PITCH_VALUE_KIND
@@ -13,23 +13,43 @@ def language_manager() -> LanguageManager:
class TestBuildPitchTooltip:
def test_fills_every_template_placeholder(self, language_manager: LanguageManager) -> None:
- tooltip = build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, "{}/{}/{}")
+ tooltip = PitchTooltips.build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, "{}/{}/{}")
assert "{}" not in tooltip
assert len(tooltip.split("/")) == 3
def test_example_value_agrees_with_example_name(self, language_manager: LanguageManager) -> None:
- _type_name, example_name, example_value = build_pitch_tooltip(
+ _type_name, example_name, example_value = PitchTooltips.build_pitch_tooltip(
language_manager, PITCH_VALUE_KIND, "{}|{}|{}"
).split("|")
assert PITCH_VALUE_KIND.to_name(int(example_value)) == example_name
def test_period_example_value_agrees_with_example_name(self, language_manager: LanguageManager) -> None:
- _type_name, example_name, example_value = build_pitch_tooltip(
+ _type_name, example_name, example_value = PitchTooltips.build_pitch_tooltip(
language_manager, PERIOD_VALUE_KIND, "{}|{}|{}"
).split("|")
assert PERIOD_VALUE_KIND.to_name(int(example_value)) == example_name
def test_pitch_and_period_name_the_quantity_differently(self, language_manager: LanguageManager) -> None:
- pitch_type = build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, "{}")
- period_type = build_pitch_tooltip(language_manager, PERIOD_VALUE_KIND, "{}")
+ pitch_type = PitchTooltips.build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, "{}")
+ period_type = PitchTooltips.build_pitch_tooltip(language_manager, PERIOD_VALUE_KIND, "{}")
assert pitch_type != period_type
+
+
+class TestPitchTooltips:
+ """A panel phrases both readings once and picks the one each field takes."""
+
+ def test_a_field_reads_the_help_its_kind_names(self, language_manager: LanguageManager) -> None:
+ tooltips = PitchTooltips.build(language_manager, "{}|{}|{}")
+
+ assert tooltips.for_kind(PITCH_VALUE_KIND) == tooltips.pitch
+ assert tooltips.for_kind(PERIOD_VALUE_KIND) == tooltips.period
+
+ def test_the_two_readings_phrase_the_same_template_differently(
+ self,
+ language_manager: LanguageManager,
+ ) -> None:
+ tooltips = PitchTooltips.build(language_manager, "{}|{}|{}")
+
+ assert tooltips.pitch != tooltips.period
+ assert "{}" not in tooltips.pitch
+ assert "{}" not in tooltips.period
diff --git a/tests/unit/sampletones_application/config/deployment/test_deployment.py b/tests/unit/sampletones_application/config/deployment/test_deployment.py
index ec76af5d..5d44b39d 100644
--- a/tests/unit/sampletones_application/config/deployment/test_deployment.py
+++ b/tests/unit/sampletones_application/config/deployment/test_deployment.py
@@ -78,7 +78,11 @@ def test_empty_override_falls_back_to_file(self, deployment_path: Path, monkeypa
],
)
def test_strict_history_boolean_coercion(
- self, deployment_path: Path, monkeypatch: pytest.MonkeyPatch, value: str, expected: bool
+ self,
+ deployment_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ value: str,
+ expected: bool,
) -> None:
monkeypatch.setenv(STRICT_HISTORY_ENV, value)
diff --git a/tests/unit/sampletones_application/config/managers/test_application.py b/tests/unit/sampletones_application/config/managers/test_application.py
index 3959705f..865ae32e 100644
--- a/tests/unit/sampletones_application/config/managers/test_application.py
+++ b/tests/unit/sampletones_application/config/managers/test_application.py
@@ -1,3 +1,4 @@
+import platform
from pathlib import Path
from typing import Type
from unittest.mock import patch
@@ -7,17 +8,20 @@
from sampletones_application.config.managers.application import ApplicationConfigManager
from sampletones_application.config.session.application.config import ApplicationConfig
+from sampletones_application.constants.keybindings import (
+ DEFAULT_SCHEME_NAME,
+ MACOS_SCHEME_NAME,
+)
+from sampletones_application.constants.playback import FollowMode
+from sampletones_core.data.metadata import Metadata
class TestApplicationConfigManagerRecovery:
def test_incompatible_master_gain_preserves_favorites(self, tmp_path: Path) -> None:
path = tmp_path / "config.yaml"
path.write_text(yaml.safe_dump({"audio": {"master_gain": 5.0}, "favorites": {"paths": ["/x/y"]}}))
- with patch(
- "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH",
- path,
- ):
- manager = ApplicationConfigManager()
+
+ manager = ApplicationConfigManager(path)
assert manager.config.audio.master_gain == ApplicationConfig().audio.master_gain
assert Path("/x/y") in manager.favorites
@@ -25,82 +29,180 @@ def test_incompatible_master_gain_preserves_favorites(self, tmp_path: Path) -> N
def test_invalid_history_budget_recovers_to_default(self, tmp_path: Path) -> None:
path = tmp_path / "config.yaml"
path.write_text(yaml.safe_dump({"history": {"budget": 0}, "favorites": {"paths": ["/x/y"]}}))
- with patch(
- "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH",
- path,
- ):
- manager = ApplicationConfigManager()
+
+ manager = ApplicationConfigManager(path)
assert manager.config.history.budget == ApplicationConfig().history.budget
assert Path("/x/y") in manager.favorites
class TestApplicationConfigManagerPlayback:
- def _manager(self, tmp_path: Path) -> ApplicationConfigManager:
- path = tmp_path / "config.yaml"
- with patch(
- "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH",
- path,
- ):
- return ApplicationConfigManager()
-
def test_toggle_autoplay_changes_value(self, tmp_path: Path) -> None:
- manager = self._manager(tmp_path)
+ manager = ApplicationConfigManager(tmp_path / "config.yaml")
initial = manager.autoplay
result = manager.toggle_autoplay()
assert result == (not initial)
assert manager.autoplay == (not initial)
- def test_set_follow_playback_round_trips(self, tmp_path: Path) -> None:
- manager = self._manager(tmp_path)
- manager.set_follow_playback(False)
- assert manager.follow_playback is False
- manager.set_follow_playback(True)
- assert manager.follow_playback is True
+ @pytest.mark.parametrize("mode", list(FollowMode), ids=str)
+ def test_set_follow_mode_round_trips(self, tmp_path: Path, mode: FollowMode) -> None:
+ manager = ApplicationConfigManager(tmp_path / "config.yaml")
+ manager.set_follow_mode(mode)
+ assert manager.follow_mode is mode
def test_set_loop_song_round_trips(self, tmp_path: Path) -> None:
- manager = self._manager(tmp_path)
+ manager = ApplicationConfigManager(tmp_path / "config.yaml")
manager.set_loop_song(True)
assert manager.loop_song is True
manager.set_loop_song(False)
assert manager.loop_song is False
def test_set_master_gain_round_trips(self, tmp_path: Path) -> None:
- manager = self._manager(tmp_path)
+ manager = ApplicationConfigManager(tmp_path / "config.yaml")
manager.set_master_gain(1.5)
assert manager.master_gain == 1.5
manager.set_master_gain(0.0)
assert manager.master_gain == 0.0
+class TestApplicationConfigManagerShortcuts:
+ def test_a_fresh_configuration_names_the_shipped_scheme(self, tmp_path: Path) -> None:
+ manager = ApplicationConfigManager(tmp_path / "config.yaml")
+
+ assert manager.shortcut_scheme_name == ApplicationConfig().shortcuts.scheme
+ assert manager.shortcut_overrides == {}
+
+ def test_set_shortcut_scheme_name_round_trips(self, tmp_path: Path) -> None:
+ manager = ApplicationConfigManager(tmp_path / "config.yaml")
+ manager.set_shortcut_scheme_name("compact")
+
+ assert manager.shortcut_scheme_name == "compact"
+
+ def test_set_shortcut_overrides_round_trips(self, tmp_path: Path) -> None:
+ manager = ApplicationConfigManager(tmp_path / "config.yaml")
+ manager.set_shortcut_overrides({"Undo": "Ctrl+Alt+U"})
+
+ assert manager.shortcut_overrides == {"Undo": "Ctrl+Alt+U"}
+
+ def test_the_preferences_reach_the_file_the_session_is_saved_to(self, tmp_path: Path) -> None:
+ """A rebind is read back on the next run, which is what makes it a preference."""
+ path = tmp_path / "config.yaml"
+ manager = ApplicationConfigManager(path)
+ manager.set_shortcut_scheme_name("compact")
+ manager.set_shortcut_overrides({"Undo": "Ctrl+Alt+U"})
+ manager.save()
+
+ reloaded = ApplicationConfigManager(path)
+
+ assert reloaded.shortcut_scheme_name == "compact"
+ assert reloaded.shortcut_overrides == {"Undo": "Ctrl+Alt+U"}
+
+
+class TestApplicationConfigManagerPlatformScheme:
+ """The keyboard a Mac opens on, decided when the configuration is created."""
+
+ def test_a_fresh_configuration_on_a_mac_names_the_mac_scheme(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ monkeypatch.setattr(platform, "system", lambda: "Darwin")
+
+ manager = ApplicationConfigManager(tmp_path / "config.yaml")
+
+ assert manager.shortcut_scheme_name == MACOS_SCHEME_NAME
+
+ def test_a_configuration_carrying_no_scheme_yet_takes_the_platform_one(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """A file written before the preference existed reaches the choice a fresh one makes."""
+ monkeypatch.setattr(platform, "system", lambda: "Darwin")
+ path = tmp_path / "config.yaml"
+ path.write_text(yaml.safe_dump({"favorites": {"paths": ["/x/y"]}}))
+
+ manager = ApplicationConfigManager(path)
+
+ assert manager.shortcut_scheme_name == MACOS_SCHEME_NAME
+ assert Path("/x/y") in manager.favorites
+
+ def test_a_stored_scheme_stands_on_a_mac(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """A reader who chose the Control keys keeps them on a machine labelled Command."""
+ monkeypatch.setattr(platform, "system", lambda: "Darwin")
+ path = tmp_path / "config.yaml"
+ path.write_text(yaml.safe_dump({"shortcuts": {"scheme": DEFAULT_SCHEME_NAME}}))
+
+ assert ApplicationConfigManager(path).shortcut_scheme_name == DEFAULT_SCHEME_NAME
+
+ def test_the_platform_decides_once_and_the_file_decides_after(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """The name a fresh configuration takes from its platform reaches the file, and the file
+ is what every run after reads, so the platform is asked one time."""
+ path = tmp_path / "config.yaml"
+ monkeypatch.setattr(platform, "system", lambda: "Darwin")
+ ApplicationConfigManager(path).save()
+
+ monkeypatch.setattr(platform, "system", lambda: "Linux")
+ reloaded = ApplicationConfigManager(path)
+
+ assert yaml.safe_load(path.read_text())["shortcuts"]["scheme"] == MACOS_SCHEME_NAME
+ assert reloaded.shortcut_scheme_name == MACOS_SCHEME_NAME
+
+
+class TestApplicationConfigManagerMetadata:
+ """The saved file names the build that wrote it."""
+
+ def test_a_file_written_by_an_earlier_build_is_stamped_on_save(self, tmp_path: Path) -> None:
+ path = tmp_path / "config.yaml"
+ path.write_text(yaml.safe_dump({"metadata": {"version": "0.0.1"}, "favorites": {"paths": ["/x/y"]}}))
+ manager = ApplicationConfigManager(path)
+ assert manager.config.metadata.version == "0.0.1"
+
+ manager.save()
+
+ assert yaml.safe_load(path.read_text())["metadata"] == Metadata.default().model_dump()
+
+ def test_the_settings_beside_the_metadata_stand(self, tmp_path: Path) -> None:
+ """Stamping the version rewrites the metadata alone, so a preference survives the save."""
+ path = tmp_path / "config.yaml"
+ path.write_text(yaml.safe_dump({"metadata": {"version": "0.0.1"}, "display": {"palette": "ink"}}))
+ ApplicationConfigManager(path).save()
+
+ reloaded = ApplicationConfigManager(path)
+
+ assert reloaded.palette_name == "ink"
+ assert reloaded.config.metadata == Metadata.default()
+
+
class TestApplicationConfigManagerSave:
@pytest.mark.parametrize("exception_type", [PermissionError, IsADirectoryError, OSError])
def test_save_recovers_from_file_error(self, tmp_path: Path, exception_type: Type[OSError]) -> None:
"""Config persistence degrades to logging when the disk rejects the write."""
- config_path = tmp_path / "config.yaml"
+ path = tmp_path / "config.yaml"
+ manager = ApplicationConfigManager(path)
with patch(
- "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH",
- config_path,
+ "sampletones_application.config.managers.application.save_yaml_atomic",
+ side_effect=exception_type("save failed"),
):
- manager = ApplicationConfigManager()
- with patch(
- "sampletones_application.config.managers.application.save_yaml_atomic",
- side_effect=exception_type("save failed"),
- ):
- manager.save()
+ manager.save()
- assert not config_path.exists()
+ assert not path.exists()
def test_save_propagates_unexpected_error(self, tmp_path: Path) -> None:
- config_path = tmp_path / "config.yaml"
- with patch(
- "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH",
- config_path,
- ):
- manager = ApplicationConfigManager()
- with patch(
+ manager = ApplicationConfigManager(tmp_path / "config.yaml")
+ with (
+ patch(
"sampletones_application.config.managers.application.save_yaml_atomic",
side_effect=RuntimeError("unexpected"),
- ):
- with pytest.raises(RuntimeError):
- manager.save()
+ ),
+ pytest.raises(RuntimeError),
+ ):
+ manager.save()
diff --git a/tests/unit/sampletones_application/config/managers/test_session.py b/tests/unit/sampletones_application/config/managers/test_session.py
index eed411b5..03e31c70 100644
--- a/tests/unit/sampletones_application/config/managers/test_session.py
+++ b/tests/unit/sampletones_application/config/managers/test_session.py
@@ -1,34 +1,44 @@
from pathlib import Path
+import pytest
+
from sampletones_application.categories.hierarchy import Tab
from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.config.profile import UserProfile
+from sampletones_application.tags.sequencer import TAG_SEQUENCER_BROWSER_PANEL
+
+
+@pytest.fixture
+def session(tmp_path: Path) -> SessionManager:
+ """A session over a profile of its own, in the state a first run finds."""
+ return SessionManager(
+ UserProfile(
+ config=tmp_path / "config.yaml",
+ state=tmp_path / "state.yaml",
+ )
+ )
class TestSessionManagerInit:
- def test_instantiation_succeeds(self) -> None:
- session = SessionManager()
+ def test_instantiation_succeeds(self, session: SessionManager) -> None:
assert session is not None
class TestSessionManagerWindowProperties:
- def test_fullscreen_property_returns_bool(self) -> None:
- session = SessionManager()
+ def test_fullscreen_property_returns_bool(self, session: SessionManager) -> None:
assert isinstance(session.fullscreen, bool)
- def test_window_coordinate_properties_return_ints(self) -> None:
- session = SessionManager()
+ def test_window_coordinate_properties_return_ints(self, session: SessionManager) -> None:
assert isinstance(session.window_x, int)
assert isinstance(session.window_y, int)
assert isinstance(session.window_width, int)
assert isinstance(session.window_height, int)
- def test_set_window_state_fullscreen_updates_fullscreen(self) -> None:
- session = SessionManager()
+ def test_set_window_state_fullscreen_updates_fullscreen(self, session: SessionManager) -> None:
session.set_window_state(True, 0, 0, 0, 0)
assert session.fullscreen is True
- def test_set_window_state_non_fullscreen_updates_dimensions(self) -> None:
- session = SessionManager()
+ def test_set_window_state_non_fullscreen_updates_dimensions(self, session: SessionManager) -> None:
session.set_window_state(False, 10, 20, 800, 600)
assert session.window_x == 10
assert session.window_y == 20
@@ -36,118 +46,114 @@ def test_set_window_state_non_fullscreen_updates_dimensions(self) -> None:
assert session.window_height == 600
+class TestSessionManagerKeybindings:
+ def test_shortcut_scheme_name_reflects_what_was_set(self, session: SessionManager) -> None:
+ session.set_shortcut_scheme_name("compact")
+ assert session.shortcut_scheme_name == "compact"
+
+ def test_shortcut_overrides_reflect_what_was_set(self, session: SessionManager) -> None:
+ session.set_shortcut_overrides({"Undo": "Ctrl+Alt+U"})
+ assert session.shortcut_overrides == {"Undo": "Ctrl+Alt+U"}
+
+
class TestSessionManagerTabAndSettings:
- def test_current_tab_property_returns_string(self) -> None:
- session = SessionManager()
+ def test_current_tab_property_returns_string(self, session: SessionManager) -> None:
assert isinstance(session.current_tab, str)
- def test_set_current_tab_updates_current_tab(self) -> None:
- session = SessionManager()
+ def test_set_current_tab_updates_current_tab(self, session: SessionManager) -> None:
session.set_current_tab(Tab.INSTRUCTIONS)
assert session.current_tab == Tab.INSTRUCTIONS
- def test_toggle_show_advanced_settings_returns_bool(self) -> None:
- session = SessionManager()
+ def test_toggle_show_advanced_settings_returns_bool(self, session: SessionManager) -> None:
result = session.toggle_show_advanced_settings()
assert isinstance(result, bool)
- def test_advanced_settings_property_reflects_toggle(self) -> None:
- session = SessionManager()
+ def test_advanced_settings_property_reflects_toggle(self, session: SessionManager) -> None:
initial = session.advanced_settings
session.toggle_show_advanced_settings()
assert session.advanced_settings != initial
- def test_toggle_autoplay_returns_bool(self) -> None:
- session = SessionManager()
+ def test_toggle_autoplay_returns_bool(self, session: SessionManager) -> None:
result = session.toggle_autoplay()
assert isinstance(result, bool)
- def test_autoplay_property_reflects_toggle(self) -> None:
- session = SessionManager()
+ def test_autoplay_property_reflects_toggle(self, session: SessionManager) -> None:
initial = session.autoplay
session.toggle_autoplay()
assert session.autoplay != initial
class TestSessionManagerCurrentState:
- def test_current_project_is_none_by_default_or_path(self) -> None:
- session = SessionManager()
- assert session.current_project is None or isinstance(
- session.current_project,
- Path,
- )
-
- def test_set_current_reconstruction_updates_property(self, tmp_path: Path) -> None:
- session = SessionManager()
+ def test_a_fresh_session_holds_no_current_project(self, session: SessionManager) -> None:
+ assert session.current_project is None
+
+ def test_set_current_reconstruction_updates_property(
+ self,
+ session: SessionManager,
+ tmp_path: Path,
+ ) -> None:
path = tmp_path / "rec.json"
session.set_current_reconstruction(path)
assert session.current_reconstruction == path
- def test_set_current_project_updates_property(self, tmp_path: Path) -> None:
- session = SessionManager()
+ def test_set_current_project_updates_property(
+ self,
+ session: SessionManager,
+ tmp_path: Path,
+ ) -> None:
path = tmp_path / "project.stp"
session.set_current_project(path)
assert session.current_project == path
class TestSessionManagerPaths:
- def test_set_and_get_config_path(self, tmp_path: Path) -> None:
- session = SessionManager()
+ def test_set_and_get_config_path(self, session: SessionManager, tmp_path: Path) -> None:
session.set_config_path(tmp_path / "config.json")
- result = session.get_config_path()
- assert isinstance(result, Path)
+ assert isinstance(session.get_config_path(), Path)
- def test_set_and_get_library_path(self, tmp_path: Path) -> None:
- session = SessionManager()
+ def test_set_and_get_library_path(self, session: SessionManager, tmp_path: Path) -> None:
session.set_library_path(tmp_path / "lib.nlib")
- result = session.get_library_path()
- assert isinstance(result, Path)
+ assert isinstance(session.get_library_path(), Path)
- def test_set_and_get_instrument_path(self, tmp_path: Path) -> None:
- session = SessionManager()
+ def test_set_and_get_instrument_path(self, session: SessionManager, tmp_path: Path) -> None:
session.set_instrument_path(tmp_path / "instr.json")
- result = session.get_instrument_path()
- assert isinstance(result, Path)
+ assert isinstance(session.get_instrument_path(), Path)
- def test_set_and_get_reconstruction_path(self, tmp_path: Path) -> None:
- session = SessionManager()
+ def test_set_and_get_reconstruction_path(self, session: SessionManager, tmp_path: Path) -> None:
session.set_reconstruction_path(tmp_path / "rec.json")
- result = session.get_reconstruction_path()
- assert isinstance(result, Path)
+ assert isinstance(session.get_reconstruction_path(), Path)
- def test_set_and_get_audio_input_path(self, tmp_path: Path) -> None:
- session = SessionManager()
+ def test_set_and_get_audio_input_path(self, session: SessionManager, tmp_path: Path) -> None:
session.set_audio_input_path(tmp_path / "clip.wav")
- result = session.get_audio_input_path()
- assert isinstance(result, Path)
+ assert isinstance(session.get_audio_input_path(), Path)
- def test_set_and_get_audio_path(self, tmp_path: Path) -> None:
- session = SessionManager()
+ def test_set_and_get_audio_path(self, session: SessionManager, tmp_path: Path) -> None:
session.set_audio_path(tmp_path / "audio.wav")
- result = session.get_audio_path()
- assert isinstance(result, Path)
+ assert isinstance(session.get_audio_path(), Path)
- def test_set_and_get_project_path(self, tmp_path: Path) -> None:
- session = SessionManager()
+ def test_set_and_get_project_path(self, session: SessionManager, tmp_path: Path) -> None:
session.set_project_path(tmp_path / "project.stp")
- result = session.get_project_path()
- assert isinstance(result, Path)
+ assert isinstance(session.get_project_path(), Path)
class TestSessionManagerFavorites:
- def test_toggle_favorite_adds_path(self, tmp_path: Path) -> None:
- session = SessionManager()
+ def test_toggle_favorite_adds_path(self, session: SessionManager, tmp_path: Path) -> None:
path = tmp_path / "favorite"
session.toggle_favorite(path)
assert path in session.favorites
- def test_toggle_favorite_twice_removes_path(self, tmp_path: Path) -> None:
- session = SessionManager()
+ def test_toggle_favorite_twice_removes_path(self, session: SessionManager, tmp_path: Path) -> None:
path = tmp_path / "favorite"
session.toggle_favorite(path)
session.toggle_favorite(path)
assert path not in session.favorites
- def test_favorites_returns_set(self) -> None:
- session = SessionManager()
+ def test_favorites_returns_set(self, session: SessionManager) -> None:
assert isinstance(session.favorites, set)
+
+ def test_a_browser_reads_the_favorites_filter_it_was_given(self, session: SessionManager) -> None:
+ session.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True)
+ assert session.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is True
+
+ def test_a_browser_a_first_run_finds_shows_the_whole_tree(self, session: SessionManager) -> None:
+ assert session.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is False
diff --git a/tests/unit/sampletones_application/config/managers/test_state.py b/tests/unit/sampletones_application/config/managers/test_state.py
index e450352a..a01acd0a 100644
--- a/tests/unit/sampletones_application/config/managers/test_state.py
+++ b/tests/unit/sampletones_application/config/managers/test_state.py
@@ -8,149 +8,218 @@
from sampletones_application.categories.hierarchy import Tab
from sampletones_application.config.managers.state import ApplicationStateManager
from sampletones_application.config.session.state.state import ApplicationState
+from sampletones_application.tags.reconstructions import TAG_RECONSTRUCTIONS_BROWSER_PANEL
+from sampletones_application.tags.sequencer import TAG_SEQUENCER_BROWSER_PANEL
+
+
+@pytest.fixture
+def manager(tmp_path: Path) -> ApplicationStateManager:
+ """A manager over a state file of its own, in the state a first run finds."""
+ return ApplicationStateManager(tmp_path / "state.yaml")
class TestApplicationStateManagerRecovery:
def test_incompatible_field_preserves_remaining_state(self, tmp_path: Path) -> None:
path = tmp_path / "state.yaml"
path.write_text(yaml.safe_dump({"viewport": {"width": "huge"}, "advanced_settings": True}))
- with patch(
- "sampletones_application.config.managers.state.APPLICATION_STATE_PATH",
- path,
- ):
- manager = ApplicationStateManager()
+
+ manager = ApplicationStateManager(path)
assert manager.advanced_settings is True
assert manager.window_width == ApplicationState().viewport.width
class TestApplicationStateManagerInit:
- def test_instantiation_loads_application_state(self) -> None:
- manager = ApplicationStateManager()
+ def test_instantiation_loads_application_state(self, tmp_path: Path) -> None:
+ path = tmp_path / "state.yaml"
+ path.write_text(yaml.safe_dump({"advanced_settings": True}))
+
+ manager = ApplicationStateManager(path)
+
assert isinstance(manager.state, ApplicationState)
+ assert manager.advanced_settings is True
def test_state_loaded_from_nonexistent_path_is_default(self) -> None:
- nonexistent = Path("/nonexistent/path/state.yaml")
- with patch(
- "sampletones_application.config.managers.state.APPLICATION_STATE_PATH",
- nonexistent,
- ):
- manager = ApplicationStateManager()
+ manager = ApplicationStateManager(Path("/nonexistent/path/state.yaml"))
assert isinstance(manager.state, ApplicationState)
class TestApplicationStateManagerWindowProperties:
- def test_fullscreen_property_returns_bool(self) -> None:
- manager = ApplicationStateManager()
+ def test_fullscreen_property_returns_bool(self, manager: ApplicationStateManager) -> None:
assert isinstance(manager.fullscreen, bool)
- def test_window_x_returns_int(self) -> None:
- manager = ApplicationStateManager()
+ def test_window_x_returns_int(self, manager: ApplicationStateManager) -> None:
assert isinstance(manager.window_x, int)
- def test_window_y_returns_int(self) -> None:
- manager = ApplicationStateManager()
+ def test_window_y_returns_int(self, manager: ApplicationStateManager) -> None:
assert isinstance(manager.window_y, int)
- def test_window_width_returns_int(self) -> None:
- manager = ApplicationStateManager()
+ def test_window_width_returns_int(self, manager: ApplicationStateManager) -> None:
assert isinstance(manager.window_width, int)
- def test_window_height_returns_int(self) -> None:
- manager = ApplicationStateManager()
+ def test_window_height_returns_int(self, manager: ApplicationStateManager) -> None:
assert isinstance(manager.window_height, int)
- def test_set_window_state_fullscreen_updates_fullscreen(self) -> None:
- manager = ApplicationStateManager()
+ def test_set_window_state_fullscreen_updates_fullscreen(self, manager: ApplicationStateManager) -> None:
manager.set_window_state(True, 0, 0, 0, 0)
assert manager.fullscreen is True
- def test_set_window_state_non_fullscreen_updates_position(self) -> None:
- manager = ApplicationStateManager()
+ def test_set_window_state_non_fullscreen_updates_position(self, manager: ApplicationStateManager) -> None:
manager.set_window_state(False, 100, 200, 800, 600)
assert manager.window_x == 100
assert manager.window_y == 200
assert manager.window_width == 800
assert manager.window_height == 600
- def test_set_window_state_fullscreen_does_not_update_position(self) -> None:
- manager = ApplicationStateManager()
+ def test_set_window_state_fullscreen_does_not_update_position(self, manager: ApplicationStateManager) -> None:
original_x = manager.window_x
manager.set_window_state(True, 999, 999, 999, 999)
assert manager.window_x == original_x
class TestApplicationStateManagerTabAndAdvanced:
- def test_set_current_tab_updates_tab(self) -> None:
- manager = ApplicationStateManager()
+ def test_set_current_tab_updates_tab(self, manager: ApplicationStateManager) -> None:
manager.set_current_tab(Tab.INSTRUCTIONS)
assert manager.load_current_tab() == Tab.INSTRUCTIONS
- def test_current_tab_property_returns_tab(self) -> None:
- manager = ApplicationStateManager()
+ def test_current_tab_property_returns_tab(self, manager: ApplicationStateManager) -> None:
assert isinstance(manager.current_tab, str)
- def test_toggle_show_advanced_settings_changes_value(self) -> None:
- manager = ApplicationStateManager()
+ def test_toggle_show_advanced_settings_changes_value(self, manager: ApplicationStateManager) -> None:
initial = manager.advanced_settings
result = manager.toggle_show_advanced_settings()
assert result == (not initial)
assert manager.advanced_settings == (not initial)
+class TestApplicationStateManagerCardsAndFilters:
+ """The per-panel state a card keeps: whether it is collapsed, and what its browser narrows to."""
+
+ def test_a_card_no_run_has_touched_reads_expanded(self, manager: ApplicationStateManager) -> None:
+ assert manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL) is False
+
+ def test_a_card_reads_the_collapse_it_was_given(self, manager: ApplicationStateManager) -> None:
+ manager.set_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL, True)
+ assert manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL) is True
+
+ def test_a_browser_no_run_has_touched_shows_the_whole_tree(self, manager: ApplicationStateManager) -> None:
+ assert manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is False
+
+ def test_a_browser_reads_the_filter_it_was_given(self, manager: ApplicationStateManager) -> None:
+ manager.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True)
+ assert manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is True
+
+ def test_each_browser_keeps_the_filter_of_its_own_panel(self, manager: ApplicationStateManager) -> None:
+ manager.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True)
+
+ assert manager.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL) is False
+
+ def test_the_filter_and_the_collapse_of_one_panel_stand_apart(self, manager: ApplicationStateManager) -> None:
+ manager.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True)
+
+ assert manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL) is False
+
+ def test_a_browser_no_run_has_touched_stands_open_nowhere(self, manager: ApplicationStateManager) -> None:
+ assert manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL) == set()
+
+ def test_a_browser_reads_the_rows_it_was_given(self, manager: ApplicationStateManager) -> None:
+ manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.a", "row.b"})
+ assert manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL) == {"row.a", "row.b"}
+
+ def test_each_browser_keeps_the_rows_of_its_own_panel(self, manager: ApplicationStateManager) -> None:
+ manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.a"})
+
+ assert manager.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL) == set()
+
+ def test_an_explorer_no_run_has_touched_stands_open_nowhere(self, manager: ApplicationStateManager) -> None:
+ assert manager.expanded_directories == set()
+
+ def test_the_explorer_reads_the_folders_it_was_given(
+ self,
+ manager: ApplicationStateManager,
+ tmp_path: Path,
+ ) -> None:
+ manager.set_expanded_directories({tmp_path})
+ assert manager.expanded_directories == {tmp_path}
+
+ def test_the_rows_are_written_in_a_settled_order(self, manager: ApplicationStateManager) -> None:
+ """The file reads the same twice, whichever order the browser answered its rows in."""
+ manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.b", "row.a"})
+
+ assert manager.state.expanded_rows[TAG_SEQUENCER_BROWSER_PANEL] == ["row.a", "row.b"]
+
+
class TestApplicationStateManagerCurrentPaths:
- def test_set_current_reconstruction_updates_property(self, tmp_path: Path) -> None:
- manager = ApplicationStateManager()
+ def test_set_current_reconstruction_updates_property(
+ self,
+ manager: ApplicationStateManager,
+ tmp_path: Path,
+ ) -> None:
path = tmp_path / "rec.json"
manager.set_current_reconstruction(path)
assert manager.current_reconstruction == path
- def test_set_current_reconstruction_to_none(self) -> None:
- manager = ApplicationStateManager()
+ def test_set_current_reconstruction_to_none(self, manager: ApplicationStateManager) -> None:
manager.set_current_reconstruction(None)
assert manager.current_reconstruction is None
- def test_set_current_project_updates_property(self, tmp_path: Path) -> None:
- manager = ApplicationStateManager()
+ def test_set_current_project_updates_property(
+ self,
+ manager: ApplicationStateManager,
+ tmp_path: Path,
+ ) -> None:
path = tmp_path / "project.stp"
manager.set_current_project(path)
assert manager.current_project == path
class TestApplicationStateManagerLastPaths:
- def test_set_config_path_stores_directory(self, tmp_path: Path) -> None:
- manager = ApplicationStateManager()
- file_path = tmp_path / "config.json"
- manager.set_config_path(file_path)
- result = manager.get_config_path()
- assert isinstance(result, Path)
-
- def test_set_library_path_stores_directory(self, tmp_path: Path) -> None:
- manager = ApplicationStateManager()
+ def test_set_config_path_stores_directory(
+ self,
+ manager: ApplicationStateManager,
+ tmp_path: Path,
+ ) -> None:
+ manager.set_config_path(tmp_path / "config.json")
+ assert isinstance(manager.get_config_path(), Path)
+
+ def test_set_library_path_stores_directory(
+ self,
+ manager: ApplicationStateManager,
+ tmp_path: Path,
+ ) -> None:
manager.set_library_path(tmp_path / "lib.json")
assert isinstance(manager.get_library_path(), Path)
- def test_set_instrument_path_stores_directory(self, tmp_path: Path) -> None:
- manager = ApplicationStateManager()
+ def test_set_instrument_path_stores_directory(
+ self,
+ manager: ApplicationStateManager,
+ tmp_path: Path,
+ ) -> None:
manager.set_instrument_path(tmp_path / "instr.json")
assert isinstance(manager.get_instrument_path(), Path)
def test_set_reconstruction_path_stores_directory(
self,
+ manager: ApplicationStateManager,
tmp_path: Path,
) -> None:
- manager = ApplicationStateManager()
manager.set_reconstruction_path(tmp_path / "rec.json")
assert isinstance(manager.get_reconstruction_path(), Path)
- def test_set_audio_input_path_stores_directory(self, tmp_path: Path) -> None:
- manager = ApplicationStateManager()
+ def test_set_audio_input_path_stores_directory(
+ self,
+ manager: ApplicationStateManager,
+ tmp_path: Path,
+ ) -> None:
manager.set_audio_input_path(tmp_path / "clip.wav")
assert isinstance(manager.get_audio_input_path(), Path)
- def test_audio_input_and_reconstruction_paths_are_independent(self, tmp_path: Path) -> None:
- manager = ApplicationStateManager()
+ def test_audio_input_and_reconstruction_paths_are_independent(
+ self,
+ manager: ApplicationStateManager,
+ tmp_path: Path,
+ ) -> None:
reconstruction_directory = tmp_path / "reconstructions"
audio_directory = tmp_path / "audio"
reconstruction_directory.mkdir()
@@ -162,69 +231,96 @@ def test_audio_input_and_reconstruction_paths_are_independent(self, tmp_path: Pa
assert manager.get_reconstruction_path() == reconstruction_directory
assert manager.get_audio_input_path() == audio_directory
- def test_set_audio_path_stores_directory(self, tmp_path: Path) -> None:
- manager = ApplicationStateManager()
+ def test_set_audio_path_stores_directory(
+ self,
+ manager: ApplicationStateManager,
+ tmp_path: Path,
+ ) -> None:
manager.set_audio_path(tmp_path / "audio.wav")
assert isinstance(manager.get_audio_path(), Path)
- def test_set_project_path_stores_directory(self, tmp_path: Path) -> None:
- manager = ApplicationStateManager()
+ def test_set_project_path_stores_directory(
+ self,
+ manager: ApplicationStateManager,
+ tmp_path: Path,
+ ) -> None:
manager.set_project_path(tmp_path / "project.stp")
assert isinstance(manager.get_project_path(), Path)
class TestApplicationStateManagerSave:
def test_save_creates_state_file(self, tmp_path: Path) -> None:
- state_path = tmp_path / "state.yaml"
- with patch(
- "sampletones_application.config.managers.state.APPLICATION_STATE_PATH",
- state_path,
- ):
- manager = ApplicationStateManager()
- manager.save()
+ path = tmp_path / "state.yaml"
+
+ ApplicationStateManager(path).save()
- assert state_path.exists()
+ assert path.exists()
def test_save_and_reload_preserves_advanced_settings(self, tmp_path: Path) -> None:
- state_path = tmp_path / "state.yaml"
- with patch(
- "sampletones_application.config.managers.state.APPLICATION_STATE_PATH",
- state_path,
- ):
- manager = ApplicationStateManager()
- manager.toggle_show_advanced_settings()
- manager.save()
- reloaded = ApplicationStateManager()
+ path = tmp_path / "state.yaml"
+ manager = ApplicationStateManager(path)
+ manager.toggle_show_advanced_settings()
+ manager.save()
+
+ reloaded = ApplicationStateManager(path)
assert reloaded.advanced_settings == manager.advanced_settings
+ def test_save_and_reload_preserves_each_browser_filter(self, tmp_path: Path) -> None:
+ """The mode a browser was left in returns on the next launch, for that browser alone."""
+ path = tmp_path / "state.yaml"
+ manager = ApplicationStateManager(path)
+ manager.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True)
+ manager.save()
+
+ reloaded = ApplicationStateManager(path)
+
+ assert reloaded.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is True
+
+ def test_save_and_reload_preserves_the_rows_each_browser_stands_open(self, tmp_path: Path) -> None:
+ """The shape the reader unfolded returns on the next launch, for that browser alone."""
+ path = tmp_path / "state.yaml"
+ manager = ApplicationStateManager(path)
+ manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.a", "row.b"})
+ manager.save()
+
+ reloaded = ApplicationStateManager(path)
+
+ assert reloaded.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL) == {"row.a", "row.b"}
+ assert reloaded.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL) == set()
+
+ def test_save_and_reload_preserves_the_folders_the_explorer_stands_open(self, tmp_path: Path) -> None:
+ """The folders the reader walked into return on the next launch, read down to as they were."""
+ path = tmp_path / "state.yaml"
+ manager = ApplicationStateManager(path)
+ manager.set_expanded_directories({tmp_path / "music", tmp_path / "notes"})
+ manager.save()
+
+ reloaded = ApplicationStateManager(path)
+
+ assert reloaded.expanded_directories == {tmp_path / "music", tmp_path / "notes"}
+ assert reloaded.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL) is False
+
@pytest.mark.parametrize("exception_type", [PermissionError, IsADirectoryError, OSError])
def test_save_recovers_from_file_error(self, tmp_path: Path, exception_type: Type[OSError]) -> None:
"""State persistence degrades to logging when the disk rejects the write."""
- state_path = tmp_path / "state.yaml"
+ path = tmp_path / "state.yaml"
+ manager = ApplicationStateManager(path)
with patch(
- "sampletones_application.config.managers.state.APPLICATION_STATE_PATH",
- state_path,
+ "sampletones_application.config.managers.state.save_yaml_atomic",
+ side_effect=exception_type("save failed"),
):
- manager = ApplicationStateManager()
- with patch(
- "sampletones_application.config.managers.state.save_yaml_atomic",
- side_effect=exception_type("save failed"),
- ):
- manager.save()
+ manager.save()
- assert not state_path.exists()
+ assert not path.exists()
def test_save_propagates_unexpected_error(self, tmp_path: Path) -> None:
- state_path = tmp_path / "state.yaml"
- with patch(
- "sampletones_application.config.managers.state.APPLICATION_STATE_PATH",
- state_path,
- ):
- manager = ApplicationStateManager()
- with patch(
+ manager = ApplicationStateManager(tmp_path / "state.yaml")
+ with (
+ patch(
"sampletones_application.config.managers.state.save_yaml_atomic",
side_effect=RuntimeError("unexpected"),
- ):
- with pytest.raises(RuntimeError):
- manager.save()
+ ),
+ pytest.raises(RuntimeError),
+ ):
+ manager.save()
diff --git a/tests/unit/sampletones_application/config/session/application/test_display.py b/tests/unit/sampletones_application/config/session/application/test_display.py
new file mode 100644
index 00000000..0d6ce7b5
--- /dev/null
+++ b/tests/unit/sampletones_application/config/session/application/test_display.py
@@ -0,0 +1,63 @@
+import pytest
+from pydantic import ValidationError
+
+from sampletones_application.config.session.application.config import ApplicationConfig
+from sampletones_application.config.session.application.display import (
+ DEFAULT_BORDERLESS,
+ DEFAULT_MAX_FPS,
+ DEFAULT_VSYNC,
+ DisplayConfig,
+)
+from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME
+from sampletones_shared.display import UNLIMITED_FRAME_RATE
+
+
+class TestDefaults:
+ def test_a_fresh_configuration_wears_the_default_palette(self) -> None:
+ assert DisplayConfig().palette == DEFAULT_PALETTE_NAME
+
+ def test_a_fresh_configuration_paces_as_shipped(self) -> None:
+ display = DisplayConfig()
+
+ assert (display.vsync, display.max_fps, display.borderless) == (
+ DEFAULT_VSYNC,
+ DEFAULT_MAX_FPS,
+ DEFAULT_BORDERLESS,
+ )
+
+ def test_the_application_configuration_carries_a_display_section(self) -> None:
+ assert ApplicationConfig().display == DisplayConfig()
+
+
+class TestFrameRate:
+ def test_the_unlimited_setting_is_accepted(self) -> None:
+ assert DisplayConfig(max_fps=UNLIMITED_FRAME_RATE).max_fps == UNLIMITED_FRAME_RATE
+
+ def test_a_negative_frame_rate_is_rejected(self) -> None:
+ with pytest.raises(ValidationError):
+ DisplayConfig(max_fps=-1)
+
+
+class TestRoundTrip:
+ def test_the_settings_survive_a_dump_and_a_reload(self) -> None:
+ display = DisplayConfig(
+ palette="light",
+ vsync=False,
+ max_fps=UNLIMITED_FRAME_RATE,
+ borderless=True,
+ )
+
+ assert DisplayConfig.model_validate(display.model_dump()) == display
+
+ def test_the_settings_survive_the_whole_application_configuration(self) -> None:
+ config = ApplicationConfig()
+ config.display.palette = "dark"
+ config.display.borderless = True
+
+ reloaded = ApplicationConfig.model_validate(config.model_dump())
+
+ assert (reloaded.display.palette, reloaded.display.borderless) == ("dark", True)
+
+ def test_a_configuration_written_before_the_display_section_reads_the_defaults(self) -> None:
+ """A stored file outlives the build that wrote it, so an absent section takes defaults."""
+ assert ApplicationConfig.model_validate({}).display == DisplayConfig()
diff --git a/tests/unit/sampletones_application/config/session/application/test_shortcuts.py b/tests/unit/sampletones_application/config/session/application/test_shortcuts.py
new file mode 100644
index 00000000..2ee2f405
--- /dev/null
+++ b/tests/unit/sampletones_application/config/session/application/test_shortcuts.py
@@ -0,0 +1,44 @@
+from sampletones_application.config.session.application.config import ApplicationConfig
+from sampletones_application.config.session.application.shortcuts import ShortcutsConfig
+from sampletones_application.constants.keybindings import platform_scheme_name
+
+REBOUND_UNDO = {"Undo": "Ctrl+Alt+U"}
+
+
+class TestDefaults:
+ def test_a_fresh_configuration_runs_the_scheme_the_platform_ships(self) -> None:
+ assert ShortcutsConfig().scheme == platform_scheme_name()
+
+ def test_a_fresh_configuration_rebinds_nothing(self) -> None:
+ assert ShortcutsConfig().overrides == {}
+
+ def test_each_configuration_carries_its_own_overrides(self) -> None:
+ """One reader's rebind stays with the configuration it was made in."""
+ first = ShortcutsConfig()
+ first.overrides.update(REBOUND_UNDO)
+
+ assert first.overrides == REBOUND_UNDO
+ assert ShortcutsConfig().overrides == {}
+
+ def test_the_application_configuration_carries_a_shortcuts_section(self) -> None:
+ assert ApplicationConfig().shortcuts == ShortcutsConfig()
+
+
+class TestRoundTrip:
+ def test_the_preferences_survive_a_dump_and_a_reload(self) -> None:
+ shortcuts = ShortcutsConfig(scheme="compact", overrides=REBOUND_UNDO)
+
+ assert ShortcutsConfig.model_validate(shortcuts.model_dump()) == shortcuts
+
+ def test_the_preferences_survive_the_whole_application_configuration(self) -> None:
+ config = ApplicationConfig()
+ config.shortcuts.scheme = "compact"
+ config.shortcuts.overrides = dict(REBOUND_UNDO)
+
+ reloaded = ApplicationConfig.model_validate(config.model_dump())
+
+ assert (reloaded.shortcuts.scheme, reloaded.shortcuts.overrides) == ("compact", REBOUND_UNDO)
+
+ def test_a_configuration_written_before_the_shortcuts_section_reads_the_defaults(self) -> None:
+ """A stored file outlives the build that wrote it, so an absent section takes defaults."""
+ assert ApplicationConfig.model_validate({}).shortcuts == ShortcutsConfig()
diff --git a/tests/unit/sampletones_application/config/test_profile.py b/tests/unit/sampletones_application/config/test_profile.py
new file mode 100644
index 00000000..4a123e29
--- /dev/null
+++ b/tests/unit/sampletones_application/config/test_profile.py
@@ -0,0 +1,25 @@
+from pathlib import Path
+
+from sampletones_application.config.profile import UserProfile
+from sampletones_application.paths import APPLICATION_STATE_PATH
+from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH
+
+
+class TestUserProfile:
+ """The profile a normal run reads, which is the one place naming the shipped locations."""
+
+ def test_the_user_profile_names_the_shipped_locations(self) -> None:
+ profile = UserProfile.user()
+
+ assert profile.config == APPLICATION_CONFIG_PATH
+ assert profile.state == APPLICATION_STATE_PATH
+
+ def test_a_profile_keeps_the_locations_it_was_given(self, tmp_path: Path) -> None:
+ """A run pointed elsewhere reads and writes there, which is what isolates one from another."""
+ profile = UserProfile(
+ config=tmp_path / "config.yaml",
+ state=tmp_path / "state.yaml",
+ )
+
+ assert profile.config == tmp_path / "config.yaml"
+ assert profile.state == tmp_path / "state.yaml"
diff --git a/tests/unit/sampletones_application/constants/__init__.py b/tests/unit/sampletones_application/constants/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/constants/test_keybindings.py b/tests/unit/sampletones_application/constants/test_keybindings.py
new file mode 100644
index 00000000..563dbb9d
--- /dev/null
+++ b/tests/unit/sampletones_application/constants/test_keybindings.py
@@ -0,0 +1,96 @@
+import platform
+from dataclasses import dataclass
+
+import pytest
+
+from sampletones_application.config.session.application.shortcuts import ShortcutsConfig
+from sampletones_application.constants.keybindings import (
+ DEFAULT_SCHEME_NAME,
+ MACOS_SCHEME_NAME,
+ PLATFORM_SCHEME_NAMES,
+ platform_scheme_name,
+)
+from sampletones_application.paths import KEYBINDINGS_DIRECTORY
+from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog
+from sampletones_shared.utils.system.system import System
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+
+class TestPlatformScheme(BaseTestSuite):
+ """The keyboard a fresh profile opens on, one platform at a time.
+
+ A platform is named as :mod:`platform` reports it, which is what the choice is read from.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ system: str
+ expected: str
+
+ test_cases = (
+ TestCase(
+ label="linux",
+ system="Linux",
+ expected=DEFAULT_SCHEME_NAME,
+ ),
+ TestCase(
+ label="windows",
+ system="Windows",
+ expected=DEFAULT_SCHEME_NAME,
+ ),
+ TestCase(
+ label="macos",
+ system="Darwin",
+ expected=MACOS_SCHEME_NAME,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_scheme_a_platform_ships(
+ self,
+ test_case: TestCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ monkeypatch.setattr(platform, "system", lambda: test_case.system)
+
+ assert platform_scheme_name() == test_case.expected
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_a_fresh_profile_opens_on_it(
+ self,
+ test_case: TestCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """A reader who has chosen nothing yet starts on the keys their machine is labelled with."""
+ monkeypatch.setattr(platform, "system", lambda: test_case.system)
+
+ assert ShortcutsConfig().scheme == test_case.expected
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_build_ships_the_scheme_it_names(
+ self,
+ test_case: TestCase,
+ ) -> None:
+ """Every platform's choice reaches a file, so a fresh profile starts on a scheme that loads."""
+ assert test_case.expected in ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).names
+
+
+class TestPlatformCoverage:
+ def test_every_supported_platform_names_a_scheme(self) -> None:
+ assert set(PLATFORM_SCHEME_NAMES) == set(System)
+
+ def test_a_stored_preference_is_read_ahead_of_the_platform(self) -> None:
+ assert ShortcutsConfig(scheme=MACOS_SCHEME_NAME).scheme == MACOS_SCHEME_NAME
diff --git a/tests/unit/sampletones_application/coordinators/edit/__init__.py b/tests/unit/sampletones_application/coordinators/edit/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/coordinators/edit/test_router.py b/tests/unit/sampletones_application/coordinators/edit/test_router.py
new file mode 100644
index 00000000..61d52fc8
--- /dev/null
+++ b/tests/unit/sampletones_application/coordinators/edit/test_router.py
@@ -0,0 +1,66 @@
+from typing import List, Sequence
+
+from sampletones_application.coordinators.edit.router import EditRouter
+
+
+class FakeSurface:
+ """A test double for a grid offering editing gestures on the cell it holds a cursor in."""
+
+ def __init__(self, name: str, *, focused: bool) -> None:
+ self.name = name
+ self.focused = focused
+ self.builds = 0
+
+ def owns_edit_actions(self) -> bool:
+ return self.focused
+
+ def build_edit_actions(self) -> None:
+ self.builds += 1
+
+
+def _router(surfaces: Sequence[FakeSurface]) -> EditRouter:
+ return EditRouter(surfaces=surfaces)
+
+
+class TestFocusedSurface:
+ """The menu states the actions of whoever holds the cursor when it is opened."""
+
+ def test_the_focused_surface_states_its_actions(self) -> None:
+ tracker = FakeSurface("tracker", focused=True)
+ order = FakeSurface("order", focused=False)
+ router = _router([tracker, order])
+
+ assert router.build_menu_actions() is True
+ assert (tracker.builds, order.builds) == (1, 0)
+
+ def test_a_surface_left_behind_states_nothing(self) -> None:
+ tracker = FakeSurface("tracker", focused=False)
+ order = FakeSurface("order", focused=True)
+ router = _router([tracker, order])
+
+ router.build_menu_actions()
+
+ assert (tracker.builds, order.builds) == (0, 1)
+
+ def test_nothing_is_built_with_no_surface_focused(self) -> None:
+ """A tab switch leaves both grids holding their cursors while neither owns the keys."""
+ surfaces = [FakeSurface("tracker", focused=False), FakeSurface("order", focused=False)]
+ router = _router(surfaces)
+
+ assert router.build_menu_actions() is False
+ assert [surface.builds for surface in surfaces] == [0, 0]
+
+ def test_a_router_with_no_surface_reports_nothing_built(self) -> None:
+ assert _router([]).build_menu_actions() is False
+
+ def test_the_surface_is_resolved_on_each_call(self) -> None:
+ """The router holds no target, so a cursor taken after it was built reaches the menu."""
+ tracker = FakeSurface("tracker", focused=False)
+ router = _router([tracker])
+
+ first: List[bool] = [router.build_menu_actions()]
+ tracker.focused = True
+ first.append(router.build_menu_actions())
+
+ assert first == [False, True]
+ assert tracker.builds == 1
diff --git a/tests/unit/sampletones_application/coordinators/playback/test_router.py b/tests/unit/sampletones_application/coordinators/playback/test_router.py
index 87368ce8..b4b72a0e 100644
--- a/tests/unit/sampletones_application/coordinators/playback/test_router.py
+++ b/tests/unit/sampletones_application/coordinators/playback/test_router.py
@@ -4,6 +4,8 @@
import pytest
from sampletones_application.coordinators.playback.router import PlaybackRouter
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
from tests.suite.language import FakeLanguageManager
IDLE = "idle"
@@ -159,120 +161,135 @@ def test_stop_silences_a_preview_when_no_source_is_engaged(self) -> None:
assert device.stop_calls == 1
-@dataclass(frozen=True)
-class StateCase:
- label: str
- active: Optional[str]
- background: Optional[str]
- preview: bool
- play_enabled: bool
- play_from_start_enabled: bool
- pause_enabled: bool
- paused: bool
- stop_enabled: bool
- play_label: str = field(default=PLAY_LABEL_KEY)
-
-
-STATE_CASES = [
- StateCase(
- "silent",
- active=None,
- background=None,
- preview=False,
- play_enabled=False,
- play_from_start_enabled=False,
- pause_enabled=False,
- paused=False,
- stop_enabled=False,
- ),
- StateCase(
- "preview_only",
- active=None,
- background=None,
- preview=True,
- play_enabled=False,
- play_from_start_enabled=False,
- pause_enabled=False,
- paused=False,
- stop_enabled=True,
- ),
- StateCase(
- "active_idle",
- active=IDLE,
- background=None,
- preview=False,
- play_enabled=True,
- play_from_start_enabled=True,
- pause_enabled=False,
- paused=False,
- stop_enabled=False,
- ),
- StateCase(
- "active_playing",
- active=PLAYING,
- background=None,
- preview=False,
- play_enabled=True,
- play_from_start_enabled=True,
- pause_enabled=True,
- paused=False,
- stop_enabled=True,
- play_label=PAUSE_LABEL_KEY,
- ),
- StateCase(
- "active_paused",
- active=PAUSED,
- background=None,
- preview=False,
- play_enabled=True,
- play_from_start_enabled=True,
- pause_enabled=True,
- paused=True,
- stop_enabled=True,
- play_label=RESUME_LABEL_KEY,
- ),
- StateCase(
- "background_playing_on_sourceless_tab",
- active=None,
- background=PLAYING,
- preview=False,
- play_enabled=True,
- play_from_start_enabled=False,
- pause_enabled=True,
- paused=False,
- stop_enabled=True,
- play_label=PAUSE_LABEL_KEY,
- ),
- StateCase(
- "background_paused_on_sourceless_tab",
- active=None,
- background=PAUSED,
- preview=False,
- play_enabled=True,
- play_from_start_enabled=False,
- pause_enabled=True,
- paused=True,
- stop_enabled=True,
- play_label=RESUME_LABEL_KEY,
- ),
- StateCase(
- "active_idle_over_background_playing",
- active=IDLE,
- background=PLAYING,
- preview=False,
- play_enabled=True,
- play_from_start_enabled=True,
- pause_enabled=False,
- paused=False,
- stop_enabled=True,
- ),
-]
-
-
-class TestTransportState:
- """The toolbar/menu state describes the target, and Stop follows any audible output."""
-
- @pytest.mark.parametrize("case", STATE_CASES, ids=lambda case: case.label)
+class TestTransportShutdown:
+ def test_shutdown_stops_every_source_not_only_the_engaged_one(self) -> None:
+ engaged = FakeSource(loaded=True, state=PLAYING)
+ idle = FakeSource(loaded=True, state=IDLE)
+ router = _router(active=None, sources=[engaged, idle], device=FakeDevice())
+
+ router.shutdown()
+
+ assert engaged.calls == ["stop"]
+ assert idle.calls == ["stop"]
+
+ def test_shutdown_stops_the_device(self) -> None:
+ device = FakeDevice(playing=True)
+ router = _router(active=None, sources=[FakeSource(loaded=True)], device=device)
+
+ router.shutdown()
+
+ assert device.stop_calls == 1
+
+
+class TestTransportState(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class StateCase(BaseRegularTestCase):
+ active: Optional[str]
+ background: Optional[str]
+ preview: bool
+ play_enabled: bool
+ play_from_start_enabled: bool
+ pause_enabled: bool
+ paused: bool
+ stop_enabled: bool
+ play_label: str = field(default=PLAY_LABEL_KEY)
+
+ test_cases = (
+ StateCase(
+ label="silent",
+ active=None,
+ background=None,
+ preview=False,
+ play_enabled=False,
+ play_from_start_enabled=False,
+ pause_enabled=False,
+ paused=False,
+ stop_enabled=False,
+ ),
+ StateCase(
+ label="preview_only",
+ active=None,
+ background=None,
+ preview=True,
+ play_enabled=False,
+ play_from_start_enabled=False,
+ pause_enabled=False,
+ paused=False,
+ stop_enabled=True,
+ ),
+ StateCase(
+ label="active_idle",
+ active=IDLE,
+ background=None,
+ preview=False,
+ play_enabled=True,
+ play_from_start_enabled=True,
+ pause_enabled=False,
+ paused=False,
+ stop_enabled=False,
+ ),
+ StateCase(
+ label="active_playing",
+ active=PLAYING,
+ background=None,
+ preview=False,
+ play_enabled=True,
+ play_from_start_enabled=True,
+ pause_enabled=True,
+ paused=False,
+ stop_enabled=True,
+ play_label=PAUSE_LABEL_KEY,
+ ),
+ StateCase(
+ label="active_paused",
+ active=PAUSED,
+ background=None,
+ preview=False,
+ play_enabled=True,
+ play_from_start_enabled=True,
+ pause_enabled=True,
+ paused=True,
+ stop_enabled=True,
+ play_label=RESUME_LABEL_KEY,
+ ),
+ StateCase(
+ label="background_playing_on_sourceless_tab",
+ active=None,
+ background=PLAYING,
+ preview=False,
+ play_enabled=True,
+ play_from_start_enabled=False,
+ pause_enabled=True,
+ paused=False,
+ stop_enabled=True,
+ play_label=PAUSE_LABEL_KEY,
+ ),
+ StateCase(
+ label="background_paused_on_sourceless_tab",
+ active=None,
+ background=PAUSED,
+ preview=False,
+ play_enabled=True,
+ play_from_start_enabled=False,
+ pause_enabled=True,
+ paused=True,
+ stop_enabled=True,
+ play_label=RESUME_LABEL_KEY,
+ ),
+ StateCase(
+ label="active_idle_over_background_playing",
+ active=IDLE,
+ background=PLAYING,
+ preview=False,
+ play_enabled=True,
+ play_from_start_enabled=True,
+ pause_enabled=False,
+ paused=False,
+ stop_enabled=True,
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
def test_state(self, case: StateCase) -> None:
active = FakeSource(loaded=True, state=case.active) if case.active is not None else None
background = FakeSource(loaded=True, state=case.background) if case.background is not None else None
diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py
index f15913f1..df7e31a3 100644
--- a/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py
+++ b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py
@@ -4,7 +4,9 @@
import pytest
-from sampletones_application.coordinators.tabs.instructions import InstructionsTabCoordinator
+from sampletones_application.coordinators.tabs.instructions import (
+ InstructionsTabCoordinator,
+)
from sampletones_shared.exceptions import LibraryDisplayError
from tests.suite.language import FakeLanguageManager
@@ -47,7 +49,10 @@ def test_missing_library_generates_immediately(self) -> None:
coordinator._library_logic.request_generation.assert_called_once_with()
-def _generation_coordinator(*, converter_visible: bool) -> InstructionsTabCoordinator:
+def _generation_coordinator(
+ *,
+ converter_visible: bool,
+) -> InstructionsTabCoordinator:
"""A coordinator with only the state the generation-completed notice touches, bypassing the
heavy constructor."""
coordinator = InstructionsTabCoordinator.__new__(InstructionsTabCoordinator)
@@ -77,7 +82,10 @@ def test_conversion_driven_generation_stays_silent(self) -> None:
coordinator._dialogs.show_info.assert_not_called()
-def _remove_library_coordinator(*, current_library_key: Any) -> 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
@@ -226,7 +234,11 @@ class TestRenderInstructionClassification:
@pytest.mark.parametrize(
"error",
- [KeyError("generator"), IndexError("empty histogram"), ValueError("degenerate data")],
+ [
+ KeyError("generator"),
+ IndexError("empty histogram"),
+ ValueError("degenerate data"),
+ ],
ids=["key", "index", "value"],
)
def test_data_shape_failure_raises_library_display_error(self, error: Exception) -> None:
diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py
index c0f660a1..b2331840 100644
--- a/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py
+++ b/tests/unit/sampletones_application/coordinators/tabs/test_reconstruction.py
@@ -53,13 +53,17 @@ def coordinator() -> ReconstructionTabCoordinator:
class TestLoadReconstructionSurfacesConcreteErrors:
"""Each concrete load failure reaches the user through a populated error dialog, so a bad
- reconstruction file is reported rather than swallowed. The browser unlocks in every case."""
+ reconstruction file is reported rather than swallowed. The browser unlocks in every case.
+ """
@pytest.mark.parametrize(
"error, expected_message",
[
(InvalidMetadataError("bad metadata"), INVALID_METADATA_KEY),
- (InvalidReconstructionValuesError("bad values", ValueError("v")), INVALID_VALUES_KEY),
+ (
+ InvalidReconstructionValuesError("bad values", ValueError("v")),
+ INVALID_VALUES_KEY,
+ ),
(InvalidReconstructionError("bad file"), INVALID_FILE_KEY),
(DeserializationError("bad bytes"), DESERIALIZATION_ERROR_KEY),
(LoadReconstructionError("unclassified"), LOAD_ERROR_KEY),
@@ -76,7 +80,10 @@ def test_concrete_error_shows_populated_dialog(
coordinator.load_reconstruction(Path("sample.stn"))
- coordinator._dialogs.show_error.assert_called_once_with(error, expected_message)
+ coordinator._dialogs.show_error.assert_called_once_with(
+ error,
+ expected_message,
+ )
coordinator._browser_panel.unlock.assert_called_once_with()
def test_missing_file_shows_file_not_found_dialog(
@@ -89,7 +96,10 @@ def test_missing_file_shows_file_not_found_dialog(
coordinator.load_reconstruction(path)
- coordinator._dialogs.show_file_not_found.assert_called_once_with(path, FILE_NOT_FOUND_KEY)
+ coordinator._dialogs.show_file_not_found.assert_called_once_with(
+ path,
+ FILE_NOT_FOUND_KEY,
+ )
coordinator._browser_panel.unlock.assert_called_once_with()
def test_incompatible_version_dialog_reports_both_versions(
@@ -124,7 +134,10 @@ def test_unclassified_load_error_shows_the_generic_dialog(
coordinator.load_reconstruction(Path("sample.stn"))
- coordinator._dialogs.show_error.assert_called_once_with(error, LOAD_ERROR_KEY)
+ coordinator._dialogs.show_error.assert_called_once_with(
+ error,
+ LOAD_ERROR_KEY,
+ )
coordinator._browser_panel.unlock.assert_called_once_with()
def test_unexpected_error_propagates_and_unlocks(
@@ -291,7 +304,11 @@ def test_a_shortened_reconstruction_export_counts_the_instruments(
kind=ExportKind.SAMPLE,
filepath=Path("instruments"),
tracker_format=TrackerFormat.FAMITRACKER,
- truncation=EnvelopeTruncation(frames=252, source_frames=410, instruments=3),
+ truncation=EnvelopeTruncation(
+ frames=252,
+ source_frames=410,
+ instruments=3,
+ ),
)
)
@@ -304,7 +321,12 @@ def test_a_wav_export_shows_its_own_message(
export_coordinator: ReconstructionTabCoordinator,
) -> None:
export_coordinator._on_export_result(
- ExportSuccess(kind=ExportKind.WAV, filepath=Path("track.wav"), tracker_format=None, truncation=None)
+ ExportSuccess(
+ kind=ExportKind.WAV,
+ filepath=Path("track.wav"),
+ tracker_format=None,
+ truncation=None,
+ )
)
assert _shown_message(export_coordinator) == export_coordinator._export_messages.wav_success
diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py
index 3f67fa64..b0e7d778 100644
--- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py
+++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py
@@ -1,27 +1,60 @@
-from datetime import datetime
+from datetime import UTC, datetime
from pathlib import Path
-from typing import Dict, Final
+from typing import Dict, Final, List
from unittest.mock import MagicMock
import pytest
from sampletones_application.categories.hierarchy import Tab
from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.constants.playback import FollowMode
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
from sampletones_application.coordinators.playback.guard import GuardedPlayer
from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator
from sampletones_application.logic.history.action import HistoryAction
from sampletones_application.logic.history.manager import HistoryManager
-from sampletones_application.logic.history.snapshot import HistoryEntry, snapshot_project
+from sampletones_application.logic.history.snapshot import HistoryEntry
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.project.manager import ProjectManager
-from sampletones_application.logic.sequencer.channels import ALL_CHANNELS, SequencerChannelsLogic
+from sampletones_application.logic.sequencer.channels import (
+ ALL_CHANNELS,
+ SequencerChannelsLogic,
+)
+from sampletones_application.logic.sequencer.clipboard import (
+ OrderBlockText,
+ ParsedBlockCache,
+ ProjectSampleDirectory,
+ SequencerClipboard,
+ TrackerBlockText,
+)
+from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail
+from sampletones_application.logic.sequencer.order import (
+ OrderBlockReader,
+ OrderBlockWriter,
+ SequencerOrderLogic,
+)
+from sampletones_application.logic.sequencer.tracker import (
+ SequencerTrackerLogic,
+ TrackerBlockReader,
+ TrackerBlockWriter,
+)
+from sampletones_application.logic.shared.project_source import snapshot_project
from sampletones_application.paths import LANG_EN
from sampletones_application.ui.panels.sequencer import channels as channels_module
-from sampletones_application.ui.panels.sequencer import grid as grid_module
-from sampletones_application.ui.panels.sequencer.grid import GUISequencerGridPanel
+from sampletones_application.ui.panels.sequencer import tracker as tracker_module
from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard.modifiers import CTRL, NO_MODIFIERS
+from sampletones_application.view_model.sequencer.region import (
+ OrderCell,
+ OrderRegion,
+ TrackerCell,
+ TrackerRegion,
+)
from sampletones_application.view_model.sequencer.samples import SampleSelection
+from sampletones_application.view_model.sequencer.slot import TrackerSlot
+from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
from sampletones_application.view_model.shared.history import (
HistoryDetailRole,
HistoryDetailSegment,
@@ -29,6 +62,7 @@
HistoryDetailWordSegment,
)
from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.project.song_position import SongPosition
from sampletones_shared.exceptions import InvalidReconstructionValuesError
from tests.suite.language import FakeLanguageManager
@@ -57,8 +91,8 @@ def coordinator() -> SequencerTabCoordinator:
instance._project_controller.has_samples = True
instance._sequencer_browser_logic = MagicMock()
instance._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 60
- instance._sequencer_grid_logic = MagicMock()
- instance._sequencer_grid_logic.settings.nes_frequency = 60
+ instance._sequencer_tracker_logic = MagicMock()
+ instance._sequencer_tracker_logic.settings.nes_frequency = 60
instance._dialogs = MagicMock()
instance._on_tab_switch = MagicMock()
instance._language_manager = FakeLanguageManager(TEXTS)
@@ -135,8 +169,8 @@ def nes_frequency_coordinator() -> SequencerTabCoordinator:
instance = object.__new__(SequencerTabCoordinator)
instance._history = MagicMock()
instance._history_detail = MagicMock()
- instance._sequencer_grid_logic = MagicMock()
- instance._sequencer_grid_logic.settings.nes_frequency = 60
+ instance._sequencer_tracker_logic = MagicMock()
+ instance._sequencer_tracker_logic.settings.nes_frequency = 60
instance._project_controller = MagicMock()
instance._project_controller.has_samples = True
instance._dialogs = MagicMock()
@@ -153,7 +187,7 @@ def test_unchanged_value_does_nothing(
) -> None:
nes_frequency_coordinator._request_nes_frequency_change(60)
- nes_frequency_coordinator._sequencer_grid_logic.set_nes_frequency.assert_not_called()
+ nes_frequency_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called()
nes_frequency_coordinator._dialogs.show_confirmation.assert_not_called()
def test_applies_without_confirmation_when_no_samples(
@@ -164,7 +198,7 @@ def test_applies_without_confirmation_when_no_samples(
nes_frequency_coordinator._request_nes_frequency_change(30)
- nes_frequency_coordinator._sequencer_grid_logic.set_nes_frequency.assert_called_once_with(30)
+ nes_frequency_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_called_once_with(30)
nes_frequency_coordinator._dialogs.show_confirmation.assert_not_called()
def test_applies_without_confirmation_once_acknowledged(
@@ -175,7 +209,7 @@ def test_applies_without_confirmation_once_acknowledged(
nes_frequency_coordinator._request_nes_frequency_change(30)
- nes_frequency_coordinator._sequencer_grid_logic.set_nes_frequency.assert_called_once_with(30)
+ nes_frequency_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_called_once_with(30)
nes_frequency_coordinator._dialogs.show_confirmation.assert_not_called()
def test_prompts_before_applying_when_samples_exist(
@@ -185,11 +219,11 @@ def test_prompts_before_applying_when_samples_exist(
nes_frequency_coordinator._request_nes_frequency_change(30)
nes_frequency_coordinator._dialogs.show_confirmation.assert_called_once()
- nes_frequency_coordinator._sequencer_grid_logic.set_nes_frequency.assert_not_called()
+ nes_frequency_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called()
confirmation = nes_frequency_coordinator._dialogs.show_confirmation.call_args.kwargs
confirmation["on_confirm"]()
- nes_frequency_coordinator._sequencer_grid_logic.set_nes_frequency.assert_called_once_with(30)
+ nes_frequency_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_called_once_with(30)
def test_applying_requests_a_retune_of_the_samples(
self,
@@ -222,7 +256,28 @@ def test_cancel_restores_the_field(
confirmation = nes_frequency_coordinator._dialogs.show_confirmation.call_args.kwargs
confirmation["on_cancel"]()
- nes_frequency_coordinator._sequencer_grid_logic.push_settings.assert_called_once()
+ nes_frequency_coordinator._sequencer_tracker_logic.push_settings.assert_called_once()
+
+
+SOUNDING_ROW: Final[int] = 7
+
+
+def _playhead(frame_index: int, row_index: int) -> SongPosition:
+ """The playhead standing on a row of an order frame."""
+ return SongPosition(order_position=frame_index, row_index=row_index)
+
+
+def _player_view(*, follow_mode: FollowMode) -> SongPlayerViewModel:
+ """A stopped transport view, which is what the coordinator reads the follow behaviour from."""
+ return SongPlayerViewModel(
+ is_loaded=True,
+ is_playing=False,
+ is_paused=False,
+ follow_mode=follow_mode,
+ order_position=0,
+ row_index=0,
+ error=None,
+ )
@pytest.fixture
@@ -230,78 +285,88 @@ def playback_coordinator() -> SequencerTabCoordinator:
"""A coordinator with only the collaborators the follow-playback handlers touch."""
instance = object.__new__(SequencerTabCoordinator)
instance._song_player_logic = MagicMock()
- instance._sequencer_grid_logic = MagicMock()
- instance._sequencer_grid_panel = MagicMock()
+ instance._sequencer_tracker_logic = MagicMock()
+ instance._sequencer_tracker_panel = MagicMock()
instance._sequencer_order_panel = MagicMock()
+ instance._playing_position = None
return instance
-class TestFollowPlayback:
- def test_position_change_follows_playhead_when_enabled(
+class TestFollowMode:
+ @pytest.mark.parametrize("mode", list(FollowMode), ids=str)
+ def test_the_sounding_frame_is_shown_while_the_mode_follows_patterns(
self,
playback_coordinator: SequencerTabCoordinator,
+ mode: FollowMode,
) -> None:
- playback_coordinator._song_player_logic.follow_playback = True
+ """The marks move on every mode; only a following mode moves the frame that is edited."""
+ playback_coordinator._song_player_logic.follow_mode = mode
playback_coordinator._on_player_position_changed(2, 5)
- playback_coordinator._sequencer_grid_panel.set_playing_row.assert_called_once_with(5)
+ panel = playback_coordinator._sequencer_tracker_panel
+ panel.set_playing_position.assert_called_once_with(_playhead(2, 5))
playback_coordinator._sequencer_order_panel.set_playing_position.assert_called_once_with(2)
- playback_coordinator._sequencer_grid_logic.select_frame.assert_called_once_with(2)
+ assert playback_coordinator._sequencer_tracker_logic.select_frame.called is mode.follows_pattern
- def test_position_change_does_not_move_edited_frame_when_disabled(
+ def test_the_frame_is_selected_before_the_row_is_marked(
self,
playback_coordinator: SequencerTabCoordinator,
) -> None:
- playback_coordinator._song_player_logic.follow_playback = False
+ """The mark and the scroll that reveals it land on the pattern the playhead has reached."""
+ playback_coordinator._song_player_logic.follow_mode = FollowMode.ROWS
+ recorder = MagicMock()
+ recorder.attach_mock(playback_coordinator._sequencer_tracker_logic, "logic")
+ recorder.attach_mock(playback_coordinator._sequencer_tracker_panel, "panel")
playback_coordinator._on_player_position_changed(2, 5)
- playback_coordinator._sequencer_grid_panel.set_playing_row.assert_called_once_with(5)
- playback_coordinator._sequencer_order_panel.set_playing_position.assert_called_once_with(2)
- playback_coordinator._sequencer_grid_logic.select_frame.assert_not_called()
+ names = [name for name, _, _ in recorder.mock_calls]
+ assert names.index("logic.select_frame") < names.index("panel.set_playing_position")
- def test_order_selection_seeks_playhead_when_following(
+ @pytest.mark.parametrize("mode", list(FollowMode), ids=str)
+ def test_the_view_states_whether_the_grid_follows_the_row(
self,
playback_coordinator: SequencerTabCoordinator,
+ mode: FollowMode,
) -> None:
- playback_coordinator._song_player_logic.follow_playback = True
-
- playback_coordinator._on_order_frame_selected(3)
+ playback_coordinator._on_player_view_changed(_player_view(follow_mode=mode))
- playback_coordinator._sequencer_grid_logic.select_frame.assert_called_once_with(3)
- playback_coordinator._song_player_logic.seek.assert_called_once_with(3)
+ panel = playback_coordinator._sequencer_tracker_panel
+ panel.set_row_following.assert_called_once_with(mode.follows_row)
- def test_order_selection_only_edits_when_not_following(
+ def test_a_stopped_view_drops_the_marks(
self,
playback_coordinator: SequencerTabCoordinator,
) -> None:
- playback_coordinator._song_player_logic.follow_playback = False
-
- playback_coordinator._on_order_frame_selected(3)
-
- playback_coordinator._sequencer_grid_logic.select_frame.assert_called_once_with(3)
- playback_coordinator._song_player_logic.seek.assert_not_called()
+ playback_coordinator._on_player_view_changed(_player_view(follow_mode=FollowMode.ROWS))
+ playback_coordinator._sequencer_tracker_panel.set_playing_position.assert_called_once_with(None)
+ playback_coordinator._sequencer_order_panel.set_playing_position.assert_called_once_with(None)
-class TestNoteOffDispatch:
- def test_channel_cell_writes_note_off_to_that_channel(
+ @pytest.mark.parametrize("mode", list(FollowMode), ids=str)
+ def test_order_selection_seeks_the_playhead_while_following(
self,
playback_coordinator: SequencerTabCoordinator,
+ mode: FollowMode,
) -> None:
- playback_coordinator._on_set_note_off(2, GeneratorName.PULSE1)
+ """Choosing a frame always picks what is edited, and moves the playhead when following."""
+ playback_coordinator._song_player_logic.follow_mode = mode
- playback_coordinator._sequencer_grid_logic.set_note_off.assert_called_once_with(GeneratorName.PULSE1, 2)
- playback_coordinator._sequencer_grid_logic.set_note_off_all_generators.assert_not_called()
+ playback_coordinator._on_order_frame_selected(3)
+
+ playback_coordinator._sequencer_tracker_logic.select_frame.assert_called_once_with(3)
+ assert playback_coordinator._song_player_logic.seek.called is mode.follows_pattern
- def test_sample_column_cuts_every_channel(
+ @pytest.mark.parametrize("mode", list(FollowMode), ids=str)
+ def test_a_chosen_mode_reaches_the_player(
self,
playback_coordinator: SequencerTabCoordinator,
+ mode: FollowMode,
) -> None:
- playback_coordinator._on_set_note_off(2, None)
+ playback_coordinator.set_follow_mode(mode)
- playback_coordinator._sequencer_grid_logic.set_note_off_all_generators.assert_called_once_with(2)
- playback_coordinator._sequencer_grid_logic.set_note_off.assert_not_called()
+ playback_coordinator._song_player_logic.set_follow_mode.assert_called_once_with(mode)
@pytest.fixture
@@ -309,11 +374,12 @@ def order_ops_coordinator() -> SequencerTabCoordinator:
"""A coordinator with only the collaborators the order-frame handlers touch."""
instance = object.__new__(SequencerTabCoordinator)
instance._sequencer_order_logic = MagicMock()
- instance._sequencer_grid_logic = MagicMock()
+ instance._sequencer_tracker_logic = MagicMock()
instance._sequencer_order_panel = MagicMock()
+ instance._sequencer_tracker_panel = MagicMock()
instance._song_player_logic = MagicMock()
instance._project_controller = MagicMock()
- instance._playing_order = None
+ instance._playing_position = None
return instance
@@ -334,7 +400,7 @@ def test_remove_pulls_playhead_earlier_when_playing(
order_ops_coordinator: SequencerTabCoordinator,
) -> None:
coordinator = order_ops_coordinator
- coordinator._playing_order = 3
+ coordinator._playing_position = _playhead(3, SOUNDING_ROW)
coordinator._project_controller.order_length = 5
coordinator._on_order_remove(1)
@@ -347,7 +413,7 @@ def test_remove_does_not_relocate_when_not_playing(
order_ops_coordinator: SequencerTabCoordinator,
) -> None:
coordinator = order_ops_coordinator
- coordinator._playing_order = None
+ coordinator._playing_position = None
coordinator._project_controller.order_length = 5
coordinator._on_order_remove(1)
@@ -359,7 +425,7 @@ def test_duplicate_before_playhead_shifts_it_later(
order_ops_coordinator: SequencerTabCoordinator,
) -> None:
coordinator = order_ops_coordinator
- coordinator._playing_order = 2
+ coordinator._playing_position = _playhead(2, SOUNDING_ROW)
coordinator._song_player_logic.is_playing.return_value = True
coordinator._on_order_duplicate(0)
@@ -372,7 +438,7 @@ def test_move_makes_the_playing_frame_follow_itself(
order_ops_coordinator: SequencerTabCoordinator,
) -> None:
coordinator = order_ops_coordinator
- coordinator._playing_order = 2
+ coordinator._playing_position = _playhead(2, SOUNDING_ROW)
coordinator._song_player_logic.is_playing.return_value = True
coordinator._on_order_move(2, 5)
@@ -387,20 +453,34 @@ def test_move_advances_cursor_and_highlight_immediately(
# The cursor and playing highlight must advance on the keypress, not on the next row
# update, so a rapid second Alt+arrow acts on the moved frame rather than snapping back.
coordinator = order_ops_coordinator
- coordinator._playing_order = 2
+ coordinator._playing_position = _playhead(2, SOUNDING_ROW)
coordinator._song_player_logic.is_playing.return_value = True
coordinator._on_order_move(2, 3)
- coordinator._sequencer_grid_logic.select_frame.assert_called_once_with(3)
+ coordinator._sequencer_tracker_logic.select_frame.assert_called_once_with(3)
coordinator._sequencer_order_panel.set_playing_position.assert_called_once_with(3)
+ def test_move_carries_the_sounding_row_to_the_frame_it_lands_on(
+ self,
+ order_ops_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ """The tracker's mark belongs to a frame, so an edit that moves the frame moves the mark."""
+ coordinator = order_ops_coordinator
+ coordinator._playing_position = _playhead(2, SOUNDING_ROW)
+ coordinator._song_player_logic.is_playing.return_value = True
+
+ coordinator._on_order_move(2, 5)
+
+ panel = coordinator._sequencer_tracker_panel
+ panel.set_playing_position.assert_called_once_with(_playhead(5, SOUNDING_ROW))
+
def test_clear_leaves_the_playhead_in_place(
self,
order_ops_coordinator: SequencerTabCoordinator,
) -> None:
coordinator = order_ops_coordinator
- coordinator._playing_order = 2
+ coordinator._playing_position = _playhead(2, SOUNDING_ROW)
coordinator._on_order_clear(2)
@@ -481,13 +561,13 @@ def test_matching_frequency_adds_without_prompt_or_adopt(
self,
coordinator: SequencerTabCoordinator,
) -> None:
- coordinator._sequencer_grid_logic.settings.nes_frequency = 60
+ coordinator._sequencer_tracker_logic.settings.nes_frequency = 60
coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 60
coordinator.import_reconstruction(Path("reconstruction.stn"))
coordinator._dialogs.show_confirmation.assert_not_called()
- coordinator._sequencer_grid_logic.set_nes_frequency.assert_not_called()
+ coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called()
coordinator._sequencer_browser_logic.add_reconstruction.assert_called_once()
def test_empty_project_adopts_reconstruction_frequency_silently(
@@ -495,12 +575,12 @@ def test_empty_project_adopts_reconstruction_frequency_silently(
coordinator: SequencerTabCoordinator,
) -> None:
coordinator._project_controller.has_samples = False
- coordinator._sequencer_grid_logic.settings.nes_frequency = 60
+ coordinator._sequencer_tracker_logic.settings.nes_frequency = 60
coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50
coordinator.import_reconstruction(Path("reconstruction.stn"))
- coordinator._sequencer_grid_logic.set_nes_frequency.assert_called_once_with(50)
+ coordinator._sequencer_tracker_logic.set_nes_frequency.assert_called_once_with(50)
coordinator._sequencer_browser_logic.add_reconstruction.assert_called_once()
coordinator._dialogs.show_confirmation.assert_not_called()
coordinator._on_tab_switch.assert_called_once_with(Tab.SEQUENCER)
@@ -510,13 +590,13 @@ def test_mismatch_with_samples_confirms_before_adding(
coordinator: SequencerTabCoordinator,
) -> None:
coordinator._project_controller.has_samples = True
- coordinator._sequencer_grid_logic.settings.nes_frequency = 60
+ coordinator._sequencer_tracker_logic.settings.nes_frequency = 60
coordinator._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 50
coordinator.import_reconstruction(Path("reconstruction.stn"))
coordinator._dialogs.show_confirmation.assert_called_once()
- coordinator._sequencer_grid_logic.set_nes_frequency.assert_not_called()
+ coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called()
coordinator._sequencer_browser_logic.add_reconstruction.assert_not_called()
coordinator._on_tab_switch.assert_not_called()
@@ -543,8 +623,8 @@ def replace_coordinator() -> SequencerTabCoordinator:
instance._project_controller.sample_count = 2
instance._sequencer_browser_logic = MagicMock()
instance._sequencer_browser_logic.load_reconstruction.return_value.config.nes_frequency = 60
- instance._sequencer_grid_logic = MagicMock()
- instance._sequencer_grid_logic.settings.nes_frequency = 60
+ instance._sequencer_tracker_logic = MagicMock()
+ instance._sequencer_tracker_logic.settings.nes_frequency = 60
instance._sequencer_samples_logic = MagicMock()
instance._sequencer_samples_panel = MagicMock()
instance._sequencer_samples_panel.selection = SampleSelection(
@@ -603,7 +683,7 @@ def test_selected_sample_is_renamed_and_substituted(
reconstruction,
)
replace_coordinator._dialogs.show_confirmation.assert_not_called()
- replace_coordinator._sequencer_grid_logic.set_nes_frequency.assert_not_called()
+ replace_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called()
def test_rename_and_substitution_share_one_history_entry(
self,
@@ -623,7 +703,10 @@ def test_detail_reads_the_sample_before_it_is_substituted(
"""The detail names the outgoing reconstruction, which the sample only holds until the swap."""
order = MagicMock()
order.attach_mock(replace_coordinator._history_detail.replace_sample, "detail")
- order.attach_mock(replace_coordinator._sequencer_browser_logic.replace_reconstruction, "replace")
+ order.attach_mock(
+ replace_coordinator._sequencer_browser_logic.replace_reconstruction,
+ "replace",
+ )
replace_coordinator.replace_reconstruction(Path("kick_02.stn"))
@@ -638,7 +721,10 @@ def test_replacement_is_announced_before_the_substitution(
reconstruction = replace_coordinator._sequencer_browser_logic.load_reconstruction.return_value
order = MagicMock()
order.attach_mock(replace_coordinator._on_sample_reconstruction_replaced, "announce")
- order.attach_mock(replace_coordinator._sequencer_browser_logic.replace_reconstruction, "replace")
+ order.attach_mock(
+ replace_coordinator._sequencer_browser_logic.replace_reconstruction,
+ "replace",
+ )
replace_coordinator.replace_reconstruction(Path("kick_02.stn"))
@@ -657,7 +743,7 @@ def test_sole_sample_adopts_the_reconstruction_frequency_silently(
replace_coordinator.replace_reconstruction(Path("kick_02.stn"))
- replace_coordinator._sequencer_grid_logic.set_nes_frequency.assert_called_once_with(50)
+ replace_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_called_once_with(50)
replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_called_once()
replace_coordinator._dialogs.show_confirmation.assert_not_called()
@@ -678,7 +764,7 @@ def test_mismatch_beside_other_samples_confirms_before_replacing(
confirmation["on_confirm"]()
replace_coordinator._sequencer_browser_logic.replace_reconstruction.assert_called_once()
- replace_coordinator._sequencer_grid_logic.set_nes_frequency.assert_not_called()
+ replace_coordinator._sequencer_tracker_logic.set_nes_frequency.assert_not_called()
class TestReplaceTargetLabel:
@@ -699,14 +785,22 @@ def test_label_is_absent_without_a_selection(
@pytest.fixture
def history_coordinator() -> SequencerTabCoordinator:
- """A coordinator with only the history collaborator wired."""
+ """A coordinator with the two collaborators an undoable gesture reaches.
+
+ The history is a mock, so a test reads the transaction a gesture opens; the
+ controller is real, so a test reads the notifications the gesture's mutations
+ actually produce.
+ """
instance = object.__new__(SequencerTabCoordinator)
instance._history = MagicMock()
+ instance._project_controller = ProjectController(ProjectManager())
return instance
@pytest.fixture
-def wired_history_coordinator(monkeypatch: pytest.MonkeyPatch) -> SequencerTabCoordinator:
+def wired_history_coordinator(
+ monkeypatch: pytest.MonkeyPatch,
+) -> SequencerTabCoordinator:
"""A coordinator whose history wiring matches production.
A real manager observes a real controller, and every project replacement —
@@ -739,7 +833,10 @@ def test_project_replacement_reseeds_history(
with coordinator._history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(150)
- controller.replace_project(snapshot_project(controller.project), clean=False)
+ controller.replace_project(
+ snapshot_project(controller.project),
+ clean=False,
+ )
assert len(coordinator._history.entries) == 1
assert coordinator._history.entries[0].action is HistoryAction.INITIAL
@@ -848,17 +945,17 @@ def channels_coordinator(monkeypatch: pytest.MonkeyPatch) -> SequencerTabCoordin
wiring is read for. The menu bar above the tab is a recorder, so a test can read whether it
was told. Modifiers are reported as held nowhere; a test that needs Ctrl says so.
"""
- monkeypatch.setattr(grid_module.dpg, "does_item_exist", lambda item: False)
- monkeypatch.setattr(grid_module.dpg, "set_value", lambda item, value: None)
+ monkeypatch.setattr(tracker_module.dpg, "does_item_exist", lambda item: False)
+ monkeypatch.setattr(tracker_module.dpg, "set_value", lambda item, value: None)
monkeypatch.setattr(channels_module, "capture_modifiers", lambda: NO_MODIFIERS)
language_manager = LanguageManager(LANG_EN)
instance = object.__new__(SequencerTabCoordinator)
instance._on_channels_changed = MagicMock()
instance._sequencer_channels_logic = SequencerChannelsLogic()
- instance._sequencer_grid_panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel)
- instance._sequencer_grid_panel._current_channels = None
- instance._sequencer_grid_panel._create_channel_switch(language_manager)
+ instance._sequencer_tracker_panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ instance._sequencer_tracker_panel._current_channels = None
+ instance._sequencer_tracker_panel._create_channel_switch(language_manager)
instance._sequencer_order_panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
instance._sequencer_order_panel._current_channels = None
instance._sequencer_order_panel._create_channel_switch(language_manager)
@@ -873,7 +970,7 @@ def test_header_click_silences_that_channel(
self,
channels_coordinator: SequencerTabCoordinator,
) -> None:
- panel = channels_coordinator._sequencer_grid_panel
+ panel = channels_coordinator._sequencer_tracker_panel
panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
@@ -884,7 +981,7 @@ def test_a_second_click_returns_the_channel_to_the_mix(
self,
channels_coordinator: SequencerTabCoordinator,
) -> None:
- panel = channels_coordinator._sequencer_grid_panel
+ panel = channels_coordinator._sequencer_tracker_panel
panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
@@ -898,7 +995,7 @@ def test_ctrl_header_click_solos_that_channel(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(channels_module, "capture_modifiers", lambda: CTRL)
- panel = channels_coordinator._sequencer_grid_panel
+ panel = channels_coordinator._sequencer_tracker_panel
panel._on_header_clicked(0, True, GeneratorName.PULSE2)
@@ -908,7 +1005,7 @@ def test_sample_header_click_silences_every_channel(
self,
channels_coordinator: SequencerTabCoordinator,
) -> None:
- panel = channels_coordinator._sequencer_grid_panel
+ panel = channels_coordinator._sequencer_tracker_panel
panel._on_header_clicked(0, True, None)
@@ -919,7 +1016,7 @@ def test_sample_header_click_restores_every_channel_from_full_silence(
self,
channels_coordinator: SequencerTabCoordinator,
) -> None:
- panel = channels_coordinator._sequencer_grid_panel
+ panel = channels_coordinator._sequencer_tracker_panel
panel._on_header_clicked(0, True, None)
panel._on_header_clicked(0, True, None)
@@ -931,7 +1028,7 @@ def test_the_menu_silences_every_channel_from_a_mixed_set(
self,
channels_coordinator: SequencerTabCoordinator,
) -> None:
- panel = channels_coordinator._sequencer_grid_panel
+ panel = channels_coordinator._sequencer_tracker_panel
panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
panel.call(panel.on_channels_muted)
@@ -943,7 +1040,7 @@ def test_the_menu_restores_every_channel_from_a_mixed_set(
self,
channels_coordinator: SequencerTabCoordinator,
) -> None:
- panel = channels_coordinator._sequencer_grid_panel
+ panel = channels_coordinator._sequencer_tracker_panel
panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
panel.call(panel.on_channels_unmuted)
@@ -992,10 +1089,10 @@ def test_a_tracker_click_reaches_the_order_table(
self,
channels_coordinator: SequencerTabCoordinator,
) -> None:
- grid_panel = channels_coordinator._sequencer_grid_panel
+ tracker_panel = channels_coordinator._sequencer_tracker_panel
order_panel = channels_coordinator._sequencer_order_panel
- grid_panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
+ tracker_panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
assert order_panel._is_muted(GeneratorName.TRIANGLE)
@@ -1003,12 +1100,12 @@ def test_an_order_click_reaches_the_tracker(
self,
channels_coordinator: SequencerTabCoordinator,
) -> None:
- grid_panel = channels_coordinator._sequencer_grid_panel
+ tracker_panel = channels_coordinator._sequencer_tracker_panel
order_panel = channels_coordinator._sequencer_order_panel
order_panel._on_label_clicked(0, True, GeneratorName.PULSE2)
- assert grid_panel._is_muted(GeneratorName.PULSE2)
+ assert tracker_panel._is_muted(GeneratorName.PULSE2)
def test_the_order_menu_silences_every_channel(
self,
@@ -1077,14 +1174,14 @@ def test_a_menu_toggle_shows_in_both_tables(
) -> None:
channels_coordinator.toggle_channel(GeneratorName.TRIANGLE)
- assert channels_coordinator._sequencer_grid_panel._is_muted(GeneratorName.TRIANGLE)
+ assert channels_coordinator._sequencer_tracker_panel._is_muted(GeneratorName.TRIANGLE)
assert channels_coordinator._sequencer_order_panel._is_muted(GeneratorName.TRIANGLE)
def test_a_table_click_tells_the_menu_bar(
self,
channels_coordinator: SequencerTabCoordinator,
) -> None:
- channels_coordinator._sequencer_grid_panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
+ channels_coordinator._sequencer_tracker_panel._on_header_clicked(0, True, GeneratorName.TRIANGLE)
channels_coordinator._on_channels_changed.assert_called_once_with()
@@ -1120,11 +1217,18 @@ def test_wrapped_call_runs_inside_a_transaction(self, history_coordinator: Seque
)
target.assert_called_once_with(150)
- def test_wrapped_call_passes_computed_detail(self, history_coordinator: SequencerTabCoordinator) -> None:
+ def test_wrapped_call_passes_computed_detail(
+ self,
+ history_coordinator: SequencerTabCoordinator,
+ ) -> None:
target = MagicMock()
segments = (HistoryDetailSegment(text="v150", role=HistoryDetailRole.VALUE),)
- wrapped = history_coordinator._undoable(HistoryAction.SET_TEMPO, target, detail=lambda _: segments)
+ wrapped = history_coordinator._undoable(
+ HistoryAction.SET_TEMPO,
+ target,
+ detail=lambda _: segments,
+ )
wrapped(150)
history_coordinator._history.transaction.assert_called_once_with(
@@ -1133,7 +1237,10 @@ def test_wrapped_call_passes_computed_detail(self, history_coordinator: Sequence
coalesce=None,
)
- def test_wrapped_call_passes_computed_coalesce_key(self, history_coordinator: SequencerTabCoordinator) -> None:
+ def test_wrapped_call_passes_computed_coalesce_key(
+ self,
+ history_coordinator: SequencerTabCoordinator,
+ ) -> None:
target = MagicMock()
wrapped = history_coordinator._undoable(
@@ -1149,6 +1256,25 @@ def test_wrapped_call_passes_computed_coalesce_key(self, history_coordinator: Se
coalesce=("tempo",),
)
+ def test_wrapped_call_announces_one_song_change_for_the_whole_gesture(
+ self,
+ history_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ controller = history_coordinator._project_controller
+ announcements: List[str] = []
+ controller.on_song_changed = lambda: announcements.append("song")
+ initial_length = controller.order_length
+
+ def append_frames(count: int) -> None:
+ for _ in range(count):
+ controller.append_frame()
+
+ wrapped = history_coordinator._undoable(HistoryAction.EDIT_ROW, append_frames)
+ wrapped(3)
+
+ assert controller.order_length == initial_length + 3
+ assert announcements == ["song"]
+
@pytest.fixture
def view_coordinator() -> SequencerTabCoordinator:
@@ -1164,7 +1290,7 @@ def _loop_entry(loop: bool) -> HistoryEntry:
return HistoryEntry(
project=MagicMock(),
action=HistoryAction.SET_SAMPLE_LOOP,
- created=datetime.now(),
+ created=datetime.now(tz=UTC),
detail=(
HistoryDetailSegment(text="00:", role=HistoryDetailRole.SAMPLE),
HistoryDetailWordSegment(word=word, role=HistoryDetailRole.VALUE),
@@ -1173,7 +1299,10 @@ def _loop_entry(loop: bool) -> HistoryEntry:
class TestHistoryViewModelBuild:
- def test_word_segments_resolve_to_language_text(self, view_coordinator: SequencerTabCoordinator) -> None:
+ def test_word_segments_resolve_to_language_text(
+ self,
+ view_coordinator: SequencerTabCoordinator,
+ ) -> None:
view_coordinator._history.cursor = 1
view_coordinator._history.entries = (_loop_entry(True), _loop_entry(False))
@@ -1203,5 +1332,365 @@ def exposure_coordinator() -> SequencerTabCoordinator:
class TestPlayerExposure:
- def test_player_returns_the_guarded_wrapper(self, exposure_coordinator: SequencerTabCoordinator) -> None:
+ def test_player_returns_the_guarded_wrapper(
+ self,
+ exposure_coordinator: SequencerTabCoordinator,
+ ) -> None:
assert isinstance(exposure_coordinator.player, GuardedPlayer)
+
+
+PULSE1_CELL: Final[TrackerRegion] = TrackerRegion(
+ first_row=0,
+ last_row=0,
+ first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT).flat_index,
+ last_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME).flat_index,
+)
+PULSE1_FRAME: Final[OrderRegion] = OrderRegion(
+ first_row=CHANNEL_AXIS.index(GeneratorName.PULSE1),
+ last_row=CHANNEL_AXIS.index(GeneratorName.PULSE1),
+ first_position=0,
+ last_position=0,
+)
+
+
+class FakeTextClipboard:
+ """The desktop's clipboard, held in memory so a test reads what a copy put there."""
+
+ def __init__(self) -> None:
+ self.text: str = ""
+
+ def read(self) -> str:
+ return self.text
+
+ def write(self, text: str) -> None:
+ self.text = text
+
+
+@pytest.fixture
+def block_coordinator() -> SequencerTabCoordinator:
+ """A coordinator whose block path is real, from the tracker logic through to the clipboard.
+
+ A real manager observes the same controller production wires it to, so a test reads the
+ entries a gesture actually records, and the hooks are the ones ``_wire_block_callbacks``
+ assigns rather than wrappers a test built to look like them. The system clipboard is the one
+ boundary standing in, since the desktop's own is reached through a running viewport.
+ """
+ instance = object.__new__(SequencerTabCoordinator)
+ controller = ProjectController(ProjectManager())
+ history = HistoryManager(controller, budget=10, strict=True)
+ controller.on_mutation = history.handle_mutation
+ controller.new()
+ history.reset()
+ instance._project_controller = controller
+ instance._history = history
+ instance._sequencer_tracker_logic = SequencerTrackerLogic(controller)
+ instance._clipboard = SequencerClipboard()
+ instance._system_clipboard = FakeTextClipboard()
+ instance._tracker_block_text = TrackerBlockText(samples=ProjectSampleDirectory(controller))
+ instance._order_block_text = OrderBlockText()
+ instance._tracker_text_cache = ParsedBlockCache(instance._tracker_block_text.parse)
+ instance._order_text_cache = ParsedBlockCache(instance._order_block_text.parse)
+ instance._tracker_block_reader = TrackerBlockReader(instance._sequencer_tracker_logic)
+ instance._tracker_block_writer = TrackerBlockWriter(instance._sequencer_tracker_logic)
+ instance._sequencer_order_logic = SequencerOrderLogic(controller)
+ instance._order_block_reader = OrderBlockReader(instance._sequencer_order_logic)
+ instance._order_block_writer = OrderBlockWriter(instance._sequencer_order_logic)
+ instance._history_detail = SequencerHistoryDetail(
+ instance._sequencer_tracker_logic,
+ MagicMock(),
+ )
+ instance._sequencer_tracker_panel = MagicMock()
+ instance._sequencer_order_panel = MagicMock()
+ instance._wire_block_callbacks()
+ return instance
+
+
+def _place_transpose(
+ coordinator: SequencerTabCoordinator,
+ transpose: int,
+) -> None:
+ """Puts one value in the frame, through the same wrapper an edit reaches the history by."""
+ edit = coordinator._undoable(
+ HistoryAction.EDIT_ROW,
+ coordinator._sequencer_tracker_logic.write_cell,
+ )
+ edit(0, GeneratorName.PULSE1, None, transpose, None)
+
+
+class TestBlockCopy:
+ def test_a_copy_fills_the_clipboard_with_the_block_it_covers(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+ with coordinator._history.transaction(HistoryAction.EDIT_ROW):
+ coordinator._sequencer_tracker_logic.set_cell_subcolumn(
+ 0,
+ GeneratorName.PULSE1,
+ transpose=5,
+ )
+
+ coordinator._on_tracker_copy_block(PULSE1_CELL)
+
+ block = coordinator._clipboard.tracker_block
+ assert block is not None
+ assert block.transposes[(0, 1)] == 5
+
+ def test_a_copy_leaves_the_history_stack_as_it_stands(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ """A gesture that only reads the project records nothing, where the edit beside it does."""
+ coordinator = block_coordinator
+ _place_transpose(coordinator, 5)
+ recorded = len(coordinator._history.entries)
+
+ coordinator._on_tracker_copy_block(PULSE1_CELL)
+
+ assert recorded > 0
+ assert len(coordinator._history.entries) == recorded
+
+
+class TestBlockEdits:
+ """Each gesture that writes records the one entry that takes the grid back."""
+
+ def test_a_cut_takes_the_block_and_empties_what_it_covered(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+ _place_transpose(coordinator, 5)
+
+ coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL)
+
+ block = coordinator._clipboard.tracker_block
+ assert block is not None
+ assert block.transposes[(0, 1)] == 5
+ assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 0).transpose is None
+
+ def test_a_cut_records_one_entry(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+ _place_transpose(coordinator, 5)
+ recorded = len(coordinator._history.entries)
+
+ coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL)
+
+ assert len(coordinator._history.entries) == recorded + 1
+ assert coordinator._history.entries[-1].action is HistoryAction.CUT_BLOCK
+
+ def test_a_delete_empties_the_region_in_one_entry(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+ _place_transpose(coordinator, 5)
+ recorded = len(coordinator._history.entries)
+
+ coordinator._sequencer_tracker_panel.on_delete_block(PULSE1_CELL)
+
+ assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 0).transpose is None
+ assert len(coordinator._history.entries) == recorded + 1
+ assert coordinator._history.entries[-1].action is HistoryAction.DELETE_BLOCK
+
+ def test_a_paste_writes_the_copied_block_in_one_entry(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+ _place_transpose(coordinator, 5)
+ coordinator._on_tracker_copy_block(PULSE1_CELL)
+ recorded = len(coordinator._history.entries)
+
+ coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE2))
+
+ assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE2, 1).transpose == 5
+ assert len(coordinator._history.entries) == recorded + 1
+ assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK
+
+
+class TestOrderBlockEdits:
+ """The order's gestures reach the same clipboard and record the same one entry each."""
+
+ def test_a_copy_fills_the_clipboard_and_leaves_the_history_as_it_stands(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+ recorded = len(coordinator._history.entries)
+
+ coordinator._sequencer_order_panel.on_copy_block(PULSE1_FRAME)
+
+ block = coordinator._clipboard.order_block
+ assert block is not None
+ assert block.entries == {(0, 0): 0}
+ assert len(coordinator._history.entries) == recorded
+
+ def test_a_cut_takes_the_block_and_silences_what_it_covered(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+ recorded = len(coordinator._history.entries)
+
+ coordinator._sequencer_order_panel.on_cut_block(PULSE1_FRAME)
+
+ assert coordinator._sequencer_order_logic.entry(GeneratorName.PULSE1, 0) is None
+ assert len(coordinator._history.entries) == recorded + 1
+ assert coordinator._history.entries[-1].action is HistoryAction.CUT_BLOCK
+
+ def test_a_delete_silences_the_region_in_one_entry(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+ recorded = len(coordinator._history.entries)
+
+ coordinator._sequencer_order_panel.on_delete_block(PULSE1_FRAME)
+
+ assert coordinator._sequencer_order_logic.entry(GeneratorName.PULSE1, 0) is None
+ assert len(coordinator._history.entries) == recorded + 1
+ assert coordinator._history.entries[-1].action is HistoryAction.DELETE_BLOCK
+
+ def test_a_paste_covers_the_frames_it_appends_and_the_entries_it_writes(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ """One entry stands for the whole gesture, so an undo takes the appended frames back too."""
+ coordinator = block_coordinator
+ coordinator._sequencer_order_panel.on_copy_block(PULSE1_FRAME)
+ recorded = len(coordinator._history.entries)
+
+ coordinator._sequencer_order_panel.on_paste_block(OrderCell(generator=GeneratorName.NOISE, position=1))
+
+ assert coordinator._sequencer_order_logic.position_count() == 2
+ assert coordinator._sequencer_order_logic.entry(GeneratorName.NOISE, 1) == 0
+ assert len(coordinator._history.entries) == recorded + 1
+ assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK
+
+ coordinator._history.undo()
+
+ assert coordinator._sequencer_order_logic.position_count() == 1
+
+ def test_a_paste_with_nothing_copied_records_nothing(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ """A transaction over a gesture that writes nothing commits nothing, so an empty clipboard
+ leaves the history where it stood."""
+ coordinator = block_coordinator
+ _place_transpose(coordinator, 5)
+ recorded = len(coordinator._history.entries)
+
+ coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE2))
+
+ assert len(coordinator._history.entries) == recorded
+
+
+class TestSystemClipboardCopy:
+ """A copy writes both clipboards, so the same gesture reaches a paste here and elsewhere."""
+
+ def test_a_copy_states_the_block_as_text(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+ _place_transpose(coordinator, 5)
+
+ coordinator._on_tracker_copy_block(PULSE1_CELL)
+
+ assert coordinator._system_clipboard.text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ."
+
+ def test_an_order_copy_states_its_own_grid(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+
+ coordinator._on_order_copy_block(PULSE1_FRAME)
+
+ assert coordinator._system_clipboard.text == "SampleToNES/1 order rows=1 positions=0..0\n00"
+
+ def test_a_cut_states_the_block_it_took(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+ _place_transpose(coordinator, 5)
+
+ coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL)
+
+ assert coordinator._system_clipboard.text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ."
+
+
+class TestSystemClipboardPrecedence:
+ """Text that reads as a block for this grid stands ahead of the slot it copied into."""
+
+ def test_a_block_copied_elsewhere_is_the_one_a_paste_writes(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ """This is a second instance's copy arriving, which is what carries a block between them."""
+ coordinator = block_coordinator
+ _place_transpose(coordinator, 5)
+ coordinator._on_tracker_copy_block(PULSE1_CELL)
+ coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .")
+
+ coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1))
+
+ assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 9
+
+ def test_unrelated_text_leaves_the_copied_block_in_hand(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+ _place_transpose(coordinator, 5)
+ coordinator._on_tracker_copy_block(PULSE1_CELL)
+ coordinator._system_clipboard.write("a line from a message")
+
+ coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1))
+
+ assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 5
+
+ def test_a_truncated_block_leaves_the_copied_block_in_hand(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ coordinator = block_coordinator
+ _place_transpose(coordinator, 5)
+ coordinator._on_tracker_copy_block(PULSE1_CELL)
+ coordinator._system_clipboard.write("SampleToNES/1 tracker rows=4 slots=3..5\n.. +09 .")
+
+ coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1))
+
+ assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 5
+
+ def test_the_other_grid_s_text_leaves_the_copied_block_in_hand(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ """A tracker copy stands on the clipboard while the order pastes, so each grid keeps its own."""
+ coordinator = block_coordinator
+ coordinator._on_order_copy_block(PULSE1_FRAME)
+ coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .")
+
+ coordinator._sequencer_order_panel.on_paste_block(OrderCell(generator=GeneratorName.NOISE, position=1))
+
+ assert coordinator._sequencer_order_logic.entry(GeneratorName.NOISE, 1) == 0
+
+ def test_a_paste_offers_itself_on_the_text_standing_on_the_clipboard(
+ self,
+ block_coordinator: SequencerTabCoordinator,
+ ) -> None:
+ """The menu asks the same question the paste does, so it offers what the next press reaches."""
+ coordinator = block_coordinator
+
+ assert not coordinator._can_paste_tracker_block()
+
+ coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .")
+
+ assert coordinator._can_paste_tracker_block()
+ assert not coordinator._can_paste_order_block()
diff --git a/tests/unit/sampletones_application/coordinators/test_config.py b/tests/unit/sampletones_application/coordinators/test_config.py
index ff7032f1..ace3c6c5 100644
--- a/tests/unit/sampletones_application/coordinators/test_config.py
+++ b/tests/unit/sampletones_application/coordinators/test_config.py
@@ -23,7 +23,10 @@ def _coordinator(config_manager: MagicMock) -> ConfigCoordinator:
)
-def _manager_with(*outcomes: Any, 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)
@@ -38,17 +41,35 @@ class ReasonCase:
reason_cases = [
- ReasonCase("load", ConfigLoadFailureReason.LOAD_ERROR, "global.dialog.message.configuration_load_error"),
- ReasonCase("parse", ConfigLoadFailureReason.PARSE_ERROR, "global.dialog.message.configuration_parse_error"),
- ReasonCase("invalid", ConfigLoadFailureReason.INVALID, "global.dialog.message.configuration_invalid_error"),
+ ReasonCase(
+ "load",
+ ConfigLoadFailureReason.LOAD_ERROR,
+ "global.dialog.message.configuration_load_error",
+ ),
+ ReasonCase(
+ "parse",
+ ConfigLoadFailureReason.PARSE_ERROR,
+ "global.dialog.message.configuration_parse_error",
+ ),
+ ReasonCase(
+ "invalid",
+ ConfigLoadFailureReason.INVALID,
+ "global.dialog.message.configuration_invalid_error",
+ ),
]
class TestPresentPendingLoadOutcomes:
- def test_recovered_outcome_shows_recovery_dialog(self, tmp_path: Path) -> None:
+ def test_recovered_outcome_shows_recovery_dialog(
+ self,
+ tmp_path: Path,
+ ) -> None:
config_path = tmp_path / "config.json"
config_manager = _manager_with(
- ConfigRecovered(source_version="1.0.0", dropped=(("generation", "drive"), ("obsolete_field",))),
+ ConfigRecovered(
+ source_version="1.0.0",
+ dropped=(("generation", "drive"), ("obsolete_field",)),
+ ),
config_path=config_path,
)
coordinator = _coordinator(config_manager)
@@ -63,7 +84,10 @@ def test_recovered_outcome_shows_recovery_dialog(self, tmp_path: Path) -> None:
coordinator._dialogs.show_error.assert_not_called()
@pytest.mark.parametrize("case", reason_cases, ids=lambda case: case.label)
- def test_failure_outcome_shows_error_with_mapped_message(self, case: ReasonCase) -> None:
+ def test_failure_outcome_shows_error_with_mapped_message(
+ self,
+ case: ReasonCase,
+ ) -> None:
config_manager = _manager_with(ConfigLoadFailure(RuntimeError("boom"), case.reason))
coordinator = _coordinator(config_manager)
@@ -75,7 +99,12 @@ def test_failure_outcome_shows_error_with_mapped_message(self, case: ReasonCase)
coordinator._dialogs.show_config_recovery.assert_not_called()
def test_outcomes_are_cleared_after_presenting(self) -> None:
- config_manager = _manager_with(ConfigLoadFailure(RuntimeError("boom"), ConfigLoadFailureReason.LOAD_ERROR))
+ config_manager = _manager_with(
+ ConfigLoadFailure(
+ RuntimeError("boom"),
+ ConfigLoadFailureReason.LOAD_ERROR,
+ )
+ )
coordinator = _coordinator(config_manager)
coordinator.present_pending_load_outcomes()
@@ -101,7 +130,11 @@ class TestHandleSave:
[PermissionError("denied"), ValueError("No configuration to save")],
ids=["io", "empty"],
)
- def test_save_failure_shows_the_error_dialog(self, error: Exception, tmp_path: Path) -> None:
+ def test_save_failure_shows_the_error_dialog(
+ self,
+ error: Exception,
+ tmp_path: Path,
+ ) -> None:
config_manager = _manager_with()
config_manager.save_config_to_file.side_effect = error
coordinator = _coordinator(config_manager)
diff --git a/tests/unit/sampletones_application/coordinators/test_display.py b/tests/unit/sampletones_application/coordinators/test_display.py
new file mode 100644
index 00000000..3776d0c2
--- /dev/null
+++ b/tests/unit/sampletones_application/coordinators/test_display.py
@@ -0,0 +1,543 @@
+from typing import Any, Dict, List, Tuple
+
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.coordinators.display import DisplayCoordinator
+from sampletones_application.layout.behavior.display import DisplayBehavior
+from sampletones_application.layout.general.window import WindowLayout
+from sampletones_application.paths import LANG_EN
+from sampletones_application.utils.monitors import MonitorArea
+from sampletones_application.view_model.shared.display_settings import (
+ DisplaySettings,
+ DisplaySettingsViewModel,
+ WindowMode,
+)
+from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution
+from sampletones_shared.types.callback import VoidCallback
+
+STUDIO = "studio"
+DARK = "dark"
+
+WIDESCREEN = Resolution(width=1600, height=900)
+DEFAULT_RESOLUTION = Resolution(width=1280, height=800)
+
+COUNTDOWN_SECONDS = 10.0
+
+BEHAVIOR = DisplayBehavior(
+ resolutions=(
+ Resolution(width=1024, height=768),
+ DEFAULT_RESOLUTION,
+ WIDESCREEN,
+ ),
+ frame_rates=(UNLIMITED_FRAME_RATE, 30, 60, 120),
+ revert_countdown_seconds=COUNTDOWN_SECONDS,
+)
+
+WINDOW_LAYOUT = WindowLayout(
+ width=1280,
+ height=800,
+ min_width=1024,
+ min_height=640,
+ position_x=200,
+ fullscreen=False,
+ max_monitor_ratio=0.9,
+ fallback_monitor=Resolution(width=1920, height=1080),
+)
+
+
+class _Palette:
+ """Stands in for a loaded palette, which the coordinator only ever reads the name of."""
+
+ def __init__(self, name: str) -> None:
+ self.name = name
+
+
+class _PaletteSourceRecorder:
+ def __init__(self) -> None:
+ self.palette = _Palette(STUDIO)
+ self.activated: List[str] = []
+
+ def activate(self, palette: _Palette) -> None:
+ self.activated.append(palette.name)
+ self.palette = palette
+
+
+class _PaletteCatalogRecorder:
+ names: Tuple[str, ...] = (DARK, "light", STUDIO)
+
+ def select(self, name: str) -> _Palette:
+ return _Palette(name)
+
+
+class _SessionRecorder:
+ def __init__(self) -> None:
+ self.palette_name = STUDIO
+ self.vsync = True
+ self.max_fps = 60
+ self.borderless = False
+ self.fullscreen = False
+ self.writes: List[Tuple[str, Any]] = []
+
+ def set_palette_name(self, name: str) -> None:
+ self.writes.append(("palette", name))
+ self.palette_name = name
+
+ def set_vsync(self, vsync: bool) -> None:
+ self.writes.append(("vsync", vsync))
+ self.vsync = vsync
+
+ def set_max_fps(self, max_fps: int) -> None:
+ self.writes.append(("max_fps", max_fps))
+ self.max_fps = max_fps
+
+ def set_borderless(self, borderless: bool) -> None:
+ self.writes.append(("borderless", borderless))
+ self.borderless = borderless
+
+
+class _ViewportRecorder:
+ def __init__(self) -> None:
+ self.resolution: Tuple[int, int] = (
+ DEFAULT_RESOLUTION.width,
+ DEFAULT_RESOLUTION.height,
+ )
+ self.fullscreen_toggles = 0
+ self.calls: List[Tuple[str, Any]] = []
+
+ @property
+ def monitor_area(self) -> MonitorArea:
+ return MonitorArea(x=0, y=0, width=1920, height=1080, usable_ratio=0.9)
+
+ def set_resolution(self, width: int, height: int) -> None:
+ self.calls.append(("resolution", (width, height)))
+ self.resolution = (width, height)
+
+ def set_borderless(self, borderless: bool) -> None:
+ self.calls.append(("borderless", borderless))
+
+ def set_vsync(self, vsync: bool) -> None:
+ self.calls.append(("vsync", vsync))
+
+ def toggle_fullscreen(self) -> None:
+ self.fullscreen_toggles += 1
+ self.calls.append(("fullscreen", self.fullscreen_toggles))
+
+
+class _FrameLimiterRecorder:
+ def __init__(self) -> None:
+ self.rates: List[int] = []
+
+ def set_max_fps(self, max_fps: int) -> None:
+ self.rates.append(max_fps)
+
+
+class _WindowRecorder:
+ """Stands in for the dialog window, with the modal hand-off collapsed to a direct call.
+
+ The real window defers the modal it yields to by a frame, which is what keeps the two from
+ competing for the one modal DearPyGui carries; here the frame is taken as having passed.
+ """
+
+ def __init__(self) -> None:
+ self.view_models: List[DisplaySettingsViewModel] = []
+ self.visible = False
+ self.on_settings_changed: Any = None
+ self.on_commit: Any = None
+ self.on_cancel: Any = None
+
+ def open(self, view_model: DisplaySettingsViewModel) -> None:
+ self.visible = True
+ self.view_models.append(view_model)
+
+ def update_view(self, view_model: DisplaySettingsViewModel) -> None:
+ self.view_models.append(view_model)
+
+ def yield_to(self, raise_modal: VoidCallback) -> None:
+ self.visible = False
+ raise_modal()
+
+ def resume(self) -> None:
+ self.visible = True
+
+ def hide(self) -> None:
+ self.visible = False
+
+ @property
+ def settings(self) -> DisplaySettings:
+ return self.view_models[-1].settings
+
+
+class _CountdownRecorder:
+ def __init__(self) -> None:
+ self.opens = 0
+ self.hides = 0
+ self.visible = False
+ self.remaining: List[int] = []
+ self.on_keep: Any = None
+ self.on_revert: Any = None
+
+ def open(self, remaining: int) -> None:
+ self.opens += 1
+ self.visible = True
+ self.remaining.append(remaining)
+
+ def set_remaining(self, remaining: int) -> None:
+ self.remaining.append(remaining)
+
+ def hide(self) -> None:
+ self.hides += 1
+ self.visible = False
+
+
+class _DialogsRecorder:
+ def __init__(self) -> None:
+ self.confirmations: List[Dict[str, Any]] = []
+
+ def show_confirmation(self, **kwargs: Any) -> None:
+ self.confirmations.append(kwargs)
+
+ def confirm(self) -> None:
+ self.confirmations[-1]["on_confirm"]()
+
+ def decline(self) -> None:
+ self.confirmations[-1]["on_cancel"]()
+
+
+class Harness:
+ """The coordinator wired to recorders, with the gestures a user makes spelled as methods."""
+
+ def __init__(self) -> None:
+ self.session = _SessionRecorder()
+ self.viewport = _ViewportRecorder()
+ self.frame_limiter = _FrameLimiterRecorder()
+ self.palette_source = _PaletteSourceRecorder()
+ self.window = _WindowRecorder()
+ self.countdown = _CountdownRecorder()
+ self.dialogs = _DialogsRecorder()
+ self.coordinator = DisplayCoordinator(
+ self.session,
+ self.viewport,
+ self.frame_limiter,
+ self.palette_source,
+ _PaletteCatalogRecorder(),
+ window=self.window,
+ countdown=self.countdown,
+ behavior=BEHAVIOR,
+ window_layout=WINDOW_LAYOUT,
+ dialogs=self.dialogs,
+ language_manager=LanguageManager(LANG_EN),
+ )
+
+ def open(self) -> None:
+ self.coordinator.open()
+
+ def change(self, settings: DisplaySettings) -> None:
+ self.window.on_settings_changed(settings)
+
+ def commit(self) -> None:
+ self.window.on_commit()
+
+ def cancel(self) -> None:
+ self.window.on_cancel()
+
+ def keep(self) -> None:
+ self.countdown.on_keep()
+
+ def revert(self) -> None:
+ self.countdown.on_revert()
+
+ def elapse(self, seconds: float) -> None:
+ self.coordinator.tick(seconds)
+
+ @property
+ def settings(self) -> DisplaySettings:
+ return self.window.settings
+
+
+@pytest.fixture(name="harness")
+def harness_fixture() -> Harness:
+ harness = Harness()
+ harness.open()
+ return harness
+
+
+class TestOpening:
+ def test_the_dialog_shows_the_settings_in_force(self, harness: Harness) -> None:
+ assert harness.settings == DisplaySettings(
+ palette=STUDIO,
+ window=WindowMode(resolution=DEFAULT_RESOLUTION, borderless=False, fullscreen=False),
+ vsync=True,
+ frame_rate=60,
+ )
+
+ def test_only_the_sizes_the_monitor_leaves_room_for_are_offered(self, harness: Harness) -> None:
+ assert harness.window.view_models[-1].resolutions == BEHAVIOR.resolutions
+
+ def test_every_shipped_palette_is_offered(self, harness: Harness) -> None:
+ assert harness.window.view_models[-1].palettes == _PaletteCatalogRecorder.names
+
+
+class TestLiveApplication:
+ def test_a_palette_is_swapped_the_moment_it_is_picked(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_palette(DARK))
+
+ assert harness.palette_source.activated == [DARK]
+
+ def test_a_frame_rate_repaces_the_loop_the_moment_it_is_picked(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_frame_rate(UNLIMITED_FRAME_RATE))
+
+ assert harness.frame_limiter.rates == [UNLIMITED_FRAME_RATE]
+
+ def test_vsync_reaches_the_viewport_the_moment_it_is_switched(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_vsync(False))
+
+ assert ("vsync", False) in harness.viewport.calls
+
+ def test_a_size_reaches_the_viewport_the_moment_it_is_picked(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN)))
+
+ assert (
+ "resolution",
+ (WIDESCREEN.width, WIDESCREEN.height),
+ ) in harness.viewport.calls
+
+ def test_nothing_is_written_to_the_session_before_it_is_confirmed(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_palette(DARK))
+ harness.change(harness.settings.with_vsync(False))
+
+ assert harness.session.writes == []
+
+ def test_fullscreen_goes_through_the_toggle_the_menu_shares(self, harness: Harness) -> None:
+ """The View menu's checkmark follows the viewport manager's own toggle."""
+ harness.change(harness.settings.with_window(harness.settings.window.with_fullscreen(True)))
+
+ assert harness.viewport.fullscreen_toggles == 1
+
+ def test_a_fullscreen_window_offers_neither_a_size_nor_a_frame(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_fullscreen(True)))
+
+ assert not harness.window.view_models[-1].window_controls_enabled
+
+
+class TestCommit:
+ def test_confirming_writes_every_setting_to_the_session(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_palette(DARK))
+ harness.change(harness.settings.with_frame_rate(120))
+ harness.commit()
+
+ assert dict(harness.session.writes) == {
+ "palette": DARK,
+ "vsync": True,
+ "max_fps": 120,
+ "borderless": False,
+ }
+
+ def test_confirming_closes_the_dialog(self, harness: Harness) -> None:
+ harness.commit()
+
+ assert not harness.window.visible
+
+ def test_confirming_while_the_clock_runs_keeps_the_change_and_stops_the_clock(
+ self,
+ harness: Harness,
+ ) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True)))
+ harness.commit()
+ harness.elapse(COUNTDOWN_SECONDS)
+
+ assert dict(harness.session.writes)["borderless"] is True
+ assert not harness.countdown.visible
+
+
+class TestCancel:
+ def test_cancelling_an_untouched_dialog_closes_it_without_asking(self, harness: Harness) -> None:
+ harness.cancel()
+
+ assert harness.dialogs.confirmations == []
+ assert not harness.window.visible
+
+ def test_cancelling_a_changed_dialog_asks_first(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_palette(DARK))
+ harness.cancel()
+
+ assert len(harness.dialogs.confirmations) == 1
+
+ def test_the_dialog_steps_aside_so_the_prompt_can_open(self, harness: Harness) -> None:
+ """A prompt raised while the dialog still holds the screen opens where nobody can reach it."""
+ harness.change(harness.settings.with_palette(DARK))
+ harness.cancel()
+
+ assert not harness.window.visible
+
+ def test_keeping_the_edit_brings_the_dialog_back(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_palette(DARK))
+ harness.cancel()
+ harness.dialogs.decline()
+
+ assert harness.window.visible
+ assert harness.settings.palette == DARK
+
+ def test_discarding_puts_back_the_palette_the_dialog_opened_with(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_palette(DARK))
+ harness.cancel()
+ harness.dialogs.confirm()
+
+ assert harness.palette_source.activated == [DARK, STUDIO]
+ assert not harness.window.visible
+
+ def test_discarding_puts_back_the_window_mode_the_dialog_opened_with(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN)))
+ harness.cancel()
+ harness.dialogs.confirm()
+
+ assert harness.viewport.calls[-1] == (
+ "resolution",
+ (DEFAULT_RESOLUTION.width, DEFAULT_RESOLUTION.height),
+ )
+
+ def test_discarding_writes_nothing_to_the_session(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_vsync(False))
+ harness.cancel()
+ harness.dialogs.confirm()
+
+ assert harness.session.writes == []
+
+
+class TestCountdown:
+ def test_a_window_mode_change_starts_the_clock(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True)))
+
+ assert harness.countdown.visible
+ assert harness.countdown.remaining[0] == int(COUNTDOWN_SECONDS)
+
+ @pytest.mark.parametrize(
+ "field",
+ ["palette", "vsync", "frame_rate"],
+ ids=["palette", "vsync", "frame_rate"],
+ )
+ def test_a_change_outside_the_window_mode_leaves_the_clock_alone(
+ self,
+ harness: Harness,
+ field: str,
+ ) -> None:
+ changes: Dict[str, DisplaySettings] = {
+ "palette": harness.settings.with_palette(DARK),
+ "vsync": harness.settings.with_vsync(False),
+ "frame_rate": harness.settings.with_frame_rate(30),
+ }
+ harness.change(changes[field])
+
+ assert not harness.countdown.visible
+
+ def test_the_clock_counts_down_in_whole_seconds(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True)))
+ harness.elapse(1.5)
+
+ assert harness.countdown.remaining[-1] == int(COUNTDOWN_SECONDS) - 1
+
+ def test_the_clock_running_out_puts_the_window_mode_back(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN)))
+ harness.elapse(COUNTDOWN_SECONDS)
+
+ assert harness.settings.window.resolution == DEFAULT_RESOLUTION
+ assert not harness.countdown.visible
+
+ def test_the_clock_running_out_leaves_every_other_edit_standing(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_palette(DARK))
+ harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN)))
+ harness.elapse(COUNTDOWN_SECONDS)
+
+ assert harness.settings.palette == DARK
+ assert harness.settings.window.resolution == DEFAULT_RESOLUTION
+
+ def test_a_second_change_restarts_one_clock_rather_than_starting_another(
+ self,
+ harness: Harness,
+ ) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN)))
+ harness.elapse(4.0)
+ harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True)))
+
+ assert harness.countdown.opens == 1
+ assert harness.countdown.hides == 0
+ assert harness.countdown.remaining[-1] == int(COUNTDOWN_SECONDS)
+
+ def test_a_run_of_changes_returns_to_the_mode_last_seen_as_readable(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN)))
+ harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True)))
+ harness.elapse(COUNTDOWN_SECONDS)
+
+ assert harness.settings.window == WindowMode(
+ resolution=DEFAULT_RESOLUTION,
+ borderless=False,
+ fullscreen=False,
+ )
+
+ def test_the_dialog_steps_aside_so_the_prompt_can_open(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True)))
+
+ assert not harness.window.visible
+
+ @pytest.mark.parametrize(
+ "answer",
+ ["keep", "revert"],
+ ids=["keep", "revert"],
+ )
+ def test_answering_the_prompt_brings_the_dialog_back(self, harness: Harness, answer: str) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True)))
+ {"keep": harness.keep, "revert": harness.revert}[answer]()
+
+ assert harness.window.visible
+
+ def test_the_clock_running_out_brings_the_dialog_back(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True)))
+ harness.elapse(COUNTDOWN_SECONDS)
+
+ assert harness.window.visible
+
+ def test_keeping_stops_the_clock_and_leaves_the_change_standing(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True)))
+ harness.keep()
+ harness.elapse(COUNTDOWN_SECONDS)
+
+ assert not harness.countdown.visible
+ assert harness.settings.window.borderless is True
+
+ def test_a_kept_change_is_still_undone_by_cancelling(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True)))
+ harness.keep()
+ harness.cancel()
+ harness.dialogs.confirm()
+
+ assert harness.viewport.calls[-1] == ("borderless", False)
+
+ def test_reverting_by_hand_puts_the_window_mode_back_at_once(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_resolution(WIDESCREEN)))
+ harness.revert()
+
+ assert harness.settings.window.resolution == DEFAULT_RESOLUTION
+ assert not harness.countdown.visible
+
+ def test_reverting_a_fullscreen_change_toggles_back(self, harness: Harness) -> None:
+ harness.change(harness.settings.with_window(harness.settings.window.with_fullscreen(True)))
+ harness.revert()
+
+ assert harness.viewport.fullscreen_toggles == 2
+ assert harness.settings.window.fullscreen is False
+
+ def test_a_closed_dialog_leaves_the_clock_idle(self, harness: Harness) -> None:
+ harness.commit()
+ harness.elapse(COUNTDOWN_SECONDS)
+
+ assert harness.countdown.remaining == []
+
+
+class TestClosedDialog:
+ def test_editing_a_closed_dialog_is_refused(self, harness: Harness) -> None:
+ """A gesture arriving after the dialog closed has no state to edit."""
+ settings = harness.settings.with_palette(DARK)
+ harness.commit()
+
+ with pytest.raises(SystemError):
+ harness.change(settings)
diff --git a/tests/unit/sampletones_application/coordinators/test_keybindings.py b/tests/unit/sampletones_application/coordinators/test_keybindings.py
new file mode 100644
index 00000000..56ee13d1
--- /dev/null
+++ b/tests/unit/sampletones_application/coordinators/test_keybindings.py
@@ -0,0 +1,501 @@
+from typing import Any, Dict, Final, List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.coordinators.keybindings import KeybindingsCoordinator
+from sampletones_application.paths import LANG_EN
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.shortcuts.ids import (
+ EDITABLE_SHORTCUT_CATEGORIES,
+ ShortcutId,
+)
+from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
+from sampletones_application.view_model.shared.keybindings import (
+ KeybindingRow,
+ KeybindingsViewModel,
+)
+from sampletones_shared.types.callback import VoidCallback
+from tests.suite.shortcuts import shipped_catalog, shipped_scheme
+
+SAVE_PROJECT: Final[str] = ShortcutId.SAVE_PROJECT.value
+ABOUT_DIALOG: Final[str] = ShortcutId.ABOUT_DIALOG.value
+UNDO: Final[str] = ShortcutId.UNDO.value
+REDO: Final[str] = ShortcutId.REDO.value
+
+SAVE_COMBINATION: Final[str] = "Ctrl+S"
+UNDO_COMBINATION: Final[str] = "Ctrl+Z"
+REDO_COMBINATION: Final[str] = "Ctrl+Y"
+FREE_COMBINATION: Final[str] = "Ctrl+Alt+B"
+UNREADABLE_COMBINATION: Final[str] = "Ctrl+Nonsense"
+
+
+class _SessionRecorder:
+ def __init__(self) -> None:
+ self.shortcut_scheme_name = shipped_scheme().name
+ self.shortcut_overrides: Dict[str, Optional[str]] = {}
+ self.writes: List[Tuple[str, Any]] = []
+
+ def set_shortcut_scheme_name(self, name: str) -> None:
+ self.writes.append(("scheme", name))
+ self.shortcut_scheme_name = name
+
+ def set_shortcut_overrides(self, overrides: Dict[str, Optional[str]]) -> None:
+ self.writes.append(("overrides", overrides))
+ self.shortcut_overrides = overrides
+
+
+class _SourceRecorder:
+ def __init__(self) -> None:
+ self.scheme = shipped_scheme()
+ self.activated: List[ShortcutScheme] = []
+
+ def activate(self, scheme: ShortcutScheme) -> None:
+ self.activated.append(scheme)
+ self.scheme = scheme
+
+
+class _WindowRecorder:
+ """Stands in for the dialog window, with the modal hand-off collapsed to a direct call."""
+
+ def __init__(self) -> None:
+ self.view_models: List[KeybindingsViewModel] = []
+ self.visible = False
+ self.on_scheme_selected: Any = None
+ self.on_action_selected: Any = None
+ self.on_combination_typed: Any = None
+ self.on_combination_captured: Any = None
+ self.on_clear: Any = None
+ self.on_reset: Any = None
+ self.on_commit: Any = None
+ self.on_cancel: Any = None
+
+ def open(self, view_model: KeybindingsViewModel) -> None:
+ self.visible = True
+ self.view_models.append(view_model)
+
+ def update_view(self, view_model: KeybindingsViewModel) -> None:
+ self.view_models.append(view_model)
+
+ def yield_to(self, raise_modal: VoidCallback) -> None:
+ self.visible = False
+ raise_modal()
+
+ def resume(self) -> None:
+ self.visible = True
+
+ def hide(self) -> None:
+ self.visible = False
+
+ @property
+ def view_model(self) -> KeybindingsViewModel:
+ return self.view_models[-1]
+
+
+class _DialogsRecorder:
+ def __init__(self) -> None:
+ self.confirmations: List[Dict[str, Any]] = []
+
+ def show_confirmation(self, **kwargs: Any) -> None:
+ self.confirmations.append(kwargs)
+
+ def confirm(self) -> None:
+ self.confirmations[-1]["on_confirm"]()
+
+ def decline(self) -> None:
+ self.confirmations[-1]["on_cancel"]()
+
+
+class Harness:
+ """The coordinator wired to recorders, with the gestures a user makes spelled as methods."""
+
+ def __init__(self) -> None:
+ self.session = _SessionRecorder()
+ self.source = _SourceRecorder()
+ self.window = _WindowRecorder()
+ self.dialogs = _DialogsRecorder()
+ self.coordinator = KeybindingsCoordinator(
+ self.session,
+ self.source,
+ shipped_catalog(),
+ window=self.window,
+ dialogs=self.dialogs,
+ language_manager=LanguageManager(LANG_EN),
+ )
+
+ def open(self) -> None:
+ self.coordinator.open()
+
+ def select(self, action: str) -> None:
+ self.window.on_action_selected(action)
+
+ def type_combination(self, text: str) -> None:
+ self.window.on_combination_typed(text)
+
+ def capture(self, text: str) -> None:
+ self.window.on_combination_captured(KeyCombination.parse(text))
+
+ def clear(self) -> None:
+ self.window.on_clear()
+
+ def reset(self) -> None:
+ self.window.on_reset()
+
+ def commit(self) -> None:
+ self.window.on_commit()
+
+ def cancel(self) -> None:
+ self.window.on_cancel()
+
+ def select_scheme(self, name: str) -> None:
+ self.window.on_scheme_selected(name)
+
+ def row(self, action: str) -> KeybindingRow:
+ for group in self.window.view_model.groups:
+ for row in group.rows:
+ if row.action == action:
+ return row
+
+ raise AssertionError(f"The dialog lists no row for {action!r}")
+
+
+@pytest.fixture(name="harness")
+def harness_fixture() -> Harness:
+ harness = Harness()
+ harness.open()
+ return harness
+
+
+class TestOpening:
+ def test_the_dialog_shows_the_keys_in_force(self, harness: Harness) -> None:
+ assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION
+
+ def test_an_unbound_action_is_listed_carrying_no_keys(self, harness: Harness) -> None:
+ assert harness.row(ABOUT_DIALOG).combination == ""
+
+ def test_every_editable_scope_is_listed(self, harness: Harness) -> None:
+ assert tuple(group.category for group in harness.window.view_model.groups) == tuple(
+ category.value for category in EDITABLE_SHORTCUT_CATEGORIES
+ )
+
+ def test_every_editable_action_reaches_a_row(self, harness: Harness) -> None:
+ listed = {row.action for group in harness.window.view_model.groups for row in group.rows}
+ editable = {
+ shortcut_id.value for shortcut_id in ShortcutId if shortcut_id.category in EDITABLE_SHORTCUT_CATEGORIES
+ }
+
+ assert listed == editable
+
+ def test_every_row_carries_a_label_a_reader_sees(self, harness: Harness) -> None:
+ unlabelled = [row.action for group in harness.window.view_model.groups for row in group.rows if not row.label]
+
+ assert unlabelled == []
+
+ def test_every_shipped_scheme_is_offered(self, harness: Harness) -> None:
+ assert harness.window.view_model.schemes == shipped_catalog().names
+
+ def test_the_stored_preference_reaches_the_dialog(self) -> None:
+ harness = Harness()
+ harness.session.shortcut_overrides = {SAVE_PROJECT: FREE_COMBINATION}
+ harness.open()
+
+ assert harness.row(SAVE_PROJECT).combination == FREE_COMBINATION
+
+
+class TestSelection:
+ def test_selecting_an_action_puts_its_keys_in_the_entry_box(self, harness: Harness) -> None:
+ harness.select(SAVE_PROJECT)
+
+ assert harness.window.view_model.selected == SAVE_PROJECT
+ assert harness.window.view_model.combination == SAVE_COMBINATION
+
+ def test_an_unbound_action_leaves_the_entry_box_empty(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+
+ assert harness.window.view_model.combination == ""
+
+ def test_a_combination_arriving_with_nothing_selected_is_refused(self, harness: Harness) -> None:
+ with pytest.raises(SystemError):
+ harness.type_combination(FREE_COMBINATION)
+
+
+class TestAssignment:
+ def test_a_written_combination_reaches_the_action(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(FREE_COMBINATION)
+
+ assert harness.row(ABOUT_DIALOG).combination == FREE_COMBINATION
+
+ def test_a_written_combination_reads_back_the_way_it_is_displayed(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination("shift+ctrl+alt+b")
+
+ assert harness.row(ABOUT_DIALOG).combination == "Ctrl+Alt+Shift+B"
+
+ def test_a_captured_press_reaches_the_action(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.capture(FREE_COMBINATION)
+
+ assert harness.row(ABOUT_DIALOG).combination == FREE_COMBINATION
+
+ def test_a_combination_naming_no_key_is_reported_and_the_keys_stand(self, harness: Harness) -> None:
+ harness.select(SAVE_PROJECT)
+ harness.type_combination(UNREADABLE_COMBINATION)
+
+ assert UNREADABLE_COMBINATION in harness.window.view_model.message
+ assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION
+
+ def test_a_later_assignment_clears_the_message(self, harness: Harness) -> None:
+ harness.select(SAVE_PROJECT)
+ harness.type_combination(UNREADABLE_COMBINATION)
+ harness.type_combination(FREE_COMBINATION)
+
+ assert harness.window.view_model.message == ""
+
+ def test_nothing_reaches_the_keys_in_force_before_it_is_confirmed(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(FREE_COMBINATION)
+
+ assert harness.source.activated == []
+ assert harness.session.writes == []
+
+
+class TestTakenCombination:
+ def test_assigning_keys_another_action_holds_asks_first(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(SAVE_COMBINATION)
+
+ assert len(harness.dialogs.confirmations) == 1
+ assert harness.row(ABOUT_DIALOG).combination == ""
+
+ def test_the_prompt_names_the_action_the_keys_are_taken_from(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(SAVE_COMBINATION)
+
+ assert "Save project" in harness.dialogs.confirmations[-1]["message"]
+
+ def test_the_dialog_steps_aside_so_the_prompt_can_open(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(SAVE_COMBINATION)
+
+ assert not harness.window.visible
+
+ def test_confirming_takes_the_keys_and_leaves_the_holder_unbound(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(SAVE_COMBINATION)
+ harness.dialogs.confirm()
+
+ assert harness.row(ABOUT_DIALOG).combination == SAVE_COMBINATION
+ assert harness.row(SAVE_PROJECT).combination == ""
+ assert harness.window.visible
+
+ def test_declining_leaves_both_actions_on_the_keys_they_had(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(SAVE_COMBINATION)
+ harness.dialogs.decline()
+
+ assert harness.row(ABOUT_DIALOG).combination == ""
+ assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION
+ assert harness.window.visible
+
+ def test_an_alias_another_action_answers_is_taken_the_same_way(self, harness: Harness) -> None:
+ """Redo answers Ctrl+Shift+Z beside its own keys, which an assignment takes with them."""
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination("Ctrl+Shift+Z")
+ harness.dialogs.confirm()
+
+ assert harness.row(ABOUT_DIALOG).combination == "Ctrl+Shift+Z"
+ assert harness.row(ShortcutId.REDO.value).combination == ""
+
+ def test_the_keys_an_action_already_answers_are_assigned_without_asking(self, harness: Harness) -> None:
+ harness.select(SAVE_PROJECT)
+ harness.type_combination(SAVE_COMBINATION)
+
+ assert harness.dialogs.confirmations == []
+ assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION
+
+
+class TestClear:
+ def test_clearing_leaves_the_action_unbound(self, harness: Harness) -> None:
+ harness.select(SAVE_PROJECT)
+ harness.clear()
+
+ assert harness.row(SAVE_PROJECT).combination == ""
+
+ def test_the_keys_a_cleared_action_held_are_free_to_take(self, harness: Harness) -> None:
+ harness.select(SAVE_PROJECT)
+ harness.clear()
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(SAVE_COMBINATION)
+
+ assert harness.dialogs.confirmations == []
+ assert harness.row(ABOUT_DIALOG).combination == SAVE_COMBINATION
+
+
+class TestReset:
+ def test_resetting_asks_first(self, harness: Harness) -> None:
+ harness.select(SAVE_PROJECT)
+ harness.type_combination(FREE_COMBINATION)
+ harness.reset()
+
+ assert len(harness.dialogs.confirmations) == 1
+ assert not harness.window.visible
+
+ def test_confirming_puts_every_action_back_on_the_shipped_keys(self, harness: Harness) -> None:
+ harness.select(SAVE_PROJECT)
+ harness.type_combination(FREE_COMBINATION)
+ harness.reset()
+ harness.dialogs.confirm()
+
+ assert harness.row(SAVE_PROJECT).combination == SAVE_COMBINATION
+ assert harness.window.visible
+
+ def test_declining_leaves_the_edits_standing(self, harness: Harness) -> None:
+ harness.select(SAVE_PROJECT)
+ harness.type_combination(FREE_COMBINATION)
+ harness.reset()
+ harness.dialogs.decline()
+
+ assert harness.row(SAVE_PROJECT).combination == FREE_COMBINATION
+
+ def test_a_reset_stores_no_overrides(self, harness: Harness) -> None:
+ harness.select(SAVE_PROJECT)
+ harness.type_combination(FREE_COMBINATION)
+ harness.reset()
+ harness.dialogs.confirm()
+ harness.commit()
+
+ assert dict(harness.session.writes)["overrides"] == {}
+
+
+class TestCommit:
+ def test_confirming_puts_the_edited_keys_in_force(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(FREE_COMBINATION)
+ harness.commit()
+
+ assert harness.source.scheme.shortcut(ShortcutId.ABOUT_DIALOG).display() == FREE_COMBINATION
+
+ def test_confirming_stores_the_rebound_actions_and_nothing_else(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(FREE_COMBINATION)
+ harness.commit()
+
+ assert dict(harness.session.writes)["overrides"] == {ABOUT_DIALOG: FREE_COMBINATION}
+
+ def test_a_displaced_action_is_stored_as_unbound(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(SAVE_COMBINATION)
+ harness.dialogs.confirm()
+ harness.commit()
+
+ assert dict(harness.session.writes)["overrides"] == {
+ ABOUT_DIALOG: SAVE_COMBINATION,
+ SAVE_PROJECT: None,
+ }
+
+ def test_confirming_stores_the_scheme_the_dialog_worked_from(self, harness: Harness) -> None:
+ harness.commit()
+
+ assert dict(harness.session.writes)["scheme"] == shipped_scheme().name
+
+ def test_confirming_closes_the_dialog(self, harness: Harness) -> None:
+ harness.commit()
+
+ assert not harness.window.visible
+
+ def test_a_stored_preference_reopens_on_the_keys_it_stored(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(SAVE_COMBINATION)
+ harness.dialogs.confirm()
+ harness.commit()
+ harness.open()
+
+ assert harness.row(ABOUT_DIALOG).combination == SAVE_COMBINATION
+ assert harness.row(SAVE_PROJECT).combination == ""
+
+
+class TestCancel:
+ def test_cancelling_an_untouched_dialog_closes_it_without_asking(self, harness: Harness) -> None:
+ harness.cancel()
+
+ assert harness.dialogs.confirmations == []
+ assert not harness.window.visible
+
+ def test_cancelling_an_edited_dialog_asks_first(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(FREE_COMBINATION)
+ harness.cancel()
+
+ assert len(harness.dialogs.confirmations) == 1
+ assert not harness.window.visible
+
+ def test_keeping_the_edit_brings_the_dialog_back(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(FREE_COMBINATION)
+ harness.cancel()
+ harness.dialogs.decline()
+
+ assert harness.window.visible
+ assert harness.row(ABOUT_DIALOG).combination == FREE_COMBINATION
+
+ def test_discarding_leaves_the_keys_in_force_alone(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(FREE_COMBINATION)
+ harness.cancel()
+ harness.dialogs.confirm()
+
+ assert harness.source.activated == []
+ assert harness.session.writes == []
+ assert not harness.window.visible
+
+ def test_editing_a_closed_dialog_is_refused(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.cancel()
+
+ with pytest.raises(SystemError):
+ harness.type_combination(FREE_COMBINATION)
+
+
+class TestScheme:
+ def test_choosing_the_scheme_already_open_leaves_the_edits_standing(self, harness: Harness) -> None:
+ harness.select(ABOUT_DIALOG)
+ harness.type_combination(FREE_COMBINATION)
+ harness.select_scheme(shipped_scheme().name)
+
+ assert harness.row(ABOUT_DIALOG).combination == FREE_COMBINATION
+
+ def test_an_unknown_scheme_falls_back_to_the_one_the_build_defaults_to(self, harness: Harness) -> None:
+ harness.select_scheme("nonexistent")
+
+ assert harness.window.view_model.scheme == shipped_scheme().name
+
+
+class TestTrade:
+ """Two actions passing keys between them, which is what a displaced holder makes room for."""
+
+ @pytest.fixture(name="traded")
+ def traded_fixture(self, harness: Harness) -> Harness:
+ harness.select(UNDO)
+ harness.type_combination(REDO_COMBINATION)
+ harness.dialogs.confirm()
+ harness.select(REDO)
+ harness.type_combination(UNDO_COMBINATION)
+ return harness
+
+ def test_each_action_arrives_at_the_keys_the_other_left(self, traded: Harness) -> None:
+ assert traded.row(UNDO).combination == REDO_COMBINATION
+ assert traded.row(REDO).combination == UNDO_COMBINATION
+
+ def test_the_traded_keys_reach_the_scheme_put_in_force(self, traded: Harness) -> None:
+ traded.commit()
+
+ assert traded.source.scheme.shortcut(ShortcutId.UNDO).display() == REDO_COMBINATION
+ assert traded.source.scheme.shortcut(ShortcutId.REDO).display() == UNDO_COMBINATION
+
+ def test_a_stored_trade_reopens_on_the_keys_it_stored(self, traded: Harness) -> None:
+ traded.commit()
+ traded.open()
+
+ assert traded.row(UNDO).combination == REDO_COMBINATION
+ assert traded.row(REDO).combination == UNDO_COMBINATION
diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py
index c821a1f5..e811e956 100644
--- a/tests/unit/sampletones_application/coordinators/test_project.py
+++ b/tests/unit/sampletones_application/coordinators/test_project.py
@@ -33,7 +33,10 @@ def project_coordinator() -> ProjectCoordinator:
class TestProjectRestoreSuccess:
- def test_loads_and_keeps_session_pointer(self, project_coordinator: ProjectCoordinator) -> None:
+ def test_loads_and_keeps_session_pointer(
+ self,
+ project_coordinator: ProjectCoordinator,
+ ) -> None:
path = Path("song.stp")
project_coordinator.load_project_safely(path)
@@ -47,12 +50,24 @@ class TestProjectRestoreAbsorbsFailures(BaseTestSuite):
class TestCase(BaseRegularTestCase):
failure: Exception
- test_cases = [
- TestCase(label="invalid_archive", failure=NotAValidArchiveError("corrupt"), expected=None),
- TestCase(label="missing_file", failure=FileNotFoundError("gone"), expected=None),
- ]
+ test_cases = (
+ TestCase(
+ label="invalid_archive",
+ failure=NotAValidArchiveError("corrupt"),
+ expected=None,
+ ),
+ TestCase(
+ label="missing_file",
+ failure=FileNotFoundError("gone"),
+ expected=None,
+ ),
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_restore_clears_session_pointer(
self,
test_case: TestCase,
@@ -66,7 +81,10 @@ def test_restore_clears_session_pointer(
class TestProjectRestorePropagatesUnexpected:
- def test_runtime_error_propagates(self, project_coordinator: ProjectCoordinator) -> None:
+ def test_runtime_error_propagates(
+ self,
+ project_coordinator: ProjectCoordinator,
+ ) -> None:
project_coordinator._project_controller.load.side_effect = RuntimeError("boom")
with pytest.raises(RuntimeError):
@@ -84,21 +102,45 @@ class TestProjectManualLoadSurfacesErrors(BaseTestSuite):
class TestCase(BaseRegularTestCase):
failure: Exception
- test_cases = [
- TestCase(label="invalid_archive", failure=NotAValidArchiveError("corrupt"), expected=None),
- TestCase(label="incorrect_reconstruction", failure=IncorrectReconstructionDataError("bad"), expected=None),
- TestCase(label="invalid_values", failure=InvalidProjectDataValuesError("bad", ValueError("v")), expected=None),
- TestCase(label="missing_file", failure=MissingProjectDataFileError("missing"), expected=None),
+ test_cases = (
+ TestCase(
+ label="invalid_archive",
+ failure=NotAValidArchiveError("corrupt"),
+ expected=None,
+ ),
+ TestCase(
+ label="incorrect_reconstruction",
+ failure=IncorrectReconstructionDataError("bad"),
+ expected=None,
+ ),
+ TestCase(
+ label="invalid_values",
+ failure=InvalidProjectDataValuesError("bad", ValueError("v")),
+ expected=None,
+ ),
+ TestCase(
+ label="missing_file",
+ failure=MissingProjectDataFileError("missing"),
+ expected=None,
+ ),
TestCase(
label="incompatible_version",
- failure=IncompatibleProjectVersionError("mismatch", expected_version="1.0", actual_version="9.0"),
+ failure=IncompatibleProjectVersionError(
+ "mismatch",
+ expected_version="1.0",
+ actual_version="9.0",
+ ),
expected=None,
),
TestCase(label="unhandled", failure=UnhandledProjectError("unhandled"), expected=None),
TestCase(label="os_error", failure=OSError("io"), expected=None),
- ]
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_manual_load_shows_error_dialog(
self,
test_case: TestCase,
diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py
index 80167c75..efe25158 100644
--- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py
+++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py
@@ -36,7 +36,11 @@ def reconstruction_coordinator() -> ReconstructionCoordinator:
)
-def _gating_coordinator(*, unsaved: bool, embedded: bool) -> ReconstructionCoordinator:
+def _gating_coordinator(
+ *,
+ unsaved: bool,
+ embedded: bool,
+) -> ReconstructionCoordinator:
coordinator = ReconstructionCoordinator(
MagicMock(),
MagicMock(),
@@ -55,7 +59,10 @@ def _gating_coordinator(*, unsaved: bool, embedded: bool) -> ReconstructionCoord
class TestReconstructionRestoreSuccess:
- def test_loads_and_keeps_session_pointer(self, reconstruction_coordinator: ReconstructionCoordinator) -> None:
+ def test_loads_and_keeps_session_pointer(
+ self,
+ reconstruction_coordinator: ReconstructionCoordinator,
+ ) -> None:
path = Path("lead.stn")
reconstruction_coordinator.load_reconstruction_safely(path)
@@ -69,7 +76,7 @@ class TestReconstructionRestoreAbsorbsFailures(BaseTestSuite):
class TestCase(BaseRegularTestCase):
failure: Exception
- test_cases = [
+ test_cases = (
TestCase(
label="invalid_values",
failure=InvalidReconstructionValuesError("bad", ValueError("inner")),
@@ -85,9 +92,13 @@ class TestCase(BaseRegularTestCase):
failure=FileNotFoundError("gone"),
expected=None,
),
- ]
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_restore_clears_session_pointer(
self,
test_case: TestCase,
@@ -145,7 +156,10 @@ def test_history_hook_sees_prior_reconstruction_identity(
class TestReconstructionRestorePropagatesUnexpected:
- def test_runtime_error_propagates(self, reconstruction_coordinator: ReconstructionCoordinator) -> None:
+ def test_runtime_error_propagates(
+ self,
+ reconstruction_coordinator: ReconstructionCoordinator,
+ ) -> None:
reconstruction_coordinator._reconstruction_manager.load_reconstruction.side_effect = RuntimeError("boom")
with pytest.raises(RuntimeError):
@@ -161,22 +175,50 @@ class TestCase(BaseRegularTestCase):
embedded: bool
expects_prompt: bool
- test_cases = [
- TestCase(label="standalone_unsaved_prompts", unsaved=True, embedded=False, expects_prompt=True, expected=True),
+ test_cases = (
TestCase(
- label="embedded_unsaved_skips_prompt", unsaved=True, embedded=True, expects_prompt=False, expected=False
+ label="standalone_unsaved_prompts",
+ unsaved=True,
+ embedded=False,
+ expects_prompt=True,
+ expected=True,
),
TestCase(
- label="standalone_saved_skips_prompt", unsaved=False, embedded=False, expects_prompt=False, expected=False
+ label="embedded_unsaved_skips_prompt",
+ unsaved=True,
+ embedded=True,
+ expects_prompt=False,
+ expected=False,
),
TestCase(
- label="embedded_saved_skips_prompt", unsaved=False, embedded=True, expects_prompt=False, expected=False
+ label="standalone_saved_skips_prompt",
+ unsaved=False,
+ embedded=False,
+ expects_prompt=False,
+ expected=False,
),
- ]
+ TestCase(
+ label="embedded_saved_skips_prompt",
+ unsaved=False,
+ embedded=True,
+ expects_prompt=False,
+ expected=False,
+ ),
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
- def test_close_prompts_only_for_standalone_unsaved(self, test_case: TestCase) -> None:
- coordinator = _gating_coordinator(unsaved=test_case.unsaved, embedded=test_case.embedded)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_close_prompts_only_for_standalone_unsaved(
+ self,
+ test_case: TestCase,
+ ) -> None:
+ coordinator = _gating_coordinator(
+ unsaved=test_case.unsaved,
+ embedded=test_case.embedded,
+ )
coordinator.close_with_confirmation()
@@ -187,9 +229,19 @@ def test_close_prompts_only_for_standalone_unsaved(self, test_case: TestCase) ->
coordinator._dialogs.show_save_confirmation.assert_not_called()
coordinator._reconstruction_manager.close_reconstruction.assert_called_once()
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
- def test_load_prompts_only_for_standalone_unsaved(self, test_case: TestCase) -> None:
- coordinator = _gating_coordinator(unsaved=test_case.unsaved, embedded=test_case.embedded)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_load_prompts_only_for_standalone_unsaved(
+ self,
+ test_case: TestCase,
+ ) -> None:
+ coordinator = _gating_coordinator(
+ unsaved=test_case.unsaved,
+ embedded=test_case.embedded,
+ )
path = Path("lead.stn")
coordinator.load_with_confirmation(path)
diff --git a/tests/unit/sampletones_application/coordinators/test_render.py b/tests/unit/sampletones_application/coordinators/test_render.py
new file mode 100644
index 00000000..8e642d44
--- /dev/null
+++ b/tests/unit/sampletones_application/coordinators/test_render.py
@@ -0,0 +1,361 @@
+from pathlib import Path
+from typing import Any, Dict, Final, List, Optional
+from unittest.mock import MagicMock
+
+import pytest
+
+from sampletones_application.coordinators import render as render_module
+from sampletones_application.coordinators.render import SongRenderCoordinator
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.render.logic import SongRenderLogic
+from sampletones_application.services.result import (
+ ServiceCancelled,
+ ServiceError,
+ ServiceSuccess,
+)
+from sampletones_application.utils.file_dialogs.filter import FileFilter
+from sampletones_application.view_model.shared.render import (
+ SongRenderSettings,
+ SongRenderViewModel,
+)
+from sampletones_core.audio.writers import AudioFormat
+from sampletones_core.configs import Config
+from sampletones_shared.types.callback import VoidCallback
+from tests.suite.language import FakeLanguageManager
+from tests.suite.render import FakeRenderService
+
+AUDIO_DIRECTORY: Final[Path] = Path("/home/user/audio")
+PROJECT_NAME: Final[str] = "chiptune"
+CHOSEN: Final[Path] = Path("/home/user/renders/take one.wav")
+
+
+class _WindowRecorder:
+ """Stands in for the render dialog, holding what it was told to show."""
+
+ def __init__(self) -> None:
+ self.view_models: List[SongRenderViewModel] = []
+ self.visible = False
+ self.hides = 0
+ self.on_settings_changed: Any = None
+ self.on_browse: Any = None
+ self.on_render: Any = None
+ self.on_cancel: Any = None
+ self.on_close: Any = None
+
+ def open(self, view_model: SongRenderViewModel) -> None:
+ self.visible = True
+ self.view_models.append(view_model)
+
+ def update_view(self, view_model: SongRenderViewModel) -> None:
+ self.view_models.append(view_model)
+
+ def hide(self) -> None:
+ self.hides += 1
+ self.visible = False
+
+ @property
+ def view(self) -> SongRenderViewModel:
+ assert self.view_models, "A view was expected to reach the window"
+ return self.view_models[-1]
+
+
+class _DialogsRecorder:
+ def __init__(self) -> None:
+ self.paths: List[Dict[str, Any]] = []
+ self.errors: List[Dict[str, Any]] = []
+
+ def show_message_with_path(self, title: str, message: str, path: Path) -> None:
+ self.paths.append({"title": title, "message": message, "path": path})
+
+ def show_error(self, exception: Exception, message: Optional[str] = None) -> None:
+ self.errors.append({"exception": exception, "message": message})
+
+
+class _SaveDialogRecorder:
+ """The OS save dialog as the coordinator asks it, answering with a stated path."""
+
+ def __init__(self) -> None:
+ self.answer: Optional[Path] = CHOSEN
+ self.requests: List[Dict[str, Any]] = []
+
+ def __call__(self, **kwargs: Any) -> Optional[Path]:
+ self.requests.append(kwargs)
+ return self.answer
+
+ @property
+ def filters(self) -> List[FileFilter]:
+ assert self.requests, "A destination was expected to be asked for"
+ return list(self.requests[-1]["filters"])
+
+
+class RenderFixture:
+ """The coordinator over a real render logic, a recording service, and a recorded screen.
+
+ The frame the report waits for is taken as passing when a test asks for it, so the hand-off
+ from the window to the dialog that reports an outcome is walked one step at a time.
+ """
+
+ def __init__(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ *,
+ operation_active: bool = False,
+ ) -> None:
+ project_manager = ProjectManager()
+ project_manager.session.mark_loaded(PROJECT_NAME)
+ self.controller = ProjectController(project_manager)
+ self.session_manager = MagicMock()
+ self.session_manager.get_audio_path.return_value = AUDIO_DIRECTORY
+ self.service = FakeRenderService()
+ self.logic = SongRenderLogic(
+ self.controller,
+ MagicMock(config=Config()),
+ self.session_manager,
+ self.service,
+ language_manager=FakeLanguageManager(), # type: ignore[arg-type]
+ is_operation_active=lambda: operation_active,
+ )
+
+ self.window = _WindowRecorder()
+ self.dialogs = _DialogsRecorder()
+ self.save_dialog = _SaveDialogRecorder()
+ self.activity = 0
+ self.pending: List[VoidCallback] = []
+
+ monkeypatch.setattr(render_module, "save_file_dialog", self.save_dialog)
+ monkeypatch.setattr(
+ render_module.FrameCallbackManager,
+ "set_frame_callback",
+ lambda callback, frame_count=1: self.pending.append(callback),
+ )
+
+ self.coordinator = SongRenderCoordinator(
+ self.logic,
+ window=self.window, # type: ignore[arg-type]
+ dialogs=self.dialogs, # type: ignore[arg-type]
+ language_manager=FakeLanguageManager(), # type: ignore[arg-type]
+ on_activity_changed=self._on_activity_changed,
+ )
+
+ def _on_activity_changed(self) -> None:
+ self.activity += 1
+
+ def open(self) -> None:
+ self.coordinator.open()
+
+ def edit(self, settings: SongRenderSettings) -> None:
+ self.window.on_settings_changed(settings)
+
+ def browse(self) -> None:
+ self.window.on_browse()
+
+ def start(self) -> None:
+ self.window.on_render()
+
+ def stop(self) -> None:
+ self.window.on_cancel()
+
+ def close(self) -> None:
+ self.window.on_close()
+
+ def advance_frame(self) -> None:
+ """Runs what was waiting for the frame the window left the screen in."""
+ pending = self.pending
+ self.pending = []
+ for callback in pending:
+ callback()
+
+
+@pytest.fixture
+def render(monkeypatch: pytest.MonkeyPatch) -> RenderFixture:
+ return RenderFixture(monkeypatch)
+
+
+class TestOfferingTheRender:
+ def test_the_dialog_opens_over_the_render_being_set_up(self, render: RenderFixture) -> None:
+ render.open()
+
+ assert render.window.visible
+ assert render.window.view.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav"
+
+ def test_opening_claims_the_application(self, render: RenderFixture) -> None:
+ render.open()
+
+ assert render.coordinator.is_active
+ assert render.activity == 1
+
+ def test_another_exclusive_operation_leaves_the_dialog_closed(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ render = RenderFixture(monkeypatch, operation_active=True)
+
+ render.open()
+
+ assert not render.window.visible
+ assert not render.window.view_models
+ assert not render.activity
+
+ def test_closing_the_setup_hands_the_application_back(self, render: RenderFixture) -> None:
+ render.open()
+
+ render.close()
+
+ assert render.window.hides == 1
+ assert not render.coordinator.is_active
+ assert render.activity == 2
+
+
+class TestTheEditsTheDialogReports:
+ def test_an_edit_comes_back_reconciled(self, render: RenderFixture) -> None:
+ render.open()
+
+ render.edit(render.window.view.settings.with_format(AudioFormat.MP3))
+
+ assert render.window.view.spec.audio_format == AudioFormat.MP3
+ assert render.window.view.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.mp3"
+
+ def test_the_running_render_reaches_the_window(self, render: RenderFixture) -> None:
+ render.open()
+ render.start()
+
+ render.service.emit(ServiceSuccess(value=CHOSEN))
+
+ assert render.window.view.progress == 1.0
+
+
+class TestAskingForTheDestination:
+ def test_the_file_is_asked_for_from_where_it_stands(self, render: RenderFixture) -> None:
+ render.open()
+
+ render.browse()
+
+ request = render.save_dialog.requests[-1]
+ assert request["initial_directory"] == AUDIO_DIRECTORY
+ assert request["default_filename"] == f"{PROJECT_NAME}.wav"
+
+ def test_the_type_offered_is_the_container_standing(self, render: RenderFixture) -> None:
+ render.open()
+ render.edit(render.window.view.settings.with_format(AudioFormat.MP3))
+
+ render.browse()
+
+ assert [file_filter.extensions for file_filter in render.save_dialog.filters] == [(".mp3",)]
+
+ def test_a_chosen_file_becomes_the_one_the_render_writes(self, render: RenderFixture) -> None:
+ render.open()
+
+ render.browse()
+
+ assert render.window.view.destination == CHOSEN
+ render.session_manager.set_audio_path.assert_called_once_with(CHOSEN)
+
+ def test_a_dismissed_dialog_leaves_the_file_alone(self, render: RenderFixture) -> None:
+ render.open()
+ render.save_dialog.answer = None
+ standing = render.window.view.destination
+
+ render.browse()
+
+ assert render.window.view.destination == standing
+
+
+class TestDrivingTheRender:
+ def test_the_start_reaches_the_service(self, render: RenderFixture) -> None:
+ render.open()
+
+ render.start()
+
+ assert render.service.request.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav"
+
+ def test_the_stop_reaches_the_service(self, render: RenderFixture) -> None:
+ render.open()
+ render.start()
+
+ render.stop()
+
+ assert render.service.cancels == 1
+
+ def test_exit_winds_a_running_render_down(self, render: RenderFixture) -> None:
+ render.open()
+ render.start()
+
+ render.coordinator.cleanup()
+
+ assert render.service.shutdowns == 1
+
+
+class TestReportingTheOutcome:
+ def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture) -> None:
+ render.open()
+ render.start()
+
+ render.service.emit(ServiceSuccess(value=CHOSEN))
+ render.advance_frame()
+
+ assert render.dialogs.paths == [
+ {
+ "title": "settings.render.title.rendered",
+ "message": "settings.render.message.rendered",
+ "path": CHOSEN,
+ }
+ ]
+
+ def test_the_report_waits_for_the_screen_the_window_left(self, render: RenderFixture) -> None:
+ render.open()
+ render.start()
+
+ render.service.emit(ServiceSuccess(value=CHOSEN))
+
+ assert render.window.hides == 1
+ assert not render.dialogs.paths
+
+ def test_a_failed_render_reports_what_went_wrong(self, render: RenderFixture) -> None:
+ render.open()
+ render.start()
+ failure = OSError("no room on the device")
+
+ render.service.emit(ServiceError(exception=failure))
+ render.advance_frame()
+
+ assert render.dialogs.errors == [
+ {
+ "exception": failure,
+ "message": "settings.render.message.render_failed",
+ }
+ ]
+
+ def test_a_stopped_render_closes_without_a_report(self, render: RenderFixture) -> None:
+ render.open()
+ render.start()
+ render.stop()
+
+ render.service.emit(ServiceCancelled())
+ render.advance_frame()
+
+ assert render.window.hides == 1
+ assert not render.dialogs.paths
+ assert not render.dialogs.errors
+
+ @pytest.mark.parametrize(
+ "outcome",
+ [
+ ServiceSuccess(value=CHOSEN),
+ ServiceError(exception=OSError("no room on the device")),
+ ServiceCancelled(),
+ ],
+ ids=["completed", "failed", "cancelled"],
+ )
+ def test_every_outcome_hands_the_application_back(
+ self,
+ render: RenderFixture,
+ outcome: Any,
+ ) -> None:
+ render.open()
+ render.start()
+
+ render.service.emit(outcome)
+
+ assert not render.coordinator.is_active
+ assert not render.window.visible
diff --git a/tests/unit/sampletones_application/layout/__init__.py b/tests/unit/sampletones_application/layout/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/layout/behavior/__init__.py b/tests/unit/sampletones_application/layout/behavior/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/layout/behavior/test_display.py b/tests/unit/sampletones_application/layout/behavior/test_display.py
new file mode 100644
index 00000000..46688029
--- /dev/null
+++ b/tests/unit/sampletones_application/layout/behavior/test_display.py
@@ -0,0 +1,95 @@
+from typing import Any, Dict, List
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_application.config.session.application.display import DEFAULT_MAX_FPS
+from sampletones_application.layout.behavior.behavior import BehaviorConfig
+from sampletones_application.layout.behavior.display import DisplayBehavior
+from sampletones_application.paths import BEHAVIOR_DIRECTORY
+from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution
+from sampletones_shared.utils.serialization import load_yaml_model
+
+RESOLUTIONS: List[Dict[str, int]] = [
+ {"width": 1024, "height": 768},
+ {"width": 1280, "height": 720},
+ {"width": 1280, "height": 800},
+]
+
+FRAME_RATES: List[int] = [UNLIMITED_FRAME_RATE, 30, 60]
+
+COUNTDOWN_SECONDS: float = 10.0
+
+
+def behavior(**overrides: Any) -> DisplayBehavior:
+ return DisplayBehavior.model_validate(
+ {
+ "resolutions": RESOLUTIONS,
+ "frame_rates": FRAME_RATES,
+ "revert_countdown_seconds": COUNTDOWN_SECONDS,
+ **overrides,
+ }
+ )
+
+
+class TestDisplayBehavior:
+ def test_the_offered_sizes_are_read_as_resolutions(self) -> None:
+ assert behavior().resolutions[0] == Resolution(width=1024, height=768)
+
+ @pytest.mark.parametrize(
+ "resolutions",
+ [
+ [{"width": 1280, "height": 800}, {"width": 1024, "height": 768}],
+ [{"width": 1280, "height": 800}, {"width": 1280, "height": 720}],
+ [{"width": 1024, "height": 768}, {"width": 1024, "height": 768}],
+ ],
+ ids=["descending", "same_width_descending_height", "repeated"],
+ )
+ def test_sizes_out_of_ascending_order_raise(self, resolutions: List[Dict[str, int]]) -> None:
+ """A combo shows the file's order, so the order it declares is the order it is read in."""
+ with pytest.raises(ValidationError):
+ behavior(resolutions=resolutions)
+
+ @pytest.mark.parametrize(
+ "frame_rates",
+ [[60, 30], [30, 30]],
+ ids=["descending", "repeated"],
+ )
+ def test_rates_out_of_ascending_order_raise(self, frame_rates: List[int]) -> None:
+ with pytest.raises(ValidationError):
+ behavior(frame_rates=frame_rates)
+
+ @pytest.mark.parametrize("field", ["resolutions", "frame_rates"])
+ def test_an_empty_list_raises(self, field: str) -> None:
+ """A combo offers at least one entry to select."""
+ with pytest.raises(ValidationError):
+ behavior(**{field: []})
+
+ def test_a_size_without_extent_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ behavior(resolutions=[{"width": 0, "height": 768}])
+
+ @pytest.mark.parametrize("seconds", [0.0, -1.0])
+ def test_a_countdown_without_time_raises(self, seconds: float) -> None:
+ """A window mode nobody confirms is given time to be judged in."""
+ with pytest.raises(ValidationError):
+ behavior(revert_countdown_seconds=seconds)
+
+
+@pytest.fixture(scope="module")
+def display() -> DisplayBehavior:
+ return load_yaml_model(BEHAVIOR_DIRECTORY / "general.yaml", BehaviorConfig).display
+
+
+class TestShippedDisplayBehavior:
+ def test_the_shipped_catalog_loads(self, display: DisplayBehavior) -> None:
+ assert display.resolutions and display.frame_rates
+
+ def test_the_unlimited_setting_is_offered(self, display: DisplayBehavior) -> None:
+ assert UNLIMITED_FRAME_RATE in display.frame_rates
+
+ def test_the_default_frame_rate_is_one_of_the_offered_rates(self, display: DisplayBehavior) -> None:
+ assert DEFAULT_MAX_FPS in display.frame_rates
+
+ def test_a_window_mode_is_given_time_to_be_judged_in(self, display: DisplayBehavior) -> None:
+ assert display.revert_countdown_seconds > 0.0
diff --git a/tests/unit/sampletones_application/layout/test_about_dialog.py b/tests/unit/sampletones_application/layout/test_about_dialog.py
new file mode 100644
index 00000000..99296cca
--- /dev/null
+++ b/tests/unit/sampletones_application/layout/test_about_dialog.py
@@ -0,0 +1,7 @@
+from sampletones_application.layout.general.dialogs.about import AboutDialogLayout
+
+
+class TestTheRoomTheTextTakes:
+ def test_the_text_wraps_in_what_the_mark_leaves(self) -> None:
+ layout = AboutDialogLayout(width=480, height=210, logo=72, padding=40)
+ assert layout.text_wrap == 368
diff --git a/tests/unit/sampletones_application/layout/test_fonts.py b/tests/unit/sampletones_application/layout/test_fonts.py
new file mode 100644
index 00000000..451768a9
--- /dev/null
+++ b/tests/unit/sampletones_application/layout/test_fonts.py
@@ -0,0 +1,20 @@
+from typing import Final
+
+from sampletones_application.layout.fonts import FontScale, FontsLayout, Step, Typeface
+
+SANS: Final[FontScale] = FontScale(small=11, medium=12, large=13, title=14)
+MONO: Final[FontScale] = FontScale(small=21, medium=22, large=23, title=24)
+ICON: Final[FontScale] = FontScale(small=31, medium=32, large=33, title=34)
+
+
+class TestTheSizeLadder:
+ """Every rung a font asks for answers with a size, on the typeface asking for it."""
+
+ def test_every_rung_answers_with_the_size_the_scale_states(self) -> None:
+ assert [SANS.step(step) for step in Step] == [11, 12, 13, 14]
+
+ def test_a_typeface_answers_from_a_ladder_of_its_own(self) -> None:
+ layout = FontsLayout(scale=1, sans=SANS, mono=MONO, icon=ICON)
+
+ assert layout.size_for(Typeface.MONO, Step.TITLE) == 24
+ assert layout.size_for(Typeface.SANS, Step.TITLE) == 14
diff --git a/tests/unit/sampletones_application/logic/history/test_action_labels.py b/tests/unit/sampletones_application/logic/history/test_action_labels.py
index a8496f57..ec94cc15 100644
--- a/tests/unit/sampletones_application/logic/history/test_action_labels.py
+++ b/tests/unit/sampletones_application/logic/history/test_action_labels.py
@@ -1,9 +1,7 @@
import pytest
-from sampletones_application.categories.elements.sequencer import (
- SequencerHistoryActionElements,
- SequencerHistoryElements,
-)
+from sampletones_application.categories.abstract import AbstractElement
+from sampletones_application.categories.elements.sequencer import SequencerHistoryElements
from sampletones_application.categories.hierarchy import Page, Panel, TextType
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.logic.history.action import HistoryAction
@@ -16,9 +14,9 @@ def language_manager() -> LanguageManager:
return LanguageManager(LANG_EN)
-class TestActionLabelParity:
- def test_actions_and_elements_share_the_same_values(self) -> None:
- assert {member.value for member in HistoryAction} == {member.value for member in SequencerHistoryActionElements}
+class TestActionLabels:
+ def test_an_action_is_the_element_its_label_is_looked_up_by(self) -> None:
+ assert issubclass(HistoryAction, AbstractElement)
@pytest.mark.parametrize("action", list(HistoryAction), ids=lambda action: action.value)
def test_every_action_resolves_a_label(
@@ -30,7 +28,7 @@ def test_every_action_resolves_a_label(
Page.SEQUENCER,
Panel.HISTORY,
TextType.LABEL,
- SequencerHistoryActionElements(action.value),
+ action,
]
assert label
diff --git a/tests/unit/sampletones_application/logic/history/test_fingerprint.py b/tests/unit/sampletones_application/logic/history/test_fingerprint.py
index 79845dd6..a1f08ffd 100644
--- a/tests/unit/sampletones_application/logic/history/test_fingerprint.py
+++ b/tests/unit/sampletones_application/logic/history/test_fingerprint.py
@@ -4,9 +4,12 @@
from sampletones_application.logic.history.action import HistoryAction
from sampletones_application.logic.history.errors import HistoryIntegrityError
-from sampletones_application.logic.history.fingerprint import ReconstructionHashCache, fingerprint_project
-from sampletones_application.logic.history.snapshot import snapshot_project
+from sampletones_application.logic.history.fingerprint import (
+ ReconstructionHashCache,
+ fingerprint_project,
+)
from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.shared.project_source import snapshot_project
from sampletones_core.reconstructions import Reconstruction
from sampletones_shared.utils.serialization import hash_model
from tests.conftest import ReconstructionFactory
@@ -35,8 +38,14 @@ def test_fingerprint_stable_across_snapshot(
assert fingerprint_project(snapshot, reconstruction_hash=hash_model) == original
- def test_fingerprint_changes_with_state(self, project_controller: ProjectController) -> None:
- before = fingerprint_project(project_controller.project, reconstruction_hash=hash_model)
+ def test_fingerprint_changes_with_state(
+ self,
+ project_controller: ProjectController,
+ ) -> None:
+ before = fingerprint_project(
+ project_controller.project,
+ reconstruction_hash=hash_model,
+ )
project_controller.set_tempo(project_controller.project.settings.tempo + 7)
@@ -107,6 +116,7 @@ def test_capture_memoized_restore_verified_fresh(
controller, history = history_factory()
with history.transaction(HistoryAction.ADD_SAMPLE):
controller.add_sample(reconstruction_factory(), name="lead")
+
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(150)
@@ -132,6 +142,7 @@ def test_restore_raises_on_mutated_snapshot_shared_state(
controller, history = history_factory()
with history.transaction(HistoryAction.ADD_SAMPLE):
sample = controller.add_sample(reconstruction_factory(), name="lead")
+
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(150)
@@ -148,8 +159,10 @@ def test_eviction_prunes_cache_to_retained_reconstructions(
controller, history = history_factory(budget=2)
with history.transaction(HistoryAction.ADD_SAMPLE):
sample = controller.add_sample(reconstruction_factory(), name="lead")
+
with history.transaction(HistoryAction.REMOVE_SAMPLE):
controller.remove_sample(sample.id)
+
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(150)
diff --git a/tests/unit/sampletones_application/logic/history/test_manager.py b/tests/unit/sampletones_application/logic/history/test_manager.py
index 2878c246..dc6cc06f 100644
--- a/tests/unit/sampletones_application/logic/history/test_manager.py
+++ b/tests/unit/sampletones_application/logic/history/test_manager.py
@@ -5,12 +5,18 @@
from sampletones_application.logic.history.action import HistoryAction
from sampletones_application.logic.history.errors import UntrackedMutationError
-from sampletones_application.view_model.shared.history import HistoryDetailRole, HistoryDetailSegment
+from sampletones_application.view_model.shared.history import (
+ HistoryDetailRole,
+ HistoryDetailSegment,
+)
from tests.unit.sampletones_application.logic.history.conftest import HistoryFactory
class TestBaseline:
- def test_reset_seeds_single_baseline(self, history_factory: HistoryFactory) -> None:
+ def test_reset_seeds_single_baseline(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
_, history = history_factory()
assert len(history.entries) == 1
@@ -18,7 +24,10 @@ def test_reset_seeds_single_baseline(self, history_factory: HistoryFactory) -> N
assert history.can_undo is False
assert history.can_redo is False
- def test_reset_without_a_project_empties_the_stack(self, history_factory: HistoryFactory) -> None:
+ def test_reset_without_a_project_empties_the_stack(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(150)
@@ -32,7 +41,10 @@ def test_reset_without_a_project_empties_the_stack(self, history_factory: Histor
class TestGrouping:
- def test_single_edit_commits_one_entry(self, history_factory: HistoryFactory) -> None:
+ def test_single_edit_commits_one_entry(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO):
@@ -42,7 +54,10 @@ def test_single_edit_commits_one_entry(self, history_factory: HistoryFactory) ->
assert history.cursor == 1
assert history.can_undo is True
- def test_compound_edit_commits_one_entry(self, history_factory: HistoryFactory) -> None:
+ def test_compound_edit_commits_one_entry(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO):
@@ -51,7 +66,10 @@ def test_compound_edit_commits_one_entry(self, history_factory: HistoryFactory)
assert len(history.entries) == 2
- def test_transaction_without_mutation_records_nothing(self, history_factory: HistoryFactory) -> None:
+ def test_transaction_without_mutation_records_nothing(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
_, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO):
@@ -59,7 +77,10 @@ def test_transaction_without_mutation_records_nothing(self, history_factory: His
assert len(history.entries) == 1
- def test_nested_transactions_coalesce_into_one_entry(self, history_factory: HistoryFactory) -> None:
+ def test_nested_transactions_coalesce_into_one_entry(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.ADD_SAMPLE):
@@ -71,6 +92,22 @@ def test_nested_transactions_coalesce_into_one_entry(self, history_factory: Hist
assert len(history.entries) == 2
assert history.entries[-1].action is HistoryAction.ADD_SAMPLE
+ def test_batched_edit_commits_one_entry(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
+ controller, history = history_factory()
+ original = controller.project.settings.tempo
+
+ with history.transaction(HistoryAction.SET_TEMPO), controller.batch():
+ controller.set_tempo(150)
+ controller.set_speed(4)
+
+ assert len(history.entries) == 2
+
+ history.undo()
+ assert controller.project.settings.tempo == original
+
def test_exception_inside_transaction_commits_partial_gesture(
self,
history_factory: HistoryFactory,
@@ -78,10 +115,9 @@ def test_exception_inside_transaction_commits_partial_gesture(
controller, history = history_factory()
original = controller.project.settings.tempo
- with pytest.raises(RuntimeError):
- with history.transaction(HistoryAction.SET_TEMPO):
- controller.set_tempo(150)
- raise RuntimeError("boom")
+ with pytest.raises(RuntimeError), history.transaction(HistoryAction.SET_TEMPO):
+ controller.set_tempo(150)
+ raise RuntimeError("boom")
assert len(history.entries) == 2
assert controller.project.settings.tempo == 150
@@ -91,7 +127,10 @@ def test_exception_inside_transaction_commits_partial_gesture(
class TestCoalescing:
- def test_same_action_and_target_replaces_top_entry(self, history_factory: HistoryFactory) -> None:
+ def test_same_action_and_target_replaces_top_entry(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
original = controller.project.settings.tempo
@@ -119,11 +158,15 @@ def test_different_target_appends(self, history_factory: HistoryFactory) -> None
assert len(history.entries) == 3
- def test_different_action_appends(self, history_factory: HistoryFactory) -> None:
+ def test_different_action_appends(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO, coalesce=()):
controller.set_tempo(150)
+
with history.transaction(HistoryAction.SET_SPEED, coalesce=()):
controller.set_speed(4)
@@ -142,45 +185,70 @@ def test_restore_breaks_run(self, history_factory: HistoryFactory) -> None:
assert len(history.entries) == 3
assert controller.project.settings.tempo == 160
- def test_intervening_gesture_breaks_run(self, history_factory: HistoryFactory) -> None:
+ def test_intervening_gesture_breaks_run(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO, coalesce=()):
controller.set_tempo(150)
+
with history.transaction(HistoryAction.SET_SPEED):
controller.set_speed(4)
+
with history.transaction(HistoryAction.SET_TEMPO, coalesce=()):
controller.set_tempo(160)
assert len(history.entries) == 4
- def test_empty_gesture_keeps_run(self, history_factory: HistoryFactory) -> None:
+ def test_empty_gesture_keeps_run(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO, coalesce=()):
controller.set_tempo(150)
+
with history.transaction(HistoryAction.SET_SPEED):
pass
+
with history.transaction(HistoryAction.SET_TEMPO, coalesce=()):
controller.set_tempo(160)
assert len(history.entries) == 2
- def test_replacement_refreshes_detail(self, history_factory: HistoryFactory) -> None:
+ def test_replacement_refreshes_detail(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
first = (HistoryDetailSegment(text="150", role=HistoryDetailRole.VALUE),)
second = (HistoryDetailSegment(text="160", role=HistoryDetailRole.VALUE),)
- with history.transaction(HistoryAction.SET_TEMPO, detail=first, coalesce=()):
+ with history.transaction(
+ HistoryAction.SET_TEMPO,
+ detail=first,
+ coalesce=(),
+ ):
controller.set_tempo(150)
- with history.transaction(HistoryAction.SET_TEMPO, detail=second, coalesce=()):
+
+ with history.transaction(
+ HistoryAction.SET_TEMPO,
+ detail=second,
+ coalesce=(),
+ ):
controller.set_tempo(160)
assert history.entries[-1].detail == second
class TestReversibility:
- def test_undo_then_redo_restores_state(self, history_factory: HistoryFactory) -> None:
+ def test_undo_then_redo_restores_state(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
original = controller.project.settings.tempo
@@ -193,7 +261,10 @@ def test_undo_then_redo_restores_state(self, history_factory: HistoryFactory) ->
history.redo()
assert controller.project.settings.tempo == original + 10
- def test_arbitrary_composition_reproduces_each_index(self, history_factory: HistoryFactory) -> None:
+ def test_arbitrary_composition_reproduces_each_index(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
tempos = [110, 120, 130, 140]
for tempo in tempos:
@@ -203,18 +274,24 @@ def test_arbitrary_composition_reproduces_each_index(self, history_factory: Hist
# Strict verification raises on any divergence; the walk exercises many paths.
for _ in range(3):
history.undo()
+
for _ in range(2):
history.redo()
+
history.undo()
history.jump_to(len(history.entries) - 1)
assert controller.project.settings.tempo == tempos[-1]
- def test_new_edit_truncates_redo_branch(self, history_factory: HistoryFactory) -> None:
+ def test_new_edit_truncates_redo_branch(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(150)
+
with history.transaction(HistoryAction.SET_SPEED):
controller.set_speed(6)
@@ -225,10 +302,14 @@ def test_new_edit_truncates_redo_branch(self, history_factory: HistoryFactory) -
assert history.can_redo is False
assert controller.project.settings.tempo == 199
- def test_jump_to_out_of_range_or_current_is_ignored(self, history_factory: HistoryFactory) -> None:
+ def test_jump_to_out_of_range_or_current_is_ignored(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(150)
+
notifications: List[int] = []
history.on_history_changed = lambda: notifications.append(history.cursor)
@@ -242,24 +323,33 @@ def test_jump_to_out_of_range_or_current_is_ignored(self, history_factory: Histo
class TestSavedCursor:
- def test_undo_to_clean_baseline_clears_dirty(self, history_factory: HistoryFactory) -> None:
+ def test_undo_to_clean_baseline_clears_dirty(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(150)
+
assert controller.is_dirty is True
history.undo()
assert controller.is_dirty is False
- def test_undo_to_save_point_clears_dirty(self, history_factory: HistoryFactory) -> None:
+ def test_undo_to_save_point_clears_dirty(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(150)
+
history.mark_saved()
with history.transaction(HistoryAction.SET_SPEED):
controller.set_speed(4)
+
assert controller.is_dirty is True
history.undo()
@@ -285,7 +375,10 @@ def test_save_hook_marks_the_current_cursor(
history.undo()
assert controller.is_dirty is False
- def test_truncating_the_saved_branch_keeps_dirty(self, history_factory: HistoryFactory) -> None:
+ def test_truncating_the_saved_branch_keeps_dirty(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO):
@@ -300,14 +393,19 @@ def test_truncating_the_saved_branch_keeps_dirty(self, history_factory: HistoryF
history.redo()
assert controller.is_dirty is True
- def test_eviction_shifts_the_saved_cursor(self, history_factory: HistoryFactory) -> None:
+ def test_eviction_shifts_the_saved_cursor(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory(budget=3)
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(100)
+
history.mark_saved()
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(101)
+
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(102)
@@ -317,21 +415,29 @@ def test_eviction_shifts_the_saved_cursor(self, history_factory: HistoryFactory)
assert controller.project.settings.tempo == 100
assert controller.is_dirty is False
- def test_evicting_the_saved_entry_keeps_dirty(self, history_factory: HistoryFactory) -> None:
+ def test_evicting_the_saved_entry_keeps_dirty(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory(budget=2)
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(100)
+
history.mark_saved()
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(101)
+
with history.transaction(HistoryAction.SET_TEMPO):
controller.set_tempo(102)
history.undo()
assert controller.is_dirty is True
- def test_coalescing_never_replaces_the_saved_entry(self, history_factory: HistoryFactory) -> None:
+ def test_coalescing_never_replaces_the_saved_entry(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory()
with history.transaction(HistoryAction.SET_TEMPO, coalesce=()):
@@ -348,13 +454,29 @@ def test_coalescing_never_replaces_the_saved_entry(self, history_factory: Histor
class TestCompleteness:
- def test_untracked_mutation_raises_under_strict(self, history_factory: HistoryFactory) -> None:
+ def test_untracked_mutation_raises_under_strict(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, _ = history_factory(strict=True)
with pytest.raises(UntrackedMutationError):
controller.set_tempo(120)
- def test_untracked_mutation_self_heals_when_lenient(self, history_factory: HistoryFactory) -> None:
+ def test_batched_mutation_outside_a_transaction_raises_under_strict(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
+ """A batch defers the notifications a gesture raises, never the mutations it records."""
+ controller, _ = history_factory(strict=True)
+
+ with pytest.raises(UntrackedMutationError), controller.batch():
+ controller.set_tempo(120)
+
+ def test_untracked_mutation_self_heals_when_lenient(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory(strict=False)
controller.set_tempo(120)
@@ -364,7 +486,10 @@ def test_untracked_mutation_self_heals_when_lenient(self, history_factory: Histo
class TestBudget:
- def test_oldest_entries_are_evicted(self, history_factory: HistoryFactory) -> None:
+ def test_oldest_entries_are_evicted(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory(budget=3)
for tempo in range(100, 105):
@@ -374,7 +499,10 @@ def test_oldest_entries_are_evicted(self, history_factory: HistoryFactory) -> No
assert len(history.entries) == 3
assert history.cursor == 2
- def test_navigation_after_eviction_stays_valid(self, history_factory: HistoryFactory) -> None:
+ def test_navigation_after_eviction_stays_valid(
+ self,
+ history_factory: HistoryFactory,
+ ) -> None:
controller, history = history_factory(budget=3)
for tempo in range(100, 105):
diff --git a/tests/unit/sampletones_application/logic/history/test_snapshot.py b/tests/unit/sampletones_application/logic/history/test_snapshot.py
deleted file mode 100644
index 4e15bcb7..00000000
--- a/tests/unit/sampletones_application/logic/history/test_snapshot.py
+++ /dev/null
@@ -1,27 +0,0 @@
-from typing import Callable
-
-from sampletones_application.logic.history.snapshot import snapshot_project
-from sampletones_application.logic.project.controller import ProjectController
-from sampletones_core.reconstructions import Reconstruction
-
-
-class TestSnapshotIndependence:
- def test_light_structure_is_deep_copied(self, project_controller: ProjectController) -> None:
- project_controller.set_tempo(120)
-
- snapshot = snapshot_project(project_controller.project)
- project_controller.set_tempo(200)
-
- assert snapshot.settings.tempo == 120
- assert snapshot.song is not project_controller.project.song
-
- def test_reconstruction_audio_is_shared(
- self,
- project_controller: ProjectController,
- reconstruction_factory: Callable[[], Reconstruction],
- ) -> None:
- sample = project_controller.add_sample(reconstruction_factory(), name="lead")
-
- snapshot = snapshot_project(project_controller.project)
-
- assert snapshot.samples[sample.id].reconstruction is sample.reconstruction
diff --git a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py
index 4a7b0e1d..e1ac1669 100644
--- a/tests/unit/sampletones_application/logic/instruction/test_library_logic.py
+++ b/tests/unit/sampletones_application/logic/instruction/test_library_logic.py
@@ -86,7 +86,8 @@ def _load_logic(*, load_error: Exception) -> LibraryLogic:
class TestLoadLibraryTail:
"""The load pipeline wraps every unclassified deserialize failure in a ``LoadLibraryError``
subtype, so the ladder's tail reports those through ``on_load_error`` with the generic
- message; a failure outside the load contract is a bug and propagates. Both paths unlock."""
+ message; a failure outside the load contract is a bug and propagates. Both paths unlock.
+ """
def test_unclassified_load_error_reports_the_generic_message(self) -> None:
error = UnhandledLibraryError("wrapped")
@@ -125,7 +126,10 @@ class TestLoadLibrarySurfacesConcreteErrors:
[
(OSError("io"), FILE_LOAD_ERROR_KEY),
(InvalidMetadataError("bad metadata"), INVALID_METADATA_KEY),
- (InvalidLibraryDataValuesError("bad values", ValueError("v")), INVALID_DATA_VALUES_KEY),
+ (
+ InvalidLibraryDataValuesError("bad values", ValueError("v")),
+ INVALID_DATA_VALUES_KEY,
+ ),
(InvalidLibraryDataError("bad data"), INVALID_DATA_KEY),
(DeserializationError("bad bytes"), DESERIALIZATION_ERROR_KEY),
(LoadLibraryError("unclassified"), LOAD_ERROR_KEY),
diff --git a/tests/unit/sampletones_application/logic/main/test_converter.py b/tests/unit/sampletones_application/logic/main/test_converter.py
index 468c4a8a..4fc8ddac 100644
--- a/tests/unit/sampletones_application/logic/main/test_converter.py
+++ b/tests/unit/sampletones_application/logic/main/test_converter.py
@@ -4,7 +4,10 @@
import pytest
-from sampletones_application.logic.main.converter import ConversionSuccess, ConverterLogic
+from sampletones_application.logic.main.converter import (
+ ConversionSuccess,
+ ConverterLogic,
+)
from sampletones_application.view_model.main.converter import (
ACTIVE_PHASES,
ConversionPhase,
@@ -121,7 +124,11 @@ class TestActivePhases:
covering the WAITING preparation that runs before the service starts."""
@pytest.mark.parametrize("phase", sorted(ACTIVE_PHASES, key=str))
- def test_active_during_non_terminal_phases(self, converter_logic: ConverterLogic, phase: ConversionPhase) -> None:
+ def test_active_during_non_terminal_phases(
+ self,
+ converter_logic: ConverterLogic,
+ phase: ConversionPhase,
+ ) -> None:
converter_logic._phase = phase
assert converter_logic.is_active is True
@@ -134,7 +141,11 @@ def test_active_during_non_terminal_phases(self, converter_logic: ConverterLogic
ConversionPhase.FAILED,
],
)
- def test_inactive_when_idle_or_terminal(self, converter_logic: ConverterLogic, phase: ConversionPhase) -> None:
+ def test_inactive_when_idle_or_terminal(
+ self,
+ converter_logic: ConverterLogic,
+ phase: ConversionPhase,
+ ) -> None:
converter_logic._phase = phase
assert converter_logic.is_active is False
@@ -146,9 +157,13 @@ def _last_view_model(converter_logic: ConverterLogic) -> ConverterViewModel:
class TestActionLabel:
"""The one action button's label is a projection of converter state, composed where the display
strings are resolved (the logic layer) rather than glued together in the panel: it names the
- selected input while idle and reads the cancel label once a conversion holds resources."""
+ selected input while idle and reads the cancel label once a conversion holds resources.
+ """
- def test_idle_file_label_names_the_selected_file(self, converter_logic: ConverterLogic) -> None:
+ def test_idle_file_label_names_the_selected_file(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
converter_logic._is_file = True
converter_logic._input_path = Path("/audio/kick.wav")
@@ -156,7 +171,10 @@ def test_idle_file_label_names_the_selected_file(self, converter_logic: Converte
assert _last_view_model(converter_logic).action_label == "Convert sample: kick.wav"
- def test_idle_directory_label_uses_the_directory_variant(self, converter_logic: ConverterLogic) -> None:
+ def test_idle_directory_label_uses_the_directory_variant(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
converter_logic._is_file = False
converter_logic._input_path = Path("/audio/drums")
@@ -164,14 +182,20 @@ def test_idle_directory_label_uses_the_directory_variant(self, converter_logic:
assert _last_view_model(converter_logic).action_label == "Convert directory: drums"
- def test_idle_without_input_reads_the_bare_convert_label(self, converter_logic: ConverterLogic) -> None:
+ def test_idle_without_input_reads_the_bare_convert_label(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
converter_logic._input_path = None
converter_logic.emit_initial_view()
assert _last_view_model(converter_logic).action_label == "Convert sample"
- def test_active_conversion_reads_the_cancel_label(self, converter_logic: ConverterLogic) -> None:
+ def test_active_conversion_reads_the_cancel_label(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
converter_logic._input_path = Path("/audio/kick.wav")
with patch("sampletones_application.logic.main.converter.CallbackQueue.add"):
@@ -184,7 +208,10 @@ class TestStartConversionGate:
"""A conversion refuses to start while another exclusive operation is active, so two heavy
processes cannot run at once."""
- def test_refuses_when_an_operation_is_active(self, converter_logic: ConverterLogic) -> None:
+ def test_refuses_when_an_operation_is_active(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
converter_logic._is_operation_active = lambda: True
converter_logic.start_conversion()
@@ -193,7 +220,10 @@ def test_refuses_when_an_operation_is_active(self, converter_logic: ConverterLog
converter_logic.generate_library.assert_not_called()
assert converter_logic._phase == ConversionPhase.IDLE
- def test_proceeds_when_nothing_is_active(self, converter_logic: ConverterLogic) -> None:
+ def test_proceeds_when_nothing_is_active(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
with patch("sampletones_application.logic.main.converter.CallbackQueue.add"):
converter_logic.start_conversion()
@@ -222,20 +252,28 @@ def test_path_failure_reports_error_and_aborts(
"sampletones_application.logic.main.converter.get_output_path",
side_effect=error,
):
- result = converter_logic._assign_paths(Path("/tmp/input.wav"), MagicMock())
+ result = converter_logic._assign_paths(
+ Path("/tmp/input.wav"),
+ MagicMock(),
+ )
assert result is False
converter_logic.on_error.assert_called_once_with(error)
- def test_unexpected_failure_propagates(self, converter_logic: ConverterLogic) -> None:
+ def test_unexpected_failure_propagates(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
converter_logic.on_error = MagicMock()
- with patch(
- "sampletones_application.logic.main.converter.get_output_path",
- side_effect=KeyError("drive"),
+ with (
+ patch(
+ "sampletones_application.logic.main.converter.get_output_path",
+ side_effect=KeyError("drive"),
+ ),
+ pytest.raises(KeyError),
):
- with pytest.raises(KeyError):
- converter_logic._assign_paths(Path("/tmp/input.wav"), MagicMock())
+ converter_logic._assign_paths(Path("/tmp/input.wav"), MagicMock())
converter_logic.on_error.assert_not_called()
@@ -244,7 +282,10 @@ class TestConversionCompleteHandsOverOutcome:
"""A completed conversion tells its listener what was produced, so the follow-up load offer can
target the single reconstruction (file) or the browser (directory)."""
- def test_success_carries_input_kind_and_output_path(self, converter_logic: ConverterLogic) -> None:
+ def test_success_carries_input_kind_and_output_path(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
on_success = MagicMock()
converter_logic.on_success = on_success
converter_logic._is_file = True
@@ -262,7 +303,10 @@ class TestFailureReturnsToIdle:
"""With no Close button, a failure reports through ``on_error`` and schedules its own return to
idle so the panel never strands on the failed phase."""
- def test_failure_schedules_return_to_idle_and_reports(self, converter_logic: ConverterLogic) -> None:
+ def test_failure_schedules_return_to_idle_and_reports(
+ self,
+ converter_logic: ConverterLogic,
+ ) -> None:
converter_logic.on_error = MagicMock()
with patch("sampletones_application.logic.main.converter.CallbackQueue.add") as scheduled:
diff --git a/tests/unit/sampletones_application/logic/main/test_explorer_manager.py b/tests/unit/sampletones_application/logic/main/test_explorer_manager.py
new file mode 100644
index 00000000..ae9d1820
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/main/test_explorer_manager.py
@@ -0,0 +1,220 @@
+from pathlib import Path
+from typing import AbstractSet, Dict, List, Optional, Set
+
+import pytest
+
+from sampletones_application.logic.main.explorer_manager import ExplorerManager
+from sampletones_core.structures.tree import FileSystemNode, Tree
+from tests.suite.language import FakeLanguageManager
+
+MUSIC = "music"
+DRUMS = "drums"
+NOTES = "notes"
+
+
+class FakeConfigManager:
+ """Answers the directories the explorer reveals, which a test points at its own corpus."""
+
+ def __init__(self, directory: Path) -> None:
+ self._directory = directory
+
+ def get_library_directory(self) -> Path:
+ return self._directory
+
+ def get_reconstructions_directory(self) -> Path:
+ return self._directory
+
+
+def write_corpus(root: Path) -> Dict[str, Path]:
+ """A folder holding a folder, beside a folder of its own, each carrying a file to be listed."""
+ paths = {
+ MUSIC: root / MUSIC,
+ DRUMS: root / MUSIC / DRUMS,
+ NOTES: root / NOTES,
+ }
+ for path in paths.values():
+ path.mkdir(parents=True)
+ (path / "song.wav").touch()
+
+ return paths
+
+
+def build_manager(
+ root: Path,
+ open_directories: AbstractSet[Path],
+ monkeypatch: pytest.MonkeyPatch,
+) -> ExplorerManager:
+ """An explorer reading one directory as its whole filesystem, so a test states every folder."""
+ manager = ExplorerManager(
+ FakeConfigManager(root), # type: ignore[arg-type]
+ language_manager=FakeLanguageManager(),
+ open_directories=open_directories,
+ )
+ monkeypatch.setattr(manager, "_get_filesystems", lambda: [root], raising=False)
+ return manager
+
+
+def row_at(tree: Tree, path: Path) -> Optional[FileSystemNode]:
+ rows = tree.find_nodes(FileSystemNode, lambda node: node.filepath == path)
+ return rows[0] if rows else None
+
+
+def rows_below(tree: Tree, path: Path) -> List[str]:
+ row = row_at(tree, path)
+ assert row is not None
+ return sorted(str(child.name) for child in row.children)
+
+
+class TestTheShapeASessionLeft:
+ """The folders standing open are handed back at startup, and read down to on the next refresh."""
+
+ def test_a_remembered_folder_comes_back_open(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ paths = write_corpus(tmp_path)
+ manager = build_manager(tmp_path, {paths[MUSIC]}, monkeypatch)
+
+ manager.refresh_tree()
+
+ assert manager.is_directory_open(paths[MUSIC])
+ assert rows_below(manager.tree, paths[MUSIC]) == [DRUMS, "song.wav"]
+
+ def test_a_folder_nested_in_a_remembered_one_is_read_down_to(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """A row stands for every folder above the remembered one, which is what shows it."""
+ paths = write_corpus(tmp_path)
+ manager = build_manager(tmp_path, {paths[DRUMS]}, monkeypatch)
+
+ manager.refresh_tree()
+
+ assert manager.is_directory_open(paths[MUSIC])
+ assert rows_below(manager.tree, paths[DRUMS]) == ["song.wav"]
+
+ def test_a_folder_no_session_left_open_stays_folded(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ paths = write_corpus(tmp_path)
+ manager = build_manager(tmp_path, {paths[MUSIC]}, monkeypatch)
+
+ manager.refresh_tree()
+
+ assert not manager.is_directory_open(paths[NOTES])
+ assert rows_below(manager.tree, paths[NOTES]) == []
+
+ def test_a_folder_the_disk_has_lost_is_dropped_at_startup(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ paths = write_corpus(tmp_path)
+ gone = tmp_path / "gone"
+
+ manager = build_manager(tmp_path, {paths[MUSIC], gone}, monkeypatch)
+
+ assert manager.open_directories == {paths[MUSIC]}
+
+ def test_a_file_standing_where_a_folder_was_is_dropped_at_startup(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ write_corpus(tmp_path)
+ replaced = tmp_path / "replaced"
+ replaced.touch()
+
+ manager = build_manager(tmp_path, {replaced}, monkeypatch)
+
+ assert manager.open_directories == set()
+
+
+class TestReadingApartFromStandingOpen:
+ """A folder read once and then folded away is loaded and closed, and comes back closed."""
+
+ def test_a_folded_folder_keeps_the_children_it_read(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ paths = write_corpus(tmp_path)
+ manager = build_manager(tmp_path, set(), monkeypatch)
+ manager.refresh_tree()
+ music = row_at(manager.tree, paths[MUSIC])
+ assert music is not None
+
+ manager.expand_directory(music)
+ manager.set_directory_open(paths[MUSIC], False)
+
+ assert manager.has_loaded_children(paths[MUSIC])
+ assert not manager.is_directory_open(paths[MUSIC])
+
+ def test_a_refresh_brings_a_folded_folder_back_folded(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ paths = write_corpus(tmp_path)
+ manager = build_manager(tmp_path, set(), monkeypatch)
+ manager.refresh_tree()
+ music = row_at(manager.tree, paths[MUSIC])
+ assert music is not None
+ manager.expand_directory(music)
+ manager.set_directory_open(paths[MUSIC], True)
+ manager.set_directory_open(paths[MUSIC], False)
+
+ manager.refresh_tree()
+
+ assert not manager.is_directory_open(paths[MUSIC])
+
+ def test_the_filesystem_the_tree_opens_at_stands_open(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ write_corpus(tmp_path)
+ manager = build_manager(tmp_path, set(), monkeypatch)
+
+ manager.refresh_tree()
+
+ assert manager.is_directory_open(tmp_path)
+
+
+class TestCollapseAll:
+ def test_every_folder_is_folded_and_what_was_read_is_dropped(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ paths = write_corpus(tmp_path)
+ manager = build_manager(tmp_path, {paths[DRUMS]}, monkeypatch)
+ manager.refresh_tree()
+
+ manager.collapse_all()
+
+ assert manager.open_directories == set()
+ assert not manager.has_loaded_children(paths[MUSIC])
+ assert rows_below(manager.tree, tmp_path) == []
+
+
+class TestTheShapeASaveReads:
+ def test_the_folders_standing_open_are_answered_apart_from_the_explorer(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """The explorer keeps writing its own shape, so what a save carries is a reading of it."""
+ paths = write_corpus(tmp_path)
+ manager = build_manager(tmp_path, {paths[MUSIC]}, monkeypatch)
+ manager.refresh_tree()
+ written: Set[Path] = manager.open_directories
+
+ manager.set_directory_open(paths[MUSIC], False)
+
+ assert paths[MUSIC] in written
+ assert paths[MUSIC] not in manager.open_directories
diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py
index ed2e55dd..54dfae4d 100644
--- a/tests/unit/sampletones_application/logic/project/test_controller.py
+++ b/tests/unit/sampletones_application/logic/project/test_controller.py
@@ -2,6 +2,7 @@
from typing import Callable, List
import numpy as np
+import pytest
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.project.manager import ProjectManager
@@ -30,6 +31,13 @@ def test_settings_edits_apply(self) -> None:
assert controller.project.settings.tempo == 128
assert controller.project.settings.speed == 4
+ def test_highlight_edits_apply(self) -> None:
+ controller = _controller()
+ controller.set_first_highlight(3)
+ controller.set_second_highlight(12)
+ assert controller.project.settings.first_highlight == 3
+ assert controller.project.settings.second_highlight == 12
+
def test_rows_per_pattern_resizes_all_patterns(self) -> None:
controller = _controller()
song = controller.project.song
@@ -45,7 +53,10 @@ def test_rows_per_pattern_resizes_all_patterns(self) -> None:
class TestSamples:
- def test_add_sample_appends_and_emits(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_add_sample_appends_and_emits(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller = _controller()
emitted: List[str] = []
controller.on_samples_changed = lambda: emitted.append("samples")
@@ -69,7 +80,10 @@ def test_add_sample_detaches_source_but_keeps_object_identity(
assert sample.reconstruction is reconstruction
assert sample.reconstruction.audio_filepath is None
- def test_remove_sample_purges_row_references(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_remove_sample_purges_row_references(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller = _controller()
sample = controller.add_sample(reconstruction_factory(), name="lead")
song = controller.project.song
@@ -101,12 +115,18 @@ def test_is_sample_used_reflects_pattern_references(
GeneratorName.PULSE1,
pattern_id,
0,
- command=Instrument(sample_id=sample.id, generator_name=GeneratorName.PULSE1),
+ command=Instrument(
+ sample_id=sample.id,
+ generator_name=GeneratorName.PULSE1,
+ ),
)
assert controller.is_sample_used(sample.id) is True
- def test_move_sample_reorders_pool(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_move_sample_reorders_pool(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller = _controller()
first = controller.add_sample(reconstruction_factory(), name="first")
controller.add_sample(reconstruction_factory(), name="second")
@@ -114,10 +134,17 @@ def test_move_sample_reorders_pool(self, reconstruction_factory: Callable[[], Re
controller.move_sample(first.id, 2)
- assert [sample.name for sample in controller.project.samples] == ["second", "third", "first"]
+ assert [sample.name for sample in controller.project.samples] == [
+ "second",
+ "third",
+ "first",
+ ]
assert controller.project.samples.get_index(first.id) == 2
- def test_move_sample_preserves_row_references(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_move_sample_preserves_row_references(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller = _controller()
sample = controller.add_sample(reconstruction_factory(), name="lead")
controller.add_sample(reconstruction_factory(), name="pad")
@@ -127,7 +154,10 @@ def test_move_sample_preserves_row_references(self, reconstruction_factory: Call
GeneratorName.PULSE1,
pattern_id,
0,
- command=Instrument(sample_id=sample.id, generator_name=GeneratorName.PULSE1),
+ command=Instrument(
+ sample_id=sample.id,
+ generator_name=GeneratorName.PULSE1,
+ ),
)
controller.move_sample(sample.id, 1)
@@ -162,10 +192,16 @@ def test_duplicate_sample_appends_independent_copy(
assert clone.id != source.id
assert clone.name == source.name
assert clone.reconstruction is not source.reconstruction
- assert [sample.name for sample in controller.project.samples] == ["lead", "lead"]
+ assert [sample.name for sample in controller.project.samples] == [
+ "lead",
+ "lead",
+ ]
assert controller.project.samples.get_index(clone.id) == 1
- def test_duplicate_sample_emits_samples_change(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_duplicate_sample_emits_samples_change(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller = _controller()
source = controller.add_sample(reconstruction_factory(), name="lead")
emitted: List[str] = []
@@ -214,10 +250,16 @@ def test_replace_sample_reconstruction_preserves_row_references(
GeneratorName.PULSE1,
pattern_id,
0,
- command=Instrument(sample_id=sample.id, generator_name=GeneratorName.PULSE1),
+ command=Instrument(
+ sample_id=sample.id,
+ generator_name=GeneratorName.PULSE1,
+ ),
)
- controller.replace_sample_reconstruction(sample.id, reconstruction_factory())
+ controller.replace_sample_reconstruction(
+ sample.id,
+ reconstruction_factory(),
+ )
row = song.pattern(GeneratorName.PULSE1, pattern_id).rows[0]
assert row.command is not None
@@ -233,14 +275,20 @@ def test_replace_sample_reconstruction_emits_samples_and_song_changes(
controller.on_samples_changed = lambda: emitted.append("samples")
controller.on_song_changed = lambda: emitted.append("song")
- controller.replace_sample_reconstruction(sample.id, reconstruction_factory())
+ controller.replace_sample_reconstruction(
+ sample.id,
+ reconstruction_factory(),
+ )
assert "samples" in emitted
assert "song" in emitted
class TestSong:
- def test_set_row_replaces_row(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_set_row_replaces_row(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller = _controller()
sample = controller.add_sample(reconstruction_factory(), name="lead")
song = controller.project.song
@@ -250,7 +298,10 @@ def test_set_row_replaces_row(self, reconstruction_factory: Callable[[], Reconst
GeneratorName.PULSE1,
pattern_id,
2,
- command=Instrument(sample_id=sample.id, generator_name=GeneratorName.PULSE1),
+ command=Instrument(
+ sample_id=sample.id,
+ generator_name=GeneratorName.PULSE1,
+ ),
transpose=0,
volume=10,
)
@@ -303,7 +354,10 @@ def test_controller_edits_survive_save_load(
GeneratorName.PULSE1,
pattern_id,
0,
- command=Instrument(sample_id=sample.id, generator_name=GeneratorName.PULSE1),
+ command=Instrument(
+ sample_id=sample.id,
+ generator_name=GeneratorName.PULSE1,
+ ),
volume=12,
)
@@ -325,7 +379,10 @@ def test_order_length_returns_number_of_frames(self) -> None:
controller = _controller()
assert controller.order_length >= 1
- def test_sample_count_tracks_the_pool(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_sample_count_tracks_the_pool(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller = _controller()
assert controller.sample_count == 0
@@ -415,6 +472,20 @@ def test_set_speed_fires_settings_callback(self) -> None:
controller.set_speed(6)
assert fired == ["settings"]
+ def test_set_first_highlight_fires_settings_callback(self) -> None:
+ controller = _controller()
+ fired: List[str] = []
+ controller.on_settings_changed = lambda: fired.append("settings")
+ controller.set_first_highlight(3)
+ assert fired == ["settings"]
+
+ def test_set_second_highlight_fires_settings_callback(self) -> None:
+ controller = _controller()
+ fired: List[str] = []
+ controller.on_settings_changed = lambda: fired.append("settings")
+ controller.set_second_highlight(12)
+ assert fired == ["settings"]
+
def test_set_nes_frequency_updates_settings(self) -> None:
controller = _controller()
fired: List[str] = []
@@ -449,10 +520,10 @@ def test_add_pattern_returns_int_index(self) -> None:
index = controller.add_pattern(GeneratorName.PULSE1)
assert isinstance(index, int)
- def test_duplicate_pattern_creates_independent_copy(self) -> None:
+ def test_clone_pattern_creates_independent_copy(self) -> None:
controller = _controller()
original_index = controller.add_pattern(GeneratorName.TRIANGLE)
- clone_index = controller.duplicate_pattern(
+ clone_index = controller.clone_pattern(
GeneratorName.TRIANGLE,
original_index,
)
@@ -509,7 +580,132 @@ def test_in_place_reconstruction_edit_is_visible_through_project(
new_instructions,
np.zeros(64, dtype=np.float32),
72,
+ (),
)
stored = controller.project.sample(sample.id).reconstruction
assert stored.get_generator_instructions(GeneratorName.PULSE1) == new_instructions
+
+
+class TestBatch:
+ def test_a_batch_announces_one_song_change_for_many_rows(self) -> None:
+ controller = _controller()
+ emitted: List[str] = []
+ controller.on_song_changed = lambda: emitted.append("song")
+ pattern_index = controller.song.order[0][GeneratorName.PULSE1]
+
+ with controller.batch():
+ for row_index in range(8):
+ controller.set_row(
+ GeneratorName.PULSE1,
+ pattern_index,
+ row_index,
+ volume=15,
+ )
+
+ assert emitted == ["song"]
+
+ def test_a_mutation_applies_before_its_announcement_arrives(self) -> None:
+ controller = _controller()
+ emitted: List[str] = []
+ controller.on_settings_changed = lambda: emitted.append("settings")
+
+ with controller.batch():
+ controller.set_tempo(150)
+ assert controller.project.settings.tempo == 150
+ assert emitted == []
+
+ assert emitted == ["settings"]
+
+ def test_each_kind_of_change_announces_once_in_the_order_it_first_arose(self) -> None:
+ controller = _controller()
+ emitted: List[str] = []
+ controller.on_settings_changed = lambda: emitted.append("settings")
+ controller.on_song_changed = lambda: emitted.append("song")
+ controller.on_info_changed = lambda: emitted.append("info")
+
+ with controller.batch():
+ controller.set_tempo(150)
+ controller.append_frame()
+ controller.set_title("Demo")
+ controller.set_speed(4)
+ controller.append_frame()
+
+ assert emitted == ["settings", "song", "info"]
+
+ def test_the_dirty_stamp_lands_once_for_the_whole_batch(self) -> None:
+ project_manager = ProjectManager()
+ controller = ProjectController(project_manager)
+ stamps: List[str] = []
+ project_manager.session.on_state_changed = lambda: stamps.append("state")
+
+ with controller.batch():
+ controller.set_tempo(150)
+ controller.set_speed(4)
+ assert controller.is_dirty is False
+
+ assert stamps == ["state"]
+ assert controller.is_dirty is True
+
+ def test_every_mutation_signals_the_history_as_it_lands(self) -> None:
+ controller = _controller()
+ mutations: List[str] = []
+ controller.on_mutation = lambda: mutations.append("mutation")
+
+ with controller.batch():
+ controller.set_tempo(150)
+ controller.set_speed(4)
+ assert mutations == ["mutation", "mutation"]
+
+ assert mutations == ["mutation", "mutation"]
+
+ def test_nested_batches_announce_on_the_outermost_exit(self) -> None:
+ controller = _controller()
+ emitted: List[str] = []
+ controller.on_settings_changed = lambda: emitted.append("settings")
+
+ with controller.batch():
+ controller.set_tempo(150)
+ with controller.batch():
+ controller.set_speed(4)
+
+ assert emitted == []
+
+ assert emitted == ["settings"]
+
+ def test_a_batch_that_raises_still_announces_what_landed(self) -> None:
+ controller = _controller()
+ emitted: List[str] = []
+ controller.on_settings_changed = lambda: emitted.append("settings")
+
+ with pytest.raises(RuntimeError), controller.batch():
+ controller.set_tempo(150)
+ raise RuntimeError("boom")
+
+ assert emitted == ["settings"]
+ assert controller.project.settings.tempo == 150
+ assert controller.is_dirty is True
+
+ def test_a_batch_without_mutations_announces_nothing(self) -> None:
+ controller = _controller()
+ emitted: List[str] = []
+ controller.on_settings_changed = lambda: emitted.append("settings")
+ controller.on_song_changed = lambda: emitted.append("song")
+
+ with controller.batch():
+ pass
+
+ assert emitted == []
+ assert controller.is_dirty is False
+
+ def test_announcements_resume_immediately_after_a_batch(self) -> None:
+ controller = _controller()
+ emitted: List[str] = []
+ controller.on_settings_changed = lambda: emitted.append("settings")
+
+ with controller.batch():
+ controller.set_tempo(150)
+
+ controller.set_speed(4)
+
+ assert emitted == ["settings", "settings"]
diff --git a/tests/unit/sampletones_application/logic/project/title/test_compose.py b/tests/unit/sampletones_application/logic/project/title/test_compose.py
index 32fce3bc..350f22fe 100644
--- a/tests/unit/sampletones_application/logic/project/title/test_compose.py
+++ b/tests/unit/sampletones_application/logic/project/title/test_compose.py
@@ -1,4 +1,7 @@
-from sampletones_application.logic.project.title.compose import join_segments, window_title
+from sampletones_application.logic.project.title.compose import (
+ join_segments,
+ window_title,
+)
from sampletones_shared.constants.symbols import TITLE_SEPARATOR
diff --git a/tests/unit/sampletones_application/logic/project/title/test_document.py b/tests/unit/sampletones_application/logic/project/title/test_document.py
index ca6fbd9f..88480868 100644
--- a/tests/unit/sampletones_application/logic/project/title/test_document.py
+++ b/tests/unit/sampletones_application/logic/project/title/test_document.py
@@ -32,7 +32,7 @@ class TestCase(BaseRegularTestCase):
reconstruction_included: bool
expected: str
- test_cases = [
+ test_cases = (
TestCase(
label="project_is_primary",
project_name="Song",
@@ -123,9 +123,13 @@ class TestCase(BaseRegularTestCase):
reconstruction_included=False,
expected="Recon.stn*",
),
- ]
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_document_title(self, test_case: TestCase) -> None:
project = State(test_case.project_name, test_case.project_unsaved)
reconstruction = (
diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/__init__.py b/tests/unit/sampletones_application/logic/reconstruction/browser/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py
new file mode 100644
index 00000000..8a1cae3e
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py
@@ -0,0 +1,181 @@
+from pathlib import Path
+from typing import Dict, Final, List
+from unittest.mock import MagicMock
+
+import pytest
+
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
+from sampletones_application.logic.reconstruction.browser.tree.entries.directory import (
+ DirectoryEntry,
+ ScanEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import (
+ ReconstructionEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_core.constants.enums import SpectrumMethod
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
+from tests.suite.language import FakeLanguageManager
+
+HASH_A: Final[str] = "6edf7c948606917a78b45d153c7ca7e0"
+HASH_B: Final[str] = "a1b2c3d4e5f60718293a4b5c6d7e8f90"
+
+RECONSTRUCTIONS: Final[Path] = Path("/reconstructions")
+BRANCH_NAME: Final[str] = "branch"
+
+CONFIGURATION_BRANCH_KEY: Final[str] = "global.browser.label.by_configuration"
+SAMPLE_BRANCH_KEY: Final[str] = "global.browser.label.by_sample"
+
+
+def config_fields(
+ *,
+ sample_rate: int = 44100,
+ nes_frequency: int = 30,
+ spectrum_method: SpectrumMethod = SpectrumMethod.FFT,
+ transformation_gamma: int = 0,
+ generators: str = "PTN",
+ config_hash: str = HASH_A,
+) -> ConfigDirectoryFields:
+ """Builds configuration fields, so a test states only the field whose effect it examines."""
+ return ConfigDirectoryFields(
+ sr=sample_rate,
+ nf=nes_frequency,
+ sm=spectrum_method,
+ tg=transformation_gamma,
+ gn=generators,
+ ch=config_hash,
+ )
+
+
+def reconstruction_entry(directory: Path, *relative_parts: str) -> ReconstructionEntry:
+ return ReconstructionEntry(path=directory.joinpath(*relative_parts).with_suffix(EXT_FILE_RECONSTRUCTION))
+
+
+def config_entry(fields: ConfigDirectoryFields, *audio_names: str) -> DirectoryEntry:
+ """Records a configuration directory holding one reconstruction per stated audio name."""
+ directory = RECONSTRUCTIONS / fields.directory_name
+ return DirectoryEntry(
+ path=directory,
+ config=fields,
+ entries=tuple(reconstruction_entry(directory, name) for name in audio_names),
+ )
+
+
+def plain_entry(name: str, *entries: ScanEntry) -> DirectoryEntry:
+ """Records a folder whose name states no configuration."""
+ return DirectoryEntry(path=RECONSTRUCTIONS / name, config=None, entries=entries)
+
+
+def scan_of(*entries: ScanEntry) -> ReconstructionScan:
+ return ReconstructionScan(entries=entries)
+
+
+def config_directory(root: Path, fields: ConfigDirectoryFields) -> Path:
+ directory = root / fields.directory_name
+ directory.mkdir(parents=True, exist_ok=True)
+ return directory
+
+
+def write_reconstruction(directory: Path, *relative_parts: str) -> Path:
+ """Creates an empty reconstruction file at the stated place, with the folders leading to it."""
+ path = directory.joinpath(*relative_parts).with_suffix(EXT_FILE_RECONSTRUCTION)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.touch()
+ return path
+
+
+def container_root() -> TreeNode:
+ return TreeNode("Root", node_type=NodeType.ROOT)
+
+
+def group_node(name: str, parent: TreeNode) -> TreeNode:
+ return TreeNode(name, node_type=NodeType.GROUP, parent=parent)
+
+
+def sample_node(name: str, parent: TreeNode) -> TreeNode:
+ return TreeNode(name, node_type=NodeType.SAMPLE, parent=parent)
+
+
+def directory_node(name: str, parent: TreeNode) -> FileSystemNode:
+ return FileSystemNode(
+ name,
+ node_type=NodeType.DIRECTORY,
+ filepath=RECONSTRUCTIONS / name,
+ parent=parent,
+ )
+
+
+def file_node(name: str, parent: TreeNode) -> FileSystemNode:
+ return FileSystemNode(
+ name,
+ node_type=NodeType.FILE,
+ filepath=(RECONSTRUCTIONS / name).with_suffix(EXT_FILE_RECONSTRUCTION),
+ parent=parent,
+ )
+
+
+def child_names(node: TreeNode) -> List[str]:
+ return [str(child.name) for child in node.children]
+
+
+def reconstruction_paths(node: TreeNode) -> List[Path]:
+ """Answers the reconstructions a branch offers, wherever the rows of that branch put them."""
+ return sorted(
+ descendant.filepath
+ for descendant in node.descendants
+ if isinstance(descendant, FileSystemNode) and descendant.node_type == NodeType.FILE
+ )
+
+
+def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]:
+ return {
+ child.name: child
+ for child in node.children
+ if isinstance(child, FileSystemNode) and child.node_type == NodeType.DIRECTORY
+ }
+
+
+def file_children(node: TreeNode) -> Dict[str, FileSystemNode]:
+ return {
+ child.name: child
+ for child in node.children
+ if isinstance(child, FileSystemNode) and child.node_type == NodeType.FILE
+ }
+
+
+def group_children(node: TreeNode) -> Dict[str, TreeNode]:
+ return {child.name: child for child in node.children if child.node_type == NodeType.GROUP}
+
+
+def sample_children(node: TreeNode) -> Dict[str, TreeNode]:
+ return {child.name: child for child in node.children if child.node_type == NodeType.SAMPLE}
+
+
+def branch_of(browser_manager: BrowserManager, key: str) -> TreeNode:
+ root = browser_manager.tree.get_root()
+ assert root is not None
+ return group_children(root)[key]
+
+
+def configuration_branch(browser_manager: BrowserManager) -> TreeNode:
+ return branch_of(browser_manager, CONFIGURATION_BRANCH_KEY)
+
+
+def sample_branch(browser_manager: BrowserManager) -> TreeNode:
+ return branch_of(browser_manager, SAMPLE_BRANCH_KEY)
+
+
+@pytest.fixture
+def config_manager(tmp_path: Path) -> MagicMock:
+ mock = MagicMock()
+ mock.get_reconstructions_directory.return_value = tmp_path
+ return mock
+
+
+@pytest.fixture
+def browser_manager(config_manager: MagicMock) -> BrowserManager:
+ return BrowserManager(config_manager, language_manager=FakeLanguageManager()) # type: ignore[arg-type]
diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py
new file mode 100644
index 00000000..80c0c9b9
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py
@@ -0,0 +1,127 @@
+from sampletones_application.logic.reconstruction.browser.tree.collapse import (
+ collapse_single_child_containers,
+)
+from sampletones_core.structures.tree import NodeType
+
+from .conftest import (
+ child_names,
+ container_root,
+ directory_node,
+ file_node,
+ group_node,
+ sample_node,
+)
+
+
+class TestLoneHeadings:
+ def test_a_group_leading_to_one_row_folds_into_it(self) -> None:
+ root = container_root()
+ branch = group_node("branch", root)
+ file_node("song", group_node("44.1 kHz·30 Hz", branch))
+
+ collapse_single_child_containers(root)
+
+ assert child_names(branch) == ["44.1 kHz·30 Hz·song"]
+
+ def test_a_chain_folds_into_one_row(self) -> None:
+ """The deepest heading folds first, so each level it passes adds one separator."""
+ root = container_root()
+ branch = group_node("branch", root)
+ frequencies = group_node("44.1 kHz·30 Hz", branch)
+ file_node("song", group_node("FFT·γ0", frequencies))
+
+ collapse_single_child_containers(root)
+
+ assert child_names(branch) == ["44.1 kHz·30 Hz·FFT·γ0·song"]
+
+ def test_a_sample_leading_to_one_variant_folds_into_it(self) -> None:
+ root = container_root()
+ branch = group_node("branch", root)
+ file_node("44.1 kHz·30 Hz·FFT·γ0·PTN", sample_node("cw_amen02_165", branch))
+
+ collapse_single_child_containers(root)
+
+ assert child_names(branch) == ["cw_amen02_165·44.1 kHz·30 Hz·FFT·γ0·PTN"]
+
+ def test_the_folded_row_keeps_what_it_carries(self) -> None:
+ root = container_root()
+ branch = group_node("branch", root)
+ reconstruction = file_node("song", group_node("44.1 kHz·30 Hz", branch))
+ held = reconstruction.filepath
+
+ collapse_single_child_containers(root)
+
+ folded = branch.children[0]
+ assert folded is reconstruction
+ assert folded.node_type == NodeType.FILE
+ assert folded.filepath == held
+
+ def test_a_folded_group_keeps_the_children_it_led_to(self) -> None:
+ root = container_root()
+ branch = group_node("branch", root)
+ directory = directory_node("Amen Breaks", group_node("44.1 kHz·30 Hz", branch))
+ file_node("song", directory)
+
+ collapse_single_child_containers(root)
+
+ assert child_names(branch) == ["44.1 kHz·30 Hz·Amen Breaks"]
+ assert child_names(directory) == ["song"]
+
+
+class TestHeadingsThatStay:
+ def test_a_group_gathering_several_rows_stays(self) -> None:
+ root = container_root()
+ branch = group_node("branch", root)
+ frequencies = group_node("44.1 kHz·30 Hz", branch)
+ file_node("first", frequencies)
+ file_node("second", frequencies)
+
+ collapse_single_child_containers(root)
+
+ assert child_names(branch) == ["44.1 kHz·30 Hz"]
+ assert child_names(frequencies) == ["first", "second"]
+
+ def test_a_branch_root_stays(self) -> None:
+ """Each branch names a way of reading the whole tree, so it heads its rows however few they are."""
+ root = container_root()
+ branch = group_node("branch", root)
+ file_node("song", branch)
+
+ collapse_single_child_containers(root)
+
+ assert child_names(root) == ["branch"]
+ assert child_names(branch) == ["song"]
+
+ def test_a_folder_leading_to_one_row_stays(self) -> None:
+ """The configuration branch mirrors the disk, so a folder holding one file is still a folder."""
+ root = container_root()
+ branch = group_node("branch", root)
+ directory = directory_node("Amen Breaks", branch)
+ file_node("song", directory)
+
+ collapse_single_child_containers(root)
+
+ assert child_names(branch) == ["Amen Breaks"]
+ assert child_names(directory) == ["song"]
+
+ def test_a_group_whose_fold_would_repeat_a_name_beside_it_stays(self) -> None:
+ root = container_root()
+ branch = group_node("branch", root)
+ frequencies = group_node("44.1 kHz·30 Hz", branch)
+ file_node("song", frequencies)
+ file_node("44.1 kHz·30 Hz·song", branch)
+
+ collapse_single_child_containers(root)
+
+ assert child_names(branch) == ["44.1 kHz·30 Hz", "44.1 kHz·30 Hz·song"]
+ assert child_names(frequencies) == ["song"]
+
+ def test_the_container_root_stays(self) -> None:
+ root = container_root()
+ file_node("song", group_node("branch", root))
+
+ collapse_single_child_containers(root)
+
+ assert root.node_type == NodeType.ROOT
+ assert root.parent is None
+ assert child_names(root) == ["branch"]
diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py
new file mode 100644
index 00000000..c8a5bdea
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py
@@ -0,0 +1,164 @@
+from typing import Dict
+
+from sampletones_application.logic.reconstruction.browser.tree.configurations.branch import (
+ build_configuration_branch,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_core.configs.display import (
+ disambiguated_display_name,
+ format_frequencies,
+ format_transformation,
+)
+from sampletones_core.constants.enums import SpectrumMethod
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+from sampletones_core.structures.tree import (
+ ConfigNode,
+ FileSystemNode,
+ NodeType,
+ TreeNode,
+)
+
+from .conftest import (
+ BRANCH_NAME,
+ HASH_A,
+ HASH_B,
+ RECONSTRUCTIONS,
+ config_entry,
+ config_fields,
+ directory_children,
+ file_children,
+ group_children,
+ plain_entry,
+ reconstruction_entry,
+ scan_of,
+)
+
+
+def build_branch(scan: ReconstructionScan) -> TreeNode:
+ return build_configuration_branch(
+ scan,
+ name=BRANCH_NAME,
+ parent=TreeNode("Root", node_type=NodeType.ROOT),
+ )
+
+
+def frequencies_name(fields: ConfigDirectoryFields) -> str:
+ return format_frequencies(fields.sr, fields.nf)
+
+
+def transformation_name(fields: ConfigDirectoryFields) -> str:
+ return format_transformation(fields.sm, fields.tg)
+
+
+def generator_directories(
+ branch: TreeNode,
+ fields: ConfigDirectoryFields,
+) -> Dict[str, FileSystemNode]:
+ frequencies_node = group_children(branch)[frequencies_name(fields)]
+ return directory_children(group_children(frequencies_node)[transformation_name(fields)])
+
+
+class TestTopLevelConfigDirectories:
+ def test_config_directory_groups_by_frequencies_then_transformation(self) -> None:
+ fields = config_fields(generators="PpT")
+ branch = build_branch(scan_of(config_entry(fields, "song")))
+
+ frequencies = group_children(branch)
+ assert set(frequencies) == {frequencies_name(fields)}
+
+ transformations = group_children(frequencies[frequencies_name(fields)])
+ assert set(transformations) == {transformation_name(fields)}
+
+ assert set(directory_children(transformations[transformation_name(fields)])) == {fields.gn}
+
+ def test_config_directory_keeps_its_reconstructions(self) -> None:
+ fields = config_fields()
+ entry = config_entry(fields, "song")
+ branch = build_branch(scan_of(entry))
+
+ directory_node = generator_directories(branch, fields)[fields.gn]
+ assert file_children(directory_node)["song"].filepath == entry.entries[0].path
+
+ def test_config_directory_carries_its_parsed_configuration(self) -> None:
+ fields = config_fields()
+ branch = build_branch(scan_of(config_entry(fields, "song")))
+
+ directory_node = generator_directories(branch, fields)[fields.gn]
+ assert isinstance(directory_node, ConfigNode)
+ assert directory_node.config == fields
+
+ def test_colliding_generators_get_a_hash_suffix(self) -> None:
+ first = config_fields(config_hash=HASH_A)
+ second = config_fields(config_hash=HASH_B)
+ branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song")))
+
+ assert set(generator_directories(branch, first)) == {
+ disambiguated_display_name(first.gn, HASH_A),
+ disambiguated_display_name(second.gn, HASH_B),
+ }
+
+ def test_distinct_generators_share_a_transformation_group_under_their_own_names(self) -> None:
+ first = config_fields(generators="PTN")
+ second = config_fields(generators="TN")
+ branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song")))
+
+ assert set(generator_directories(branch, first)) == {"PTN", "TN"}
+
+ def test_distinct_frequencies_form_separate_groups(self) -> None:
+ first = config_fields(sample_rate=44100, nes_frequency=30)
+ second = config_fields(sample_rate=48000, nes_frequency=60)
+ branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song")))
+
+ assert set(group_children(branch)) == {frequencies_name(first), frequencies_name(second)}
+
+ def test_distinct_transformations_form_separate_groups(self) -> None:
+ first = config_fields(spectrum_method=SpectrumMethod.FFT)
+ second = config_fields(spectrum_method=SpectrumMethod.CQT)
+ branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song")))
+
+ transformations = group_children(group_children(branch)[frequencies_name(first)])
+ assert set(transformations) == {transformation_name(first), transformation_name(second)}
+
+
+class TestPlainFolders:
+ def test_plain_folder_keeps_its_name_and_holds_its_reconstructions(self) -> None:
+ entry = plain_entry("my_songs", reconstruction_entry(RECONSTRUCTIONS / "my_songs", "song"))
+ branch = build_branch(scan_of(entry))
+
+ directory_node = directory_children(branch)["my_songs"]
+ assert set(file_children(directory_node)) == {"song"}
+
+ def test_empty_folder_stays_in_place(self) -> None:
+ branch = build_branch(scan_of(plain_entry("empty")))
+
+ assert set(directory_children(branch)) == {"empty"}
+
+ def test_nested_config_directory_takes_its_friendly_name(self) -> None:
+ fields = config_fields()
+ branch = build_branch(scan_of(plain_entry("my_songs", config_entry(fields, "song"))))
+
+ nested = directory_children(directory_children(branch)["my_songs"])
+ assert set(nested) == {fields.display_name}
+
+ def test_colliding_nested_config_directories_get_a_hash_suffix(self) -> None:
+ first = config_fields(config_hash=HASH_A)
+ second = config_fields(config_hash=HASH_B)
+ branch = build_branch(
+ scan_of(plain_entry("my_songs", config_entry(first, "song"), config_entry(second, "song")))
+ )
+
+ nested = directory_children(directory_children(branch)["my_songs"])
+ assert set(nested) == {
+ disambiguated_display_name(first.display_name, HASH_A),
+ disambiguated_display_name(second.display_name, HASH_B),
+ }
+
+
+class TestLooseReconstructions:
+ def test_reconstruction_beside_the_config_directories_is_listed_here(self) -> None:
+ entry = reconstruction_entry(RECONSTRUCTIONS, "song")
+ branch = build_branch(scan_of(entry))
+
+ assert file_children(branch)["song"].filepath == entry.path
diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py
new file mode 100644
index 00000000..57d8dc29
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py
@@ -0,0 +1,315 @@
+from pathlib import Path
+from typing import Iterator, List
+
+import pytest
+
+from sampletones_application.logic.reconstruction.browser.manager import BrowserManager
+from sampletones_core.configs.display import (
+ DISPLAY_SEPARATOR,
+ format_frequencies,
+ format_transformation,
+)
+from sampletones_core.structures.tree import NodeType
+
+from .conftest import (
+ CONFIGURATION_BRANCH_KEY,
+ HASH_B,
+ SAMPLE_BRANCH_KEY,
+ config_directory,
+ config_fields,
+ configuration_branch,
+ directory_children,
+ file_children,
+ group_children,
+ reconstruction_paths,
+ sample_branch,
+ sample_children,
+ write_reconstruction,
+)
+
+
+class TestRefreshTree:
+ def test_missing_directory_leaves_no_root(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ browser_manager.reconstructions_directory = tmp_path / "does_not_exist"
+ browser_manager.refresh_tree()
+ assert browser_manager.tree.root is None
+
+ def test_root_holds_both_branches(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ write_reconstruction(config_directory(tmp_path, config_fields()), "song")
+
+ browser_manager.refresh_tree()
+
+ root = browser_manager.tree.get_root()
+ assert root is not None
+ assert list(group_children(root)) == [CONFIGURATION_BRANCH_KEY, SAMPLE_BRANCH_KEY]
+
+ def test_directory_holding_nothing_to_show_leaves_no_branches(
+ self,
+ browser_manager: BrowserManager,
+ ) -> None:
+ """Both views are headings over reconstructions, so neither is offered where there are none."""
+ browser_manager.refresh_tree()
+
+ root = browser_manager.tree.get_root()
+ assert root is not None
+ assert root.children == ()
+
+ def test_the_configuration_branch_still_lists_a_folder_holding_no_reconstruction(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ (tmp_path / "empty").mkdir()
+
+ browser_manager.refresh_tree()
+
+ root = browser_manager.tree.get_root()
+ assert root is not None
+ assert list(group_children(root)) == [CONFIGURATION_BRANCH_KEY]
+ assert set(directory_children(configuration_branch(browser_manager))) == {"empty"}
+
+ def test_reconstruction_is_reachable_from_both_branches(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ path = write_reconstruction(config_directory(tmp_path, config_fields()), "song")
+
+ browser_manager.refresh_tree()
+
+ assert reconstruction_paths(configuration_branch(browser_manager)) == [path]
+ assert reconstruction_paths(sample_branch(browser_manager)) == [path]
+
+ def test_reads_every_folder_once(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """Both branches are built from one reading, so no folder is listed twice per refresh."""
+ directory = config_directory(tmp_path, config_fields())
+ write_reconstruction(directory, "Amen Breaks", "cw_amen02_165")
+
+ listed: List[Path] = []
+ original_iterdir = Path.iterdir
+
+ def counting_iterdir(directory_path: Path) -> Iterator[Path]:
+ listed.append(directory_path)
+ return original_iterdir(directory_path)
+
+ monkeypatch.setattr(Path, "iterdir", counting_iterdir)
+ browser_manager.refresh_tree()
+
+ assert tmp_path in listed
+ assert sorted(listed) == sorted(set(listed))
+
+
+class TestBranchShape:
+ def test_a_lone_configuration_reads_as_one_row(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ """One configuration needs no headings to be told apart, so its row carries the whole label."""
+ fields = config_fields()
+ write_reconstruction(config_directory(tmp_path, fields), "song")
+
+ browser_manager.refresh_tree()
+
+ configurations = configuration_branch(browser_manager)
+ folded = directory_children(configurations)[fields.display_name]
+
+ assert list(directory_children(configurations)) == [fields.display_name]
+ assert list(file_children(folded)) == ["song"]
+
+ def test_the_heading_telling_two_configurations_apart_stays(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ """Two configurations sharing their rates are gathered by rate and read apart by spectrum."""
+ first = config_fields()
+ second = config_fields(transformation_gamma=1, config_hash=HASH_B)
+ write_reconstruction(config_directory(tmp_path, first), "song")
+ write_reconstruction(config_directory(tmp_path, second), "song")
+
+ browser_manager.refresh_tree()
+
+ configurations = configuration_branch(browser_manager)
+ frequencies = group_children(configurations)[format_frequencies(first.sr, first.nf)]
+
+ assert list(directory_children(frequencies)) == [
+ DISPLAY_SEPARATOR.join([format_transformation(first.sm, first.tg), first.gn]),
+ DISPLAY_SEPARATOR.join([format_transformation(second.sm, second.tg), second.gn]),
+ ]
+
+ def test_a_sample_reconstructed_once_reads_as_one_row(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ fields = config_fields()
+ write_reconstruction(config_directory(tmp_path, fields), "song")
+
+ browser_manager.refresh_tree()
+
+ samples = sample_branch(browser_manager)
+
+ assert list(file_children(samples)) == [DISPLAY_SEPARATOR.join(["song", fields.display_name])]
+
+ def test_a_sample_reconstructed_twice_keeps_its_row(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ first = config_fields()
+ second = config_fields(transformation_gamma=1, config_hash=HASH_B)
+ write_reconstruction(config_directory(tmp_path, first), "song")
+ write_reconstruction(config_directory(tmp_path, second), "song")
+
+ browser_manager.refresh_tree()
+
+ samples = sample_branch(browser_manager)
+
+ assert list(file_children(sample_children(samples)["song"])) == [
+ first.display_name,
+ second.display_name,
+ ]
+
+
+class TestNodesAt:
+ def test_a_reconstruction_is_answered_once_per_view(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ """Both views hold the reconstruction, so a favorite change reaches a row in each of them."""
+ path = write_reconstruction(config_directory(tmp_path, config_fields()), "song")
+
+ browser_manager.refresh_tree()
+
+ nodes = browser_manager.nodes_at(path)
+ assert [node.node_type for node in nodes] == [NodeType.FILE, NodeType.FILE]
+ assert all(node.filepath == path for node in nodes)
+
+ def test_a_directory_is_answered_where_it_is_listed(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ """The configuration branch mirrors the disk, and it is the branch that lists folders."""
+ directory = config_directory(tmp_path, config_fields())
+ write_reconstruction(directory, "first")
+ write_reconstruction(directory, "second")
+
+ browser_manager.refresh_tree()
+
+ assert [node.filepath for node in browser_manager.nodes_at(directory)] == [directory]
+
+ def test_a_path_the_tree_holds_nowhere_is_answered_by_nothing(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ write_reconstruction(config_directory(tmp_path, config_fields()), "song")
+
+ browser_manager.refresh_tree()
+
+ assert browser_manager.nodes_at(tmp_path / "elsewhere.stn") == ()
+
+
+class TestSetReconstructionsDirectory:
+ def test_directory_is_taken_over(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ directory = tmp_path / "new"
+ directory.mkdir()
+ browser_manager.set_reconstructions_directory(directory)
+ assert browser_manager.reconstructions_directory == directory
+
+ def test_directory_change_refreshes_the_tree(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ directory = tmp_path / "populated"
+ directory.mkdir()
+ write_reconstruction(directory, "track")
+
+ browser_manager.set_reconstructions_directory(directory)
+
+ assert len(browser_manager.get_all_reconstruction_files()) == 1
+
+
+class TestGetAllReconstructionFiles:
+ def test_empty_directory_holds_no_reconstructions(self, browser_manager: BrowserManager) -> None:
+ browser_manager.refresh_tree()
+ assert browser_manager.get_all_reconstruction_files() == []
+
+ def test_missing_directory_holds_no_reconstructions(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ browser_manager.reconstructions_directory = tmp_path / "does_not_exist"
+ browser_manager.refresh_tree()
+ assert browser_manager.get_all_reconstruction_files() == []
+
+ def test_reconstructions_are_answered_in_path_order(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ root_path = write_reconstruction(tmp_path, "a")
+ nested_path = write_reconstruction(tmp_path / "sub", "b")
+
+ browser_manager.refresh_tree()
+
+ assert browser_manager.get_all_reconstruction_files() == sorted([root_path, nested_path])
+
+ def test_other_files_stay_out(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ (tmp_path / "audio.wav").touch()
+ path = write_reconstruction(tmp_path, "song")
+
+ browser_manager.refresh_tree()
+
+ assert browser_manager.get_all_reconstruction_files() == [path]
+
+ def test_folder_without_reconstructions_contributes_nothing(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ audio_only = tmp_path / "audio_only"
+ audio_only.mkdir()
+ (audio_only / "track.wav").touch()
+ (tmp_path / "empty").mkdir()
+
+ browser_manager.refresh_tree()
+
+ assert browser_manager.get_all_reconstruction_files() == []
+
+ def test_reconstruction_in_both_branches_is_answered_once(
+ self,
+ browser_manager: BrowserManager,
+ tmp_path: Path,
+ ) -> None:
+ path = write_reconstruction(config_directory(tmp_path, config_fields()), "song")
+
+ browser_manager.refresh_tree()
+
+ assert browser_manager.get_all_reconstruction_files() == [path]
diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_order.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_order.py
new file mode 100644
index 00000000..911a9a5f
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_order.py
@@ -0,0 +1,88 @@
+from sampletones_application.logic.reconstruction.browser.tree.order import order_children
+
+from .conftest import (
+ CONFIGURATION_BRANCH_KEY,
+ SAMPLE_BRANCH_KEY,
+ child_names,
+ container_root,
+ directory_node,
+ file_node,
+ group_node,
+ sample_node,
+)
+
+
+class TestNameOrder:
+ def test_numbers_read_as_numbers(self) -> None:
+ """A frequency group sits by the value its label states, whatever the folder name spells."""
+ root = container_root()
+ branch = group_node("branch", root)
+ for name in ("44.1 kHz·30 Hz", "8 kHz·60 Hz", "22.05 kHz·30 Hz"):
+ group_node(name, branch)
+
+ order_children(root)
+
+ assert child_names(branch) == ["8 kHz·60 Hz", "22.05 kHz·30 Hz", "44.1 kHz·30 Hz"]
+
+ def test_names_read_as_a_reader_reads_them(self) -> None:
+ """A capital letter states nothing about order, so names read alphabetically as they look."""
+ root = container_root()
+ branch = group_node("branch", root)
+ for name in ("Beats", "amen", "Cymbals"):
+ sample_node(name, branch)
+
+ order_children(root)
+
+ assert child_names(branch) == ["amen", "Beats", "Cymbals"]
+
+ def test_order_reaches_every_level(self) -> None:
+ root = container_root()
+ branch = group_node("branch", root)
+ sample = sample_node("song", branch)
+ for name in ("FFT·γ0", "CQT·γ0"):
+ file_node(name, sample)
+
+ order_children(root)
+
+ assert child_names(sample) == ["CQT·γ0", "FFT·γ0"]
+
+
+class TestContainersFirst:
+ def test_folders_and_groups_precede_reconstructions(self) -> None:
+ root = container_root()
+ branch = group_node("branch", root)
+ file_node("aaa", branch)
+ group_node("zzz group", branch)
+ directory_node("zzz folder", branch)
+ sample_node("zzz sample", branch)
+
+ order_children(root)
+
+ assert child_names(branch) == ["zzz folder", "zzz group", "zzz sample", "aaa"]
+
+
+class TestBranches:
+ def test_branches_keep_the_order_the_browser_states(self) -> None:
+ """The two views read in the order they are built, rather than by the labels they carry."""
+ root = container_root()
+ group_node(CONFIGURATION_BRANCH_KEY, root)
+ group_node(SAMPLE_BRANCH_KEY, root)
+
+ order_children(root)
+
+ assert child_names(root) == [CONFIGURATION_BRANCH_KEY, SAMPLE_BRANCH_KEY]
+
+
+class TestSubtrees:
+ def test_reordered_rows_keep_what_they_hold(self) -> None:
+ root = container_root()
+ branch = group_node("branch", root)
+ second = group_node("second", branch)
+ file_node("song", second)
+ group_node("first", branch)
+
+ order_children(root)
+
+ assert child_names(branch) == ["first", "second"]
+ assert child_names(second) == ["song"]
+ assert second.parent is branch
diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_prune.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_prune.py
new file mode 100644
index 00000000..41fdd30f
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_prune.py
@@ -0,0 +1,103 @@
+from sampletones_application.logic.reconstruction.browser.tree.prune import (
+ prune_empty_containers,
+)
+from sampletones_core.structures.tree import NodeType
+
+from .conftest import (
+ child_names,
+ container_root,
+ directory_node,
+ file_node,
+ group_node,
+ sample_node,
+)
+
+
+class TestEmptyContainers:
+ def test_group_holding_nothing_leaves(self) -> None:
+ root = container_root()
+ group_node("44.1 kHz·30 Hz", root)
+
+ prune_empty_containers(root)
+
+ assert root.children == ()
+
+ def test_sample_holding_nothing_leaves(self) -> None:
+ root = container_root()
+ sample_node("cw_amen02_165", root)
+
+ prune_empty_containers(root)
+
+ assert root.children == ()
+
+ def test_a_whole_chain_of_empty_containers_leaves(self) -> None:
+ """The deepest rows go first, so a heading emptied by its own children goes with them."""
+ root = container_root()
+ branch = group_node("branch", root)
+ sample_node("cw_amen02_165", group_node("Amen Breaks", branch))
+
+ prune_empty_containers(root)
+
+ assert root.children == ()
+
+ def test_the_container_root_stays(self) -> None:
+ root = container_root()
+ group_node("branch", root)
+
+ prune_empty_containers(root)
+
+ assert root.node_type == NodeType.ROOT
+ assert root.parent is None
+
+
+class TestGatheringContainers:
+ def test_group_holding_a_reconstruction_stays(self) -> None:
+ root = container_root()
+ file_node("song", group_node("branch", root))
+
+ prune_empty_containers(root)
+
+ assert child_names(root) == ["branch"]
+
+ def test_sample_holding_its_variants_stays(self) -> None:
+ root = container_root()
+ sample = sample_node("song", root)
+ file_node("44.1 kHz·30 Hz·FFT·γ0·PTN", sample)
+
+ prune_empty_containers(root)
+
+ assert child_names(root) == ["song"]
+ assert child_names(sample) == ["44.1 kHz·30 Hz·FFT·γ0·PTN"]
+
+ def test_a_branch_keeps_the_containers_leading_to_a_reconstruction(self) -> None:
+ root = container_root()
+ branch = group_node("branch", root)
+ kept = group_node("Amen Breaks", branch)
+ file_node("song", sample_node("cw_amen02_165", kept))
+ group_node("Beats", branch)
+
+ prune_empty_containers(root)
+
+ assert child_names(branch) == ["Amen Breaks"]
+ assert child_names(kept) == ["cw_amen02_165"]
+
+
+class TestFolders:
+ def test_folder_holding_nothing_stays(self) -> None:
+ """The configuration branch reads the disk as it is, so an empty folder is still a folder."""
+ root = container_root()
+ branch = group_node("branch", root)
+ directory_node("empty", branch)
+
+ prune_empty_containers(root)
+
+ assert child_names(branch) == ["empty"]
+
+ def test_group_holding_only_an_empty_folder_stays(self) -> None:
+ root = container_root()
+ branch = group_node("branch", root)
+ directory_node("empty", group_node("44.1 kHz·30 Hz", branch))
+
+ prune_empty_containers(root)
+
+ assert child_names(branch) == ["44.1 kHz·30 Hz"]
diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py
new file mode 100644
index 00000000..57eeece4
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py
@@ -0,0 +1,157 @@
+from sampletones_application.logic.reconstruction.browser.tree.entries.directory import (
+ DirectoryEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.scan import (
+ ReconstructionScan,
+)
+from sampletones_application.logic.reconstruction.browser.tree.samples.branch import (
+ build_sample_branch,
+)
+from sampletones_core.configs.display import disambiguated_display_name
+from sampletones_core.constants.enums import SpectrumMethod
+from sampletones_core.structures.tree import ConfigNode, NodeType, TreeNode
+
+from .conftest import (
+ BRANCH_NAME,
+ HASH_A,
+ HASH_B,
+ RECONSTRUCTIONS,
+ config_entry,
+ config_fields,
+ file_children,
+ group_children,
+ plain_entry,
+ reconstruction_entry,
+ sample_children,
+ scan_of,
+)
+
+
+def build_branch(scan: ReconstructionScan) -> TreeNode:
+ return build_sample_branch(
+ scan,
+ name=BRANCH_NAME,
+ parent=TreeNode("Root", node_type=NodeType.ROOT),
+ )
+
+
+class TestSampleGrouping:
+ def test_audio_appears_under_the_folders_it_came_from(self) -> None:
+ fields = config_fields()
+ directory = RECONSTRUCTIONS / fields.directory_name
+ entry = DirectoryEntry(
+ path=directory,
+ config=fields,
+ entries=(reconstruction_entry(directory, "Amen Breaks", "vol.1", "cw_amen02_165"),),
+ )
+ branch = build_branch(scan_of(entry))
+
+ amen_breaks = group_children(branch)["Amen Breaks"]
+ volume = group_children(amen_breaks)["vol.1"]
+ audio_node = sample_children(volume)["cw_amen02_165"]
+ assert file_children(audio_node)[fields.display_name].filepath == entry.entries[0].path
+
+ def test_audio_at_the_root_of_a_config_directory_appears_at_the_branch_root(self) -> None:
+ fields = config_fields()
+ branch = build_branch(scan_of(config_entry(fields, "song")))
+
+ assert set(sample_children(branch)) == {"song"}
+ assert set(file_children(sample_children(branch)["song"])) == {fields.display_name}
+
+ def test_one_audio_lists_every_configuration_that_reconstructed_it(self) -> None:
+ first = config_fields(spectrum_method=SpectrumMethod.FFT)
+ second = config_fields(spectrum_method=SpectrumMethod.CQT)
+ branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song")))
+
+ audio_node = sample_children(branch)["song"]
+ assert set(file_children(audio_node)) == {first.display_name, second.display_name}
+
+ def test_each_audio_gathers_only_its_own_variants(self) -> None:
+ fields = config_fields()
+ branch = build_branch(scan_of(config_entry(fields, "first", "second")))
+
+ assert set(sample_children(branch)) == {"first", "second"}
+ for audio_name in ("first", "second"):
+ assert set(file_children(sample_children(branch)[audio_name])) == {fields.display_name}
+
+ def test_colliding_variants_of_one_audio_get_a_hash_suffix(self) -> None:
+ first = config_fields(config_hash=HASH_A)
+ second = config_fields(config_hash=HASH_B)
+ branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song")))
+
+ audio_node = sample_children(branch)["song"]
+ assert set(file_children(audio_node)) == {
+ disambiguated_display_name(first.display_name, HASH_A),
+ disambiguated_display_name(second.display_name, HASH_B),
+ }
+
+
+class TestSampleNodeTypes:
+ def test_audio_is_a_sample_and_the_folder_above_it_is_a_group(self) -> None:
+ fields = config_fields()
+ directory = RECONSTRUCTIONS / fields.directory_name
+ entry = DirectoryEntry(
+ path=directory,
+ config=fields,
+ entries=(reconstruction_entry(directory, "Amen Breaks", "cw_amen02_165"),),
+ )
+ branch = build_branch(scan_of(entry))
+
+ folder_node = group_children(branch)["Amen Breaks"]
+ assert folder_node.node_type == NodeType.GROUP
+ assert sample_children(folder_node)["cw_amen02_165"].node_type == NodeType.SAMPLE
+
+ def test_a_folder_and_the_audio_beside_it_stay_two_rows(self) -> None:
+ """A configuration directory holding ``song.stn`` beside ``song/inner.stn`` lists both.
+
+ The folder gathers what it holds while the audio gathers its variants, each row found among
+ the siblings of its own kind.
+ """
+ fields = config_fields()
+ directory = RECONSTRUCTIONS / fields.directory_name
+ entry = DirectoryEntry(
+ path=directory,
+ config=fields,
+ entries=(
+ DirectoryEntry(
+ path=directory / "song",
+ config=None,
+ entries=(reconstruction_entry(directory, "song", "inner"),),
+ ),
+ reconstruction_entry(directory, "song"),
+ ),
+ )
+ branch = build_branch(scan_of(entry))
+
+ assert set(group_children(branch)) == {"song"}
+ assert set(sample_children(branch)) == {"song"}
+ assert set(sample_children(group_children(branch)["song"])) == {"inner"}
+ assert set(file_children(sample_children(branch)["song"])) == {fields.display_name}
+
+
+class TestSampleVariants:
+ def test_variant_carries_the_configuration_of_its_directory(self) -> None:
+ """A leaf in the sample view states the configuration its directory names.
+
+ Its own filename is the audio name, so the configuration reaches the tooltip and the
+ configuration font from the node rather than from the path.
+ """
+ fields = config_fields()
+ branch = build_branch(scan_of(config_entry(fields, "song")))
+
+ variant = next(iter(file_children(sample_children(branch)["song"]).values()))
+ assert isinstance(variant, ConfigNode)
+ assert variant.config == fields
+
+
+class TestSampleSources:
+ def test_folder_stating_no_configuration_stays_out(self) -> None:
+ entry = plain_entry("my_songs", reconstruction_entry(RECONSTRUCTIONS / "my_songs", "song"))
+ branch = build_branch(scan_of(entry))
+
+ assert branch.children == ()
+
+ def test_reconstruction_beside_the_config_directories_stays_out(self) -> None:
+ branch = build_branch(scan_of(reconstruction_entry(RECONSTRUCTIONS, "song")))
+
+ assert branch.children == ()
diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_scan.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_scan.py
new file mode 100644
index 00000000..534d3842
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_scan.py
@@ -0,0 +1,110 @@
+from pathlib import Path
+
+from sampletones_application.logic.reconstruction.browser.tree.entries.directory import (
+ DirectoryEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import (
+ ReconstructionEntry,
+)
+from sampletones_application.logic.reconstruction.browser.tree.scan import (
+ scan_reconstructions,
+)
+
+from .conftest import config_directory, config_fields, write_reconstruction
+
+
+class TestScanEntries:
+ def test_reconstruction_file_becomes_an_entry_named_by_its_audio(self, tmp_path: Path) -> None:
+ path = write_reconstruction(tmp_path, "song")
+
+ scan = scan_reconstructions(tmp_path)
+
+ assert scan.entries == (ReconstructionEntry(path=path),)
+ assert scan.entries[0].name == "song"
+
+ def test_other_files_stay_out(self, tmp_path: Path) -> None:
+ (tmp_path / "audio.wav").touch()
+ write_reconstruction(tmp_path, "song")
+
+ scan = scan_reconstructions(tmp_path)
+
+ assert [entry.path.name for entry in scan.entries] == ["song.stn"]
+
+ def test_entries_follow_the_sorted_order_of_the_folder(self, tmp_path: Path) -> None:
+ for name in ("charlie", "alpha", "bravo"):
+ write_reconstruction(tmp_path, name)
+
+ scan = scan_reconstructions(tmp_path)
+
+ assert [entry.name for entry in scan.entries] == ["alpha", "bravo", "charlie"]
+
+ def test_folder_becomes_an_entry_holding_what_is_inside(self, tmp_path: Path) -> None:
+ path = write_reconstruction(tmp_path / "my_songs", "song")
+
+ scan = scan_reconstructions(tmp_path)
+
+ assert scan.entries == (
+ DirectoryEntry(
+ path=tmp_path / "my_songs",
+ config=None,
+ entries=(ReconstructionEntry(path=path),),
+ ),
+ )
+
+ def test_empty_folder_becomes_an_entry_holding_nothing(self, tmp_path: Path) -> None:
+ (tmp_path / "empty").mkdir()
+
+ scan = scan_reconstructions(tmp_path)
+
+ assert scan.entries == (DirectoryEntry(path=tmp_path / "empty", config=None, entries=()),)
+
+
+class TestScanConfiguration:
+ def test_config_directory_states_the_configuration_its_name_encodes(self, tmp_path: Path) -> None:
+ fields = config_fields()
+ config_directory(tmp_path, fields)
+
+ scan = scan_reconstructions(tmp_path)
+
+ assert [entry.config for entry in scan.entries] == [fields]
+
+ def test_plain_folder_states_no_configuration(self, tmp_path: Path) -> None:
+ (tmp_path / "my_songs").mkdir()
+
+ scan = scan_reconstructions(tmp_path)
+
+ assert [entry.config for entry in scan.entries] == [None]
+
+ def test_nested_config_directory_states_its_configuration(self, tmp_path: Path) -> None:
+ fields = config_fields()
+ config_directory(tmp_path / "my_songs", fields)
+
+ scan = scan_reconstructions(tmp_path)
+
+ nested = scan.entries[0]
+ assert isinstance(nested, DirectoryEntry)
+ assert [entry.config for entry in nested.entries] == [fields]
+
+
+class TestScanReconstructions:
+ def test_collects_every_reconstruction_beneath_the_directory(self, tmp_path: Path) -> None:
+ root_path = write_reconstruction(tmp_path, "song")
+ nested_path = write_reconstruction(tmp_path / "sub" / "deeper", "track")
+
+ scan = scan_reconstructions(tmp_path)
+
+ assert {entry.path for entry in scan.reconstructions} == {root_path, nested_path}
+
+ def test_collects_nothing_from_an_empty_directory(self, tmp_path: Path) -> None:
+ assert scan_reconstructions(tmp_path).reconstructions == ()
+
+ def test_directory_entry_collects_the_reconstructions_beneath_it(self, tmp_path: Path) -> None:
+ fields = config_fields()
+ directory = config_directory(tmp_path, fields)
+ nested_path = write_reconstruction(directory, "Amen Breaks", "cw_amen02_165")
+ write_reconstruction(tmp_path, "outside")
+
+ scan = scan_reconstructions(tmp_path)
+
+ config_entry = next(entry for entry in scan.entries if isinstance(entry, DirectoryEntry))
+ assert [entry.path for entry in scan.collect_reconstructions(config_entry.entries)] == [nested_path]
diff --git a/tests/unit/sampletones_application/logic/reconstruction/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/conftest.py
index fd484923..d2d8b13e 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/conftest.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/conftest.py
@@ -1,6 +1,6 @@
import pytest
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
from tests.suite.application import scheduling, synchronous_queue
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py
deleted file mode 100644
index bfb3b820..00000000
--- a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py
+++ /dev/null
@@ -1,201 +0,0 @@
-from __future__ import annotations
-
-from pathlib import Path
-from typing import Dict
-from unittest.mock import MagicMock
-
-import pytest
-
-from sampletones_application.logic.reconstruction.browser_manager import BrowserManager
-from sampletones_core.structures.tree import FileSystemNode, NodeType
-
-HASH_A = "6edf7c948606917a78b45d153c7ca7e0"
-HASH_B = "a1b2c3d4e5f60718293a4b5c6d7e8f90"
-
-
-def directory_nodes(browser_manager: BrowserManager) -> Dict[str, FileSystemNode]:
- root = browser_manager.tree.get_root()
- assert root is not None
- return {
- child.name: child
- for child in root.children
- if isinstance(child, FileSystemNode) and child.node_type == NodeType.DIRECTORY
- }
-
-
-@pytest.fixture
-def config_manager(tmp_path: Path) -> MagicMock:
- mock = MagicMock()
- mock.get_reconstructions_directory.return_value = tmp_path
- return mock
-
-
-@pytest.fixture
-def language_manager() -> MagicMock:
- mock = MagicMock()
- mock.__getitem__ = MagicMock(return_value="Reconstructions")
- return mock
-
-
-@pytest.fixture
-def browser_manager(config_manager: MagicMock, language_manager: MagicMock) -> BrowserManager:
- return BrowserManager(config_manager, language_manager=language_manager)
-
-
-class TestBrowserManagerRefreshTree:
- def test_non_existent_directory_sets_root_to_none(
- self,
- browser_manager: BrowserManager,
- tmp_path: Path,
- ) -> None:
- browser_manager.reconstructions_directory = tmp_path / "does_not_exist"
- browser_manager.refresh_tree()
- assert browser_manager.tree.root is None
-
- def test_empty_directory_produces_empty_leaf_list(
- self,
- browser_manager: BrowserManager,
- ) -> None:
- browser_manager.refresh_tree()
- assert browser_manager.get_all_reconstruction_files() == []
-
- def test_stn_files_appear_as_leaves(
- self,
- browser_manager: BrowserManager,
- tmp_path: Path,
- ) -> None:
- (tmp_path / "song.stn").touch()
- browser_manager.refresh_tree()
- files = browser_manager.get_all_reconstruction_files()
- assert len(files) == 1
- assert files[0] == tmp_path / "song.stn"
-
- def test_non_stn_files_are_excluded(
- self,
- browser_manager: BrowserManager,
- tmp_path: Path,
- ) -> None:
- (tmp_path / "audio.wav").touch()
- (tmp_path / "song.stn").touch()
- browser_manager.refresh_tree()
- files = browser_manager.get_all_reconstruction_files()
- assert len(files) == 1
- assert all(f.suffix == ".stn" for f in files)
-
- def test_nested_stn_files_are_included(
- self,
- browser_manager: BrowserManager,
- tmp_path: Path,
- ) -> None:
- subdir = tmp_path / "sub"
- subdir.mkdir()
- (subdir / "song.stn").touch()
- browser_manager.refresh_tree()
- files = browser_manager.get_all_reconstruction_files()
- assert len(files) == 1
- assert files[0] == subdir / "song.stn"
-
- def test_directory_with_only_non_stn_files_is_not_returned(
- self,
- browser_manager: BrowserManager,
- tmp_path: Path,
- ) -> None:
- subdir = tmp_path / "audio_only"
- subdir.mkdir()
- (subdir / "track.wav").touch()
- browser_manager.refresh_tree()
- assert browser_manager.get_all_reconstruction_files() == []
-
- def test_empty_subdirectory_is_not_returned(
- self,
- browser_manager: BrowserManager,
- tmp_path: Path,
- ) -> None:
- (tmp_path / "empty_dir").mkdir()
- browser_manager.refresh_tree()
- assert browser_manager.get_all_reconstruction_files() == []
-
-
-class TestBrowserManagerFriendlyNames:
- def test_config_directory_gets_friendly_name(
- self,
- browser_manager: BrowserManager,
- tmp_path: Path,
- ) -> None:
- config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PpT_ch_{HASH_A}"
- config_dir.mkdir()
- (config_dir / "song.stn").touch()
-
- browser_manager.refresh_tree()
-
- assert "44.1 kHz·30 Hz·FFT·γ0·PpT" in directory_nodes(browser_manager)
-
- def test_colliding_config_directories_get_hash_suffix(
- self,
- browser_manager: BrowserManager,
- tmp_path: Path,
- ) -> None:
- for config_hash in (HASH_A, HASH_B):
- config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PpT_ch_{config_hash}"
- config_dir.mkdir()
- (config_dir / "song.stn").touch()
-
- browser_manager.refresh_tree()
-
- names = set(directory_nodes(browser_manager))
- assert names == {
- f"44.1 kHz·30 Hz·FFT·γ0·PpT·#{HASH_A[:7]}",
- f"44.1 kHz·30 Hz·FFT·γ0·PpT·#{HASH_B[:7]}",
- }
-
- def test_non_config_directory_keeps_raw_name(
- self,
- browser_manager: BrowserManager,
- tmp_path: Path,
- ) -> None:
- plain = tmp_path / "my_songs"
- plain.mkdir()
- (plain / "song.stn").touch()
-
- browser_manager.refresh_tree()
-
- assert "my_songs" in directory_nodes(browser_manager)
-
-
-class TestBrowserManagerSetDirectory:
- def test_set_reconstructions_directory_updates_directory(
- self,
- browser_manager: BrowserManager,
- tmp_path: Path,
- ) -> None:
- new_dir = tmp_path / "new"
- new_dir.mkdir()
- browser_manager.set_reconstructions_directory(new_dir)
- assert browser_manager.reconstructions_directory == new_dir
-
- def test_set_reconstructions_directory_triggers_refresh(
- self,
- browser_manager: BrowserManager,
- tmp_path: Path,
- ) -> None:
- new_dir = tmp_path / "populated"
- new_dir.mkdir()
- (new_dir / "track.stn").touch()
- browser_manager.set_reconstructions_directory(new_dir)
- assert len(browser_manager.get_all_reconstruction_files()) == 1
-
-
-class TestBrowserManagerGetAllReconstructionFiles:
- def test_returns_paths_for_all_stn_leaves(
- self,
- browser_manager: BrowserManager,
- tmp_path: Path,
- ) -> None:
- (tmp_path / "a.stn").touch()
- subdir = tmp_path / "sub"
- subdir.mkdir()
- (subdir / "b.stn").touch()
- browser_manager.refresh_tree()
- files = browser_manager.get_all_reconstruction_files()
- assert len(files) == 2
- assert {f.name for f in files} == {"a.stn", "b.stn"}
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py
index 9aeeda5a..a8f734e8 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py
@@ -1,8 +1,7 @@
from pathlib import Path
-from typing import Callable, List
+from typing import Callable
import numpy as np
-import pytest
from sampletones_application.logic.reconstruction.data import ReconstructionData
from sampletones_core.audio import write_wave
@@ -18,7 +17,10 @@ def test_wraps_the_same_object_for_live_linking(
) -> None:
reconstruction = reconstruction_factory()
- data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction,
+ name="Sample",
+ )
assert data.reconstruction is reconstruction
@@ -28,14 +30,20 @@ def test_has_no_filepath_for_in_memory_reconstruction(
) -> None:
reconstruction = reconstruction_factory()
- data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction,
+ name="Sample",
+ )
assert data.filepath is None
def test_uses_the_supplied_display_name(
self,
reconstruction_factory: Callable[[], Reconstruction],
) -> None:
- data = ReconstructionData.from_reconstruction(reconstruction_factory(), name="Kick drum")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction_factory(),
+ name="Kick drum",
+ )
assert data.name == "Kick drum"
def test_detached_reconstruction_has_no_original_audio(
@@ -45,7 +53,10 @@ def test_detached_reconstruction_has_no_original_audio(
reconstruction = reconstruction_factory()
reconstruction.detach_source()
- data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction,
+ name="Sample",
+ )
assert reconstruction.audio_filepath is None
assert data.original_audio is None
@@ -56,10 +67,17 @@ def test_loads_original_audio_when_source_file_is_available(
tmp_path: Path,
) -> None:
source_audio = tmp_path / "source.wav"
- write_wave(source_audio, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5)
+ write_wave(
+ source_audio,
+ Config().library.sample_rate,
+ np.ones(64, dtype=np.float32) * 0.5,
+ )
reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": source_audio})
- data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction,
+ name="Sample",
+ )
assert data.original_audio is not None
@@ -99,7 +117,10 @@ def test_produces_a_distinct_reconstruction_object(
tmp_path: Path,
) -> None:
reconstruction = reconstruction_factory()
- data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction,
+ name="Sample",
+ )
copy = data.detached_copy(tmp_path / "lead.stn")
@@ -110,7 +131,10 @@ def test_is_file_backed_at_the_target_path(
reconstruction_factory: Callable[[], Reconstruction],
tmp_path: Path,
) -> None:
- data = ReconstructionData.from_reconstruction(reconstruction_factory(), name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction_factory(),
+ name="Sample",
+ )
target = tmp_path / "lead.stn"
copy = data.detached_copy(target)
@@ -124,7 +148,10 @@ def test_names_after_the_file_when_audio_is_detached(
) -> None:
reconstruction = reconstruction_factory()
reconstruction.detach_source()
- data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction,
+ name="Sample",
+ )
copy = data.detached_copy(tmp_path / "lead.stn")
@@ -136,7 +163,10 @@ def test_names_after_the_source_audio_when_present(
tmp_path: Path,
) -> None:
reconstruction = reconstruction_factory()
- data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction,
+ name="Sample",
+ )
copy = data.detached_copy(tmp_path / "lead.stn")
@@ -149,9 +179,16 @@ def test_reuses_the_already_loaded_original_audio(
tmp_path: Path,
) -> None:
source_audio = tmp_path / "source.wav"
- write_wave(source_audio, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5)
+ write_wave(
+ source_audio,
+ Config().library.sample_rate,
+ np.ones(64, dtype=np.float32) * 0.5,
+ )
reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": source_audio})
- data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction,
+ name="Sample",
+ )
copy = data.detached_copy(tmp_path / "lead.stn")
@@ -165,7 +202,10 @@ def test_projects_the_render_relevant_fields(
reconstruction_factory: Callable[[], Reconstruction],
) -> None:
reconstruction = reconstruction_factory()
- data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction,
+ name="Sample",
+ )
waveform_data = data.waveform_data()
@@ -182,7 +222,10 @@ def test_empty_generator_list_returns_zeros(
reconstruction_factory: Callable[[], Reconstruction],
) -> None:
reconstruction = reconstruction_factory()
- data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction,
+ name="Sample",
+ )
result = data.get_partials([])
@@ -193,7 +236,10 @@ def test_unknown_generator_returns_zeros(
reconstruction_factory: Callable[[], Reconstruction],
) -> None:
reconstruction = reconstruction_factory()
- data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction,
+ name="Sample",
+ )
result = data.get_partials([GeneratorName.TRIANGLE])
@@ -204,7 +250,10 @@ def test_known_generator_returns_its_approximation(
reconstruction_factory: Callable[[], Reconstruction],
) -> None:
reconstruction = reconstruction_factory()
- data = ReconstructionData.from_reconstruction(reconstruction, name="Sample")
+ data = ReconstructionData.from_reconstruction(
+ reconstruction,
+ name="Sample",
+ )
result = data.get_partials([GeneratorName.PULSE1])
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py
index 115bb349..96bb0d6b 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py
@@ -11,7 +11,9 @@
@pytest.fixture
-def reconstruction(reconstruction_factory: Callable[[], Reconstruction]) -> Reconstruction:
+def reconstruction(
+ reconstruction_factory: Callable[[], Reconstruction],
+) -> Reconstruction:
return reconstruction_factory()
@@ -22,11 +24,20 @@ def feature_data(reconstruction: Reconstruction) -> FeatureData:
class TestFeatureDataLoad:
def test_load_creates_entry_for_each_generator(
+ self,
+ feature_data: FeatureData,
+ ) -> None:
+ assert set(feature_data.generators.keys()) == set(GeneratorName.items())
+
+ def test_a_channel_standing_by_carries_empty_envelopes(
self,
reconstruction: Reconstruction,
feature_data: FeatureData,
) -> None:
- assert set(feature_data.generators.keys()) == set(reconstruction.approximations.keys())
+ """A channel the reconstruction leaves silent is loaded describing no frame."""
+ standing_by = set(GeneratorName.items()) - set(reconstruction.playing_generators)
+ assert standing_by
+ assert all(not feature_data[generator_name].has_frames for generator_name in standing_by)
def test_loaded_features_include_initial_pitch(
self,
@@ -37,15 +48,10 @@ def test_loaded_features_include_initial_pitch(
class TestFeatureDataQueries:
- def test_get_generator_features_returns_features_for_present(
- self,
- feature_data: FeatureData,
- ) -> None:
- result = feature_data.get_generator_features(GeneratorName.PULSE1)
- assert isinstance(result, Features)
-
- def test_get_generator_features_returns_none_for_absent(
+ @pytest.mark.parametrize("generator_name", GeneratorName.items(), ids=lambda name: name.value)
+ def test_every_channel_answers_with_its_features(
self,
feature_data: FeatureData,
+ generator_name: GeneratorName,
) -> None:
- assert feature_data.get_generator_features(GeneratorName.TRIANGLE) is None
+ assert isinstance(feature_data[generator_name], Features)
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py
index 5b0ce1b7..3ab55ba9 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py
@@ -1,18 +1,24 @@
-from __future__ import annotations
-
from typing import Callable, Dict, List, Optional
from unittest.mock import MagicMock
import numpy as np
import pytest
-from sampletones_application.layout.behavior import SchedulingBehavior
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.logic.reconstruction.feature import FeatureData
-from sampletones_application.logic.reconstruction.instruments import ReconstructionInstrumentsLogic
+from sampletones_application.logic.reconstruction.instruments import (
+ ReconstructionInstrumentsLogic,
+)
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
-from sampletones_application.view_model.reconstruction.instruments import ReconstructionInstrumentsViewModel
+from sampletones_application.view_model.reconstruction.instruments import (
+ ReconstructionInstrumentsViewModel,
+)
from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.exporters import Features
+from sampletones_core.formats.famitracker.footprint import (
+ features_footprint,
+ total_footprint,
+)
from sampletones_core.reconstructions import Reconstruction
@@ -23,7 +29,8 @@ def mock_reconstruction_manager() -> MagicMock:
@pytest.fixture
def instruments_logic(
- mock_reconstruction_manager: MagicMock, scheduling: SchedulingBehavior
+ mock_reconstruction_manager: MagicMock,
+ scheduling: SchedulingBehavior,
) -> ReconstructionInstrumentsLogic:
return ReconstructionInstrumentsLogic(
mock_reconstruction_manager,
@@ -80,7 +87,7 @@ def test_with_features_fires_on_feature_data_changed_with_data(
instruments_logic.update_display()
assert received == [feature_data.generators]
- def test_with_features_exposes_available_generators(
+ def test_with_features_exposes_the_playing_generators(
self,
instruments_logic: ReconstructionInstrumentsLogic,
mock_reconstruction_manager: MagicMock,
@@ -90,7 +97,142 @@ def test_with_features_exposes_available_generators(
received: List[ReconstructionInstrumentsViewModel] = []
instruments_logic.on_view_changed = received.append
instruments_logic.update_display()
- assert GeneratorName.PULSE1 in received[0].available_generators
+ assert GeneratorName.PULSE1 in received[0].playing_generators
+
+
+class TestReconstructionInstrumentsLogicFootprint:
+ """The byte figures the view carries, measured from the envelopes the manager holds."""
+
+ def test_no_reconstruction_carries_no_footprint(
+ self,
+ instruments_logic: ReconstructionInstrumentsLogic,
+ mock_reconstruction_manager: MagicMock,
+ ) -> None:
+ mock_reconstruction_manager.current_features = None
+ received: List[ReconstructionInstrumentsViewModel] = []
+ instruments_logic.on_view_changed = received.append
+ instruments_logic.update_display()
+ assert received[0].footprint is None
+
+ def test_every_playing_channel_is_measured(
+ self,
+ instruments_logic: ReconstructionInstrumentsLogic,
+ mock_reconstruction_manager: MagicMock,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ feature_data = FeatureData.load(reconstruction_factory())
+ mock_reconstruction_manager.current_features = feature_data
+ received: List[ReconstructionInstrumentsViewModel] = []
+ instruments_logic.on_view_changed = received.append
+ instruments_logic.update_display()
+ footprint = received[0].footprint
+ assert footprint is not None
+ assert {instrument.generator for instrument in footprint.instruments} == {
+ generator_name for generator_name, features in feature_data.generators.items() if features.has_frames
+ }
+
+ def test_the_size_is_the_one_a_one_shot_export_writes(
+ self,
+ instruments_logic: ReconstructionInstrumentsLogic,
+ mock_reconstruction_manager: MagicMock,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ """A reconstruction exports its instruments as one-shots, so that is the size shown."""
+ feature_data = FeatureData.load(reconstruction_factory())
+ mock_reconstruction_manager.current_features = feature_data
+ received: List[ReconstructionInstrumentsViewModel] = []
+ instruments_logic.on_view_changed = received.append
+ instruments_logic.update_display()
+ footprint = received[0].footprint
+ assert footprint is not None
+ expected = total_footprint(
+ features_footprint(features, loop=False)
+ for features in feature_data.generators.values()
+ if features.has_frames
+ )
+ assert footprint.total_bytes == expected.total_bytes
+
+ def test_an_envelope_edit_is_measured_as_it_arrives(
+ self,
+ instruments_logic: ReconstructionInstrumentsLogic,
+ mock_reconstruction_manager: MagicMock,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ """The typed envelope is measured at once, so the figure answers what is on screen."""
+ feature_data = FeatureData.load(reconstruction_factory())
+ mock_reconstruction_manager.current_features = feature_data
+ received: List[ReconstructionInstrumentsViewModel] = []
+ instruments_logic.on_view_changed = received.append
+
+ volume = np.array([15, 12, 8, 4, 0], dtype=np.int8)
+ instruments_logic.handle_raw_data_changed(
+ GeneratorName.PULSE1,
+ FeatureKey.VOLUME,
+ volume,
+ )
+
+ edited = feature_data.generators[GeneratorName.PULSE1].model_copy(deep=True)
+ edited[FeatureKey.VOLUME] = volume
+ footprint = received[0].footprint
+ assert footprint is not None
+ assert footprint.bytes_for(GeneratorName.PULSE1) == features_footprint(edited, loop=False).total_bytes
+
+ def test_a_bar_edit_is_measured_as_it_arrives(
+ self,
+ instruments_logic: ReconstructionInstrumentsLogic,
+ mock_reconstruction_manager: MagicMock,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ feature_data = FeatureData.load(reconstruction_factory())
+ mock_reconstruction_manager.current_features = feature_data
+ received: List[ReconstructionInstrumentsViewModel] = []
+ instruments_logic.on_view_changed = received.append
+
+ instruments_logic.handle_bar_point_clicked(
+ GeneratorName.PULSE1,
+ FeatureKey.ARPEGGIO,
+ np.zeros(6, dtype=np.int8),
+ )
+
+ assert received[0].footprint is not None
+
+ def test_measuring_an_edit_leaves_the_loaded_envelopes_as_they_are(
+ self,
+ instruments_logic: ReconstructionInstrumentsLogic,
+ mock_reconstruction_manager: MagicMock,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ """The regeneration owns the loaded envelopes, so the measurement reads a copy."""
+ feature_data = FeatureData.load(reconstruction_factory())
+ mock_reconstruction_manager.current_features = feature_data
+ loaded_volume = feature_data.generators[GeneratorName.PULSE1].volume.copy()
+
+ instruments_logic.handle_raw_data_changed(
+ GeneratorName.PULSE1,
+ FeatureKey.VOLUME,
+ np.array([15, 12, 8, 4, 0], dtype=np.int8),
+ )
+
+ assert np.array_equal(feature_data.generators[GeneratorName.PULSE1].volume, loaded_volume)
+
+ def test_a_refresh_reports_the_view_alone(
+ self,
+ instruments_logic: ReconstructionInstrumentsLogic,
+ mock_reconstruction_manager: MagicMock,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ """A regenerated reconstruction refreshes the figures, leaving the edited envelopes displayed."""
+ mock_reconstruction_manager.current_features = FeatureData.load(reconstruction_factory())
+ received: List[ReconstructionInstrumentsViewModel] = []
+ feature_updates: List[Optional[Dict[GeneratorName, Features]]] = []
+ instruments_logic.on_view_changed = received.append
+ instruments_logic.on_feature_data_changed = feature_updates.append
+
+ instruments_logic.refresh_view()
+
+ assert len(received) == 1
+ assert received[0].footprint is not None
+ assert feature_updates == []
class TestReconstructionInstrumentsLogicHandlePitchValueChanged:
diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py
index 242e85f6..0d426cfb 100644
--- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py
+++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py
@@ -1,6 +1,4 @@
-from __future__ import annotations
-
-from dataclasses import dataclass
+from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Dict, Final, List
from unittest.mock import MagicMock
@@ -10,7 +8,9 @@
from sampletones_application.logic.reconstruction.data import ReconstructionData
from sampletones_application.logic.reconstruction.manager import ReconstructionManager
-from sampletones_application.logic.reconstruction.reconstruction import ReconstructionPanelLogic
+from sampletones_application.logic.reconstruction.reconstruction import (
+ ReconstructionPanelLogic,
+)
from sampletones_application.view_model.reconstruction.reconstruction import (
ReconstructionPathState,
ReconstructionViewModel,
@@ -18,15 +18,17 @@
from sampletones_core.audio import write_wave
from sampletones_core.configs import Config
from sampletones_core.constants.enums import AudioSourceType, GeneratorName
-from sampletones_core.paths import (
+from sampletones_core.instructions import TriangleInstruction
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.trackers.format import TrackerFormat
+from sampletones_core.trackers.registry import build_tracker_backends
+from sampletones_shared.paths.extensions import (
EXT_FILE_BITPHASE,
EXT_FILE_INSTRUMENT,
EXT_FILE_JSON,
EXT_FILE_MODULE,
)
-from sampletones_core.reconstructions import Reconstruction
-from sampletones_core.trackers.format import TrackerFormat
-from sampletones_core.trackers.registry import build_tracker_backends
+from tests.suite.case import BaseRegularTestCase
NO_EXTENSION: Final[str] = ""
@@ -100,8 +102,13 @@ def mock_tracker_backends() -> Dict[TrackerFormat, MagicMock]:
@pytest.fixture
-def loaded_data(reconstruction_factory: Callable[[], Reconstruction]) -> ReconstructionData:
- return ReconstructionData.from_reconstruction(reconstruction_factory(), name="Sample")
+def loaded_data(
+ reconstruction_factory: Callable[[], Reconstruction],
+) -> ReconstructionData:
+ return ReconstructionData.from_reconstruction(
+ reconstruction_factory(),
+ name="Sample",
+ )
@pytest.fixture
@@ -110,41 +117,15 @@ def data_with_original_audio(
tmp_path: Path,
) -> ReconstructionData:
source_audio = tmp_path / "source.wav"
- write_wave(source_audio, Config().library.sample_rate, np.ones(64, dtype=np.float32) * 0.5)
+ write_wave(
+ source_audio,
+ Config().library.sample_rate,
+ np.ones(64, dtype=np.float32) * 0.5,
+ )
reconstruction = reconstruction_factory().model_copy(update={"audio_filepath": source_audio})
return ReconstructionData.from_reconstruction(reconstruction, name="Sample")
-@dataclass(frozen=True)
-class AudioPathCase:
- label: str
- has_filepath: bool
- has_content: bool
- expected_state: ReconstructionPathState
-
-
-audio_path_cases = [
- AudioPathCase(
- "detached",
- has_filepath=False,
- has_content=False,
- expected_state=ReconstructionPathState.NOT_APPLICABLE,
- ),
- AudioPathCase(
- "recorded_but_unavailable",
- has_filepath=True,
- has_content=False,
- expected_state=ReconstructionPathState.NOT_FOUND,
- ),
- AudioPathCase(
- "available",
- has_filepath=True,
- has_content=True,
- expected_state=ReconstructionPathState.AVAILABLE,
- ),
-]
-
-
class TestReconstructionPanelLogicDisplay:
def test_display_with_no_data_is_no_op(
self,
@@ -202,7 +183,8 @@ def test_detached_reconstruction_reports_both_locations_not_applicable(
reconstruction = reconstruction_factory()
reconstruction.detach_source()
mock_reconstruction_manager.current_reconstruction = ReconstructionData.from_reconstruction(
- reconstruction, name="Sample"
+ reconstruction,
+ name="Sample",
)
captured: List[ReconstructionViewModel] = []
panel_logic.on_view_changed = captured.append
@@ -213,14 +195,44 @@ def test_detached_reconstruction_reports_both_locations_not_applicable(
assert view_model.reconstruction_file.state is ReconstructionPathState.NOT_APPLICABLE
assert view_model.original_audio.state is ReconstructionPathState.NOT_APPLICABLE
- @pytest.mark.parametrize("case", audio_path_cases, ids=lambda case: case.label)
+ @dataclass(frozen=True, kw_only=True)
+ class AudioPathCase(BaseRegularTestCase):
+ has_filepath: bool
+ has_content: bool
+ expected: ReconstructionPathState
+
+ test_cases = (
+ AudioPathCase(
+ label="detached",
+ has_filepath=False,
+ has_content=False,
+ expected=ReconstructionPathState.NOT_APPLICABLE,
+ ),
+ AudioPathCase(
+ label="recorded_but_unavailable",
+ has_filepath=True,
+ has_content=False,
+ expected=ReconstructionPathState.NOT_FOUND,
+ ),
+ AudioPathCase(
+ label="available",
+ has_filepath=True,
+ has_content=True,
+ expected=ReconstructionPathState.AVAILABLE,
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
def test_audio_path_state_follows_loaded_content(self, case: AudioPathCase) -> None:
audio_filepath = Path("/songs/source.wav") if case.has_filepath else None
original_audio = np.zeros(4, dtype=np.float32) if case.has_content else None
- view_model = ReconstructionPanelLogic._build_audio_path_view_model(audio_filepath, original_audio)
+ view_model = ReconstructionPanelLogic._build_audio_path_view_model(
+ audio_filepath,
+ original_audio,
+ )
- assert view_model.state is case.expected_state
+ assert view_model.state is case.expected
class TestReconstructionPanelLogicUpdate:
@@ -271,6 +283,103 @@ def test_update_skips_audio_when_source_is_original(
callback.assert_not_called()
+class TestReconstructionPanelLogicPlayingChannels:
+ """Which channels the waveform offers, and what an edit does to the reader's choice."""
+
+ @staticmethod
+ def _received(panel_logic: ReconstructionPanelLogic) -> List[ReconstructionViewModel]:
+ received: List[ReconstructionViewModel] = []
+ panel_logic.on_view_changed = received.append
+ return received
+
+ def test_display_offers_the_channels_that_play(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ loaded_data: ReconstructionData,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = loaded_data
+ received = self._received(panel_logic)
+
+ panel_logic.display_reconstruction()
+
+ assert received[0].playing_generators == frozenset({GeneratorName.PULSE1})
+ assert received[0].selected_generators == frozenset({GeneratorName.PULSE1})
+
+ def test_an_edit_reports_the_view_again(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ loaded_data: ReconstructionData,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = loaded_data
+ panel_logic.display_reconstruction()
+ received = self._received(panel_logic)
+
+ panel_logic.update_reconstruction()
+
+ assert received[0].playing_generators == frozenset({GeneratorName.PULSE1})
+
+ def test_a_channel_switched_off_by_hand_survives_an_edit(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ loaded_data: ReconstructionData,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = loaded_data
+ panel_logic.display_reconstruction()
+ panel_logic.set_selected_generators([])
+ received = self._received(panel_logic)
+
+ panel_logic.update_reconstruction()
+
+ assert received[0].selected_generators == frozenset()
+
+ def test_a_channel_gaining_its_first_frame_joins_the_waveform(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ loaded_data: ReconstructionData,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = loaded_data
+ panel_logic.display_reconstruction()
+ loaded_data.reconstruction.update_generator_data(
+ GeneratorName.TRIANGLE,
+ [TriangleInstruction(on=True, pitch=48)],
+ np.ones(64, dtype=np.float32),
+ 48,
+ (),
+ )
+ received = self._received(panel_logic)
+
+ panel_logic.update_reconstruction()
+
+ assert received[0].playing_generators == frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE})
+ assert received[0].selected_generators == frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE})
+
+ def test_a_channel_taken_out_of_play_leaves_the_waveform(
+ self,
+ panel_logic: ReconstructionPanelLogic,
+ mock_reconstruction_manager: MagicMock,
+ loaded_data: ReconstructionData,
+ ) -> None:
+ mock_reconstruction_manager.current_reconstruction = loaded_data
+ panel_logic.display_reconstruction()
+ loaded_data.reconstruction.update_generator_data(
+ GeneratorName.PULSE1,
+ [],
+ np.zeros(0, dtype=np.float32),
+ 60,
+ (),
+ )
+ received = self._received(panel_logic)
+
+ panel_logic.update_reconstruction()
+
+ assert received[0].playing_generators == frozenset()
+ assert received[0].selected_generators == frozenset()
+
+
class TestReconstructionPanelLogicClose:
def test_close_fires_on_waveform_cleared(
self,
@@ -469,7 +578,10 @@ def test_handle_export_instrument_confirmed_with_no_data_does_not_export(
mock_export_service: MagicMock,
tmp_path: Path,
) -> None:
- panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti", GeneratorName.PULSE1)
+ panel_logic.handle_export_instrument_confirmed(
+ tmp_path / "instrument.fti",
+ GeneratorName.PULSE1,
+ )
mock_export_service.export_instrument.assert_not_called()
def test_handle_export_instrument_confirmed_calls_export_service(
@@ -481,7 +593,10 @@ def test_handle_export_instrument_confirmed_calls_export_service(
tmp_path: Path,
) -> None:
mock_reconstruction_manager.current_reconstruction = loaded_data
- panel_logic.handle_export_instrument_confirmed(tmp_path / "instrument.fti", GeneratorName.PULSE1)
+ panel_logic.handle_export_instrument_confirmed(
+ tmp_path / "instrument.fti",
+ GeneratorName.PULSE1,
+ )
mock_export_service.export_instrument.assert_called_once()
def test_handle_export_instrument_confirmed_names_the_instrument_after_the_destination(
@@ -493,7 +608,10 @@ def test_handle_export_instrument_confirmed_names_the_instrument_after_the_desti
tmp_path: Path,
) -> None:
mock_reconstruction_manager.current_reconstruction = loaded_data
- panel_logic.handle_export_instrument_confirmed(tmp_path / "Clap (pulse1).fti", GeneratorName.PULSE1)
+ panel_logic.handle_export_instrument_confirmed(
+ tmp_path / "Clap (pulse1).fti",
+ GeneratorName.PULSE1,
+ )
request = mock_export_service.export_instrument.call_args.args[2]
assert request.name == "Clap (pulse1)"
@@ -628,7 +746,11 @@ def test_handle_export_instruments_confirmed_names_the_batch_after_the_destinati
assert request.name == "Clap"
assert [instrument.name for instrument in request.instruments] == ["Clap (pulse1)"]
- @pytest.mark.parametrize("case", INSTRUMENT_FORMAT_CASES, ids=lambda case: case.extension)
+ @pytest.mark.parametrize(
+ "case",
+ INSTRUMENT_FORMAT_CASES,
+ ids=lambda case: case.extension,
+ )
def test_handle_export_instruments_confirmed_writes_through_the_chosen_tracker(
self,
panel_logic: ReconstructionPanelLogic,
diff --git a/tests/unit/sampletones_application/logic/render/__init__.py b/tests/unit/sampletones_application/logic/render/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/logic/render/test_logic.py b/tests/unit/sampletones_application/logic/render/test_logic.py
new file mode 100644
index 00000000..df2ee241
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/render/test_logic.py
@@ -0,0 +1,307 @@
+from pathlib import Path
+from typing import Final, List
+from unittest.mock import MagicMock
+
+import pytest
+
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.render.logic import SongRenderLogic
+from sampletones_application.logic.sequencer.playback.synthesizer import (
+ RowSynthesizer,
+ SongLength,
+)
+from sampletones_application.services.render.result import RenderStage
+from sampletones_application.services.result import (
+ ServiceCancelled,
+ ServiceError,
+ ServiceProgress,
+ ServiceSuccess,
+)
+from sampletones_application.view_model.shared.render import (
+ RenderPhase,
+ SongRenderViewModel,
+)
+from sampletones_core.audio.writers import AudioFormat
+from sampletones_core.configs import Config
+from tests.suite.language import FakeLanguageManager
+from tests.suite.render import FakeRenderService
+
+AUDIO_DIRECTORY: Final[Path] = Path("/home/user/audio")
+PROJECT_NAME: Final[str] = "chiptune"
+LOW_RATE: Final[int] = 8000
+
+
+class RenderFixture:
+ """A render logic wired to a real project and a service that records what it is asked for."""
+
+ def __init__(self, *, operation_active: bool = False, accepts: bool = True) -> None:
+ project_manager = ProjectManager()
+ project_manager.session.mark_loaded(PROJECT_NAME)
+ self.controller = ProjectController(project_manager)
+ self.session_manager = MagicMock()
+ self.session_manager.get_audio_path.return_value = AUDIO_DIRECTORY
+ self.service = FakeRenderService(accepts=accepts)
+ self.views: List[SongRenderViewModel] = []
+ self.logic = SongRenderLogic(
+ self.controller,
+ MagicMock(config=Config()),
+ self.session_manager,
+ self.service,
+ language_manager=FakeLanguageManager(), # type: ignore[arg-type]
+ is_operation_active=lambda: operation_active,
+ )
+ self.logic.on_view_changed = self.views.append
+
+ @property
+ def view(self) -> SongRenderViewModel:
+ assert self.views, "A view was expected to be emitted"
+ return self.views[-1]
+
+ def configure(self) -> None:
+ self.logic.open()
+
+ def start_at(self, sample_rate: int) -> None:
+ self.configure()
+ self.logic.apply(self.view.settings.with_sample_rate(sample_rate))
+ self.logic.start()
+
+
+@pytest.fixture
+def render() -> RenderFixture:
+ return RenderFixture()
+
+
+def render_whole_song(synthesizer: RowSynthesizer) -> int:
+ """The samples a kernel produces when the song is played from its first row to its last."""
+ synthesizer.set_position(0, 0)
+ synthesizer.reset()
+ rendered = 0
+ while not synthesizer.is_finished:
+ chunk, _ = synthesizer.render_row()
+ rendered += len(chunk)
+
+ return rendered
+
+
+class TestOfferingTheRender:
+ def test_the_dialog_opens_on_a_destination_named_after_the_project(
+ self,
+ render: RenderFixture,
+ ) -> None:
+ render.configure()
+
+ assert render.view.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav"
+ assert render.view.phase == RenderPhase.CONFIGURING
+
+ def test_the_render_occupies_the_application_from_the_dialog_opening(
+ self,
+ render: RenderFixture,
+ ) -> None:
+ assert not render.logic.is_active
+
+ render.configure()
+
+ assert render.logic.is_active
+
+ def test_another_exclusive_operation_holds_the_dialog_closed(self) -> None:
+ render = RenderFixture(operation_active=True)
+
+ assert not render.logic.open()
+ assert not render.logic.is_active
+ assert not render.views
+
+ def test_closing_releases_the_application(self, render: RenderFixture) -> None:
+ render.configure()
+
+ render.logic.close()
+
+ assert not render.logic.is_active
+
+
+class TestTheDestinationFollowsTheFormat:
+ def test_choosing_another_container_renames_the_file(self, render: RenderFixture) -> None:
+ render.configure()
+
+ render.logic.apply(render.view.settings.with_format(AudioFormat.MP3))
+
+ assert render.view.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.mp3"
+
+ def test_a_chosen_destination_is_remembered_for_the_next_render(
+ self,
+ render: RenderFixture,
+ ) -> None:
+ render.configure()
+ chosen = Path("/home/user/renders/take one.wav")
+
+ render.logic.set_destination(chosen)
+
+ assert render.view.destination == chosen
+ render.session_manager.set_audio_path.assert_called_once_with(chosen)
+
+ def test_the_destination_is_asked_for_from_where_it_stands(self, render: RenderFixture) -> None:
+ render.configure()
+ on_choose_destination = MagicMock()
+ render.logic.on_choose_destination = on_choose_destination
+
+ render.logic.request_destination()
+
+ on_choose_destination.assert_called_once_with(
+ AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav",
+ AudioFormat.WAVE,
+ )
+
+
+class TestStartingTheRender:
+ def test_the_service_is_asked_for_the_chosen_file(self, render: RenderFixture) -> None:
+ render.configure()
+ render.logic.apply(render.view.settings.with_normalize(True))
+
+ render.logic.start()
+
+ request = render.service.request
+ assert request.destination == render.view.destination
+ assert request.spec == render.view.settings.spec
+ assert request.normalize
+
+ def test_the_song_is_measured_at_the_rate_it_is_written_at(self, render: RenderFixture) -> None:
+ render.start_at(LOW_RATE)
+
+ expected = SongLength.measure(render.controller.project, sample_rate=LOW_RATE)
+ assert render.service.request.total_samples == expected.samples
+
+ def test_the_kernel_renders_the_measured_song_over_a_held_document(
+ self,
+ render: RenderFixture,
+ ) -> None:
+ """The kernel reads a snapshot at the chosen rate, so the audio it produces is the length
+ the service was told to expect however the project moves on."""
+ render.start_at(LOW_RATE)
+ request = render.service.request
+
+ render.controller.set_tempo(render.controller.project.settings.tempo + 40)
+
+ assert render_whole_song(request.synthesizer) == request.total_samples
+
+ def test_a_declined_request_leaves_the_dialog_setting_up(self) -> None:
+ render = RenderFixture(accepts=False)
+ render.configure()
+
+ render.logic.start()
+
+ assert render.view.phase == RenderPhase.CONFIGURING
+
+ def test_a_render_starts_from_the_setup_alone(self, render: RenderFixture) -> None:
+ render.configure()
+ render.logic.start()
+
+ render.logic.start()
+
+ assert len(render.service.requests) == 1
+
+
+class TestReportingTheRender:
+ def test_a_pass_reports_how_far_it_has_got(self, render: RenderFixture) -> None:
+ render.configure()
+ render.logic.start()
+
+ render.service.emit(
+ ServiceProgress(
+ completed=25,
+ total=100,
+ current_item=RenderStage.SYNTHESIS,
+ )
+ )
+
+ assert render.view.phase == RenderPhase.RENDERING
+ assert render.view.progress == 0.25
+ assert render.view.status_text.startswith("settings.render.message.status_synthesis")
+
+ def test_the_second_pass_names_itself(self, render: RenderFixture) -> None:
+ render.configure()
+ render.logic.start()
+
+ render.service.emit(
+ ServiceProgress(
+ completed=50,
+ total=100,
+ current_item=RenderStage.ENCODING,
+ )
+ )
+
+ assert render.view.status_text.startswith("settings.render.message.status_encoding")
+
+ def test_a_stop_holds_its_message_over_the_reports_still_arriving(
+ self,
+ render: RenderFixture,
+ ) -> None:
+ render.configure()
+ render.logic.start()
+ render.logic.cancel()
+
+ render.service.emit(
+ ServiceProgress(
+ completed=75,
+ total=100,
+ current_item=RenderStage.SYNTHESIS,
+ )
+ )
+
+ assert render.view.phase == RenderPhase.CANCELLING
+ assert render.view.status_text == "settings.render.message.status_cancelling"
+
+ def test_a_stop_reaches_the_service(self, render: RenderFixture) -> None:
+ render.configure()
+ render.logic.start()
+
+ render.logic.cancel()
+
+ assert render.service.cancels == 1
+
+ def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture) -> None:
+ render.configure()
+ render.logic.start()
+ on_success = MagicMock()
+ render.logic.on_success = on_success
+ written = AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav"
+
+ render.service.emit(ServiceSuccess(value=written))
+
+ on_success.assert_called_once_with(written)
+ assert render.view.phase == RenderPhase.COMPLETED
+ assert render.view.progress == 1.0
+ assert not render.logic.is_active
+
+ def test_a_stopped_render_reports_the_cancellation(self, render: RenderFixture) -> None:
+ render.configure()
+ render.logic.start()
+ on_cancelled = MagicMock()
+ render.logic.on_cancelled = on_cancelled
+
+ render.logic.cancel()
+ render.service.emit(ServiceCancelled())
+
+ on_cancelled.assert_called_once()
+ assert render.view.phase == RenderPhase.CANCELLED
+ assert not render.logic.is_active
+
+ def test_a_failed_render_reports_what_went_wrong(self, render: RenderFixture) -> None:
+ render.configure()
+ render.logic.start()
+ on_error = MagicMock()
+ render.logic.on_error = on_error
+ failure = OSError("no room on the device")
+
+ render.service.emit(ServiceError(exception=failure))
+
+ on_error.assert_called_once_with(failure)
+ assert render.view.phase == RenderPhase.FAILED
+ assert not render.logic.is_active
+
+ def test_exit_winds_a_running_render_down(self, render: RenderFixture) -> None:
+ render.configure()
+ render.logic.start()
+
+ render.logic.cleanup()
+
+ assert render.service.shutdowns == 1
diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/__init__.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_cache.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_cache.py
new file mode 100644
index 00000000..a425d534
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_cache.py
@@ -0,0 +1,60 @@
+from typing import List, Optional
+
+from sampletones_application.logic.sequencer.clipboard.cache import ParsedBlockCache
+
+BLOCK = "SampleToNES/1 tracker rows=1 slots=3..5"
+OTHER = "SampleToNES/1 order rows=1 positions=0..0"
+
+
+class FakeParser:
+ """A parser recording every text it was put to, reading each one as its own length."""
+
+ def __init__(self) -> None:
+ self.asked: List[str] = []
+
+ def parse(self, text: str) -> Optional[int]:
+ self.asked.append(text)
+ return len(text) if text.startswith("SampleToNES") else None
+
+
+class TestReadingTheSameTextTwice:
+ def test_a_text_asked_about_again_is_read_once(self) -> None:
+ parser = FakeParser()
+ cache: ParsedBlockCache[int] = ParsedBlockCache(parser.parse)
+
+ first = cache.block(BLOCK)
+ second = cache.block(BLOCK)
+
+ assert first == second == len(BLOCK)
+ assert parser.asked == [BLOCK]
+
+ def test_a_text_reading_as_no_block_is_held_the_same_way(self) -> None:
+ """A menu opening over unrelated text costs one comparison, as one over a block does."""
+ parser = FakeParser()
+ cache: ParsedBlockCache[int] = ParsedBlockCache(parser.parse)
+
+ assert cache.block("a message") is None
+ assert cache.block("a message") is None
+ assert parser.asked == ["a message"]
+
+
+class TestReadingAnotherText:
+ def test_text_replaced_on_the_clipboard_is_read_afresh(self) -> None:
+ parser = FakeParser()
+ cache: ParsedBlockCache[int] = ParsedBlockCache(parser.parse)
+
+ cache.block(BLOCK)
+ second = cache.block(OTHER)
+
+ assert second == len(OTHER)
+ assert parser.asked == [BLOCK, OTHER]
+
+ def test_returning_to_an_earlier_text_reads_it_again(self) -> None:
+ parser = FakeParser()
+ cache: ParsedBlockCache[int] = ParsedBlockCache(parser.parse)
+
+ cache.block(BLOCK)
+ cache.block(OTHER)
+
+ assert cache.block(BLOCK) == len(BLOCK)
+ assert parser.asked == [BLOCK, OTHER, BLOCK]
diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_header.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_header.py
new file mode 100644
index 00000000..cd3eec82
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_header.py
@@ -0,0 +1,79 @@
+from dataclasses import dataclass
+from typing import List, Optional
+
+import pytest
+
+from sampletones_application.logic.sequencer.clipboard.header import (
+ BLOCK_MAGIC,
+ BlockShape,
+ parse_header,
+ state_header,
+)
+
+GRID = "tracker"
+SPAN_KEY = "slots"
+
+
+def _parse(line: str) -> Optional[BlockShape]:
+ return parse_header(line, grid=GRID, span_key=SPAN_KEY)
+
+
+class TestStating:
+ def test_a_header_names_the_grid_and_the_shape(self) -> None:
+ shape = BlockShape(rows=4, first=3, last=11)
+
+ line = state_header(grid=GRID, span_key=SPAN_KEY, shape=shape)
+
+ assert line == f"{BLOCK_MAGIC} tracker rows=4 slots=3..11"
+
+ def test_a_span_of_one_slot_names_the_same_bound_twice(self) -> None:
+ shape = BlockShape(rows=1, first=7, last=7)
+
+ line = state_header(grid=GRID, span_key=SPAN_KEY, shape=shape)
+
+ assert line == f"{BLOCK_MAGIC} tracker rows=1 slots=7..7"
+
+
+class TestParsing:
+ def test_a_stated_header_reads_back_as_the_shape_it_named(self) -> None:
+ shape = BlockShape(rows=4, first=3, last=11)
+
+ assert _parse(state_header(grid=GRID, span_key=SPAN_KEY, shape=shape)) == shape
+
+ def test_the_width_counts_both_bounds(self) -> None:
+ assert BlockShape(rows=1, first=3, last=11).width == 9
+
+ def test_surrounding_spaces_leave_the_shape_as_it_stands(self) -> None:
+ assert _parse(f" {BLOCK_MAGIC} tracker rows=2 slots=0..2 ") == BlockShape(rows=2, first=0, last=2)
+
+
+@dataclass(frozen=True)
+class RefusalCase:
+ name: str
+ line: str
+
+
+REFUSALS: List[RefusalCase] = [
+ RefusalCase("another application", "Tracker/1 tracker rows=2 slots=0..2"),
+ RefusalCase("another grid", f"{BLOCK_MAGIC} order rows=2 slots=0..2"),
+ RefusalCase("another span", f"{BLOCK_MAGIC} tracker rows=2 positions=0..2"),
+ RefusalCase("a missing span", f"{BLOCK_MAGIC} tracker rows=2"),
+ RefusalCase("a trailing word", f"{BLOCK_MAGIC} tracker rows=2 slots=0..2 more"),
+ RefusalCase("no rows at all", f"{BLOCK_MAGIC} tracker rows=0 slots=0..2"),
+ RefusalCase("a fractional count", f"{BLOCK_MAGIC} tracker rows=2.5 slots=0..2"),
+ RefusalCase("a negative count", f"{BLOCK_MAGIC} tracker rows=-2 slots=0..2"),
+ RefusalCase("bounds out of order", f"{BLOCK_MAGIC} tracker rows=2 slots=11..3"),
+ RefusalCase("one bound", f"{BLOCK_MAGIC} tracker rows=2 slots=3"),
+ RefusalCase("a wordy bound", f"{BLOCK_MAGIC} tracker rows=2 slots=three..11"),
+ RefusalCase("a label with no value", f"{BLOCK_MAGIC} tracker rows slots=3..11"),
+ RefusalCase("a line of prose", "have a look at this pattern"),
+ RefusalCase("nothing at all", ""),
+]
+
+
+class TestRefusals:
+ """A header states this grid's form, and anything else states no shape at all."""
+
+ @pytest.mark.parametrize("case", REFUSALS, ids=lambda case: case.name)
+ def test_a_header_this_grid_never_wrote_states_no_shape(self, case: RefusalCase) -> None:
+ assert _parse(case.line) is None
diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_order.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_order.py
new file mode 100644
index 00000000..be444d6b
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_order.py
@@ -0,0 +1,163 @@
+from dataclasses import dataclass
+from typing import List
+
+import pytest
+
+from sampletones_application.logic.sequencer.clipboard.order import OrderBlockText
+from sampletones_application.logic.sequencer.order.block import OrderBlock
+from sampletones_application.view_model.sequencer.region import OrderRegion
+
+
+@pytest.fixture
+def text() -> OrderBlockText:
+ return OrderBlockText()
+
+
+def _region(
+ *,
+ rows: int = 1,
+ first_position: int = 0,
+ positions: int = 1,
+ first_row: int = 0,
+) -> OrderRegion:
+ return OrderRegion(
+ first_row=first_row,
+ last_row=first_row + rows - 1,
+ first_position=first_position,
+ last_position=first_position + positions - 1,
+ )
+
+
+def _body(text: OrderBlockText, block: OrderBlock, region: OrderRegion) -> List[str]:
+ return text.state(block, region).splitlines()[1:]
+
+
+class TestTheFormAFieldTakes:
+ """Every field carries what the table shows in its cell."""
+
+ def test_a_pattern_prints_the_index_the_table_shows(self, text: OrderBlockText) -> None:
+ block = OrderBlock(entries={(0, 0): 1, (0, 1): 26})
+
+ assert _body(text, block, _region(positions=2)) == ["01 1A"]
+
+ def test_a_silent_slot_prints_the_dots_beneath_it(self, text: OrderBlockText) -> None:
+ assert _body(text, OrderBlock(entries={(0, 0): None}), _region()) == [".."]
+
+ def test_a_mixed_cell_fills_its_field_with_marks(self, text: OrderBlockText) -> None:
+ assert _body(text, OrderBlock(entries={}), _region()) == ["??"]
+
+ def test_a_row_of_the_block_prints_a_line_of_its_own(self, text: OrderBlockText) -> None:
+ block = OrderBlock(entries={(0, 0): 0, (1, 0): 1, (2, 0): None})
+
+ assert _body(text, block, _region(rows=3)) == ["00", "01", ".."]
+
+
+class TestTheShapeAStatementCovers:
+ def test_a_header_opens_the_text_with_the_grid_and_the_positions(self, text: OrderBlockText) -> None:
+ region = _region(rows=3, first_position=5, positions=4)
+
+ header = text.state(OrderBlock(entries={}), region).splitlines()[0]
+
+ assert header == "SampleToNES/1 order rows=3 positions=5..8"
+
+
+@dataclass(frozen=True)
+class RoundTripCase:
+ name: str
+ block: OrderBlock
+ region: OrderRegion
+
+
+ROUND_TRIPS: List[RoundTripCase] = [
+ RoundTripCase(
+ "the three states across one row",
+ OrderBlock(entries={(0, 0): 3, (0, 1): None}),
+ _region(positions=3),
+ ),
+ RoundTripCase(
+ "a block starting past the first frame",
+ OrderBlock(entries={(0, 0): 1, (0, 1): 2}),
+ _region(first_position=7, positions=2),
+ ),
+ RoundTripCase(
+ "every channel row",
+ OrderBlock(entries={(0, 0): 1, (1, 0): 1, (2, 0): 2, (3, 0): None, (4, 0): 0}),
+ _region(rows=5, positions=1),
+ ),
+ RoundTripCase(
+ "the master row over channels that disagree",
+ OrderBlock(entries={(1, 0): 1, (1, 1): 2, (2, 0): 1}),
+ _region(rows=3, positions=2),
+ ),
+ RoundTripCase(
+ "an index past a single digit",
+ OrderBlock(entries={(0, 0): 255}),
+ _region(),
+ ),
+]
+
+
+class TestRoundTrip:
+ """A block stated as text and read back is the block it set out as."""
+
+ @pytest.mark.parametrize("case", ROUND_TRIPS, ids=lambda case: case.name)
+ def test_a_block_survives_being_stated_and_read(
+ self,
+ text: OrderBlockText,
+ case: RoundTripCase,
+ ) -> None:
+ assert text.parse(text.state(case.block, case.region)) == case.block
+
+ def test_a_master_row_the_channels_disagree_over_states_nothing(self, text: OrderBlockText) -> None:
+ """Its marks reach the reading as an absent key, so a paste passes that cell by."""
+ stated = text.state(OrderBlock(entries={(0, 1): 4}), _region(positions=2))
+
+ assert text.parse(stated) == OrderBlock(entries={(0, 1): 4})
+
+
+class TestTextTypedByHand:
+ def test_hexadecimal_reads_in_either_case(self, text: OrderBlockText) -> None:
+ upper = text.parse("SampleToNES/1 order rows=1 positions=0..1\n0a 1f")
+ lower = text.parse("SampleToNES/1 order rows=1 positions=0..1\n0A 1F")
+
+ assert upper == lower
+ assert upper == OrderBlock(entries={(0, 0): 10, (0, 1): 31})
+
+ def test_a_trailing_line_break_leaves_the_block_as_it_stands(self, text: OrderBlockText) -> None:
+ assert text.parse("SampleToNES/1 order rows=1 positions=0..0\n01\n") is not None
+
+
+@dataclass(frozen=True)
+class RefusalCase:
+ name: str
+ text: str
+
+
+HEADER = "SampleToNES/1 order rows=2 positions=0..1"
+
+REFUSALS: List[RefusalCase] = [
+ RefusalCase("nothing at all", ""),
+ RefusalCase("unrelated text", "the order goes\nintro then verse"),
+ RefusalCase("a header alone", HEADER),
+ RefusalCase("a truncated body", f"{HEADER}\n01 02"),
+ RefusalCase("a body reaching past the header", f"{HEADER}\n01 02\n01 02\n01 02"),
+ RefusalCase("a line short of a field", f"{HEADER}\n01\n01 02"),
+ RefusalCase("a line with a field too many", f"{HEADER}\n01 02 03\n01 02"),
+ RefusalCase("a tracker's block", "SampleToNES/1 tracker rows=1 slots=3..5\n01 +00 F"),
+ RefusalCase("more rows than the table has", "SampleToNES/1 order rows=6 positions=0..0\n01\n01\n01\n01\n01\n01"),
+ RefusalCase("a word in a field", f"{HEADER}\nxx 02\n01 02"),
+ RefusalCase("a signed index", f"{HEADER}\n+1 02\n01 02"),
+ RefusalCase("dots and marks in one field", f"{HEADER}\n.? 02\n01 02"),
+]
+
+
+class TestRefusals:
+ """Text this table never wrote states no block, so the slot the order copied into stands."""
+
+ @pytest.mark.parametrize("case", REFUSALS, ids=lambda case: case.name)
+ def test_text_outside_the_form_states_no_block(
+ self,
+ text: OrderBlockText,
+ case: RefusalCase,
+ ) -> None:
+ assert text.parse(case.text) is None
diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py
new file mode 100644
index 00000000..6e374b73
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py
@@ -0,0 +1,74 @@
+import pytest
+
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.sequencer.clipboard.samples import (
+ ProjectSampleDirectory,
+)
+from sampletones_core.constants.enums import GeneratorName
+from tests.suite.sequencer import sample_reconstruction
+
+
+@pytest.fixture
+def controller() -> ProjectController:
+ controller = ProjectController(ProjectManager())
+ controller.new()
+ return controller
+
+
+@pytest.fixture
+def directory(controller: ProjectController) -> ProjectSampleDirectory:
+ return ProjectSampleDirectory(controller)
+
+
+def _add_sample(controller: ProjectController, name: str) -> str:
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1]),
+ name=name,
+ )
+ return sample.id
+
+
+class TestReadingBothWays:
+ def test_a_sample_stands_at_the_position_it_is_listed_at(
+ self,
+ controller: ProjectController,
+ directory: ProjectSampleDirectory,
+ ) -> None:
+ first = _add_sample(controller, "kick")
+ second = _add_sample(controller, "snare")
+
+ assert directory.position_of(first) == 0
+ assert directory.position_of(second) == 1
+ assert directory.sample_at(0) == first
+ assert directory.sample_at(1) == second
+
+ def test_a_sample_the_project_lacks_stands_nowhere(
+ self,
+ directory: ProjectSampleDirectory,
+ ) -> None:
+ assert directory.position_of("absent") is None
+
+ def test_a_position_the_list_falls_short_of_names_no_sample(
+ self,
+ controller: ProjectController,
+ directory: ProjectSampleDirectory,
+ ) -> None:
+ _add_sample(controller, "kick")
+
+ assert directory.sample_at(1) is None
+ assert directory.sample_at(-1) is None
+
+
+class TestFollowingTheProject:
+ def test_a_sample_added_later_is_reached(
+ self,
+ controller: ProjectController,
+ directory: ProjectSampleDirectory,
+ ) -> None:
+ """The project is read on each lookup, so an undo putting another one in place is followed."""
+ assert directory.sample_at(0) is None
+
+ added = _add_sample(controller, "hat")
+
+ assert directory.sample_at(0) == added
diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py
new file mode 100644
index 00000000..2a20eb0d
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py
@@ -0,0 +1,289 @@
+from dataclasses import dataclass
+from typing import List, Optional
+
+import pytest
+
+from sampletones_application.logic.sequencer.clipboard.tracker import TrackerBlockText
+from sampletones_application.logic.sequencer.tracker.block import TrackerBlock
+from sampletones_application.view_model.sequencer.region import TrackerRegion
+from sampletones_application.view_model.sequencer.slot import TrackerSlot
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.project.instruments.note_off import NoteOff
+
+SAMPLE_IDS: List[str] = ["kick", "snare", "hat"]
+
+
+class FakeSampleDirectory:
+ """A list of samples, standing where the project's own list would."""
+
+ def __init__(self, sample_ids: List[str]) -> None:
+ self._sample_ids = sample_ids
+
+ def position_of(self, sample_id: str) -> Optional[int]:
+ if sample_id not in self._sample_ids:
+ return None
+
+ return self._sample_ids.index(sample_id)
+
+ def sample_at(self, position: int) -> Optional[str]:
+ if 0 <= position < len(self._sample_ids):
+ return self._sample_ids[position]
+
+ return None
+
+
+@pytest.fixture
+def text() -> TrackerBlockText:
+ return TrackerBlockText(samples=FakeSampleDirectory(SAMPLE_IDS))
+
+
+def _slot(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int:
+ return TrackerSlot(generator, subcolumn).flat_index
+
+
+def _region(
+ *,
+ first_slot: int,
+ last_slot: int,
+ rows: int = 1,
+) -> TrackerRegion:
+ return TrackerRegion(
+ first_row=0,
+ last_row=rows - 1,
+ first_slot=first_slot,
+ last_slot=last_slot,
+ )
+
+
+PULSE1_CELL = _region(
+ first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME),
+)
+
+
+def _body(text: TrackerBlockText, block: TrackerBlock, region: TrackerRegion) -> List[str]:
+ return text.state(block, region).splitlines()[1:]
+
+
+class TestTheFormAFieldTakes:
+ """Every field carries what the grid shows in its cell, each kind in its own width."""
+
+ def test_a_cell_of_values_prints_the_three_the_grid_prints(self, text: TrackerBlockText) -> None:
+ block = TrackerBlock(notes={(0, 0): "snare"}, transposes={(0, 1): 0}, volumes={(0, 2): 15})
+
+ assert _body(text, block, PULSE1_CELL) == ["01 +00 F"]
+
+ def test_an_empty_cell_prints_the_dots_beneath_it(self, text: TrackerBlockText) -> None:
+ block = TrackerBlock(notes={(0, 0): None}, transposes={(0, 1): None}, volumes={(0, 2): None})
+
+ assert _body(text, block, PULSE1_CELL) == [".. ... ."]
+
+ def test_a_mixed_cell_fills_its_fields_with_marks(self, text: TrackerBlockText) -> None:
+ block = TrackerBlock(notes={}, transposes={}, volumes={})
+
+ assert _body(text, block, PULSE1_CELL) == ["?? ??? ?"]
+
+ def test_a_cut_prints_the_mark_the_note_column_shows(self, text: TrackerBlockText) -> None:
+ block = TrackerBlock(notes={(0, 0): NoteOff()}, transposes={}, volumes={})
+
+ assert _body(text, block, PULSE1_CELL) == ["~~ ??? ?"]
+
+ def test_a_transpose_below_zero_prints_its_sign(self, text: TrackerBlockText) -> None:
+ block = TrackerBlock(notes={}, transposes={(0, 1): -10}, volumes={})
+
+ assert _body(text, block, PULSE1_CELL) == ["?? -0A ?"]
+
+ def test_a_note_naming_a_sample_the_list_lacks_prints_as_mixed(self, text: TrackerBlockText) -> None:
+ """A paste has nothing to place for it, so the text states nothing about that cell."""
+ block = TrackerBlock(notes={(0, 0): "cowbell"}, transposes={}, volumes={})
+
+ assert _body(text, block, PULSE1_CELL) == ["?? ??? ?"]
+
+
+class TestTheShapeAStatementCovers:
+ def test_a_header_opens_the_text_with_the_grid_and_the_slots(self, text: TrackerBlockText) -> None:
+ region = _region(
+ first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ last_slot=_slot(GeneratorName.PULSE2, SubColumn.VOLUME),
+ rows=4,
+ )
+
+ header = text.state(TrackerBlock(notes={}, transposes={}, volumes={}), region).splitlines()[0]
+
+ assert header == "SampleToNES/1 tracker rows=4 slots=3..8"
+
+ def test_a_bar_stands_between_the_columns_a_row_crosses(self, text: TrackerBlockText) -> None:
+ region = _region(
+ first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ last_slot=_slot(GeneratorName.PULSE2, SubColumn.VOLUME),
+ )
+
+ assert _body(text, TrackerBlock(notes={}, transposes={}, volumes={}), region) == ["?? ??? ? | ?? ??? ?"]
+
+ def test_a_row_of_the_block_prints_a_line_of_its_own(self, text: TrackerBlockText) -> None:
+ block = TrackerBlock(notes={}, transposes={(0, 1): 1, (2, 1): 3}, volumes={})
+ region = _region(
+ first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME),
+ rows=3,
+ )
+
+ assert _body(text, block, region) == ["?? +01 ?", "?? ??? ?", "?? +03 ?"]
+
+
+@dataclass(frozen=True)
+class RoundTripCase:
+ name: str
+ block: TrackerBlock
+ region: TrackerRegion
+
+
+ROUND_TRIPS: List[RoundTripCase] = [
+ RoundTripCase(
+ "the three states across one cell",
+ TrackerBlock(notes={(0, 0): "kick"}, transposes={(0, 1): None}, volumes={}),
+ PULSE1_CELL,
+ ),
+ RoundTripCase(
+ "a cut and an empty note",
+ TrackerBlock(notes={(0, 0): NoteOff(), (1, 0): None}, transposes={}, volumes={}),
+ _region(
+ first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME),
+ rows=2,
+ ),
+ ),
+ RoundTripCase(
+ "the whole transpose range",
+ TrackerBlock(notes={}, transposes={(0, 1): -24, (1, 1): 36, (2, 1): 0}, volumes={}),
+ _region(
+ first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME),
+ rows=3,
+ ),
+ ),
+ RoundTripCase(
+ "the whole volume range",
+ TrackerBlock(notes={}, transposes={}, volumes={(0, 2): 0, (1, 2): 15}),
+ _region(
+ first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME),
+ rows=2,
+ ),
+ ),
+ RoundTripCase(
+ "a block anchored at the sample column",
+ TrackerBlock(notes={(0, 0): "hat"}, transposes={(0, 4): 2}, volumes={(0, 5): 9}),
+ _region(
+ first_slot=_slot(None, SubColumn.INSTRUMENT),
+ last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME),
+ ),
+ ),
+ RoundTripCase(
+ "a block starting and ending mid-cell",
+ TrackerBlock(notes={(0, 3): "snare"}, transposes={(0, 1): 5, (0, 4): None}, volumes={(0, 2): 3}),
+ _region(
+ first_slot=_slot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),
+ last_slot=_slot(GeneratorName.PULSE2, SubColumn.TRANSPOSE),
+ ),
+ ),
+ RoundTripCase(
+ "the whole grid",
+ TrackerBlock(notes={(0, 12): "kick"}, transposes={(1, 1): -1}, volumes={(1, 14): 4}),
+ _region(
+ first_slot=_slot(None, SubColumn.INSTRUMENT),
+ last_slot=_slot(GeneratorName.NOISE, SubColumn.VOLUME),
+ rows=2,
+ ),
+ ),
+]
+
+
+class TestRoundTrip:
+ """A block stated as text and read back is the block it set out as."""
+
+ @pytest.mark.parametrize("case", ROUND_TRIPS, ids=lambda case: case.name)
+ def test_a_block_survives_being_stated_and_read(
+ self,
+ text: TrackerBlockText,
+ case: RoundTripCase,
+ ) -> None:
+ assert text.parse(text.state(case.block, case.region)) == case.block
+
+ def test_a_note_reaches_the_sample_standing_at_its_position(self, text: TrackerBlockText) -> None:
+ """The position is what crosses, so a block lands on the list the reading project holds."""
+ block = TrackerBlock(notes={(0, 0): "snare"}, transposes={}, volumes={})
+ stated = text.state(block, PULSE1_CELL)
+
+ elsewhere = TrackerBlockText(samples=FakeSampleDirectory(["bass", "clap"]))
+
+ assert elsewhere.parse(stated) == TrackerBlock(notes={(0, 0): "clap"}, transposes={}, volumes={})
+
+ def test_a_position_the_reading_list_falls_short_of_states_nothing(self, text: TrackerBlockText) -> None:
+ block = TrackerBlock(notes={(0, 0): "hat"}, transposes={}, volumes={})
+ stated = text.state(block, PULSE1_CELL)
+
+ elsewhere = TrackerBlockText(samples=FakeSampleDirectory(["bass"]))
+
+ assert elsewhere.parse(stated) == TrackerBlock(notes={}, transposes={}, volumes={})
+
+
+class TestTextTypedByHand:
+ """The form is readable, so a reader typing it reaches the same block a copy would."""
+
+ def test_hexadecimal_reads_in_either_case(self, text: TrackerBlockText) -> None:
+ upper = text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n02 -0a f")
+ lower = text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n02 -0A F")
+
+ assert upper == lower
+ assert upper == TrackerBlock(notes={(0, 0): "hat"}, transposes={(0, 1): -10}, volumes={(0, 2): 15})
+
+ def test_the_bars_between_columns_are_a_reading_aid(self, text: TrackerBlockText) -> None:
+ with_bars = text.parse("SampleToNES/1 tracker rows=1 slots=3..8\n01 +00 F | .. ... .")
+ without = text.parse("SampleToNES/1 tracker rows=1 slots=3..8\n01 +00 F .. ... .")
+
+ assert with_bars is not None
+ assert with_bars == without
+
+ def test_a_trailing_line_break_leaves_the_block_as_it_stands(self, text: TrackerBlockText) -> None:
+ assert text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n01 +00 F\n") is not None
+
+
+@dataclass(frozen=True)
+class RefusalCase:
+ name: str
+ text: str
+
+
+HEADER = "SampleToNES/1 tracker rows=2 slots=3..5"
+
+REFUSALS: List[RefusalCase] = [
+ RefusalCase("nothing at all", ""),
+ RefusalCase("unrelated text", "check out this riff\nit goes hard"),
+ RefusalCase("a header alone", HEADER),
+ RefusalCase("a truncated body", f"{HEADER}\n01 +00 F"),
+ RefusalCase("a body reaching past the header", f"{HEADER}\n01 +00 F\n01 +00 F\n01 +00 F"),
+ RefusalCase("a line short of a field", f"{HEADER}\n01 +00\n01 +00 F"),
+ RefusalCase("a line with a field too many", f"{HEADER}\n01 +00 F 2\n01 +00 F"),
+ RefusalCase("an order's block", "SampleToNES/1 order rows=1 positions=0..1\n01 02"),
+ RefusalCase("a slot past the grid", "SampleToNES/1 tracker rows=1 slots=13..15\n01 +00 F"),
+ RefusalCase("a word in a note field", f"{HEADER}\nxx +00 F\n01 +00 F"),
+ RefusalCase("an unsigned transpose", f"{HEADER}\n01 12 F\n01 +00 F"),
+ RefusalCase("a transpose past the range", f"{HEADER}\n01 +40 F\n01 +00 F"),
+ RefusalCase("a transpose below the range", f"{HEADER}\n01 -40 F\n01 +00 F"),
+ RefusalCase("a volume past the range", f"{HEADER}\n01 +00 FF\n01 +00 F"),
+ RefusalCase("dots and marks in one field", f"{HEADER}\n.? +00 F\n01 +00 F"),
+]
+
+
+class TestRefusals:
+ """Text this grid never wrote states no block, so the slot the tracker copied into stands."""
+
+ @pytest.mark.parametrize("case", REFUSALS, ids=lambda case: case.name)
+ def test_text_outside_the_form_states_no_block(
+ self,
+ text: TrackerBlockText,
+ case: RefusalCase,
+ ) -> None:
+ assert text.parse(case.text) is None
diff --git a/tests/unit/sampletones_application/logic/sequencer/order/__init__.py b/tests/unit/sampletones_application/logic/sequencer/order/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/logic/sequencer/test_order.py b/tests/unit/sampletones_application/logic/sequencer/order/test_order.py
similarity index 65%
rename from tests/unit/sampletones_application/logic/sequencer/test_order.py
rename to tests/unit/sampletones_application/logic/sequencer/order/test_order.py
index 95d72b01..ec24ec9f 100644
--- a/tests/unit/sampletones_application/logic/sequencer/test_order.py
+++ b/tests/unit/sampletones_application/logic/sequencer/order/test_order.py
@@ -57,6 +57,47 @@ def test_remove_from_order_drops_the_frame(self) -> None:
assert _order_column(logic, generator) == [None]
+class TestEntryAccess:
+ """The reading and writing seam a block gesture goes through, which is the table's own rule."""
+
+ def test_write_entry_reaches_one_channel(self) -> None:
+ logic = _logic()
+
+ logic.write_entry(GeneratorName.PULSE1, 0, 3)
+
+ assert _order_column(logic, GeneratorName.PULSE1) == [3]
+ assert _order_column(logic, GeneratorName.TRIANGLE) == [0]
+
+ def test_write_entry_through_the_master_row_reaches_every_channel(self) -> None:
+ logic = _logic()
+
+ logic.write_entry(None, 0, 3)
+
+ for generator in GeneratorName.items():
+ assert _order_column(logic, generator) == [3]
+
+ def test_entry_reads_the_index_a_channel_plays(self) -> None:
+ logic = _logic()
+ logic.set_order_entry(GeneratorName.NOISE, 0, 7)
+
+ assert logic.entry(GeneratorName.NOISE, 0) == 7
+
+ def test_entry_past_the_last_frame_reads_as_silence(self) -> None:
+ logic = _logic()
+
+ assert logic.entry(GeneratorName.NOISE, logic.position_count()) is None
+
+ def test_append_frame_lengthens_the_order_by_one(self) -> None:
+ logic = _logic()
+ length = logic.position_count()
+
+ logic.append_frame()
+
+ assert logic.position_count() == length + 1
+ for generator in GeneratorName.items():
+ assert logic.entry(generator, length) is None
+
+
class TestOrderFrameOps:
def test_insert_frame_adds_empty_frame_at_position(self) -> None:
logic = _logic()
@@ -66,15 +107,23 @@ def test_insert_frame_adds_empty_frame_at_position(self) -> None:
assert _order_column(logic, GeneratorName.PULSE1) == [None, 5]
- def test_duplicate_frame_gives_the_copy_its_own_pattern(self) -> None:
+ def test_duplicate_frame_repeats_the_same_pattern(self) -> None:
logic = _logic()
logic.set_order_entry(GeneratorName.PULSE1, 0, 5)
logic.duplicate_frame(0)
- source_index, duplicate_index = _order_column(logic, GeneratorName.PULSE1)
+ assert _order_column(logic, GeneratorName.PULSE1) == [5, 5]
+
+ def test_clone_frame_gives_the_copy_its_own_pattern(self) -> None:
+ logic = _logic()
+ logic.set_order_entry(GeneratorName.PULSE1, 0, 5)
+
+ logic.clone_frame(0)
+
+ source_index, clone_index = _order_column(logic, GeneratorName.PULSE1)
assert source_index == 5
- assert duplicate_index != 5
+ assert clone_index != 5
def test_clear_frame_empties_every_channel(self) -> None:
logic = _logic()
diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py
new file mode 100644
index 00000000..2c49e3cc
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py
@@ -0,0 +1,179 @@
+from typing import Optional
+
+import pytest
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.sequencer.order import (
+ OrderBlockReader,
+ SequencerOrderLogic,
+)
+from sampletones_application.view_model.sequencer.region import OrderRegion
+from sampletones_core.constants.enums import GeneratorName
+from tests.suite.sequencer import fill_order
+
+MASTER_ROW = CHANNEL_AXIS.index(None)
+PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1)
+NOISE_ROW = CHANNEL_AXIS.index(GeneratorName.NOISE)
+
+
+@pytest.fixture
+def logic() -> SequencerOrderLogic:
+ """The order logic the reader takes every entry through."""
+ return SequencerOrderLogic(ProjectController(ProjectManager()))
+
+
+@pytest.fixture
+def reader(logic: SequencerOrderLogic) -> OrderBlockReader:
+ return OrderBlockReader(logic)
+
+
+def _row(
+ generator: Optional[GeneratorName],
+ *,
+ last_position: int = 0,
+) -> OrderRegion:
+ """The region one whole row covers, out to ``last_position``."""
+ row = CHANNEL_AXIS.index(generator)
+ return OrderRegion(
+ first_row=row,
+ last_row=row,
+ first_position=0,
+ last_position=last_position,
+ )
+
+
+class TestChannelRow:
+ """A channel answers for itself, so every one of its cells reaches the block definite."""
+
+ def test_a_row_carries_the_indices_it_plays(
+ self,
+ logic: SequencerOrderLogic,
+ reader: OrderBlockReader,
+ ) -> None:
+ fill_order(
+ logic,
+ (
+ "00 01 02",
+ ".. .. ..",
+ ".. .. ..",
+ ".. .. ..",
+ ),
+ )
+
+ block = reader.read(_row(GeneratorName.PULSE1, last_position=2))
+
+ assert block.entries == {(0, 0): 0, (0, 1): 1, (0, 2): 2}
+
+ def test_a_silent_cell_carries_its_silence(
+ self,
+ logic: SequencerOrderLogic,
+ reader: OrderBlockReader,
+ ) -> None:
+ """A slot playing nothing reads as the empty cell it shows, which a paste writes as silence."""
+ fill_order(
+ logic,
+ (
+ "00",
+ "00",
+ "00",
+ "..",
+ ),
+ )
+
+ block = reader.read(_row(GeneratorName.NOISE))
+
+ assert block.entries == {(0, 0): None}
+
+
+class TestMasterRow:
+ """The master row answers for every channel, so it carries what they agree on and nothing else."""
+
+ def test_a_position_its_channels_share_carries_the_index(
+ self,
+ logic: SequencerOrderLogic,
+ reader: OrderBlockReader,
+ ) -> None:
+ fill_order(
+ logic,
+ (
+ "03",
+ "03",
+ "03",
+ "03",
+ ),
+ )
+
+ block = reader.read(_row(None))
+
+ assert block.entries == {(0, 0): 3}
+
+ def test_a_position_every_channel_leaves_silent_carries_that_silence(
+ self,
+ logic: SequencerOrderLogic,
+ reader: OrderBlockReader,
+ ) -> None:
+ """Silence is a reading the channels agree on, so it writes where a mixed cell would not."""
+ fill_order(
+ logic,
+ (
+ ".. ..",
+ ".. ..",
+ ".. ..",
+ ".. ..",
+ ),
+ )
+
+ block = reader.read(_row(None, last_position=1))
+
+ assert block.entries == {(0, 0): None, (0, 1): None}
+
+ def test_a_position_its_channels_disagree_over_is_left_out(
+ self,
+ logic: SequencerOrderLogic,
+ reader: OrderBlockReader,
+ ) -> None:
+ fill_order(
+ logic,
+ (
+ "00 04",
+ "00 05",
+ "00 04",
+ "00 04",
+ ),
+ )
+
+ block = reader.read(_row(None, last_position=1))
+
+ assert block.entries == {(0, 0): 0}
+
+
+class TestOffsets:
+ """A block addresses its entries by the offsets they stand at, counted from where it begins."""
+
+ def test_offsets_run_from_the_cell_the_region_begins_at(
+ self,
+ logic: SequencerOrderLogic,
+ reader: OrderBlockReader,
+ ) -> None:
+ fill_order(
+ logic,
+ (
+ "00 01 02",
+ "00 01 03",
+ "00 01 02",
+ "00 01 02",
+ ),
+ )
+
+ block = reader.read(
+ OrderRegion(
+ first_row=PULSE1_ROW,
+ last_row=CHANNEL_AXIS.index(GeneratorName.PULSE2),
+ first_position=2,
+ last_position=2,
+ )
+ )
+
+ assert block.entries == {(0, 0): 2, (1, 0): 3}
diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py
new file mode 100644
index 00000000..9ee95980
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py
@@ -0,0 +1,392 @@
+from dataclasses import dataclass
+from typing import Optional, Tuple
+
+import pytest
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.sequencer.order import (
+ OrderBlockReader,
+ OrderBlockWriter,
+ SequencerOrderLogic,
+)
+from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion
+from sampletones_core.constants.enums import GeneratorName
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+from tests.suite.sequencer import fill_order, parse_order_block, render_order
+
+SILENT = ".. .. .."
+
+
+@dataclass(frozen=True, kw_only=True)
+class Table:
+ """A three-position order, the state every paste case starts from."""
+
+ controller: ProjectController
+ logic: SequencerOrderLogic
+ writer: OrderBlockWriter
+
+
+@pytest.fixture
+def table() -> Table:
+ """An order short enough for a case to state whole, every channel silent to begin with."""
+ controller = ProjectController(ProjectManager())
+ logic = SequencerOrderLogic(controller)
+ fill_order(
+ logic,
+ (
+ SILENT,
+ SILENT,
+ SILENT,
+ SILENT,
+ ),
+ )
+ return Table(
+ controller=controller,
+ logic=logic,
+ writer=OrderBlockWriter(logic),
+ )
+
+
+def _row(generator: Optional[GeneratorName]) -> int:
+ return CHANNEL_AXIS.index(generator)
+
+
+class TestPaste(BaseTestSuite):
+ """What a block writes where it lands, stated as the whole order it leaves behind.
+
+ A block carries the offsets it was read at while the cell it is written from supplies the row
+ and the position it begins at, so every case states its origin as that pair.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ block: Tuple[str, ...]
+ origin: OrderCell
+ expected: Tuple[str, ...]
+ order: Tuple[str, ...] = ()
+
+ test_cases = (
+ TestCase(
+ label="a block lands at the cell it is written from",
+ block=("07 08",),
+ origin=OrderCell(generator=GeneratorName.PULSE2, position=1),
+ expected=(
+ SILENT,
+ ".. 07 08",
+ SILENT,
+ SILENT,
+ ),
+ ),
+ TestCase(
+ label="a block through the master row reaches every channel",
+ block=("05",),
+ origin=OrderCell(generator=None, position=0),
+ expected=(
+ "05 .. ..",
+ "05 .. ..",
+ "05 .. ..",
+ "05 .. ..",
+ ),
+ ),
+ TestCase(
+ label="a channel beneath the master row overwrites what it settled",
+ block=(
+ "05",
+ "06",
+ ),
+ origin=OrderCell(generator=None, position=0),
+ expected=(
+ "06 .. ..",
+ "05 .. ..",
+ "05 .. ..",
+ "05 .. ..",
+ ),
+ ),
+ TestCase(
+ label="a block read from the master row writes one channel when written to one",
+ block=("05",),
+ origin=OrderCell(generator=GeneratorName.TRIANGLE, position=2),
+ expected=(
+ SILENT,
+ SILENT,
+ ".. .. 05",
+ SILENT,
+ ),
+ ),
+ TestCase(
+ label="a mixed cell leaves its target as it stands while its neighbours take theirs",
+ order=(
+ "01 02 03",
+ SILENT,
+ SILENT,
+ SILENT,
+ ),
+ block=("09 ? 0A",),
+ origin=OrderCell(generator=GeneratorName.PULSE1, position=0),
+ expected=(
+ "09 02 0A",
+ SILENT,
+ SILENT,
+ SILENT,
+ ),
+ ),
+ TestCase(
+ label="an empty cell silences the slot it lands on",
+ order=(
+ "01 02 03",
+ SILENT,
+ SILENT,
+ SILENT,
+ ),
+ block=(".. ..",),
+ origin=OrderCell(generator=GeneratorName.PULSE1, position=0),
+ expected=(
+ ".. .. 03",
+ SILENT,
+ SILENT,
+ SILENT,
+ ),
+ ),
+ TestCase(
+ label="the rows a block carries past the last channel are left out",
+ block=(
+ "01",
+ "02",
+ "03",
+ ),
+ origin=OrderCell(generator=GeneratorName.TRIANGLE, position=0),
+ expected=(
+ SILENT,
+ SILENT,
+ "01 .. ..",
+ "02 .. ..",
+ ),
+ ),
+ TestCase(
+ label="a master row written to the last channel keeps that channel alone",
+ block=(
+ "01",
+ "02",
+ ),
+ origin=OrderCell(generator=GeneratorName.NOISE, position=0),
+ expected=(
+ SILENT,
+ SILENT,
+ SILENT,
+ "01 .. ..",
+ ),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_order_after_a_paste(
+ self,
+ table: Table,
+ test_case: TestCase,
+ ) -> None:
+ fill_order(table.logic, test_case.order)
+
+ table.writer.write(parse_order_block(test_case.block), test_case.origin)
+
+ assert render_order(table.logic) == test_case.expected
+
+
+class TestGrowth(BaseTestSuite):
+ """How far a paste past the order's end grows it, which is to the last position it writes at."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ block: Tuple[str, ...]
+ origin: OrderCell
+ expected: Tuple[str, ...]
+
+ test_cases = (
+ TestCase(
+ label="a block reaching past the end appends exactly the positions it writes",
+ block=("01 02 03",),
+ origin=OrderCell(generator=GeneratorName.PULSE1, position=2),
+ expected=(
+ ".. .. 01 02 03",
+ ".. .. .. .. ..",
+ ".. .. .. .. ..",
+ ".. .. .. .. ..",
+ ),
+ ),
+ TestCase(
+ label="a column the block says nothing about appends no position",
+ block=("01 ? ?",),
+ origin=OrderCell(generator=GeneratorName.PULSE1, position=2),
+ expected=(
+ ".. .. 01",
+ SILENT,
+ SILENT,
+ SILENT,
+ ),
+ ),
+ TestCase(
+ label="a column the block silences appends the position it silences",
+ block=("01 ? ..",),
+ origin=OrderCell(generator=GeneratorName.PULSE1, position=2),
+ expected=(
+ ".. .. 01 .. ..",
+ ".. .. .. .. ..",
+ ".. .. .. .. ..",
+ ".. .. .. .. ..",
+ ),
+ ),
+ TestCase(
+ label="the rows a block loses at the last channel take their growth with them",
+ block=(
+ "01 ?",
+ "? 02",
+ ),
+ origin=OrderCell(generator=GeneratorName.NOISE, position=2),
+ expected=(
+ SILENT,
+ SILENT,
+ SILENT,
+ ".. .. 01",
+ ),
+ ),
+ TestCase(
+ label="a wholly mixed block leaves the order the length it was",
+ block=("? ? ?",),
+ origin=OrderCell(generator=GeneratorName.PULSE1, position=2),
+ expected=(
+ SILENT,
+ SILENT,
+ SILENT,
+ SILENT,
+ ),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_order_after_a_paste_past_its_end(
+ self,
+ table: Table,
+ test_case: TestCase,
+ ) -> None:
+ table.writer.write(parse_order_block(test_case.block), test_case.origin)
+
+ assert render_order(table.logic) == test_case.expected
+
+
+class TestClear:
+ """What a delete silences, which is every cell its region covers and nothing beside."""
+
+ def test_a_region_silences_the_cells_it_covers(self, table: Table) -> None:
+ fill_order(
+ table.logic,
+ (
+ "01 02 03",
+ "01 02 03",
+ "01 02 03",
+ "01 02 03",
+ ),
+ )
+
+ table.writer.clear(
+ OrderRegion(
+ first_row=_row(GeneratorName.PULSE2),
+ last_row=_row(GeneratorName.TRIANGLE),
+ first_position=0,
+ last_position=1,
+ )
+ )
+
+ assert render_order(table.logic) == (
+ "01 02 03",
+ ".. .. 03",
+ ".. .. 03",
+ "01 02 03",
+ )
+
+ def test_a_region_over_the_master_row_silences_every_channel(self, table: Table) -> None:
+ fill_order(
+ table.logic,
+ (
+ "01 02 03",
+ "01 02 03",
+ "01 02 03",
+ "01 02 03",
+ ),
+ )
+
+ table.writer.clear(
+ OrderRegion(
+ first_row=_row(None),
+ last_row=_row(None),
+ first_position=1,
+ last_position=1,
+ )
+ )
+
+ assert render_order(table.logic) == (
+ "01 .. 03",
+ "01 .. 03",
+ "01 .. 03",
+ "01 .. 03",
+ )
+
+ def test_a_delete_leaves_the_order_the_length_it_was(self, table: Table) -> None:
+ """Emptying the frames at the end leaves them standing as silent ones."""
+ fill_order(
+ table.logic,
+ (
+ "01 02 03",
+ "01 02 03",
+ "01 02 03",
+ "01 02 03",
+ ),
+ )
+
+ table.writer.clear(
+ OrderRegion(
+ first_row=_row(None),
+ last_row=_row(GeneratorName.NOISE),
+ first_position=0,
+ last_position=2,
+ )
+ )
+
+ assert table.logic.position_count() == 3
+
+
+class TestRoundTrip:
+ """Reading a region, silencing it and writing the block back leaves the order it came from."""
+
+ def test_a_block_written_back_at_its_origin_restores_the_order(self, table: Table) -> None:
+ fill_order(
+ table.logic,
+ (
+ "01 02 03",
+ "01 04 03",
+ ".. 02 03",
+ "01 02 ..",
+ ),
+ )
+ before = render_order(table.logic)
+ region = OrderRegion(
+ first_row=_row(GeneratorName.PULSE1),
+ last_row=_row(GeneratorName.NOISE),
+ first_position=0,
+ last_position=2,
+ )
+ block = OrderBlockReader(table.logic).read(region)
+
+ table.writer.clear(region)
+ table.writer.write(block, OrderCell(generator=GeneratorName.PULSE1, position=0))
+
+ assert render_order(table.logic) == before
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py
index 9c8eac41..7d65e04f 100644
--- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py
+++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py
@@ -1,5 +1,5 @@
from pathlib import Path
-from typing import FrozenSet
+from typing import Callable, FrozenSet, Iterable
import numpy as np
import pytest
@@ -9,7 +9,8 @@
from sampletones_application.logic.sequencer.channels import ALL_CHANNELS
from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE
+from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.instructions import (
NoiseInstruction,
PulseInstruction,
@@ -30,15 +31,36 @@ def all_channels() -> FrozenSet[GeneratorName]:
return ALL_CHANNELS
+def make_synthesizer(
+ controller: ProjectController,
+ config: Config,
+ *,
+ sample_rate: int = DEFAULT_SAMPLE_RATE,
+ active_channels: Callable[[], FrozenSet[GeneratorName]] = all_channels,
+) -> RowSynthesizer:
+ """A synthesiser rendering at ``sample_rate``, standing in for the output a caller supplies."""
+ return RowSynthesizer(
+ controller,
+ config,
+ active_channels=active_channels,
+ sample_rate=lambda: sample_rate,
+ )
+
+
def make_pulse_reconstruction(
*,
pitch: int = 60,
volume: int = 15,
count: int = 1,
+ held_features: Iterable[FeatureKey] = (),
) -> Reconstruction:
- """Single-generator reconstruction with ``count`` identical PulseInstructions."""
+ """Single-generator reconstruction with ``count`` identical PulseInstructions.
+
+ ``held_features`` names the dimensions the instrument leaves to the channel, which is what
+ an envelope cleared in the instruments panel produces.
+ """
instructions = [PulseInstruction(on=True, pitch=pitch, volume=volume, duty_cycle=0)] * count
- return Reconstruction.create(
+ reconstruction = Reconstruction.create(
approximation=np.zeros(64, dtype=np.float32),
approximations={GeneratorName.PULSE1: np.zeros(64, dtype=np.float32)},
instructions={GeneratorName.PULSE1: instructions},
@@ -46,6 +68,16 @@ def make_pulse_reconstruction(
coefficient=1.0,
audio_filepath=Path("/dev/null"),
)
+ if held_features:
+ reconstruction.update_generator_data(
+ GeneratorName.PULSE1,
+ list(instructions),
+ np.zeros(64, dtype=np.float32),
+ reconstruction.initial_pitches[GeneratorName.PULSE1],
+ held_features,
+ )
+
+ return reconstruction
def make_triangle_reconstruction(
@@ -156,4 +188,4 @@ def controller() -> ProjectController:
@pytest.fixture
def synthesizer(controller: ProjectController, config: Config) -> RowSynthesizer:
- return RowSynthesizer(controller, config, active_channels=all_channels)
+ return make_synthesizer(controller, config)
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py
index 8b542fb4..2b4dcaa0 100644
--- a/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py
+++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py
@@ -3,7 +3,9 @@
import pytest
-from sampletones_application.logic.sequencer.playback.synthesizer import _apply_modifiers
+from sampletones_application.logic.sequencer.playback.synthesizer import (
+ apply_modifiers,
+)
from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH
from sampletones_core.instructions import (
NoiseInstruction,
@@ -21,31 +23,95 @@ class VolumeScalingCase(BaseRegularTestCase):
VOLUME_SCALING_CASES = [
- VolumeScalingCase(label="max×max", instruction_volume=15, row_volume=15, expected=15),
- VolumeScalingCase(label="half×half", instruction_volume=8, row_volume=8, expected=4),
- VolumeScalingCase(label="zero row", instruction_volume=15, row_volume=0, expected=0),
- VolumeScalingCase(label="zero instruction", instruction_volume=0, row_volume=15, expected=0),
- VolumeScalingCase(label="max×half", instruction_volume=15, row_volume=7, expected=7),
- VolumeScalingCase(label="one×one", instruction_volume=1, row_volume=1, expected=0),
- VolumeScalingCase(label="ten×ten", instruction_volume=10, row_volume=10, expected=7),
- VolumeScalingCase(label="max×mid", instruction_volume=15, row_volume=8, expected=8),
+ VolumeScalingCase(
+ label="max×max",
+ instruction_volume=15,
+ row_volume=15,
+ expected=15,
+ ),
+ VolumeScalingCase(
+ label="half×half",
+ instruction_volume=8,
+ row_volume=8,
+ expected=4,
+ ),
+ VolumeScalingCase(
+ label="zero row",
+ instruction_volume=15,
+ row_volume=0,
+ expected=0,
+ ),
+ VolumeScalingCase(
+ label="zero instruction",
+ instruction_volume=0,
+ row_volume=15,
+ expected=0,
+ ),
+ VolumeScalingCase(
+ label="max×half",
+ instruction_volume=15,
+ row_volume=7,
+ expected=7,
+ ),
+ VolumeScalingCase(
+ label="one×one",
+ instruction_volume=1,
+ row_volume=1,
+ expected=0,
+ ),
+ VolumeScalingCase(
+ label="ten×ten",
+ instruction_volume=10,
+ row_volume=10,
+ expected=7,
+ ),
+ VolumeScalingCase(
+ label="max×mid",
+ instruction_volume=15,
+ row_volume=8,
+ expected=8,
+ ),
]
class TestPulseVolumeScaling:
@pytest.mark.parametrize("case", VOLUME_SCALING_CASES, ids=lambda c: c.label)
- def test_volume_scaled_correctly(self, case: VolumeScalingCase) -> None:
- instruction = PulseInstruction(on=True, pitch=60, volume=case.instruction_volume, duty_cycle=0)
- result = _apply_modifiers(instruction, transpose=0, row_volume=case.row_volume)
+ def test_volume_scaled_correctly(
+ self,
+ case: VolumeScalingCase,
+ ) -> None:
+ instruction = PulseInstruction(
+ on=True,
+ pitch=60,
+ volume=case.instruction_volume,
+ duty_cycle=0,
+ )
+ result = apply_modifiers(
+ instruction,
+ transpose=0,
+ row_volume=case.row_volume,
+ )
assert isinstance(result, PulseInstruction)
assert result.volume == case.expected
class TestNoiseVolumeScaling:
@pytest.mark.parametrize("case", VOLUME_SCALING_CASES, ids=lambda c: c.label)
- def test_volume_scaled_correctly(self, case: VolumeScalingCase) -> None:
- instruction = NoiseInstruction(on=True, period=3, volume=case.instruction_volume, short=False)
- result = _apply_modifiers(instruction, transpose=0, row_volume=case.row_volume)
+ def test_volume_scaled_correctly(
+ self,
+ case: VolumeScalingCase,
+ ) -> None:
+ instruction = NoiseInstruction(
+ on=True,
+ period=3,
+ volume=case.instruction_volume,
+ short=False,
+ )
+ result = apply_modifiers(
+ instruction,
+ transpose=0,
+ row_volume=case.row_volume,
+ )
assert isinstance(result, NoiseInstruction)
assert result.volume == case.expected
@@ -59,20 +125,66 @@ class PulseTransposeCase(BaseTestCase):
PULSE_TRANSPOSE_CASES = [
- PulseTransposeCase(label="shift +5", pitch=60, transpose=5, expected_pitch=65),
- PulseTransposeCase(label="shift -12", pitch=60, transpose=-12, expected_pitch=48),
- PulseTransposeCase(label="shift +1", pitch=60, transpose=1, expected_pitch=61),
- PulseTransposeCase(label="clamped at MAX_PITCH", pitch=MAX_PITCH, transpose=20, expected_pitch=MAX_PITCH),
- PulseTransposeCase(label="clamped at MIN_PITCH", pitch=MIN_PITCH, transpose=-20, expected_pitch=MIN_PITCH),
- PulseTransposeCase(label="no transpose", pitch=60, transpose=0, expected_pitch=60),
+ PulseTransposeCase(
+ label="shift +5",
+ pitch=60,
+ transpose=5,
+ expected_pitch=65,
+ ),
+ PulseTransposeCase(
+ label="shift -12",
+ pitch=60,
+ transpose=-12,
+ expected_pitch=48,
+ ),
+ PulseTransposeCase(
+ label="shift +1",
+ pitch=60,
+ transpose=1,
+ expected_pitch=61,
+ ),
+ PulseTransposeCase(
+ label="clamped at MAX_PITCH",
+ pitch=MAX_PITCH,
+ transpose=20,
+ expected_pitch=MAX_PITCH,
+ ),
+ PulseTransposeCase(
+ label="clamped at MIN_PITCH",
+ pitch=MIN_PITCH,
+ transpose=-20,
+ expected_pitch=MIN_PITCH,
+ ),
+ PulseTransposeCase(
+ label="no transpose",
+ pitch=60,
+ transpose=0,
+ expected_pitch=60,
+ ),
]
class TestPulseTranspose:
- @pytest.mark.parametrize("case", PULSE_TRANSPOSE_CASES, ids=lambda c: c.label)
- def test_pitch_transposed_correctly(self, case: PulseTransposeCase) -> None:
- instruction = PulseInstruction(on=True, pitch=case.pitch, volume=15, duty_cycle=0)
- result = _apply_modifiers(instruction, transpose=case.transpose, row_volume=MAX_VOLUME)
+ @pytest.mark.parametrize(
+ "case",
+ PULSE_TRANSPOSE_CASES,
+ ids=lambda c: c.label,
+ )
+ def test_pitch_transposed_correctly(
+ self,
+ case: PulseTransposeCase,
+ ) -> None:
+ instruction = PulseInstruction(
+ on=True,
+ pitch=case.pitch,
+ volume=15,
+ duty_cycle=0,
+ )
+ result = apply_modifiers(
+ instruction,
+ transpose=case.transpose,
+ row_volume=MAX_VOLUME,
+ )
assert isinstance(result, PulseInstruction)
assert result.pitch == case.expected_pitch
@@ -86,20 +198,66 @@ class NoiseTransposeCase(BaseTestCase):
NOISE_TRANSPOSE_CASES = [
- NoiseTransposeCase(label="shift +5", period=3, transpose=5, expected_period=8),
- NoiseTransposeCase(label="wraps past 15", period=14, transpose=5, expected_period=3),
- NoiseTransposeCase(label="no transpose", period=7, transpose=0, expected_period=7),
- NoiseTransposeCase(label="negative wrap", period=2, transpose=-5, expected_period=13),
- NoiseTransposeCase(label="full wrap +16", period=3, transpose=16, expected_period=3),
- NoiseTransposeCase(label="shift to boundary", period=0, transpose=15, expected_period=15),
+ NoiseTransposeCase(
+ label="shift +5",
+ period=3,
+ transpose=5,
+ expected_period=8,
+ ),
+ NoiseTransposeCase(
+ label="wraps past 15",
+ period=14,
+ transpose=5,
+ expected_period=3,
+ ),
+ NoiseTransposeCase(
+ label="no transpose",
+ period=7,
+ transpose=0,
+ expected_period=7,
+ ),
+ NoiseTransposeCase(
+ label="negative wrap",
+ period=2,
+ transpose=-5,
+ expected_period=13,
+ ),
+ NoiseTransposeCase(
+ label="full wrap +16",
+ period=3,
+ transpose=16,
+ expected_period=3,
+ ),
+ NoiseTransposeCase(
+ label="shift to boundary",
+ period=0,
+ transpose=15,
+ expected_period=15,
+ ),
]
class TestNoiseTranspose:
- @pytest.mark.parametrize("case", NOISE_TRANSPOSE_CASES, ids=lambda c: c.label)
- def test_period_transposed_correctly(self, case: NoiseTransposeCase) -> None:
- instruction = NoiseInstruction(on=True, period=case.period, volume=15, short=False)
- result = _apply_modifiers(instruction, transpose=case.transpose, row_volume=MAX_VOLUME)
+ @pytest.mark.parametrize(
+ "case",
+ NOISE_TRANSPOSE_CASES,
+ ids=lambda c: c.label,
+ )
+ def test_period_transposed_correctly(
+ self,
+ case: NoiseTransposeCase,
+ ) -> None:
+ instruction = NoiseInstruction(
+ on=True,
+ period=case.period,
+ volume=15,
+ short=False,
+ )
+ result = apply_modifiers(
+ instruction,
+ transpose=case.transpose,
+ row_volume=MAX_VOLUME,
+ )
assert isinstance(result, NoiseInstruction)
assert result.period == case.expected_period
@@ -191,10 +349,21 @@ class TriangleModifiersCase(BaseTestCase):
class TestTriangleModifiers:
- @pytest.mark.parametrize("case", TRIANGLE_MODIFIERS_CASES, ids=lambda c: c.label)
- def test_modifiers_applied(self, case: TriangleModifiersCase) -> None:
+ @pytest.mark.parametrize(
+ "case",
+ TRIANGLE_MODIFIERS_CASES,
+ ids=lambda c: c.label,
+ )
+ def test_modifiers_applied(
+ self,
+ case: TriangleModifiersCase,
+ ) -> None:
instruction = TriangleInstruction(on=True, pitch=case.pitch)
- result = _apply_modifiers(instruction, transpose=case.transpose, row_volume=case.row_volume)
+ result = apply_modifiers(
+ instruction,
+ transpose=case.transpose,
+ row_volume=case.row_volume,
+ )
assert isinstance(result, TriangleInstruction)
assert result.pitch == case.expected_pitch
assert result.on == case.expected_on
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_playhead.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_playhead.py
index a75edad0..89832f51 100644
--- a/tests/unit/sampletones_application/logic/sequencer/playback/test_playhead.py
+++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_playhead.py
@@ -27,7 +27,12 @@ class TestRemapAfterRemove:
[
(3, 0, 4, 2), # removed before the playhead → one earlier
(3, 5, 4, 3), # removed after the playhead → unchanged
- (3, 3, 4, 3), # removed the playing frame, more remain → same index (the next frame)
+ (
+ 3,
+ 3,
+ 4,
+ 3,
+ ), # removed the playing frame, more remain → same index (the next frame)
(3, 3, 3, 2), # removed the playing last frame → clamps to the new last
(0, 0, 0, 0), # removed the only frame → pinned at 0
],
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py
new file mode 100644
index 00000000..ab23991a
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py
@@ -0,0 +1,68 @@
+from typing import Final
+
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.sequencer.playback.synthesizer import SongLength
+from sampletones_application.logic.sequencer.playback.synthesizer.timing import SongTiming
+
+FRACTIONAL_RATE: Final[int] = 22050
+EXACT_RATE: Final[int] = 44100
+
+
+def measure(controller: ProjectController, sample_rate: int) -> SongLength:
+ return SongLength.measure(controller.project, sample_rate=sample_rate)
+
+
+class TestTheOrderStatesTheTicks:
+ def test_the_song_lasts_its_groove_once_for_each_order_position(
+ self,
+ controller: ProjectController,
+ ) -> None:
+ groove = SongTiming.from_project(controller.project).groove()
+
+ length = measure(controller, EXACT_RATE)
+
+ assert length.ticks == controller.project.song.order_length() * groove.total_ticks
+
+ def test_appending_a_frame_lengthens_the_song_by_a_pattern(
+ self,
+ controller: ProjectController,
+ ) -> None:
+ before = measure(controller, EXACT_RATE)
+ groove = SongTiming.from_project(controller.project).groove()
+
+ controller.append_frame()
+
+ assert measure(controller, EXACT_RATE).ticks == before.ticks + groove.total_ticks
+
+
+class TestTheRateStatesTheSamples:
+ """The clock spreads a fractional samples-per-tick across ticks, so a total lands on the exact
+ duration whatever rate it is rendered at."""
+
+ def test_a_rate_dividing_evenly_gives_a_whole_frame_for_every_tick(
+ self,
+ controller: ProjectController,
+ ) -> None:
+ length = measure(controller, EXACT_RATE)
+ nes_frequency = controller.project.settings.nes_frequency
+
+ assert length.samples == length.ticks * EXACT_RATE // nes_frequency
+
+ def test_a_rate_dividing_fractionally_still_lands_on_the_exact_duration(
+ self,
+ controller: ProjectController,
+ ) -> None:
+ length = measure(controller, FRACTIONAL_RATE)
+ nes_frequency = controller.project.settings.nes_frequency
+
+ assert length.samples == length.ticks * FRACTIONAL_RATE // nes_frequency
+ assert length.samples * 2 - measure(controller, EXACT_RATE).samples <= 1
+
+ def test_a_song_plays_for_the_same_time_at_every_rate(
+ self,
+ controller: ProjectController,
+ ) -> None:
+ fractional = measure(controller, FRACTIONAL_RATE)
+ exact = measure(controller, EXACT_RATE)
+
+ assert abs(fractional.samples / FRACTIONAL_RATE - exact.samples / EXACT_RATE) < 1e-3
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_player.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_player.py
index 2f5db947..c6cfc27a 100644
--- a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_player.py
+++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_player.py
@@ -1,6 +1,9 @@
-from typing import List
+from typing import List, Tuple
from unittest.mock import MagicMock
+import pytest
+
+from sampletones_application.constants.playback import FollowMode
from sampletones_application.logic.sequencer.playback.song_player import SongPlayerLogic
from sampletones_application.services.song_player.result import (
SongPlaybackError,
@@ -9,7 +12,9 @@
)
from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel
from sampletones_core.project.song_position import SongPosition
-from tests.unit.sampletones_application.logic.sequencer.playback.conftest import make_controller
+from tests.unit.sampletones_application.logic.sequencer.playback.conftest import (
+ make_controller,
+)
def _make_logic(*, is_open: bool = True) -> SongPlayerLogic:
@@ -20,7 +25,7 @@ def _make_logic(*, is_open: bool = True) -> SongPlayerLogic:
return SongPlayerLogic(
MagicMock(),
controller,
- MagicMock(follow_playback=True),
+ MagicMock(follow_mode=FollowMode.ROWS),
service=MagicMock(is_playing=False, is_paused=False),
)
@@ -179,7 +184,14 @@ def test_position_update_sets_internal_position(self) -> None:
logic = _make_logic()
logic.on_view_changed = lambda _: None
- logic._on_service_result(SongPositionUpdate(position=SongPosition(order_position=3, row_index=5)))
+ logic._on_service_result(
+ SongPositionUpdate(
+ position=SongPosition(
+ order_position=3,
+ row_index=5,
+ )
+ )
+ )
assert logic._position.order_position == 3
assert logic._position.row_index == 5
@@ -188,9 +200,21 @@ def test_position_update_fires_on_position_changed(self) -> None:
logic = _make_logic()
logic.on_view_changed = lambda _: None
received: List[Tuple[int, int]] = []
- logic.on_position_changed = lambda order, row: received.append((order, row))
+ logic.on_position_changed = lambda order, row: received.append(
+ (
+ order,
+ row,
+ )
+ )
- logic._on_service_result(SongPositionUpdate(position=SongPosition(order_position=2, row_index=6)))
+ logic._on_service_result(
+ SongPositionUpdate(
+ position=SongPosition(
+ order_position=2,
+ row_index=6,
+ )
+ )
+ )
assert received == [(2, 6)]
@@ -199,7 +223,14 @@ def test_position_update_emits_view_with_current_position(self) -> None:
logic.on_position_changed = lambda _order, _row: None
views = _capture_views(logic)
- logic._on_service_result(SongPositionUpdate(position=SongPosition(order_position=1, row_index=4)))
+ logic._on_service_result(
+ SongPositionUpdate(
+ position=SongPosition(
+ order_position=1,
+ row_index=4,
+ )
+ )
+ )
assert views[-1].order_position == 1
assert views[-1].row_index == 4
@@ -212,9 +243,12 @@ def test_playback_stopped_emits_view(self) -> None:
assert len(views) == 1
- def test_playback_stopped_emits_idle_view_even_when_worker_still_reports_playing(self) -> None:
+ def test_playback_stopped_emits_idle_view_even_when_worker_still_reports_playing(
+ self,
+ ) -> None:
"""The worker thread may still be closing its stream when the stop result is processed;
- the emitted view must report idle regardless so the playhead highlight clears."""
+ the emitted view must report idle regardless so the playhead highlight clears.
+ """
logic = _make_logic()
logic._service.is_playing = True
logic._service.is_paused = True
@@ -309,10 +343,22 @@ def test_stale_update_before_reaching_seek_target_is_ignored(self) -> None:
logic._service.alive = True
logic.on_view_changed = lambda _: None
received: List[Tuple[int, int]] = []
- logic.on_position_changed = lambda order, row: received.append((order, row))
+ logic.on_position_changed = lambda order, row: received.append(
+ (
+ order,
+ row,
+ )
+ )
logic.seek(5)
- logic._on_service_result(SongPositionUpdate(position=SongPosition(order_position=2, row_index=7)))
+ logic._on_service_result(
+ SongPositionUpdate(
+ position=SongPosition(
+ order_position=2,
+ row_index=7,
+ )
+ )
+ )
assert received == []
@@ -321,12 +367,38 @@ def test_reaching_seek_target_resumes_position_updates(self) -> None:
logic._service.alive = True
logic.on_view_changed = lambda _: None
received: List[Tuple[int, int]] = []
- logic.on_position_changed = lambda order, row: received.append((order, row))
+ logic.on_position_changed = lambda order, row: received.append(
+ (
+ order,
+ row,
+ )
+ )
logic.seek(5)
- logic._on_service_result(SongPositionUpdate(position=SongPosition(order_position=2, row_index=7)))
- logic._on_service_result(SongPositionUpdate(position=SongPosition(order_position=5, row_index=0)))
- logic._on_service_result(SongPositionUpdate(position=SongPosition(order_position=6, row_index=0)))
+ logic._on_service_result(
+ SongPositionUpdate(
+ position=SongPosition(
+ order_position=2,
+ row_index=7,
+ )
+ )
+ )
+ logic._on_service_result(
+ SongPositionUpdate(
+ position=SongPosition(
+ order_position=5,
+ row_index=0,
+ )
+ )
+ )
+ logic._on_service_result(
+ SongPositionUpdate(
+ position=SongPosition(
+ order_position=6,
+ row_index=0,
+ )
+ )
+ )
assert received == [(5, 0), (6, 0)]
@@ -335,42 +407,57 @@ def test_seek_while_stopped_does_not_suppress_updates(self) -> None:
logic._service.alive = False
logic.on_view_changed = lambda _: None
received: List[Tuple[int, int]] = []
- logic.on_position_changed = lambda order, row: received.append((order, row))
+ logic.on_position_changed = lambda order, row: received.append(
+ (
+ order,
+ row,
+ )
+ )
logic.seek(5)
- logic._on_service_result(SongPositionUpdate(position=SongPosition(order_position=2, row_index=7)))
+ logic._on_service_result(
+ SongPositionUpdate(
+ position=SongPosition(
+ order_position=2,
+ row_index=7,
+ )
+ )
+ )
assert received == [(2, 7)]
-class TestFollowPlayback:
- def test_follow_playback_reads_session(self) -> None:
+class TestFollowMode:
+ @pytest.mark.parametrize("mode", list(FollowMode), ids=str)
+ def test_the_mode_is_read_from_the_session(self, mode: FollowMode) -> None:
logic = _make_logic(is_open=True)
- logic._session_manager.follow_playback = False
+ logic._session_manager.follow_mode = mode
- assert logic.follow_playback is False
+ assert logic.follow_mode is mode
- def test_set_follow_playback_writes_session(self) -> None:
+ @pytest.mark.parametrize("mode", list(FollowMode), ids=str)
+ def test_choosing_a_mode_writes_the_session(self, mode: FollowMode) -> None:
logic = _make_logic(is_open=True)
_capture_views(logic)
- logic.set_follow_playback(False)
+ logic.set_follow_mode(mode)
- logic._session_manager.set_follow_playback.assert_called_once_with(False)
+ logic._session_manager.set_follow_mode.assert_called_once_with(mode)
- def test_set_follow_playback_emits_view(self) -> None:
+ def test_choosing_a_mode_emits_a_view(self) -> None:
logic = _make_logic(is_open=True)
views = _capture_views(logic)
- logic.set_follow_playback(True)
+ logic.set_follow_mode(FollowMode.PATTERNS)
assert len(views) == 1
- def test_emitted_view_carries_follow_playback(self) -> None:
+ @pytest.mark.parametrize("mode", list(FollowMode), ids=str)
+ def test_the_emitted_view_carries_the_mode(self, mode: FollowMode) -> None:
logic = _make_logic(is_open=True)
- logic._session_manager.follow_playback = True
+ logic._session_manager.follow_mode = mode
views = _capture_views(logic)
logic.play()
- assert views[-1].follow_playback is True
+ assert views[-1].follow_mode is mode
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py
index 72418590..dbb76e04 100644
--- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py
+++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py
@@ -1,25 +1,38 @@
from dataclasses import dataclass, field
-from typing import Dict, FrozenSet, List, Optional
+from typing import Dict, Final, FrozenSet, List, Optional, Tuple
import numpy as np
+import pytest
+from sampletones_application.constants.playback import (
+ MAX_TICKS_PER_ROW,
+ MIN_TICKS_PER_ROW,
+)
+from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.sequencer.channels import ALL_CHANNELS
from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE
+from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.constants.general import MAX_VOLUME
-from sampletones_shared.constants.project import REFERENCE_NES_FREQUENCY, REFERENCE_TEMPO
+from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS
+from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.timing import Metre, RowRate, calculate_groove
from tests.suite.scenario import BaseTestScenario, ScenarioStep
from tests.unit.sampletones_application.logic.sequencer.playback.conftest import (
add_sample,
- all_channels,
make_controller,
make_pulse_reconstruction,
+ make_synthesizer,
place_modifier_row,
place_note_off,
place_row,
)
+SAMPLE_RATE: Final[int] = DEFAULT_SAMPLE_RATE
+SUSTAINED_FRAMES: Final[int] = 64
+QUIET_VOLUME: Final[int] = 3
+
class MaskProvider:
"""A channel mask a test moves between rows, standing in for the channels logic."""
@@ -39,6 +52,7 @@ def __call__(self) -> FrozenSet[GeneratorName]:
@dataclass
class SynthesizerContext:
synthesizer: RowSynthesizer
+ controller: ProjectController
mask: MaskProvider
chunks: List[np.ndarray] = field(default_factory=list)
tick_snapshots: Dict[str, int] = field(default_factory=dict)
@@ -49,17 +63,21 @@ def _make_context() -> SynthesizerContext:
controller = make_controller()
mask = MaskProvider()
return SynthesizerContext(
- synthesizer=RowSynthesizer(controller, Config(), active_channels=mask),
+ synthesizer=make_synthesizer(controller, Config(), active_channels=mask),
+ controller=controller,
mask=mask,
)
-def _controller(context: SynthesizerContext):
- return context.synthesizer._project_controller
+def _controller(context: SynthesizerContext) -> ProjectController:
+ return context.controller
-def _state(context: SynthesizerContext, generator: GeneratorName = GeneratorName.PULSE1):
- return context.synthesizer._channel_states[generator]
+def _state(
+ context: SynthesizerContext,
+ generator: GeneratorName = GeneratorName.PULSE1,
+):
+ return context.synthesizer._channels.state(generator)
def _render(context: SynthesizerContext) -> np.ndarray:
@@ -68,12 +86,38 @@ def _render(context: SynthesizerContext) -> np.ndarray:
return audio
+def _groove_ticks(controller: ProjectController) -> Tuple[int, ...]:
+ """The ticks each row of a pattern owes the project's timing, from the timing package itself."""
+ settings = controller.project.settings
+ return calculate_groove(
+ RowRate.from_settings(settings),
+ Metre.from_settings(settings, rows=controller.project.song.rows_per_pattern),
+ minimum_ticks=MIN_TICKS_PER_ROW,
+ maximum_ticks=MAX_TICKS_PER_ROW,
+ ).ticks
+
+
+def _row_ticks(
+ synthesizer: RowSynthesizer,
+ rows: int,
+) -> Tuple[int, ...]:
+ """The ticks ``rows`` consecutive rendered rows last, read back from the audio they produced."""
+ settings = synthesizer._project_source.project.settings
+ frame_length = round(settings.sample_rate / settings.nes_frequency)
+ return tuple(len(synthesizer.render_row()[0]) // frame_length for _ in range(rows))
+
+
class TestTriggerSetsDefaults:
def test_transpose_and_volume_default_to_zero_and_max(self) -> None:
def place_pulse_sample_on_row_0(context: SynthesizerContext) -> None:
recon = make_pulse_reconstruction(pitch=60, volume=15, count=4)
sample = add_sample(_controller(context), recon)
- place_row(_controller(context), generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ place_row(
+ _controller(context),
+ generator=GeneratorName.PULSE1,
+ row_index=0,
+ sample_id=sample.id,
+ )
def render_row_0_and_assert_defaults(context: SynthesizerContext) -> None:
_render(context)
@@ -84,8 +128,14 @@ def render_row_0_and_assert_defaults(context: SynthesizerContext) -> None:
label="trigger sets default transpose and volume",
build=_make_context,
steps=[
- ScenarioStep(label="place pulse sample on row 0", action=place_pulse_sample_on_row_0),
- ScenarioStep(label="render row 0 — assert defaults", action=render_row_0_and_assert_defaults),
+ ScenarioStep(
+ label="place pulse sample on row 0",
+ action=place_pulse_sample_on_row_0,
+ ),
+ ScenarioStep(
+ label="render row 0 — assert defaults",
+ action=render_row_0_and_assert_defaults,
+ ),
],
).run()
@@ -102,7 +152,9 @@ def place_pulse_sample_with_modifiers(context: SynthesizerContext) -> None:
volume=8,
)
- def render_row_0_and_assert_explicit_values(context: SynthesizerContext) -> None:
+ def render_row_0_and_assert_explicit_values(
+ context: SynthesizerContext,
+ ) -> None:
_render(context)
assert _state(context).transpose == 5
assert _state(context).volume == 8
@@ -112,10 +164,12 @@ def render_row_0_and_assert_explicit_values(context: SynthesizerContext) -> None
build=_make_context,
steps=[
ScenarioStep(
- label="place pulse sample with transpose=5 volume=8", action=place_pulse_sample_with_modifiers
+ label="place pulse sample with transpose=5 volume=8",
+ action=place_pulse_sample_with_modifiers,
),
ScenarioStep(
- label="render row 0 — assert explicit values", action=render_row_0_and_assert_explicit_values
+ label="render row 0 — assert explicit values",
+ action=render_row_0_and_assert_explicit_values,
),
],
).run()
@@ -126,7 +180,12 @@ def test_empty_row_continues_previous_note(self) -> None:
def place_pulse_sample_on_row_0(context: SynthesizerContext) -> None:
recon = make_pulse_reconstruction(count=12)
sample = add_sample(_controller(context), recon)
- place_row(_controller(context), generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ place_row(
+ _controller(context),
+ generator=GeneratorName.PULSE1,
+ row_index=0,
+ sample_id=sample.id,
+ )
def render_row_0_and_record_state(context: SynthesizerContext) -> None:
_render(context)
@@ -134,7 +193,9 @@ def render_row_0_and_record_state(context: SynthesizerContext) -> None:
context.sample_id_snapshots["triggered"] = _state(context).sample_id
assert _state(context).sample_id is not None
- def render_empty_row_1_and_assert_tick_advanced(context: SynthesizerContext) -> None:
+ def render_empty_row_1_and_assert_tick_advanced(
+ context: SynthesizerContext,
+ ) -> None:
_render(context)
assert _state(context).tick_index > context.tick_snapshots["after_row_0"]
assert _state(context).sample_id == context.sample_id_snapshots["triggered"]
@@ -143,10 +204,17 @@ def render_empty_row_1_and_assert_tick_advanced(context: SynthesizerContext) ->
label="sustain — empty row continues previous note",
build=_make_context,
steps=[
- ScenarioStep(label="place pulse sample on row 0", action=place_pulse_sample_on_row_0),
- ScenarioStep(label="render row 0 — note triggers", action=render_row_0_and_record_state),
ScenarioStep(
- label="render row 1 (empty) — note sustains", action=render_empty_row_1_and_assert_tick_advanced
+ label="place pulse sample on row 0",
+ action=place_pulse_sample_on_row_0,
+ ),
+ ScenarioStep(
+ label="render row 0 — note triggers",
+ action=render_row_0_and_record_state,
+ ),
+ ScenarioStep(
+ label="render row 1 (empty) — note sustains",
+ action=render_empty_row_1_and_assert_tick_advanced,
),
],
).run()
@@ -177,7 +245,9 @@ def render_row_0_and_record_state(context: SynthesizerContext) -> None:
context.sample_id_snapshots["after_row_0"] = _state(context).sample_id
assert _state(context).volume == 15
- def render_modifier_row_and_assert_volume_changed(context: SynthesizerContext) -> None:
+ def render_modifier_row_and_assert_volume_changed(
+ context: SynthesizerContext,
+ ) -> None:
_render(context)
assert _state(context).volume == 0
assert _state(context).sample_id == context.sample_id_snapshots["after_row_0"]
@@ -188,7 +258,10 @@ def render_modifier_row_and_assert_volume_changed(context: SynthesizerContext) -
build=_make_context,
steps=[
ScenarioStep(label="place sample on row 0, modifier on row 1", action=setup),
- ScenarioStep(label="render row 0 — volume=15", action=render_row_0_and_record_state),
+ ScenarioStep(
+ label="render row 0 — volume=15",
+ action=render_row_0_and_record_state,
+ ),
ScenarioStep(
label="render row 1 — volume drops to 0, no retrigger",
action=render_modifier_row_and_assert_volume_changed,
@@ -219,7 +292,9 @@ def render_row_0_and_record_sample(context: SynthesizerContext) -> None:
context.sample_id_snapshots["triggered"] = _state(context).sample_id
assert _state(context).transpose == 0
- def render_modifier_row_and_assert_transpose_changed(context: SynthesizerContext) -> None:
+ def render_modifier_row_and_assert_transpose_changed(
+ context: SynthesizerContext,
+ ) -> None:
_render(context)
assert _state(context).transpose == 7
assert _state(context).sample_id == context.sample_id_snapshots["triggered"]
@@ -228,8 +303,14 @@ def render_modifier_row_and_assert_transpose_changed(context: SynthesizerContext
label="modifier-only row changes transpose without retriggering",
build=_make_context,
steps=[
- ScenarioStep(label="place sample on row 0, transpose modifier on row 1", action=setup),
- ScenarioStep(label="render row 0 — transpose=0", action=render_row_0_and_record_sample),
+ ScenarioStep(
+ label="place sample on row 0, transpose modifier on row 1",
+ action=setup,
+ ),
+ ScenarioStep(
+ label="render row 0 — transpose=0",
+ action=render_row_0_and_record_sample,
+ ),
ScenarioStep(
label="render row 1 — transpose=7, no retrigger",
action=render_modifier_row_and_assert_transpose_changed,
@@ -243,7 +324,12 @@ def test_masked_channel_produces_silence(self) -> None:
def place_pulse_sample(context: SynthesizerContext) -> None:
recon = make_pulse_reconstruction(count=4)
sample = add_sample(_controller(context), recon)
- place_row(_controller(context), generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ place_row(
+ _controller(context),
+ generator=GeneratorName.PULSE1,
+ row_index=0,
+ sample_id=sample.id,
+ )
def mute_pulse1(context: SynthesizerContext) -> None:
context.mask.mute(GeneratorName.PULSE1)
@@ -251,7 +337,7 @@ def mute_pulse1(context: SynthesizerContext) -> None:
def render_and_compare_against_unmasked(context: SynthesizerContext) -> None:
audio_masked = _render(context)
- audible_synthesizer = RowSynthesizer(_controller(context), Config(), active_channels=all_channels)
+ audible_synthesizer = make_synthesizer(_controller(context), Config())
audio_with_pulse1, _ = audible_synthesizer.render_row()
assert np.allclose(audio_masked, 0.0)
@@ -280,7 +366,12 @@ def test_mask_change_between_rows_takes_effect_without_restart(self) -> None:
def place_looping_pulse_sample(context: SynthesizerContext) -> None:
recon = make_pulse_reconstruction(count=4)
sample = add_sample(_controller(context), recon, loop=True)
- place_row(_controller(context), generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ place_row(
+ _controller(context),
+ generator=GeneratorName.PULSE1,
+ row_index=0,
+ sample_id=sample.id,
+ )
def mute_pulse1_and_render_row_0(context: SynthesizerContext) -> None:
context.mask.mute(GeneratorName.PULSE1)
@@ -295,9 +386,18 @@ def unmute_pulse1_and_render_row_1(context: SynthesizerContext) -> None:
label="mask change heard on the next row",
build=_make_context,
steps=[
- ScenarioStep(label="place looping pulse sample on row 0", action=place_looping_pulse_sample),
- ScenarioStep(label="mute PULSE1, render row 0 — silence", action=mute_pulse1_and_render_row_0),
- ScenarioStep(label="unmute PULSE1, render row 1 — sounds", action=unmute_pulse1_and_render_row_1),
+ ScenarioStep(
+ label="place looping pulse sample on row 0",
+ action=place_looping_pulse_sample,
+ ),
+ ScenarioStep(
+ label="mute PULSE1, render row 0 — silence",
+ action=mute_pulse1_and_render_row_0,
+ ),
+ ScenarioStep(
+ label="unmute PULSE1, render row 1 — sounds",
+ action=unmute_pulse1_and_render_row_1,
+ ),
],
).run()
@@ -322,8 +422,14 @@ def assert_wrapped_to_order_1(context: SynthesizerContext) -> None:
label="row index wraps and order position increments",
build=_make_context,
steps=[
- ScenarioStep(label="assert starts at order=0 row=0", action=assert_at_row_0_order_0),
- ScenarioStep(label="render all rows in pattern", action=render_all_rows_in_pattern),
+ ScenarioStep(
+ label="assert starts at order=0 row=0",
+ action=assert_at_row_0_order_0,
+ ),
+ ScenarioStep(
+ label="render all rows in pattern",
+ action=render_all_rows_in_pattern,
+ ),
ScenarioStep(label="assert order=1 row=0", action=assert_wrapped_to_order_1),
],
).run()
@@ -336,7 +442,9 @@ def seek_past_then_shrink_pattern(context: SynthesizerContext) -> None:
context.synthesizer.set_position(0, 50)
_controller(context).set_rows_per_pattern(16)
- def render_and_assert_advanced_without_finishing(context: SynthesizerContext) -> None:
+ def render_and_assert_advanced_without_finishing(
+ context: SynthesizerContext,
+ ) -> None:
_, (order_position, row_index) = context.synthesizer.render_row()
assert (order_position, row_index) == (1, 0)
assert not context.synthesizer.is_finished
@@ -347,7 +455,8 @@ def render_and_assert_advanced_without_finishing(context: SynthesizerContext) ->
steps=[
ScenarioStep(label="append a second order frame", action=append_second_frame),
ScenarioStep(
- label="seek to row 50, then shrink pattern to 16 rows", action=seek_past_then_shrink_pattern
+ label="seek to row 50, then shrink pattern to 16 rows",
+ action=seek_past_then_shrink_pattern,
),
ScenarioStep(
label="render — playhead lands on order 1 row 0, still playing",
@@ -368,7 +477,8 @@ def render_and_check_returned_position(context: SynthesizerContext) -> None:
build=_make_context,
steps=[
ScenarioStep(
- label="render row 0 — returned position is 0,0", action=render_and_check_returned_position
+ label="render row 0 — returned position is 0,0",
+ action=render_and_check_returned_position,
),
],
).run()
@@ -379,7 +489,12 @@ def test_note_off_cuts_a_sounding_looped_voice(self) -> None:
def place_looped_sample_then_note_off(context: SynthesizerContext) -> None:
recon = make_pulse_reconstruction(count=2)
sample = add_sample(_controller(context), recon, loop=True)
- place_row(_controller(context), generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ place_row(
+ _controller(context),
+ generator=GeneratorName.PULSE1,
+ row_index=0,
+ sample_id=sample.id,
+ )
place_note_off(_controller(context), generator=GeneratorName.PULSE1, row_index=1)
def render_row_0_and_assert_audible(context: SynthesizerContext) -> None:
@@ -398,8 +513,14 @@ def render_row_1_and_assert_silenced(context: SynthesizerContext) -> None:
label="place looped sample on row 0, note-off on row 1",
action=place_looped_sample_then_note_off,
),
- ScenarioStep(label="render row 0 — audible", action=render_row_0_and_assert_audible),
- ScenarioStep(label="render row 1 — note-off cuts the voice", action=render_row_1_and_assert_silenced),
+ ScenarioStep(
+ label="render row 0 — audible",
+ action=render_row_0_and_assert_audible,
+ ),
+ ScenarioStep(
+ label="render row 1 — note-off cuts the voice",
+ action=render_row_1_and_assert_silenced,
+ ),
],
).run()
@@ -409,13 +530,20 @@ def test_loop_true_keeps_playing_after_instruction_list_exhausted(self) -> None:
def place_two_instruction_loop_sample(context: SynthesizerContext) -> None:
recon = make_pulse_reconstruction(count=2)
sample = add_sample(_controller(context), recon, loop=True)
- place_row(_controller(context), generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ place_row(
+ _controller(context),
+ generator=GeneratorName.PULSE1,
+ row_index=0,
+ sample_id=sample.id,
+ )
def render_row_0_and_assert_non_silence(context: SynthesizerContext) -> None:
audio = _render(context)
assert not np.all(audio == 0.0)
- def render_rows_1_to_3_and_assert_tick_advanced(context: SynthesizerContext) -> None:
+ def render_rows_1_to_3_and_assert_tick_advanced(
+ context: SynthesizerContext,
+ ) -> None:
for _ in range(3):
_render(context)
assert _state(context).tick_index > 2
@@ -425,9 +553,13 @@ def render_rows_1_to_3_and_assert_tick_advanced(context: SynthesizerContext) ->
build=_make_context,
steps=[
ScenarioStep(
- label="place 2-instruction looping sample on row 0", action=place_two_instruction_loop_sample
+ label="place 2-instruction looping sample on row 0",
+ action=place_two_instruction_loop_sample,
+ ),
+ ScenarioStep(
+ label="render row 0 — has audio",
+ action=render_row_0_and_assert_non_silence,
),
- ScenarioStep(label="render row 0 — has audio", action=render_row_0_and_assert_non_silence),
ScenarioStep(
label="render rows 1-3 — tick keeps advancing past 2",
action=render_rows_1_to_3_and_assert_tick_advanced,
@@ -442,9 +574,16 @@ def test_loop_false_produces_silence_after_instructions_end(self) -> None:
def place_one_instruction_non_loop_sample(context: SynthesizerContext) -> None:
recon = make_pulse_reconstruction(count=1)
sample = add_sample(_controller(context), recon, loop=False)
- place_row(_controller(context), generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ place_row(
+ _controller(context),
+ generator=GeneratorName.PULSE1,
+ row_index=0,
+ sample_id=sample.id,
+ )
- def render_row_0_and_assert_first_tick_audible_rest_silent(context: SynthesizerContext) -> None:
+ def render_row_0_and_assert_first_tick_audible_rest_silent(
+ context: SynthesizerContext,
+ ) -> None:
audio = _render(context)
first_tick = audio[:frame_length]
remaining = audio[frame_length:]
@@ -456,7 +595,8 @@ def render_row_0_and_assert_first_tick_audible_rest_silent(context: SynthesizerC
build=_make_context,
steps=[
ScenarioStep(
- label="place 1-instruction non-looping sample", action=place_one_instruction_non_loop_sample
+ label="place 1-instruction non-looping sample",
+ action=place_one_instruction_non_loop_sample,
),
ScenarioStep(
label="render row 0 — first tick audible, rest silent",
@@ -470,10 +610,17 @@ def place_loop_then_append_empty_frame(context: SynthesizerContext) -> None:
controller = _controller(context)
recon = make_pulse_reconstruction(count=2)
sample = add_sample(controller, recon, loop=True)
- place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ place_row(
+ controller,
+ generator=GeneratorName.PULSE1,
+ row_index=0,
+ sample_id=sample.id,
+ )
controller.append_frame()
- def render_into_empty_second_frame_and_assert_sustained(context: SynthesizerContext) -> None:
+ def render_into_empty_second_frame_and_assert_sustained(
+ context: SynthesizerContext,
+ ) -> None:
rows_in_first_frame = _controller(context).project.song.rows_per_pattern
for _ in range(rows_in_first_frame):
_render(context)
@@ -539,47 +686,125 @@ def render_beyond_end_and_assert_silence(context: SynthesizerContext) -> None:
steps=[
ScenarioStep(label="exhaust all rows", action=exhaust_song),
ScenarioStep(label="assert is_finished", action=assert_finished),
- ScenarioStep(label="render past end — silence", action=render_beyond_end_and_assert_silence),
+ ScenarioStep(
+ label="render past end — silence",
+ action=render_beyond_end_and_assert_silence,
+ ),
],
).run()
class TestFrameCount:
- def test_chunk_length_matches_speed_sample_rate_and_nes_frequency(self) -> None:
+ def test_chunk_length_matches_the_groove_row_and_the_frame_length(self) -> None:
def render_and_assert_chunk_length(context: SynthesizerContext) -> None:
- settings = _controller(context).project.settings
+ controller = _controller(context)
+ settings = controller.project.settings
frame_length = settings.sample_rate // settings.nes_frequency
- ticks_per_row = (settings.speed * settings.nes_frequency * REFERENCE_TEMPO) // (
- settings.tempo * REFERENCE_NES_FREQUENCY
- )
audio = _render(context)
- assert len(audio) == frame_length * ticks_per_row
+ assert len(audio) == frame_length * _groove_ticks(controller)[0]
BaseTestScenario(
- label="chunk length matches timing formula",
+ label="chunk length matches the groove's first row",
build=_make_context,
steps=[
- ScenarioStep(label="render one row and check length", action=render_and_assert_chunk_length),
+ ScenarioStep(
+ label="render one row and check length",
+ action=render_and_assert_chunk_length,
+ ),
],
).run()
+class TestGroove:
+ def test_a_pattern_plays_the_groove_the_metre_yields(
+ self,
+ controller: ProjectController,
+ synthesizer: RowSynthesizer,
+ ) -> None:
+ """Speed 6 at 60 Hz against tempo 210 asks for 30/7 ticks a row, which no single speed
+ value states. Spread over a 16-row bar of four-row beats it comes out as the bar, its
+ half, and each beat carrying the longer row.
+ """
+ controller.set_rows_per_pattern(16)
+ controller.set_tempo(210)
+
+ rendered = _row_ticks(synthesizer, controller.project.song.rows_per_pattern)
+
+ assert rendered == (5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4)
+ assert rendered == _groove_ticks(controller)
+
+ def test_the_groove_restarts_with_the_pattern(
+ self,
+ controller: ProjectController,
+ synthesizer: RowSynthesizer,
+ ) -> None:
+ """Every row reads the groove entry its position in the pattern names, so returning to
+ row 0 plays row 0's duration again — the phase an exported module also restarts on.
+ """
+ controller.set_rows_per_pattern(16)
+ controller.set_tempo(210)
+
+ opening = _row_ticks(synthesizer, 3)
+ synthesizer.set_position(0, 0)
+ again = _row_ticks(synthesizer, 1)
+
+ assert opening == (5, 4, 5)
+ assert again == (opening[0],)
+
+ def test_tempo_change_between_rows_rebuilds_the_groove(
+ self,
+ controller: ProjectController,
+ synthesizer: RowSynthesizer,
+ ) -> None:
+ """A tempo edit is heard on the next row, at that row's place in the new groove."""
+ controller.set_rows_per_pattern(16)
+ speed = controller.project.settings.speed
+
+ at_reference_tempo = _row_ticks(synthesizer, 1)
+ controller.set_tempo(210)
+ after_change = _row_ticks(synthesizer, 1)
+
+ assert at_reference_tempo == (speed,)
+ assert after_change == (_groove_ticks(controller)[1],)
+
+ def test_highlight_change_regroups_the_same_row_rate(
+ self,
+ controller: ProjectController,
+ synthesizer: RowSynthesizer,
+ ) -> None:
+ """The beat decides where the longer rows land, so narrowing it moves them without
+ changing how long the pattern lasts.
+ """
+ controller.set_rows_per_pattern(16)
+ controller.set_tempo(210)
+ rows = controller.project.song.rows_per_pattern
+
+ on_four_row_beats = _row_ticks(synthesizer, rows)
+ controller.set_first_highlight(3)
+ synthesizer.set_position(0, 0)
+ on_three_row_beats = _row_ticks(synthesizer, rows)
+
+ assert on_three_row_beats == (5, 4, 4, 5, 4, 4, 5, 4, 4, 5, 4, 4, 5, 4, 4, 4)
+ assert sum(on_three_row_beats) == sum(on_four_row_beats)
+
+
class TestNesFrequencyTempo:
def test_frame_length_follows_project_nes_frequency(self) -> None:
"""Each tick spans ``sample_rate / nes_frequency`` samples taken from the project's
- live frequency, not the fixed library config — otherwise the row duration drifts."""
+ live frequency, not the fixed library config — otherwise the row duration drifts.
+ """
def lower_nes_frequency(context: SynthesizerContext) -> None:
_controller(context).set_nes_frequency(30)
- def render_and_assert_chunk_uses_project_frequency(context: SynthesizerContext) -> None:
- settings = _controller(context).project.settings
+ def render_and_assert_chunk_uses_project_frequency(
+ context: SynthesizerContext,
+ ) -> None:
+ controller = _controller(context)
+ settings = controller.project.settings
frame_length = round(settings.sample_rate / settings.nes_frequency)
- ticks_per_row = (settings.speed * settings.nes_frequency * REFERENCE_TEMPO) // (
- settings.tempo * REFERENCE_NES_FREQUENCY
- )
audio = _render(context)
- assert len(audio) == frame_length * ticks_per_row
+ assert len(audio) == frame_length * _groove_ticks(controller)[0]
BaseTestScenario(
label="frame length tracks the project NES frequency",
@@ -595,35 +820,138 @@ def render_and_assert_chunk_uses_project_frequency(context: SynthesizerContext)
def test_tempo_is_independent_of_nes_frequency(self) -> None:
"""A whole pattern spans the same real time at 60 Hz and 30 Hz; only the instruction
- rate differs. Before the fix, halving the frequency roughly doubled the tempo."""
+ rate differs. Before the fix, halving the frequency roughly doubled the tempo.
+ """
def pattern_duration_seconds(nes_frequency: int) -> float:
controller = make_controller()
controller.set_nes_frequency(nes_frequency)
- synthesizer = RowSynthesizer(controller, Config(), active_channels=all_channels)
+ synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE)
rows = controller.project.song.rows_per_pattern
total_samples = sum(len(synthesizer.render_row()[0]) for _ in range(rows))
- return total_samples / controller.project.settings.sample_rate
+ return total_samples / SAMPLE_RATE
assert abs(pattern_duration_seconds(60) - pattern_duration_seconds(30)) < 0.1
def test_frequency_change_between_rows_takes_effect_without_restart(self) -> None:
"""A frequency change mid-playback is picked up on the next row: render_row reads the
- live setting and rebuilds the generators in place, keeping the sounding note going."""
+ live setting and rebuilds the generators in place, keeping the sounding note going.
+ """
controller = make_controller()
recon = make_pulse_reconstruction(count=12)
sample = add_sample(controller, recon)
place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
- synthesizer = RowSynthesizer(controller, Config(), active_channels=all_channels)
- sample_rate = controller.project.settings.sample_rate
- pulse_state = synthesizer._channel_states[GeneratorName.PULSE1]
+ synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE)
controller.set_nes_frequency(60)
synthesizer.render_row()
- assert pulse_state.generator.frame_length == round(sample_rate / 60)
+ pulse_state = synthesizer._channels.state(GeneratorName.PULSE1)
+ assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 60)
controller.set_nes_frequency(30)
synthesizer.render_row()
- assert pulse_state.generator.frame_length == round(sample_rate / 30)
+ assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 30)
assert pulse_state.sample_id is not None
+
+
+class TestChannelHeldValues:
+ """A dimension an instrument leaves to the channel sounds at the value the channel holds.
+
+ The channel carries that value from the start of a song, taking up a new one wherever an
+ instrument writes it, so an instrument with an empty volume envelope plays at whatever the
+ one before it left behind.
+ """
+
+ @staticmethod
+ def _place(
+ context: SynthesizerContext,
+ reconstruction: Reconstruction,
+ *,
+ row_index: int,
+ name: str,
+ ) -> None:
+ sample = add_sample(_controller(context), reconstruction, name=name)
+ place_row(
+ _controller(context),
+ generator=GeneratorName.PULSE1,
+ row_index=row_index,
+ sample_id=sample.id,
+ )
+
+ @staticmethod
+ def _peak(audio: np.ndarray) -> float:
+ return float(np.max(np.abs(audio)))
+
+ def test_the_channel_takes_up_the_level_its_instrument_writes(self) -> None:
+ context = _make_context()
+ self._place(
+ context,
+ make_pulse_reconstruction(volume=QUIET_VOLUME, count=SUSTAINED_FRAMES),
+ row_index=0,
+ name="writes",
+ )
+
+ _render(context)
+
+ assert _state(context).feature_values[FeatureKey.VOLUME] == QUIET_VOLUME
+
+ def test_a_sample_holding_its_level_sounds_at_the_channels(self) -> None:
+ context = _make_context()
+ self._place(
+ context,
+ make_pulse_reconstruction(volume=QUIET_VOLUME, count=SUSTAINED_FRAMES),
+ row_index=0,
+ name="writes",
+ )
+ self._place(
+ context,
+ make_pulse_reconstruction(
+ volume=MAX_VOLUME,
+ count=SUSTAINED_FRAMES,
+ held_features=(FeatureKey.VOLUME,),
+ ),
+ row_index=1,
+ name="holds",
+ )
+
+ written = _render(context)
+ held = _render(context)
+
+ assert self._peak(held) == pytest.approx(self._peak(written))
+
+ def test_a_song_starts_a_held_level_at_full_volume(self) -> None:
+ holding = _make_context()
+ self._place(
+ holding,
+ make_pulse_reconstruction(
+ volume=QUIET_VOLUME,
+ count=SUSTAINED_FRAMES,
+ held_features=(FeatureKey.VOLUME,),
+ ),
+ row_index=0,
+ name="holds",
+ )
+ writing = _make_context()
+ self._place(
+ writing,
+ make_pulse_reconstruction(volume=MAX_VOLUME, count=SUSTAINED_FRAMES),
+ row_index=0,
+ name="writes",
+ )
+
+ assert self._peak(_render(holding)) == pytest.approx(self._peak(_render(writing)))
+
+ def test_a_reset_returns_every_channel_to_the_values_a_song_starts_on(self) -> None:
+ context = _make_context()
+ self._place(
+ context,
+ make_pulse_reconstruction(volume=QUIET_VOLUME, count=SUSTAINED_FRAMES),
+ row_index=0,
+ name="writes",
+ )
+ _render(context)
+
+ context.synthesizer.reset()
+
+ assert _state(context).feature_values == CHANNEL_FEATURE_DEFAULTS
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py
new file mode 100644
index 00000000..7d18bc32
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py
@@ -0,0 +1,247 @@
+from fractions import Fraction
+from typing import Final, Optional, Tuple
+
+import numpy as np
+import pytest
+
+from sampletones_application.constants.playback import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.timing import Metre, RowRate, TickClock, calculate_groove
+from tests.suite.base import BaseTestSuite
+from tests.unit.sampletones_application.logic.sequencer.playback.conftest import (
+ add_sample,
+ all_channels,
+ make_controller,
+ make_pulse_reconstruction,
+ make_synthesizer,
+ place_row,
+)
+
+UNEVEN_SAMPLE_RATE: Final[int] = 22050
+EVEN_SAMPLE_RATE: Final[int] = 44100
+UNEVEN_RATES: Final[Tuple[int, ...]] = (8000, 16000, 22050)
+
+
+def _expected_ticks(controller: ProjectController) -> Tuple[int, ...]:
+ settings = controller.project.settings
+ return calculate_groove(
+ RowRate.from_settings(settings),
+ Metre.from_settings(settings, rows=controller.project.song.rows_per_pattern),
+ minimum_ticks=MIN_TICKS_PER_ROW,
+ maximum_ticks=MAX_TICKS_PER_ROW,
+ ).ticks
+
+
+class TestRowsFollowTheTickClock(BaseTestSuite):
+ """A rendered row spans the samples its ticks span, so the groove's tempo is the tempo heard."""
+
+ @pytest.mark.parametrize("sample_rate", UNEVEN_RATES + (EVEN_SAMPLE_RATE, 48000))
+ def test_a_pattern_spans_its_exact_duration(self, sample_rate: int) -> None:
+ controller = make_controller()
+ synthesizer = make_synthesizer(controller, Config(), sample_rate=sample_rate)
+ ticks = _expected_ticks(controller)
+
+ rendered = sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks)))
+
+ clock = TickClock.from_parameters(
+ sample_rate=sample_rate,
+ nes_frequency=controller.project.settings.nes_frequency,
+ )
+ assert rendered == clock.samples_at(sum(ticks))
+
+ @pytest.mark.parametrize("sample_rate", UNEVEN_RATES)
+ def test_a_long_run_does_not_drift(self, sample_rate: int) -> None:
+ """The property a fixed rounded frame length loses: the error stays below one sample."""
+ controller = make_controller()
+ synthesizer = make_synthesizer(controller, Config(), sample_rate=sample_rate)
+ ticks = _expected_ticks(controller)
+ patterns = 40
+
+ rendered = 0
+ for _ in range(patterns):
+ synthesizer.set_position(0, 0)
+ rendered += sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks)))
+
+ exact = Fraction(sample_rate, controller.project.settings.nes_frequency) * sum(ticks) * patterns
+ assert abs(rendered - exact) < 1
+
+ def test_a_row_spans_the_sum_of_its_ticks(self) -> None:
+ controller = make_controller()
+ synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE)
+ clock = TickClock.from_parameters(
+ sample_rate=UNEVEN_SAMPLE_RATE,
+ nes_frequency=controller.project.settings.nes_frequency,
+ )
+ ticks = _expected_ticks(controller)
+
+ elapsed = 0
+ for row_ticks in ticks:
+ chunk, _ = synthesizer.render_row()
+ expected = clock.samples_at(elapsed + row_ticks) - clock.samples_at(elapsed)
+ assert len(chunk) == expected
+ elapsed += row_ticks
+
+ def test_rows_vary_in_length_where_their_ticks_straddle_a_sample(self) -> None:
+ """The variation is the mechanism; a run of identical lengths would mean the drift is back.
+
+ An odd tick count is what makes it visible at the row: five ticks of 367.5 samples span
+ 1837.5, so consecutive rows take the floor and the ceiling in turn.
+ """
+ controller = make_controller()
+ controller.set_speed(5)
+ synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE)
+ lengths = {len(synthesizer.render_row()[0]) for _ in range(len(_expected_ticks(controller)))}
+ assert lengths == {1837, 1838}
+
+ def test_reset_returns_the_clock_to_the_first_tick(self) -> None:
+ controller = make_controller()
+ synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE)
+ first = len(synthesizer.render_row()[0])
+
+ synthesizer.set_position(0, 0)
+ synthesizer.reset()
+
+ assert len(synthesizer.render_row()[0]) == first
+
+ def test_a_frequency_change_rebuilds_the_clock(self) -> None:
+ controller = make_controller()
+ synthesizer = make_synthesizer(controller, Config(), sample_rate=EVEN_SAMPLE_RATE)
+
+ controller.set_nes_frequency(60)
+ synthesizer.render_row()
+ controller.set_nes_frequency(30)
+ synthesizer.set_position(0, 0)
+ synthesizer.reset()
+ ticks = _expected_ticks(controller)
+
+ rendered = sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks)))
+ clock = TickClock.from_parameters(sample_rate=EVEN_SAMPLE_RATE, nes_frequency=30)
+ assert rendered == clock.samples_at(sum(ticks))
+
+
+class TestTheOutputRateIsFollowed(BaseTestSuite):
+ """The audio is rendered at the rate its consumer reports, so a rendered second lasts a second.
+
+ Live playback opens its device stream at that rate and a render writes its file at it, so a
+ synthesiser fixed to some other rate plays the song at the ratio between the two.
+ """
+
+ @pytest.mark.parametrize("sample_rate", UNEVEN_RATES + (EVEN_SAMPLE_RATE, 48000))
+ def test_a_pattern_lasts_the_seconds_its_ticks_last(self, sample_rate: int) -> None:
+ controller = make_controller()
+ synthesizer = make_synthesizer(controller, Config(), sample_rate=sample_rate)
+ ticks = _expected_ticks(controller)
+
+ rendered = sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks)))
+ expected = Fraction(sum(ticks), controller.project.settings.nes_frequency)
+
+ assert abs(Fraction(rendered, sample_rate) - expected) < Fraction(1, sample_rate)
+
+ def test_a_rate_change_is_picked_up_on_the_next_row(self) -> None:
+ """Selecting another output device rate re-times the audio rather than the song."""
+ controller = make_controller()
+ rates = [EVEN_SAMPLE_RATE]
+ synthesizer = RowSynthesizer(
+ controller,
+ Config(),
+ active_channels=all_channels,
+ sample_rate=lambda: rates[0],
+ )
+ at_even = len(synthesizer.render_row()[0])
+
+ rates[0] = UNEVEN_SAMPLE_RATE
+ synthesizer.set_position(0, 0)
+ synthesizer.reset()
+ at_uneven = len(synthesizer.render_row()[0])
+
+ difference = abs(Fraction(at_even, EVEN_SAMPLE_RATE) - Fraction(at_uneven, UNEVEN_SAMPLE_RATE))
+ assert difference < Fraction(1, UNEVEN_SAMPLE_RATE)
+
+
+class _LateRate:
+ """The rate a machine with no output device reports: none, until a device is chosen."""
+
+ def __init__(self) -> None:
+ self.rate: Optional[int] = None
+ self.reads: int = 0
+
+ def __call__(self) -> int:
+ self.reads += 1
+ if self.rate is None:
+ raise ValueError("No audio device selected")
+
+ return self.rate
+
+
+class TestTheRateIsAskedForWhenAudioIsTaken(BaseTestSuite):
+ """The rate belongs to whoever takes the audio, so it is asked for once there is audio to take.
+
+ That is what lets a session come up on a machine where nothing can play it: the song is edited,
+ exported and rendered to a file all the same, and the first row sounds at the rate the consumer
+ that reached it reports.
+ """
+
+ def test_a_synthesizer_stands_where_no_rate_can_be_stated(self) -> None:
+ rate = _LateRate()
+
+ synthesizer = RowSynthesizer(
+ make_controller(),
+ Config(),
+ active_channels=all_channels,
+ sample_rate=rate,
+ )
+ synthesizer.set_position(0, 0)
+ synthesizer.reset()
+
+ assert rate.reads == 0
+
+ def test_the_first_row_renders_at_the_rate_that_answers(self) -> None:
+ controller = make_controller()
+ rate = _LateRate()
+ synthesizer = RowSynthesizer(
+ controller,
+ Config(),
+ active_channels=all_channels,
+ sample_rate=rate,
+ )
+
+ rate.rate = UNEVEN_SAMPLE_RATE
+ rendered = len(synthesizer.render_row()[0])
+
+ clock = TickClock.from_parameters(
+ sample_rate=UNEVEN_SAMPLE_RATE,
+ nes_frequency=controller.project.settings.nes_frequency,
+ )
+ assert rendered == clock.samples_at(_expected_ticks(controller)[0])
+
+
+class TestChannelsFillTheRow(BaseTestSuite):
+ """Every channel writes into the same tick boundaries, so a mix never leaves a gap."""
+
+ def test_a_sounding_channel_fills_every_tick(self) -> None:
+ controller = make_controller()
+ reconstruction = make_pulse_reconstruction(count=1)
+ sample = add_sample(controller, reconstruction, loop=True)
+ place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE)
+
+ chunk, _ = synthesizer.render_row()
+
+ assert np.any(chunk != 0.0)
+ assert not np.any(np.isnan(chunk))
+
+ def test_a_sounding_note_stays_continuous_across_a_tick_length_change(self) -> None:
+ """A tick of a different length resumes the oscillator where the last one ended."""
+ controller = make_controller()
+ reconstruction = make_pulse_reconstruction(count=1)
+ sample = add_sample(controller, reconstruction, loop=True)
+ place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id)
+ synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE)
+
+ chunk, _ = synthesizer.render_row()
+ steps = np.abs(np.diff(chunk))
+
+ assert float(steps.max()) <= 1.0
diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py
new file mode 100644
index 00000000..e7b1778e
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py
@@ -0,0 +1,304 @@
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, Final, Iterable, List, Sequence
+
+import numpy as np
+import pytest
+
+from sampletones_application.logic.sequencer.playback.synthesizer import SampleVoice
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.constants.general import MAX_VOLUME
+from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS
+from sampletones_core.instructions import (
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
+from sampletones_core.reconstructions import Reconstruction
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+AUDIO_LENGTH: Final[int] = 64
+REFERENCE_PITCH: Final[int] = 60
+REFERENCE_PERIOD: Final[int] = 4
+SAMPLE_VOLUME: Final[int] = 9
+CHANNEL_VOLUME: Final[int] = 4
+CHANNEL_ARPEGGIO: Final[int] = 7
+CHANNEL_DUTY_CYCLE: Final[int] = 1
+CHANNEL_LONG_MODE: Final[int] = 0
+DUTY_CYCLE: Final[int] = 2
+
+
+def _reconstruction(
+ generator_name: GeneratorName,
+ instructions: Sequence[InstructionUnion],
+ held_features: Iterable[FeatureKey],
+) -> Reconstruction:
+ """A one-channel reconstruction whose instrument leaves ``held_features`` to the channel."""
+ reconstruction = Reconstruction.create(
+ approximation=np.zeros(AUDIO_LENGTH, dtype=np.float32),
+ approximations={generator_name: np.zeros(AUDIO_LENGTH, dtype=np.float32)},
+ instructions={generator_name: list(instructions)},
+ config=Config(),
+ coefficient=1.0,
+ audio_filepath=Path("/dev/null"),
+ )
+ reconstruction.update_generator_data(
+ generator_name,
+ list(instructions),
+ np.ones(AUDIO_LENGTH, dtype=np.float32),
+ reconstruction.initial_pitches[generator_name],
+ held_features,
+ )
+ return reconstruction
+
+
+def _voice(
+ generator_name: GeneratorName,
+ instructions: Sequence[InstructionUnion],
+ held_features: Iterable[FeatureKey],
+) -> SampleVoice:
+ return SampleVoice.read(_reconstruction(generator_name, instructions, held_features), generator_name)
+
+
+def _channel_values() -> Dict[FeatureKey, int]:
+ return CHANNEL_FEATURE_DEFAULTS.copy()
+
+
+class TestAFrameSoundsAsTheInstrumentWroteIt(BaseTestSuite):
+ """An instrument writing every dimension sounds its frames exactly as it holds them."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ generator_name: GeneratorName
+ instructions: List[InstructionUnion]
+
+ test_cases = (
+ TestCase(
+ label="pulse",
+ generator_name=GeneratorName.PULSE1,
+ instructions=[
+ PulseInstruction(
+ on=True,
+ pitch=REFERENCE_PITCH,
+ volume=SAMPLE_VOLUME,
+ duty_cycle=DUTY_CYCLE,
+ )
+ ],
+ ),
+ TestCase(
+ label="triangle",
+ generator_name=GeneratorName.TRIANGLE,
+ instructions=[TriangleInstruction(on=True, pitch=REFERENCE_PITCH)],
+ ),
+ TestCase(
+ label="noise",
+ generator_name=GeneratorName.NOISE,
+ instructions=[
+ NoiseInstruction(
+ on=True,
+ period=REFERENCE_PERIOD,
+ volume=SAMPLE_VOLUME,
+ short=True,
+ )
+ ],
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_frame_plays_as_it_stands(self, test_case: TestCase) -> None:
+ voice = _voice(test_case.generator_name, test_case.instructions, ())
+
+ assert voice.sound(test_case.instructions[0], _channel_values()) == test_case.instructions[0]
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_channel_takes_up_what_the_instrument_writes(self, test_case: TestCase) -> None:
+ voice = _voice(test_case.generator_name, test_case.instructions, ())
+ values = _channel_values()
+
+ voice.sound(test_case.instructions[0], values)
+
+ assert values[FeatureKey.ARPEGGIO] == 0
+ assert values[FeatureKey.VOLUME] == (MAX_VOLUME if test_case.label == "triangle" else SAMPLE_VOLUME)
+
+
+class TestAHeldDimensionSoundsAtTheChannelsValue(BaseTestSuite):
+ """A dimension the instrument leaves empty is the channel's, so it sounds at the value it holds.
+
+ Each channel offers its own dimensions and spells them in its own terms — an arpeggio is a
+ pitch on pulse and triangle and a period on noise, and a duty cycle is a waveform on pulse and
+ the noise mode on noise — so every dimension a channel offers is held here in the terms that
+ channel reads it in.
+ """
+
+ _INSTRUCTION = PulseInstruction(
+ on=True,
+ pitch=REFERENCE_PITCH,
+ volume=SAMPLE_VOLUME,
+ duty_cycle=DUTY_CYCLE,
+ )
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ generator_name: GeneratorName
+ instruction: InstructionUnion
+ held_feature: FeatureKey
+ channel_value: int
+ expected: InstructionUnion
+
+ test_cases = (
+ TestCase(
+ label="pulse volume",
+ generator_name=GeneratorName.PULSE1,
+ instruction=_INSTRUCTION,
+ held_feature=FeatureKey.VOLUME,
+ channel_value=CHANNEL_VOLUME,
+ expected=PulseInstruction(
+ on=True,
+ pitch=REFERENCE_PITCH,
+ volume=CHANNEL_VOLUME,
+ duty_cycle=DUTY_CYCLE,
+ ),
+ ),
+ TestCase(
+ label="pulse arpeggio",
+ generator_name=GeneratorName.PULSE1,
+ instruction=_INSTRUCTION,
+ held_feature=FeatureKey.ARPEGGIO,
+ channel_value=CHANNEL_ARPEGGIO,
+ expected=PulseInstruction(
+ on=True,
+ pitch=REFERENCE_PITCH + CHANNEL_ARPEGGIO,
+ volume=SAMPLE_VOLUME,
+ duty_cycle=DUTY_CYCLE,
+ ),
+ ),
+ TestCase(
+ label="pulse duty cycle",
+ generator_name=GeneratorName.PULSE1,
+ instruction=_INSTRUCTION,
+ held_feature=FeatureKey.DUTY_CYCLE,
+ channel_value=CHANNEL_DUTY_CYCLE,
+ expected=PulseInstruction(
+ on=True,
+ pitch=REFERENCE_PITCH,
+ volume=SAMPLE_VOLUME,
+ duty_cycle=CHANNEL_DUTY_CYCLE,
+ ),
+ ),
+ TestCase(
+ label="triangle arpeggio",
+ generator_name=GeneratorName.TRIANGLE,
+ instruction=TriangleInstruction(on=True, pitch=REFERENCE_PITCH),
+ held_feature=FeatureKey.ARPEGGIO,
+ channel_value=CHANNEL_ARPEGGIO,
+ expected=TriangleInstruction(on=True, pitch=REFERENCE_PITCH + CHANNEL_ARPEGGIO),
+ ),
+ TestCase(
+ label="noise period",
+ generator_name=GeneratorName.NOISE,
+ instruction=NoiseInstruction(
+ on=True,
+ period=REFERENCE_PERIOD,
+ volume=SAMPLE_VOLUME,
+ short=True,
+ ),
+ held_feature=FeatureKey.ARPEGGIO,
+ channel_value=CHANNEL_ARPEGGIO,
+ expected=NoiseInstruction(
+ on=True,
+ period=REFERENCE_PERIOD + CHANNEL_ARPEGGIO,
+ volume=SAMPLE_VOLUME,
+ short=True,
+ ),
+ ),
+ TestCase(
+ label="noise mode",
+ generator_name=GeneratorName.NOISE,
+ instruction=NoiseInstruction(
+ on=True,
+ period=REFERENCE_PERIOD,
+ volume=SAMPLE_VOLUME,
+ short=True,
+ ),
+ held_feature=FeatureKey.DUTY_CYCLE,
+ channel_value=CHANNEL_LONG_MODE,
+ expected=NoiseInstruction(
+ on=True,
+ period=REFERENCE_PERIOD,
+ volume=SAMPLE_VOLUME,
+ short=False,
+ ),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_frame_sounds_the_channels_value_and_the_instruments_rest(self, test_case: TestCase) -> None:
+ voice = _voice(test_case.generator_name, [test_case.instruction], (test_case.held_feature,))
+ values = _channel_values()
+ values[test_case.held_feature] = test_case.channel_value
+
+ assert voice.sound(test_case.instruction, values) == test_case.expected
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_a_held_dimension_leaves_the_channels_value_where_it_stands(self, test_case: TestCase) -> None:
+ voice = _voice(test_case.generator_name, [test_case.instruction], (test_case.held_feature,))
+ values = _channel_values()
+ values[test_case.held_feature] = test_case.channel_value
+
+ voice.sound(test_case.instruction, values)
+
+ assert values[test_case.held_feature] == test_case.channel_value
+
+ def test_a_level_one_instrument_wrote_is_what_the_next_one_holds(self) -> None:
+ """The channel carries a value across samples, which is what makes an empty envelope mean this."""
+ writes = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], ())
+ holds = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,))
+ values = _channel_values()
+
+ writes.sound(self._INSTRUCTION, values)
+
+ assert holds.sound(self._INSTRUCTION, values).volume == SAMPLE_VOLUME
+
+ def test_an_instrument_holding_its_level_sounds_a_silent_frame(self) -> None:
+ """Silence is stated by a volume envelope, so an instrument leaving one out plays on."""
+ rest = PulseInstruction.null_instruction()
+ voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION, rest], (FeatureKey.VOLUME,))
+
+ assert voice.sound(rest, _channel_values()).on is True
+
+ def test_a_silent_frame_takes_the_channel_to_silence_where_the_instrument_writes_its_level(self) -> None:
+ rest = PulseInstruction.null_instruction()
+ voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION, rest], ())
+ values = _channel_values()
+
+ assert voice.sound(rest, values).on is False
+ assert values[FeatureKey.VOLUME] == 0
+
+ def test_a_silent_frame_leaves_the_other_dimensions_where_the_channel_holds_them(self) -> None:
+ rest = PulseInstruction.null_instruction()
+ voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION, rest], ())
+ values = _channel_values()
+ values[FeatureKey.DUTY_CYCLE] = DUTY_CYCLE
+
+ voice.sound(rest, values)
+
+ assert values[FeatureKey.DUTY_CYCLE] == DUTY_CYCLE
diff --git a/tests/unit/sampletones_application/logic/sequencer/test_channels.py b/tests/unit/sampletones_application/logic/sequencer/test_channels.py
index 1c317991..965284fa 100644
--- a/tests/unit/sampletones_application/logic/sequencer/test_channels.py
+++ b/tests/unit/sampletones_application/logic/sequencer/test_channels.py
@@ -7,7 +7,9 @@
ALL_CHANNELS,
SequencerChannelsLogic,
)
-from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel
+from sampletones_application.view_model.sequencer.channels import (
+ SequencerChannelsViewModel,
+)
from sampletones_core.constants.enums import GeneratorName
from tests.suite.case import BaseTestCase
@@ -118,7 +120,14 @@ class GestureCase(BaseTestCase):
),
GestureCase(
label="the master gesture becomes what the next solo returns to",
- gestures=(toggle(PULSE1), solo(TRIANGLE), toggle_all(), toggle_all(), solo(TRIANGLE), solo(TRIANGLE)),
+ gestures=(
+ toggle(PULSE1),
+ solo(TRIANGLE),
+ toggle_all(),
+ toggle_all(),
+ solo(TRIANGLE),
+ solo(TRIANGLE),
+ ),
expected_muted=frozenset(),
),
GestureCase(
@@ -138,7 +147,13 @@ class GestureCase(BaseTestCase):
),
GestureCase(
label="muting all becomes what the next solo returns to",
- gestures=(toggle(PULSE1), solo(TRIANGLE), mute_all(), solo(TRIANGLE), solo(TRIANGLE)),
+ gestures=(
+ toggle(PULSE1),
+ solo(TRIANGLE),
+ mute_all(),
+ solo(TRIANGLE),
+ solo(TRIANGLE),
+ ),
expected_muted=ALL_CHANNELS,
),
GestureCase(
@@ -158,7 +173,13 @@ class GestureCase(BaseTestCase):
),
GestureCase(
label="reset starts the next solo from a full mix",
- gestures=(toggle(PULSE1), solo(TRIANGLE), reset(), solo(TRIANGLE), solo(TRIANGLE)),
+ gestures=(
+ toggle(PULSE1),
+ solo(TRIANGLE),
+ reset(),
+ solo(TRIANGLE),
+ solo(TRIANGLE),
+ ),
expected_muted=frozenset(),
),
]
@@ -171,14 +192,20 @@ def _make_logic() -> Tuple[SequencerChannelsLogic, List[SequencerChannelsViewMod
return logic, views
-def _perform(logic: SequencerChannelsLogic, gestures: Tuple[Gesture, ...]) -> None:
+def _perform(
+ logic: SequencerChannelsLogic,
+ gestures: Tuple[Gesture, ...],
+) -> None:
for gesture in gestures:
gesture(logic)
class TestGestures:
@pytest.mark.parametrize("case", GESTURE_CASES, ids=lambda case: case.label)
- def test_gestures_produce_expected_mute_set(self, case: GestureCase) -> None:
+ def test_gestures_produce_expected_mute_set(
+ self,
+ case: GestureCase,
+ ) -> None:
logic, _ = _make_logic()
_perform(logic, case.gestures)
@@ -186,7 +213,10 @@ def test_gestures_produce_expected_mute_set(self, case: GestureCase) -> None:
assert logic.build_channels().muted == case.expected_muted
@pytest.mark.parametrize("case", GESTURE_CASES, ids=lambda case: case.label)
- def test_active_channels_complement_the_mute_set(self, case: GestureCase) -> None:
+ def test_active_channels_complement_the_mute_set(
+ self,
+ case: GestureCase,
+ ) -> None:
logic, _ = _make_logic()
_perform(logic, case.gestures)
diff --git a/tests/unit/sampletones_application/logic/sequencer/test_grid.py b/tests/unit/sampletones_application/logic/sequencer/test_grid.py
deleted file mode 100644
index 6a220eee..00000000
--- a/tests/unit/sampletones_application/logic/sequencer/test_grid.py
+++ /dev/null
@@ -1,375 +0,0 @@
-from pathlib import Path
-from typing import List
-
-import numpy as np
-
-from sampletones_application.logic.project.controller import ProjectController
-from sampletones_application.logic.project.manager import ProjectManager
-from sampletones_application.logic.sequencer.grid import SequencerGridLogic
-from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME
-from sampletones_core.instructions import PulseInstruction
-from sampletones_core.project.instruments.instrument import Instrument
-from sampletones_core.project.instruments.note_off import NoteOff
-from sampletones_core.project.patterns.row import Row
-from sampletones_core.reconstructions import Reconstruction
-from sampletones_shared.constants.symbols import MIXED
-
-_LENGTH = 64
-
-
-def _controller() -> ProjectController:
- return ProjectController(ProjectManager())
-
-
-def _reconstruction(generators: List[GeneratorName]) -> Reconstruction:
- instructions = {
- generator: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)] for generator in generators
- }
- approximations = {generator: np.zeros(_LENGTH, dtype=np.float32) for generator in generators}
- return Reconstruction.create(
- approximation=np.zeros(_LENGTH, dtype=np.float32),
- approximations=approximations,
- instructions=instructions,
- config=Config(),
- coefficient=1.0,
- audio_filepath=Path("/dev/null"),
- )
-
-
-def _row(controller: ProjectController, generator: GeneratorName, row_index: int = 0) -> Row:
- song = controller.project.song
- pattern_index = song.order[0][generator]
- return song[generator].get_row(pattern_index, row_index)
-
-
-def _place_instrument(controller: ProjectController, generator: GeneratorName, sample_id: str) -> None:
- pattern_index = controller.project.song.order[0][generator]
- controller.set_row(
- generator,
- pattern_index,
- 0,
- command=Instrument(sample_id=sample_id, generator_name=generator),
- )
-
-
-class TestSetNoteOff:
- def test_set_note_off_writes_note_off_command(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
-
- logic.set_note_off(GeneratorName.PULSE1, 0)
-
- assert isinstance(_row(controller, GeneratorName.PULSE1).command, NoteOff)
-
- def test_set_note_off_all_generators_cuts_every_channel(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
-
- logic.set_note_off_all_generators(0)
-
- for generator in GeneratorName.items():
- assert isinstance(_row(controller, generator).command, NoteOff)
-
-
-class TestSetSampleInstrument:
- def test_fills_only_used_generators(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- sample = controller.add_sample(
- _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
- name="lead",
- )
-
- logic.set_sample_instrument(0, sample.id)
-
- for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE):
- command = _row(controller, generator).command
- assert command is not None
- assert command.sample_id == sample.id
- assert command.generator_name == generator
-
- for generator in (GeneratorName.PULSE2, GeneratorName.NOISE):
- assert _row(controller, generator).command is None
-
- def test_clears_channels_the_new_sample_does_not_use(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- stale = controller.add_sample(_reconstruction([GeneratorName.PULSE2]), name="bass")
- pattern_index = controller.project.song.order[0][GeneratorName.PULSE2]
- controller.set_row(
- GeneratorName.PULSE2,
- pattern_index,
- 0,
- command=Instrument(sample_id=stale.id, generator_name=GeneratorName.PULSE2),
- volume=15,
- )
-
- lead = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead")
- logic.set_sample_instrument(0, lead.id)
-
- assert _row(controller, GeneratorName.PULSE1).command is not None
- cleared = _row(controller, GeneratorName.PULSE2)
- assert cleared.command is None
- assert cleared.volume is None
-
- def test_none_sample_clears_the_whole_row(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead")
- logic.set_sample_instrument(0, sample.id)
-
- logic.set_sample_instrument(0, None)
-
- for generator in GeneratorName.items():
- assert _row(controller, generator).command is None
-
-
-class TestSampleSubcolumn:
- def test_synchronises_across_relevant_channels_even_without_instrument(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- sample = controller.add_sample(
- _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
- name="lead",
- )
- _place_instrument(controller, GeneratorName.PULSE1, sample.id)
-
- logic.set_sample_subcolumn(0, transpose=5)
- logic.set_sample_subcolumn(0, volume=10)
-
- carrier = _row(controller, GeneratorName.PULSE1)
- assert carrier.command is not None
- assert carrier.transpose == 5
- assert carrier.volume == 10
-
- synced = _row(controller, GeneratorName.TRIANGLE)
- assert synced.command is None
- assert synced.transpose == 5
- assert synced.volume == 10
-
- for generator in (GeneratorName.PULSE2, GeneratorName.NOISE):
- row = _row(controller, generator)
- assert row.transpose is None
- assert row.volume is None
-
- def test_synchronises_across_all_channels_when_no_sample_is_referenced(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
-
- logic.set_sample_subcolumn(0, transpose=5, volume=10)
-
- for generator in GeneratorName.items():
- row = _row(controller, generator)
- assert row.command is None
- assert row.transpose == 5
- assert row.volume == 10
-
- def test_clear_removes_one_subcolumn_across_relevant_channels(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- sample = controller.add_sample(
- _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
- name="lead",
- )
- logic.set_sample_instrument(0, sample.id)
- logic.set_sample_subcolumn(0, transpose=5)
- logic.set_sample_subcolumn(0, volume=10)
-
- logic.clear_sample_subcolumn(0, transpose=True)
-
- for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE):
- row = _row(controller, generator)
- assert row.transpose is None
- assert row.volume == 10
- assert row.command is not None
-
-
-class TestAdjustTranspose:
- def test_first_nudge_writes_the_delta_from_zero(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
-
- logic.adjust_transpose(GeneratorName.PULSE1, 0, 1)
-
- assert _row(controller, GeneratorName.PULSE1).transpose == 1
-
- def test_repeated_nudges_accumulate(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
-
- logic.adjust_transpose(GeneratorName.PULSE1, 0, 1)
- logic.adjust_transpose(GeneratorName.PULSE1, 0, 12)
-
- assert _row(controller, GeneratorName.PULSE1).transpose == 13
-
- def test_clamps_to_max_transpose(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- logic.set_row(GeneratorName.PULSE1, 0, transpose=MAX_TRANSPOSE)
-
- logic.adjust_transpose(GeneratorName.PULSE1, 0, 12)
-
- assert _row(controller, GeneratorName.PULSE1).transpose == MAX_TRANSPOSE
-
- def test_preserves_instrument_and_volume(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead")
- _place_instrument(controller, GeneratorName.PULSE1, sample.id)
- logic.adjust_volume(GeneratorName.PULSE1, 0, -1)
-
- logic.adjust_transpose(GeneratorName.PULSE1, 0, 2)
-
- row = _row(controller, GeneratorName.PULSE1)
- assert row.command is not None
- assert row.transpose == 2
- assert row.volume == MAX_VOLUME - 1
-
-
-class TestAdjustVolume:
- def test_unset_volume_steps_down_from_full(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
-
- logic.adjust_volume(GeneratorName.PULSE1, 0, -1)
-
- assert _row(controller, GeneratorName.PULSE1).volume == MAX_VOLUME - 1
-
- def test_unset_volume_up_stays_full(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
-
- logic.adjust_volume(GeneratorName.PULSE1, 0, 1)
-
- assert _row(controller, GeneratorName.PULSE1).volume == MAX_VOLUME
-
- def test_clamps_to_zero(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- logic.set_row(GeneratorName.PULSE1, 0, volume=1)
-
- logic.adjust_volume(GeneratorName.PULSE1, 0, -4)
-
- assert _row(controller, GeneratorName.PULSE1).volume == 0
-
-
-class TestAdjustSampleColumn:
- def test_sample_transpose_shifts_only_relevant_channels(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- sample = controller.add_sample(
- _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
- name="lead",
- )
- logic.set_sample_instrument(0, sample.id)
-
- logic.adjust_sample_transpose(0, 3)
-
- for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE):
- assert _row(controller, generator).transpose == 3
-
- for generator in (GeneratorName.PULSE2, GeneratorName.NOISE):
- assert _row(controller, generator).transpose is None
-
- def test_sample_volume_steps_relevant_channels_down_from_full(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- sample = controller.add_sample(
- _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
- name="lead",
- )
- logic.set_sample_instrument(0, sample.id)
-
- logic.adjust_sample_volume(0, -1)
-
- for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE):
- assert _row(controller, generator).volume == MAX_VOLUME - 1
-
-
-class TestBuildGridAggregation:
- def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- sample = controller.add_sample(
- _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
- name="lead",
- )
- _place_instrument(controller, GeneratorName.PULSE1, sample.id)
-
- row = logic.build_grid().rows[0]
-
- assert row.sample_instrument == MIXED
-
- def test_full_placement_reads_as_the_sample(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- sample = controller.add_sample(
- _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
- name="lead",
- )
- logic.set_sample_instrument(0, sample.id)
-
- row = logic.build_grid().rows[0]
-
- assert row.sample_instrument == row.cells[GeneratorName.PULSE1].instrument
- assert row.sample_instrument != MIXED
-
- def test_diverging_transpose_renders_as_mixed(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- sample = controller.add_sample(
- _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
- name="lead",
- )
- logic.set_sample_instrument(0, sample.id)
- logic.set_row(GeneratorName.PULSE1, 0, transpose=5)
-
- row = logic.build_grid().rows[0]
-
- assert row.sample_transpose == MIXED
-
- def test_shared_transpose_is_reflected_in_the_sample_column(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- sample = controller.add_sample(
- _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
- name="lead",
- )
- logic.set_sample_instrument(0, sample.id)
- logic.set_sample_subcolumn(0, transpose=5)
-
- row = logic.build_grid().rows[0]
-
- assert row.sample_transpose == row.cells[GeneratorName.PULSE1].transpose
- assert row.sample_transpose != MIXED
-
-
-class TestEmptyFrameAutoCreate:
- def _append_empty_frame(self, controller: ProjectController) -> None:
- controller.append_frame()
-
- def test_editing_an_empty_slot_creates_and_assigns_a_pattern(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- self._append_empty_frame(controller)
- logic.select_frame(1)
-
- logic.set_row(GeneratorName.PULSE1, 0, transpose=5)
-
- song = controller.project.song
- new_index = song.order[1][GeneratorName.PULSE1]
- assert new_index is not None
- assert song[GeneratorName.PULSE1].get_row(new_index, 0).transpose == 5
- assert song.order[1][GeneratorName.PULSE2] is None
-
- def test_empty_frame_still_shows_editable_rows(self) -> None:
- controller = _controller()
- logic = SequencerGridLogic(controller)
- self._append_empty_frame(controller)
- logic.select_frame(1)
-
- grid = logic.build_grid()
-
- assert len(grid.rows) == controller.project.song.rows_per_pattern
diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py
index 85876cd2..1baec865 100644
--- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py
+++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py
@@ -1,15 +1,23 @@
-from pathlib import Path
from typing import List, Tuple
from unittest.mock import MagicMock
-import numpy as np
import pytest
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
from sampletones_application.logic.project.controller import ProjectController
from sampletones_application.logic.project.manager import ProjectManager
-from sampletones_application.logic.sequencer.grid import SequencerGridLogic
-from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail
+from sampletones_application.logic.sequencer.history_detail import (
+ SequencerHistoryDetail,
+)
from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic
+from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic
+from sampletones_application.view_model.sequencer.region import (
+ OrderCell,
+ OrderRegion,
+ TrackerCell,
+ TrackerRegion,
+)
+from sampletones_application.view_model.sequencer.slot import TrackerSlot
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
from sampletones_application.view_model.shared.history import (
HistoryDetailRole,
@@ -17,12 +25,8 @@
HistoryDetailWord,
HistoryDetailWordSegment,
)
-from sampletones_core.configs import Config
from sampletones_core.constants.enums import FeatureKey, GeneratorName
-from sampletones_core.instructions import PulseInstruction
-from sampletones_core.reconstructions import Reconstruction
-
-_LENGTH = 64
+from tests.suite.sequencer import sample_reconstruction
Pair = Tuple[str, HistoryDetailRole]
@@ -31,41 +35,26 @@ def _controller() -> ProjectController:
return ProjectController(ProjectManager())
-def _reconstruction(generators: List[GeneratorName]) -> Reconstruction:
- instructions = {
- generator: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)] for generator in generators
- }
- approximations = {generator: np.zeros(_LENGTH, dtype=np.float32) for generator in generators}
- return Reconstruction.create(
- approximation=np.zeros(_LENGTH, dtype=np.float32),
- approximations=approximations,
- instructions=instructions,
- config=Config(),
- coefficient=1.0,
- audio_filepath=Path("/dev/null"),
- )
-
-
def _formatter(controller: ProjectController) -> SequencerHistoryDetail:
- grid_logic = SequencerGridLogic(controller)
+ tracker_logic = SequencerTrackerLogic(controller)
samples_logic = SequencerSamplesLogic(
controller,
MagicMock(),
MagicMock(),
scheduling=MagicMock(),
)
- return SequencerHistoryDetail(grid_logic, samples_logic)
+ return SequencerHistoryDetail(tracker_logic, samples_logic)
def _pairs(segments: Tuple[HistoryDetailSegment, ...]) -> List[Pair]:
return [(segment.text, segment.role) for segment in segments]
-class TestGridDetails:
+class TestTrackerDetails:
def test_edit_row_single_channel_places_sample(self) -> None:
controller = _controller()
- controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead")
- target = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="bass")
+ controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead")
+ target = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="bass")
formatter = _formatter(controller)
segments = formatter.edit_row(10, GeneratorName.PULSE1, target.id, None, None)
@@ -81,7 +70,7 @@ def test_edit_row_single_channel_places_sample(self) -> None:
def test_edit_row_sample_column_lists_the_samples_channels(self) -> None:
controller = _controller()
sample = controller.add_sample(
- _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE, GeneratorName.NOISE]),
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE, GeneratorName.NOISE]),
name="chord",
)
formatter = _formatter(controller)
@@ -135,11 +124,66 @@ def test_clear_subcolumn_names_the_column(self) -> None:
("v", HistoryDetailRole.VOLUME),
]
+ def test_a_block_reads_as_the_channels_and_the_rows_it_covers(self) -> None:
+ formatter = _formatter(_controller())
+
+ segments = formatter.tracker_block(
+ TrackerRegion(
+ first_row=4,
+ last_row=11,
+ first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE).flat_index,
+ last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index,
+ )
+ )
+
+ assert _pairs(segments) == [
+ ("00", HistoryDetailRole.FRAME),
+ ("Pp", HistoryDetailRole.CHANNEL),
+ ("04-0B", HistoryDetailRole.ROW),
+ ]
+
+ def test_a_block_reaching_the_sample_column_reads_as_every_channel(self) -> None:
+ formatter = _formatter(_controller())
+
+ segments = formatter.tracker_block(
+ TrackerRegion(
+ first_row=0,
+ last_row=0,
+ first_slot=TrackerSlot(None, SubColumn.INSTRUMENT).flat_index,
+ last_slot=TrackerSlot(None, SubColumn.VOLUME).flat_index,
+ )
+ )
+
+ assert _pairs(segments) == [
+ ("00", HistoryDetailRole.FRAME),
+ ("PpTN", HistoryDetailRole.CHANNEL),
+ ("00", HistoryDetailRole.ROW),
+ ]
+
+ def test_a_paste_reads_as_the_cell_it_was_written_from(self) -> None:
+ formatter = _formatter(_controller())
+
+ segments = formatter.tracker_paste(TrackerCell(row=3, generator=GeneratorName.NOISE))
+
+ assert _pairs(segments) == [
+ ("00", HistoryDetailRole.FRAME),
+ ("N", HistoryDetailRole.CHANNEL),
+ ("03", HistoryDetailRole.ROW),
+ ]
+
def test_adjust_transpose_shows_signed_delta(self) -> None:
controller = _controller()
formatter = _formatter(controller)
- segments = formatter.adjust_transpose(0, GeneratorName.PULSE2, -3)
+ segments = formatter.adjust_transpose(
+ TrackerRegion(
+ first_row=0,
+ last_row=0,
+ first_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index,
+ last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.VOLUME).flat_index,
+ ),
+ -3,
+ )
assert _pairs(segments) == [
("00", HistoryDetailRole.FRAME),
@@ -148,6 +192,28 @@ def test_adjust_transpose_shows_signed_delta(self) -> None:
("-03", HistoryDetailRole.TRANSPOSE),
]
+ def test_adjust_volume_reads_the_rows_it_covers(self) -> None:
+ """A shift over a selection names the span it reached, the way a block gesture does."""
+ controller = _controller()
+ formatter = _formatter(controller)
+
+ segments = formatter.adjust_volume(
+ TrackerRegion(
+ first_row=0,
+ last_row=3,
+ first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME).flat_index,
+ last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.VOLUME).flat_index,
+ ),
+ -1,
+ )
+
+ assert _pairs(segments) == [
+ ("00", HistoryDetailRole.FRAME),
+ ("Pp", HistoryDetailRole.CHANNEL),
+ ("00-03", HistoryDetailRole.ROW),
+ ("-1", HistoryDetailRole.VOLUME),
+ ]
+
class TestOrderDetails:
def test_add_frame_reports_the_landing_index(self) -> None:
@@ -155,10 +221,11 @@ def test_add_frame_reports_the_landing_index(self) -> None:
assert _pairs(formatter.add_frame(2)) == [("03", HistoryDetailRole.FRAME)]
- def test_duplicate_frame_points_source_to_the_copy(self) -> None:
+ def test_copy_frame_points_source_to_the_copy(self) -> None:
+ """One builder serves both duplicating and cloning, since each lands a copy after its source."""
formatter = _formatter(_controller())
- assert _pairs(formatter.duplicate_frame(2)) == [
+ assert _pairs(formatter.copy_frame(2)) == [
("02", HistoryDetailRole.FRAME),
(">", HistoryDetailRole.SEPARATOR),
("03", HistoryDetailRole.FRAME),
@@ -193,6 +260,50 @@ def test_set_master_entry_lists_every_channel(self) -> None:
("05", HistoryDetailRole.VALUE),
]
+ def test_a_block_reads_as_the_positions_and_the_channels_it_covers(self) -> None:
+ formatter = _formatter(_controller())
+
+ segments = formatter.order_block(
+ OrderRegion(
+ first_row=CHANNEL_AXIS.index(GeneratorName.PULSE2),
+ last_row=CHANNEL_AXIS.index(GeneratorName.TRIANGLE),
+ first_position=1,
+ last_position=4,
+ )
+ )
+
+ assert _pairs(segments) == [
+ ("01-04", HistoryDetailRole.FRAME),
+ ("pT", HistoryDetailRole.CHANNEL),
+ ]
+
+ def test_a_block_reaching_the_master_row_reads_as_every_channel(self) -> None:
+ formatter = _formatter(_controller())
+
+ segments = formatter.order_block(
+ OrderRegion(
+ first_row=CHANNEL_AXIS.index(None),
+ last_row=CHANNEL_AXIS.index(None),
+ first_position=2,
+ last_position=2,
+ )
+ )
+
+ assert _pairs(segments) == [
+ ("02", HistoryDetailRole.FRAME),
+ ("PpTN", HistoryDetailRole.CHANNEL),
+ ]
+
+ def test_a_paste_reads_as_the_cell_it_was_written_from(self) -> None:
+ formatter = _formatter(_controller())
+
+ segments = formatter.order_paste(OrderCell(generator=GeneratorName.NOISE, position=3))
+
+ assert _pairs(segments) == [
+ ("03", HistoryDetailRole.FRAME),
+ ("N", HistoryDetailRole.CHANNEL),
+ ]
+
class TestSampleDetails:
def test_add_sample_shows_the_name(self) -> None:
@@ -202,7 +313,7 @@ def test_add_sample_shows_the_name(self) -> None:
def test_remove_sample_shows_position_and_name(self) -> None:
controller = _controller()
- sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="Bass")
+ sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass")
formatter = _formatter(controller)
assert _pairs(formatter.remove_sample(sample.id)) == [
@@ -212,7 +323,7 @@ def test_remove_sample_shows_position_and_name(self) -> None:
def test_replace_sample_shows_position_and_both_names(self) -> None:
controller = _controller()
- sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="Bass")
+ sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass")
formatter = _formatter(controller)
assert _pairs(formatter.replace_sample(sample.id, "Kick")) == [
@@ -233,7 +344,7 @@ def test_rename_sample_shows_old_and_new(self) -> None:
def test_move_sample_shows_source_position_and_destination(self) -> None:
controller = _controller()
- sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="Bass")
+ sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass")
formatter = _formatter(controller)
assert _pairs(formatter.move_sample(sample.id, 5)) == [
@@ -244,7 +355,7 @@ def test_move_sample_shows_source_position_and_destination(self) -> None:
def test_set_sample_loop_stores_the_state_as_a_word_key(self) -> None:
controller = _controller()
- sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="Bass")
+ sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass")
formatter = _formatter(controller)
on_segments = formatter.set_sample_loop(sample.id, True)
@@ -269,7 +380,7 @@ def test_value_wraps_a_number(self) -> None:
class TestReconstructionDetails:
def test_edit_reconstruction_names_position_channel_and_feature(self) -> None:
controller = _controller()
- sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead")
+ sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead")
formatter = _formatter(controller)
segments = formatter.edit_reconstruction(sample.id, GeneratorName.PULSE1, FeatureKey.VOLUME)
@@ -298,7 +409,7 @@ def test_every_feature_has_a_letter_and_a_colour_role(
role: HistoryDetailRole,
) -> None:
controller = _controller()
- sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead")
+ sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead")
formatter = _formatter(controller)
segments = formatter.edit_reconstruction(sample.id, GeneratorName.PULSE1, feature_key)
diff --git a/tests/unit/sampletones_application/logic/sequencer/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/test_samples.py
index 189a486e..f0a23eca 100644
--- a/tests/unit/sampletones_application/logic/sequencer/test_samples.py
+++ b/tests/unit/sampletones_application/logic/sequencer/test_samples.py
@@ -7,9 +7,12 @@
from sampletones_application.logic.project.manager import ProjectManager
from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic
from sampletones_application.logic.shared.playback_priority import PlaybackPriority
+from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.formats.famitracker.footprint import reconstruction_footprints
from sampletones_core.project.instruments.instrument import Instrument
from sampletones_core.reconstructions import Reconstruction
+from tests.suite.sequencer import sample_reconstruction
def _logic() -> Tuple[ProjectController, SequencerSamplesLogic]:
@@ -23,7 +26,12 @@ def _logic() -> Tuple[ProjectController, SequencerSamplesLogic]:
return controller, logic
-def _logic_with_mocks() -> Tuple[ProjectController, SequencerSamplesLogic, MagicMock, MagicMock]:
+def _logic_with_mocks() -> Tuple[
+ ProjectController,
+ SequencerSamplesLogic,
+ MagicMock,
+ MagicMock,
+]:
controller = ProjectController(ProjectManager())
session_manager = MagicMock()
audio_device_manager = MagicMock()
@@ -36,7 +44,11 @@ def _logic_with_mocks() -> Tuple[ProjectController, SequencerSamplesLogic, Magic
return controller, logic, session_manager, audio_device_manager
-def _place_instrument(controller: ProjectController, generator: GeneratorName, sample_id: str) -> None:
+def _place_instrument(
+ controller: ProjectController,
+ generator: GeneratorName,
+ sample_id: str,
+) -> None:
pattern_index = controller.project.song.order[0][generator]
controller.set_row(
generator,
@@ -47,19 +59,28 @@ def _place_instrument(controller: ProjectController, generator: GeneratorName, s
class TestSampleName:
- def test_returns_the_sample_name(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_returns_the_sample_name(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller, logic = _logic()
sample = controller.add_sample(reconstruction_factory(), name="lead")
assert logic.sample_name(sample.id) == "lead"
class TestIsSampleUsed:
- def test_false_for_unreferenced_sample(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_false_for_unreferenced_sample(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller, logic = _logic()
sample = controller.add_sample(reconstruction_factory(), name="lead")
assert logic.is_sample_used(sample.id) is False
- def test_true_after_placing_in_a_pattern(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_true_after_placing_in_a_pattern(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller, logic = _logic()
sample = controller.add_sample(reconstruction_factory(), name="lead")
_place_instrument(controller, GeneratorName.PULSE1, sample.id)
@@ -67,7 +88,10 @@ def test_true_after_placing_in_a_pattern(self, reconstruction_factory: Callable[
class TestRemoveSample:
- def test_removes_unused_sample_from_pool(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_removes_unused_sample_from_pool(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller, logic = _logic()
sample = controller.add_sample(reconstruction_factory(), name="lead")
@@ -89,36 +113,120 @@ def test_removing_used_sample_clears_its_references(
class TestMoveSample:
- def test_move_sample_reorders_pool(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_move_sample_reorders_pool(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller, logic = _logic()
first = controller.add_sample(reconstruction_factory(), name="first")
controller.add_sample(reconstruction_factory(), name="second")
logic.move_sample(first.id, 1)
- assert [sample.name for sample in controller.project.samples] == ["second", "first"]
+ assert [sample.name for sample in controller.project.samples] == [
+ "second",
+ "first",
+ ]
class TestDuplicateSample:
- def test_duplicate_sample_appends_copy(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_duplicate_sample_appends_copy(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller, logic = _logic()
source = controller.add_sample(reconstruction_factory(), name="lead")
logic.duplicate_sample(source.id)
- assert [sample.name for sample in controller.project.samples] == ["lead", "lead"]
+ assert [sample.name for sample in controller.project.samples] == [
+ "lead",
+ "lead",
+ ]
class TestBuildSamples:
- def test_lists_added_samples_in_insertion_order(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_lists_added_samples_in_insertion_order(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller, logic = _logic()
first = controller.add_sample(reconstruction_factory(), name="first")
second = controller.add_sample(reconstruction_factory(), name="second")
view_model = logic.build_samples()
- assert [entry.sample_id for entry in view_model.samples] == [first.id, second.id]
- assert [entry.name for entry in view_model.samples] == ["first", "second"]
+ assert [entry.sample_id for entry in view_model.samples] == [
+ first.id,
+ second.id,
+ ]
+ assert [entry.name for entry in view_model.samples] == [
+ "first",
+ "second",
+ ]
+
+
+class TestBuildSampleFootprint:
+ """The samples menu prints what a sample occupies, measured the way the sample is placed."""
+
+ def test_it_names_each_playing_channel(self) -> None:
+ controller, logic = _logic()
+ generators = (GeneratorName.PULSE1, GeneratorName.TRIANGLE)
+ sample = controller.add_sample(sample_reconstruction(generators), name="bell")
+
+ footprint = logic.build_sample_footprint(sample.id)
+
+ assert footprint is not None
+ assert [instrument.generator for instrument in footprint.instruments] == list(generators)
+
+ def test_it_measures_the_sample_under_its_own_loop_flag(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ controller, logic = _logic()
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+ controller.set_sample_loop(sample.id, True)
+
+ footprint = logic.build_sample_footprint(sample.id)
+
+ assert footprint == SampleFootprintViewModel.from_footprints(
+ reconstruction_footprints(sample.reconstruction, loop=True)
+ )
+
+ def test_a_looping_sample_costs_less_than_a_one_shot(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ """A looping instrument shares the shortest dimension's length, so it stores fewer items."""
+ controller, logic = _logic()
+ sample = controller.add_sample(reconstruction_factory(), name="lead")
+ one_shot = logic.build_sample_footprint(sample.id)
+
+ controller.set_sample_loop(sample.id, True)
+ looping = logic.build_sample_footprint(sample.id)
+
+ assert one_shot is not None and looping is not None
+ assert looping.total_bytes < one_shot.total_bytes
+
+ def test_each_channel_is_measured_as_the_instrument_it_sounds(self) -> None:
+ """A channel's figure is the cost of its own instrument, and the channels differ.
+
+ The triangle states a pitch alone where the pulse states a level and a waveform too, so
+ the same frame written on each costs the triangle the less.
+ """
+ controller, logic = _logic()
+ generators = (GeneratorName.PULSE1, GeneratorName.TRIANGLE)
+ sample = controller.add_sample(sample_reconstruction(generators), name="bell")
+
+ footprint = logic.build_sample_footprint(sample.id)
+
+ assert footprint is not None
+ assert footprint.bytes_for(GeneratorName.TRIANGLE) < footprint.bytes_for(GeneratorName.PULSE1)
+
+ def test_a_sample_the_pool_has_dropped_is_measured_nowhere(self) -> None:
+ _, logic = _logic()
+
+ assert logic.build_sample_footprint("missing") is None
class TestPlaySample:
@@ -186,7 +294,10 @@ def test_cancel_autoplay_drops_pending_preview(
audio_device_manager.play.assert_not_called()
- def test_request_edit_cancels_pending_preview(self, reconstruction_factory: Callable[[], Reconstruction]) -> None:
+ def test_request_edit_cancels_pending_preview(
+ self,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
controller, logic, session_manager, audio_device_manager = _logic_with_mocks()
session_manager.autoplay = True
sample = controller.add_sample(reconstruction_factory(), name="lead")
diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/__init__.py b/tests/unit/sampletones_application/logic/sequencer/tracker/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py
new file mode 100644
index 00000000..20da9fe3
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py
@@ -0,0 +1,312 @@
+from dataclasses import dataclass
+from typing import Final, Optional, Tuple
+
+import pytest
+
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.sequencer.tracker import (
+ SequencerTrackerLogic,
+ TrackerRegionAdjuster,
+)
+from sampletones_application.view_model.sequencer.region import TrackerRegion
+from sampletones_application.view_model.sequencer.slot import TrackerSlot
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+from tests.suite.sequencer import fill_frame, render_frame, sample_reconstruction
+
+FRAME_ROWS: Final[int] = 3
+EMPTY: Final[str] = ".. ... . | .. ... . | .. ... . | .. ... ."
+LEAD: Final[str] = "00"
+
+
+@dataclass(frozen=True, kw_only=True)
+class Grid:
+ """A three-row frame with a sample over two channels, the state every case starts from."""
+
+ controller: ProjectController
+ logic: SequencerTrackerLogic
+ adjuster: TrackerRegionAdjuster
+ sample_ids: Tuple[str, ...]
+
+
+@pytest.fixture
+def grid() -> Grid:
+ """A frame short enough for a case to state whole, holding a sample over two of the channels.
+
+ Which channels a sample governs is what the sample column fans a shift out over, so a governed
+ row and an ungoverned one both stand available to a case.
+ """
+ controller = ProjectController(ProjectManager())
+ logic = SequencerTrackerLogic(controller)
+ logic.set_rows_per_pattern(FRAME_ROWS)
+ lead = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]),
+ name="lead",
+ )
+ return Grid(
+ controller=controller,
+ logic=logic,
+ adjuster=TrackerRegionAdjuster(logic),
+ sample_ids=(lead.id,),
+ )
+
+
+def _region(
+ first: Tuple[Optional[GeneratorName], SubColumn],
+ last: Tuple[Optional[GeneratorName], SubColumn],
+ *,
+ first_row: int = 0,
+ last_row: int = 0,
+) -> TrackerRegion:
+ """The rectangle a pair of slots bounds, each stated as the column and subcolumn it addresses."""
+ return TrackerRegion(
+ first_row=first_row,
+ last_row=last_row,
+ first_slot=TrackerSlot(*first).flat_index,
+ last_slot=TrackerSlot(*last).flat_index,
+ )
+
+
+class TestAdjustTranspose(BaseTestSuite):
+ """Which cells a transpose shift reaches, stated as the whole frame it leaves behind.
+
+ A shift acts on whole cells while a region names its edges as subcolumns, so each case states
+ the subcolumns its region begins and ends on and reads the columns behind them in the result.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ region: TrackerRegion
+ delta: int
+ expected: Tuple[str, ...]
+ frame: Tuple[str, ...] = ()
+
+ test_cases = (
+ TestCase(
+ label="a cell alone shifts its own channel",
+ region=_region(
+ (GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ (GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ ),
+ delta=1,
+ expected=(
+ ".. +01 . | .. ... . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a region standing on another subcolumn still shifts the transpose",
+ region=_region(
+ (GeneratorName.TRIANGLE, SubColumn.VOLUME),
+ (GeneratorName.TRIANGLE, SubColumn.VOLUME),
+ ),
+ delta=-1,
+ expected=(
+ ".. ... . | .. ... . | .. -01 . | .. ... .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a shift adds to the transpose a cell already holds",
+ frame=(".. +02 . | .. ... . | .. ... . | .. ... .",),
+ region=_region(
+ (GeneratorName.PULSE1, SubColumn.TRANSPOSE),
+ (GeneratorName.PULSE1, SubColumn.TRANSPOSE),
+ ),
+ delta=12,
+ expected=(
+ ".. +0E . | .. ... . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a region across columns shifts each of them",
+ region=_region(
+ (GeneratorName.PULSE2, SubColumn.VOLUME),
+ (GeneratorName.NOISE, SubColumn.INSTRUMENT),
+ ),
+ delta=1,
+ expected=(
+ ".. ... . | .. +01 . | .. +01 . | .. +01 .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a region across rows shifts each of them",
+ region=_region(
+ (GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ (GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ first_row=1,
+ last_row=2,
+ ),
+ delta=2,
+ expected=(
+ EMPTY,
+ ".. +02 . | .. ... . | .. ... . | .. ... .",
+ ".. +02 . | .. ... . | .. ... . | .. ... .",
+ ),
+ ),
+ TestCase(
+ label="an ungoverned sample column reaches every channel",
+ region=_region(
+ (None, SubColumn.INSTRUMENT),
+ (None, SubColumn.VOLUME),
+ ),
+ delta=3,
+ expected=(
+ ".. +03 . | .. +03 . | .. +03 . | .. +03 .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a governed sample column reaches the channels its sample uses",
+ frame=(f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .",),
+ region=_region(
+ (None, SubColumn.INSTRUMENT),
+ (None, SubColumn.VOLUME),
+ ),
+ delta=3,
+ expected=(
+ "00 +03 . | 00 +03 . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a channel covered beside the sample column moves a single step",
+ frame=(f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .",),
+ region=_region(
+ (None, SubColumn.INSTRUMENT),
+ (GeneratorName.PULSE1, SubColumn.VOLUME),
+ ),
+ delta=1,
+ expected=(
+ "00 +01 . | 00 +01 . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a shift stops at the transpose range",
+ frame=(".. +20 . | .. ... . | .. ... . | .. ... .",),
+ region=_region(
+ (GeneratorName.PULSE1, SubColumn.TRANSPOSE),
+ (GeneratorName.PULSE1, SubColumn.TRANSPOSE),
+ ),
+ delta=12,
+ expected=(
+ ".. +24 . | .. ... . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_frame_after_a_shift(
+ self,
+ grid: Grid,
+ test_case: TestCase,
+ ) -> None:
+ fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids)
+
+ grid.adjuster.adjust_transpose(test_case.region, test_case.delta)
+
+ assert render_frame(grid.logic) == test_case.expected
+
+
+class TestAdjustVolume(BaseTestSuite):
+ """Which cells a volume shift reaches, read the same way a transpose shift is."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ region: TrackerRegion
+ delta: int
+ expected: Tuple[str, ...]
+ frame: Tuple[str, ...] = ()
+
+ test_cases = (
+ TestCase(
+ label="an unset cell steps down from full",
+ region=_region(
+ (GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ (GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ ),
+ delta=-1,
+ expected=(
+ ".. ... E | .. ... . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a coarse step moves the whole region",
+ frame=(".. ... 8 | .. ... 8 | .. ... . | .. ... .",),
+ region=_region(
+ (GeneratorName.PULSE1, SubColumn.VOLUME),
+ (GeneratorName.PULSE2, SubColumn.VOLUME),
+ ),
+ delta=-4,
+ expected=(
+ ".. ... 4 | .. ... 4 | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a shift stops at silence",
+ frame=(".. ... 1 | .. ... . | .. ... . | .. ... .",),
+ region=_region(
+ (GeneratorName.PULSE1, SubColumn.VOLUME),
+ (GeneratorName.PULSE1, SubColumn.VOLUME),
+ ),
+ delta=-4,
+ expected=(
+ ".. ... 0 | .. ... . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a channel covered beside the sample column moves a single step",
+ frame=(f"{LEAD} ... 8 | {LEAD} ... 8 | .. ... . | .. ... .",),
+ region=_region(
+ (None, SubColumn.INSTRUMENT),
+ (GeneratorName.PULSE1, SubColumn.VOLUME),
+ ),
+ delta=-1,
+ expected=(
+ "00 ... 7 | 00 ... 7 | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_frame_after_a_shift(
+ self,
+ grid: Grid,
+ test_case: TestCase,
+ ) -> None:
+ fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids)
+
+ grid.adjuster.adjust_volume(test_case.region, test_case.delta)
+
+ assert render_frame(grid.logic) == test_case.expected
diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py
new file mode 100644
index 00000000..f1518560
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py
@@ -0,0 +1,278 @@
+from typing import Optional, Tuple
+
+import pytest
+
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.sequencer.tracker import (
+ SequencerTrackerLogic,
+ TrackerBlockReader,
+)
+from sampletones_application.view_model.sequencer.region import TrackerRegion
+from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS, TrackerSlot
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.project.instruments.note_off import NoteOff
+from tests.suite.sequencer import sample_reconstruction
+
+
+def _key(subcolumn: SubColumn, row_offset: int = 0) -> Tuple[int, int]:
+ """Where a subcolumn's value stands in a block read from the column it belongs to.
+
+ Offsets run from the base of that column, so a subcolumn's own place in the column is the
+ slot offset it reaches the block at.
+ """
+ return (row_offset, SUBCOLUMNS.index(subcolumn))
+
+
+@pytest.fixture
+def controller() -> ProjectController:
+ """A controller over a fresh project, which the samples a test places are added to."""
+ return ProjectController(ProjectManager())
+
+
+@pytest.fixture
+def logic(controller: ProjectController) -> SequencerTrackerLogic:
+ """The tracker logic the reader takes every value through."""
+ return SequencerTrackerLogic(controller)
+
+
+@pytest.fixture
+def reader(logic: SequencerTrackerLogic) -> TrackerBlockReader:
+ return TrackerBlockReader(logic)
+
+
+def _slot(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int:
+ return TrackerSlot(generator, subcolumn).flat_index
+
+
+def _cell(
+ row_index: int,
+ generator: Optional[GeneratorName],
+ subcolumn: SubColumn,
+) -> TrackerRegion:
+ """The region one subcolumn of one cell covers."""
+ slot = _slot(generator, subcolumn)
+ return TrackerRegion(
+ first_row=row_index,
+ last_row=row_index,
+ first_slot=slot,
+ last_slot=slot,
+ )
+
+
+def _column(
+ generator: Optional[GeneratorName],
+ *,
+ last_row: int = 0,
+) -> TrackerRegion:
+ """The region one whole column covers, down to ``last_row``."""
+ return TrackerRegion(
+ first_row=0,
+ last_row=last_row,
+ first_slot=_slot(generator, SubColumn.INSTRUMENT),
+ last_slot=_slot(generator, SubColumn.VOLUME),
+ )
+
+
+class TestChannelColumn:
+ """A channel answers for itself, so every one of its cells reaches the block definite."""
+
+ def test_a_cell_carries_the_values_it_holds(
+ self,
+ controller: ProjectController,
+ logic: SequencerTrackerLogic,
+ reader: TrackerBlockReader,
+ ) -> None:
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1]),
+ name="lead",
+ )
+ logic.place_note(0, GeneratorName.PULSE1, sample.id)
+ logic.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=5, volume=3)
+
+ block = reader.read(_column(GeneratorName.PULSE1))
+
+ assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id
+ assert block.transposes[_key(SubColumn.TRANSPOSE)] == 5
+ assert block.volumes[_key(SubColumn.VOLUME)] == 3
+
+ def test_an_empty_cell_carries_its_emptiness(
+ self,
+ reader: TrackerBlockReader,
+ ) -> None:
+ """An untouched channel holds no pattern at all, which reads as the empty cell it shows."""
+ block = reader.read(_column(GeneratorName.NOISE))
+
+ assert block.notes[_key(SubColumn.INSTRUMENT)] is None
+ assert block.transposes[_key(SubColumn.TRANSPOSE)] is None
+ assert block.volumes[_key(SubColumn.VOLUME)] is None
+
+ def test_a_cut_cell_carries_the_cut(
+ self,
+ logic: SequencerTrackerLogic,
+ reader: TrackerBlockReader,
+ ) -> None:
+ logic.cut_note(0, GeneratorName.PULSE1)
+
+ block = reader.read(_cell(0, GeneratorName.PULSE1, SubColumn.INSTRUMENT))
+
+ assert block.notes[_key(SubColumn.INSTRUMENT)] == NoteOff()
+
+ def test_a_zero_transpose_carries_as_the_value_it_is(
+ self,
+ logic: SequencerTrackerLogic,
+ reader: TrackerBlockReader,
+ ) -> None:
+ """An explicit zero resets the channel's transpose, so it is a value and not an absence."""
+ logic.set_cell_subcolumn(0, GeneratorName.PULSE2, transpose=0)
+
+ block = reader.read(_cell(0, GeneratorName.PULSE2, SubColumn.TRANSPOSE))
+
+ assert block.transposes[_key(SubColumn.TRANSPOSE)] == 0
+
+ def test_rows_past_the_pattern_read_empty(
+ self,
+ logic: SequencerTrackerLogic,
+ reader: TrackerBlockReader,
+ ) -> None:
+ """A region reaching past the rows a pattern holds takes emptiness from beyond its end."""
+ logic.set_rows_per_pattern(2)
+ logic.set_cell_subcolumn(0, GeneratorName.PULSE1, volume=4)
+
+ block = reader.read(_column(GeneratorName.PULSE1, last_row=3))
+
+ assert block.volumes[_key(SubColumn.VOLUME)] == 4
+ assert block.volumes[_key(SubColumn.VOLUME, 2)] is None
+ assert block.volumes[_key(SubColumn.VOLUME, 3)] is None
+
+
+class TestSampleColumn:
+ """The sample column answers for the channels it governs, agreeing or reading as nothing."""
+
+ def test_a_value_every_governed_channel_shares_carries_over(
+ self,
+ controller: ProjectController,
+ logic: SequencerTrackerLogic,
+ reader: TrackerBlockReader,
+ ) -> None:
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ name="lead",
+ )
+ logic.place_note(0, None, sample.id)
+ logic.set_cell_subcolumn(0, None, transpose=7)
+
+ block = reader.read(_column(None))
+
+ assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id
+ assert block.transposes[_key(SubColumn.TRANSPOSE)] == 7
+
+ def test_a_note_carries_as_the_sample_it_names(
+ self,
+ controller: ProjectController,
+ logic: SequencerTrackerLogic,
+ reader: TrackerBlockReader,
+ ) -> None:
+ """The channels hold instruments of their own, and the block keeps the sample they share."""
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]),
+ name="chord",
+ )
+ logic.place_note(0, None, sample.id)
+
+ block = reader.read(_cell(0, None, SubColumn.INSTRUMENT))
+
+ assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id
+
+ def test_a_column_its_channels_disagree_over_leaves_its_key_out(
+ self,
+ logic: SequencerTrackerLogic,
+ reader: TrackerBlockReader,
+ ) -> None:
+ """No sample governs the row, so the column spans every channel and only one holds a value."""
+ logic.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=5)
+
+ block = reader.read(_cell(0, None, SubColumn.TRANSPOSE))
+
+ assert _key(SubColumn.TRANSPOSE) not in block.transposes
+
+ def test_a_half_cut_row_leaves_its_note_out(
+ self,
+ logic: SequencerTrackerLogic,
+ reader: TrackerBlockReader,
+ ) -> None:
+ logic.cut_note(0, GeneratorName.PULSE1)
+
+ block = reader.read(_cell(0, None, SubColumn.INSTRUMENT))
+
+ assert _key(SubColumn.INSTRUMENT) not in block.notes
+
+ def test_a_wholly_cut_row_carries_the_cut(
+ self,
+ logic: SequencerTrackerLogic,
+ reader: TrackerBlockReader,
+ ) -> None:
+ logic.cut_note(0, None)
+
+ block = reader.read(_cell(0, None, SubColumn.INSTRUMENT))
+
+ assert block.notes[_key(SubColumn.INSTRUMENT)] == NoteOff()
+
+ def test_an_untouched_row_carries_its_emptiness(
+ self,
+ reader: TrackerBlockReader,
+ ) -> None:
+ """Every channel is equally empty, which is a reading they agree on."""
+ block = reader.read(_column(None))
+
+ assert block.notes[_key(SubColumn.INSTRUMENT)] is None
+ assert block.transposes[_key(SubColumn.TRANSPOSE)] is None
+ assert block.volumes[_key(SubColumn.VOLUME)] is None
+
+
+class TestOffsets:
+ """A block addresses its values by the offsets it was read at, whatever the cells hold."""
+
+ def test_a_mixed_edge_column_leaves_only_itself_out(
+ self,
+ logic: SequencerTrackerLogic,
+ reader: TrackerBlockReader,
+ ) -> None:
+ """The last slot reads as nothing, and the cells beside it keep the offsets they stand at."""
+ logic.set_cell_subcolumn(0, GeneratorName.PULSE1, volume=2)
+
+ block = reader.read(
+ TrackerRegion(
+ first_row=0,
+ last_row=0,
+ first_slot=_slot(None, SubColumn.INSTRUMENT),
+ last_slot=_slot(None, SubColumn.VOLUME),
+ )
+ )
+
+ assert set(block.notes) == {_key(SubColumn.INSTRUMENT)}
+ assert set(block.transposes) == {_key(SubColumn.TRANSPOSE)}
+ assert _key(SubColumn.VOLUME) not in block.volumes
+
+ def test_the_offsets_are_measured_from_the_column_the_block_begins_in(
+ self,
+ reader: TrackerBlockReader,
+ ) -> None:
+ """A block beginning midway through a column keeps that column's base as its own zero.
+
+ The offsets stay a whole column apart from the kind they address, which is what lands
+ each value in a subcolumn of its own kind wherever the block is written.
+ """
+ block = reader.read(
+ TrackerRegion(
+ first_row=0,
+ last_row=0,
+ first_slot=_slot(GeneratorName.PULSE2, SubColumn.TRANSPOSE),
+ last_slot=_slot(GeneratorName.TRIANGLE, SubColumn.INSTRUMENT),
+ )
+ )
+
+ assert set(block.transposes) == {(0, 1)}
+ assert set(block.volumes) == {(0, 2)}
+ assert set(block.notes) == {(0, 3)}
diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py
new file mode 100644
index 00000000..bee5646f
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py
@@ -0,0 +1,571 @@
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME
+from sampletones_core.project.instruments.instrument import Instrument
+from sampletones_core.project.instruments.note_off import NoteOff
+from sampletones_core.project.patterns.row import Row
+from sampletones_shared.constants.symbols import MIXED
+from tests.suite.sequencer import sample_reconstruction
+
+
+def _controller() -> ProjectController:
+ return ProjectController(ProjectManager())
+
+
+def _row(
+ controller: ProjectController,
+ generator: GeneratorName,
+ row_index: int = 0,
+) -> Row:
+ song = controller.project.song
+ pattern_index = song.order[0][generator]
+ return song[generator].get_row(pattern_index, row_index)
+
+
+def _place_instrument(
+ controller: ProjectController,
+ generator: GeneratorName,
+ sample_id: str,
+) -> None:
+ pattern_index = controller.project.song.order[0][generator]
+ controller.set_row(
+ generator,
+ pattern_index,
+ 0,
+ command=Instrument(sample_id=sample_id, generator_name=generator),
+ )
+
+
+class TestClearCell:
+ def test_a_channel_cell_clears_only_that_channel(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ logic.set_row(GeneratorName.PULSE1, 0, transpose=5)
+ logic.set_row(GeneratorName.PULSE2, 0, transpose=7)
+
+ logic.clear_cell(0, GeneratorName.PULSE1)
+
+ assert _row(controller, GeneratorName.PULSE1).transpose is None
+ assert _row(controller, GeneratorName.PULSE2).transpose == 7
+
+ def test_the_sample_column_clears_every_channel(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ logic.set_sample_subcolumn(0, transpose=5)
+
+ logic.clear_cell(0, None)
+
+ for generator in GeneratorName.items():
+ assert _row(controller, generator).transpose is None
+
+
+class TestClearCellSubcolumn:
+ def test_a_channel_cell_clears_one_subcolumn_of_its_own(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ logic.set_row(GeneratorName.PULSE1, 0, transpose=5, volume=10)
+
+ logic.clear_cell_subcolumn(0, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+
+ row = _row(controller, GeneratorName.PULSE1)
+ assert row.transpose is None
+ assert row.volume == 10
+
+ def test_the_sample_column_clears_instruments_from_every_channel(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ name="lead",
+ )
+ logic.set_sample_instrument(0, sample.id)
+ logic.set_note_off(GeneratorName.NOISE, 0)
+
+ logic.clear_cell_subcolumn(0, None, SubColumn.INSTRUMENT)
+
+ for generator in GeneratorName.items():
+ assert _row(controller, generator).command is None
+
+ def test_the_sample_column_clears_transpose_from_the_sample_channels(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ name="lead",
+ )
+ logic.set_sample_instrument(0, sample.id)
+ for generator in GeneratorName.items():
+ logic.set_row(generator, 0, transpose=5)
+
+ logic.clear_cell_subcolumn(0, None, SubColumn.TRANSPOSE)
+
+ for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE):
+ assert _row(controller, generator).transpose is None
+
+ for generator in (GeneratorName.PULSE2, GeneratorName.NOISE):
+ assert _row(controller, generator).transpose == 5
+
+
+class TestWriteCell:
+ def test_a_sample_in_the_sample_column_spreads_over_its_channels(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ name="lead",
+ )
+
+ logic.write_cell(0, None, sample.id, None, None)
+
+ for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE):
+ assert isinstance(_row(controller, generator).command, Instrument)
+
+ for generator in (GeneratorName.PULSE2, GeneratorName.NOISE):
+ assert _row(controller, generator).command is None
+
+ def test_a_sample_in_a_channel_cell_is_named_for_that_channel(self) -> None:
+ """A cell re-targets the sample onto its own channel, whichever channels the sample covers."""
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1]),
+ name="lead",
+ )
+
+ logic.write_cell(0, GeneratorName.NOISE, sample.id, None, None)
+
+ command = _row(controller, GeneratorName.NOISE).command
+ assert isinstance(command, Instrument)
+ assert command.sample_id == sample.id
+ assert command.generator_name == GeneratorName.NOISE
+ assert _row(controller, GeneratorName.PULSE1).command is None
+
+ def test_a_transpose_in_the_sample_column_reaches_every_channel(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+
+ logic.write_cell(0, None, None, 5, None)
+
+ for generator in GeneratorName.items():
+ assert _row(controller, generator).transpose == 5
+
+ def test_a_volume_in_a_channel_cell_leaves_the_rest_of_the_cell_standing(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ logic.set_row(GeneratorName.PULSE1, 0, transpose=5)
+
+ logic.write_cell(0, GeneratorName.PULSE1, None, None, 10)
+
+ row = _row(controller, GeneratorName.PULSE1)
+ assert row.transpose == 5
+ assert row.volume == 10
+
+ def test_an_edit_carrying_no_value_leaves_the_frame_alone(self) -> None:
+ """Typing a sample index the project has no sample for creates no pattern."""
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ controller.append_frame()
+ logic.select_frame(1)
+
+ logic.write_cell(0, GeneratorName.PULSE1, None, None, None)
+
+ assert controller.project.song.order[1][GeneratorName.PULSE1] is None
+
+
+class TestCutNote:
+ def test_a_channel_cell_cuts_that_channel(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+
+ logic.cut_note(0, GeneratorName.PULSE1)
+
+ assert isinstance(_row(controller, GeneratorName.PULSE1).command, NoteOff)
+ assert _row(controller, GeneratorName.PULSE2).command is None
+
+ def test_the_sample_column_cuts_every_channel(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+
+ logic.cut_note(0, None)
+
+ for generator in GeneratorName.items():
+ assert isinstance(_row(controller, generator).command, NoteOff)
+
+
+class TestFrameRowCount:
+ def test_counts_the_rows_the_grid_builds(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+
+ assert logic.frame_row_count() == len(logic.build_grid().rows)
+
+ def test_an_empty_frame_counts_editable_rows(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ controller.append_frame()
+ logic.select_frame(1)
+
+ assert logic.frame_row_count() == controller.project.song.rows_per_pattern
+
+ def test_an_order_without_frames_counts_nothing(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ controller.remove_frame(0)
+
+ assert logic.frame_row_count() == 0
+
+
+class TestRowAccess:
+ def test_reads_the_stored_row(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ logic.set_row(GeneratorName.PULSE1, 0, transpose=5)
+
+ row = logic.row(GeneratorName.PULSE1, 0)
+
+ assert row is not None
+ assert row.transpose == 5
+
+ def test_a_channel_without_a_pattern_has_no_row(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ controller.append_frame()
+ logic.select_frame(1)
+
+ assert logic.row(GeneratorName.PULSE1, 0) is None
+
+
+class TestReferencedGenerators:
+ def test_one_placement_reports_the_samples_whole_span(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ name="lead",
+ )
+ _place_instrument(controller, GeneratorName.PULSE1, sample.id)
+
+ assert logic.referenced_generators(0) == frozenset(
+ {
+ GeneratorName.PULSE1,
+ GeneratorName.TRIANGLE,
+ }
+ )
+
+ def test_a_row_naming_no_sample_references_no_channel(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ logic.set_note_off(GeneratorName.PULSE1, 0)
+
+ assert logic.referenced_generators(0) == frozenset()
+ assert logic.relevant_generators(0) == GeneratorName.items()
+
+
+class TestSetNoteOff:
+ def test_set_note_off_writes_note_off_command(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+
+ logic.set_note_off(GeneratorName.PULSE1, 0)
+
+ assert isinstance(
+ _row(controller, GeneratorName.PULSE1).command,
+ NoteOff,
+ )
+
+ def test_set_note_off_all_generators_cuts_every_channel(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+
+ logic.set_note_off_all_generators(0)
+
+ for generator in GeneratorName.items():
+ assert isinstance(_row(controller, generator).command, NoteOff)
+
+
+class TestSetSampleInstrument:
+ def test_fills_only_used_generators(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ name="lead",
+ )
+
+ logic.set_sample_instrument(0, sample.id)
+
+ for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE):
+ command = _row(controller, generator).command
+ assert isinstance(command, Instrument)
+ assert command.sample_id == sample.id
+ assert command.generator_name == generator
+
+ for generator in (GeneratorName.PULSE2, GeneratorName.NOISE):
+ assert _row(controller, generator).command is None
+
+ def test_clears_channels_the_new_sample_does_not_use(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ stale = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE2]),
+ name="bass",
+ )
+ pattern_index = controller.project.song.order[0][GeneratorName.PULSE2]
+ controller.set_row(
+ GeneratorName.PULSE2,
+ pattern_index,
+ 0,
+ command=Instrument(
+ sample_id=stale.id,
+ generator_name=GeneratorName.PULSE2,
+ ),
+ volume=15,
+ )
+
+ lead = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1]),
+ name="lead",
+ )
+ logic.set_sample_instrument(0, lead.id)
+
+ assert _row(controller, GeneratorName.PULSE1).command is not None
+ cleared = _row(controller, GeneratorName.PULSE2)
+ assert cleared.command is None
+ assert cleared.volume is None
+
+ def test_none_sample_clears_the_whole_row(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1]),
+ name="lead",
+ )
+ logic.set_sample_instrument(0, sample.id)
+
+ logic.set_sample_instrument(0, None)
+
+ for generator in GeneratorName.items():
+ assert _row(controller, generator).command is None
+
+
+class TestSampleSubcolumn:
+ def test_synchronises_across_relevant_channels_even_without_instrument(
+ self,
+ ) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ name="lead",
+ )
+ _place_instrument(controller, GeneratorName.PULSE1, sample.id)
+
+ logic.set_sample_subcolumn(0, transpose=5)
+ logic.set_sample_subcolumn(0, volume=10)
+
+ carrier = _row(controller, GeneratorName.PULSE1)
+ assert carrier.command is not None
+ assert carrier.transpose == 5
+ assert carrier.volume == 10
+
+ synced = _row(controller, GeneratorName.TRIANGLE)
+ assert synced.command is None
+ assert synced.transpose == 5
+ assert synced.volume == 10
+
+ for generator in (GeneratorName.PULSE2, GeneratorName.NOISE):
+ row = _row(controller, generator)
+ assert row.transpose is None
+ assert row.volume is None
+
+ def test_synchronises_across_all_channels_when_no_sample_is_referenced(
+ self,
+ ) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+
+ logic.set_sample_subcolumn(0, transpose=5, volume=10)
+
+ for generator in GeneratorName.items():
+ row = _row(controller, generator)
+ assert row.command is None
+ assert row.transpose == 5
+ assert row.volume == 10
+
+ def test_clear_removes_one_subcolumn_across_relevant_channels(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ name="lead",
+ )
+ logic.set_sample_instrument(0, sample.id)
+ logic.set_sample_subcolumn(0, transpose=5)
+ logic.set_sample_subcolumn(0, volume=10)
+
+ logic.clear_sample_subcolumn(0, transpose=True)
+
+ for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE):
+ row = _row(controller, generator)
+ assert row.transpose is None
+ assert row.volume == 10
+ assert row.command is not None
+
+
+class TestAdjustTranspose:
+ def test_first_nudge_writes_the_delta_from_zero(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+
+ logic.adjust_transpose(GeneratorName.PULSE1, 0, 1)
+
+ assert _row(controller, GeneratorName.PULSE1).transpose == 1
+
+ def test_repeated_nudges_accumulate(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+
+ logic.adjust_transpose(GeneratorName.PULSE1, 0, 1)
+ logic.adjust_transpose(GeneratorName.PULSE1, 0, 12)
+
+ assert _row(controller, GeneratorName.PULSE1).transpose == 13
+
+ def test_clamps_to_max_transpose(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ logic.set_row(GeneratorName.PULSE1, 0, transpose=MAX_TRANSPOSE)
+
+ logic.adjust_transpose(GeneratorName.PULSE1, 0, 12)
+
+ assert _row(controller, GeneratorName.PULSE1).transpose == MAX_TRANSPOSE
+
+ def test_preserves_instrument_and_volume(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead")
+ _place_instrument(controller, GeneratorName.PULSE1, sample.id)
+ logic.adjust_volume(GeneratorName.PULSE1, 0, -1)
+
+ logic.adjust_transpose(GeneratorName.PULSE1, 0, 2)
+
+ row = _row(controller, GeneratorName.PULSE1)
+ assert row.command is not None
+ assert row.transpose == 2
+ assert row.volume == MAX_VOLUME - 1
+
+
+class TestAdjustVolume:
+ def test_unset_volume_steps_down_from_full(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+
+ logic.adjust_volume(GeneratorName.PULSE1, 0, -1)
+
+ assert _row(controller, GeneratorName.PULSE1).volume == MAX_VOLUME - 1
+
+ def test_unset_volume_up_stays_full(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+
+ logic.adjust_volume(GeneratorName.PULSE1, 0, 1)
+
+ assert _row(controller, GeneratorName.PULSE1).volume == MAX_VOLUME
+
+ def test_clamps_to_zero(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ logic.set_row(GeneratorName.PULSE1, 0, volume=1)
+
+ logic.adjust_volume(GeneratorName.PULSE1, 0, -4)
+
+ assert _row(controller, GeneratorName.PULSE1).volume == 0
+
+
+class TestBuildTrackerAggregation:
+ def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ name="lead",
+ )
+ _place_instrument(controller, GeneratorName.PULSE1, sample.id)
+
+ row = logic.build_grid().rows[0]
+
+ assert row.sample_instrument == MIXED
+
+ def test_full_placement_reads_as_the_sample(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ name="lead",
+ )
+ logic.set_sample_instrument(0, sample.id)
+
+ row = logic.build_grid().rows[0]
+
+ assert row.sample_instrument == row.cells[GeneratorName.PULSE1].instrument
+ assert row.sample_instrument != MIXED
+
+ def test_diverging_transpose_renders_as_mixed(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ name="lead",
+ )
+ logic.set_sample_instrument(0, sample.id)
+ logic.set_row(GeneratorName.PULSE1, 0, transpose=5)
+
+ row = logic.build_grid().rows[0]
+
+ assert row.sample_transpose == MIXED
+
+ def test_shared_transpose_is_reflected_in_the_sample_column(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ sample = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]),
+ name="lead",
+ )
+ logic.set_sample_instrument(0, sample.id)
+ logic.set_sample_subcolumn(0, transpose=5)
+
+ row = logic.build_grid().rows[0]
+
+ assert row.sample_transpose == row.cells[GeneratorName.PULSE1].transpose
+ assert row.sample_transpose != MIXED
+
+
+class TestEmptyFrameAutoCreate:
+ def _append_empty_frame(self, controller: ProjectController) -> None:
+ controller.append_frame()
+
+ def test_editing_an_empty_slot_creates_and_assigns_a_pattern(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ self._append_empty_frame(controller)
+ logic.select_frame(1)
+
+ logic.set_row(GeneratorName.PULSE1, 0, transpose=5)
+
+ song = controller.project.song
+ new_index = song.order[1][GeneratorName.PULSE1]
+ assert new_index is not None
+ assert song[GeneratorName.PULSE1].get_row(new_index, 0).transpose == 5
+ assert song.order[1][GeneratorName.PULSE2] is None
+
+ def test_empty_frame_still_shows_editable_rows(self) -> None:
+ controller = _controller()
+ logic = SequencerTrackerLogic(controller)
+ self._append_empty_frame(controller)
+ logic.select_frame(1)
+
+ tracker = logic.build_grid()
+
+ assert len(tracker.rows) == controller.project.song.rows_per_pattern
diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py
new file mode 100644
index 00000000..349dc460
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py
@@ -0,0 +1,441 @@
+from dataclasses import dataclass
+from typing import Final, Tuple
+
+import pytest
+
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.sequencer.tracker import (
+ SequencerTrackerLogic,
+ TrackerBlockReader,
+ TrackerBlockWriter,
+)
+from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion
+from sampletones_application.view_model.sequencer.slot import TrackerSlot
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+from tests.suite.sequencer import (
+ fill_frame,
+ parse_block,
+ render_frame,
+ render_slots,
+ sample_reconstruction,
+)
+
+FRAME_ROWS: Final[int] = 4
+EMPTY: Final[str] = ".. ... . | .. ... . | .. ... . | .. ... ."
+LEAD: Final[str] = "00"
+BASS: Final[str] = "01"
+
+
+@dataclass(frozen=True, kw_only=True)
+class Grid:
+ """A four-row frame with two samples, the state every paste case starts from."""
+
+ controller: ProjectController
+ logic: SequencerTrackerLogic
+ writer: TrackerBlockWriter
+ sample_ids: Tuple[str, ...]
+
+
+@pytest.fixture
+def grid() -> Grid:
+ """A frame short enough for a case to state whole, holding a sample over two channels and one
+ over a third.
+
+ Which channels a sample governs is what the sample column fans a write out over, so the pair
+ covers both readings: a write that reaches some channels and clears the rest, and a note
+ written into a channel its own reconstruction leaves out.
+ """
+ controller = ProjectController(ProjectManager())
+ logic = SequencerTrackerLogic(controller)
+ logic.set_rows_per_pattern(FRAME_ROWS)
+ lead = controller.add_sample(
+ sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]),
+ name="lead",
+ )
+ bass = controller.add_sample(
+ sample_reconstruction([GeneratorName.TRIANGLE]),
+ name="bass",
+ )
+ return Grid(
+ controller=controller,
+ logic=logic,
+ writer=TrackerBlockWriter(logic),
+ sample_ids=(lead.id, bass.id),
+ )
+
+
+class TestPaste(BaseTestSuite):
+ """What a block writes where it lands, stated as the whole frame it leaves behind.
+
+ A block carries the subcolumn offsets it was read at while the cell it is written from supplies
+ only a row and a column, so every case states its origin as that pair: which subcolumn the
+ cursor happened to stand in cannot reach the result.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ block: Tuple[str, ...]
+ first_subcolumn: SubColumn
+ origin: TrackerCell
+ expected: Tuple[str, ...]
+ frame: Tuple[str, ...] = ()
+
+ test_cases = (
+ TestCase(
+ label="a block keeps its own kinds wherever the cursor stands",
+ block=("+02 8",),
+ first_subcolumn=SubColumn.TRANSPOSE,
+ origin=TrackerCell(row=1, generator=GeneratorName.PULSE2),
+ expected=(
+ EMPTY,
+ ".. ... . | .. +02 8 | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a sample through the sample column reaches its channels and clears the rest",
+ frame=(".. ... . | .. ... . | .. ... . | .. ... 5",),
+ block=(LEAD,),
+ first_subcolumn=SubColumn.INSTRUMENT,
+ origin=TrackerCell(row=0, generator=None),
+ expected=(
+ "00 ... . | 00 ... . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a channel beside the sample column overwrites what it settled",
+ block=(f"{LEAD} ... . | {BASS}",),
+ first_subcolumn=SubColumn.INSTRUMENT,
+ origin=TrackerCell(row=0, generator=None),
+ expected=(
+ "01 ... . | 00 ... . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a block read from the sample column writes one channel when written to one",
+ block=(LEAD,),
+ first_subcolumn=SubColumn.INSTRUMENT,
+ origin=TrackerCell(row=0, generator=GeneratorName.TRIANGLE),
+ expected=(
+ ".. ... . | .. ... . | 00 ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a mixed cell leaves its target as it stands while its neighbours clear theirs",
+ frame=("00 +03 7 | .. ... . | .. ... . | .. ... .",),
+ block=(".. ? .",),
+ first_subcolumn=SubColumn.INSTRUMENT,
+ origin=TrackerCell(row=0, generator=GeneratorName.PULSE1),
+ expected=(
+ ".. +03 . | .. ... . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="an explicit zero transpose lands while an empty one clears",
+ frame=(".. +03 . | .. +05 . | .. ... . | .. ... .",),
+ block=("+00 ? | ? ...",),
+ first_subcolumn=SubColumn.TRANSPOSE,
+ origin=TrackerCell(row=0, generator=GeneratorName.PULSE1),
+ expected=(
+ ".. +00 . | .. ... . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a cut through the sample column cuts every channel",
+ frame=("00 ... . | 00 ... . | .. ... . | .. ... .",),
+ block=("~~",),
+ first_subcolumn=SubColumn.INSTRUMENT,
+ origin=TrackerCell(row=0, generator=None),
+ expected=(
+ "~~ ... . | ~~ ... . | ~~ ... . | ~~ ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a note naming an absent sample writes nothing into a channel",
+ frame=("00 +02 5 | .. ... . | .. ... . | .. ... .",),
+ block=("!! ? ?",),
+ first_subcolumn=SubColumn.INSTRUMENT,
+ origin=TrackerCell(row=0, generator=GeneratorName.PULSE1),
+ expected=(
+ "00 +02 5 | .. ... . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a note naming an absent sample clears nothing through the sample column",
+ frame=("00 ... . | 00 ... . | .. ... . | .. ... 5",),
+ block=("!!",),
+ first_subcolumn=SubColumn.INSTRUMENT,
+ origin=TrackerCell(row=0, generator=None),
+ expected=(
+ "00 ... . | 00 ... . | .. ... . | .. ... 5",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="an empty instrument through the sample column clears every channel",
+ frame=("00 ... . | 00 ... . | .. ... . | ~~ ... .",),
+ block=("..",),
+ first_subcolumn=SubColumn.INSTRUMENT,
+ origin=TrackerCell(row=0, generator=None),
+ expected=(EMPTY, EMPTY, EMPTY, EMPTY),
+ ),
+ TestCase(
+ label="an empty transpose through an ungoverned sample column clears every channel",
+ frame=(".. +02 . | .. +02 . | .. +02 . | .. +02 .",),
+ block=("...",),
+ first_subcolumn=SubColumn.TRANSPOSE,
+ origin=TrackerCell(row=0, generator=None),
+ expected=(EMPTY, EMPTY, EMPTY, EMPTY),
+ ),
+ TestCase(
+ label="a transpose through a governed sample column reaches its channels alone",
+ frame=("00 ... . | 00 ... . | .. ... . | .. ... .",),
+ block=("+02",),
+ first_subcolumn=SubColumn.TRANSPOSE,
+ origin=TrackerCell(row=0, generator=None),
+ expected=(
+ "00 +02 . | 00 +02 . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a silent volume writes zero rather than emptiness",
+ block=("0",),
+ first_subcolumn=SubColumn.VOLUME,
+ origin=TrackerCell(row=0, generator=GeneratorName.PULSE1),
+ expected=(
+ ".. ... 0 | .. ... . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="rows past the frame's last are dropped rather than wrapped",
+ block=("+01", "+02", "+03"),
+ first_subcolumn=SubColumn.TRANSPOSE,
+ origin=TrackerCell(row=2, generator=GeneratorName.PULSE1),
+ expected=(
+ EMPTY,
+ EMPTY,
+ ".. +01 . | .. ... . | .. ... . | .. ... .",
+ ".. +02 . | .. ... . | .. ... . | .. ... .",
+ ),
+ ),
+ TestCase(
+ label="slots past the last column are dropped rather than wrapped",
+ block=(f"{LEAD} ... . | {BASS}",),
+ first_subcolumn=SubColumn.INSTRUMENT,
+ origin=TrackerCell(row=0, generator=GeneratorName.NOISE),
+ expected=(
+ ".. ... . | .. ... . | .. ... . | 00 ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a wholly mixed block leaves the frame as it stands",
+ frame=("00 +02 5 | .. ... . | .. ... . | .. ... .",),
+ block=("? ? ?",),
+ first_subcolumn=SubColumn.INSTRUMENT,
+ origin=TrackerCell(row=0, generator=GeneratorName.PULSE1),
+ expected=(
+ "00 +02 5 | .. ... . | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ TestCase(
+ label="a wholly empty block empties what it covers",
+ frame=("00 +02 5 | 00 +02 5 | .. ... . | .. ... .",),
+ block=(".. ... .",),
+ first_subcolumn=SubColumn.INSTRUMENT,
+ origin=TrackerCell(row=0, generator=GeneratorName.PULSE1),
+ expected=(
+ ".. ... . | 00 +02 5 | .. ... . | .. ... .",
+ EMPTY,
+ EMPTY,
+ EMPTY,
+ ),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_frame_after_a_paste(
+ self,
+ grid: Grid,
+ test_case: TestCase,
+ ) -> None:
+ fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids)
+ block = parse_block(
+ test_case.block,
+ first_subcolumn=test_case.first_subcolumn,
+ sample_ids=grid.sample_ids,
+ )
+
+ grid.writer.write(block, test_case.origin)
+
+ assert render_frame(grid.logic) == test_case.expected
+
+
+class TestSingleSlotEquivalence:
+ """A block of one cell writes what typing that cell writes, which is what makes a paste
+ explainable as the edits it is made of."""
+
+ def test_a_single_cell_block_matches_the_edit_it_stands_for(self, grid: Grid) -> None:
+ block = parse_block(
+ ("+02",),
+ first_subcolumn=SubColumn.TRANSPOSE,
+ sample_ids=grid.sample_ids,
+ )
+ grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE1))
+ pasted = render_frame(grid.logic)
+
+ typed = _typed_grid()
+ typed.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=2)
+
+ assert pasted == render_frame(typed)
+
+
+class TestClear:
+ """What a delete empties, which is every subcolumn its region covers and nothing beside."""
+
+ def test_a_region_empties_the_subcolumns_it_covers(self, grid: Grid) -> None:
+ fill_frame(
+ grid.logic,
+ ("00 +02 5 | 00 +03 6 | .. ... . | .. ... .",),
+ sample_ids=grid.sample_ids,
+ )
+
+ grid.writer.clear(
+ TrackerRegion(
+ first_row=0,
+ last_row=0,
+ first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE).flat_index,
+ last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index,
+ )
+ )
+
+ assert render_frame(grid.logic)[0] == "00 ... . | .. +03 6 | .. ... . | .. ... ."
+
+ def test_a_region_over_the_sample_column_empties_the_channels_it_governs(self, grid: Grid) -> None:
+ fill_frame(
+ grid.logic,
+ ("00 +02 5 | 00 +02 5 | .. ... . | .. ... 5",),
+ sample_ids=grid.sample_ids,
+ )
+
+ grid.writer.clear(
+ TrackerRegion(
+ first_row=0,
+ last_row=0,
+ first_slot=TrackerSlot(None, SubColumn.VOLUME).flat_index,
+ last_slot=TrackerSlot(None, SubColumn.VOLUME).flat_index,
+ )
+ )
+
+ assert render_frame(grid.logic)[0] == "00 +02 . | 00 +02 . | .. ... . | .. ... 5"
+
+
+class TestRoundTrip:
+ """Reading a region, emptying it and writing the block back leaves the frame it came from."""
+
+ def test_a_block_written_back_at_its_origin_restores_the_frame(self, grid: Grid) -> None:
+ fill_frame(
+ grid.logic,
+ (
+ "00 +02 5 | 00 ... . | .. ... . | ~~ ... 3",
+ ".. ... . | 01 +00 0 | .. +07 . | .. ... .",
+ ),
+ sample_ids=grid.sample_ids,
+ )
+ before = render_frame(grid.logic)
+ region = TrackerRegion(
+ first_row=0,
+ last_row=1,
+ first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT).flat_index,
+ last_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index,
+ )
+ block = TrackerBlockReader(grid.logic).read(region)
+
+ grid.writer.clear(region)
+ grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE1))
+
+ assert render_frame(grid.logic) == before
+
+
+class TestMaterialisation:
+ """A paste reaches a channel holding no pattern by giving it one, the way an edit does."""
+
+ def test_a_frame_holding_no_pattern_gains_one_where_a_block_lands(self, grid: Grid) -> None:
+ position = grid.controller.project.song.order_length()
+ grid.controller.append_frame()
+ grid.logic.select_frame(position)
+ assert render_slots(grid.controller, position) == ".. .. .. .."
+
+ block = parse_block(
+ ("+02",),
+ first_subcolumn=SubColumn.TRANSPOSE,
+ sample_ids=grid.sample_ids,
+ )
+ grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE2))
+
+ assert render_slots(grid.controller, position) == ".. 01 .. .."
+ assert render_frame(grid.logic)[0] == ".. ... . | .. +02 . | .. ... . | .. ... ."
+
+ def test_a_wholly_mixed_block_leaves_a_frame_with_no_patterns_at_all(self, grid: Grid) -> None:
+ position = grid.controller.project.song.order_length()
+ grid.controller.append_frame()
+ grid.logic.select_frame(position)
+
+ block = parse_block(
+ ("? ? ?",),
+ first_subcolumn=SubColumn.INSTRUMENT,
+ sample_ids=grid.sample_ids,
+ )
+ grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE2))
+
+ assert render_slots(grid.controller, position) == ".. .. .. .."
+
+
+def _typed_grid() -> SequencerTrackerLogic:
+ """A second frame of the same shape, reached through the single-slot edits alone."""
+ logic = SequencerTrackerLogic(ProjectController(ProjectManager()))
+ logic.set_rows_per_pattern(FRAME_ROWS)
+ return logic
diff --git a/tests/unit/sampletones_application/logic/shared/test_project_source.py b/tests/unit/sampletones_application/logic/shared/test_project_source.py
new file mode 100644
index 00000000..4d8b081c
--- /dev/null
+++ b/tests/unit/sampletones_application/logic/shared/test_project_source.py
@@ -0,0 +1,65 @@
+from typing import Callable
+
+import pytest
+
+from sampletones_application.logic.project.controller import ProjectController
+from sampletones_application.logic.project.manager import ProjectManager
+from sampletones_application.logic.shared.project_source import (
+ ProjectSnapshot,
+ ProjectSource,
+ snapshot_project,
+)
+from sampletones_core.reconstructions import Reconstruction
+from tests.suite.base import BaseTestSuite
+
+
+@pytest.fixture
+def project_controller() -> ProjectController:
+ return ProjectController(ProjectManager())
+
+
+class TestSnapshotIndependence(BaseTestSuite):
+ def test_light_structure_is_deep_copied(self, project_controller: ProjectController) -> None:
+ project_controller.set_tempo(120)
+
+ snapshot = snapshot_project(project_controller.project)
+ project_controller.set_tempo(200)
+
+ assert snapshot.settings.tempo == 120
+ assert snapshot.song is not project_controller.project.song
+
+ def test_reconstruction_audio_is_shared(
+ self,
+ project_controller: ProjectController,
+ reconstruction_factory: Callable[[], Reconstruction],
+ ) -> None:
+ sample = project_controller.add_sample(reconstruction_factory(), name="lead")
+
+ snapshot = snapshot_project(project_controller.project)
+
+ assert snapshot.samples[sample.id].reconstruction is sample.reconstruction
+
+
+class TestASnapshotIsASource(BaseTestSuite):
+ """A captured document reads as the source a synthesiser takes."""
+
+ def test_the_live_controller_is_a_source(self, project_controller: ProjectController) -> None:
+ source: ProjectSource = project_controller
+
+ assert source.project is project_controller.project
+
+ def test_a_snapshot_is_a_source(self, project_controller: ProjectController) -> None:
+ source: ProjectSource = ProjectSnapshot.capture(project_controller)
+
+ assert source.project.settings.tempo == project_controller.project.settings.tempo
+
+ def test_the_document_stands_still_while_the_project_moves_on(
+ self,
+ project_controller: ProjectController,
+ ) -> None:
+ project_controller.set_tempo(120)
+
+ snapshot = ProjectSnapshot.capture(project_controller)
+ project_controller.set_tempo(200)
+
+ assert snapshot.project.settings.tempo == 120
diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py
index 7152921b..afc741b3 100644
--- a/tests/unit/sampletones_application/logic/shared/test_tree.py
+++ b/tests/unit/sampletones_application/logic/shared/test_tree.py
@@ -4,17 +4,15 @@
import pytest
-from sampletones_application.layout.behavior import (
- SchedulingBehavior,
- SchedulingDelays,
- SchedulingEmit,
- SchedulingPriorities,
-)
+from sampletones_application.layout.behavior.scheduling.delays import SchedulingDelays
+from sampletones_application.layout.behavior.scheduling.emit import SchedulingEmit
+from sampletones_application.layout.behavior.scheduling.priorities import SchedulingPriorities
+from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior
from sampletones_application.logic.shared.playback_priority import PlaybackPriority
from sampletones_application.logic.shared.tree import TreeLogic
-from sampletones_core import paths
from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode
from sampletones_shared.exceptions import InvalidReconstructionError
+from sampletones_shared.paths import extensions
def _tree(
@@ -26,8 +24,10 @@ def _tree(
session_manager = MagicMock()
session_manager.autoplay = True
session_manager.favorites = set()
+
if audio_device_manager is None:
audio_device_manager = MagicMock()
+
if scheduling is None:
scheduling = SchedulingBehavior(
delays=SchedulingDelays(
@@ -43,7 +43,12 @@ def _tree(
emit=SchedulingEmit(priority=0, batch_size=128),
queue_budget_seconds=0.005,
)
- return TreeLogic(session_manager, audio_device_manager, scheduling=scheduling)
+
+ return TreeLogic(
+ session_manager,
+ audio_device_manager,
+ scheduling=scheduling,
+ )
def _file_node(filepath: Path) -> FileSystemNode:
@@ -120,7 +125,10 @@ def test_autoplay_wav_file_calls_play_file(self, tmp_path: Path) -> None:
priority=PlaybackPriority.PREVIEW,
)
- def test_autoplay_with_directory_node_is_no_op(self, tmp_path: Path) -> None:
+ def test_autoplay_with_directory_node_is_no_op(
+ self,
+ tmp_path: Path,
+ ) -> None:
audio_device_manager = MagicMock()
session_manager = MagicMock()
session_manager.autoplay = True
@@ -157,7 +165,10 @@ def test_autoplay_enabled_property_reflects_session(self) -> None:
class TestTreeLogicPlayNode:
- def test_play_node_uses_normal_priority_and_ignores_autoplay(self, tmp_path: Path) -> None:
+ def test_play_node_uses_normal_priority_and_ignores_autoplay(
+ self,
+ tmp_path: Path,
+ ) -> None:
audio_device_manager = MagicMock()
session_manager = MagicMock()
session_manager.autoplay = False
@@ -251,6 +262,48 @@ def test_has_favorite_ancestor_returns_true_when_parent_is_favorite(
child.parent = parent
assert tree.has_favorite_ancestor(child) is True
+ def test_has_favorite_ancestor_reads_the_path_rather_than_the_rows_above(
+ self,
+ tmp_path: Path,
+ ) -> None:
+ """A view may list a file under invented rows, and the favorite directory is still its own."""
+ directory_path = tmp_path / "config"
+ session_manager = MagicMock()
+ session_manager.favorites = {directory_path}
+ tree = _tree(session_manager=session_manager)
+ node = _file_node(directory_path / "song.stn")
+ node.parent = TreeNode("cw_amen02_165", NodeType.SAMPLE)
+ assert tree.has_favorite_ancestor(node) is True
+
+ def test_has_favorite_ancestor_reaches_any_depth(
+ self,
+ tmp_path: Path,
+ ) -> None:
+ session_manager = MagicMock()
+ session_manager.favorites = {tmp_path}
+ tree = _tree(session_manager=session_manager)
+ node = _file_node(tmp_path / "config" / "album" / "song.stn")
+ assert tree.has_favorite_ancestor(node) is True
+
+ def test_has_favorite_ancestor_returns_false_for_a_favorite_sibling(
+ self,
+ tmp_path: Path,
+ ) -> None:
+ session_manager = MagicMock()
+ session_manager.favorites = {tmp_path / "other.wav"}
+ tree = _tree(session_manager=session_manager)
+ assert tree.has_favorite_ancestor(_file_node(tmp_path / "audio.wav")) is False
+
+ def test_has_favorite_ancestor_returns_false_for_the_node_itself(
+ self,
+ tmp_path: Path,
+ ) -> None:
+ filepath = tmp_path / "audio.wav"
+ session_manager = MagicMock()
+ session_manager.favorites = {filepath}
+ tree = _tree(session_manager=session_manager)
+ assert tree.has_favorite_ancestor(_file_node(filepath)) is False
+
def test_toggle_favorite_delegates_to_session(
self,
tmp_path: Path,
@@ -299,11 +352,15 @@ class TestReconstructionAutoplayFailure:
[InvalidReconstructionError("corrupt"), PermissionError("denied")],
ids=["domain", "io"],
)
- def test_load_failure_reports_autoplay_error(self, tmp_path: Path, error: Exception) -> None:
+ def test_load_failure_reports_autoplay_error(
+ self,
+ tmp_path: Path,
+ error: Exception,
+ ) -> None:
audio_device_manager = MagicMock()
tree = _tree(audio_device_manager=audio_device_manager)
tree.on_autoplay_error = MagicMock()
- node = _file_node(tmp_path / f"sample{paths.EXT_FILE_RECONSTRUCTION}")
+ node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}")
with patch(
"sampletones_application.logic.shared.tree.Reconstruction.load",
@@ -317,13 +374,15 @@ def test_load_failure_reports_autoplay_error(self, tmp_path: Path, error: Except
def test_unexpected_failure_propagates(self, tmp_path: Path) -> None:
tree = _tree()
tree.on_autoplay_error = MagicMock()
- node = _file_node(tmp_path / f"sample{paths.EXT_FILE_RECONSTRUCTION}")
+ node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}")
- with patch(
- "sampletones_application.logic.shared.tree.Reconstruction.load",
- side_effect=RuntimeError("bug"),
+ with (
+ patch(
+ "sampletones_application.logic.shared.tree.Reconstruction.load",
+ side_effect=RuntimeError("bug"),
+ ),
+ pytest.raises(RuntimeError),
):
- with pytest.raises(RuntimeError):
- tree.request_autoplay(node)
+ tree.request_autoplay(node)
tree.on_autoplay_error.assert_not_called()
diff --git a/tests/unit/sampletones_application/parameters/conftest.py b/tests/unit/sampletones_application/parameters/conftest.py
index d5b15f2d..42e8a24a 100644
--- a/tests/unit/sampletones_application/parameters/conftest.py
+++ b/tests/unit/sampletones_application/parameters/conftest.py
@@ -2,10 +2,16 @@
from sampletones_application.layout.config import LayoutConfig
from sampletones_application.layout.loader import load_layout_config
-from sampletones_application.paths import BEHAVIOR_DIRECTORY, LAYOUT_DIRECTORY, PALETTE_PATH
-from sampletones_application.utils.palette import Palette
+from sampletones_application.paths import (
+ BEHAVIOR_DIRECTORY,
+ LAYOUT_DIRECTORY,
+ PALETTES_DIRECTORY,
+)
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.source import PaletteSource
@pytest.fixture
def layout_config() -> LayoutConfig:
- return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, Palette.load(PALETTE_PATH))
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
diff --git a/tests/unit/sampletones_application/parameters/test_geometry.py b/tests/unit/sampletones_application/parameters/test_geometry.py
index a6efa21d..288d0314 100644
--- a/tests/unit/sampletones_application/parameters/test_geometry.py
+++ b/tests/unit/sampletones_application/parameters/test_geometry.py
@@ -4,9 +4,13 @@
class TestTabGeometryFromConfig:
"""The shared geometry core reads its six scalars from the storage paths the coordinators
- used to reach through, so the deep-path knowledge lives in one factory instead of four."""
+ used to reach through, so the deep-path knowledge lives in one factory instead of four.
+ """
- def test_flattens_the_geometry_paths(self, layout_config: LayoutConfig) -> None:
+ def test_flattens_the_geometry_paths(
+ self,
+ layout_config: LayoutConfig,
+ ) -> None:
geometry = TabGeometry.from_config(layout_config)
assert geometry.side_width == layout_config.general.columns.side.width
diff --git a/tests/unit/sampletones_application/parameters/test_instructions.py b/tests/unit/sampletones_application/parameters/test_instructions.py
index 47867977..3741ce0e 100644
--- a/tests/unit/sampletones_application/parameters/test_instructions.py
+++ b/tests/unit/sampletones_application/parameters/test_instructions.py
@@ -11,7 +11,7 @@ def test_forwards_models_and_flattens_geometry(self, layout_config: LayoutConfig
params = InstructionsTabParameters.from_config(layout_config)
assert params.baseline_viewport_height == layout_config.general.responsive.baseline_viewport_height
- assert params.max_stack_height == layout_config.general.responsive.max_stack_height
+ assert params.max_graph_height == layout_config.general.responsive.max_graph_height
assert params.base_graph_height == layout_config.graphs.dimensions.height
assert params.right_column_width == layout_config.tabs.instructions.right_column.width
assert params.right_column_height == layout_config.tabs.instructions.right_column.height
diff --git a/tests/unit/sampletones_application/parameters/test_main.py b/tests/unit/sampletones_application/parameters/test_main.py
index 537cb4dd..64d4ec01 100644
--- a/tests/unit/sampletones_application/parameters/test_main.py
+++ b/tests/unit/sampletones_application/parameters/test_main.py
@@ -4,9 +4,13 @@
class TestMainTabParametersFromConfig:
"""The Main tab view forwards cohesive feature models whole and flattens only the geometry the
- coordinator feeds to pure-int sinks; the tree colors are pre-built at the composition root."""
+ coordinator feeds to pure-int sinks; the tree colors are pre-built at the composition root.
+ """
- def test_forwards_models_and_flattens_geometry(self, layout_config: LayoutConfig) -> None:
+ def test_forwards_models_and_flattens_geometry(
+ self,
+ layout_config: LayoutConfig,
+ ) -> None:
params = MainTabParameters.from_config(layout_config)
assert params.config_height == layout_config.tabs.main.config.height
@@ -15,7 +19,10 @@ def test_forwards_models_and_flattens_geometry(self, layout_config: LayoutConfig
assert params.path_colors is layout_config.general.colors.paths
assert params.scheduling is layout_config.behavior.scheduling
- def test_tree_colors_take_the_path_hover_accent(self, layout_config: LayoutConfig) -> None:
+ def test_tree_colors_take_the_path_hover_accent(
+ self,
+ layout_config: LayoutConfig,
+ ) -> None:
params = MainTabParameters.from_config(layout_config)
assert params.tree_colors.accent == layout_config.general.colors.paths.hover
diff --git a/tests/unit/sampletones_application/parameters/test_reconstruction.py b/tests/unit/sampletones_application/parameters/test_reconstruction.py
index bc6c43b6..d19d1ef4 100644
--- a/tests/unit/sampletones_application/parameters/test_reconstruction.py
+++ b/tests/unit/sampletones_application/parameters/test_reconstruction.py
@@ -7,7 +7,10 @@ class TestReconstructionTabParametersFromConfig:
column geometry, and narrows the instruments panel's slice of the general layout to a
pitch-stepper style plus the two extra fields the panel draws with."""
- def test_forwards_models_and_flattens_geometry(self, layout_config: LayoutConfig) -> None:
+ def test_forwards_models_and_flattens_geometry(
+ self,
+ layout_config: LayoutConfig,
+ ) -> None:
params = ReconstructionTabParameters.from_config(layout_config)
assert params.right_column_width == layout_config.tabs.reconstruction.right_column.width
@@ -19,12 +22,18 @@ def test_forwards_models_and_flattens_geometry(self, layout_config: LayoutConfig
assert params.path_status_color == layout_config.general.colors.text.disabled
assert params.scheduling is layout_config.behavior.scheduling
- def test_tree_colors_take_the_reconstruction_header_accent(self, layout_config: LayoutConfig) -> None:
+ def test_tree_colors_take_the_reconstruction_header_accent(
+ self,
+ layout_config: LayoutConfig,
+ ) -> None:
params = ReconstructionTabParameters.from_config(layout_config)
assert params.tree_colors.accent == layout_config.general.colors.headers.reconstruction
- def test_pitch_stepper_style_is_narrowed_from_general(self, layout_config: LayoutConfig) -> None:
+ def test_pitch_stepper_style_is_narrowed_from_general(
+ self,
+ layout_config: LayoutConfig,
+ ) -> None:
params = ReconstructionTabParameters.from_config(layout_config)
assert params.pitch_stepper_style.dimensions is layout_config.general.pitch_stepper
diff --git a/tests/unit/sampletones_application/services/export/test_result.py b/tests/unit/sampletones_application/services/export/test_result.py
index a1743601..715763e1 100644
--- a/tests/unit/sampletones_application/services/export/test_result.py
+++ b/tests/unit/sampletones_application/services/export/test_result.py
@@ -13,7 +13,12 @@
class TestExportSuccess:
def test_stores_kind_and_filepath(self) -> None:
filepath = Path("/exports/track.wav")
- success = ExportSuccess(kind=ExportKind.WAV, filepath=filepath, tracker_format=None, truncation=None)
+ success = ExportSuccess(
+ kind=ExportKind.WAV,
+ filepath=filepath,
+ tracker_format=None,
+ truncation=None,
+ )
assert success.kind == ExportKind.WAV
assert success.filepath == filepath
assert success.tracker_format is None
@@ -29,7 +34,11 @@ def test_stores_the_tracker_format(self) -> None:
assert success.tracker_format == TrackerFormat.BITPHASE
def test_stores_the_truncation(self) -> None:
- truncation = EnvelopeTruncation(frames=252, source_frames=300, instruments=1)
+ truncation = EnvelopeTruncation(
+ frames=252,
+ source_frames=300,
+ instruments=1,
+ )
success = ExportSuccess(
kind=ExportKind.INSTRUMENT,
filepath=Path("/x"),
@@ -39,47 +48,94 @@ def test_stores_the_truncation(self) -> None:
assert success.truncation == truncation
def test_frozen(self) -> None:
- success = ExportSuccess(kind=ExportKind.WAV, filepath=Path("/x"), tracker_format=None, truncation=None)
+ success = ExportSuccess(
+ kind=ExportKind.WAV,
+ filepath=Path("/x"),
+ tracker_format=None,
+ truncation=None,
+ )
with pytest.raises(FrozenInstanceError):
success.kind = ExportKind.INSTRUMENT # type: ignore[misc]
def test_equality(self) -> None:
path = Path("/x")
- assert ExportSuccess(kind=ExportKind.WAV, filepath=path, tracker_format=None, truncation=None) == ExportSuccess(
- kind=ExportKind.WAV, filepath=path, tracker_format=None, truncation=None
+ assert ExportSuccess(
+ kind=ExportKind.WAV,
+ filepath=path,
+ tracker_format=None,
+ truncation=None,
+ ) == ExportSuccess(
+ kind=ExportKind.WAV,
+ filepath=path,
+ tracker_format=None,
+ truncation=None,
)
- assert ExportSuccess(kind=ExportKind.WAV, filepath=path, tracker_format=None, truncation=None) != ExportSuccess(
- kind=ExportKind.INSTRUMENT, filepath=path, tracker_format=None, truncation=None
+ assert ExportSuccess(
+ kind=ExportKind.WAV,
+ filepath=path,
+ tracker_format=None,
+ truncation=None,
+ ) != ExportSuccess(
+ kind=ExportKind.INSTRUMENT,
+ filepath=path,
+ tracker_format=None,
+ truncation=None,
)
def test_the_tracker_format_separates_two_otherwise_equal_results(self) -> None:
path = Path("/x")
assert ExportSuccess(
- kind=ExportKind.INSTRUMENT, filepath=path, tracker_format=TrackerFormat.FAMITRACKER, truncation=None
+ kind=ExportKind.INSTRUMENT,
+ filepath=path,
+ tracker_format=TrackerFormat.FAMITRACKER,
+ truncation=None,
) != ExportSuccess(
- kind=ExportKind.INSTRUMENT, filepath=path, tracker_format=TrackerFormat.BITPHASE, truncation=None
+ kind=ExportKind.INSTRUMENT,
+ filepath=path,
+ tracker_format=TrackerFormat.BITPHASE,
+ truncation=None,
)
class TestExportError:
def test_stores_kind_and_exception(self) -> None:
exception = OSError("disk full")
- error = ExportError(kind=ExportKind.INSTRUMENT, tracker_format=TrackerFormat.FAMITRACKER, exception=exception)
+ error = ExportError(
+ kind=ExportKind.INSTRUMENT,
+ tracker_format=TrackerFormat.FAMITRACKER,
+ exception=exception,
+ )
assert error.kind == ExportKind.INSTRUMENT
assert error.tracker_format == TrackerFormat.FAMITRACKER
assert error.exception is exception
def test_frozen(self) -> None:
- error = ExportError(kind=ExportKind.WAV, tracker_format=None, exception=OSError())
+ error = ExportError(
+ kind=ExportKind.WAV,
+ tracker_format=None,
+ exception=OSError(),
+ )
with pytest.raises(FrozenInstanceError):
error.kind = ExportKind.SAMPLE # type: ignore[misc]
def test_eq_false_same_exception_instances_differ(self) -> None:
exception = OSError("same")
- error_a = ExportError(kind=ExportKind.WAV, tracker_format=None, exception=exception)
- error_b = ExportError(kind=ExportKind.WAV, tracker_format=None, exception=exception)
+ error_a = ExportError(
+ kind=ExportKind.WAV,
+ tracker_format=None,
+ exception=exception,
+ )
+ error_b = ExportError(
+ kind=ExportKind.WAV,
+ tracker_format=None,
+ exception=exception,
+ )
assert error_a != error_b
def test_same_instance_equals_itself(self) -> None:
- error = ExportError(kind=ExportKind.WAV, tracker_format=None, exception=OSError())
+ error = ExportError(
+ kind=ExportKind.WAV,
+ tracker_format=None,
+ exception=OSError(),
+ )
assert error == error # noqa: PLR0124
diff --git a/tests/unit/sampletones_application/services/export/test_service.py b/tests/unit/sampletones_application/services/export/test_service.py
index ebde20c2..664aaba7 100644
--- a/tests/unit/sampletones_application/services/export/test_service.py
+++ b/tests/unit/sampletones_application/services/export/test_service.py
@@ -52,19 +52,37 @@ def supported_scopes(self) -> frozenset:
def extension(self, scope: ExportScope) -> str:
return ".fti"
- def write_instrument(self, destination: Path, request: InstrumentExport) -> ExportArtifact:
+ def write_instrument(
+ self,
+ destination: Path,
+ request: InstrumentExport,
+ ) -> ExportArtifact:
return self._write("instrument", destination, request)
- def write_sample(self, destination: Path, request: SampleExport) -> ExportArtifact:
+ def write_sample(
+ self,
+ destination: Path,
+ request: SampleExport,
+ ) -> ExportArtifact:
return self._write("sample", destination, request)
- def write_project(self, destination: Path, request: ProjectExport) -> ExportArtifact:
+ def write_project(
+ self,
+ destination: Path,
+ request: ProjectExport,
+ ) -> ExportArtifact:
return self._write("project", destination, request)
- def _write(self, scope: str, destination: Path, request: Any) -> ExportArtifact:
+ def _write(
+ self,
+ scope: str,
+ destination: Path,
+ request: Any,
+ ) -> ExportArtifact:
self.calls.append((scope, destination, request))
if self.exception is not None:
raise self.exception
+
return ExportArtifact(paths=(destination,), truncation=self.truncation)
@@ -119,7 +137,11 @@ def test_success_emits_export_success(self, service, tmp_path) -> None:
assert result.kind == ExportKind.WAV
assert result.filepath == filepath
- def test_success_calls_write_wave_with_correct_args(self, service, tmp_path) -> None:
+ def test_success_calls_write_wave_with_correct_args(
+ self,
+ service,
+ tmp_path,
+ ) -> None:
export_service, _ = service
filepath = tmp_path / "track.wav"
audio = np.zeros(100)
@@ -134,7 +156,10 @@ def test_error_emits_export_error(self, service, tmp_path) -> None:
filepath = tmp_path / "track.wav"
exception = OSError("disk full")
- with patch("sampletones_application.services.export.service.write_wave", side_effect=exception):
+ with patch(
+ "sampletones_application.services.export.service.write_wave",
+ side_effect=exception,
+ ):
export_service.export_wav(filepath, 44100, np.zeros(100))
assert len(results) == 1
@@ -160,7 +185,11 @@ def test_success_emits_export_success(self, service, tmp_path) -> None:
export_service, results = service
filepath = tmp_path / "instrument.fti"
- export_service.export_instrument(filepath, StubBackend(), build_instrument())
+ export_service.export_instrument(
+ filepath,
+ StubBackend(),
+ build_instrument(),
+ )
assert len(results) == 1
result = results[0]
@@ -168,7 +197,11 @@ def test_success_emits_export_success(self, service, tmp_path) -> None:
assert result.kind == ExportKind.INSTRUMENT
assert result.filepath == filepath
- def test_the_backend_receives_the_destination_and_the_request(self, service, tmp_path) -> None:
+ def test_the_backend_receives_the_destination_and_the_request(
+ self,
+ service,
+ tmp_path,
+ ) -> None:
export_service, _ = service
filepath = tmp_path / "instrument.fti"
backend = StubBackend()
@@ -207,7 +240,11 @@ def test_error_does_not_emit_success(self, service, tmp_path) -> None:
class TestExportSample:
- def test_success_emits_export_success_with_the_destination(self, service, tmp_path) -> None:
+ def test_success_emits_export_success_with_the_destination(
+ self,
+ service,
+ tmp_path,
+ ) -> None:
export_service, results = service
export_service.export_sample(tmp_path, StubBackend(), build_sample())
@@ -218,7 +255,11 @@ def test_success_emits_export_success_with_the_destination(self, service, tmp_pa
assert result.kind == ExportKind.SAMPLE
assert result.filepath == tmp_path
- def test_the_backend_receives_every_slice_in_one_call(self, service, tmp_path) -> None:
+ def test_the_backend_receives_every_slice_in_one_call(
+ self,
+ service,
+ tmp_path,
+ ) -> None:
export_service, _ = service
backend = StubBackend()
request = build_sample(3)
@@ -231,7 +272,11 @@ def test_error_emits_export_error(self, service, tmp_path) -> None:
export_service, results = service
exception = OSError("no space")
- export_service.export_sample(tmp_path, StubBackend(exception=exception), build_sample())
+ export_service.export_sample(
+ tmp_path,
+ StubBackend(exception=exception),
+ build_sample(),
+ )
assert len(results) == 1
result = results[0]
@@ -239,7 +284,11 @@ def test_error_emits_export_error(self, service, tmp_path) -> None:
assert result.kind == ExportKind.SAMPLE
assert result.exception is exception
- def test_a_sample_with_no_slices_emits_success(self, service, tmp_path) -> None:
+ def test_a_sample_with_no_slices_emits_success(
+ self,
+ service,
+ tmp_path,
+ ) -> None:
export_service, results = service
export_service.export_sample(tmp_path, StubBackend(), build_sample(0))
@@ -262,7 +311,11 @@ def test_success_emits_export_success(self, service, tmp_path) -> None:
assert result.kind == ExportKind.PROJECT
assert result.filepath == filepath
- def test_the_backend_receives_the_destination_and_the_request(self, service, tmp_path) -> None:
+ def test_the_backend_receives_the_destination_and_the_request(
+ self,
+ service,
+ tmp_path,
+ ) -> None:
export_service, _ = service
filepath = tmp_path / "song.ftm"
backend = StubBackend()
@@ -290,17 +343,33 @@ def test_error_emits_export_error(self, service, tmp_path) -> None:
class TestExportFormatReporting:
- def test_a_tracker_export_names_the_format_it_was_written_in(self, service, tmp_path) -> None:
+ def test_a_tracker_export_names_the_format_it_was_written_in(
+ self,
+ service,
+ tmp_path,
+ ) -> None:
export_service, results = service
- export_service.export_instrument(tmp_path / "inst.fti", StubBackend(), build_instrument())
+ export_service.export_instrument(
+ tmp_path / "inst.fti",
+ StubBackend(),
+ build_instrument(),
+ )
assert results[0].tracker_format == TrackerFormat.FAMITRACKER
- def test_a_failed_tracker_export_names_the_format_it_was_written_in(self, service, tmp_path) -> None:
+ def test_a_failed_tracker_export_names_the_format_it_was_written_in(
+ self,
+ service,
+ tmp_path,
+ ) -> None:
export_service, results = service
- export_service.export_sample(tmp_path, StubBackend(exception=OSError("fail")), build_sample())
+ export_service.export_sample(
+ tmp_path,
+ StubBackend(exception=OSError("fail")),
+ build_sample(),
+ )
assert results[0].tracker_format == TrackerFormat.FAMITRACKER
@@ -308,22 +377,42 @@ def test_a_wav_export_names_no_format(self, service, tmp_path) -> None:
export_service, results = service
with patch("sampletones_application.services.export.service.write_wave"):
- export_service.export_wav(tmp_path / "track.wav", 44100, np.zeros(100))
+ export_service.export_wav(
+ tmp_path / "track.wav",
+ 44100,
+ np.zeros(100),
+ )
assert results[0].tracker_format is None
class TestExportTruncationReporting:
- def test_a_complete_instrument_reports_no_truncation(self, service, tmp_path) -> None:
+ def test_a_complete_instrument_reports_no_truncation(
+ self,
+ service,
+ tmp_path,
+ ) -> None:
export_service, results = service
- export_service.export_instrument(tmp_path / "inst.fti", StubBackend(), build_instrument())
+ export_service.export_instrument(
+ tmp_path / "inst.fti",
+ StubBackend(),
+ build_instrument(),
+ )
assert results[0].truncation is None
- def test_a_shortened_instrument_carries_the_backend_report(self, service, tmp_path) -> None:
+ def test_a_shortened_instrument_carries_the_backend_report(
+ self,
+ service,
+ tmp_path,
+ ) -> None:
export_service, results = service
- truncation = EnvelopeTruncation(frames=252, source_frames=300, instruments=1)
+ truncation = EnvelopeTruncation(
+ frames=252,
+ source_frames=300,
+ instruments=1,
+ )
export_service.export_instrument(
tmp_path / "inst.fti",
@@ -333,35 +422,69 @@ def test_a_shortened_instrument_carries_the_backend_report(self, service, tmp_pa
assert results[0].truncation == truncation
- def test_a_shortened_sample_carries_the_backend_report(self, service, tmp_path) -> None:
+ def test_a_shortened_sample_carries_the_backend_report(
+ self,
+ service,
+ tmp_path,
+ ) -> None:
export_service, results = service
- truncation = EnvelopeTruncation(frames=252, source_frames=410, instruments=2)
+ truncation = EnvelopeTruncation(
+ frames=252,
+ source_frames=410,
+ instruments=2,
+ )
- export_service.export_sample(tmp_path, StubBackend(truncation=truncation), build_sample(3))
+ export_service.export_sample(
+ tmp_path,
+ StubBackend(truncation=truncation),
+ build_sample(3),
+ )
assert results[0].truncation == truncation
- def test_a_wav_export_reports_no_truncation(self, service, tmp_path) -> None:
+ def test_a_wav_export_reports_no_truncation(
+ self,
+ service,
+ tmp_path,
+ ) -> None:
export_service, results = service
with patch("sampletones_application.services.export.service.write_wave"):
- export_service.export_wav(tmp_path / "track.wav", 44100, np.zeros(100))
+ export_service.export_wav(
+ tmp_path / "track.wav",
+ 44100,
+ np.zeros(100),
+ )
assert results[0].truncation is None
class TestExportServiceConcurrency:
- def test_second_export_while_first_running_is_rejected(self, tmp_path) -> None:
+ def test_second_export_while_first_running_is_rejected(
+ self,
+ tmp_path,
+ ) -> None:
export_service = ExportService()
results: List[Any] = []
export_service.subscribe(results.append)
- with patch.object(export_service._executor, "execute", return_value=False):
- export_service.export_wav(tmp_path / "track.wav", 44100, np.zeros(10))
+ with patch.object(
+ export_service._executor,
+ "execute",
+ return_value=False,
+ ):
+ export_service.export_wav(
+ tmp_path / "track.wav",
+ 44100,
+ np.zeros(10),
+ )
assert results == []
- def test_multiple_simultaneous_calls_do_not_stack_up(self, tmp_path) -> None:
+ def test_multiple_simultaneous_calls_do_not_stack_up(
+ self,
+ tmp_path,
+ ) -> None:
export_service = ExportService()
call_count = 0
@@ -371,8 +494,16 @@ def on_result(result: Any) -> None:
export_service.subscribe(on_result)
- with patch.object(export_service._executor, "execute", return_value=False):
+ with patch.object(
+ export_service._executor,
+ "execute",
+ return_value=False,
+ ):
for _ in range(5):
- export_service.export_wav(tmp_path / "track.wav", 44100, np.zeros(10))
+ export_service.export_wav(
+ tmp_path / "track.wav",
+ 44100,
+ np.zeros(10),
+ )
assert call_count == 0
diff --git a/tests/unit/sampletones_application/services/render/__init__.py b/tests/unit/sampletones_application/services/render/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/services/render/conftest.py b/tests/unit/sampletones_application/services/render/conftest.py
new file mode 100644
index 00000000..a67b0d58
--- /dev/null
+++ b/tests/unit/sampletones_application/services/render/conftest.py
@@ -0,0 +1,79 @@
+from pathlib import Path
+from typing import Callable, Final, List, Optional, Tuple
+
+import numpy as np
+import soundfile
+
+from sampletones_core.audio.writers import AudioOutputSpec, WaveOutputSpec
+from sampletones_core.project.song_position import SongPosition
+
+SAMPLE_RATE: Final[int] = 44100
+ROW_SAMPLES: Final[int] = 735
+ROWS: Final[int] = 24
+TOTAL_SAMPLES: Final[int] = ROW_SAMPLES * ROWS
+LEVEL: Final[float] = 0.25
+
+
+def wave_spec(sample_rate: int = SAMPLE_RATE) -> AudioOutputSpec:
+ return WaveOutputSpec(sample_rate=sample_rate)
+
+
+class FakeSynthesizer:
+ """A kernel that renders a fixed number of identical rows, standing in for a song.
+
+ Each row is a constant level, so a normalising pass has a peak to find and a written file
+ can be checked sample by sample without modelling a generator.
+ """
+
+ def __init__(
+ self,
+ *,
+ rows: int = ROWS,
+ row_samples: int = ROW_SAMPLES,
+ level: float = LEVEL,
+ on_row: Optional[Callable[[int], None]] = None,
+ error: Optional[Exception] = None,
+ ) -> None:
+ self._rows = rows
+ self._row_samples = row_samples
+ self._level = level
+ self._on_row = on_row
+ self._error = error
+ self.rendered: int = 0
+ self.resets: int = 0
+ self.positions: List[Tuple[int, int]] = []
+
+ @property
+ def order_position(self) -> int:
+ return self.rendered
+
+ @property
+ def row_index(self) -> int:
+ return 0
+
+ @property
+ def is_finished(self) -> bool:
+ return self.rendered >= self._rows
+
+ def set_position(self, order_position: int, row_index: int) -> None:
+ self.positions.append((order_position, row_index))
+
+ def reset(self) -> None:
+ self.resets += 1
+ self.rendered = 0
+
+ def render_row(self) -> Tuple[np.ndarray, SongPosition]:
+ if self._error is not None and self.rendered == self._rows // 2:
+ raise self._error
+
+ if self._on_row is not None:
+ self._on_row(self.rendered)
+
+ self.rendered += 1
+ row = np.full(self._row_samples, self._level, dtype=np.float32)
+ return row, SongPosition()
+
+
+def read_samples(path: Path) -> np.ndarray:
+ audio, _ = soundfile.read(path, dtype="float32")
+ return np.asarray(audio, dtype=np.float32)
diff --git a/tests/unit/sampletones_application/services/render/test_service.py b/tests/unit/sampletones_application/services/render/test_service.py
new file mode 100644
index 00000000..2a1ce82c
--- /dev/null
+++ b/tests/unit/sampletones_application/services/render/test_service.py
@@ -0,0 +1,270 @@
+from pathlib import Path
+from typing import List
+
+import numpy as np
+import pytest
+
+from sampletones_application.services.render.constants import SCRATCH_SUFFIX
+from sampletones_application.services.render.result import RenderResult, RenderStage
+from sampletones_application.services.render.service import SongRenderService
+from sampletones_application.services.result import (
+ ServiceCancelled,
+ ServiceError,
+ ServiceProgress,
+ ServiceStarted,
+ ServiceSuccess,
+)
+from tests.suite.base import BaseTestSuite
+from tests.unit.sampletones_application.services.render.conftest import (
+ LEVEL,
+ TOTAL_SAMPLES,
+ FakeSynthesizer,
+ read_samples,
+ wave_spec,
+)
+
+
+def _render(
+ destination: Path,
+ synthesizer: FakeSynthesizer,
+ *,
+ normalize: bool = False,
+ total_samples: int = TOTAL_SAMPLES,
+) -> List[RenderResult]:
+ """Runs one render to completion, returning everything it reported."""
+ service = SongRenderService()
+ results: List[RenderResult] = []
+ service.subscribe(results.append)
+ service.start(
+ synthesizer=synthesizer,
+ destination=destination,
+ spec=wave_spec(),
+ normalize=normalize,
+ total_samples=total_samples,
+ )
+ return results
+
+
+def _progress(results: List[RenderResult], stage: RenderStage) -> List[ServiceProgress[RenderStage]]:
+ return [result for result in results if isinstance(result, ServiceProgress) and result.current_item is stage]
+
+
+class TestARenderReachesItsFile(BaseTestSuite):
+ """A render that runs to the end leaves the whole song at the destination."""
+
+ def test_the_success_names_the_destination(self, tmp_path: Path) -> None:
+ destination = tmp_path / "song.wav"
+
+ results = _render(destination, FakeSynthesizer())
+
+ assert results[-1] == ServiceSuccess(value=destination)
+
+ def test_the_file_holds_every_row(self, tmp_path: Path) -> None:
+ destination = tmp_path / "song.wav"
+
+ _render(destination, FakeSynthesizer())
+
+ assert len(read_samples(destination)) == TOTAL_SAMPLES
+
+ def test_the_song_is_rendered_from_its_first_row(self, tmp_path: Path) -> None:
+ """A render describes the document, so where a listener left the playhead does not reach it."""
+ synthesizer = FakeSynthesizer()
+
+ _render(tmp_path / "song.wav", synthesizer)
+
+ assert synthesizer.positions[0] == (0, 0)
+ assert synthesizer.resets == 1
+
+ def test_the_first_report_states_the_total(self, tmp_path: Path) -> None:
+ results = _render(tmp_path / "song.wav", FakeSynthesizer())
+
+ assert results[0] == ServiceStarted(total=TOTAL_SAMPLES)
+
+
+class TestProgressIsReported(BaseTestSuite):
+ """Every pass reports the samples it has covered, against the total the song holds."""
+
+ def test_synthesis_progress_climbs_to_the_total(self, tmp_path: Path) -> None:
+ results = _render(tmp_path / "song.wav", FakeSynthesizer())
+ reports = _progress(results, RenderStage.SYNTHESIS)
+
+ assert [report.completed for report in reports] == sorted(report.completed for report in reports)
+ assert reports[-1].completed == TOTAL_SAMPLES
+
+ def test_every_report_is_measured_against_the_song(self, tmp_path: Path) -> None:
+ results = _render(tmp_path / "song.wav", FakeSynthesizer())
+ reports = _progress(results, RenderStage.SYNTHESIS)
+
+ assert all(report.total == TOTAL_SAMPLES for report in reports)
+
+ def test_a_direct_render_reports_one_pass(self, tmp_path: Path) -> None:
+ results = _render(tmp_path / "song.wav", FakeSynthesizer())
+
+ assert not _progress(results, RenderStage.ENCODING)
+
+ def test_a_normalized_render_reports_both_passes(self, tmp_path: Path) -> None:
+ results = _render(tmp_path / "song.wav", FakeSynthesizer(), normalize=True)
+
+ assert _progress(results, RenderStage.SYNTHESIS)
+ assert _progress(results, RenderStage.ENCODING)
+
+ def test_the_encoding_pass_climbs_to_the_total(self, tmp_path: Path) -> None:
+ results = _render(tmp_path / "song.wav", FakeSynthesizer(), normalize=True)
+ reports = _progress(results, RenderStage.ENCODING)
+
+ assert reports[-1].completed == TOTAL_SAMPLES
+
+
+class TestNormalizing(BaseTestSuite):
+ """Normalising scales the whole render by what its loudest sample turned out to be."""
+
+ def test_the_peak_reaches_full_scale(self, tmp_path: Path) -> None:
+ destination = tmp_path / "song.wav"
+
+ _render(destination, FakeSynthesizer(), normalize=True)
+
+ assert float(np.abs(read_samples(destination)).max()) == pytest.approx(1.0, abs=1e-4)
+
+ def test_a_direct_render_keeps_the_level_it_was_given(self, tmp_path: Path) -> None:
+ destination = tmp_path / "song.wav"
+
+ _render(destination, FakeSynthesizer())
+
+ assert float(np.abs(read_samples(destination)).max()) == pytest.approx(LEVEL, abs=1e-4)
+
+ def test_silence_is_written_as_it_stands(self, tmp_path: Path) -> None:
+ destination = tmp_path / "song.wav"
+
+ _render(destination, FakeSynthesizer(level=0.0), normalize=True)
+
+ assert not float(np.abs(read_samples(destination)).max())
+
+ def test_the_spill_file_is_removed(self, tmp_path: Path) -> None:
+ destination = tmp_path / "song.wav"
+
+ _render(destination, FakeSynthesizer(), normalize=True)
+
+ assert not (tmp_path / f"song.wav{SCRATCH_SUFFIX}").exists()
+
+
+class TestCancelling(BaseTestSuite):
+ """A cancelled render reports itself cancelled and names no file."""
+
+ def _cancelling_synthesizer(self, service: SongRenderService) -> FakeSynthesizer:
+ return FakeSynthesizer(on_row=lambda rendered: service.cancel() if rendered == 4 else None)
+
+ def test_a_cancelled_render_leaves_no_file(self, tmp_path: Path) -> None:
+ destination = tmp_path / "song.wav"
+ service = SongRenderService()
+ service.start(
+ synthesizer=self._cancelling_synthesizer(service),
+ destination=destination,
+ spec=wave_spec(),
+ normalize=False,
+ total_samples=TOTAL_SAMPLES,
+ )
+
+ assert not destination.exists()
+
+ def test_a_cancelled_render_reports_itself_cancelled(self, tmp_path: Path) -> None:
+ service = SongRenderService()
+ results: List[RenderResult] = []
+ service.subscribe(results.append)
+ service.start(
+ synthesizer=self._cancelling_synthesizer(service),
+ destination=tmp_path / "song.wav",
+ spec=wave_spec(),
+ normalize=False,
+ total_samples=TOTAL_SAMPLES,
+ )
+
+ assert results[-1] == ServiceCancelled()
+
+ def test_a_cancelled_normalized_render_leaves_no_spill(self, tmp_path: Path) -> None:
+ service = SongRenderService()
+ service.start(
+ synthesizer=self._cancelling_synthesizer(service),
+ destination=tmp_path / "song.wav",
+ spec=wave_spec(),
+ normalize=True,
+ total_samples=TOTAL_SAMPLES,
+ )
+
+ assert not list(tmp_path.iterdir())
+
+
+class TestFailing(BaseTestSuite):
+ """A render that raises reports the failure and takes its partial file with it."""
+
+ def test_the_failure_is_reported(self, tmp_path: Path) -> None:
+ error = RuntimeError("no sample")
+
+ results = _render(tmp_path / "song.wav", FakeSynthesizer(error=error))
+ reported = results[-1]
+
+ assert isinstance(reported, ServiceError)
+ assert reported.exception is error
+
+ def test_the_partial_file_is_removed(self, tmp_path: Path) -> None:
+ destination = tmp_path / "song.wav"
+
+ _render(destination, FakeSynthesizer(error=RuntimeError("no sample")))
+
+ assert not destination.exists()
+
+ def test_a_failing_render_stops_running(self, tmp_path: Path) -> None:
+ service = SongRenderService()
+ service.start(
+ synthesizer=FakeSynthesizer(error=RuntimeError("no sample")),
+ destination=tmp_path / "song.wav",
+ spec=wave_spec(),
+ normalize=False,
+ total_samples=TOTAL_SAMPLES,
+ )
+
+ assert not service.is_running()
+
+
+class TestOneRenderAtATime(BaseTestSuite):
+ """A render holds the service until it finishes, so a second request is declined."""
+
+ def test_a_request_arriving_mid_render_is_declined(self, tmp_path: Path) -> None:
+ service = SongRenderService()
+ declined: List[bool] = []
+
+ def request_again(rendered: int) -> None:
+ if rendered:
+ return
+
+ declined.append(
+ service.start(
+ synthesizer=FakeSynthesizer(),
+ destination=tmp_path / "second.wav",
+ spec=wave_spec(),
+ normalize=False,
+ total_samples=TOTAL_SAMPLES,
+ )
+ )
+
+ service.start(
+ synthesizer=FakeSynthesizer(on_row=request_again),
+ destination=tmp_path / "song.wav",
+ spec=wave_spec(),
+ normalize=False,
+ total_samples=TOTAL_SAMPLES,
+ )
+
+ assert declined == [False]
+ assert not (tmp_path / "second.wav").exists()
+
+ def test_the_service_is_free_once_a_render_finishes(self, tmp_path: Path) -> None:
+ service = SongRenderService()
+ service.start(
+ synthesizer=FakeSynthesizer(),
+ destination=tmp_path / "song.wav",
+ spec=wave_spec(),
+ normalize=False,
+ total_samples=TOTAL_SAMPLES,
+ )
+
+ assert not service.is_running()
diff --git a/tests/unit/sampletones_application/services/render/test_sink.py b/tests/unit/sampletones_application/services/render/test_sink.py
new file mode 100644
index 00000000..38fe6160
--- /dev/null
+++ b/tests/unit/sampletones_application/services/render/test_sink.py
@@ -0,0 +1,158 @@
+from pathlib import Path
+from typing import List
+
+import numpy as np
+import pytest
+
+from sampletones_application.services.render.constants import SCRATCH_SUFFIX
+from sampletones_application.services.render.scratch import ScratchAudio
+from sampletones_application.services.render.sink import (
+ DirectRenderSink,
+ NormalizingRenderSink,
+ build_render_sink,
+)
+from sampletones_shared.exceptions import AudioWriteError
+from tests.suite.base import BaseTestSuite
+from tests.unit.sampletones_application.services.render.conftest import (
+ read_samples,
+ wave_spec,
+)
+
+KEEP_GOING = True
+
+
+def _rows(count: int, samples: int, level: float) -> List[np.ndarray]:
+ return [np.full(samples, level, dtype=np.float32) for _ in range(count)]
+
+
+class TestTheSinkIsChosenByTheLevelChoice(BaseTestSuite):
+ def test_a_plain_render_writes_straight_out(self, tmp_path: Path) -> None:
+ sink = build_render_sink(tmp_path / "song.wav", wave_spec(), normalize=False)
+
+ assert isinstance(sink, DirectRenderSink)
+
+ def test_a_normalized_render_spills_first(self, tmp_path: Path) -> None:
+ sink = build_render_sink(tmp_path / "song.wav", wave_spec(), normalize=True)
+
+ assert isinstance(sink, NormalizingRenderSink)
+
+
+class TestTheSinkOwnsItsFile(BaseTestSuite):
+ def test_writing_outside_the_block_is_refused(self, tmp_path: Path) -> None:
+ sink = DirectRenderSink(tmp_path / "song.wav", wave_spec())
+
+ with pytest.raises(AudioWriteError, match="write within the sink's context"):
+ sink.write(np.zeros(4, dtype=np.float32))
+
+ def test_spilling_outside_the_block_is_refused(self, tmp_path: Path) -> None:
+ sink = NormalizingRenderSink(tmp_path / "song.wav", wave_spec())
+
+ with pytest.raises(AudioWriteError, match="write between start and seal"):
+ sink.write(np.zeros(4, dtype=np.float32))
+
+ def test_discarding_removes_the_destination(self, tmp_path: Path) -> None:
+ destination = tmp_path / "song.wav"
+ sink = DirectRenderSink(destination, wave_spec())
+ with sink:
+ sink.write(np.zeros(64, dtype=np.float32))
+
+ sink.discard()
+
+ assert not destination.exists()
+
+ def test_discarding_a_render_that_never_ran_is_harmless(self, tmp_path: Path) -> None:
+ sink = DirectRenderSink(tmp_path / "song.wav", wave_spec())
+
+ sink.discard()
+
+ assert not list(tmp_path.iterdir())
+
+
+class TestNormalizingSink(BaseTestSuite):
+ def _write(self, sink: NormalizingRenderSink, rows: List[np.ndarray]) -> bool:
+ with sink:
+ for row in rows:
+ sink.write(row)
+
+ return sink.finish(lambda _encoded: KEEP_GOING)
+
+ def test_the_loudest_row_sets_the_scale_for_every_row(self, tmp_path: Path) -> None:
+ destination = tmp_path / "song.wav"
+ sink = NormalizingRenderSink(destination, wave_spec())
+
+ self._write(sink, [*_rows(1, 32, 0.1), *_rows(1, 32, 0.5)])
+ written = read_samples(destination)
+
+ assert float(written[:32].max()) == pytest.approx(0.2, abs=1e-4)
+ assert float(written[32:].max()) == pytest.approx(1.0, abs=1e-4)
+
+ def test_a_pass_stopped_partway_reports_the_file_unfinished(self, tmp_path: Path) -> None:
+ sink = NormalizingRenderSink(tmp_path / "song.wav", wave_spec())
+
+ with sink:
+ sink.write(np.full(4, 0.5, dtype=np.float32))
+ completed = sink.finish(lambda _encoded: not KEEP_GOING)
+
+ assert not completed
+
+ def test_the_spill_stands_beside_the_destination(self, tmp_path: Path) -> None:
+ sink = NormalizingRenderSink(tmp_path / "song.wav", wave_spec())
+
+ with sink:
+ sink.write(np.full(4, 0.5, dtype=np.float32))
+
+ assert (tmp_path / f"song.wav{SCRATCH_SUFFIX}").exists()
+
+
+class TestScratchAudio(BaseTestSuite):
+ """The spill file holds what it was given, and reports what it holds."""
+
+ def test_the_samples_read_back_in_the_order_they_were_written(self, tmp_path: Path) -> None:
+ scratch = ScratchAudio(tmp_path / "spill")
+ written = np.arange(10, dtype=np.float32)
+
+ scratch.start()
+ scratch.write(written[:4])
+ scratch.write(written[4:])
+ scratch.seal()
+
+ assert np.array_equal(np.concatenate(list(scratch.blocks(3))), written)
+
+ def test_the_blocks_are_bounded_by_the_size_asked_for(self, tmp_path: Path) -> None:
+ scratch = ScratchAudio(tmp_path / "spill")
+
+ scratch.start()
+ scratch.write(np.zeros(10, dtype=np.float32))
+ scratch.seal()
+
+ assert [len(block) for block in scratch.blocks(4)] == [4, 4, 2]
+
+ def test_the_peak_spans_every_chunk(self, tmp_path: Path) -> None:
+ scratch = ScratchAudio(tmp_path / "spill")
+
+ scratch.start()
+ scratch.write(np.full(4, 0.2, dtype=np.float32))
+ scratch.write(np.full(4, -0.7, dtype=np.float32))
+ scratch.write(np.full(4, 0.3, dtype=np.float32))
+ scratch.seal()
+
+ assert scratch.peak == pytest.approx(0.7)
+ assert scratch.samples == 12
+
+ def test_sealing_twice_is_harmless(self, tmp_path: Path) -> None:
+ scratch = ScratchAudio(tmp_path / "spill")
+
+ scratch.start()
+ scratch.seal()
+ scratch.seal()
+
+ assert not scratch.samples
+
+ def test_removing_clears_the_spill(self, tmp_path: Path) -> None:
+ scratch = ScratchAudio(tmp_path / "spill")
+
+ scratch.start()
+ scratch.seal()
+ scratch.remove()
+
+ assert not scratch.path.exists()
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 e01cfcbc..40db1bcb 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
@@ -1,14 +1,28 @@
-from unittest.mock import MagicMock
+import threading
+from typing import Callable, Final, List, Optional, Tuple
+from unittest.mock import MagicMock, patch
import numpy as np
-from sampletones_application.services.song_player.player import SongPlayerService, _RenderedRow
+from sampletones_application.services.song_player.player import (
+ SongPlayerService,
+ _RenderedRow,
+)
from sampletones_application.services.song_player.result import (
+ SongPlaybackError,
SongPlaybackStopped,
+ SongPlayerResult,
SongPositionUpdate,
)
from sampletones_core.project.song_position import SongPosition
+SAMPLE_RATE: Final[int] = 44100
+WRITE_BLOCK: Final[int] = 64
+WAIT_TIMEOUT: Final[float] = 5.0
+SHORT_JOIN_TIMEOUT: Final[float] = 0.05
+WRITE_RELEASE_DELAY: Final[float] = 0.05
+JOIN_TIMEOUT_TARGET: Final[str] = "sampletones_application.services.song_player.player.STOP_JOIN_TIMEOUT"
+
def _make_service(
*,
@@ -27,6 +41,110 @@ def _make_service(
)
+class _FakeStream:
+ """A stand-in for the device stream that records the frame count of every block handed to it."""
+
+ def __init__(
+ self,
+ *,
+ gate: Optional[threading.Event] = None,
+ error: Optional[Exception] = None,
+ after_write: Optional[Callable[[], None]] = None,
+ ) -> None:
+ self._gate = gate
+ self._error = error
+ self._after_write = after_write
+ self.writes: List[int] = []
+ self.entered_write = threading.Event()
+ self.stopped = threading.Event()
+ self.closed = threading.Event()
+
+ def write(self, data: bytes) -> None:
+ self.writes.append(len(data) // np.dtype(np.float32).itemsize)
+ self.entered_write.set()
+ if self._gate is not None:
+ self._gate.wait(timeout=WAIT_TIMEOUT)
+
+ if self._after_write is not None:
+ self._after_write()
+
+ if self._error is not None:
+ raise self._error
+
+ def stop_stream(self) -> None:
+ self.stopped.set()
+
+ def close(self) -> None:
+ self.closed.set()
+
+
+class _FakeSynthesizer:
+ """Renders a fixed number of equal-length rows and then reports itself finished."""
+
+ def __init__(self, *, rows: int, frames: int) -> None:
+ self._rows = rows
+ self._frames = frames
+ self._rendered = 0
+ self.order_position = 0
+ self.row_index = 0
+
+ @property
+ def is_finished(self) -> bool:
+ return self._rendered >= self._rows
+
+ def set_position(self, order_position: int, row_index: int) -> None:
+ self.order_position = order_position
+ self.row_index = row_index
+
+ def reset(self) -> None:
+ self._rendered = 0
+
+ def render_row(self) -> Tuple[np.ndarray, SongPosition]:
+ position = SongPosition(order_position=0, row_index=self._rendered)
+ self._rendered += 1
+ return np.ones(self._frames, dtype=np.float32), position
+
+
+def _close_stream(stream: _FakeStream) -> None:
+ stream.stop_stream()
+ stream.close()
+
+
+def _make_device_manager(stream: Optional[_FakeStream] = None) -> MagicMock:
+ """A device manager that winds a handed-back stream down as the real one does."""
+ audio_device_manager = MagicMock()
+ audio_device_manager.sample_rate = SAMPLE_RATE
+ audio_device_manager.buffer_size = WRITE_BLOCK
+ audio_device_manager.open_output_stream.return_value = stream
+ audio_device_manager.close_output_stream.side_effect = _close_stream
+ return audio_device_manager
+
+
+def _make_streaming_service(
+ audio_device_manager: MagicMock,
+ *,
+ rows: int = 1,
+ frames: int = 4 * WRITE_BLOCK,
+) -> SongPlayerService:
+ return SongPlayerService(
+ audio_device_manager,
+ _FakeSynthesizer(rows=rows, frames=frames),
+ should_loop=lambda: False,
+ master_gain=lambda: 1.0,
+ )
+
+
+def _wedged_thread(gate: threading.Event) -> threading.Thread:
+ """A started worker that stays alive until ``gate`` is set."""
+ thread = threading.Thread(
+ target=lambda: gate.wait(timeout=WAIT_TIMEOUT),
+ daemon=True,
+ name="WedgedWorker",
+ )
+ thread.start()
+ return thread
+
+
class TestSongPlayerServiceInitialState:
def test_alive_is_false_initially(self) -> None:
service = _make_service()
@@ -331,3 +449,140 @@ def test_dequeue_returns_no_row_after_stop(self) -> None:
service._stop_event.set()
assert service._dequeue() == (False, None)
+
+
+class TestSongPlayerServiceBoundedWrites:
+ def test_start_takes_the_write_block_from_the_device(self) -> None:
+ service = _make_streaming_service(_make_device_manager(_FakeStream()))
+
+ service.start()
+ service.stop()
+
+ assert service._write_block_frames == WRITE_BLOCK
+
+ def test_row_reaches_the_device_in_buffer_sized_blocks(self) -> None:
+ service = _make_service()
+ service.subscribe(lambda result: None)
+ service._write_block_frames = WRITE_BLOCK
+
+ stream = _FakeStream()
+ row = _RenderedRow(chunk=np.ones(3 * WRITE_BLOCK + 8, dtype=np.float32), position=SongPosition())
+ service._play_row(stream, row)
+
+ assert stream.writes == [WRITE_BLOCK, WRITE_BLOCK, WRITE_BLOCK, 8]
+
+ def test_stop_mid_row_leaves_the_remaining_blocks_unwritten(self) -> None:
+ service = _make_service()
+ received: List[SongPlayerResult] = []
+ service.subscribe(received.append)
+ service._write_block_frames = WRITE_BLOCK
+
+ stream = _FakeStream(after_write=service._stop_event.set)
+ row = _RenderedRow(chunk=np.ones(4 * WRITE_BLOCK, dtype=np.float32), position=SongPosition())
+ service._play_row(stream, row)
+
+ assert stream.writes == [WRITE_BLOCK]
+ assert received == []
+
+
+class TestSongPlayerServiceStopQuiescence:
+ def test_stop_returns_after_the_writer_closed_its_stream(self) -> None:
+ gate = threading.Event()
+ stream = _FakeStream(gate=gate)
+ service = _make_streaming_service(_make_device_manager(stream), rows=8)
+ service.subscribe(lambda result: None)
+
+ service.start()
+ assert stream.entered_write.wait(timeout=WAIT_TIMEOUT)
+
+ releaser = threading.Timer(WRITE_RELEASE_DELAY, gate.set)
+ releaser.start()
+ try:
+ service.stop()
+ finally:
+ releaser.cancel()
+ gate.set()
+
+ assert service.alive is False
+ assert stream.stopped.is_set()
+ assert stream.closed.is_set()
+
+ def test_stop_keeps_a_worker_that_outlives_the_deadline(self) -> None:
+ gate = threading.Event()
+ service = _make_service()
+ service._write_thread = _wedged_thread(gate)
+
+ try:
+ with patch(JOIN_TIMEOUT_TARGET, SHORT_JOIN_TIMEOUT):
+ service.stop()
+
+ assert service._write_thread is not None
+ assert service.alive is True
+ finally:
+ gate.set()
+
+ def test_start_is_refused_while_a_worker_still_holds_the_output(self) -> None:
+ gate = threading.Event()
+ audio_device_manager = _make_device_manager(_FakeStream())
+ service = _make_streaming_service(audio_device_manager)
+ service._write_thread = _wedged_thread(gate)
+
+ try:
+ with patch(JOIN_TIMEOUT_TARGET, SHORT_JOIN_TIMEOUT):
+ service.start()
+
+ audio_device_manager.open_output_stream.assert_not_called()
+ finally:
+ gate.set()
+
+
+class TestSongPlayerServiceStreamOwnership:
+ """The device hands out a stream against a release, and gets it back when the writer finishes."""
+
+ def test_the_stream_is_opened_against_a_release_that_stops_playback(self) -> None:
+ audio_device_manager = _make_device_manager(_FakeStream())
+ service = _make_streaming_service(audio_device_manager)
+ service.subscribe(lambda result: None)
+
+ service.start()
+ service.stop()
+
+ _, keywords = audio_device_manager.open_output_stream.call_args
+ assert keywords["release"] == service.stop
+
+ def test_the_writer_hands_the_stream_back(self) -> None:
+ stream = _FakeStream()
+ audio_device_manager = _make_device_manager(stream)
+ service = _make_streaming_service(audio_device_manager)
+ service.subscribe(lambda result: None)
+
+ service.start()
+ service.stop()
+
+ audio_device_manager.close_output_stream.assert_called_once_with(stream)
+
+
+class TestSongPlayerServiceWriteFailure:
+ def test_a_failing_write_reports_a_playback_error(self) -> None:
+ error = OSError("device disappeared")
+ service = _make_streaming_service(_make_device_manager(_FakeStream(error=error)))
+ received: List[SongPlayerResult] = []
+ service.subscribe(received.append)
+ service._resume_event.set()
+ service._buffer.append(_RenderedRow(chunk=np.ones(WRITE_BLOCK, dtype=np.float32), position=SongPosition()))
+
+ service._write_loop()
+
+ assert received == [SongPlaybackError(error=error)]
+
+ def test_a_failing_write_still_closes_the_stream(self) -> None:
+ stream = _FakeStream(error=OSError("device disappeared"))
+ service = _make_streaming_service(_make_device_manager(stream))
+ service.subscribe(lambda result: None)
+ service._resume_event.set()
+ service._buffer.append(_RenderedRow(chunk=np.ones(WRITE_BLOCK, dtype=np.float32), position=SongPosition()))
+
+ service._write_loop()
+
+ assert stream.stopped.is_set()
+ assert stream.closed.is_set()
diff --git a/tests/unit/sampletones_application/services/test_conversion.py b/tests/unit/sampletones_application/services/test_conversion.py
index d9f0ee02..d7580c14 100644
--- a/tests/unit/sampletones_application/services/test_conversion.py
+++ b/tests/unit/sampletones_application/services/test_conversion.py
@@ -1,6 +1,6 @@
from pathlib import Path
from time import sleep
-from typing import Any, List
+from typing import Any, Callable, Dict, Iterator, List, Tuple, TypeAlias
from unittest.mock import MagicMock, patch
import pytest
@@ -15,18 +15,20 @@
ServiceSuccess,
)
from sampletones_core.parallelization import TaskProgress, TaskStatus
-from sampletones_shared.types.data import SerializedData
+
+MockConverterClass: TypeAlias = Tuple[MagicMock, MagicMock, Dict[str, Callable[..., Any]]]
+Service: TypeAlias = Tuple[ConversionService, MagicMock, Dict[str, Callable[..., Any]], List[Any]]
@pytest.fixture
-def mock_converter_class():
+def mock_converter_class() -> Iterator[MockConverterClass]:
with patch("sampletones_application.services.conversion.ReconstructionConverter") as cls:
instance = MagicMock()
instance.is_running.return_value = False
instance.status = TaskStatus.COMPLETED
instance.total_tasks = 5
- captured: SerializedData = {}
+ captured: Dict[str, Callable[..., Any]] = {}
instance.set_callbacks.side_effect = lambda **kwargs: captured.update(kwargs)
cls.return_value = instance
@@ -34,8 +36,10 @@ def mock_converter_class():
@pytest.fixture
-def service(mock_converter_class):
- cls, instance, callbacks = mock_converter_class
+def service(
+ mock_converter_class: MockConverterClass,
+) -> Service:
+ _, instance, callbacks = mock_converter_class
conversion_service = ConversionService()
results: List[Any] = []
conversion_service.subscribe(results.append)
@@ -48,7 +52,10 @@ def service(mock_converter_class):
class TestConversionServiceStart:
- def test_start_creates_and_starts_converter(self, mock_converter_class) -> None:
+ def test_start_creates_and_starts_converter(
+ self,
+ mock_converter_class: MockConverterClass,
+ ) -> None:
cls, instance, _ = mock_converter_class
conversion_service = ConversionService()
conversion_service.start(MagicMock(), MagicMock())
@@ -56,14 +63,25 @@ def test_start_creates_and_starts_converter(self, mock_converter_class) -> None:
cls.assert_called_once()
instance.start.assert_called_once()
- def test_start_wires_five_lifecycle_callbacks(self, mock_converter_class) -> None:
+ def test_start_wires_five_lifecycle_callbacks(
+ self,
+ mock_converter_class: MockConverterClass,
+ ) -> None:
_, _, callbacks = mock_converter_class
conversion_service = ConversionService()
conversion_service.start(MagicMock(), MagicMock())
- assert set(callbacks.keys()) == {"on_start", "on_progress", "on_completed", "on_error", "on_cancelled"}
-
- def test_start_while_running_does_not_create_second_converter(self, mock_converter_class) -> None:
+ assert set(callbacks.keys()) == {
+ "on_start",
+ "on_progress",
+ "on_completed",
+ "on_error",
+ "on_cancelled",
+ }
+
+ def test_start_while_running_does_not_create_second_converter(
+ self, mock_converter_class: MockConverterClass
+ ) -> None:
cls, instance, _ = mock_converter_class
instance.is_running.return_value = True
instance.status = TaskStatus.RUNNING
@@ -76,7 +94,10 @@ def test_start_while_running_does_not_create_second_converter(self, mock_convert
class TestConversionServiceEmissions:
- def test_on_start_emits_service_started_with_total(self, service) -> None:
+ def test_on_start_emits_service_started_with_total(
+ self,
+ service: Service,
+ ) -> None:
_, _, callbacks, results = service
callbacks["on_start"]()
@@ -85,7 +106,10 @@ def test_on_start_emits_service_started_with_total(self, service) -> None:
assert isinstance(result, ServiceStarted)
assert result.total == 5
- def test_on_progress_running_emits_service_progress(self, service) -> None:
+ def test_on_progress_running_emits_service_progress(
+ self,
+ service: Service,
+ ) -> None:
_, _, callbacks, results = service
callbacks["on_start"]()
results.clear()
@@ -100,7 +124,10 @@ def test_on_progress_running_emits_service_progress(self, service) -> None:
assert result.total == 5
assert result.current_item == Path("/some/file.wav")
- def test_on_progress_cancelling_emits_service_progress(self, service) -> None:
+ def test_on_progress_cancelling_emits_service_progress(
+ self,
+ service: Service,
+ ) -> None:
_, _, callbacks, results = service
callbacks["on_start"]()
results.clear()
@@ -111,7 +138,10 @@ def test_on_progress_cancelling_emits_service_progress(self, service) -> None:
assert len(results) == 1
assert isinstance(results[0], ServiceProgress)
- def test_on_progress_pending_does_not_emit(self, service) -> None:
+ def test_on_progress_pending_does_not_emit(
+ self,
+ service: Service,
+ ) -> None:
_, _, callbacks, results = service
callbacks["on_start"]()
results.clear()
@@ -121,7 +151,10 @@ def test_on_progress_pending_does_not_emit(self, service) -> None:
assert results == []
- def test_on_progress_completed_does_not_emit(self, service) -> None:
+ def test_on_progress_completed_does_not_emit(
+ self,
+ service: Service,
+ ) -> None:
_, _, callbacks, results = service
callbacks["on_start"]()
results.clear()
@@ -131,7 +164,10 @@ def test_on_progress_completed_does_not_emit(self, service) -> None:
assert results == []
- def test_on_progress_current_item_none_when_absent(self, service) -> None:
+ def test_on_progress_current_item_none_when_absent(
+ self,
+ service: Service,
+ ) -> None:
_, _, callbacks, results = service
callbacks["on_start"]()
results.clear()
@@ -141,7 +177,10 @@ def test_on_progress_current_item_none_when_absent(self, service) -> None:
assert results[0].current_item is None
- def test_on_completed_emits_service_success(self, service) -> None:
+ def test_on_completed_emits_service_success(
+ self,
+ service: Service,
+ ) -> None:
_, _, callbacks, results = service
output_path = Path("/output/result.nes")
callbacks["on_completed"](output_path)
@@ -150,7 +189,10 @@ def test_on_completed_emits_service_success(self, service) -> None:
assert isinstance(results[0], ServiceSuccess)
assert results[0].value == output_path
- def test_on_error_emits_service_error(self, service) -> None:
+ def test_on_error_emits_service_error(
+ self,
+ service: Service,
+ ) -> None:
_, _, callbacks, results = service
exception = RuntimeError("converter failed")
callbacks["on_error"](exception)
@@ -160,17 +202,26 @@ def test_on_error_emits_service_error(self, service) -> None:
assert isinstance(result, ServiceError)
assert result.exception is exception
- def test_on_cancelled_emits_service_cancelled(self, service) -> None:
+ def test_on_cancelled_emits_service_cancelled(
+ self,
+ service: Service,
+ ) -> None:
_, _, callbacks, results = service
callbacks["on_cancelled"]()
assert len(results) == 1
assert isinstance(results[0], ServiceCancelled)
- def test_forward_library_progress_emits_service_intermediate(self, service) -> None:
+ def test_forward_library_progress_emits_service_intermediate(
+ self,
+ service: Service,
+ ) -> None:
conversion_service, _, _, results = service
task_progress = TaskProgress(total=10, completed=4)
- conversion_service.forward_library_progress(TaskStatus.RUNNING, task_progress)
+ conversion_service.forward_library_progress(
+ TaskStatus.RUNNING,
+ task_progress,
+ )
assert len(results) == 1
result = results[0]
@@ -179,16 +230,25 @@ def test_forward_library_progress_emits_service_intermediate(self, service) -> N
class TestConversionServiceETA:
- def test_eta_estimator_none_before_on_start_fires(self, service) -> None:
+ def test_eta_estimator_none_before_on_start_fires(
+ self,
+ service: Service,
+ ) -> None:
conversion_service, _, _, _ = service
assert conversion_service._eta_estimator is None
- def test_eta_estimator_created_after_on_start(self, service) -> None:
+ def test_eta_estimator_created_after_on_start(
+ self,
+ service: Service,
+ ) -> None:
conversion_service, _, callbacks, _ = service
callbacks["on_start"]()
assert conversion_service._eta_estimator is not None
- def test_eta_seconds_none_with_single_sample(self, service) -> None:
+ def test_eta_seconds_none_with_single_sample(
+ self,
+ service: Service,
+ ) -> None:
_, _, callbacks, results = service
callbacks["on_start"]()
results.clear()
@@ -198,7 +258,10 @@ def test_eta_seconds_none_with_single_sample(self, service) -> None:
assert results[0].eta_seconds is None
- def test_eta_seconds_populated_after_two_samples(self, service) -> None:
+ def test_eta_seconds_populated_after_two_samples(
+ self,
+ service: Service,
+ ) -> None:
_, _, callbacks, results = service
callbacks["on_start"]()
results.clear()
@@ -222,7 +285,10 @@ def test_eta_seconds_populated_after_two_samples(self, service) -> None:
assert results[-1].eta_seconds is not None
assert results[-1].eta_seconds > 0
- def test_eta_estimator_reset_on_cleanup(self, service) -> None:
+ def test_eta_estimator_reset_on_cleanup(
+ self,
+ service: Service,
+ ) -> None:
conversion_service, _, callbacks, _ = service
callbacks["on_start"]()
assert conversion_service._eta_estimator is not None
@@ -233,46 +299,61 @@ def test_eta_estimator_reset_on_cleanup(self, service) -> None:
class TestConversionServiceLifecycle:
- def test_cleanup_resets_converter(self, service) -> None:
+ def test_cleanup_resets_converter(self, service: Service) -> None:
conversion_service, _, _, _ = service
conversion_service.cleanup()
assert conversion_service._converter is None
- def test_cleanup_disposes_running_converter(self, service) -> None:
+ def test_cleanup_disposes_running_converter(
+ self,
+ service: Service,
+ ) -> None:
conversion_service, converter, _, _ = service
converter.is_running.return_value = True
conversion_service.cleanup()
converter.cleanup.assert_called_once()
assert conversion_service._converter is None
- def test_shutdown_tears_down_converter_synchronously(self, service) -> None:
+ def test_shutdown_tears_down_converter_synchronously(
+ self,
+ service: Service,
+ ) -> None:
conversion_service, converter, _, _ = service
conversion_service.shutdown()
converter.shutdown.assert_called_once()
assert conversion_service._converter is None
- def test_is_running_true_when_converter_running(self, service) -> None:
+ def test_is_running_true_when_converter_running(
+ self,
+ service: Service,
+ ) -> None:
conversion_service, converter, _, _ = service
converter.is_running.return_value = True
assert conversion_service.is_running()
- def test_is_running_true_when_converter_pending(self, service) -> None:
+ def test_is_running_true_when_converter_pending(
+ self,
+ service: Service,
+ ) -> None:
conversion_service, converter, _, _ = service
converter.is_running.return_value = False
converter.status = TaskStatus.PENDING
assert conversion_service.is_running()
- def test_is_running_false_when_converter_none(self) -> None:
+ def test_is_running_false_when_converter_none(self: Any) -> None:
conversion_service = ConversionService()
assert not conversion_service.is_running()
- def test_cancel_delegates_to_converter(self, service) -> None:
+ def test_cancel_delegates_to_converter(self, service: Service) -> None:
conversion_service, converter, _, _ = service
converter.is_running.return_value = True
conversion_service.cancel()
converter.cancel.assert_called_once()
- def test_cancel_when_not_running_does_not_call_converter_cancel(self, service) -> None:
+ def test_cancel_when_not_running_does_not_call_converter_cancel(
+ self,
+ service: Service,
+ ) -> None:
conversion_service, converter, _, _ = service
converter.is_running.return_value = False
conversion_service.cancel()
diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py
index 57be1d74..ede8ea03 100644
--- a/tests/unit/sampletones_application/services/test_regeneration.py
+++ b/tests/unit/sampletones_application/services/test_regeneration.py
@@ -1,29 +1,45 @@
import threading
from types import SimpleNamespace
-from typing import Any, Dict, Final, List
+from typing import Any, Callable, Dict, Final, Iterator, List, Tuple, TypeAlias, cast
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from sampletones_application.services.regeneration import RegenerationService
-from sampletones_application.services.result import ServiceCancelled, ServiceError, ServiceSuccess
+from sampletones_application.services.result import (
+ ServiceCancelled,
+ ServiceError,
+ ServiceSuccess,
+)
from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.exporters import Features
+from sampletones_core.reconstructions import Reconstruction
+from tests.conftest import ReconstructionFactory
REFERENCE_PITCH: Final[int] = 60
+MockReconstruction: TypeAlias = MagicMock
+SynthesisMocks: TypeAlias = SimpleNamespace
+ResultCallback: TypeAlias = Callable[[Any], None]
+
class FakeFeatures(Dict[Any, Any]):
"""Stands in for ``Features``: records the edited dimension and carries a reference pitch.
Assigning ``FeatureKey.INITIAL_PITCH`` moves the reference pitch, matching the real model,
- so the pitch stepper's edit is observable through ``initial_pitch``.
+ so the pitch stepper's edit is observable through ``initial_pitch``. The dimensions left to
+ the channel are read the same way the real model reports them: those whose envelope is empty.
"""
def __init__(self, initial_pitch: int) -> None:
super().__init__()
self.initial_pitch = initial_pitch
+ @property
+ def held_features(self) -> Tuple[FeatureKey, ...]:
+ return tuple(key for key, value in self.items() if isinstance(value, np.ndarray) and value.size == 0)
+
def __setitem__(self, feature_key: Any, value: Any) -> None:
if feature_key == FeatureKey.INITIAL_PITCH:
self.initial_pitch = value
@@ -37,7 +53,7 @@ def features() -> FakeFeatures:
@pytest.fixture
-def synthesis_mocks():
+def synthesis_mocks() -> Iterator[SynthesisMocks]:
mock_instruction = MagicMock()
mock_exporter = MagicMock()
mock_generator_class = MagicMock()
@@ -63,19 +79,21 @@ def synthesis_mocks():
@pytest.fixture
-def reconstruction():
+def reconstruction() -> MockReconstruction:
reconstruction = MagicMock()
reconstruction.config = MagicMock()
return reconstruction
class TestRegenerationServiceStart:
- def test_start_when_not_cancelled_returns_true(self, synthesis_mocks, reconstruction) -> None:
+ def test_start_when_not_cancelled_returns_true(
+ self, synthesis_mocks: SynthesisMocks, reconstruction: MockReconstruction
+ ) -> None:
service = RegenerationService()
result = service.start(
reconstruction,
synthesis_mocks.generator_name,
- {},
+ cast(Features, {}),
FeatureKey.VOLUME,
1,
)
@@ -85,7 +103,7 @@ def test_start_when_cancelled_returns_false(self) -> None:
service = RegenerationService()
service.cancel()
- result = service.start(MagicMock(), MagicMock(), {}, MagicMock(), MagicMock())
+ result = service.start(MagicMock(), MagicMock(), cast(Features, {}), MagicMock(), MagicMock())
assert result is False
@@ -95,7 +113,13 @@ def test_start_when_cancelled_does_not_emit(self) -> None:
service.subscribe(results.append)
service.cancel()
- service.start(MagicMock(), MagicMock(), {}, MagicMock(), MagicMock())
+ service.start(
+ MagicMock(),
+ MagicMock(),
+ cast(Features, {}),
+ MagicMock(),
+ MagicMock(),
+ )
assert results == []
@@ -107,7 +131,13 @@ def test_start_reports_a_submit_failure(self) -> None:
"""
service = RegenerationService()
with patch.object(service._executor, "submit", return_value=False):
- result = service.start(MagicMock(), MagicMock(), {}, MagicMock(), MagicMock())
+ result = service.start(
+ MagicMock(),
+ MagicMock(),
+ cast(Features, {}),
+ MagicMock(),
+ MagicMock(),
+ )
assert result is False
@@ -139,12 +169,23 @@ def test_run_when_cancelled_emits_service_cancelled(self) -> None:
service.subscribe(results.append)
service._cancelled = True
- service._run(MagicMock(), MagicMock(), {}, MagicMock(), MagicMock())
+ service._run(
+ MagicMock(),
+ MagicMock(),
+ cast(Features, {}),
+ MagicMock(),
+ MagicMock(),
+ )
assert len(results) == 1
assert isinstance(results[0], ServiceCancelled)
- def test_run_success_emits_service_success(self, synthesis_mocks, reconstruction, features) -> None:
+ def test_run_success_emits_service_success(
+ self,
+ synthesis_mocks: SynthesisMocks,
+ reconstruction: MockReconstruction,
+ features: FakeFeatures,
+ ) -> None:
service = RegenerationService()
results: List[Any] = []
service.subscribe(results.append)
@@ -152,7 +193,7 @@ def test_run_success_emits_service_success(self, synthesis_mocks, reconstruction
service._run(
reconstruction,
synthesis_mocks.generator_name,
- features,
+ cast(Features, features),
FeatureKey.VOLUME,
1,
)
@@ -165,7 +206,12 @@ def test_run_success_emits_service_success(self, synthesis_mocks, reconstruction
assert outcome.generator_name is synthesis_mocks.generator_name
assert outcome.feature_key is FeatureKey.VOLUME
- def test_run_updates_feature_before_synthesis(self, synthesis_mocks, reconstruction, features) -> None:
+ def test_run_updates_feature_before_synthesis(
+ self,
+ synthesis_mocks: SynthesisMocks,
+ reconstruction: MockReconstruction,
+ features: FakeFeatures,
+ ) -> None:
service = RegenerationService()
feature_key = FeatureKey.VOLUME
new_value = 42
@@ -173,20 +219,25 @@ def test_run_updates_feature_before_synthesis(self, synthesis_mocks, reconstruct
service._run(
reconstruction,
synthesis_mocks.generator_name,
- features,
+ cast(Features, features),
feature_key,
new_value,
)
assert features[feature_key] == new_value
- def test_run_updates_reconstruction_copy(self, synthesis_mocks, reconstruction, features) -> None:
+ def test_run_updates_reconstruction_copy(
+ self,
+ synthesis_mocks: SynthesisMocks,
+ reconstruction: MockReconstruction,
+ features: FakeFeatures,
+ ) -> None:
service = RegenerationService()
service._run(
reconstruction,
synthesis_mocks.generator_name,
- features,
+ cast(Features, features),
FeatureKey.VOLUME,
1,
)
@@ -198,7 +249,10 @@ def test_run_updates_reconstruction_copy(self, synthesis_mocks, reconstruction,
assert call_args.args[0] == synthesis_mocks.generator_name
def test_run_carries_the_reference_pitch_through_an_arpeggio_edit(
- self, synthesis_mocks, reconstruction, features
+ self,
+ synthesis_mocks: SynthesisMocks,
+ reconstruction: MockReconstruction,
+ features: FakeFeatures,
) -> None:
"""An arpeggio edit stores the reference pitch the edit was made from.
@@ -210,7 +264,7 @@ def test_run_carries_the_reference_pitch_through_an_arpeggio_edit(
service._run(
reconstruction,
synthesis_mocks.generator_name,
- features,
+ cast(Features, features),
FeatureKey.ARPEGGIO,
np.array([12, 0], dtype=np.int8),
)
@@ -218,7 +272,12 @@ def test_run_carries_the_reference_pitch_through_an_arpeggio_edit(
call_args = reconstruction.model_copy.return_value.update_generator_data.call_args
assert call_args.args[3] == REFERENCE_PITCH
- def test_run_carries_a_moved_reference_pitch(self, synthesis_mocks, reconstruction, features) -> None:
+ def test_run_carries_a_moved_reference_pitch(
+ self,
+ synthesis_mocks: SynthesisMocks,
+ reconstruction: MockReconstruction,
+ features: FakeFeatures,
+ ) -> None:
"""The pitch stepper's edit stores the new reference pitch."""
moved_pitch = REFERENCE_PITCH + 12
service = RegenerationService()
@@ -226,7 +285,7 @@ def test_run_carries_a_moved_reference_pitch(self, synthesis_mocks, reconstructi
service._run(
reconstruction,
synthesis_mocks.generator_name,
- features,
+ cast(Features, features),
FeatureKey.INITIAL_PITCH,
moved_pitch,
)
@@ -234,22 +293,33 @@ def test_run_carries_a_moved_reference_pitch(self, synthesis_mocks, reconstructi
call_args = reconstruction.model_copy.return_value.update_generator_data.call_args
assert call_args.args[3] == moved_pitch
- def test_run_calls_generator_for_each_instruction(self, synthesis_mocks, reconstruction, features) -> None:
+ def test_run_calls_generator_for_each_instruction(
+ self,
+ synthesis_mocks: SynthesisMocks,
+ reconstruction: MockReconstruction,
+ features: FakeFeatures,
+ ) -> None:
extra_instruction = MagicMock()
- synthesis_mocks.exporter.from_features.return_value = [synthesis_mocks.instruction, extra_instruction]
+ synthesis_mocks.exporter.from_features.return_value = [
+ synthesis_mocks.instruction,
+ extra_instruction,
+ ]
service = RegenerationService()
service._run(
reconstruction,
synthesis_mocks.generator_name,
- features,
+ cast(Features, features),
FeatureKey.VOLUME,
1,
)
assert synthesis_mocks.generator.call_count == 2
- def test_run_exception_emits_service_error(self, reconstruction) -> None:
+ def test_run_exception_emits_service_error(
+ self,
+ reconstruction: MockReconstruction,
+ ) -> None:
service = RegenerationService()
results: List[Any] = []
service.subscribe(results.append)
@@ -265,7 +335,7 @@ def test_run_exception_emits_service_error(self, reconstruction) -> None:
service._run(
reconstruction,
GeneratorName.PULSE1,
- {},
+ cast(Features, {}),
FeatureKey.VOLUME,
1,
)
@@ -275,7 +345,10 @@ def test_run_exception_emits_service_error(self, reconstruction) -> None:
assert isinstance(result, ServiceError)
assert result.exception is exception
- def test_run_exception_does_not_update_reconstruction(self, reconstruction) -> None:
+ def test_run_exception_does_not_update_reconstruction(
+ self,
+ reconstruction: MockReconstruction,
+ ) -> None:
service = RegenerationService()
mock_exporter = MagicMock()
mock_exporter.get_generator_type.side_effect = RuntimeError("fail")
@@ -287,7 +360,7 @@ def test_run_exception_does_not_update_reconstruction(self, reconstruction) -> N
service._run(
reconstruction,
GeneratorName.PULSE1,
- {},
+ cast(Features, {}),
FeatureKey.VOLUME,
1,
)
@@ -295,6 +368,83 @@ def test_run_exception_does_not_update_reconstruction(self, reconstruction) -> N
reconstruction.update_generator_data.assert_not_called()
+class TestClearingEveryEnvelope:
+ """An instrument left with no envelope at all describes no frame, so its channel stands by.
+
+ This is the edit the instruments panel offers on the last dimension an instrument writes, and
+ it runs the whole way through the service: the exporter produces no instruction, the render
+ produces no audio, and the reconstruction that comes back holds the channel without playing it.
+ """
+
+ @staticmethod
+ def _regenerated(reconstruction: Reconstruction) -> Reconstruction:
+ """The reconstruction the service returns once every dimension is left to the channel."""
+ features = reconstruction.export()[GeneratorName.PULSE1]
+ features.leave_to_channel([FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE])
+ service = RegenerationService()
+ results: List[Any] = []
+ service.subscribe(results.append)
+
+ service._run(
+ reconstruction,
+ GeneratorName.PULSE1,
+ features,
+ FeatureKey.VOLUME,
+ np.array([], dtype=np.int8),
+ )
+
+ assert isinstance(results[0], ServiceSuccess)
+ regenerated: Reconstruction = results[0].value.reconstruction
+ return regenerated
+
+ def test_a_cleared_instrument_takes_its_channel_out_of_play(
+ self,
+ reconstruction_factory: ReconstructionFactory,
+ ) -> None:
+ reconstruction = reconstruction_factory()
+
+ regenerated = self._regenerated(reconstruction)
+
+ assert regenerated.instructions[GeneratorName.PULSE1] == []
+ assert regenerated.playing_generators == ()
+
+ def test_a_cleared_instrument_sounds_as_an_empty_waveform(
+ self,
+ reconstruction_factory: ReconstructionFactory,
+ ) -> None:
+ reconstruction = reconstruction_factory()
+
+ regenerated = self._regenerated(reconstruction)
+
+ assert regenerated.approximations == {}
+ assert regenerated.approximation.size == 0
+
+ def test_the_cleared_channel_records_every_dimension_as_the_channels(
+ self,
+ reconstruction_factory: ReconstructionFactory,
+ ) -> None:
+ reconstruction = reconstruction_factory()
+
+ regenerated = self._regenerated(reconstruction)
+
+ assert regenerated.held_features[GeneratorName.PULSE1] == (
+ FeatureKey.VOLUME,
+ FeatureKey.ARPEGGIO,
+ FeatureKey.DUTY_CYCLE,
+ )
+ assert not regenerated.export()[GeneratorName.PULSE1].has_frames
+
+ def test_the_reconstruction_the_edit_was_made_from_keeps_playing(
+ self,
+ reconstruction_factory: ReconstructionFactory,
+ ) -> None:
+ reconstruction = reconstruction_factory()
+
+ self._regenerated(reconstruction)
+
+ assert reconstruction.playing_generators == (GeneratorName.PULSE1,)
+
+
class TestRegenerationServiceCancellationConstraints:
"""Tests that document the non-preemptive cancellation behaviour.
@@ -302,7 +452,11 @@ class TestRegenerationServiceCancellationConstraints:
synthesis that is already in progress.
"""
- def test_cancel_while_running_does_not_interrupt_synthesis(self, synthesis_mocks, features) -> None:
+ def test_cancel_while_running_does_not_interrupt_synthesis(
+ self,
+ synthesis_mocks: SynthesisMocks,
+ features: FakeFeatures,
+ ) -> None:
service = RegenerationService()
results: List[Any] = []
done = threading.Event()
@@ -316,7 +470,7 @@ def on_result(result: Any) -> None:
task_started = threading.Event()
task_unblock = threading.Event()
- def blocking_from_features(edited_features):
+ def blocking_from_features(edited_features: Any) -> List[MagicMock]:
task_started.set()
task_unblock.wait(timeout=2.0)
return [synthesis_mocks.instruction]
@@ -329,7 +483,7 @@ def blocking_from_features(edited_features):
target=lambda: service._run(
reconstruction,
synthesis_mocks.generator_name,
- features,
+ cast(Features, features),
FeatureKey.VOLUME,
1,
),
@@ -346,7 +500,11 @@ def blocking_from_features(edited_features):
assert len(results) == 1
assert isinstance(results[0], ServiceSuccess)
- def test_cancel_after_completion_prevents_new_tasks(self, synthesis_mocks, reconstruction) -> None:
+ def test_cancel_after_completion_prevents_new_tasks(
+ self,
+ synthesis_mocks: SynthesisMocks,
+ reconstruction: MockReconstruction,
+ ) -> None:
service = RegenerationService()
results: List[Any] = []
service.subscribe(results.append)
@@ -354,7 +512,7 @@ def test_cancel_after_completion_prevents_new_tasks(self, synthesis_mocks, recon
service.start(
reconstruction,
synthesis_mocks.generator_name,
- {},
+ cast(Features, {}),
FeatureKey.VOLUME,
1,
)
@@ -363,7 +521,7 @@ def test_cancel_after_completion_prevents_new_tasks(self, synthesis_mocks, recon
second_result = service.start(
reconstruction,
synthesis_mocks.generator_name,
- {},
+ cast(Features, {}),
FeatureKey.VOLUME,
2,
)
diff --git a/tests/unit/sampletones_application/tags/test_compose.py b/tests/unit/sampletones_application/tags/test_compose.py
index cc4fa6b1..75d3eda0 100644
--- a/tests/unit/sampletones_application/tags/test_compose.py
+++ b/tests/unit/sampletones_application/tags/test_compose.py
@@ -19,28 +19,80 @@ class TestComposeTag(BaseTestSuite):
class TestCase(BaseRegularTestCase):
parts: Tuple[Any, ...]
- test_cases = [
- TestCase(label="single_part", parts=("plot",), expected="plot"),
- TestCase(label="two_parts", parts=("handler", "mouse"), expected="handler.mouse"),
- TestCase(label="four_parts", parts=("a", "b", "c", "d"), expected="a.b.c.d"),
- TestCase(label="uppercase_lowers", parts=("Pulse", "Duty"), expected="pulse.duty"),
- TestCase(label="space_becomes_underscore", parts=("my layer",), expected="my_layer"),
- TestCase(label="whitespace_run_collapses", parts=("my layer",), expected="my_layer"),
- TestCase(label="surrounding_whitespace_strips", parts=(" layer ",), expected="layer"),
+ test_cases = (
+ TestCase(
+ label="single_part",
+ parts=("plot",),
+ expected="plot",
+ ),
+ TestCase(
+ label="two_parts",
+ parts=("handler", "mouse"),
+ expected="handler.mouse",
+ ),
+ TestCase(
+ label="four_parts",
+ parts=("a", "b", "c", "d"),
+ expected="a.b.c.d",
+ ),
+ TestCase(
+ label="uppercase_lowers",
+ parts=("Pulse", "Duty"),
+ expected="pulse.duty",
+ ),
+ TestCase(
+ label="space_becomes_underscore",
+ parts=("my layer",),
+ expected="my_layer",
+ ),
+ TestCase(
+ label="whitespace_run_collapses",
+ parts=("my layer",),
+ expected="my_layer",
+ ),
+ TestCase(
+ label="surrounding_whitespace_strips",
+ parts=(" layer ",),
+ expected="layer",
+ ),
TestCase(label="tab_and_newline_normalize", parts=("a\tb\nc",), expected="a_b_c"),
TestCase(
label="composed_base_contributes_its_segments",
parts=("global.graph.y_axis", "theme"),
expected="global.graph.y_axis.theme",
),
- TestCase(label="str_enum_member_serves_as_part", parts=(_Layer.PULSE_ONE, "graph"), expected="pulse_1.graph"),
- TestCase(label="digits_survive", parts=("layer", "12"), expected="layer.12"),
- TestCase(label="no_part_raises", parts=(), expected=ValueError),
- TestCase(label="empty_part_raises", parts=("base", ""), expected=ValueError),
- TestCase(label="whitespace_only_part_raises", parts=("base", " "), expected=ValueError),
- ]
+ TestCase(
+ label="str_enum_member_serves_as_part",
+ parts=(_Layer.PULSE_ONE, "graph"),
+ expected="pulse_1.graph",
+ ),
+ TestCase(
+ label="digits_survive",
+ parts=("layer", "12"),
+ expected="layer.12",
+ ),
+ TestCase(
+ label="no_part_raises",
+ parts=(),
+ expected=ValueError,
+ ),
+ TestCase(
+ label="empty_part_raises",
+ parts=("base", ""),
+ expected=ValueError,
+ ),
+ TestCase(
+ label="whitespace_only_part_raises",
+ parts=("base", " "),
+ expected=ValueError,
+ ),
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_compose_tag(self, test_case: TestCase) -> None:
if not expect_error(compose_tag, test_case.expected, *test_case.parts):
assert compose_tag(*test_case.parts) == test_case.expected
diff --git a/tests/unit/sampletones_application/test_application_channels.py b/tests/unit/sampletones_application/test_application_channels.py
new file mode 100644
index 00000000..39a46fc0
--- /dev/null
+++ b/tests/unit/sampletones_application/test_application_channels.py
@@ -0,0 +1,109 @@
+from dataclasses import dataclass
+from enum import StrEnum
+from functools import partial
+from typing import List, Tuple
+from unittest.mock import MagicMock
+
+import pytest
+
+from sampletones_application.application import Application
+from sampletones_application.categories.hierarchy import Tab
+from sampletones_core.constants.enums import GeneratorName
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+
+class Surface(StrEnum):
+ """The control a channel is switched by, one per tab that carries one."""
+
+ MAIN = "main"
+ RECONSTRUCTIONS = "reconstructions"
+ SEQUENCER = "sequencer"
+
+
+class Harness:
+ """An application standing in one tab, recording which surface a channel key reaches."""
+
+ def __init__(self, tab: Tab) -> None:
+ self.switched: List[Tuple[Surface, GeneratorName]] = []
+
+ self.application = Application.__new__(Application)
+ self.application._shell = MagicMock()
+ self.application._shell.get_current_tab.return_value = tab
+ self.application._main_tab = MagicMock()
+ self.application._main_tab.toggle_generator = partial(self._record, Surface.MAIN)
+ self.application._reconstructions_tab = MagicMock()
+ self.application._reconstructions_tab.toggle_generator = partial(self._record, Surface.RECONSTRUCTIONS)
+ self.application._sequencer_tab = MagicMock()
+ self.application._sequencer_tab.toggle_channel = partial(self._record, Surface.SEQUENCER)
+
+ def _record(self, surface: Surface, generator: GeneratorName) -> None:
+ self.switched.append((surface, generator))
+
+
+class TestToggleChannel(BaseTestSuite):
+ """One key switches the channel of whichever tab the reader is standing in."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ tab: Tab
+ expected: Surface
+
+ test_cases = (
+ TestCase(
+ label="the main tab switches a generator of the reconstructor",
+ tab=Tab.MAIN,
+ expected=Surface.MAIN,
+ ),
+ TestCase(
+ label="the reconstructions tab switches a slice of the waveform",
+ tab=Tab.RECONSTRUCTIONS,
+ expected=Surface.RECONSTRUCTIONS,
+ ),
+ TestCase(
+ label="the sequencer switches its mix",
+ tab=Tab.SEQUENCER,
+ expected=Surface.SEQUENCER,
+ ),
+ TestCase(
+ label="a tab carrying no control of its own falls to the mix",
+ tab=Tab.INSTRUCTIONS,
+ expected=Surface.SEQUENCER,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_surface_a_channel_key_reaches(self, test_case: TestCase) -> None:
+ harness = Harness(test_case.tab)
+
+ harness.application._toggle_channel(GeneratorName.TRIANGLE)
+
+ assert harness.switched == [(test_case.expected, GeneratorName.TRIANGLE)]
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_every_channel_reaches_the_same_surface(self, test_case: TestCase) -> None:
+ """The four keys stand together, so a tab answers all of them or none."""
+ harness = Harness(test_case.tab)
+
+ for generator in GeneratorName:
+ harness.application._toggle_channel(generator)
+
+ assert harness.switched == [(test_case.expected, generator) for generator in GeneratorName]
+
+
+class TestMuteChannel:
+ def test_the_menu_gesture_switches_the_sequencer_mix_from_any_tab(self) -> None:
+ """The Channels submenu shows the sequencer's mix, so choosing an item switches that mix."""
+ harness = Harness(Tab.MAIN)
+
+ harness.application._mute_channel(GeneratorName.NOISE)
+
+ assert harness.switched == [(Surface.SEQUENCER, GeneratorName.NOISE)]
diff --git a/tests/unit/sampletones_application/test_application_retune.py b/tests/unit/sampletones_application/test_application_retune.py
index f58f6e17..66684f8e 100644
--- a/tests/unit/sampletones_application/test_application_retune.py
+++ b/tests/unit/sampletones_application/test_application_retune.py
@@ -13,7 +13,9 @@ def _retuned(sample_id: str, rate: int) -> RetunedSample:
def _app(
- current_rate: int, sample: Optional[MagicMock], open_reconstruction: Optional[MagicMock] = None
+ current_rate: int,
+ sample: Optional[MagicMock],
+ open_reconstruction: Optional[MagicMock] = None,
) -> Application:
app = Application.__new__(Application)
app.project_manager = MagicMock()
@@ -106,7 +108,10 @@ def _app_for_rate(
class TestRetuneDim:
def test_dims_the_open_reconstruction_when_it_will_be_retuned(self) -> None:
open_sample = _sample("open", 30)
- app = _app_for_rate([open_sample, _sample("other", 30)], open_reconstruction=open_sample.reconstruction)
+ app = _app_for_rate(
+ [open_sample, _sample("other", 30)],
+ open_reconstruction=open_sample.reconstruction,
+ )
app._retune_samples_for_rate(60)
@@ -114,7 +119,10 @@ def test_dims_the_open_reconstruction_when_it_will_be_retuned(self) -> None:
def test_does_not_dim_when_the_open_sample_already_matches(self) -> None:
open_sample = _sample("open", 60)
- app = _app_for_rate([open_sample, _sample("other", 30)], open_reconstruction=open_sample.reconstruction)
+ app = _app_for_rate(
+ [open_sample, _sample("other", 30)],
+ open_reconstruction=open_sample.reconstruction,
+ )
app._retune_samples_for_rate(60)
diff --git a/tests/unit/sampletones_application/test_busy_lock.py b/tests/unit/sampletones_application/test_busy_lock.py
index d2835987..5449f8ee 100644
--- a/tests/unit/sampletones_application/test_busy_lock.py
+++ b/tests/unit/sampletones_application/test_busy_lock.py
@@ -3,7 +3,12 @@
from sampletones_application.application import Application
-def _application(*, converter_running: bool = False, library_generating: bool = False) -> Application:
+def _application(
+ *,
+ converter_running: bool = False,
+ library_generating: bool = False,
+ rendering: bool = False,
+) -> Application:
"""An application with only the attributes the busy methods touch, bypassing the full composition
root constructor."""
application = Application.__new__(Application)
@@ -12,12 +17,15 @@ def _application(*, converter_running: bool = False, library_generating: bool =
application._instructions_tab = MagicMock()
application._instructions_tab.is_library_generating.return_value = library_generating
application._reconstructions_tab = MagicMock()
+ application._render_coordinator = MagicMock()
+ application._render_coordinator.is_active = rendering
+ application._update_menu = MagicMock()
return application
class TestBusySourceOfTruth:
- """``_is_operation_active`` is the single busy authority: a conversion or a library
- generation each make it true, and only an idle pair makes it false."""
+ """``_is_operation_active`` is the single busy authority: a conversion, a library generation or
+ a render each make it true, and only an idle set makes it false."""
def test_busy_while_converter_runs(self) -> None:
assert _application(converter_running=True)._is_operation_active() is True
@@ -25,15 +33,29 @@ def test_busy_while_converter_runs(self) -> None:
def test_busy_while_library_generates(self) -> None:
assert _application(library_generating=True)._is_operation_active() is True
- def test_idle_when_neither_runs(self) -> None:
+ def test_busy_while_song_renders(self) -> None:
+ assert _application(rendering=True)._is_operation_active() is True
+
+ def test_idle_when_none_runs(self) -> None:
assert _application()._is_operation_active() is False
class TestBusyRefreshPropagation:
- """A busy-state change nudges both tabs to re-evaluate their action buttons; each panel reads the
- live busy authority for itself, so no value is pushed."""
+ """A busy-state change nudges both tabs to re-evaluate their action buttons and the menu to
+ re-read what may start another such operation; each reads the live busy authority for itself,
+ so no value is pushed."""
def test_refresh_nudges_both_tabs(self) -> None:
application = _application()
application._refresh_busy_state()
application._instructions_tab.refresh_generate_button.assert_called_once_with()
+
+ def test_refresh_reaches_the_menu(self) -> None:
+ application = _application()
+ application._refresh_busy_state()
+ application._update_menu.assert_called_once_with()
+
+ def test_a_render_edge_refreshes_the_converter_view(self) -> None:
+ application = _application()
+ application._on_render_activity_changed()
+ application._main_tab.refresh_converter_view.assert_called_once_with()
diff --git a/tests/unit/sampletones_application/test_project_properties_history.py b/tests/unit/sampletones_application/test_project_properties_history.py
index 405a97d2..ce9ff781 100644
--- a/tests/unit/sampletones_application/test_project_properties_history.py
+++ b/tests/unit/sampletones_application/test_project_properties_history.py
@@ -7,11 +7,14 @@
from sampletones_application.logic.project.manager import ProjectManager
HISTORY_BUDGET: Final[int] = 10
+FIRST_HIGHLIGHT: Final[int] = 3
+SECOND_HIGHLIGHT: Final[int] = 12
def _application() -> Application:
"""An application with only the attributes the properties commit touches, bypassing the full
- composition root constructor. History runs strict so an untracked mutation fails the test."""
+ composition root constructor. History runs strict so an untracked mutation fails the test.
+ """
application = Application.__new__(Application)
controller = ProjectController(ProjectManager())
history = HistoryManager(controller, budget=HISTORY_BUDGET, strict=True)
@@ -25,33 +28,83 @@ def _application() -> Application:
class TestPropertiesCommitHistory:
"""The properties dialog's commit lands as one undoable gesture: every changed field joins a
- single ``EDIT_PROJECT_PROPERTIES`` entry, and an unchanged confirmation records nothing."""
+ single ``EDIT_PROJECT_PROPERTIES`` entry, and an unchanged confirmation records nothing.
+ """
def test_changed_fields_group_into_one_entry(self) -> None:
application = _application()
- application._commit_project_properties("Title", "Author", "Comment")
+ application._commit_project_properties(
+ "Title",
+ "Author",
+ "Comment",
+ FIRST_HIGHLIGHT,
+ SECOND_HIGHLIGHT,
+ )
assert len(application.history.entries) == 2
assert application.history.entries[-1].action is HistoryAction.EDIT_PROJECT_PROPERTIES
info = application.project_controller.project.info
assert (info.title, info.author, info.comment) == ("Title", "Author", "Comment")
+ def test_the_metre_joins_the_same_entry_as_the_info(self) -> None:
+ """The highlights are project settings, and the dialog commits them beside the info."""
+ application = _application()
+
+ application._commit_project_properties(
+ "Title",
+ "Author",
+ "Comment",
+ FIRST_HIGHLIGHT,
+ SECOND_HIGHLIGHT,
+ )
+
+ settings = application.project_controller.project.settings
+ assert (settings.first_highlight, settings.second_highlight) == (FIRST_HIGHLIGHT, SECOND_HIGHLIGHT)
+ assert len(application.history.entries) == 2
+
def test_unchanged_confirmation_records_nothing(self) -> None:
application = _application()
info = application.project_controller.project.info
+ settings = application.project_controller.project.settings
- application._commit_project_properties(info.title, info.author, info.comment)
+ application._commit_project_properties(
+ info.title,
+ info.author,
+ info.comment,
+ settings.first_highlight,
+ settings.second_highlight,
+ )
assert len(application.history.entries) == 1
def test_undo_restores_the_previous_properties(self) -> None:
application = _application()
info = application.project_controller.project.info
- previous = (info.title, info.author, info.comment)
+ settings = application.project_controller.project.settings
+ previous = (
+ info.title,
+ info.author,
+ info.comment,
+ settings.first_highlight,
+ settings.second_highlight,
+ )
- application._commit_project_properties("Title", "Author", "Comment")
+ application._commit_project_properties(
+ "Title",
+ "Author",
+ "Comment",
+ FIRST_HIGHLIGHT,
+ SECOND_HIGHLIGHT,
+ )
application.history.undo()
info = application.project_controller.project.info
- assert (info.title, info.author, info.comment) == previous
+ settings = application.project_controller.project.settings
+ assert (
+ info.title,
+ info.author,
+ info.comment,
+ settings.first_highlight,
+ settings.second_highlight,
+ ) == previous
diff --git a/tests/unit/sampletones_application/test_shell.py b/tests/unit/sampletones_application/test_shell.py
new file mode 100644
index 00000000..eb5f5a37
--- /dev/null
+++ b/tests/unit/sampletones_application/test_shell.py
@@ -0,0 +1,49 @@
+from dataclasses import fields
+from unittest.mock import Mock
+
+import pytest
+
+from sampletones_application.constants.playback import FollowMode
+from sampletones_application.shell import ApplicationShell, ShortcutBindings
+from sampletones_application.utils.gui.shortcuts.ids import (
+ FOLLOW_MODE_SHORTCUT_IDS,
+ ShortcutCategory,
+ ShortcutId,
+)
+
+APPLICATION_ACTIONS = frozenset(
+ shortcut_id for shortcut_id in ShortcutId if shortcut_id.category is ShortcutCategory.APPLICATION
+)
+
+
+def _bindings() -> ShortcutBindings:
+ """Bindings whose calls are all stand-ins, since the pairing is what the shell states."""
+ return ShortcutBindings(**{field.name: Mock() for field in fields(ShortcutBindings)})
+
+
+class TestShortcutCallbacks:
+ def test_every_application_action_names_the_call_it_makes(self) -> None:
+ """A menu asks the manager for any action it lists, which an unwired action answers with none."""
+ assert frozenset(ApplicationShell._shortcut_callbacks(_bindings())) == APPLICATION_ACTIONS
+
+ def test_an_export_action_carries_the_format_it_writes(self) -> None:
+ bindings = _bindings()
+
+ ApplicationShell._shortcut_callbacks(bindings)[ShortcutId.EXPORT_PROJECT_FAMITRACKER]()
+
+ bindings.export_project.assert_called_once()
+
+ def test_a_channel_action_carries_the_channel_it_switches(self) -> None:
+ bindings = _bindings()
+
+ ApplicationShell._shortcut_callbacks(bindings)[ShortcutId.TOGGLE_CHANNEL_NOISE]()
+
+ bindings.toggle_channel.assert_called_once()
+
+ @pytest.mark.parametrize("mode", list(FollowMode), ids=str)
+ def test_a_follow_action_carries_the_reach_it_chooses(self, mode: FollowMode) -> None:
+ bindings = _bindings()
+
+ ApplicationShell._shortcut_callbacks(bindings)[FOLLOW_MODE_SHORTCUT_IDS[mode]]()
+
+ bindings.set_follow_mode.assert_called_once_with(mode)
diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py
index c5cf2330..dac38796 100644
--- a/tests/unit/sampletones_application/test_startup.py
+++ b/tests/unit/sampletones_application/test_startup.py
@@ -1,19 +1,32 @@
-from contextlib import ExitStack
+from contextlib import ExitStack, contextmanager
from pathlib import Path
-from typing import Any, Callable, Final, Generator, List
-from unittest.mock import patch
+from typing import Any, Callable, Dict, Final, Generator, List
+from unittest.mock import PropertyMock, patch
import dearpygui.dearpygui as dpg
import pytest
from sampletones_application.application import Application
+from sampletones_application.categories.hierarchy import Tab
+from sampletones_application.config.managers.session import SessionManager
+from sampletones_application.config.profile import UserProfile
+from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME
from sampletones_application.logic.history.action import HistoryAction
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.utils.gui.shortcuts.ids import (
+ CHANNEL_SHORTCUT_IDS,
+ TAB_SHORTCUT_IDS,
+ ShortcutId,
+)
from sampletones_application.utils.parallelization.background import (
stop_background_workers,
)
from sampletones_application.utils.parallelization.thread import SingleThreadExecutor
+from sampletones_core.constants.enums import GeneratorName
from sampletones_core.reconstructions import Reconstruction
+REBOUND_UNDO: Final[Dict[str, str]] = {"Undo": "Ctrl+Alt+U"}
+
_DPG_DISPLAY_FUNCTIONS = [
"create_context",
"create_viewport",
@@ -54,6 +67,32 @@ def _display_patches() -> List[Any]:
return display_patches
+@contextmanager
+def _no_audio_devices() -> Generator[None, None, None]:
+ """The machine a headless run comes up on: the backend reports no output device at all."""
+ with (
+ patch("pyaudio.PyAudio.get_device_count", return_value=0),
+ patch(
+ "pyaudio.PyAudio.get_default_output_device_info",
+ side_effect=OSError,
+ ),
+ ):
+ yield
+
+
+def _profile(directory: Path) -> UserProfile:
+ """Starts the application on a profile of its own, in the state a first run finds.
+
+ The settings and the keys an application comes up on are read from its profile, so a suite
+ given the user's own answers for whatever that machine prefers. A directory per test is what
+ holds a run to the shipped defaults.
+ """
+ return UserProfile(
+ config=directory / "config.yaml",
+ state=directory / "state.yaml",
+ )
+
+
class TestGUIStartup:
@pytest.fixture(autouse=True)
def dpg_context(self) -> Generator[Any, Application, Any]:
@@ -63,28 +102,97 @@ def dpg_context(self) -> Generator[Any, Application, Any]:
SingleThreadExecutor.reset_shutdown()
dpg.destroy_context()
- def test_initialises_without_error(self) -> None:
+ def test_initialises_without_error(self, tmp_path: Path) -> None:
+ with ExitStack() as stack:
+ for display_patch in _display_patches():
+ stack.enter_context(display_patch)
+
+ Application(profile=_profile(tmp_path))
+
+ def test_initialises_where_nothing_can_play(self, tmp_path: Path) -> None:
+ """Editing a song, exporting a module and rendering to a file need no output device.
+
+ The rate the audio is rendered at is the consumer's to state, so a machine offering no
+ device to play through still opens the window and everything that writes rather than
+ sounds works on it.
+ """
with ExitStack() as stack:
- for p in _display_patches():
- stack.enter_context(p)
+ for display_patch in _display_patches():
+ stack.enter_context(display_patch)
+ stack.enter_context(_no_audio_devices())
- Application()
+ Application(profile=_profile(tmp_path))
@pytest.fixture
-def app() -> Generator[Any, Application, Any]:
+def app(tmp_path: Path) -> Generator[Any, Application, Any]:
dpg.create_context()
try:
with ExitStack() as stack:
- for p in _display_patches():
- stack.enter_context(p)
- yield Application()
+ for display_patch in _display_patches():
+ stack.enter_context(display_patch)
+
+ yield Application(profile=_profile(tmp_path))
finally:
stop_background_workers()
SingleThreadExecutor.reset_shutdown()
dpg.destroy_context()
+class TestKeybindingPreferences:
+ """The application runs on the keys the session stores, which is what makes a rebind stick.
+
+ The session names the scheme it runs under, so a case reads the same keys on whichever platform
+ the suite runs; a Mac opens a fresh profile on Command.
+ """
+
+ @pytest.fixture
+ def application(self, tmp_path: Path) -> Generator[Any, Application, Any]:
+ dpg.create_context()
+ try:
+ with ExitStack() as stack:
+ for display_patch in _display_patches():
+ stack.enter_context(display_patch)
+
+ stack.enter_context(
+ patch.object(
+ SessionManager,
+ "shortcut_scheme_name",
+ new_callable=PropertyMock,
+ return_value=DEFAULT_SCHEME_NAME,
+ )
+ )
+ stack.enter_context(
+ patch.object(
+ SessionManager,
+ "shortcut_overrides",
+ new_callable=PropertyMock,
+ return_value=REBOUND_UNDO,
+ )
+ )
+ yield Application(profile=_profile(tmp_path))
+ finally:
+ stop_background_workers()
+ SingleThreadExecutor.reset_shutdown()
+ dpg.destroy_context()
+
+ def test_a_stored_override_reaches_the_keys_in_place(self, application: Application) -> None:
+ assert application._shortcut_source.display(ShortcutId.UNDO) == REBOUND_UNDO["Undo"]
+
+ def test_the_actions_the_override_leaves_alone_keep_the_scheme_s_keys(
+ self,
+ application: Application,
+ ) -> None:
+ assert application._shortcut_source.display(ShortcutId.SAVE_PROJECT) == "Ctrl+S"
+
+ def test_another_scheme_hands_its_keys_to_the_dispatcher(self, application: Application) -> None:
+ """A rebind reaches what has already read a combination, which is how it takes effect live."""
+ with patch.object(application.shortcut_manager, "rebind") as rebind:
+ application._shortcut_source.activate(application._shortcut_catalog.default)
+
+ rebind.assert_called_once()
+
+
class TestStartupRestoreDelegation:
"""Application only forwards the startup restore to the domain coordinators, which
are the recovery boundary (docs/development/architecture.md § Error Handling Policy). The
@@ -221,3 +329,73 @@ def test_embedded_sample_is_a_detached_copy(
assert sample.reconstruction is not app.reconstruction_manager.reconstruction
assert sample.reconstruction.audio_filepath is None
assert not app._editing_project_sample()
+
+
+def _press_shortcut(app: Application, shortcut_id: ShortcutId) -> None:
+ """Routes the press the scheme in place gives an action, so a rebind carries the case with it."""
+ combination = app._shortcut_source.shortcut(shortcut_id).combination
+ assert combination is not None
+ app.key_router.route(KeyEvent(key=combination.key, modifiers=combination.modifiers))
+
+
+class TestChannelKeys:
+ """One key per channel, reaching the switch of the tab in front of the reader.
+
+ The whole application answers here, so a press travels the way it does at runtime: the router
+ hands it to the dispatcher, the scheme names the action, and the tab on screen decides which
+ of its controls the action reaches.
+ """
+
+ @staticmethod
+ def _press(app: Application, generator: GeneratorName, tab: Tab) -> None:
+ with patch.object(app._shell, "get_current_tab", return_value=tab):
+ _press_shortcut(app, CHANNEL_SHORTCUT_IDS[generator])
+
+ def test_the_main_tab_switches_the_generator_a_reconstruction_is_built_from(self, app: Application) -> None:
+ selected = frozenset(app.config_manager.config.generation.generators)
+
+ self._press(app, GeneratorName.TRIANGLE, Tab.MAIN)
+
+ assert frozenset(app.config_manager.config.generation.generators) == selected ^ {GeneratorName.TRIANGLE}
+
+ def test_the_sequencer_switches_its_mix(self, app: Application) -> None:
+ self._press(app, GeneratorName.NOISE, Tab.SEQUENCER)
+
+ assert app._sequencer_tab.channels.is_muted(GeneratorName.NOISE)
+
+ def test_a_second_press_returns_the_mix_it_started_from(self, app: Application) -> None:
+ self._press(app, GeneratorName.PULSE1, Tab.SEQUENCER)
+ self._press(app, GeneratorName.PULSE1, Tab.SEQUENCER)
+
+ assert not app._sequencer_tab.channels.any_muted
+
+ def test_the_reconstructions_tab_holding_nothing_leaves_the_mix_alone(self, app: Application) -> None:
+ """With no reconstruction loaded every slice reads as unavailable, so the key rests there."""
+ self._press(app, GeneratorName.PULSE2, Tab.RECONSTRUCTIONS)
+
+ assert not app._sequencer_tab.channels.any_muted
+
+ def test_the_main_tab_leaves_the_sequencer_mix_alone(self, app: Application) -> None:
+ self._press(app, GeneratorName.PULSE1, Tab.MAIN)
+
+ assert not app._sequencer_tab.channels.any_muted
+
+
+class TestTabKeys:
+ """One key per tab, bringing it to the front from wherever the reader stands.
+
+ The whole application answers here, so a press travels the way it does at runtime: the router
+ hands it to the dispatcher, the scheme names the action, and the shell puts the tab on screen.
+ """
+
+ @pytest.mark.parametrize("tab", tuple(TAB_SHORTCUT_IDS), ids=lambda tab: str(tab))
+ def test_the_key_puts_its_tab_on_screen(self, app: Application, tab: Tab) -> None:
+ with patch.object(app._shell, "set_current_tab") as set_current_tab:
+ _press_shortcut(app, TAB_SHORTCUT_IDS[tab])
+
+ set_current_tab.assert_called_once_with(tab)
+
+ @pytest.mark.parametrize("tab", tuple(TAB_SHORTCUT_IDS), ids=lambda tab: str(tab))
+ def test_the_key_answers_while_a_field_is_edited(self, app: Application, tab: Tab) -> None:
+ """Naming a tab reaches it the way stepping to the next one does, typing included."""
+ assert app._shortcut_source.shortcut(TAB_SHORTCUT_IDS[tab]).field_transparent
diff --git a/tests/unit/sampletones_application/test_viewport.py b/tests/unit/sampletones_application/test_viewport.py
index e1690e17..15da5705 100644
--- a/tests/unit/sampletones_application/test_viewport.py
+++ b/tests/unit/sampletones_application/test_viewport.py
@@ -4,7 +4,9 @@
import pytest
from screeninfo import Monitor, ScreenInfoError
-from sampletones_application.viewport import _MAX_WINDOW_MONITOR_RATIO, ViewportManager
+from sampletones_application.layout.general.window import WindowLayout
+from sampletones_application.viewport import ViewportManager
+from sampletones_shared.display import Resolution
_TOGGLE_FULLSCREEN = "dearpygui.dearpygui.toggle_viewport_fullscreen"
@@ -13,18 +15,29 @@
_MIN_WIDTH = 1024
_MIN_HEIGHT = 640
+_USABLE_RATIO = 0.9
+
+_WINDOW = WindowLayout(
+ width=1280,
+ height=800,
+ min_width=_MIN_WIDTH,
+ min_height=_MIN_HEIGHT,
+ position_x=200,
+ fullscreen=False,
+ max_monitor_ratio=_USABLE_RATIO,
+ fallback_monitor=Resolution(width=1920, height=1080),
+)
def _manager() -> ViewportManager:
manager = ViewportManager.__new__(ViewportManager)
- manager._min_width = _MIN_WIDTH
- manager._min_height = _MIN_HEIGHT
+ manager._window = _WINDOW
return manager
def _usable_bounds(monitor: Monitor) -> Tuple[int, int, int, int]:
- usable_width = int(monitor.width * _MAX_WINDOW_MONITOR_RATIO)
- usable_height = int(monitor.height * _MAX_WINDOW_MONITOR_RATIO)
+ usable_width = int(monitor.width * _USABLE_RATIO)
+ usable_height = int(monitor.height * _USABLE_RATIO)
margin_x = (monitor.width - usable_width) // 2
margin_y = (monitor.height - usable_height) // 2
return usable_width, usable_height, margin_x, margin_y
@@ -41,29 +54,73 @@ class FitCase:
height: int
-_FIT_CASES = (
- FitCase("oversized_from_larger_monitor", (_PRIMARY,), _PRIMARY, 200, 200, 2560, 1440),
- FitCase("equal_to_monitor", (_PRIMARY,), _PRIMARY, 0, 0, 1920, 1080),
- FitCase("off_screen_top_left", (_PRIMARY,), _PRIMARY, -500, -500, 1280, 800),
- FitCase("off_screen_bottom_right", (_PRIMARY,), _PRIMARY, 5000, 5000, 1280, 800),
- FitCase("on_secondary_monitor", (_PRIMARY, _SECONDARY), _SECONDARY, 2000, 100, 4000, 3000),
-)
-
-
class TestFitWindowToMonitor:
- @pytest.mark.parametrize("case", _FIT_CASES, ids=lambda case: case.name)
+ test_cases = (
+ FitCase(
+ "oversized_from_larger_monitor",
+ (_PRIMARY,),
+ _PRIMARY,
+ 200,
+ 200,
+ 2560,
+ 1440,
+ ),
+ FitCase(
+ "equal_to_monitor",
+ (_PRIMARY,),
+ _PRIMARY,
+ 0,
+ 0,
+ 1920,
+ 1080,
+ ),
+ FitCase(
+ "off_screen_top_left",
+ (_PRIMARY,),
+ _PRIMARY,
+ -500,
+ -500,
+ 1280,
+ 800,
+ ),
+ FitCase(
+ "off_screen_bottom_right",
+ (_PRIMARY,),
+ _PRIMARY,
+ 5000,
+ 5000,
+ 1280,
+ 800,
+ ),
+ FitCase(
+ "on_secondary_monitor",
+ (_PRIMARY, _SECONDARY),
+ _SECONDARY,
+ 2000,
+ 100,
+ 4000,
+ 3000,
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.name)
def test_result_stays_within_usable_area(
self,
case: FitCase,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
- "sampletones_application.viewport.get_monitors",
+ "sampletones_application.utils.monitors.get_monitors",
lambda: list(case.monitors),
)
manager = _manager()
- x, y, width, height = manager._fit_window_to_monitor(case.x, case.y, case.width, case.height)
+ x, y, width, height = manager._fit_window_to_monitor(
+ case.x,
+ case.y,
+ case.width,
+ case.height,
+ )
usable_width, usable_height, margin_x, margin_y = _usable_bounds(case.target)
assert width <= usable_width
@@ -73,30 +130,49 @@ def test_result_stays_within_usable_area(
assert x + width <= case.target.x + case.target.width - margin_x
assert y + height <= case.target.y + case.target.height - margin_y
- def test_window_that_already_fits_is_unchanged(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_window_that_already_fits_is_unchanged(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
monkeypatch.setattr(
- "sampletones_application.viewport.get_monitors",
+ "sampletones_application.utils.monitors.get_monitors",
lambda: [_PRIMARY],
)
manager = _manager()
- assert manager._fit_window_to_monitor(300, 200, 1280, 800) == (300, 200, 1280, 800)
+ assert manager._fit_window_to_monitor(300, 200, 1280, 800) == (
+ 300,
+ 200,
+ 1280,
+ 800,
+ )
- def test_monitor_sized_window_is_shrunk_below_monitor(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_monitor_sized_window_is_shrunk_below_monitor(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
monkeypatch.setattr(
- "sampletones_application.viewport.get_monitors",
+ "sampletones_application.utils.monitors.get_monitors",
lambda: [_PRIMARY],
)
manager = _manager()
- _, _, width, height = manager._fit_window_to_monitor(0, 0, _PRIMARY.width, _PRIMARY.height)
+ _, _, width, height = manager._fit_window_to_monitor(
+ 0,
+ 0,
+ _PRIMARY.width,
+ _PRIMARY.height,
+ )
assert width < _PRIMARY.width
assert height < _PRIMARY.height
- def test_window_below_minimum_is_held_at_minimum(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_window_below_minimum_is_held_at_minimum(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
monkeypatch.setattr(
- "sampletones_application.viewport.get_monitors",
+ "sampletones_application.utils.monitors.get_monitors",
lambda: [_PRIMARY],
)
manager = _manager()
@@ -106,34 +182,48 @@ def test_window_below_minimum_is_held_at_minimum(self, monkeypatch: pytest.Monke
assert width >= _MIN_WIDTH
assert height >= _MIN_HEIGHT
- def test_falls_back_to_screen_dimensions_without_monitors(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_falls_back_to_assumed_dimensions_without_monitors(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
monkeypatch.setattr(
- "sampletones_application.viewport.get_monitors",
+ "sampletones_application.utils.monitors.get_monitors",
list,
)
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)
+ 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
- def test_falls_back_to_screen_dimensions_when_enumeration_fails(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_falls_back_to_assumed_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",
+ "sampletones_application.utils.monitors.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)
+ x, y, width, height = manager._fit_window_to_monitor(
+ 200,
+ 200,
+ 4000,
+ 4000,
+ )
assert 0 <= x and 0 <= y
assert x + width <= 1920
@@ -149,15 +239,26 @@ class FakeSession:
window_height: int = 800
set_calls: List[bool] = field(default_factory=list)
- def set_window_state(self, *, fullscreen: bool, x: int, y: int, width: int, height: int) -> None:
+ def set_window_state(
+ self,
+ *,
+ fullscreen: bool,
+ x: int,
+ y: int,
+ width: int,
+ height: int,
+ ) -> None:
self.set_calls.append(fullscreen)
self.fullscreen = fullscreen
-def _fullscreen_manager(session: FakeSession, changes: List[int]) -> ViewportManager:
+def _fullscreen_manager(
+ session: FakeSession,
+ changes: List[int],
+) -> ViewportManager:
manager = ViewportManager.__new__(ViewportManager)
manager._session_manager = session # type: ignore[assignment]
- manager._on_fullscreen_state_changed = lambda: changes.append(1) # type: ignore[assignment]
+ manager._on_fullscreen_state_changed = lambda: changes.append(1)
return manager
@@ -168,14 +269,13 @@ class ToggleCase:
expect_fullscreen: bool
-_TOGGLE_CASES = (
- ToggleCase("enters_from_windowed", False, True),
- ToggleCase("exits_from_fullscreen", True, False),
-)
-
-
class TestToggleFullscreen:
- @pytest.mark.parametrize("case", _TOGGLE_CASES, ids=lambda case: case.name)
+ test_cases = (
+ ToggleCase("enters_from_windowed", False, True),
+ ToggleCase("exits_from_fullscreen", True, False),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.name)
def test_toggle_flips_dpg_and_session_together(
self,
case: ToggleCase,
@@ -196,7 +296,10 @@ def test_toggle_flips_dpg_and_session_together(
class TestApplyFullscreenState:
- def test_enters_fullscreen_when_session_requests_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_enters_fullscreen_when_session_requests_it(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
toggles: List[int] = []
monkeypatch.setattr(_TOGGLE_FULLSCREEN, lambda: toggles.append(1))
session = FakeSession(fullscreen=True)
@@ -208,7 +311,10 @@ def test_enters_fullscreen_when_session_requests_it(self, monkeypatch: pytest.Mo
assert session.fullscreen is True
assert session.set_calls == []
- def test_stays_windowed_when_session_is_windowed(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_stays_windowed_when_session_is_windowed(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
toggles: List[int] = []
monkeypatch.setattr(_TOGGLE_FULLSCREEN, lambda: toggles.append(1))
session = FakeSession(fullscreen=False)
diff --git a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py
index 69ba2ebc..ce2de554 100644
--- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py
+++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py
@@ -6,6 +6,7 @@
from sampletones_application.ui.elements.graphs import waveform as waveform_module
from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph
+from sampletones_application.utils.palette.colors.written import LiteralColor
class _FakeDPG:
@@ -57,7 +58,9 @@ def fake_dpg(monkeypatch: pytest.MonkeyPatch) -> _FakeDPG:
monkeypatch.setattr(waveform_module.dpg, "get_item_alias", instance.get_item_alias)
monkeypatch.setattr(waveform_module, "dpg_delete_item", instance.delete_item)
monkeypatch.setattr(
- waveform_module.dpg, "configure_item", lambda *args, **kwargs: instance.configured.append(args[0])
+ waveform_module.dpg,
+ "configure_item",
+ lambda *args, **kwargs: instance.configured.append(args[0]),
)
monkeypatch.setattr(waveform_module.dpg, "add_line_series", lambda *args, **kwargs: None)
monkeypatch.setattr(waveform_module, "dpg_bind_item_theme", lambda *args, **kwargs: None)
@@ -80,7 +83,7 @@ def __init__(self, name: str) -> None:
self.name = name
self.x_data = _Array()
self.y_data = _Array()
- self.color = (255, 255, 255, 255)
+ self.color = LiteralColor((255, 255, 255, 255))
class _Array:
@@ -105,7 +108,7 @@ def _graph() -> GUIWaveformGraph:
def _with_layout(graph: GUIWaveformGraph, opacity: float = 0.4) -> None:
graph._layout = SimpleNamespace( # type: ignore[assignment]
- colors=SimpleNamespace(waveform_reconstruction=(255, 200, 100, 255)),
+ colors=SimpleNamespace(waveform_reconstruction=LiteralColor((255, 200, 100, 255))),
waveform=SimpleNamespace(reconstruction_dim_opacity=opacity),
)
@@ -144,17 +147,18 @@ def test_series_color_is_untouched_when_not_dimmed(self) -> None:
graph = _graph()
layer = _Layer("Reconstruction")
- assert graph._series_color(layer) == layer.color
+ assert graph._series_color(layer, graph._series_shade(layer)) == layer.color
def test_series_color_greys_the_reconstruction_when_dimmed(self) -> None:
graph = _graph()
_with_layout(graph, opacity=0.4)
graph._reconstruction_dimmed = True
- faded = graph._series_color(_Layer("Reconstruction"))
+ layer = _Layer("Reconstruction")
+ faded = graph._series_color(layer, graph._series_shade(layer))
gray = round(0.299 * 255 + 0.587 * 200 + 0.114 * 100)
- assert faded == (gray, gray, gray, round(0.4 * 255))
+ assert faded.rgba == (gray, gray, gray, round(0.4 * 255))
def test_series_color_leaves_other_layers_opaque_when_dimmed(self) -> None:
graph = _graph()
@@ -162,7 +166,7 @@ def test_series_color_leaves_other_layers_opaque_when_dimmed(self) -> None:
graph._reconstruction_dimmed = True
layer = _Layer("Sample Name")
- assert graph._series_color(layer) == layer.color
+ assert graph._series_color(layer, graph._series_shade(layer)) == layer.color
def test_set_dimmed_rebinds_the_reconstruction_series_once(
self,
@@ -175,7 +179,11 @@ def test_set_dimmed_rebinds_the_reconstruction_series_once(
series_tag = graph._series_tag("Reconstruction")
fake_dpg.set_children("axis", [series_tag])
binds: List[str] = []
- monkeypatch.setattr(waveform_module, "dpg_bind_item_theme", lambda tag, theme: binds.append(theme))
+ monkeypatch.setattr(
+ waveform_module,
+ "dpg_bind_item_theme",
+ lambda tag, theme: binds.append(theme),
+ )
graph.set_reconstruction_dimmed(True)
assert graph._reconstruction_dimmed is True
diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py b/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py
index 09b4cff4..02af2b2b 100644
--- a/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py
+++ b/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py
@@ -5,7 +5,9 @@
from sampletones_application.layout.general.collapse import CollapseLayout
from sampletones_application.layout.general.section_header import SectionHeaderLayout
-from sampletones_application.layout.glyphs import CommonGlyphs, GlyphLayout, Glyphs
+from sampletones_application.layout.glyphs.common import CommonGlyphs
+from sampletones_application.layout.glyphs.glyph import GlyphLayout
+from sampletones_application.layout.glyphs.glyphs import Glyphs
from sampletones_application.tags.general import (
TAG_GLOBAL_THEME_COLLAPSE_HEADER,
TAG_GLOBAL_THEME_COLLAPSE_HEADER_HOVERED,
@@ -88,16 +90,21 @@ def _controller(
def _build_card(controller: CollapseController) -> None:
"""Mirrors the item subtree ``_collapsible_section`` builds, without fonts or the header theme."""
- with dpg.window():
- with dpg.child_window(tag=controller.card_tag, height=_EXPANDED_HEIGHT):
- with dpg.child_window(tag=controller.strip_tag, height=_HEADER_BAR_HEIGHT, border=False):
- dpg.add_text(controller.chevron_glyph, tag=controller.chevron_tag)
- if controller.is_horizontal:
- with dpg.child_window(tag=controller.rail_tag, width=_RAIL_WIDTH, show=False):
- dpg.add_text(".")
- controller.attach()
- with dpg.group(tag=controller.body_tag):
- dpg.add_text("body")
+ with (
+ dpg.window(),
+ dpg.child_window(tag=controller.card_tag, height=_EXPANDED_HEIGHT),
+ ):
+ with dpg.child_window(tag=controller.strip_tag, height=_HEADER_BAR_HEIGHT, border=False):
+ dpg.add_text(controller.chevron_glyph, tag=controller.chevron_tag)
+
+ if controller.is_horizontal:
+ with dpg.child_window(tag=controller.rail_tag, width=_RAIL_WIDTH, show=False):
+ dpg.add_text(".")
+
+ controller.attach()
+ with dpg.group(tag=controller.body_tag):
+ dpg.add_text("body")
+
controller.set_collapsed(controller.collapsed, notify=False)
@@ -184,7 +191,8 @@ class TestFillVerticalCollapse:
"""A fill card fills its owner's reserved footprint while expanded and pins to its header bar while
collapsed: collapsing hides the body and shrinks the card to the strip plus its padding, and expanding
restores the fill sentinel height (0) so the card fills the reservation again. Pinning makes the
- collapsed size intrinsic, so the bar holds even when the owner is no longer reserving its footprint."""
+ collapsed size intrinsic, so the bar holds even when the owner is no longer reserving its footprint.
+ """
def test_collapsing_hides_the_body_and_pins_the_card_to_the_strip(
self, dpg_context: None, rendered_strip_padding: None
@@ -215,7 +223,8 @@ def test_expanding_shows_the_body_and_restores_the_fill_height(
class TestHorizontalCollapse:
"""A horizontal card leaves its own width to the coordinator: collapsing hides the body and the
- strip and reveals the rail, and the toggle is announced so the coordinator can reclaim the column."""
+ strip and reveals the rail, and the toggle is announced so the coordinator can reclaim the column.
+ """
def test_collapsing_swaps_the_strip_for_the_rail(self, dpg_context: None) -> None:
controller = _controller(CollapseAxis.HORIZONTAL_LEFT)
@@ -252,7 +261,9 @@ def test_toggle_announces_the_new_state(self, dpg_context: None) -> None:
assert announced == [(_CARD_TAG, True), (_CARD_TAG, False)]
- def test_strip_chevron_points_at_the_dock_edge_and_the_rail_chevron_points_away(self) -> None:
+ def test_strip_chevron_points_at_the_dock_edge_and_the_rail_chevron_points_away(
+ self,
+ ) -> None:
"""The strip (shown while expanded) points at the dock edge; the rail (shown while collapsed) the other way.
Each affordance shows in only one state, so neither flips: clicking the strip collapses the card toward
@@ -293,8 +304,15 @@ def section_panel(dpg_context: None, monkeypatch: pytest.MonkeyPatch) -> _RailPa
monkeypatch.setattr(dpg, "get_text_size", lambda text, font=0: None)
GUIPanel.configure_section_header(
_glyphs(),
- SectionHeaderLayout(glyph=GlyphLayout(indent=0, width=_RAIL_WIDTH, top_offset=0), chevron_offset=8),
- CollapseLayout(header_bar_height=_HEADER_BAR_HEIGHT, rail_width=_RAIL_WIDTH, rail_title_gap=6),
+ SectionHeaderLayout(
+ glyph=GlyphLayout(indent=0, width=_RAIL_WIDTH, top_offset=0),
+ chevron_offset=8,
+ ),
+ CollapseLayout(
+ header_bar_height=_HEADER_BAR_HEIGHT,
+ rail_width=_RAIL_WIDTH,
+ rail_title_gap=6,
+ ),
)
panel = _RailPanel(tag=_CARD_TAG)
panel._enable_horizontal_collapse(initial_collapsed=True, side=CollapseAxis.HORIZONTAL_LEFT)
@@ -303,7 +321,8 @@ def section_panel(dpg_context: None, monkeypatch: pytest.MonkeyPatch) -> _RailPa
class TestHorizontalRailTitle:
"""A docked card's rail names itself: it stacks the card title one uppercased character per line,
- matching the header's treatment, so a collapsed column still reads as what it holds."""
+ matching the header's treatment, so a collapsed column still reads as what it holds.
+ """
def test_rail_stacks_the_uppercased_title_one_character_per_line(self, section_panel: _RailPanel) -> None:
with dpg.window():
diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py b/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py
index 156e6033..9f1f0fb1 100644
--- a/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py
+++ b/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py
@@ -6,57 +6,100 @@
expanded_side_width,
stacked_graph_height,
)
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
-@dataclass(frozen=True)
-class StackedHeightCase:
- label: str
- base_height: int
- viewport_height: int
- baseline_viewport_height: int
- graph_count: int
- max_stack_height: int
- expected: int
-
-
-_STACKED_HEIGHT_CASES = [
- StackedHeightCase("fills_at_baseline", 292, 800, 800, 2, 1200, 292),
- StackedHeightCase("holds_base_below_baseline", 292, 640, 800, 2, 1200, 292),
- StackedHeightCase("shares_surplus_equally", 292, 1000, 800, 2, 1200, 392),
- StackedHeightCase("just_below_the_cap", 292, 1414, 800, 2, 1200, 599),
- StackedHeightCase("reaches_the_cap", 292, 1416, 800, 2, 1200, 600),
- StackedHeightCase("holds_the_cap_above_it", 292, 2200, 800, 2, 1200, 600),
- StackedHeightCase("three_graphs_share_surplus", 292, 1100, 800, 3, 1200, 392),
- StackedHeightCase("three_graphs_lower_cap", 292, 1124, 800, 3, 1200, 400),
-]
-
-
-@dataclass(frozen=True)
-class SideWidthCase:
- label: str
- base_width: int
- viewport_width: int
- baseline_viewport_width: int
- side_panel_count: int
- center_weight: int
- expected: int
-
-
-_SIDE_WIDTH_CASES = [
- SideWidthCase("holds_base_at_baseline", 300, 1280, 1280, 2, 2, 300),
- SideWidthCase("holds_base_below_baseline", 300, 1000, 1280, 2, 2, 300),
- SideWidthCase("single_side_takes_a_third", 300, 1580, 1280, 1, 2, 400),
- SideWidthCase("two_sides_split_after_centre", 300, 1600, 1280, 2, 2, 380),
- SideWidthCase("heavier_centre_narrows_sides", 300, 1600, 1280, 2, 4, 353),
-]
-
-
-class TestStackedGraphHeight:
+class TestStackedGraphHeight(BaseTestSuite):
"""``stacked_graph_height`` fills a vertical graph stack at the lowest-resolution baseline, then
- shares the taller viewport's surplus equally across the graphs until their combined height reaches
- the configured maximum, from where each graph holds at its per-graph cap."""
-
- @pytest.mark.parametrize("case", _STACKED_HEIGHT_CASES, ids=lambda case: case.label)
+ shares the taller viewport's surplus equally across the graphs until each one stands at the
+ configured maximum, where it holds however many graphs the stack carries."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class StackedHeightCase(BaseRegularTestCase):
+ base_height: int
+ viewport_height: int
+ baseline_viewport_height: int
+ graph_count: int
+ max_graph_height: int
+ expected: int
+
+ test_cases = (
+ StackedHeightCase(
+ label="fills_at_baseline",
+ base_height=292,
+ viewport_height=800,
+ baseline_viewport_height=800,
+ graph_count=2,
+ max_graph_height=600,
+ expected=292,
+ ),
+ StackedHeightCase(
+ label="holds_base_below_baseline",
+ base_height=292,
+ viewport_height=640,
+ baseline_viewport_height=800,
+ graph_count=2,
+ max_graph_height=600,
+ expected=292,
+ ),
+ StackedHeightCase(
+ label="shares_surplus_equally",
+ base_height=292,
+ viewport_height=1000,
+ baseline_viewport_height=800,
+ graph_count=2,
+ max_graph_height=600,
+ expected=392,
+ ),
+ StackedHeightCase(
+ label="just_below_the_cap",
+ base_height=292,
+ viewport_height=1414,
+ baseline_viewport_height=800,
+ graph_count=2,
+ max_graph_height=600,
+ expected=599,
+ ),
+ StackedHeightCase(
+ label="reaches_the_cap",
+ base_height=292,
+ viewport_height=1416,
+ baseline_viewport_height=800,
+ graph_count=2,
+ max_graph_height=600,
+ expected=600,
+ ),
+ StackedHeightCase(
+ label="holds_the_cap_above_it",
+ base_height=292,
+ viewport_height=2200,
+ baseline_viewport_height=800,
+ graph_count=2,
+ max_graph_height=600,
+ expected=600,
+ ),
+ StackedHeightCase(
+ label="three_graphs_share_surplus",
+ base_height=292,
+ viewport_height=1100,
+ baseline_viewport_height=800,
+ graph_count=3,
+ max_graph_height=600,
+ expected=392,
+ ),
+ StackedHeightCase(
+ label="three_graphs_take_the_same_cap",
+ base_height=292,
+ viewport_height=2200,
+ baseline_viewport_height=800,
+ graph_count=3,
+ max_graph_height=600,
+ expected=600,
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
def test_height_follows_the_surplus_rule(self, case: StackedHeightCase) -> None:
assert (
stacked_graph_height(
@@ -64,28 +107,87 @@ def test_height_follows_the_surplus_rule(self, case: StackedHeightCase) -> None:
case.viewport_height,
case.baseline_viewport_height,
case.graph_count,
- case.max_stack_height,
+ case.max_graph_height,
)
== case.expected
)
@pytest.mark.parametrize("viewport_height", range(600, 3000, 37))
- def test_stays_within_base_and_combined_cap(self, viewport_height: int) -> None:
- """Across the whole viewport range each graph sits at or above its base height and the graphs
- together stay within the combined maximum."""
- graph_count = 2
- max_stack_height = 1200
- height = stacked_graph_height(292, viewport_height, 800, graph_count, max_stack_height)
+ def test_stays_between_the_base_and_the_cap(
+ self,
+ viewport_height: int,
+ ) -> None:
+ """Across the whole viewport range a graph sits at or above its base height and at or below
+ the configured maximum."""
+ max_graph_height = 600
+ height = stacked_graph_height(292, viewport_height, 800, 2, max_graph_height)
assert height >= 292
- assert height * graph_count <= max_stack_height
+ assert height <= max_graph_height
-class TestExpandedSideWidth:
+class TestExpandedSideWidth(BaseTestSuite):
"""``expanded_side_width`` holds a fixed side column at its configured width up to the design
baseline, then grants it one share of the wider viewport's surplus against the stretching centre
column's ``center_weight`` shares."""
- @pytest.mark.parametrize("case", _SIDE_WIDTH_CASES, ids=lambda case: case.label)
+ @dataclass(frozen=True, kw_only=True)
+ class SideWidthCase(BaseRegularTestCase):
+ base_width: int
+ viewport_width: int
+ baseline_viewport_width: int
+ side_panel_count: int
+ center_weight: int
+ expected: int
+
+ test_cases = (
+ SideWidthCase(
+ label="holds_base_at_baseline",
+ base_width=300,
+ viewport_width=1280,
+ baseline_viewport_width=1280,
+ side_panel_count=2,
+ center_weight=2,
+ expected=300,
+ ),
+ SideWidthCase(
+ label="holds_base_below_baseline",
+ base_width=300,
+ viewport_width=1000,
+ baseline_viewport_width=1280,
+ side_panel_count=2,
+ center_weight=2,
+ expected=300,
+ ),
+ SideWidthCase(
+ label="single_side_takes_a_third",
+ base_width=300,
+ viewport_width=1580,
+ baseline_viewport_width=1280,
+ side_panel_count=1,
+ center_weight=2,
+ expected=400,
+ ),
+ SideWidthCase(
+ label="two_sides_split_after_centre",
+ base_width=300,
+ viewport_width=1600,
+ baseline_viewport_width=1280,
+ side_panel_count=2,
+ center_weight=2,
+ expected=380,
+ ),
+ SideWidthCase(
+ label="heavier_centre_narrows_sides",
+ base_width=300,
+ viewport_width=1600,
+ baseline_viewport_width=1280,
+ side_panel_count=2,
+ center_weight=4,
+ expected=353,
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
def test_width_follows_the_surplus_split(self, case: SideWidthCase) -> None:
assert (
expanded_side_width(
diff --git a/tests/unit/sampletones_application/ui/elements/table/test_caret.py b/tests/unit/sampletones_application/ui/elements/table/test_caret.py
index 7bd21cbc..3d493f24 100644
--- a/tests/unit/sampletones_application/ui/elements/table/test_caret.py
+++ b/tests/unit/sampletones_application/ui/elements/table/test_caret.py
@@ -3,7 +3,9 @@
import pytest
+from sampletones_application.layout.general.caret import CaretLayout
from sampletones_application.ui.elements.table.caret import CaretOverlay
+from sampletones_application.utils.palette.colors.written import LiteralColor
ROOT_WINDOW = "global.window.main"
ROOT_ID = 5
@@ -15,16 +17,29 @@
# Parent chains keyed by item id: the tracker's panel sits under the primary window,
# while the dialog is a top-level window outside it.
_PARENTS: Dict[int, Optional[int]] = {PANEL_ID: ROOT_ID, ROOT_ID: None, DIALOG_ID: None}
-_ALIAS_IDS: Dict[str, int] = {ROOT_WINDOW: ROOT_ID, PANEL_WINDOW: PANEL_ID, DIALOG_WINDOW: DIALOG_ID}
+_ALIAS_IDS: Dict[str, int] = {
+ ROOT_WINDOW: ROOT_ID,
+ PANEL_WINDOW: PANEL_ID,
+ DIALOG_WINDOW: DIALOG_ID,
+}
+
+CARET_LAYOUT = CaretLayout(
+ fill=LiteralColor((102, 187, 255, 64)),
+ border=LiteralColor((102, 187, 255, 255)),
+ offset=3,
+ width_padding=2,
+)
@pytest.fixture(autouse=True)
def caret_state() -> Iterator[None]:
CaretOverlay._root_window = ROOT_WINDOW
+ CaretOverlay._layout = CARET_LAYOUT
CaretOverlay._rectangle = 123
CaretOverlay._widget = None
yield
CaretOverlay._root_window = None
+ CaretOverlay._layout = None
CaretOverlay._rectangle = None
CaretOverlay._widget = None
@@ -61,7 +76,10 @@ def test_inactive_when_active_window_was_just_destroyed(self) -> None:
with patch("dearpygui.dearpygui.get_active_window", return_value=stale_id):
with patch("dearpygui.dearpygui.get_alias_id", side_effect=_alias_id):
with patch("dearpygui.dearpygui.does_item_exist", return_value=False):
- with patch("dearpygui.dearpygui.get_item_parent", side_effect=AssertionError("must not walk")):
+ with patch(
+ "dearpygui.dearpygui.get_item_parent",
+ side_effect=AssertionError("must not walk"),
+ ):
assert not CaretOverlay._active_within_root()
diff --git a/tests/unit/sampletones_application/ui/elements/table/test_cells.py b/tests/unit/sampletones_application/ui/elements/table/test_cells.py
index 2a61713c..3553b52d 100644
--- a/tests/unit/sampletones_application/ui/elements/table/test_cells.py
+++ b/tests/unit/sampletones_application/ui/elements/table/test_cells.py
@@ -47,6 +47,27 @@ def test_reconcile_updates_only_changed_registered_cells(self) -> None:
configure.assert_called_once_with(20, label="label-b")
assert cells.values["b"] == "z"
+ def test_a_registered_widget_reads_back_as_its_key(self) -> None:
+ """A cell cache answers from both sides, because a handler reports the widget it fired for."""
+ cells: EditableCells[str] = EditableCells()
+ cells.register("a", 1)
+
+ assert cells.key(1) == "a"
+ assert cells.widget("a") == 1
+
+ def test_a_rebuild_drops_both_directions(self) -> None:
+ cells: EditableCells[str] = EditableCells()
+ cells.register("a", 1)
+ cells.reset({})
+
+ assert cells.key(1) is None
+ assert cells.widget("a") is None
+
+ def test_an_unknown_widget_names_no_cell(self) -> None:
+ cells: EditableCells[str] = EditableCells()
+
+ assert cells.key(1) is None
+
def test_reconcile_caches_value_even_without_a_widget(self) -> None:
cells: EditableCells[str] = EditableCells()
cells.reset({"a": "x"})
diff --git a/tests/unit/sampletones_application/ui/elements/table/test_drag.py b/tests/unit/sampletones_application/ui/elements/table/test_drag.py
new file mode 100644
index 00000000..220075c6
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/table/test_drag.py
@@ -0,0 +1,147 @@
+from typing import Optional, Tuple
+
+import pytest
+
+from sampletones_application.ui.elements.table.cells import EditableCells
+from sampletones_application.ui.elements.table.drag import DragSelection
+from sampletones_application.utils.gui.keyboard.modifiers import Modifier
+
+Key = Tuple[int, int]
+
+ORIGIN_WIDGET = 101
+OTHER_WIDGET = 202
+ORIGIN: Key = (2, 1)
+REACHED: Key = (5, 3)
+
+
+class _Pointer:
+ """Where the pointer stands, which a drag reads off the grid between holds."""
+
+ def __init__(self, cell: Optional[Key]) -> None:
+ self.cell = cell
+
+
+def _hold_modifiers(monkeypatch: pytest.MonkeyPatch, shift: bool) -> None:
+ monkeypatch.setattr(
+ "sampletones_application.ui.elements.table.drag.capture_modifiers",
+ lambda: {Modifier.SHIFT} if shift else set(),
+ )
+
+
+def _drag(
+ monkeypatch: pytest.MonkeyPatch,
+ reached: Optional[Key],
+ shift: bool = False,
+) -> Tuple[DragSelection[Key], _Pointer]:
+ cells: EditableCells[Key] = EditableCells()
+ cells.register(ORIGIN, ORIGIN_WIDGET)
+ pointer = _Pointer(reached)
+ _hold_modifiers(monkeypatch, shift)
+ return (
+ DragSelection(cells=cells, cell_at=lambda: pointer.cell),
+ pointer,
+ )
+
+
+class TestDragReach:
+ """A press grows into a drag only once the pointer has left the cell it landed on."""
+
+ def test_a_press_alone_reaches_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ drag, _ = _drag(monkeypatch, reached=REACHED)
+
+ assert drag.hold(ORIGIN_WIDGET) is None
+
+ def test_a_press_held_on_its_own_cell_stays_a_click(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ drag, _ = _drag(monkeypatch, reached=ORIGIN)
+
+ drag.hold(ORIGIN_WIDGET)
+
+ assert drag.hold(ORIGIN_WIDGET) is None
+
+ def test_a_drag_reports_the_cell_it_grew_from(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ drag, _ = _drag(monkeypatch, reached=REACHED)
+
+ drag.hold(ORIGIN_WIDGET)
+ reach = drag.hold(ORIGIN_WIDGET)
+
+ assert reach is not None
+ assert reach.origin == ORIGIN
+ assert reach.reached == REACHED
+ assert reach.extends is False
+
+ def test_a_shift_press_reports_a_carried_selection(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ drag, _ = _drag(monkeypatch, reached=REACHED, shift=True)
+
+ drag.hold(ORIGIN_WIDGET)
+ reach = drag.hold(ORIGIN_WIDGET)
+
+ assert reach is not None
+ assert reach.extends is True
+
+ def test_a_drag_returning_to_its_origin_reaches_that_cell(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ drag, pointer = _drag(monkeypatch, reached=REACHED)
+
+ drag.hold(ORIGIN_WIDGET)
+ drag.hold(ORIGIN_WIDGET)
+ pointer.cell = ORIGIN
+ reach = drag.hold(ORIGIN_WIDGET)
+
+ assert reach is not None
+ assert reach.reached == ORIGIN
+
+ def test_a_pointer_off_the_grid_reaches_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ drag, _ = _drag(monkeypatch, reached=None)
+
+ drag.hold(ORIGIN_WIDGET)
+
+ assert drag.hold(ORIGIN_WIDGET) is None
+
+ def test_a_press_on_a_cell_the_cache_forgot_starts_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ drag, _ = _drag(monkeypatch, reached=REACHED)
+
+ drag.hold(OTHER_WIDGET)
+
+ assert drag.hold(OTHER_WIDGET) is None
+
+
+class TestDragClick:
+ """The click a drag ends on belongs to the drag; every other click is a gesture of its own."""
+
+ def test_a_click_without_a_press_stands_on_its_own(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ drag, _ = _drag(monkeypatch, reached=REACHED)
+
+ assert drag.claims_click() is False
+
+ def test_a_press_that_never_moved_leaves_its_click_alone(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ drag, _ = _drag(monkeypatch, reached=ORIGIN)
+
+ drag.hold(ORIGIN_WIDGET)
+
+ assert drag.claims_click() is False
+
+ def test_a_drag_takes_the_click_that_ends_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ drag, _ = _drag(monkeypatch, reached=REACHED)
+
+ drag.hold(ORIGIN_WIDGET)
+ drag.hold(ORIGIN_WIDGET)
+
+ assert drag.claims_click() is True
+
+ def test_the_claimed_click_ends_the_gesture(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ drag, _ = _drag(monkeypatch, reached=REACHED)
+
+ drag.hold(ORIGIN_WIDGET)
+ drag.hold(ORIGIN_WIDGET)
+ drag.claims_click()
+
+ assert drag.claims_click() is False
+
+ def test_a_cleared_gesture_starts_afresh(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ drag, _ = _drag(monkeypatch, reached=REACHED)
+
+ drag.hold(ORIGIN_WIDGET)
+ drag.hold(ORIGIN_WIDGET)
+ drag.clear()
+
+ assert drag.claims_click() is False
+ assert drag.hold(ORIGIN_WIDGET) is None
diff --git a/tests/unit/sampletones_application/ui/elements/table/test_selection.py b/tests/unit/sampletones_application/ui/elements/table/test_selection.py
new file mode 100644
index 00000000..6a19fb8a
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/table/test_selection.py
@@ -0,0 +1,184 @@
+from typing import Dict, FrozenSet, List, Optional, Set, Tuple
+
+import pytest
+
+from sampletones_application.ui.elements.table.cells import EditableCells
+from sampletones_application.ui.elements.table.selection import TableSelection
+from sampletones_application.utils.gui.keyboard.modifiers import Modifier
+
+Key = Tuple[int, int]
+
+ORIGIN: Key = (2, 1)
+REACHED: Key = (5, 3)
+FORGOTTEN: Key = (9, 9)
+WIDGETS: Dict[Key, int] = {ORIGIN: 101, REACHED: 202}
+
+
+class _Grid:
+ """A grid stating what its selection covers, and recording what was painted on it."""
+
+ def __init__(self, covered: Set[Key]) -> None:
+ self.covered = covered
+ self.painted: List[Tuple[int, bool]] = []
+ self.cell: Optional[Key] = REACHED
+
+ def covers(self) -> FrozenSet[Key]:
+ return frozenset(self.covered)
+
+
+def _selection(
+ monkeypatch: pytest.MonkeyPatch,
+ covered: Set[Key],
+) -> Tuple[TableSelection[Key], _Grid]:
+ cells: EditableCells[Key] = EditableCells()
+ for key, widget in WIDGETS.items():
+ cells.register(key, widget)
+
+ grid = _Grid(covered)
+ monkeypatch.setattr(
+ "sampletones_application.ui.elements.table.selection.dpg.set_value",
+ lambda widget, value: grid.painted.append((widget, value)),
+ )
+ monkeypatch.setattr(
+ "sampletones_application.ui.elements.table.drag.capture_modifiers",
+ lambda: set(),
+ )
+ return (
+ TableSelection(cells=cells, cell_at=lambda: grid.cell, covered=grid.covers),
+ grid,
+ )
+
+
+def _drag_out(selection: TableSelection[Key], widget: int) -> None:
+ """Carries a press out to another cell, which is what turns it into a drag."""
+ selection.hold(widget)
+ selection.hold(widget)
+
+
+class TestRepaint:
+ """A repaint reaches the cells whose membership changed, and leaves the rest standing."""
+
+ def test_the_cells_now_covered_are_marked(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ selection, grid = _selection(monkeypatch, covered={ORIGIN})
+
+ selection.repaint()
+
+ assert grid.painted == [(WIDGETS[ORIGIN], True)]
+
+ def test_a_cell_the_selection_has_left_is_released(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ selection, grid = _selection(monkeypatch, covered={ORIGIN})
+ selection.repaint()
+ grid.painted.clear()
+
+ grid.covered = {REACHED}
+ selection.repaint()
+
+ assert sorted(grid.painted) == [(WIDGETS[ORIGIN], False), (WIDGETS[REACHED], True)]
+
+ def test_a_cell_standing_as_it_was_is_left_alone(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ selection, grid = _selection(monkeypatch, covered={ORIGIN})
+ selection.repaint()
+ grid.painted.clear()
+
+ selection.repaint()
+
+ assert grid.painted == []
+
+ def test_a_cell_the_cache_forgot_is_passed_over(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A region names cells of the grid, so a repaint reaches those the cache holds a widget for."""
+ selection, grid = _selection(monkeypatch, covered={FORGOTTEN})
+
+ selection.repaint()
+
+ assert grid.painted == []
+
+
+class TestClick:
+ """A click releases the cell DearPyGui toggled, and a drag takes the click that ends it."""
+
+ def test_a_click_releases_the_selectable(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ selection, grid = _selection(monkeypatch, covered=set())
+
+ claimed = selection.claims_click(WIDGETS[ORIGIN], ORIGIN)
+
+ assert claimed is False
+ assert grid.painted == [(WIDGETS[ORIGIN], False)]
+
+ def test_a_clicked_cell_the_selection_covers_is_marked_again(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The click leaves the cell released, so the repaint after it states the membership again."""
+ selection, grid = _selection(monkeypatch, covered={ORIGIN})
+ selection.repaint()
+ grid.painted.clear()
+
+ selection.claims_click(WIDGETS[ORIGIN], ORIGIN)
+ selection.repaint()
+
+ assert grid.painted == [(WIDGETS[ORIGIN], False), (WIDGETS[ORIGIN], True)]
+
+ def test_a_drag_takes_the_click_that_ends_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ selection, grid = _selection(monkeypatch, covered={ORIGIN})
+ _drag_out(selection, WIDGETS[ORIGIN])
+ grid.painted.clear()
+
+ claimed = selection.claims_click(WIDGETS[ORIGIN], ORIGIN)
+
+ assert claimed is True
+ assert grid.painted == [(WIDGETS[ORIGIN], False), (WIDGETS[ORIGIN], True)]
+
+ def test_a_second_click_stands_on_its_own(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The drag ends with the click it was claimed by, so the click after it places a cursor."""
+ selection, _ = _selection(monkeypatch, covered={ORIGIN})
+ _drag_out(selection, WIDGETS[ORIGIN])
+ selection.claims_click(WIDGETS[ORIGIN], ORIGIN)
+
+ assert selection.claims_click(WIDGETS[ORIGIN], ORIGIN) is False
+
+
+class TestGestureAndReset:
+ """The gesture in hand and the selection painted are dropped by different callers."""
+
+ def test_dropping_the_gesture_leaves_the_selection_painted(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ selection, grid = _selection(monkeypatch, covered={ORIGIN})
+ _drag_out(selection, WIDGETS[ORIGIN])
+ selection.repaint()
+ grid.painted.clear()
+
+ selection.drop_gesture()
+ selection.repaint()
+
+ assert selection.claims_click(WIDGETS[ORIGIN], ORIGIN) is False
+ assert grid.painted == [(WIDGETS[ORIGIN], False)]
+
+ def test_a_reset_forgets_what_stood_painted(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A rebuilt table holds cells of its own, so the selection is marked onto them afresh."""
+ selection, grid = _selection(monkeypatch, covered={ORIGIN})
+ selection.repaint()
+ grid.painted.clear()
+
+ selection.reset()
+ selection.repaint()
+
+ assert grid.painted == [(WIDGETS[ORIGIN], True)]
+
+ def test_a_reset_drops_the_gesture_in_hand(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ selection, _ = _selection(monkeypatch, covered=set())
+ _drag_out(selection, WIDGETS[ORIGIN])
+
+ selection.reset()
+
+ assert selection.claims_click(WIDGETS[ORIGIN], ORIGIN) is False
+
+
+def test_a_shift_press_carries_the_selection_out(monkeypatch: pytest.MonkeyPatch) -> None:
+ """The reach a hold reports is the drag's own, which the grid turns into its selection."""
+ selection, grid = _selection(monkeypatch, covered=set())
+ monkeypatch.setattr(
+ "sampletones_application.ui.elements.table.drag.capture_modifiers",
+ lambda: {Modifier.SHIFT},
+ )
+
+ selection.hold(WIDGETS[ORIGIN])
+ reach = selection.hold(WIDGETS[ORIGIN])
+
+ assert reach is not None
+ assert (reach.origin, reach.reached, reach.extends) == (ORIGIN, grid.cell, True)
diff --git a/tests/unit/sampletones_application/ui/elements/test_button.py b/tests/unit/sampletones_application/ui/elements/test_button.py
index db7556ce..69833bc7 100644
--- a/tests/unit/sampletones_application/ui/elements/test_button.py
+++ b/tests/unit/sampletones_application/ui/elements/test_button.py
@@ -39,7 +39,10 @@ def test_enabling_reaches_the_group_and_the_button(
) -> None:
_button().set_enabled(True)
- assert configured == [(GROUP_TAG, {"enabled": True}), (INNER_TAG, {"enabled": True})]
+ assert configured == [
+ (GROUP_TAG, {"enabled": True}),
+ (INNER_TAG, {"enabled": True}),
+ ]
def test_disabling_reaches_the_group_and_the_button(
self,
@@ -47,7 +50,10 @@ def test_disabling_reaches_the_group_and_the_button(
) -> None:
_button().set_enabled(False)
- assert configured == [(GROUP_TAG, {"enabled": False}), (INNER_TAG, {"enabled": False})]
+ assert configured == [
+ (GROUP_TAG, {"enabled": False}),
+ (INNER_TAG, {"enabled": False}),
+ ]
def test_configure_item_applies_the_enabled_state_to_both(
self,
diff --git a/tests/unit/sampletones_application/ui/elements/test_path.py b/tests/unit/sampletones_application/ui/elements/test_path.py
new file mode 100644
index 00000000..0d4c5d4e
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/test_path.py
@@ -0,0 +1,103 @@
+from pathlib import Path
+from typing import List
+
+import pytest
+
+from sampletones_application.ui.elements import path as path_module
+from sampletones_application.ui.elements.path import GUIDestinationPathText, GUIPathText
+
+FILENAME = "chiptune.wav"
+
+
+def _path_text(path: Path) -> GUIPathText:
+ """A path text carrying only the path a click reads, bypassing the DearPyGui-dependent
+ constructor."""
+ instance = GUIPathText.__new__(GUIPathText)
+ instance.path = path
+ return instance
+
+
+def _destination_text(path: Path) -> GUIDestinationPathText:
+ instance = GUIDestinationPathText.__new__(GUIDestinationPathText)
+ instance.path = path
+ return instance
+
+
+@pytest.fixture
+def opened(monkeypatch: pytest.MonkeyPatch) -> List[Path]:
+ revealed: List[Path] = []
+ monkeypatch.setattr(path_module, "open_path_in_explorer", revealed.append)
+ return revealed
+
+
+class TestAPathThatStands:
+ """A path text shows what it points at, so a click reaches the file itself."""
+
+ def test_an_existing_file_is_revealed(self, tmp_path: Path, opened: List[Path]) -> None:
+ filepath = tmp_path / FILENAME
+ filepath.touch()
+
+ _path_text(filepath)._on_clicked()
+
+ assert opened == [filepath]
+
+ def test_a_file_yet_to_be_written_reveals_nothing(
+ self,
+ tmp_path: Path,
+ opened: List[Path],
+ ) -> None:
+ _path_text(tmp_path / FILENAME)._on_clicked()
+
+ assert not opened
+
+
+class TestADestination:
+ """A destination names what an operation will leave behind, so a click reaches the nearest place
+ that stands however far the operation has got."""
+
+ def test_the_directory_a_file_is_written_into_is_revealed(
+ self,
+ tmp_path: Path,
+ opened: List[Path],
+ ) -> None:
+ _destination_text(tmp_path / FILENAME)._on_clicked()
+
+ assert opened == [tmp_path]
+
+ def test_a_written_file_is_revealed_itself(
+ self,
+ tmp_path: Path,
+ opened: List[Path],
+ ) -> None:
+ filepath = tmp_path / FILENAME
+ filepath.touch()
+
+ _destination_text(filepath)._on_clicked()
+
+ assert opened == [filepath]
+
+ def test_a_written_directory_is_revealed_itself(
+ self,
+ tmp_path: Path,
+ opened: List[Path],
+ ) -> None:
+ directory = tmp_path / "renders"
+ directory.mkdir()
+
+ _destination_text(directory)._on_clicked()
+
+ assert opened == [directory]
+
+ def test_a_directory_yet_to_be_created_falls_back_to_the_one_holding_it(
+ self,
+ tmp_path: Path,
+ opened: List[Path],
+ ) -> None:
+ _destination_text(tmp_path / "renders" / "session" / FILENAME)._on_clicked()
+
+ assert opened == [tmp_path]
+
+ def test_an_empty_path_reveals_nothing(self, opened: List[Path]) -> None:
+ _destination_text(Path())._on_clicked()
+
+ assert not opened
diff --git a/tests/unit/sampletones_application/ui/elements/test_pitch_stepper.py b/tests/unit/sampletones_application/ui/elements/test_pitch_stepper.py
index 23d8cf79..db9b27f8 100644
--- a/tests/unit/sampletones_application/ui/elements/test_pitch_stepper.py
+++ b/tests/unit/sampletones_application/ui/elements/test_pitch_stepper.py
@@ -7,7 +7,11 @@
from sampletones_application.ui.elements.pitch_stepper import GUIPitchStepper
from sampletones_application.utils.callbacks.queue import CallbackQueue
from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PITCH
-from sampletones_core.utils.pitch_kind import PERIOD_VALUE_KIND, PITCH_VALUE_KIND, PitchValueKind
+from sampletones_core.utils.pitch_kind import (
+ PERIOD_VALUE_KIND,
+ PITCH_VALUE_KIND,
+ PitchValueKind,
+)
LAYOUT = PitchStepperLayout(
label_width=160,
diff --git a/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py b/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py
index b8983a99..4eb1a879 100644
--- a/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py
+++ b/tests/unit/sampletones_application/ui/elements/test_plus_minus_buttons.py
@@ -1,6 +1,8 @@
from typing import List
-from sampletones_application.layout.general.plus_minus_buttons import PlusMinusButtonsLayout
+from sampletones_application.layout.general.plus_minus_buttons import (
+ PlusMinusButtonsLayout,
+)
from sampletones_application.ui.elements.plus_minus_buttons import GUIPlusMinusButtons
LAYOUT = PlusMinusButtonsLayout(
diff --git a/tests/unit/sampletones_application/ui/elements/test_texture.py b/tests/unit/sampletones_application/ui/elements/test_texture.py
new file mode 100644
index 00000000..6d8c9efa
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/test_texture.py
@@ -0,0 +1,41 @@
+from typing import Iterator
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.tags.general import TAG_GLOBAL_TEXTURE_LOGO
+from sampletones_application.ui.elements.texture import TextureRegistry
+
+MARK_SIZE = 256
+
+
+@pytest.fixture
+def context() -> Iterator[None]:
+ """A DearPyGui context, textures being framework items rather than plain data."""
+ dpg.create_context()
+ try:
+ yield
+ finally:
+ dpg.destroy_context()
+
+
+class TestTheImagesTheInterfaceDraws:
+ def test_the_mark_is_read_into_a_texture_named_by_its_tag(self, context: None) -> None:
+ TextureRegistry.register_textures()
+
+ assert dpg.does_item_exist(TAG_GLOBAL_TEXTURE_LOGO)
+
+ def test_the_texture_carries_the_shipped_image_at_its_own_size(self, context: None) -> None:
+ """The image is read as it ships, and whatever draws it states the size it wants."""
+ TextureRegistry.register_textures()
+
+ configuration = dpg.get_item_configuration(TAG_GLOBAL_TEXTURE_LOGO)
+ assert (configuration["width"], configuration["height"]) == (MARK_SIZE, MARK_SIZE)
+
+ def test_the_texture_is_there_to_be_drawn(self, context: None) -> None:
+ TextureRegistry.register_textures()
+
+ with dpg.window():
+ image = dpg.add_image(TAG_GLOBAL_TEXTURE_LOGO, width=72, height=72)
+
+ assert dpg.get_item_type(image) == "mvAppItemType::mvImage"
diff --git a/tests/unit/sampletones_application/ui/elements/test_window.py b/tests/unit/sampletones_application/ui/elements/test_window.py
new file mode 100644
index 00000000..12757086
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/test_window.py
@@ -0,0 +1,118 @@
+from typing import Any, Final, Iterator, Optional
+from unittest.mock import MagicMock, patch
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.ui.elements.window import GUIWindow
+from sampletones_shared.types.callback import VoidCallback
+
+MODULE: Final[str] = "sampletones_application.ui.elements.window"
+TAG: Final[str] = "test.dialog.window.probe"
+STATED_WIDTH: Final[int] = 460
+CONTENT_HEIGHT: Final[int] = 0
+
+
+class ProbeWindow(GUIWindow):
+ """A dialog whose content stretches across the window, the shape a stated width has to hold."""
+
+ def __init__(self, on_close: Optional[VoidCallback]) -> None:
+ self._on_close = on_close
+ super().__init__(
+ tag=TAG,
+ width=STATED_WIDTH,
+ height=CONTENT_HEIGHT,
+ )
+
+ def prepare(self, *_args: Any, **_kwargs: Any) -> None:
+ """The probe carries no state to seed."""
+
+ def create_window(self) -> None:
+ with self.dialog_window(
+ label="probe",
+ on_close=self._on_close,
+ ):
+ dpg.add_combo(items=["a", "b"], width=-1)
+
+
+@pytest.fixture(name="dpg_context")
+def dpg_context_fixture() -> Iterator[None]:
+ dpg.create_context()
+ try:
+ yield
+ finally:
+ dpg.destroy_context()
+
+
+class TestDialogGeometry:
+ def test_the_window_holds_the_width_it_states(self, dpg_context: None) -> None:
+ ProbeWindow(on_close=None).create_window()
+
+ assert dpg.get_item_configuration(TAG)["width"] == STATED_WIDTH
+
+ def test_the_window_takes_no_size_from_its_content(self, dpg_context: None) -> None:
+ """A window measuring itself against stretched content loses a pixel of width every frame."""
+ ProbeWindow(on_close=None).create_window()
+
+ assert dpg.get_item_configuration(TAG)["autosize"] is False
+
+
+class TestCloseAffordance:
+ def test_a_dialog_answering_for_its_close_offers_the_button(self, dpg_context: None) -> None:
+ ProbeWindow(on_close=lambda: None).create_window()
+
+ assert dpg.get_item_configuration(TAG)["no_close"] is False
+
+ def test_a_dialog_answering_for_no_close_omits_the_button(self, dpg_context: None) -> None:
+ ProbeWindow(on_close=None).create_window()
+
+ assert dpg.get_item_configuration(TAG)["no_close"] is True
+
+
+class TestModalHandOff:
+ """DearPyGui carries one modal at a time, so a dialog raising another has to step aside first."""
+
+ def test_yielding_takes_the_window_off_screen(self, dpg_context: None) -> None:
+ window = ProbeWindow(on_close=None)
+ window.create_window()
+
+ with patch(f"{MODULE}.FrameCallbackManager"):
+ window.yield_to(MagicMock())
+
+ assert dpg.get_item_configuration(TAG)["show"] is False
+
+ def test_the_modal_is_raised_a_frame_after_the_hand_off(self, dpg_context: None) -> None:
+ """A modal built while this window still holds the screen opens where nobody can reach it."""
+ window = ProbeWindow(on_close=None)
+ window.create_window()
+ raise_modal = MagicMock()
+
+ with patch(f"{MODULE}.FrameCallbackManager") as frame:
+ window.yield_to(raise_modal)
+
+ raise_modal.assert_not_called()
+ frame.set_frame_callback.assert_called_once_with(raise_modal)
+
+ def test_resuming_waits_a_frame_before_taking_the_screen_back(self, dpg_context: None) -> None:
+ window = ProbeWindow(on_close=None)
+ window.create_window()
+ with patch(f"{MODULE}.FrameCallbackManager"):
+ window.yield_to(MagicMock())
+
+ with patch(f"{MODULE}.FrameCallbackManager") as frame:
+ window.resume()
+
+ assert dpg.get_item_configuration(TAG)["show"] is False
+ frame.set_frame_callback.assert_called_once()
+ frame.set_frame_callback.call_args.args[0]()
+ assert dpg.get_item_configuration(TAG)["show"] is True
+
+ def test_the_widget_tree_survives_the_hand_off(self, dpg_context: None) -> None:
+ """Whatever is being edited has to still be there when the dialog comes back."""
+ window = ProbeWindow(on_close=None)
+ window.create_window()
+
+ with patch(f"{MODULE}.FrameCallbackManager"):
+ window.yield_to(MagicMock())
+
+ assert dpg.get_item_children(TAG, 1)
diff --git a/tests/unit/sampletones_application/ui/elements/tree/__init__.py b/tests/unit/sampletones_application/ui/elements/tree/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/ui/elements/tree/conftest.py b/tests/unit/sampletones_application/ui/elements/tree/conftest.py
new file mode 100644
index 00000000..79551b95
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/tree/conftest.py
@@ -0,0 +1,11 @@
+from pathlib import Path
+
+import pytest
+
+from tests.suite.browser import BrowserCorpus, build_corpus
+
+
+@pytest.fixture
+def corpus(tmp_path: Path) -> BrowserCorpus:
+ """The reconstructions directory the browser tests read, as both views shape it."""
+ return build_corpus(tmp_path)
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py b/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py
new file mode 100644
index 00000000..b483a703
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py
@@ -0,0 +1,15 @@
+from tests.suite.browser import WHOLE_TREE, BrowserCorpus, view
+
+
+class TestWholeTree:
+ """What a reconstructions directory reads as with nothing to narrow it, in both views.
+
+ The corpus states what the browser has to tell apart, and this is the shape it gives it: two
+ configurations differing by hash alone marked with that hash, a frequency holding two methods
+ beside one whose whole chain folded into a single row, an audio gathering the configurations
+ that reconstructed it, a sample of one variant folded into that variant, a configuration
+ directory nested in a plain folder, and a reconstruction outside every configuration directory.
+ """
+
+ def test_the_whole_tree_is_drawn_with_every_row_folded(self, corpus: BrowserCorpus) -> None:
+ assert view(corpus, set(), favorites_only=False) == WHOLE_TREE
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py b/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py
new file mode 100644
index 00000000..31d88e95
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py
@@ -0,0 +1,74 @@
+from typing import List, Tuple
+
+import pytest
+
+from sampletones_application.ui.elements.tree import tree as tree_module
+from sampletones_core.structures.tree import NodeType
+from tests.suite.browser import (
+ WHOLE_TREE,
+ BrowserCorpus,
+ build_browser_panel,
+ render_view,
+ row_named,
+ set_row_expanded,
+)
+
+
+@pytest.fixture
+def folded(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]:
+ """Records the tag and open state of every row the control reaches."""
+ calls: List[Tuple[str, bool]] = []
+ monkeypatch.setattr(
+ tree_module,
+ "dpg_set_value",
+ lambda tag, value: calls.append((tag, value)),
+ )
+ return calls
+
+
+class TestCollapseAllControl:
+ """The control folds the whole tree away, and the browser is left holding that shape."""
+
+ def test_every_row_holding_something_is_folded(
+ self,
+ corpus: BrowserCorpus,
+ folded: List[Tuple[str, bool]],
+ ) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+
+ panel._on_collapse_all_clicked()
+
+ containers = {panel._generate_node_tag(node) for node in corpus.tree.get_root().descendants if node.children}
+ assert {tag for tag, _ in folded} == containers
+ assert all(not expanded for _, expanded in folded)
+
+ def test_a_row_holding_nothing_is_left_alone(
+ self,
+ corpus: BrowserCorpus,
+ folded: List[Tuple[str, bool]],
+ ) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+
+ panel._on_collapse_all_clicked()
+
+ leaves = {
+ panel._generate_node_tag(node)
+ for node in corpus.tree.get_root().descendants
+ if node.node_type == NodeType.FILE
+ }
+ assert not leaves & {tag for tag, _ in folded}
+
+ def test_the_shape_the_control_left_is_what_the_next_pass_draws(
+ self,
+ corpus: BrowserCorpus,
+ folded: List[Tuple[str, bool]],
+ ) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+ set_row_expanded(panel, row_named(corpus, "archive"), expanded=True)
+ set_row_expanded(panel, row_named(corpus, "takes"), expanded=True)
+ render_view(panel)
+
+ panel._on_collapse_all_clicked()
+
+ assert panel.expanded_rows == set()
+ assert render_view(panel) == WHOLE_TREE
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py
new file mode 100644
index 00000000..1c3c57ff
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py
@@ -0,0 +1,107 @@
+from pathlib import Path
+from typing import Final, List
+
+import pytest
+
+from sampletones_application.ui.elements.fonts.font import Font
+from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
+from sampletones_core.configs import Config
+from sampletones_core.configs.display import format_sample_rate, short_hash
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+from sampletones_core.structures.tree.node import ConfigNode, FileSystemNode, TreeNode
+from sampletones_core.structures.tree.type import NodeType
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
+from tests.suite.language import FakeLanguageManager
+
+CONFIG_FIELDS: Final[ConfigDirectoryFields] = ConfigDirectoryFields.from_config(Config())
+CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions") / CONFIG_FIELDS.directory_name
+RECONSTRUCTION_PATH: Final[Path] = CONFIG_DIRECTORY / f"song{EXT_FILE_RECONSTRUCTION}"
+
+DETAIL_LABELS: Final[List[str]] = [
+ "sample_rate",
+ "nes_frequency",
+ "spectrum_method",
+ "transformation_gamma",
+ "window_size",
+ "generators",
+ "configuration",
+]
+
+
+@pytest.fixture
+def panel() -> GUISequencerBrowserPanel:
+ """Builds a browser panel without its DearPyGui-dependent constructor.
+
+ Resolving a node's detail items reads only the language-resolved detail labels, so the pieces
+ the constructor would build around a running GUI context are unnecessary here. A concrete
+ browser stands in for the base because the configuration font is a browser-level opt-in.
+ """
+ instance = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel)
+ instance._language_manager = FakeLanguageManager()
+ for label in DETAIL_LABELS:
+ setattr(instance, f"_lbl_detail_{label}", label)
+
+ return instance
+
+
+def config_directory_node() -> ConfigNode:
+ return ConfigNode(
+ CONFIG_FIELDS.gn,
+ node_type=NodeType.DIRECTORY,
+ filepath=CONFIG_DIRECTORY,
+ config=CONFIG_FIELDS,
+ )
+
+
+def config_variant_node() -> ConfigNode:
+ return ConfigNode(
+ CONFIG_FIELDS.display_name,
+ node_type=NodeType.FILE,
+ filepath=RECONSTRUCTION_PATH,
+ config=CONFIG_FIELDS,
+ )
+
+
+class TestConfigDetailItems:
+ def test_config_directory_states_its_configuration(
+ self,
+ panel: GUISequencerBrowserPanel,
+ ) -> None:
+ items = dict(panel._node_detail_items(config_directory_node()))
+ assert items["sample_rate"] == format_sample_rate(CONFIG_FIELDS.sr)
+ assert items["configuration"] == short_hash(CONFIG_FIELDS.ch)
+
+ def test_config_variant_leaf_states_the_same_configuration(
+ self,
+ panel: GUISequencerBrowserPanel,
+ ) -> None:
+ """A reconstruction listed by its configuration answers with that configuration.
+
+ In the sample view a leaf carries the configuration its directory names, which its own
+ filename says nothing about.
+ """
+ assert panel._node_detail_items(config_variant_node()) == panel._node_detail_items(config_directory_node())
+
+ def test_config_variant_leaf_reads_in_the_configuration_font(
+ self,
+ panel: GUISequencerBrowserPanel,
+ ) -> None:
+ assert panel._resolve_node_name_font(config_variant_node()) == Font.MONO_SMALL
+
+ def test_plain_directory_states_nothing(
+ self,
+ panel: GUISequencerBrowserPanel,
+ ) -> None:
+ node = FileSystemNode(
+ "my_songs",
+ node_type=NodeType.DIRECTORY,
+ filepath=Path("/reconstructions/my_songs"),
+ )
+ assert panel._node_detail_items(node) == []
+ assert panel._resolve_node_name_font(node) == Font.REGULAR_SMALL
+
+ def test_group_states_nothing(
+ self,
+ panel: GUISequencerBrowserPanel,
+ ) -> None:
+ assert panel._node_detail_items(TreeNode("Samples", NodeType.GROUP)) == []
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion.py
new file mode 100644
index 00000000..08eecd8a
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion.py
@@ -0,0 +1,116 @@
+from typing import Set
+
+import pytest
+
+from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory
+
+READER_ROW: str = "panel.node_reader"
+MODE_ROW: str = "panel.node_mode"
+WAY_DOWN: str = "panel.node_way_down"
+
+
+@pytest.fixture
+def memory() -> RowExpansionMemory:
+ return RowExpansionMemory(set())
+
+
+class TestTheRowsAMemoryHolds:
+ def test_a_memory_opens_holding_the_rows_a_session_left(self) -> None:
+ memory = RowExpansionMemory({READER_ROW})
+
+ assert memory.stands_open(READER_ROW)
+ assert memory.rows == {READER_ROW}
+
+ def test_a_row_no_hand_opened_stands_closed(self, memory: RowExpansionMemory) -> None:
+ assert not memory.stands_open(READER_ROW)
+ assert not memory
+
+ def test_the_rows_a_save_writes_are_the_readers_alone(self, memory: RowExpansionMemory) -> None:
+ """A save writes the shape the reader built, the mode's way down being its own to hold."""
+ memory.remember(READER_ROW, expanded=True)
+ memory.follow({MODE_ROW})
+
+ assert memory.rows == {READER_ROW}
+ assert memory.stands_open(MODE_ROW)
+
+ def test_the_rows_a_save_reads_are_taken_apart_from_the_memory(self, memory: RowExpansionMemory) -> None:
+ memory.remember(READER_ROW, expanded=True)
+ rows: Set[str] = memory.rows
+
+ rows.add(MODE_ROW)
+
+ assert memory.rows == {READER_ROW}
+
+
+class TestFollowingTheReader:
+ def test_a_row_the_reader_opens_is_theirs(self, memory: RowExpansionMemory) -> None:
+ memory.remember(READER_ROW, expanded=True)
+
+ assert memory.rows == {READER_ROW}
+
+ def test_a_row_the_reader_folds_lets_go_of_the_modes_claim(self, memory: RowExpansionMemory) -> None:
+ """A fold is the reader's word on a row whichever hand opened it, so the row stays folded."""
+ memory.follow({MODE_ROW})
+
+ memory.remember(MODE_ROW, expanded=False)
+
+ assert not memory.stands_open(MODE_ROW)
+
+ def test_a_row_the_reader_folds_leaves_the_shape_a_save_writes(self, memory: RowExpansionMemory) -> None:
+ memory.remember(READER_ROW, expanded=True)
+
+ memory.remember(READER_ROW, expanded=False)
+
+ assert memory.rows == set()
+
+
+class TestTheWayDownTheModeOpens:
+ def test_the_way_down_stands_open_while_the_mode_does(self, memory: RowExpansionMemory) -> None:
+ memory.follow({WAY_DOWN, MODE_ROW})
+
+ assert memory.follows_the_mode
+ assert memory.stands_open(WAY_DOWN)
+
+ def test_a_release_folds_the_rows_the_mode_opened(self, memory: RowExpansionMemory) -> None:
+ memory.follow({WAY_DOWN, MODE_ROW})
+
+ memory.release(set())
+
+ assert not memory.follows_the_mode
+ assert not memory.stands_open(WAY_DOWN)
+
+ def test_a_release_keeps_the_rows_the_readers_own_stand_on(self, memory: RowExpansionMemory) -> None:
+ """A row of the mode's holding one of the reader's below it becomes theirs to keep."""
+ memory.follow({WAY_DOWN, MODE_ROW})
+
+ memory.release({WAY_DOWN})
+
+ assert memory.rows == {WAY_DOWN}
+ assert not memory.stands_open(MODE_ROW)
+
+ def test_a_release_answers_for_the_rows_the_mode_opened_alone(self, memory: RowExpansionMemory) -> None:
+ """The ways down a release is handed are read off the model, and a row no hand opened stays shut."""
+ memory.follow({MODE_ROW})
+
+ memory.release({WAY_DOWN})
+
+ assert memory.rows == set()
+ assert not memory.stands_open(WAY_DOWN)
+
+
+class TestTheRowsTheModelStates:
+ def test_a_row_the_model_dropped_leaves_both_memories(self, memory: RowExpansionMemory) -> None:
+ memory.remember(READER_ROW, expanded=True)
+ memory.follow({MODE_ROW})
+
+ memory.hold_to({READER_ROW})
+
+ assert memory.rows == {READER_ROW}
+ assert not memory.stands_open(MODE_ROW)
+
+ def test_holding_to_nothing_empties_the_memory(self, memory: RowExpansionMemory) -> None:
+ memory.remember(READER_ROW, expanded=True)
+
+ memory.hold_to(set())
+
+ assert not memory
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py
new file mode 100644
index 00000000..5b133df6
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py
@@ -0,0 +1,431 @@
+from pathlib import Path
+from typing import Any, Dict, Final, List, Tuple
+
+import pytest
+
+from sampletones_application.ui.elements.tree import tree as tree_module
+from tests.suite.browser import (
+ WHOLE_TREE,
+ BrowserCorpus,
+ as_view,
+ build_browser_panel,
+ build_corpus,
+ click_favorites,
+ deselect_favorites,
+ nodes_at,
+ render_view,
+ resolve_pass,
+ row_named,
+ select_favorites,
+ set_row_expanded,
+)
+
+STARRED_CONFIGURATION: Final[str] = as_view("""
+ > By configuration
+ > 44.1 kHz·30 Hz
+ > FFT·γ0
+ > PT
+ > takes
+ - alt
+ - beat
+ > By sample
+ > beat
+ - 44.1 kHz·30 Hz·FFT·γ0·PT
+ - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT
+ """)
+SUBFOLDER_THE_READER_OPENED: Final[str] = as_view("""
+ > By configuration
+ > 44.1 kHz·30 Hz
+ > FFT·γ0
+ > PT
+ v takes
+ - alt
+ - beat
+ > By sample
+ > beat
+ - 44.1 kHz·30 Hz·FFT·γ0·PT
+ - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT
+ """)
+THE_WAY_DOWN_TO_THE_READERS_ROW: Final[str] = as_view("""
+ v By configuration
+ > 8 kHz·60 Hz·CQT·γ2·P
+ - sweep
+ v 44.1 kHz·30 Hz
+ > CQT·γ0·PTN
+ - beat
+ - solo
+ v FFT·γ0
+ > PT
+ > takes
+ - alt
+ - beat
+ > PTN·#aaaaaaa
+ > drums
+ - kick
+ - snare
+ - beat
+ - melody
+ v PTN·#bbbbbbb
+ > drums
+ - kick
+ - beat
+ - melody
+ > archive
+ > 48 kHz·50 Hz·LogFFT·γ1·TN
+ - song
+ - stray
+ > By sample
+ > beat
+ - 44.1 kHz·30 Hz·CQT·γ0·PTN
+ - 44.1 kHz·30 Hz·FFT·γ0·PT
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ > drums
+ > kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ - snare·44.1 kHz·30 Hz·FFT·γ0·PTN
+ > melody
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ - solo·44.1 kHz·30 Hz·CQT·γ0·PTN
+ - sweep·8 kHz·60 Hz·CQT·γ2·P
+ - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT
+ """)
+WHOLE_TREE_WITHOUT_THE_ARCHIVE: Final[str] = as_view("""
+ > By configuration
+ > 8 kHz·60 Hz·CQT·γ2·P
+ - sweep
+ > 44.1 kHz·30 Hz
+ > CQT·γ0·PTN
+ - beat
+ - solo
+ > FFT·γ0
+ > PT
+ > takes
+ - alt
+ - beat
+ > PTN·#aaaaaaa
+ > drums
+ - kick
+ - snare
+ - beat
+ - melody
+ > PTN·#bbbbbbb
+ > drums
+ - kick
+ - beat
+ - melody
+ - stray
+ > By sample
+ > beat
+ - 44.1 kHz·30 Hz·CQT·γ0·PTN
+ - 44.1 kHz·30 Hz·FFT·γ0·PT
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ > drums
+ > kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ - snare·44.1 kHz·30 Hz·FFT·γ0·PTN
+ > melody
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ - solo·44.1 kHz·30 Hz·CQT·γ0·PTN
+ - sweep·8 kHz·60 Hz·CQT·γ2·P
+ - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT
+ """)
+
+
+class TestTheReadersShape:
+ """A row stands where the reader left it, and a later pass brings it back that way."""
+
+ def test_a_row_the_reader_opened_is_drawn_open(self, corpus: BrowserCorpus) -> None:
+ panel = build_browser_panel(corpus, {corpus.paths["C"]}, favorites_only=True)
+ assert render_view(panel) == STARRED_CONFIGURATION
+
+ set_row_expanded(panel, row_named(corpus, "takes"), expanded=True)
+
+ assert render_view(panel) == SUBFOLDER_THE_READER_OPENED
+
+ def test_a_row_the_reader_closed_is_drawn_closed(self, corpus: BrowserCorpus) -> None:
+ panel = build_browser_panel(corpus, {corpus.paths["C"]}, favorites_only=True)
+ set_row_expanded(panel, row_named(corpus, "takes"), expanded=True)
+ render_view(panel)
+
+ set_row_expanded(panel, row_named(corpus, "takes"), expanded=False)
+
+ assert render_view(panel) == STARRED_CONFIGURATION
+
+ def test_the_rows_the_mode_opened_fold_back_once_it_goes_off(self, corpus: BrowserCorpus) -> None:
+ """What the browser unfolded to show a favorite is the mode's, so the shape is left untouched."""
+ panel = build_browser_panel(
+ corpus,
+ {corpus.paths["A/beat"]},
+ favorites_only=False,
+ auto_expand_reconstructions=True,
+ )
+ select_favorites(panel)
+ render_view(panel)
+
+ deselect_favorites(panel)
+
+ assert render_view(panel) == WHOLE_TREE
+
+ def test_the_pass_that_follows_the_click_is_what_hands_the_modes_rows_back(
+ self,
+ corpus: BrowserCorpus,
+ ) -> None:
+ """The rows the mode opened are a pass's to hand back, which a click alone leaves standing.
+
+ A click landing while the tree is locked starts no pass, so the rows stand as the mode left
+ them and whichever pass runs next folds them.
+ """
+ panel = build_browser_panel(
+ corpus,
+ {corpus.paths["A/beat"]},
+ favorites_only=False,
+ auto_expand_reconstructions=True,
+ )
+ select_favorites(panel)
+ view_the_mode_left = render_view(panel)
+
+ click_favorites(panel, favorites_only=False)
+
+ assert render_view(panel) == view_the_mode_left
+
+ resolve_pass(panel)
+
+ assert render_view(panel) == WHOLE_TREE
+
+ def test_the_way_down_to_a_row_the_reader_opened_stands_once_the_mode_goes_off(
+ self,
+ corpus: BrowserCorpus,
+ ) -> None:
+ """A row of the reader's below one the mode opened makes that row part of the view they built.
+
+ Handing back a row the reader's own stands on would take theirs off the screen with it, so the
+ way down to it stays open while the rows holding nothing of theirs fold.
+ """
+ panel = build_browser_panel(
+ corpus,
+ {corpus.paths["A/beat"]},
+ favorites_only=False,
+ auto_expand_reconstructions=True,
+ )
+ for label in ("By configuration", "44.1 kHz·30 Hz", "FFT·γ0", "PTN·#bbbbbbb"):
+ set_row_expanded(panel, row_named(corpus, label), expanded=True)
+
+ set_row_expanded(panel, row_named(corpus, "FFT·γ0"), expanded=False)
+ select_favorites(panel)
+ render_view(panel)
+ deselect_favorites(panel)
+
+ assert render_view(panel) == THE_WAY_DOWN_TO_THE_READERS_ROW
+
+ def test_the_way_down_the_mode_hands_over_is_written_down_with_the_readers_rows(
+ self,
+ corpus: BrowserCorpus,
+ ) -> None:
+ """A row the reader's own came to stand on is theirs from then on, so a session brings it back."""
+ panel = build_browser_panel(
+ corpus,
+ {corpus.paths["A/beat"]},
+ favorites_only=False,
+ auto_expand_reconstructions=True,
+ )
+ heading = row_named(corpus, "FFT·γ0")
+ set_row_expanded(panel, row_named(corpus, "PTN·#bbbbbbb"), expanded=True)
+ select_favorites(panel)
+ render_view(panel)
+
+ deselect_favorites(panel)
+
+ assert panel._generate_node_tag(heading) in panel.expanded_rows
+
+ def test_a_row_the_reader_folds_while_the_mode_is_on_stays_folded(self, corpus: BrowserCorpus) -> None:
+ """A row is the reader's to fold whichever hand opened it, so the mode lets go of its claim."""
+ panel = build_browser_panel(
+ corpus,
+ {corpus.paths["A/beat"]},
+ favorites_only=False,
+ auto_expand_reconstructions=True,
+ )
+ select_favorites(panel)
+ render_view(panel)
+
+ set_row_expanded(panel, row_named(corpus, "FFT·γ0"), expanded=False)
+ resolve_pass(panel)
+
+ assert "> FFT·γ0" in render_view(panel)
+
+ def test_the_rows_the_mode_opened_are_no_part_of_what_a_save_writes(self, corpus: BrowserCorpus) -> None:
+ panel = build_browser_panel(
+ corpus,
+ {corpus.paths["A/beat"]},
+ favorites_only=False,
+ auto_expand_reconstructions=True,
+ )
+ select_favorites(panel)
+ render_view(panel)
+
+ assert panel.expanded_rows == set()
+
+ def test_a_row_the_mode_never_drew_keeps_the_state_it_had(self, corpus: BrowserCorpus) -> None:
+ panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False)
+ set_row_expanded(panel, row_named(corpus, "archive"), expanded=True)
+ render_view(panel)
+
+ select_favorites(panel)
+ render_view(panel)
+ deselect_favorites(panel)
+
+ assert "v archive" in render_view(panel)
+
+ def test_a_refresh_brings_the_rows_back_standing_as_they_were(
+ self,
+ corpus: BrowserCorpus,
+ tmp_path: Path,
+ ) -> None:
+ """A rebuilt model states the same rows, and a row is remembered by the ancestry it reads."""
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+ set_row_expanded(panel, row_named(corpus, "archive"), expanded=True)
+ render_view(panel)
+
+ panel.tree = build_corpus(tmp_path).tree
+
+ assert "v archive" in render_view(panel)
+
+ def test_a_pass_over_the_whole_tree_forgets_the_rows_the_model_dropped(
+ self,
+ corpus: BrowserCorpus,
+ ) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+ archive = row_named(corpus, "archive")
+ set_row_expanded(panel, archive, expanded=True)
+ render_view(panel)
+
+ archive.parent = None
+
+ assert render_view(panel) == WHOLE_TREE_WITHOUT_THE_ARCHIVE
+ assert panel.expanded_rows == set()
+
+ def test_a_browser_opens_with_the_rows_a_session_left_it(self, corpus: BrowserCorpus) -> None:
+ """The shape outlives the run it was made in, so a browser is handed it as it is built."""
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+ archive_tag = panel._generate_node_tag(row_named(corpus, "archive"))
+
+ opened = build_browser_panel(
+ corpus,
+ set(),
+ favorites_only=False,
+ expanded_rows={archive_tag},
+ )
+
+ assert "v archive" in render_view(opened)
+
+ def test_a_pass_in_the_favorites_mode_forgets_the_rows_the_model_dropped(
+ self,
+ corpus: BrowserCorpus,
+ ) -> None:
+ """The model states which rows exist whatever the mode narrows to, so a lost row is dropped."""
+ panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False)
+ archive = row_named(corpus, "archive")
+ set_row_expanded(panel, archive, expanded=True)
+ render_view(panel)
+
+ archive.parent = None
+ select_favorites(panel)
+ render_view(panel)
+
+ assert panel.expanded_rows == set()
+
+ def test_the_shape_a_save_writes_is_the_rows_standing_open(self, corpus: BrowserCorpus) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+ archive_tag = panel._generate_node_tag(row_named(corpus, "archive"))
+ set_row_expanded(panel, row_named(corpus, "archive"), expanded=True)
+
+ assert panel.expanded_rows == {archive_tag}
+
+ def test_the_shape_a_save_reads_is_taken_apart_from_the_browser(self, corpus: BrowserCorpus) -> None:
+ """The browser keeps writing its own memory, so what a save carries is a reading of it."""
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+ set_row_expanded(panel, row_named(corpus, "archive"), expanded=True)
+ written = panel.expanded_rows
+
+ set_row_expanded(panel, row_named(corpus, "archive"), expanded=False)
+
+ assert written != panel.expanded_rows
+
+ def test_two_browsers_over_one_tree_remember_their_own_shape(self, corpus: BrowserCorpus) -> None:
+ """A row is remembered under the tag of the browser showing it, so neither reaches the other."""
+ sequencer = build_browser_panel(corpus, set(), favorites_only=False, panel_tag="sequencer.browser")
+ reconstruction = build_browser_panel(corpus, set(), favorites_only=False, panel_tag="reconstruction.browser")
+
+ set_row_expanded(sequencer, row_named(corpus, "archive"), expanded=True)
+
+ assert "v archive" in render_view(sequencer)
+ assert render_view(reconstruction) == WHOLE_TREE
+
+
+class TestFollowingTheReader:
+ """A click on a row is how it folds, and the browser reads what it stands as afterwards."""
+
+ def test_a_click_reads_the_row_the_frame_after_it_landed(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+ scheduled: List[Tuple[Any, Tuple[Any, ...], Dict[str, Any]]] = []
+ monkeypatch.setattr(
+ tree_module.CallbackQueue,
+ "add",
+ lambda callback, *args, **kwargs: scheduled.append((callback, args, kwargs)),
+ )
+
+ panel._remember_clicked_row((row_named(corpus, "archive"), "row.tag"))
+
+ assert scheduled == [(panel._read_row_expansion, ("row.tag",), {"delay": 1})]
+
+ def test_a_row_holding_nothing_has_nothing_to_remember(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+ scheduled: List[Tuple[Any, Tuple[Any, ...], Dict[str, Any]]] = []
+ monkeypatch.setattr(
+ tree_module.CallbackQueue,
+ "add",
+ lambda callback, *args, **kwargs: scheduled.append((callback, args, kwargs)),
+ )
+
+ panel._remember_clicked_row((nodes_at(corpus, "stray")[0], "row.tag"))
+
+ assert scheduled == []
+
+ def test_the_reading_takes_the_state_the_row_stands_in(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+ monkeypatch.setattr(tree_module.dpg, "does_item_exist", lambda tag: True)
+ monkeypatch.setattr(tree_module, "dpg_get_value", lambda tag: True)
+
+ panel._read_row_expansion("row.tag")
+
+ assert panel.expanded_rows == {"row.tag"}
+
+ def test_a_row_that_left_the_tree_is_read_no_further(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+ monkeypatch.setattr(tree_module.dpg, "does_item_exist", lambda tag: False)
+
+ panel._read_row_expansion("row.tag")
+
+ assert panel.expanded_rows == set()
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py
new file mode 100644
index 00000000..86342b4d
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py
@@ -0,0 +1,219 @@
+from pathlib import Path
+from typing import Final, List, Set, Tuple
+
+import pytest
+
+from sampletones_application.tags.general import (
+ TAG_GLOBAL_THEME_DEFAULT,
+ TAG_GLOBAL_THEME_FAVORITE_CHILD,
+)
+from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory
+from sampletones_application.ui.elements.tree.filter import NO_FILTER
+from sampletones_application.ui.elements.tree.handler import NodeHandler
+from sampletones_application.ui.elements.tree.spec import NodeSpec
+from sampletones_application.ui.elements.tree.state import TreeNodeState
+from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
+from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode
+
+CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions/sr_44100_nf_30")
+SONG_PATH: Final[Path] = CONFIG_DIRECTORY / "song.stn"
+OTHER_PATH: Final[Path] = CONFIG_DIRECTORY / "other.stn"
+
+Repaints = List[Tuple[TreeNode, bool]]
+
+
+class FakeTreeLogic:
+ def __init__(self, favorites: Set[Path]) -> None:
+ self._favorites = favorites
+
+ def is_node_favorite(self, node: TreeNode) -> bool:
+ return isinstance(node, FileSystemNode) and node.filepath in self._favorites
+
+ def has_favorite_ancestor(self, node: FileSystemNode) -> bool:
+ return any(directory in self._favorites for directory in node.filepath.parents)
+
+
+def browser_tree() -> Tree:
+ """Builds the shape both browser views give one reconstructions directory.
+
+ The same reconstruction is listed by its configuration and again by the sample it came from, so
+ one path reaches the panel as two rows.
+ """
+ root = TreeNode("Root", node_type=NodeType.ROOT)
+ configurations = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root)
+ directory = FileSystemNode(
+ "PTN",
+ node_type=NodeType.DIRECTORY,
+ filepath=CONFIG_DIRECTORY,
+ parent=configurations,
+ )
+ FileSystemNode("song", node_type=NodeType.FILE, filepath=SONG_PATH, parent=directory)
+ FileSystemNode("other", node_type=NodeType.FILE, filepath=OTHER_PATH, parent=directory)
+
+ samples = TreeNode("By sample", node_type=NodeType.GROUP, parent=root)
+ sample = TreeNode("song", node_type=NodeType.SAMPLE, parent=samples)
+ FileSystemNode(
+ "44.1 kHz·30 Hz",
+ node_type=NodeType.FILE,
+ filepath=SONG_PATH,
+ parent=sample,
+ )
+ return Tree(root=root)
+
+
+@pytest.fixture
+def repaints() -> Repaints:
+ return []
+
+
+def build_panel(
+ tree: Tree,
+ favorites: Set[Path],
+ repaints: Repaints,
+ monkeypatch: pytest.MonkeyPatch,
+) -> GUISequencerBrowserPanel:
+ """Builds a browser panel that records the rows it would repaint.
+
+ Repainting binds themes to widgets, so the theme pass stands in as a recorder here and the
+ panel keeps only the tree and the logic the favorite pass reads.
+ """
+ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel)
+ panel.tree = tree
+ panel._filter = NO_FILTER
+ panel._search_visibility = None
+ panel._favorites_visibility = None
+ panel._expansion = RowExpansionMemory(set())
+ monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False)
+ monkeypatch.setattr(
+ panel,
+ "_reapply_theme_recursively",
+ lambda node, has_favorite_ancestor=False: repaints.append((node, has_favorite_ancestor)),
+ raising=False,
+ )
+ return panel
+
+
+def rows_at(tree: Tree, filepath: Path) -> Tuple[FileSystemNode, ...]:
+ """Answers the rows the tree holds for a path, as the browser's owner hands them to the panel."""
+ return tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath)
+
+
+def build_specs(
+ tree: Tree,
+ favorites: Set[Path],
+ monkeypatch: pytest.MonkeyPatch,
+) -> List[NodeSpec]:
+ """Collects the rows a browser refresh would emit for a tree, with the themes it resolves.
+
+ The collecting pass runs off the main thread and touches no widget, so it needs only the tree,
+ the logic it asks about favorites, and a tag per row.
+ """
+ panel = build_panel(tree, favorites, [], monkeypatch)
+ panel._pending_specs = []
+ panel._node_handlers = {
+ node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType
+ }
+ monkeypatch.setattr(panel, "_generate_node_tag", lambda node: f"row.{node.name}", raising=False)
+
+ panel._build_tree_node(tree.get_root(), TreeNodeState(parent="tree"))
+ return panel._pending_specs
+
+
+def theme_of(specs: List[NodeSpec], label: str) -> str:
+ return next(spec.theme_tag for spec in specs if spec.label == label)
+
+
+class TestRowRepaint:
+ def test_every_row_standing_for_the_path_repaints(
+ self,
+ repaints: Repaints,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree, {SONG_PATH}, repaints, monkeypatch)
+
+ panel.update_favorite_indicators(rows_at(tree, SONG_PATH))
+
+ assert [node.name for node, _ in repaints] == ["song", "44.1 kHz·30 Hz"]
+
+ def test_a_path_listed_once_repaints_once(
+ self,
+ repaints: Repaints,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree, {OTHER_PATH}, repaints, monkeypatch)
+
+ panel.update_favorite_indicators(rows_at(tree, OTHER_PATH))
+
+ assert [node.name for node, _ in repaints] == ["other"]
+
+ def test_a_path_the_tree_states_nowhere_repaints_nothing(
+ self,
+ repaints: Repaints,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree, set(), repaints, monkeypatch)
+
+ panel.update_favorite_indicators(rows_at(tree, Path("/elsewhere/song.stn")))
+
+ assert repaints == []
+
+ def test_a_favorite_directory_repaints_where_each_view_holds_it(
+ self,
+ repaints: Repaints,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree, {CONFIG_DIRECTORY}, repaints, monkeypatch)
+
+ panel.update_favorite_indicators(rows_at(tree, CONFIG_DIRECTORY))
+
+ assert [node.name for node, _ in repaints] == ["PTN"]
+
+
+class TestFavoriteAncestry:
+ def test_each_row_repaints_with_the_ancestry_of_its_path(
+ self,
+ repaints: Repaints,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """A favorite configuration directory tints the reconstruction in both views."""
+ tree = browser_tree()
+ panel = build_panel(tree, {CONFIG_DIRECTORY}, repaints, monkeypatch)
+
+ panel.update_favorite_indicators(rows_at(tree, SONG_PATH))
+
+ assert [has_favorite_ancestor for _, has_favorite_ancestor in repaints] == [True, True]
+
+ def test_a_row_no_favorite_holds_repaints_plainly(
+ self,
+ repaints: Repaints,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree, {SONG_PATH}, repaints, monkeypatch)
+
+ panel.update_favorite_indicators(rows_at(tree, SONG_PATH))
+
+ assert [has_favorite_ancestor for _, has_favorite_ancestor in repaints] == [False, False]
+
+
+class TestFavoriteAncestryWhileBuilding:
+ def test_a_directory_below_a_favorite_the_view_omits_is_tinted(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """The reconstructions directory holds the row without being a row itself, and still counts."""
+ tree = browser_tree()
+ specs = build_specs(tree, {CONFIG_DIRECTORY.parent}, monkeypatch)
+ assert theme_of(specs, "PTN") == TAG_GLOBAL_THEME_FAVORITE_CHILD
+
+ def test_a_directory_no_favorite_holds_reads_plainly(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ tree = browser_tree()
+ specs = build_specs(tree, set(), monkeypatch)
+ assert theme_of(specs, "PTN") == TAG_GLOBAL_THEME_DEFAULT
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py
new file mode 100644
index 00000000..c977916c
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py
@@ -0,0 +1,835 @@
+from typing import Any, Final, List, Tuple
+
+import pytest
+
+from sampletones_application.ui.elements.tree import tree as tree_module
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_core.structures.tree import TreeNode
+from tests.suite.browser import (
+ CLOSED_MARKER,
+ OPEN_MARKER,
+ PANEL_TAG,
+ TREE_COLORS,
+ WHOLE_TREE,
+ BrowserCorpus,
+ FakeTreeLogic,
+ as_view,
+ build_browser_panel,
+ nodes_at,
+ render_view,
+ resolve_pass,
+ select_favorites,
+ view,
+ view_on_selecting_favorites,
+)
+
+CHECKBOX_TAG: Final[str] = "sequencer.browser.checkbox.favorites"
+GLYPH_TAG: Final[str] = "sequencer.browser.text.favorites"
+
+
+def rows_of(rendered: str) -> List[str]:
+ """The rows a view holds, read apart from the state each of them stands in."""
+ return [line.replace(OPEN_MARKER, CLOSED_MARKER, 1) for line in rendered.splitlines()]
+
+
+STARRED_RECONSTRUCTION: Final[str] = as_view("""
+ > By configuration
+ > 44.1 kHz·30 Hz
+ > FFT·γ0
+ > PTN·#aaaaaaa
+ - beat
+ > By sample
+ > beat
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ """)
+STARRED_RECONSTRUCTION_OPENED: Final[str] = as_view("""
+ v By configuration
+ v 44.1 kHz·30 Hz
+ v FFT·γ0
+ v PTN·#aaaaaaa
+ - beat
+ v By sample
+ v beat
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ """)
+STARRED_LONE_AUDIO: Final[str] = as_view("""
+ > By configuration
+ > 44.1 kHz·30 Hz
+ > CQT·γ0·PTN
+ - solo
+ > By sample
+ - solo·44.1 kHz·30 Hz·CQT·γ0·PTN
+ """)
+STARRED_LONE_AUDIO_OPENED: Final[str] = as_view("""
+ v By configuration
+ v 44.1 kHz·30 Hz
+ v CQT·γ0·PTN
+ - solo
+ v By sample
+ - solo·44.1 kHz·30 Hz·CQT·γ0·PTN
+ """)
+STARRED_IN_SUBFOLDER: Final[str] = as_view("""
+ > By configuration
+ > 44.1 kHz·30 Hz
+ > FFT·γ0
+ > PTN·#aaaaaaa
+ > drums
+ - kick
+ > By sample
+ > drums
+ > kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ """)
+STARRED_IN_SUBFOLDER_OPENED: Final[str] = as_view("""
+ v By configuration
+ v 44.1 kHz·30 Hz
+ v FFT·γ0
+ v PTN·#aaaaaaa
+ v drums
+ - kick
+ v By sample
+ v drums
+ v kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ """)
+STARRED_CONFIGURATION: Final[str] = as_view("""
+ > By configuration
+ > 44.1 kHz·30 Hz
+ > FFT·γ0
+ > PT
+ > takes
+ - alt
+ - beat
+ > By sample
+ > beat
+ - 44.1 kHz·30 Hz·FFT·γ0·PT
+ - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT
+ """)
+STARRED_CONFIGURATION_OPENED: Final[str] = as_view("""
+ v By configuration
+ v 44.1 kHz·30 Hz
+ v FFT·γ0
+ > PT
+ > takes
+ - alt
+ - beat
+ v By sample
+ v beat
+ - 44.1 kHz·30 Hz·FFT·γ0·PT
+ - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT
+ """)
+STARRED_PLAIN_FOLDER: Final[str] = as_view("""
+ > By configuration
+ > archive
+ > 48 kHz·50 Hz·LogFFT·γ1·TN
+ - song
+ """)
+STARRED_PLAIN_FOLDER_OPENED: Final[str] = as_view("""
+ v By configuration
+ > archive
+ > 48 kHz·50 Hz·LogFFT·γ1·TN
+ - song
+ """)
+STARRED_FOLDER_IN_STARRED_FOLDER_OPENED: Final[str] = as_view("""
+ v By configuration
+ v archive
+ > 48 kHz·50 Hz·LogFFT·γ1·TN
+ - song
+ """)
+STARRED_STRAY: Final[str] = as_view("""
+ > By configuration
+ - stray
+ """)
+STARRED_STRAY_OPENED: Final[str] = as_view("""
+ v By configuration
+ - stray
+ """)
+STARRED_OF_TWO_ALIKE: Final[str] = as_view("""
+ > By configuration
+ > 44.1 kHz·30 Hz
+ > FFT·γ0
+ > PTN·#aaaaaaa
+ > drums
+ - kick
+ - snare
+ - beat
+ - melody
+ > By sample
+ > beat
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ > drums
+ > kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - snare·44.1 kHz·30 Hz·FFT·γ0·PTN
+ > melody
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ """)
+STARRED_OF_TWO_ALIKE_OPENED: Final[str] = as_view("""
+ v By configuration
+ v 44.1 kHz·30 Hz
+ v FFT·γ0
+ > PTN·#aaaaaaa
+ > drums
+ - kick
+ - snare
+ - beat
+ - melody
+ v By sample
+ v beat
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ v drums
+ v kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - snare·44.1 kHz·30 Hz·FFT·γ0·PTN
+ v melody
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ """)
+STARRED_FOLDED_CONFIGURATION: Final[str] = as_view("""
+ > By configuration
+ > 8 kHz·60 Hz·CQT·γ2·P
+ - sweep
+ > By sample
+ - sweep·8 kHz·60 Hz·CQT·γ2·P
+ """)
+STARRED_FOLDED_CONFIGURATION_OPENED: Final[str] = as_view("""
+ v By configuration
+ > 8 kHz·60 Hz·CQT·γ2·P
+ - sweep
+ v By sample
+ - sweep·8 kHz·60 Hz·CQT·γ2·P
+ """)
+STARRED_CONFIGURATION_B: Final[str] = as_view("""
+ > By configuration
+ > 44.1 kHz·30 Hz
+ > FFT·γ0
+ > PTN·#bbbbbbb
+ > drums
+ - kick
+ - beat
+ - melody
+ > By sample
+ > beat
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ > drums
+ > kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ > melody
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ """)
+STARRED_CONFIGURATION_B_OPENED: Final[str] = as_view("""
+ v By configuration
+ v 44.1 kHz·30 Hz
+ v FFT·γ0
+ > PTN·#bbbbbbb
+ > drums
+ - kick
+ - beat
+ - melody
+ v By sample
+ v beat
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ v drums
+ v kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ v melody
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ """)
+STARRED_FOLDER_HOLDING_A_STAR_OPENED: Final[str] = as_view("""
+ v By configuration
+ v 44.1 kHz·30 Hz
+ v FFT·γ0
+ v PTN·#bbbbbbb
+ v drums
+ - kick
+ - beat
+ - melody
+ v By sample
+ > beat
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ v drums
+ v kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ > melody
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ """)
+STARRED_FOLDER_HOLDING_A_STAR_BY_FOLDER: Final[str] = as_view("""
+ v By configuration
+ v 44.1 kHz·30 Hz
+ v FFT·γ0
+ > PTN·#bbbbbbb
+ > drums
+ - kick
+ - beat
+ - melody
+ v By sample
+ v beat
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ > drums
+ > kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ v melody
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ """)
+QUERY_INSIDE_THE_MODE: Final[str] = as_view("""
+ v By configuration
+ v 44.1 kHz·30 Hz
+ v FFT·γ0
+ v PTN·#bbbbbbb
+ > drums [hidden]
+ - kick [hidden]
+ - beat [hidden]
+ - melody
+ v By sample
+ > beat [hidden]
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden]
+ > drums [hidden]
+ > kick [hidden]
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden]
+ v melody
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ """)
+QUERY_PAST_THE_MODE: Final[str] = as_view("""
+ v By configuration
+ v 44.1 kHz·30 Hz
+ v FFT·γ0
+ v PTN·#aaaaaaa
+ - beat [hidden]
+ v By sample
+ > beat [hidden]
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa [hidden]
+ """)
+QUERY_ALONE: Final[str] = as_view("""
+ v By configuration
+ > 8 kHz·60 Hz·CQT·γ2·P [hidden]
+ - sweep [hidden]
+ v 44.1 kHz·30 Hz
+ > CQT·γ0·PTN [hidden]
+ - beat [hidden]
+ - solo [hidden]
+ v FFT·γ0
+ > PT [hidden]
+ > takes [hidden]
+ - alt [hidden]
+ - beat [hidden]
+ v PTN·#aaaaaaa
+ v drums
+ - kick
+ - snare [hidden]
+ - beat [hidden]
+ - melody [hidden]
+ v PTN·#bbbbbbb
+ v drums
+ - kick
+ - beat [hidden]
+ - melody [hidden]
+ > archive [hidden]
+ > 48 kHz·50 Hz·LogFFT·γ1·TN [hidden]
+ - song [hidden]
+ - stray [hidden]
+ v By sample
+ > beat [hidden]
+ - 44.1 kHz·30 Hz·CQT·γ0·PTN [hidden]
+ - 44.1 kHz·30 Hz·FFT·γ0·PT [hidden]
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa [hidden]
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden]
+ v drums
+ v kick
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb
+ - snare·44.1 kHz·30 Hz·FFT·γ0·PTN [hidden]
+ > melody [hidden]
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa [hidden]
+ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden]
+ - solo·44.1 kHz·30 Hz·CQT·γ0·PTN [hidden]
+ - sweep·8 kHz·60 Hz·CQT·γ2·P [hidden]
+ - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT [hidden]
+ """)
+
+
+class TestDrawnRows:
+ """Which rows the mode draws: what the star reaches, and the rows leading down to it.
+
+ What is drawn is the star's to state and nothing else, so every row stands folded here: the mode
+ is stated the way a session restores it, and a mode nobody asked for opens no row.
+ """
+
+ def test_a_starred_reconstruction_is_drawn_in_both_views(self, corpus: BrowserCorpus) -> None:
+ assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION
+
+ def test_a_starred_reconstruction_of_an_audio_one_configuration_holds(self, corpus: BrowserCorpus) -> None:
+ """A sample of a single variant folded into that variant, and the fold carries the star."""
+ assert view(corpus, {corpus.paths["D/solo"]}, favorites_only=True) == STARRED_LONE_AUDIO
+
+ def test_a_starred_reconstruction_in_a_mirrored_subfolder(self, corpus: BrowserCorpus) -> None:
+ assert view(corpus, {corpus.paths["A/drums/kick"]}, favorites_only=True) == STARRED_IN_SUBFOLDER
+
+ def test_a_starred_configuration_directory_brings_what_it_holds(self, corpus: BrowserCorpus) -> None:
+ assert view(corpus, {corpus.paths["C"]}, favorites_only=True) == STARRED_CONFIGURATION
+
+ def test_a_starred_plain_folder_reaches_the_configuration_nested_in_it(self, corpus: BrowserCorpus) -> None:
+ """The sample branch reads the top-level configurations, so a nested one stands there alone."""
+ assert view(corpus, {corpus.paths["archive"]}, favorites_only=True) == STARRED_PLAIN_FOLDER
+
+ def test_a_starred_reconstruction_outside_every_configuration(self, corpus: BrowserCorpus) -> None:
+ assert view(corpus, {corpus.paths["stray"]}, favorites_only=True) == STARRED_STRAY
+
+ def test_a_star_on_one_of_two_configurations_reading_alike(self, corpus: BrowserCorpus) -> None:
+ """The star belongs to a path, so the sibling marked with the other hash stays out."""
+ assert view(corpus, {corpus.paths["A"]}, favorites_only=True) == STARRED_OF_TWO_ALIKE
+
+ def test_a_starred_configuration_whose_chain_folded_into_one_row(self, corpus: BrowserCorpus) -> None:
+ assert view(corpus, {corpus.paths["E"]}, favorites_only=True) == STARRED_FOLDED_CONFIGURATION
+
+ def test_nothing_starred_draws_no_row(self, corpus: BrowserCorpus) -> None:
+ assert view(corpus, set(), favorites_only=True) == ""
+
+ def test_the_mode_off_draws_every_row(self, corpus: BrowserCorpus) -> None:
+ assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=False) == WHOLE_TREE
+
+ def test_the_rows_drawn_are_the_same_whichever_stars_are_followed(self, corpus: BrowserCorpus) -> None:
+ """Opening the way down to a star is a separate answer, so it moves no row in or out."""
+ favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]}
+ assert rows_of(
+ view_on_selecting_favorites(
+ corpus,
+ favorites,
+ auto_expand_reconstructions=True,
+ auto_expand_directories=True,
+ )
+ ) == rows_of(view(corpus, favorites, favorites_only=True))
+
+
+class TestOpenRows:
+ """Which rows stand open: the way down to a star the reader asked the browser to follow."""
+
+ def test_the_preference_off_opens_nothing(self, corpus: BrowserCorpus) -> None:
+ assert view_on_selecting_favorites(corpus, {corpus.paths["A/beat"]}) == STARRED_RECONSTRUCTION
+
+ def test_the_rows_above_a_starred_reconstruction_open(self, corpus: BrowserCorpus) -> None:
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ {corpus.paths["A/beat"]},
+ auto_expand_reconstructions=True,
+ )
+ == STARRED_RECONSTRUCTION_OPENED
+ )
+
+ def test_the_sample_row_above_a_starred_reconstruction_of_a_lone_audio_opens(
+ self,
+ corpus: BrowserCorpus,
+ ) -> None:
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ {corpus.paths["D/solo"]},
+ auto_expand_reconstructions=True,
+ )
+ == STARRED_LONE_AUDIO_OPENED
+ )
+
+ def test_the_subfolder_above_a_starred_reconstruction_opens(self, corpus: BrowserCorpus) -> None:
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ {corpus.paths["A/drums/kick"]},
+ auto_expand_reconstructions=True,
+ )
+ == STARRED_IN_SUBFOLDER_OPENED
+ )
+
+ def test_the_branch_above_a_starred_reconstruction_outside_every_configuration_opens(
+ self,
+ corpus: BrowserCorpus,
+ ) -> None:
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ {corpus.paths["stray"]},
+ auto_expand_reconstructions=True,
+ )
+ == STARRED_STRAY_OPENED
+ )
+
+ def test_a_starred_folder_is_left_folded_while_reconstructions_alone_are_followed(
+ self,
+ corpus: BrowserCorpus,
+ ) -> None:
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ {corpus.paths["A"]},
+ auto_expand_reconstructions=True,
+ )
+ == STARRED_OF_TWO_ALIKE
+ )
+
+ def test_a_starred_reconstruction_is_left_folded_while_directories_alone_are_followed(
+ self,
+ corpus: BrowserCorpus,
+ ) -> None:
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ {corpus.paths["A/beat"]},
+ auto_expand_directories=True,
+ )
+ == STARRED_RECONSTRUCTION
+ )
+
+ def test_the_rows_above_a_starred_configuration_open_and_it_stays_folded(self, corpus: BrowserCorpus) -> None:
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ {corpus.paths["C"]},
+ auto_expand_directories=True,
+ )
+ == STARRED_CONFIGURATION_OPENED
+ )
+
+ def test_the_rows_above_a_starred_plain_folder_open_and_it_stays_folded(self, corpus: BrowserCorpus) -> None:
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ {corpus.paths["archive"]},
+ auto_expand_directories=True,
+ )
+ == STARRED_PLAIN_FOLDER_OPENED
+ )
+
+ def test_a_starred_folder_holding_a_starred_folder_opens_the_way_down_to_it(
+ self,
+ corpus: BrowserCorpus,
+ ) -> None:
+ """The folder above stands on the way to the star below, which is what opens it."""
+ favorites = {corpus.paths["archive"], corpus.paths["archive/F"]}
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ favorites,
+ auto_expand_directories=True,
+ )
+ == STARRED_FOLDER_IN_STARRED_FOLDER_OPENED
+ )
+
+ def test_a_starred_configuration_whose_chain_folded_keeps_the_folded_row_closed(
+ self,
+ corpus: BrowserCorpus,
+ ) -> None:
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ {corpus.paths["E"]},
+ auto_expand_directories=True,
+ )
+ == STARRED_FOLDED_CONFIGURATION_OPENED
+ )
+
+ def test_the_sample_branch_opens_the_way_to_the_variants_a_starred_folder_holds(
+ self,
+ corpus: BrowserCorpus,
+ ) -> None:
+ """No row stands for the folder there, so the variants are where the star arrives."""
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ {corpus.paths["B"]},
+ auto_expand_directories=True,
+ )
+ == STARRED_CONFIGURATION_B_OPENED
+ )
+
+ def test_a_star_inside_a_starred_folder_opens_that_folder(self, corpus: BrowserCorpus) -> None:
+ """A reconstruction answers by its own preference, so following those opens the folder above."""
+ favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]}
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ favorites,
+ auto_expand_reconstructions=True,
+ )
+ == STARRED_FOLDER_HOLDING_A_STAR_OPENED
+ )
+
+ def test_a_star_inside_a_starred_folder_takes_its_own_preference(self, corpus: BrowserCorpus) -> None:
+ """Following folders alone opens the way to the folder, leaving the star inside it folded away."""
+ favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]}
+ assert (
+ view_on_selecting_favorites(
+ corpus,
+ favorites,
+ auto_expand_directories=True,
+ )
+ == STARRED_FOLDER_HOLDING_A_STAR_BY_FOLDER
+ )
+
+ def test_a_mode_a_session_restored_opens_nothing(self, corpus: BrowserCorpus) -> None:
+ """A browser opens with the rows its reader left standing, whichever stars it would follow."""
+ assert (
+ view(
+ corpus,
+ {corpus.paths["A/beat"]},
+ favorites_only=True,
+ auto_expand_reconstructions=True,
+ )
+ == STARRED_RECONSTRUCTION
+ )
+
+ def test_the_way_down_stands_open_for_as_long_as_the_mode_does(self, corpus: BrowserCorpus) -> None:
+ """A refresh while the mode is on leaves the reader looking at the way down to their stars."""
+ panel = build_browser_panel(
+ corpus,
+ {corpus.paths["A/beat"]},
+ favorites_only=False,
+ auto_expand_reconstructions=True,
+ )
+ select_favorites(panel)
+ assert render_view(panel) == STARRED_RECONSTRUCTION_OPENED
+
+ resolve_pass(panel)
+
+ assert render_view(panel) == STARRED_RECONSTRUCTION_OPENED
+
+ def test_a_star_gained_while_the_mode_is_on_opens_no_way_of_its_own(self, corpus: BrowserCorpus) -> None:
+ """The reader asked to be pointed at the stars they had, so a star gained since points nowhere."""
+ panel = build_browser_panel(
+ corpus,
+ set(),
+ favorites_only=False,
+ auto_expand_reconstructions=True,
+ )
+ select_favorites(panel)
+ panel._logic = FakeTreeLogic( # type: ignore[assignment]
+ {corpus.paths["A/beat"]},
+ auto_expand_reconstructions=True,
+ auto_expand_directories=False,
+ )
+
+ resolve_pass(panel)
+
+ assert render_view(panel) == STARRED_RECONSTRUCTION
+
+
+class TestSearchInsideTheMode:
+ """The mode states which rows are drawn, and the query states which of them are shown."""
+
+ def test_a_query_hides_the_drawn_rows_it_leaves_out(self, corpus: BrowserCorpus) -> None:
+ assert (
+ view(
+ corpus,
+ {corpus.paths["B"]},
+ favorites_only=True,
+ query="melody",
+ )
+ == QUERY_INSIDE_THE_MODE
+ )
+
+ def test_a_query_naming_a_row_the_mode_leaves_out_shows_nothing_of_it(self, corpus: BrowserCorpus) -> None:
+ assert (
+ view(
+ corpus,
+ {corpus.paths["A/beat"]},
+ favorites_only=True,
+ query="melody",
+ )
+ == QUERY_PAST_THE_MODE
+ )
+
+ def test_a_query_cleared_shows_the_rows_the_mode_draws(self, corpus: BrowserCorpus) -> None:
+ assert (
+ view(
+ corpus,
+ {corpus.paths["B"]},
+ favorites_only=True,
+ query="",
+ )
+ == STARRED_CONFIGURATION_B
+ )
+
+ def test_a_query_alone_draws_every_row_and_shows_the_matches(self, corpus: BrowserCorpus) -> None:
+ assert view(corpus, set(), favorites_only=False, query="kick") == QUERY_ALONE
+
+
+class TestEmptyAnswer:
+ """A rebuild drawing no row names the filter that answered so, where the rows would be."""
+
+ def test_the_mode_finding_no_favorite_names_the_favorites(self, corpus: BrowserCorpus) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=True)
+ assert panel._empty_filter_message() == "global.dialog.message.tree_no_favorites"
+
+ def test_a_query_finding_nothing_names_the_results(self, corpus: BrowserCorpus) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=False, query="nothing")
+ assert panel._empty_filter_message() == "global.dialog.message.tree_no_results"
+
+
+class TestControl:
+ """What the checkbox beside the search box answers for: the mode, the memory of it, the rows."""
+
+ def test_the_mode_the_control_reads_reaches_the_filter(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False)
+ monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False)
+
+ panel._on_favorites_only_changed(None, True)
+
+ assert panel._filter.favorites_only
+
+ def test_a_change_is_handed_to_the_hook_remembering_it(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False)
+ remembered: List[Tuple[str, bool]] = []
+ panel.on_favorites_filter_changed = lambda panel_tag, favorites_only: remembered.append(
+ (panel_tag, favorites_only)
+ )
+ monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False)
+
+ panel._on_favorites_only_changed(None, True)
+
+ assert remembered == [(PANEL_TAG, True)]
+
+ def test_a_change_draws_the_rows_the_new_mode_names(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False)
+ redraws: List[bool] = []
+ monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False)
+
+ panel._on_favorites_only_changed(None, True)
+
+ assert redraws == [True]
+
+ def test_a_query_typed_earlier_survives_a_change_of_mode(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False, query="beat")
+ monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False)
+
+ panel._on_favorites_only_changed(None, True)
+
+ assert panel._filter.query == "beat"
+
+
+class TestStarColor:
+ """The star beside the label reads in the colour of the mode it stands for."""
+
+ def test_the_star_reads_favorite_while_the_mode_is_on(self, corpus: BrowserCorpus) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=True)
+ assert panel._favorites_glyph_color() == TREE_COLORS.favorite
+
+ def test_the_star_reads_muted_while_the_mode_is_off(self, corpus: BrowserCorpus) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+ assert panel._favorites_glyph_color() == TREE_COLORS.muted
+
+ def test_the_star_is_coloured_with_the_token_the_mode_names(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """The colour reaches the star as a token, so the star follows a palette swapped in place."""
+ panel = build_browser_panel(corpus, set(), favorites_only=True)
+ panel._favorites_glyph_tag = GLYPH_TAG
+ coloured: List[Tuple[str, BaseColor]] = []
+ monkeypatch.setattr(
+ tree_module,
+ "dpg_set_palette_color",
+ lambda item, color: coloured.append((item, color)),
+ )
+
+ panel._apply_favorites_glyph_color()
+
+ assert coloured == [(GLYPH_TAG, TREE_COLORS.favorite)]
+
+
+class TestControlLock:
+ """A rebuild is what the control asks for, so the tree's lock reaches it."""
+
+ def test_the_lock_reaches_the_control(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=True)
+ panel._favorites_checkbox_tag = CHECKBOX_TAG
+ configured: List[Tuple[str, Any]] = []
+ monkeypatch.setattr(
+ tree_module,
+ "dpg_configure_item",
+ lambda tag, **kwargs: configured.append((tag, kwargs["enabled"])),
+ )
+
+ panel.set_favorites_filter_enabled(False)
+
+ assert configured == [(CHECKBOX_TAG, False)]
+
+ def test_a_browser_offering_no_control_answers_the_lock_as_it_stands(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = build_browser_panel(corpus, set(), favorites_only=False)
+ configured: List[Tuple[str, Any]] = []
+ monkeypatch.setattr(
+ tree_module,
+ "dpg_configure_item",
+ lambda tag, **kwargs: configured.append((tag, kwargs["enabled"])),
+ )
+
+ panel.set_favorites_filter_enabled(False)
+
+ assert configured == []
+
+
+class TestFavoriteChange:
+ def test_a_change_draws_the_tree_again_while_the_mode_is_on(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=True)
+ redraws: List[bool] = []
+ repaints: List[TreeNode] = []
+ monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False)
+ monkeypatch.setattr(
+ panel,
+ "_reapply_theme_recursively",
+ lambda node, has_favorite_ancestor=False: repaints.append(node),
+ raising=False,
+ )
+
+ panel.update_favorite_indicators(nodes_at(corpus, "A/beat"))
+
+ assert redraws == [True]
+ assert repaints == []
+
+ def test_a_change_repaints_every_row_standing_for_the_path_while_the_mode_is_off(
+ self,
+ corpus: BrowserCorpus,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """One path reaches the panel as a row in each view, and each takes its own ancestry."""
+ panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False)
+ redraws: List[bool] = []
+ repaints: List[TreeNode] = []
+ monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False)
+ monkeypatch.setattr(
+ panel,
+ "_reapply_theme_recursively",
+ lambda node, has_favorite_ancestor=False: repaints.append(node),
+ raising=False,
+ )
+ rows = nodes_at(corpus, "A/beat")
+
+ panel.update_favorite_indicators(rows)
+
+ assert redraws == []
+ assert repaints == list(rows)
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py
new file mode 100644
index 00000000..c88768b1
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py
@@ -0,0 +1,194 @@
+from typing import Dict, List, Set, Type
+
+from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory
+from sampletones_application.ui.elements.tree.filter import NO_FILTER, TreeFilter
+from sampletones_application.ui.panels.reconstruction.browser import GUIReconstructionsBrowserPanel
+from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
+from sampletones_application.ui.panels.shared.browser import GUIReconstructionBrowserPanel
+from sampletones_core.structures.tree import NodeType, Tree, TreeNode
+
+
+class FakeTreeLogic:
+ """Stands in for the logic a panel schedules the search on, recording what it was asked for."""
+
+ def __init__(self) -> None:
+ self.scheduled_queries: List[str] = []
+
+ def schedule_search_update(self, query: str) -> None:
+ self.scheduled_queries.append(query)
+
+
+def browser_tree() -> Tree:
+ """Builds the shape both browser views give one reconstructions directory, a row per label."""
+ root = TreeNode("Root", node_type=NodeType.ROOT)
+ configurations = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root)
+ TreeNode("song", node_type=NodeType.FILE, parent=configurations)
+ TreeNode("other", node_type=NodeType.FILE, parent=configurations)
+ samples = TreeNode("By sample", node_type=NodeType.GROUP, parent=root)
+ sample = TreeNode("sample", node_type=NodeType.SAMPLE, parent=samples)
+ TreeNode("variant", node_type=NodeType.FILE, parent=sample)
+ return Tree(root=root)
+
+
+def rows_of(tree: Tree) -> Dict[str, TreeNode]:
+ """The rows a tree holds, read by the label each of them carries."""
+ root = tree.get_root()
+ assert root is not None
+ return {node.name: node for node in (root, *root.descendants)}
+
+
+def build_panel(
+ tree: Tree,
+ panel_class: Type[GUIReconstructionBrowserPanel] = GUISequencerBrowserPanel,
+) -> GUIReconstructionBrowserPanel:
+ """Builds a browser panel holding a filter, with the tree it reads and the logic it schedules on.
+
+ Resolving a filter reads the model alone, so the panel needs neither widgets nor a search box.
+ """
+ panel = panel_class.__new__(panel_class)
+ panel.tree = tree
+ panel._logic = FakeTreeLogic()
+ panel._search_input_tag = None
+ panel._filter = NO_FILTER
+ panel._search_visibility = None
+ panel._favorites_visibility = None
+ panel._expansion = RowExpansionMemory(set())
+ return panel
+
+
+def visible_rows(panel: GUIReconstructionBrowserPanel, tree: Tree) -> Set[str]:
+ return {name for name, node in rows_of(tree).items() if panel._is_node_visible(node)}
+
+
+class TestFilterComposition:
+ def test_a_filter_stating_nothing_narrows_nothing(self) -> None:
+ assert not NO_FILTER.is_active
+
+ def test_a_filter_carrying_a_query_narrows(self) -> None:
+ assert NO_FILTER.with_query("song").is_active
+
+ def test_dropping_the_query_leaves_the_filter_narrowing_nothing(self) -> None:
+ assert not NO_FILTER.with_query("song").with_query("").is_active
+
+ def test_a_filter_showing_the_favorites_alone_narrows(self) -> None:
+ assert NO_FILTER.with_favorites_only(True).is_active
+
+ def test_the_query_and_the_favorites_mode_are_stated_side_by_side(self) -> None:
+ tree_filter = NO_FILTER.with_query("song").with_favorites_only(True)
+ assert tree_filter.query == "song"
+ assert tree_filter.favorites_only
+
+ def test_the_filter_a_new_one_was_taken_from_reads_as_it_did(self) -> None:
+ original = TreeFilter(query="song", favorites_only=False)
+ original.with_query("other")
+ original.with_favorites_only(True)
+ assert original.query == "song"
+ assert not original.favorites_only
+
+
+class TestPanelOwnedFilter:
+ def test_a_query_shows_the_rows_it_names_and_the_rows_above_them(self) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree)
+
+ panel._on_search_changed(None, "other")
+
+ assert visible_rows(panel, tree) == {"Root", "By configuration", "other"}
+
+ def test_a_query_naming_a_container_shows_what_it_gathers(self) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree)
+
+ panel._on_search_changed(None, "sample")
+
+ assert visible_rows(panel, tree) == {"Root", "By sample", "sample", "variant"}
+
+ def test_no_query_shows_every_row(self) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree)
+
+ assert visible_rows(panel, tree) == set(rows_of(tree))
+
+ def test_clearing_the_search_shows_every_row_again(self) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree)
+
+ panel._on_search_changed(None, "other")
+ panel._on_clear_search_clicked()
+
+ assert visible_rows(panel, tree) == set(rows_of(tree))
+
+ def test_the_search_is_scheduled_as_it_is_typed_and_as_it_is_cleared(self) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree)
+ logic = panel._logic
+
+ panel._on_search_changed(None, "oth")
+ panel._on_clear_search_clicked()
+
+ assert logic.scheduled_queries == ["oth", ""]
+
+
+class TestTwoPanelsOverOneTree:
+ """Both reconstruction browsers render one tree, and each of them narrows to its own filter."""
+
+ def test_a_query_in_one_panel_leaves_the_other_reading_as_it_was(self) -> None:
+ tree = browser_tree()
+ searching = build_panel(tree, GUISequencerBrowserPanel)
+ untouched = build_panel(tree, GUIReconstructionsBrowserPanel)
+
+ searching._on_search_changed(None, "other")
+
+ assert visible_rows(untouched, tree) == set(rows_of(tree))
+ assert not untouched._filter.is_active
+
+ def test_each_panel_narrows_to_the_query_it_was_given(self) -> None:
+ tree = browser_tree()
+ first = build_panel(tree, GUISequencerBrowserPanel)
+ second = build_panel(tree, GUIReconstructionsBrowserPanel)
+
+ first._on_search_changed(None, "other")
+ second._on_search_changed(None, "variant")
+
+ assert visible_rows(first, tree) == {"Root", "By configuration", "other"}
+ assert visible_rows(second, tree) == {"Root", "By sample", "sample", "variant"}
+
+
+class TestFilterAcrossARebuild:
+ def test_a_query_answers_for_the_rows_a_refresh_brings(self) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree)
+ panel._on_search_changed(None, "arrival")
+
+ root = TreeNode("Root", node_type=NodeType.ROOT)
+ group = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root)
+ arrival = TreeNode("arrival", node_type=NodeType.FILE, parent=group)
+ tree.set_root(root)
+ panel._resolve_filter()
+
+ assert panel._is_node_visible(arrival)
+ assert visible_rows(panel, tree) == {"Root", "By configuration", "arrival"}
+
+
+class TestExpandedRows:
+ def test_a_row_leading_to_a_result_is_emitted_open(self) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree)
+
+ panel._on_search_changed(None, "other")
+
+ assert panel._should_expand_node(rows_of(tree)["By configuration"])
+
+ def test_a_row_beside_the_way_in_is_emitted_folded(self) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree)
+
+ panel._on_search_changed(None, "other")
+
+ assert not panel._should_expand_node(rows_of(tree)["By sample"])
+
+ def test_no_query_leaves_every_row_as_it_stands(self) -> None:
+ tree = browser_tree()
+ panel = build_panel(tree)
+
+ assert not any(panel._should_expand_node(node) for node in rows_of(tree).values())
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py b/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py
new file mode 100644
index 00000000..6b9d9e89
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py
@@ -0,0 +1,31 @@
+import pytest
+
+from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
+from sampletones_core.structures.tree import NodeType, TreeNode
+from tests.suite.language import FakeLanguageManager
+
+
+def _panel() -> GUISequencerBrowserPanel:
+ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel)
+ panel._language_manager = FakeLanguageManager()
+ return panel
+
+
+class TestExpandableNodeMessage:
+ @pytest.mark.parametrize(
+ ("node_type", "key"),
+ [
+ (NodeType.GROUP, "global.status.message.node_group"),
+ (NodeType.SAMPLE, "global.status.message.node_sample"),
+ (NodeType.DIRECTORY, "global.status.message.node_directory"),
+ ],
+ )
+ def test_the_message_names_what_the_row_holds(
+ self,
+ node_type: NodeType,
+ key: str,
+ ) -> None:
+ """Each row the reader opens holds something of its own, and its hover message says so."""
+ panel = _panel()
+
+ assert panel._expandable_node_message(TreeNode("row", node_type=node_type)) == key
diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_tag.py b/tests/unit/sampletones_application/ui/elements/tree/test_tag.py
new file mode 100644
index 00000000..ac0f5f43
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/elements/tree/test_tag.py
@@ -0,0 +1,62 @@
+from typing import Final
+
+from sampletones_application.ui.elements.tree.tag import compose_node_tag
+from sampletones_core.structures.tree import NodeType, TreeNode
+
+PANEL_TAG: Final[str] = "sequencer.browser.panel"
+OTHER_PANEL_TAG: Final[str] = "reconstructions.browser.panel"
+
+
+def root() -> TreeNode:
+ return TreeNode("Root", node_type=NodeType.ROOT)
+
+
+def group(name: str, parent: TreeNode) -> TreeNode:
+ return TreeNode(name, node_type=NodeType.GROUP, parent=parent)
+
+
+def tag_of(node: TreeNode) -> str:
+ return compose_node_tag(node, panel_tag=PANEL_TAG)
+
+
+class TestReadability:
+ def test_tag_states_the_panel_and_the_names_above_the_row(self) -> None:
+ node = group("cw_amen02_165", group("Amen Breaks", root()))
+
+ assert tag_of(node).startswith(f"{PANEL_TAG}.")
+ assert "node_root_amen_breaks_cw_amen02_165" in tag_of(node)
+
+ def test_one_node_keeps_one_tag(self) -> None:
+ node = group("song", root())
+
+ assert tag_of(node) == tag_of(node)
+
+ def test_each_panel_names_the_row_its_own_way(self) -> None:
+ """Both browsers render one tree, so a row reaches each panel under a tag of that panel."""
+ node = group("song", root())
+
+ assert compose_node_tag(node, panel_tag=PANEL_TAG) != compose_node_tag(node, panel_tag=OTHER_PANEL_TAG)
+
+
+class TestDistinctRows:
+ def test_a_folder_and_the_audio_beside_it_keep_their_own_tags(self) -> None:
+ container = root()
+ folder = group("song", container)
+ audio = TreeNode("song", node_type=NodeType.SAMPLE, parent=container)
+
+ assert tag_of(folder) != tag_of(audio)
+
+ def test_names_differing_in_spacing_keep_their_own_tags(self) -> None:
+ """``drums/kick`` and ``drums kick`` read alike as a name path and stand as two rows."""
+ container = root()
+ nested = group("kick", group("drums", container))
+ spaced = group("drums kick", container)
+
+ assert tag_of(nested) != tag_of(spaced)
+
+ def test_names_differing_in_case_keep_their_own_tags(self) -> None:
+ container = root()
+ lowercase = group("song", container)
+ capitalized = group("Song", container)
+
+ assert tag_of(lowercase) != tag_of(capitalized)
diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/__init__.py b/tests/unit/sampletones_application/ui/panels/dialogs/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/conftest.py b/tests/unit/sampletones_application/ui/panels/dialogs/conftest.py
new file mode 100644
index 00000000..514f516d
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/dialogs/conftest.py
@@ -0,0 +1,38 @@
+from typing import Iterator
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.layout.loader import load_layout_config
+from sampletones_application.paths import (
+ BEHAVIOR_DIRECTORY,
+ LAYOUT_DIRECTORY,
+ PALETTES_DIRECTORY,
+ THEME_DIRECTORY,
+)
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.ui.themes.setup import setup_themes
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.source import PaletteSource
+
+
+@pytest.fixture
+def layout_config() -> LayoutConfig:
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
+
+
+@pytest.fixture
+def dpg_context(layout_config: LayoutConfig) -> Iterator[None]:
+ """Stands up the context, fonts and themes a dialog resolves on construction, as startup does."""
+ dpg.create_context()
+ FontRegistry.setup(layout_config.fonts)
+ FontRegistry.register_fonts(layout_config.fonts.scale)
+ setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default))
+ try:
+ yield
+ finally:
+ ThemeRegistry.clear()
+ dpg.destroy_context()
diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_countdown.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_countdown.py
new file mode 100644
index 00000000..3238e462
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_countdown.py
@@ -0,0 +1,85 @@
+from typing import Final, List
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.paths import LANG_EN
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import SUF_BUTTON
+from sampletones_application.tags.settings import (
+ TAG_SETTINGS_DISPLAY_BUTTON_KEEP,
+ TAG_SETTINGS_DISPLAY_BUTTON_REVERT,
+ TAG_SETTINGS_DISPLAY_TEXT_COUNTDOWN,
+)
+from sampletones_application.ui.panels.dialogs.countdown import GUICountdownWindow
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from tests.suite.shortcuts import shipped_source
+
+LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN)
+REMAINING_FORMAT: Final[str] = LANGUAGE_MANAGER["settings.display.template.countdown_remaining"]
+
+
+@pytest.fixture(name="window")
+def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUICountdownWindow:
+ return GUICountdownWindow(
+ layout=layout_config.settings.display.countdown,
+ title=LANGUAGE_MANAGER["settings.display.title.countdown"],
+ message=LANGUAGE_MANAGER["settings.display.message.countdown"],
+ remaining_format=REMAINING_FORMAT,
+ keep_label=LANGUAGE_MANAGER["settings.display.label.keep_button"],
+ revert_label=LANGUAGE_MANAGER["settings.display.label.revert_button"],
+ key_router=KeyRouter(),
+ shortcut_source=shipped_source(),
+ )
+
+
+def render(window: GUICountdownWindow, remaining: int) -> None:
+ """Builds the widget tree for the given count, the way ``open`` does without a live frame."""
+ window.set_remaining(remaining)
+ window.create_window()
+
+
+def press(tag: str) -> None:
+ dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))()
+
+
+class TestCountdownWindow:
+ def test_the_seconds_left_are_on_the_prompt(self, window: GUICountdownWindow) -> None:
+ render(window, 10)
+
+ assert dpg.get_value(TAG_SETTINGS_DISPLAY_TEXT_COUNTDOWN) == REMAINING_FORMAT.format(seconds=10)
+
+ def test_a_new_second_reaches_the_prompt(self, window: GUICountdownWindow) -> None:
+ render(window, 10)
+
+ window.set_remaining(9)
+
+ assert dpg.get_value(TAG_SETTINGS_DISPLAY_TEXT_COUNTDOWN) == REMAINING_FORMAT.format(seconds=9)
+
+ def test_both_answers_are_offered(self, window: GUICountdownWindow) -> None:
+ render(window, 10)
+
+ assert dpg.does_item_exist(TAG_SETTINGS_DISPLAY_BUTTON_KEEP)
+ assert dpg.does_item_exist(TAG_SETTINGS_DISPLAY_BUTTON_REVERT)
+
+
+class TestReportedAnswers:
+ @pytest.fixture(name="answers")
+ def answers_fixture(self, window: GUICountdownWindow) -> List[str]:
+ answers: List[str] = []
+ window.on_keep = lambda: answers.append("keep")
+ window.on_revert = lambda: answers.append("revert")
+ render(window, 10)
+ return answers
+
+ def test_keeping_reports_it(self, window: GUICountdownWindow, answers: List[str]) -> None:
+ press(TAG_SETTINGS_DISPLAY_BUTTON_KEEP)
+
+ assert answers == ["keep"]
+
+ def test_reverting_reports_it(self, window: GUICountdownWindow, answers: List[str]) -> None:
+ press(TAG_SETTINGS_DISPLAY_BUTTON_REVERT)
+
+ assert answers == ["revert"]
diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py
new file mode 100644
index 00000000..ccc1306a
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py
@@ -0,0 +1,222 @@
+from typing import Final, List, Tuple
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.paths import LANG_EN
+from sampletones_application.tags.settings import (
+ TAG_SETTINGS_DISPLAY_BUTTON_CANCEL,
+ TAG_SETTINGS_DISPLAY_BUTTON_OK,
+ TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS,
+ TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN,
+ TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC,
+ TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE,
+ TAG_SETTINGS_DISPLAY_COMBO_PALETTE,
+ TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION,
+)
+from sampletones_application.ui.panels.dialogs.display_settings import (
+ GUIDisplaySettingsWindow,
+)
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.view_model.shared.display_settings import (
+ DisplaySettings,
+ DisplaySettingsViewModel,
+ WindowMode,
+)
+from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution
+from tests.suite.shortcuts import shipped_source
+
+LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN)
+UNLIMITED_LABEL: Final[str] = LANGUAGE_MANAGER["settings.display.label.unlimited_frame_rate"]
+
+RESOLUTIONS: Final[Tuple[Resolution, ...]] = (
+ Resolution(width=1024, height=768),
+ Resolution(width=1280, height=800),
+ Resolution(width=1600, height=900),
+)
+FRAME_RATES: Final[Tuple[int, ...]] = (UNLIMITED_FRAME_RATE, 30, 60, 120)
+PALETTES: Final[Tuple[str, ...]] = ("dark", "light", "studio")
+
+
+def view_model(*, fullscreen: bool = False) -> DisplaySettingsViewModel:
+ return DisplaySettingsViewModel(
+ settings=DisplaySettings(
+ palette="studio",
+ window=WindowMode(
+ resolution=Resolution(width=1280, height=800),
+ borderless=False,
+ fullscreen=fullscreen,
+ ),
+ vsync=True,
+ frame_rate=60,
+ ),
+ resolutions=RESOLUTIONS,
+ frame_rates=FRAME_RATES,
+ palettes=PALETTES,
+ )
+
+
+@pytest.fixture(name="window")
+def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIDisplaySettingsWindow:
+ return GUIDisplaySettingsWindow(
+ layout=layout_config.settings,
+ language_manager=LANGUAGE_MANAGER,
+ key_router=KeyRouter(),
+ shortcut_source=shipped_source(),
+ )
+
+
+def render(window: GUIDisplaySettingsWindow, *, fullscreen: bool = False) -> None:
+ """Builds the widget tree for the given state, the way ``open`` does without a live frame."""
+ window.update_view(view_model(fullscreen=fullscreen))
+ window.create_window()
+
+
+class TestDisplaySettingsWindow:
+ def test_every_offered_size_reaches_the_combo(self, window: GUIDisplaySettingsWindow) -> None:
+ render(window)
+
+ assert dpg.get_item_configuration(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION)["items"] == [
+ "1024x768",
+ "1280x800",
+ "1600x900",
+ ]
+
+ def test_the_selected_size_is_the_one_showing(self, window: GUIDisplaySettingsWindow) -> None:
+ render(window)
+
+ assert dpg.get_value(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION) == "1280x800"
+
+ def test_the_unlimited_rate_is_offered_by_name(self, window: GUIDisplaySettingsWindow) -> None:
+ render(window)
+
+ assert UNLIMITED_LABEL in dpg.get_item_configuration(TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE)["items"]
+
+ def test_every_shipped_palette_reaches_the_combo(self, window: GUIDisplaySettingsWindow) -> None:
+ render(window)
+
+ assert dpg.get_item_configuration(TAG_SETTINGS_DISPLAY_COMBO_PALETTE)["items"] == list(PALETTES)
+
+ def test_the_switches_show_the_state_in_force(self, window: GUIDisplaySettingsWindow) -> None:
+ render(window)
+
+ assert dpg.get_value(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC) is True
+ assert dpg.get_value(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS) is False
+ assert dpg.get_value(TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN) is False
+
+ def test_a_windowed_window_offers_its_size_and_frame(self, window: GUIDisplaySettingsWindow) -> None:
+ render(window)
+
+ assert dpg.get_item_configuration(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION)["enabled"]
+ assert dpg.get_item_configuration(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS)["enabled"]
+
+ def test_a_fullscreen_window_offers_neither_a_size_nor_a_frame(self, window: GUIDisplaySettingsWindow) -> None:
+ render(window, fullscreen=True)
+
+ assert not dpg.get_item_configuration(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION)["enabled"]
+ assert not dpg.get_item_configuration(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS)["enabled"]
+
+ def test_both_actions_are_offered(self, window: GUIDisplaySettingsWindow) -> None:
+ render(window)
+
+ assert dpg.does_item_exist(TAG_SETTINGS_DISPLAY_BUTTON_OK)
+ assert dpg.does_item_exist(TAG_SETTINGS_DISPLAY_BUTTON_CANCEL)
+
+
+class TestReportedEdits:
+ """Every control reports the whole edited state, so the owner applies one value."""
+
+ @pytest.fixture(name="reported")
+ def reported_fixture(self, window: GUIDisplaySettingsWindow) -> List[DisplaySettings]:
+ reported: List[DisplaySettings] = []
+ window.on_settings_changed = reported.append
+ render(window)
+ return reported
+
+ def test_picking_a_size_reports_it(
+ self,
+ window: GUIDisplaySettingsWindow,
+ reported: List[DisplaySettings],
+ ) -> None:
+ dpg.set_value(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, "1600x900")
+ dpg.get_item_callback(TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION)(
+ TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION,
+ "1600x900",
+ )
+
+ assert reported[-1].window.resolution == Resolution(width=1600, height=900)
+
+ def test_switching_borderless_reports_it(
+ self,
+ window: GUIDisplaySettingsWindow,
+ reported: List[DisplaySettings],
+ ) -> None:
+ dpg.get_item_callback(TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS)(
+ TAG_SETTINGS_DISPLAY_CHECKBOX_BORDERLESS,
+ True,
+ )
+
+ assert reported[-1].window.borderless is True
+
+ def test_switching_fullscreen_reports_it(
+ self,
+ window: GUIDisplaySettingsWindow,
+ reported: List[DisplaySettings],
+ ) -> None:
+ dpg.get_item_callback(TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN)(
+ TAG_SETTINGS_DISPLAY_CHECKBOX_FULLSCREEN,
+ True,
+ )
+
+ assert reported[-1].window.fullscreen is True
+
+ def test_switching_vsync_reports_it(
+ self,
+ window: GUIDisplaySettingsWindow,
+ reported: List[DisplaySettings],
+ ) -> None:
+ dpg.get_item_callback(TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC)(
+ TAG_SETTINGS_DISPLAY_CHECKBOX_VSYNC,
+ False,
+ )
+
+ assert reported[-1].vsync is False
+
+ def test_picking_the_unlimited_rate_reports_it(
+ self,
+ window: GUIDisplaySettingsWindow,
+ reported: List[DisplaySettings],
+ ) -> None:
+ dpg.get_item_callback(TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE)(
+ TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE,
+ UNLIMITED_LABEL,
+ )
+
+ assert reported[-1].frame_rate == UNLIMITED_FRAME_RATE
+
+ def test_picking_a_palette_reports_it(
+ self,
+ window: GUIDisplaySettingsWindow,
+ reported: List[DisplaySettings],
+ ) -> None:
+ dpg.get_item_callback(TAG_SETTINGS_DISPLAY_COMBO_PALETTE)(
+ TAG_SETTINGS_DISPLAY_COMBO_PALETTE,
+ "dark",
+ )
+
+ assert reported[-1].palette == "dark"
+
+ def test_an_edit_leaves_the_rest_of_the_state_standing(
+ self,
+ window: GUIDisplaySettingsWindow,
+ reported: List[DisplaySettings],
+ ) -> None:
+ dpg.get_item_callback(TAG_SETTINGS_DISPLAY_COMBO_PALETTE)(
+ TAG_SETTINGS_DISPLAY_COMBO_PALETTE,
+ "dark",
+ )
+
+ assert reported[-1].vsync is True
+ assert reported[-1].window == view_model().settings.window
diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py
new file mode 100644
index 00000000..1dfe4852
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py
@@ -0,0 +1,383 @@
+from typing import Final, List, Optional, Tuple
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.paths import LANG_EN
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import SUF_BUTTON
+from sampletones_application.tags.settings import (
+ PRE_SETTINGS_KEYBINDINGS_GROUP,
+ PRE_SETTINGS_KEYBINDINGS_ROW,
+ SUF_SETTINGS_KEYBINDINGS_ACTION,
+ SUF_SETTINGS_KEYBINDINGS_SHORTCUT,
+ TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL,
+ TAG_SETTINGS_KEYBINDINGS_BUTTON_CLEAR,
+ TAG_SETTINGS_KEYBINDINGS_BUTTON_OK,
+ TAG_SETTINGS_KEYBINDINGS_BUTTON_RESET,
+ TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME,
+ TAG_SETTINGS_KEYBINDINGS_INPUT_FILTER,
+ TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT,
+ TAG_SETTINGS_KEYBINDINGS_TEXT_MESSAGE,
+)
+from sampletones_application.ui.panels.dialogs.keybindings import GUIKeybindingsWindow
+from sampletones_application.utils.gui.keyboard import KeyCombination, KeyEvent, KeyRouter
+from sampletones_application.utils.gui.keyboard.modifiers import CTRL, CTRL_ALT, NO_MODIFIERS
+from sampletones_application.view_model.shared.keybindings import (
+ KeybindingGroup,
+ KeybindingRow,
+ KeybindingsViewModel,
+)
+from tests.suite.shortcuts import shipped_source
+
+LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN)
+UNBOUND_LABEL: Final[str] = LANGUAGE_MANAGER["settings.keybindings.label.unbound"]
+CAPTURING_MESSAGE: Final[str] = LANGUAGE_MANAGER["settings.keybindings.message.capturing"]
+
+SAVE_PROJECT: Final[str] = "SaveProject"
+ABOUT_DIALOG: Final[str] = "AboutDialog"
+TRACKER_NEXT_ROW: Final[str] = "TrackerNextRow"
+
+SCHEMES: Final[Tuple[str, ...]] = ("default", "studio")
+
+
+def row_tag(action: str) -> str:
+ return compose_tag(PRE_SETTINGS_KEYBINDINGS_ROW, action)
+
+
+def action_tag(action: str) -> str:
+ return compose_tag(row_tag(action), SUF_SETTINGS_KEYBINDINGS_ACTION)
+
+
+def shortcut_tag(action: str) -> str:
+ return compose_tag(row_tag(action), SUF_SETTINGS_KEYBINDINGS_SHORTCUT)
+
+
+def group_tag(category: str) -> str:
+ return compose_tag(PRE_SETTINGS_KEYBINDINGS_GROUP, category)
+
+
+def view_model(
+ *,
+ selected: Optional[str] = None,
+ combination: str = "",
+ message: str = "",
+) -> KeybindingsViewModel:
+ return KeybindingsViewModel(
+ groups=(
+ KeybindingGroup(
+ category="application",
+ label="Application",
+ rows=(
+ KeybindingRow(action=SAVE_PROJECT, label="Save project", combination="Ctrl+S"),
+ KeybindingRow(action=ABOUT_DIALOG, label="About", combination=""),
+ ),
+ ),
+ KeybindingGroup(
+ category="tracker",
+ label="Tracker",
+ rows=(KeybindingRow(action=TRACKER_NEXT_ROW, label="Next row", combination="Down"),),
+ ),
+ ),
+ schemes=SCHEMES,
+ scheme="default",
+ selected=selected,
+ combination=combination,
+ message=message,
+ )
+
+
+class Harness:
+ """The window built on a router of its own, with the gestures a user makes spelled as methods."""
+
+ def __init__(self, layout_config: LayoutConfig) -> None:
+ self.router = KeyRouter()
+ self.window = GUIKeybindingsWindow(
+ layout=layout_config.settings,
+ language_manager=LANGUAGE_MANAGER,
+ key_router=self.router,
+ shortcut_source=shipped_source(),
+ )
+ self.selected: List[str] = []
+ self.typed: List[str] = []
+ self.captured: List[KeyCombination] = []
+ self.schemes: List[str] = []
+ self.gestures: List[str] = []
+ self.window.on_action_selected = self.selected.append
+ self.window.on_combination_typed = self.typed.append
+ self.window.on_combination_captured = self.captured.append
+ self.window.on_scheme_selected = self.schemes.append
+ self.window.on_clear = lambda: self.gestures.append("clear")
+ self.window.on_reset = lambda: self.gestures.append("reset")
+ self.window.on_commit = lambda: self.gestures.append("commit")
+ self.window.on_cancel = lambda: self.gestures.append("cancel")
+
+ def render(self, model: Optional[KeybindingsViewModel] = None) -> None:
+ """Builds the widget tree for the given view, the way ``open`` does without a live frame."""
+ self.window.update_view(model if model is not None else view_model())
+ self.window.create_window()
+
+ def show(self, model: KeybindingsViewModel) -> None:
+ self.window.update_view(model)
+
+ def click_action(self, action: str) -> None:
+ dpg.get_item_callback(action_tag(action))(action_tag(action), True, action)
+
+ def click_shortcut(self, action: str) -> None:
+ dpg.get_item_callback(shortcut_tag(action))(shortcut_tag(action), True, action)
+
+ def type_filter(self, text: str) -> None:
+ dpg.get_item_callback(TAG_SETTINGS_KEYBINDINGS_INPUT_FILTER)(
+ TAG_SETTINGS_KEYBINDINGS_INPUT_FILTER,
+ text,
+ )
+
+ def type_shortcut(self, text: str) -> None:
+ dpg.get_item_callback(TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT)(
+ TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT,
+ text,
+ )
+
+ def press(self, key: int, modifiers: frozenset = NO_MODIFIERS) -> None:
+ self.router.route(KeyEvent(key=key, modifiers=modifiers))
+
+ @staticmethod
+ def press_button(tag: str) -> None:
+ dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))()
+
+ @staticmethod
+ def label_of(tag: str) -> str:
+ label: str = dpg.get_item_configuration(tag)["label"]
+ return label
+
+ @staticmethod
+ def is_shown(tag: str) -> bool:
+ shown: bool = dpg.get_item_configuration(tag)["show"]
+ return shown
+
+
+@pytest.fixture(name="harness")
+def harness_fixture(dpg_context: None, layout_config: LayoutConfig) -> Harness:
+ return Harness(layout_config)
+
+
+class TestActionList:
+ def test_every_action_reaches_a_row(self, harness: Harness) -> None:
+ harness.render()
+
+ assert dpg.does_item_exist(row_tag(SAVE_PROJECT))
+ assert dpg.does_item_exist(row_tag(ABOUT_DIALOG))
+ assert dpg.does_item_exist(row_tag(TRACKER_NEXT_ROW))
+
+ def test_every_scope_reaches_a_header(self, harness: Harness) -> None:
+ harness.render()
+
+ assert dpg.does_item_exist(group_tag("application"))
+ assert dpg.does_item_exist(group_tag("tracker"))
+
+ def test_a_row_reads_the_keys_its_action_answers(self, harness: Harness) -> None:
+ harness.render()
+
+ assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == "Ctrl+S"
+
+ def test_an_action_carrying_no_keys_reads_as_unassigned(self, harness: Harness) -> None:
+ harness.render()
+
+ assert harness.label_of(shortcut_tag(ABOUT_DIALOG)) == UNBOUND_LABEL
+
+ def test_every_shipped_scheme_reaches_the_combo(self, harness: Harness) -> None:
+ harness.render()
+
+ assert dpg.get_item_configuration(TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME)["items"] == list(SCHEMES)
+
+ def test_a_later_view_re_reads_the_rows_already_built(self, harness: Harness) -> None:
+ harness.render()
+ harness.show(
+ KeybindingsViewModel(
+ groups=(
+ KeybindingGroup(
+ category="application",
+ label="Application",
+ rows=(
+ KeybindingRow(action=SAVE_PROJECT, label="Save project", combination="Ctrl+Alt+B"),
+ KeybindingRow(action=ABOUT_DIALOG, label="About", combination=""),
+ ),
+ ),
+ KeybindingGroup(
+ category="tracker",
+ label="Tracker",
+ rows=(KeybindingRow(action=TRACKER_NEXT_ROW, label="Next row", combination="Down"),),
+ ),
+ ),
+ schemes=SCHEMES,
+ scheme="default",
+ selected=None,
+ combination="",
+ message="",
+ )
+ )
+
+ assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == "Ctrl+Alt+B"
+
+ def test_the_message_line_shows_what_the_owner_reported(self, harness: Harness) -> None:
+ harness.render(view_model(message="Ctrl+Nonsense names no key on the keyboard."))
+
+ assert dpg.get_value(TAG_SETTINGS_KEYBINDINGS_TEXT_MESSAGE).startswith("Ctrl+Nonsense")
+
+ def test_every_action_is_offered(self, harness: Harness) -> None:
+ harness.render()
+
+ assert dpg.does_item_exist(TAG_SETTINGS_KEYBINDINGS_BUTTON_CLEAR)
+ assert dpg.does_item_exist(TAG_SETTINGS_KEYBINDINGS_BUTTON_RESET)
+ assert dpg.does_item_exist(TAG_SETTINGS_KEYBINDINGS_BUTTON_OK)
+ assert dpg.does_item_exist(TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL)
+
+
+class TestFilter:
+ def test_an_empty_filter_leaves_every_row_listed(self, harness: Harness) -> None:
+ harness.render()
+
+ assert harness.is_shown(row_tag(SAVE_PROJECT))
+ assert harness.is_shown(row_tag(TRACKER_NEXT_ROW))
+
+ def test_a_filter_leaves_only_the_rows_it_matches(self, harness: Harness) -> None:
+ harness.render()
+ harness.type_filter("row")
+
+ assert harness.is_shown(row_tag(TRACKER_NEXT_ROW))
+ assert not harness.is_shown(row_tag(SAVE_PROJECT))
+
+ def test_a_filter_reads_the_keys_as_well_as_the_name(self, harness: Harness) -> None:
+ harness.render()
+ harness.type_filter("ctrl+s")
+
+ assert harness.is_shown(row_tag(SAVE_PROJECT))
+ assert not harness.is_shown(row_tag(TRACKER_NEXT_ROW))
+
+ def test_a_scope_the_filter_empties_takes_its_header_with_it(self, harness: Harness) -> None:
+ harness.render()
+ harness.type_filter("row")
+
+ assert harness.is_shown(group_tag("tracker"))
+ assert not harness.is_shown(group_tag("application"))
+
+ def test_clearing_the_filter_lists_every_row_again(self, harness: Harness) -> None:
+ harness.render()
+ harness.type_filter("row")
+ harness.type_filter("")
+
+ assert harness.is_shown(row_tag(SAVE_PROJECT))
+ assert harness.is_shown(group_tag("application"))
+
+
+class TestSelection:
+ def test_clicking_an_action_reports_it(self, harness: Harness) -> None:
+ harness.render()
+ harness.click_action(SAVE_PROJECT)
+
+ assert harness.selected == [SAVE_PROJECT]
+
+ def test_clicking_a_shortcut_reports_the_action_too(self, harness: Harness) -> None:
+ harness.render()
+ harness.click_shortcut(SAVE_PROJECT)
+
+ assert harness.selected == [SAVE_PROJECT]
+
+ def test_the_selected_row_reads_as_selected(self, harness: Harness) -> None:
+ harness.render(view_model(selected=SAVE_PROJECT))
+
+ assert dpg.get_value(action_tag(SAVE_PROJECT)) is True
+ assert dpg.get_value(shortcut_tag(SAVE_PROJECT)) is True
+ assert dpg.get_value(action_tag(ABOUT_DIALOG)) is False
+
+ def test_the_entry_box_shows_the_selected_action_keys(self, harness: Harness) -> None:
+ harness.render(view_model(selected=SAVE_PROJECT, combination="Ctrl+S"))
+
+ assert dpg.get_value(TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT) == "Ctrl+S"
+
+
+class TestCapture:
+ def test_clicking_a_shortcut_listens_for_a_press(self, harness: Harness) -> None:
+ harness.render(view_model(selected=SAVE_PROJECT))
+ harness.click_shortcut(SAVE_PROJECT)
+ harness.press(dpg.mvKey_G, CTRL_ALT)
+
+ assert harness.captured == [KeyCombination(dpg.mvKey_G, CTRL_ALT)]
+
+ def test_a_listening_cell_asks_for_the_press(self, harness: Harness) -> None:
+ harness.render(view_model(selected=SAVE_PROJECT))
+ harness.click_shortcut(SAVE_PROJECT)
+
+ assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == CAPTURING_MESSAGE
+
+ def test_a_cancelled_capture_leaves_the_cell_reading_its_keys(self, harness: Harness) -> None:
+ harness.render(view_model(selected=SAVE_PROJECT))
+ harness.click_shortcut(SAVE_PROJECT)
+ harness.press(dpg.mvKey_Escape)
+
+ assert harness.captured == []
+ assert harness.label_of(shortcut_tag(SAVE_PROJECT)) == "Ctrl+S"
+
+ def test_clicking_an_action_listens_for_nothing(self, harness: Harness) -> None:
+ """The name cell selects the row, which leaves the keyboard where it was."""
+ harness.render(view_model(selected=SAVE_PROJECT))
+ harness.click_action(SAVE_PROJECT)
+ harness.press(dpg.mvKey_G, CTRL_ALT)
+
+ assert harness.captured == []
+
+ def test_selecting_another_row_stops_listening(self, harness: Harness) -> None:
+ harness.render(view_model(selected=SAVE_PROJECT))
+ harness.click_shortcut(SAVE_PROJECT)
+ harness.click_action(ABOUT_DIALOG)
+ harness.press(dpg.mvKey_G, CTRL_ALT)
+
+ assert harness.captured == []
+
+
+class TestReportedGestures:
+ def test_a_written_combination_is_reported_on_entry(self, harness: Harness) -> None:
+ harness.render(view_model(selected=SAVE_PROJECT))
+ harness.type_shortcut("Ctrl+Alt+B")
+
+ assert harness.typed == ["Ctrl+Alt+B"]
+
+ def test_picking_a_scheme_reports_it(self, harness: Harness) -> None:
+ harness.render()
+ dpg.get_item_callback(TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME)(
+ TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME,
+ "studio",
+ )
+
+ assert harness.schemes == ["studio"]
+
+ @pytest.mark.parametrize(
+ "tag, gesture",
+ [
+ (TAG_SETTINGS_KEYBINDINGS_BUTTON_CLEAR, "clear"),
+ (TAG_SETTINGS_KEYBINDINGS_BUTTON_RESET, "reset"),
+ (TAG_SETTINGS_KEYBINDINGS_BUTTON_OK, "commit"),
+ (TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL, "cancel"),
+ ],
+ ids=["clear", "reset", "commit", "cancel"],
+ )
+ def test_every_button_reports_what_it_stands_for(
+ self,
+ harness: Harness,
+ tag: str,
+ gesture: str,
+ ) -> None:
+ harness.render()
+ harness.press_button(tag)
+
+ assert harness.gestures == [gesture]
+
+ def test_a_button_pressed_mid_capture_stops_listening(self, harness: Harness) -> None:
+ harness.render(view_model(selected=SAVE_PROJECT))
+ harness.click_shortcut(SAVE_PROJECT)
+ harness.press_button(TAG_SETTINGS_KEYBINDINGS_BUTTON_CANCEL)
+ harness.press(dpg.mvKey_G, CTRL)
+
+ assert harness.captured == []
diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py
new file mode 100644
index 00000000..0dd8e62f
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py
@@ -0,0 +1,167 @@
+from datetime import datetime
+from typing import Final, List, Tuple
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.paths import LANG_EN
+from sampletones_application.tags.settings import (
+ TAG_SETTINGS_PROPERTIES_BUTTON_CANCEL,
+ TAG_SETTINGS_PROPERTIES_BUTTON_OK,
+ TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR,
+ TAG_SETTINGS_PROPERTIES_INPUT_COMMENT,
+ TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT,
+ TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT,
+ TAG_SETTINGS_PROPERTIES_INPUT_TITLE,
+)
+from sampletones_application.ui.panels.dialogs.project_properties import (
+ GUIProjectPropertiesWindow,
+)
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.view_model.shared.project_properties import (
+ ProjectPropertiesViewModel,
+)
+from sampletones_shared.constants.project import MAX_HIGHLIGHT, MIN_HIGHLIGHT
+from tests.suite.shortcuts import shipped_source
+
+LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN)
+
+TIMESTAMP: Final[datetime] = datetime(2026, 8, 10, 12, 30)
+FIRST_HIGHLIGHT: Final[int] = 4
+SECOND_HIGHLIGHT: Final[int] = 12
+
+Committed = Tuple[str, str, str, int, int]
+
+
+def view_model(
+ *,
+ first_highlight: int = FIRST_HIGHLIGHT,
+ second_highlight: int = SECOND_HIGHLIGHT,
+) -> ProjectPropertiesViewModel:
+ return ProjectPropertiesViewModel(
+ title="Chiptune",
+ author="Composer",
+ comment="A note to self",
+ first_highlight=first_highlight,
+ second_highlight=second_highlight,
+ created=TIMESTAMP,
+ modified=TIMESTAMP,
+ )
+
+
+@pytest.fixture(name="window")
+def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIProjectPropertiesWindow:
+ return GUIProjectPropertiesWindow(
+ layout=layout_config.project_properties,
+ language_manager=LANGUAGE_MANAGER,
+ key_router=KeyRouter(),
+ shortcut_source=shipped_source(),
+ )
+
+
+def render(
+ window: GUIProjectPropertiesWindow,
+ *,
+ first_highlight: int = FIRST_HIGHLIGHT,
+ second_highlight: int = SECOND_HIGHLIGHT,
+) -> None:
+ """Builds the widget tree for the given project, the way ``open`` does without a live frame."""
+ window._seed(
+ view_model(
+ first_highlight=first_highlight,
+ second_highlight=second_highlight,
+ )
+ )
+ window.create_window()
+
+
+class TestProjectPropertiesWindow:
+ def test_the_info_shows_the_project_s_own(self, window: GUIProjectPropertiesWindow) -> None:
+ render(window)
+
+ assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE) == "Chiptune"
+ assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR) == "Composer"
+ assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_COMMENT) == "A note to self"
+
+ def test_the_metre_shows_the_project_s_highlights(self, window: GUIProjectPropertiesWindow) -> None:
+ render(window)
+
+ assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT) == FIRST_HIGHLIGHT
+ assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT) == SECOND_HIGHLIGHT
+
+ def test_each_highlight_field_holds_the_range_the_project_accepts(
+ self,
+ window: GUIProjectPropertiesWindow,
+ ) -> None:
+ render(window)
+
+ for tag in (
+ TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT,
+ TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT,
+ ):
+ configuration = dpg.get_item_configuration(tag)
+ assert configuration["min_value"] == MIN_HIGHLIGHT
+ assert configuration["max_value"] == MAX_HIGHLIGHT
+
+ def test_both_actions_are_offered(self, window: GUIProjectPropertiesWindow) -> None:
+ render(window)
+
+ assert dpg.does_item_exist(TAG_SETTINGS_PROPERTIES_BUTTON_OK)
+ assert dpg.does_item_exist(TAG_SETTINGS_PROPERTIES_BUTTON_CANCEL)
+
+
+class TestCommit:
+ """Confirming reports the whole form at once, so the owner applies one undoable gesture."""
+
+ @pytest.fixture(name="committed")
+ def committed_fixture(self, window: GUIProjectPropertiesWindow) -> List[Committed]:
+ committed: List[Committed] = []
+ window.on_commit = lambda title, author, comment, first_highlight, second_highlight: committed.append(
+ (title, author, comment, first_highlight, second_highlight)
+ )
+ render(window)
+ return committed
+
+ def test_the_edited_metre_reaches_the_owner(
+ self,
+ window: GUIProjectPropertiesWindow,
+ committed: List[Committed],
+ ) -> None:
+ dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, 3)
+ dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, 9)
+
+ window._commit()
+
+ assert committed == [("Chiptune", "Composer", "A note to self", 3, 9)]
+
+ def test_a_highlight_past_the_range_arrives_clamped(
+ self,
+ window: GUIProjectPropertiesWindow,
+ committed: List[Committed],
+ ) -> None:
+ """The project rejects a highlight outside its bounds, so the dialog reports one inside."""
+ dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, MAX_HIGHLIGHT + 1)
+ dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, MIN_HIGHLIGHT - 1)
+
+ window._commit()
+
+ assert committed[-1][3:] == (MAX_HIGHLIGHT, MIN_HIGHLIGHT)
+
+ def test_the_metre_carries_the_info_with_it(
+ self,
+ window: GUIProjectPropertiesWindow,
+ committed: List[Committed],
+ ) -> None:
+ dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE, "Another song")
+
+ window._commit()
+
+ assert committed[-1] == (
+ "Another song",
+ "Composer",
+ "A note to self",
+ FIRST_HIGHLIGHT,
+ SECOND_HIGHLIGHT,
+ )
diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py
new file mode 100644
index 00000000..9b8cece3
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py
@@ -0,0 +1,309 @@
+from pathlib import Path
+from typing import Final, List, Optional
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.paths import LANG_EN
+from sampletones_application.tags.compose import compose_tag
+from sampletones_application.tags.general import SUF_BUTTON
+from sampletones_application.tags.settings import (
+ TAG_SETTINGS_RENDER_BUTTON_BROWSE,
+ TAG_SETTINGS_RENDER_BUTTON_CANCEL,
+ TAG_SETTINGS_RENDER_BUTTON_CLOSE,
+ TAG_SETTINGS_RENDER_BUTTON_START,
+ TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE,
+ TAG_SETTINGS_RENDER_COMBO_BITRATE,
+ TAG_SETTINGS_RENDER_COMBO_DEPTH,
+ TAG_SETTINGS_RENDER_COMBO_FORMAT,
+ TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE,
+ TAG_SETTINGS_RENDER_GROUP_BITRATE,
+ TAG_SETTINGS_RENDER_GROUP_DEPTH,
+ TAG_SETTINGS_RENDER_GROUP_PROGRESS,
+ TAG_SETTINGS_RENDER_GROUP_SETUP,
+ TAG_SETTINGS_RENDER_PATH_DESTINATION,
+ TAG_SETTINGS_RENDER_PROGRESS,
+ TAG_SETTINGS_RENDER_TEXT_DURATION,
+ TAG_SETTINGS_RENDER_TEXT_STATUS,
+)
+from sampletones_application.ui.elements.status import GUIStatusBar
+from sampletones_application.ui.panels.dialogs.render import GUIRenderWindow
+from sampletones_application.utils.gui.keyboard import KeyRouter
+from sampletones_application.view_model.shared.render import (
+ RenderPhase,
+ SongRenderSettings,
+ SongRenderViewModel,
+)
+from sampletones_core.audio.writers import AudioDepth, AudioFormat
+from sampletones_shared.utils.system.paths import shorten_path
+from tests.suite.shortcuts import shipped_source
+
+LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN)
+DESTINATION: Final[Path] = Path("/home/user/audio/chiptune.wav")
+TOTAL_SAMPLES: Final[int] = 44100 * 90
+STATUS_TEXT: Final[str] = "Rendering the song..."
+
+
+def view_model(
+ *,
+ settings: SongRenderSettings,
+ phase: RenderPhase = RenderPhase.CONFIGURING,
+ progress: float = 0.0,
+) -> SongRenderViewModel:
+ return SongRenderViewModel(
+ phase=phase,
+ formats=(AudioFormat.WAVE, AudioFormat.MP3),
+ depths=(AudioDepth.PCM_U8, AudioDepth.PCM_16, AudioDepth.PCM_24),
+ settings=settings,
+ destination=DESTINATION,
+ total_samples=TOTAL_SAMPLES,
+ status_text=STATUS_TEXT if phase == RenderPhase.RENDERING else "",
+ progress=progress,
+ )
+
+
+def wave_settings() -> SongRenderSettings:
+ return SongRenderSettings.initial(AudioFormat.WAVE)
+
+
+def mp3_settings() -> SongRenderSettings:
+ return SongRenderSettings.initial(AudioFormat.MP3)
+
+
+@pytest.fixture(name="window")
+def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIRenderWindow:
+ return GUIRenderWindow(
+ layout=layout_config.settings,
+ path_colors=layout_config.general.colors.paths,
+ language_manager=LANGUAGE_MANAGER,
+ key_router=KeyRouter(),
+ shortcut_source=shipped_source(),
+ status_bar=GUIStatusBar(display_time=1.0),
+ )
+
+
+def render(
+ window: GUIRenderWindow,
+ *,
+ settings: Optional[SongRenderSettings] = None,
+ phase: RenderPhase = RenderPhase.CONFIGURING,
+ progress: float = 0.0,
+) -> None:
+ """Builds the widget tree for the given state, the way ``open`` does without a live frame."""
+ window.update_view(
+ view_model(
+ settings=settings if settings is not None else wave_settings(),
+ phase=phase,
+ progress=progress,
+ )
+ )
+ window.create_window()
+
+
+def press(tag: str) -> None:
+ dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))()
+
+
+class TestTheSetup:
+ def test_every_written_container_reaches_the_combo(self, window: GUIRenderWindow) -> None:
+ render(window)
+
+ assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_FORMAT)["items"] == ["WAV", "MP3"]
+
+ def test_the_rates_offered_are_the_containers_own(self, window: GUIRenderWindow) -> None:
+ render(window, settings=mp3_settings())
+
+ assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE)["items"] == [
+ "8000 Hz",
+ "16000 Hz",
+ "22050 Hz",
+ "44100 Hz",
+ "48000 Hz",
+ ]
+
+ def test_a_container_storing_samples_offers_a_depth(self, window: GUIRenderWindow) -> None:
+ render(window)
+
+ assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_DEPTH)["show"]
+ assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_BITRATE)["show"]
+ assert dpg.get_value(TAG_SETTINGS_RENDER_COMBO_DEPTH) == "16-bit PCM"
+
+ def test_a_container_encoding_to_a_bitrate_offers_one(self, window: GUIRenderWindow) -> None:
+ render(window, settings=mp3_settings())
+
+ assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_BITRATE)["show"]
+ assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_DEPTH)["show"]
+ assert dpg.get_value(TAG_SETTINGS_RENDER_COMBO_BITRATE) == "192 kbps"
+
+ def test_the_song_is_shown_at_the_length_it_renders_to(self, window: GUIRenderWindow) -> None:
+ render(window)
+
+ assert dpg.get_value(TAG_SETTINGS_RENDER_TEXT_DURATION) == "1m 30s"
+
+ def test_the_file_and_the_actions_over_it_are_offered(self, window: GUIRenderWindow) -> None:
+ render(window)
+
+ assert dpg.get_value(TAG_SETTINGS_RENDER_PATH_DESTINATION) == shorten_path(DESTINATION)
+ assert dpg.does_item_exist(TAG_SETTINGS_RENDER_BUTTON_BROWSE)
+ assert dpg.does_item_exist(TAG_SETTINGS_RENDER_BUTTON_START)
+ assert dpg.does_item_exist(TAG_SETTINGS_RENDER_BUTTON_CLOSE)
+
+
+class TestTheTwoFaces:
+ def test_setting_up_shows_the_setup_alone(self, window: GUIRenderWindow) -> None:
+ render(window)
+
+ assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_SETUP)["show"]
+ assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_PROGRESS)["show"]
+
+ def test_rendering_shows_the_progress_alone(self, window: GUIRenderWindow) -> None:
+ render(window, phase=RenderPhase.RENDERING, progress=0.5)
+
+ assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_PROGRESS)["show"]
+ assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_SETUP)["show"]
+ assert dpg.get_value(TAG_SETTINGS_RENDER_PROGRESS) == pytest.approx(0.5)
+ assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_PROGRESS)["overlay"] == "50%"
+ assert dpg.get_value(TAG_SETTINGS_RENDER_TEXT_STATUS) == STATUS_TEXT
+
+ def test_a_control_off_screen_takes_no_focus(self, window: GUIRenderWindow) -> None:
+ """The focus ring skips a disabled stop, which is what keeps Tab on the face being shown."""
+ render(window, phase=RenderPhase.RENDERING)
+
+ assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_FORMAT)["enabled"]
+ assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_DEPTH)["enabled"]
+ assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_BUTTON_CANCEL)["enabled"]
+
+ def test_a_render_already_stopping_takes_no_further_stop(self, window: GUIRenderWindow) -> None:
+ render(window, phase=RenderPhase.CANCELLING)
+
+ assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_BUTTON_CANCEL)["enabled"]
+
+ def test_the_hidden_choice_takes_no_focus(self, window: GUIRenderWindow) -> None:
+ render(window, settings=mp3_settings())
+
+ assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_DEPTH)["enabled"]
+ assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_BITRATE)["enabled"]
+
+
+class TestReportedEdits:
+ """Every control reports the whole edited state, so the owner reconciles one value."""
+
+ @pytest.fixture(name="reported")
+ def reported_fixture(self, window: GUIRenderWindow) -> List[SongRenderSettings]:
+ reported: List[SongRenderSettings] = []
+ window.on_settings_changed = reported.append
+ render(window)
+ return reported
+
+ def test_picking_a_container_reports_it(
+ self,
+ window: GUIRenderWindow,
+ reported: List[SongRenderSettings],
+ ) -> None:
+ dpg.get_item_callback(TAG_SETTINGS_RENDER_COMBO_FORMAT)(TAG_SETTINGS_RENDER_COMBO_FORMAT, "MP3")
+
+ assert reported[-1].spec.audio_format == AudioFormat.MP3
+
+ def test_picking_a_rate_reports_it(
+ self,
+ window: GUIRenderWindow,
+ reported: List[SongRenderSettings],
+ ) -> None:
+ dpg.get_item_callback(TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE)(
+ TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE,
+ "8000 Hz",
+ )
+
+ assert reported[-1].spec.sample_rate == 8000
+
+ def test_picking_a_depth_reports_it(
+ self,
+ window: GUIRenderWindow,
+ reported: List[SongRenderSettings],
+ ) -> None:
+ dpg.get_item_callback(TAG_SETTINGS_RENDER_COMBO_DEPTH)(
+ TAG_SETTINGS_RENDER_COMBO_DEPTH,
+ "8-bit PCM",
+ )
+
+ assert reported[-1].depth == AudioDepth.PCM_U8
+
+ def test_picking_a_bitrate_reports_it(self, window: GUIRenderWindow) -> None:
+ reported: List[SongRenderSettings] = []
+ window.on_settings_changed = reported.append
+ render(window, settings=mp3_settings())
+
+ dpg.get_item_callback(TAG_SETTINGS_RENDER_COMBO_BITRATE)(
+ TAG_SETTINGS_RENDER_COMBO_BITRATE,
+ "128 kbps",
+ )
+
+ assert reported[-1].bitrate == 128
+
+ def test_asking_for_the_peak_to_reach_full_scale_reports_it(
+ self,
+ window: GUIRenderWindow,
+ reported: List[SongRenderSettings],
+ ) -> None:
+ dpg.get_item_callback(TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE)(
+ TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE,
+ True,
+ )
+
+ assert reported[-1].normalize
+
+
+class TestReportedActions:
+ def test_the_browse_button_asks_for_a_file(self, window: GUIRenderWindow) -> None:
+ asked: List[None] = []
+ window.on_browse = lambda: asked.append(None)
+ render(window)
+
+ press(TAG_SETTINGS_RENDER_BUTTON_BROWSE)
+
+ assert asked
+
+ def test_the_render_button_starts_the_render(self, window: GUIRenderWindow) -> None:
+ started: List[None] = []
+ window.on_render = lambda: started.append(None)
+ render(window)
+
+ press(TAG_SETTINGS_RENDER_BUTTON_START)
+
+ assert started
+
+ def test_the_stop_button_stops_a_running_render(self, window: GUIRenderWindow) -> None:
+ stopped: List[None] = []
+ window.on_cancel = lambda: stopped.append(None)
+ render(window, phase=RenderPhase.RENDERING)
+
+ press(TAG_SETTINGS_RENDER_BUTTON_CANCEL)
+
+ assert stopped
+
+ def test_leaving_the_setup_closes_the_dialog(self, window: GUIRenderWindow) -> None:
+ closed: List[None] = []
+ stopped: List[None] = []
+ window.on_close = lambda: closed.append(None)
+ window.on_cancel = lambda: stopped.append(None)
+ render(window)
+
+ press(TAG_SETTINGS_RENDER_BUTTON_CLOSE)
+
+ assert closed
+ assert not stopped
+
+ def test_leaving_a_running_render_stops_it_instead(self, window: GUIRenderWindow) -> None:
+ """Escape and the title bar answer through the same handler the Cancel button does."""
+ closed: List[None] = []
+ stopped: List[None] = []
+ window.on_close = lambda: closed.append(None)
+ window.on_cancel = lambda: stopped.append(None)
+ render(window, phase=RenderPhase.RENDERING)
+
+ press(TAG_SETTINGS_RENDER_BUTTON_CLOSE)
+
+ assert stopped
+ assert not closed
diff --git a/tests/unit/sampletones_application/ui/panels/instruction/test_details_instruction_changed.py b/tests/unit/sampletones_application/ui/panels/instruction/test_details_instruction_changed.py
index 98eb4668..5babe2a9 100644
--- a/tests/unit/sampletones_application/ui/panels/instruction/test_details_instruction_changed.py
+++ b/tests/unit/sampletones_application/ui/panels/instruction/test_details_instruction_changed.py
@@ -4,15 +4,23 @@
import pytest
from sampletones_application.ui.panels.instruction import choice as choice_module
-from sampletones_application.ui.panels.instruction.choice import GUIInstructionChoicePanel
+from sampletones_application.ui.panels.instruction.choice import (
+ GUIInstructionChoicePanel,
+)
from sampletones_core.constants.enums import GeneratorClassName
-from sampletones_core.instructions import InstructionUnion, NoiseInstruction, PulseInstruction, TriangleInstruction
+from sampletones_core.instructions import (
+ InstructionUnion,
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
@pytest.fixture(autouse=True)
def stub_dpg(monkeypatch: pytest.MonkeyPatch) -> None:
"""The volume, duty-cycle, and short controls are read straight from DearPyGui; the pitch and period
- come from the stepper. Returning fixed slider values isolates the rebuild from a live GUI."""
+ come from the stepper. Returning fixed slider values isolates the rebuild from a live GUI.
+ """
monkeypatch.setattr(choice_module.dpg, "get_value", lambda tag: 7)
monkeypatch.setattr(choice_module.dpg, "set_value", lambda tag, value: None)
diff --git a/tests/unit/sampletones_application/ui/panels/instruction/test_library_actions_lock.py b/tests/unit/sampletones_application/ui/panels/instruction/test_library_actions_lock.py
index e11ff062..70465e23 100644
--- a/tests/unit/sampletones_application/ui/panels/instruction/test_library_actions_lock.py
+++ b/tests/unit/sampletones_application/ui/panels/instruction/test_library_actions_lock.py
@@ -52,7 +52,8 @@ def _panel(*, busy: bool = False) -> GUIInstructionsLibraryPanel:
class TestGenerateButtonLock:
"""The generate button stays enabled only while the panel is unlocked and no long operation is
- running. Both inputs are read live, and the tree-rebuild lock composes with the busy state."""
+ running. Both inputs are read live, and the tree-rebuild lock composes with the busy state.
+ """
def test_busy_disables_generate_button(self, recorder: _ConfigureRecorder) -> None:
panel = _panel(busy=True)
diff --git a/tests/unit/sampletones_application/ui/panels/main/__init__.py b/tests/unit/sampletones_application/ui/panels/main/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py
new file mode 100644
index 00000000..ddaf4648
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py
@@ -0,0 +1,198 @@
+from pathlib import Path
+from typing import List, Set, Tuple
+
+import pytest
+
+from sampletones_application.ui.elements.tree import tree as tree_module
+from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory
+from sampletones_application.ui.panels.main import explorer as explorer_module
+from sampletones_application.ui.panels.main.explorer import GUIExplorerPanel
+from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode
+
+PANEL_TAG = "main_explorer"
+ROOT = Path("/")
+MUSIC = ROOT / "music"
+
+
+class FakeExplorerLogic:
+ """Answers what the panel asks of its model, recording what it is told about each folder."""
+
+ def __init__(self, tree: Tree) -> None:
+ self.tree = tree
+ self.cleared: List[Tuple[str, ...]] = []
+ self.loaded: Set[Path] = set()
+ self.read: List[Path] = []
+ self.standing: List[Tuple[Path, bool]] = []
+
+ def has_loaded_children(self, filepath: Path) -> bool:
+ return filepath in self.loaded
+
+ def expand_directory(self, node: FileSystemNode) -> None:
+ self.read.append(node.filepath)
+ self.loaded.add(node.filepath)
+
+ def set_directory_open(self, filepath: Path, is_open: bool) -> None:
+ self.standing.append((filepath, is_open))
+
+ def collapse_all(self) -> None:
+ root = self.tree.get_root()
+ assert root is not None
+ self.cleared.append(tuple(str(node.name) for node in root.descendants))
+ for filesystem_node in list(root.children):
+ for child in list(filesystem_node.children):
+ child.parent = None
+
+
+def explorer_tree() -> Tree:
+ """A filesystem root holding a folder that holds a file, as the explorer lists them."""
+ root = TreeNode("Root", node_type=NodeType.ROOT)
+ filesystem = FileSystemNode(
+ str(ROOT),
+ node_type=NodeType.DIRECTORY,
+ filepath=ROOT,
+ parent=root,
+ )
+ music = FileSystemNode(
+ MUSIC.name,
+ node_type=NodeType.DIRECTORY,
+ filepath=MUSIC,
+ parent=filesystem,
+ )
+ FileSystemNode(
+ "song.wav",
+ node_type=NodeType.FILE,
+ filepath=MUSIC / "song.wav",
+ parent=music,
+ )
+ return Tree(root=root)
+
+
+@pytest.fixture
+def folded(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]:
+ calls: List[Tuple[str, bool]] = []
+ monkeypatch.setattr(
+ tree_module,
+ "dpg_set_value",
+ lambda tag, value: calls.append((tag, value)),
+ )
+ return calls
+
+
+def build_panel(tree: Tree) -> GUIExplorerPanel:
+ """Builds an explorer panel holding a tree, which is all folding its rows away reads."""
+ panel = GUIExplorerPanel.__new__(GUIExplorerPanel)
+ panel.tag = PANEL_TAG
+ panel.tree = tree
+ panel._expansion = RowExpansionMemory(set())
+ panel._explorer_logic = FakeExplorerLogic(tree) # type: ignore[assignment]
+ return panel
+
+
+@pytest.fixture
+def toggled(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]:
+ """Records the rows the panel folds through the framework, in place of the widgets."""
+ calls: List[Tuple[str, bool]] = []
+ monkeypatch.setattr(explorer_module.dpg, "does_item_exist", lambda tag: True)
+ monkeypatch.setattr(explorer_module.dpg, "get_value", lambda tag: False)
+ monkeypatch.setattr(
+ explorer_module.dpg,
+ "set_value",
+ lambda tag, value: calls.append((tag, value)),
+ )
+ return calls
+
+
+class TestFollowingAFold:
+ """A click on a folder is how it opens, and the explorer is told what it now stands as."""
+
+ def test_opening_a_folder_reads_it_and_records_it_open(
+ self,
+ toggled: List[Tuple[str, bool]],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ tree = explorer_tree()
+ panel = build_panel(tree)
+ music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0]
+ monkeypatch.setattr(panel, "_rebuild_node_subtree", lambda node, node_tag: None, raising=False)
+
+ panel._toggle_directory_expansion(music, "row.music")
+
+ assert panel._explorer_logic.read == [MUSIC]
+ assert panel._explorer_logic.standing == [(MUSIC, True)]
+ assert toggled == [("row.music", True)]
+
+ def test_a_folder_read_already_is_not_read_again(
+ self,
+ toggled: List[Tuple[str, bool]],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ tree = explorer_tree()
+ panel = build_panel(tree)
+ music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0]
+ panel._explorer_logic.loaded.add(MUSIC)
+ monkeypatch.setattr(panel, "_rebuild_node_subtree", lambda node, node_tag: None, raising=False)
+
+ panel._toggle_directory_expansion(music, "row.music")
+
+ assert panel._explorer_logic.read == []
+ assert panel._explorer_logic.standing == [(MUSIC, True)]
+
+ def test_folding_a_folder_records_it_closed(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ tree = explorer_tree()
+ panel = build_panel(tree)
+ music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0]
+ panel._explorer_logic.loaded.add(MUSIC)
+ monkeypatch.setattr(explorer_module.dpg, "does_item_exist", lambda tag: True)
+ monkeypatch.setattr(explorer_module.dpg, "get_value", lambda tag: True)
+ monkeypatch.setattr(explorer_module.dpg, "set_value", lambda tag, value: None)
+
+ panel._toggle_directory_expansion(music, "row.music")
+
+ assert panel._explorer_logic.standing == [(MUSIC, False)]
+
+ def test_a_row_that_left_the_tree_is_left_alone(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ tree = explorer_tree()
+ panel = build_panel(tree)
+ music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0]
+ monkeypatch.setattr(explorer_module.dpg, "does_item_exist", lambda tag: False)
+
+ panel._toggle_directory_expansion(music, "row.music")
+
+ assert panel._explorer_logic.standing == []
+
+
+class TestCollapseAll:
+ def test_the_rows_fold_while_the_model_still_states_them(
+ self,
+ folded: List[Tuple[str, bool]],
+ ) -> None:
+ """A folder is reached through the model, so the fold runs before its children are dropped."""
+ tree = explorer_tree()
+ panel = build_panel(tree)
+ music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0]
+ music_tag = panel._generate_node_tag(music)
+
+ panel._on_collapse_all_clicked()
+
+ assert music_tag in {tag for tag, _ in folded}
+ assert all(not expanded for _, expanded in folded)
+
+ def test_the_folders_the_model_held_are_dropped_afterwards(
+ self,
+ folded: List[Tuple[str, bool]],
+ ) -> None:
+ tree = explorer_tree()
+ panel = build_panel(tree)
+
+ panel._on_collapse_all_clicked()
+
+ assert panel._explorer_logic.cleared == [(str(ROOT), MUSIC.name, "song.wav")]
+ root = tree.get_root()
+ assert root is not None
+ assert [str(node.name) for node in root.descendants] == [str(ROOT)]
diff --git a/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py b/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py
new file mode 100644
index 00000000..ae07e051
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py
@@ -0,0 +1,157 @@
+from dataclasses import dataclass
+from typing import Dict, FrozenSet, List, Tuple
+
+import pytest
+
+from sampletones_application.tags.main import TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE
+from sampletones_application.ui.panels.main import reconstructor as reconstructor_module
+from sampletones_application.ui.panels.main.reconstructor import GUIReconstructorPanel
+from sampletones_application.view_model.main.updates import GenerationSettingsUpdate
+from sampletones_core.constants.enums import GeneratorName
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+DRIVE = 1.5
+
+ALL_GENERATORS = frozenset(GeneratorName)
+
+
+class Harness:
+ """The panel over its checkboxes as DearPyGui holds them, without a window to hold them in."""
+
+ def __init__(
+ self,
+ checked: FrozenSet[GeneratorName],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ self.values: Dict[str, bool] = {
+ GUIReconstructorPanel._get_generator_checkbox_tag(generator): generator in checked
+ for generator in GeneratorName
+ }
+ self.reported: List[GenerationSettingsUpdate] = []
+
+ monkeypatch.setattr(reconstructor_module.dpg, "get_value", self.values.__getitem__)
+ monkeypatch.setattr(reconstructor_module, "dpg_set_value", self.values.__setitem__)
+ monkeypatch.setattr(reconstructor_module, "clamp_widget_value", self._drive)
+
+ self.panel = GUIReconstructorPanel.__new__(GUIReconstructorPanel)
+ self.panel.on_generation_settings_changed = self.reported.append
+
+ @staticmethod
+ def _drive(tag: str) -> float:
+ assert tag == TAG_MAIN_RECONSTRUCTOR_SLIDER_DRIVE
+ return DRIVE
+
+ def checked(self) -> FrozenSet[GeneratorName]:
+ return frozenset(
+ generator
+ for generator in GeneratorName
+ if self.values[GUIReconstructorPanel._get_generator_checkbox_tag(generator)]
+ )
+
+
+class TestToggleGenerator(BaseTestSuite):
+ """The key a channel answers to switches its checkbox, the gesture a click on it makes."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ checked: FrozenSet[GeneratorName]
+ generator: GeneratorName
+ expected: FrozenSet[GeneratorName]
+
+ test_cases = (
+ TestCase(
+ label="switching one off leaves the rest",
+ checked=ALL_GENERATORS,
+ generator=GeneratorName.TRIANGLE,
+ expected=ALL_GENERATORS - {GeneratorName.TRIANGLE},
+ ),
+ TestCase(
+ label="switching one on adds it alone",
+ checked=frozenset(),
+ generator=GeneratorName.PULSE1,
+ expected=frozenset({GeneratorName.PULSE1}),
+ ),
+ TestCase(
+ label="the last one switched off leaves nothing selected",
+ checked=frozenset({GeneratorName.NOISE}),
+ generator=GeneratorName.NOISE,
+ expected=frozenset(),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_set_the_checkboxes_show(
+ self,
+ test_case: TestCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = Harness(test_case.checked, monkeypatch)
+
+ harness.panel.toggle_generator(test_case.generator)
+
+ assert harness.checked() == test_case.expected
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_settings_the_panel_reports(
+ self,
+ test_case: TestCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """A switch reaches the configuration the same way a click does, drive carried along."""
+ harness = Harness(test_case.checked, monkeypatch)
+
+ harness.panel.toggle_generator(test_case.generator)
+
+ assert harness.reported == [
+ GenerationSettingsUpdate(
+ drive=DRIVE,
+ generators=[generator for generator in GeneratorName if generator in test_case.expected],
+ )
+ ]
+
+ def test_switching_a_generator_twice_returns_the_set_it_started_from(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = Harness(ALL_GENERATORS, monkeypatch)
+
+ harness.panel.toggle_generator(GeneratorName.PULSE2)
+ harness.panel.toggle_generator(GeneratorName.PULSE2)
+
+ assert harness.checked() == ALL_GENERATORS
+
+ def test_the_generators_are_reported_in_the_order_the_tracker_shows_them(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = Harness(frozenset({GeneratorName.NOISE, GeneratorName.PULSE1}), monkeypatch)
+
+ harness.panel.toggle_generator(GeneratorName.TRIANGLE)
+
+ assert self._generators(harness.reported) == [
+ GeneratorName.PULSE1,
+ GeneratorName.TRIANGLE,
+ GeneratorName.NOISE,
+ ]
+
+ @staticmethod
+ def _generators(reported: List[GenerationSettingsUpdate]) -> List[GeneratorName]:
+ return list(reported[-1].generators)
+
+
+class TestCheckboxTags:
+ def test_each_generator_carries_a_tag_of_its_own(self) -> None:
+ tags: Tuple[str, ...] = tuple(
+ GUIReconstructorPanel._get_generator_checkbox_tag(generator) for generator in GeneratorName
+ )
+
+ assert len(set(tags)) == len(tags)
diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py
index 4c13c033..9b910424 100644
--- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py
+++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py
@@ -1,4 +1,5 @@
-from typing import Final, List
+from dataclasses import dataclass
+from typing import Dict, Final, List, cast
from unittest.mock import MagicMock
import pytest
@@ -10,36 +11,66 @@
BEHAVIOR_DIRECTORY,
LANG_EN,
LAYOUT_DIRECTORY,
- PALETTE_PATH,
+ PALETTES_DIRECTORY,
THEME_DIRECTORY,
)
from sampletones_application.tags.general import (
TAG_GLOBAL_THEME_DEFAULT,
TAG_GLOBAL_THEME_INPUT_WARNING,
+ TAG_GLOBAL_THEME_INSTRUMENT_TABS,
+ TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED,
)
+from sampletones_application.ui.elements.button import GUIButton
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.ui.elements.pitch_stepper import PitchStepperStyle
-from sampletones_application.ui.panels.reconstruction.instruments.instruments import (
- GUIReconstructionInstrumentsPanel,
-)
+from sampletones_application.ui.panels.reconstruction.instruments import instruments as instruments_module
+from sampletones_application.ui.panels.reconstruction.instruments.instruments import GUIReconstructionInstrumentsPanel
from sampletones_application.ui.themes.setup import setup_themes
from sampletones_application.ui.themes.theme import Theme
-from sampletones_application.utils.palette import Palette
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_application.view_model.reconstruction.instruments import ReconstructionInstrumentsViewModel
+from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.formats.famitracker.footprint import InstrumentFootprint
from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
SEQUENCE_STATUS_KEY: Final[str] = "reconstructions.instruments.message.status_sequence"
+LARGEST_PULSE: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=9, sequence_bytes=768)
+LARGEST_TRIANGLE: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=7, sequence_bytes=512)
+SILENT_INSTRUMENT: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=3, sequence_bytes=0)
+
+NOT_LOADED: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel(
+ reconstruction_loaded=False,
+ playing_generators=frozenset(),
+ footprint=None,
+)
+
+
+def build_view_model(
+ channel_footprints: Dict[GeneratorName, InstrumentFootprint],
+) -> ReconstructionInstrumentsViewModel:
+ """A loaded reconstruction playing the given channels, each measured as given."""
+ return ReconstructionInstrumentsViewModel(
+ reconstruction_loaded=True,
+ playing_generators=frozenset(channel_footprints),
+ footprint=SampleFootprintViewModel.from_footprints(channel_footprints),
+ )
+
@pytest.fixture
def layout_config() -> LayoutConfig:
- return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, Palette.load(PALETTE_PATH))
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
@pytest.fixture(autouse=True)
def registered_themes(layout_config: LayoutConfig) -> None:
"""Registers the themes the panel resolves on construction, as startup does."""
- setup_themes(THEME_DIRECTORY, Palette.load(PALETTE_PATH))
+ setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default))
GUIPanel.configure_section_header(
layout_config.glyphs,
layout_config.general.section_header,
@@ -47,14 +78,38 @@ def registered_themes(layout_config: LayoutConfig) -> None:
)
-@pytest.fixture
+@pytest.fixture(autouse=True)
def bound_themes(monkeypatch: pytest.MonkeyPatch) -> List[str]:
- """Records the theme tags bound to items, standing in for the DPG binding."""
+ """Records the theme tags bound to items, standing in for the DPG binding.
+
+ The panel binds a theme wherever it marks an item, so every test stands in for the
+ binding and the ones asserting on it read the record.
+ """
tags: List[str] = []
monkeypatch.setattr(Theme, "bind_to_item", lambda self, item: tags.append(self.tag))
return tags
+@pytest.fixture(autouse=True)
+def written(monkeypatch: pytest.MonkeyPatch) -> Dict[str, str]:
+ """Records the texts written to items, standing in for the DPG values."""
+ values: Dict[str, str] = {}
+ monkeypatch.setattr(instruments_module, "dpg_set_value", values.__setitem__)
+ return values
+
+
+@pytest.fixture(autouse=True)
+def shown(monkeypatch: pytest.MonkeyPatch) -> Dict[str, bool]:
+ """Records which items the panel shows, standing in for the DPG configuration."""
+ flags: Dict[str, bool] = {}
+
+ def configure(tag: str, *, show: bool) -> None:
+ flags[tag] = show
+
+ monkeypatch.setattr(instruments_module, "dpg_configure_item", configure)
+ return flags
+
+
@pytest.fixture
def panel(layout_config: LayoutConfig) -> GUIReconstructionInstrumentsPanel:
return GUIReconstructionInstrumentsPanel(
@@ -97,7 +152,10 @@ def test_a_shortened_sequence_returns_to_the_default_theme(
) -> None:
panel._apply_input_theme(GeneratorName.NOISE, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 40)
panel._apply_input_theme(GeneratorName.NOISE, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS)
- assert bound_themes == [TAG_GLOBAL_THEME_INPUT_WARNING, TAG_GLOBAL_THEME_DEFAULT]
+ assert bound_themes == [
+ TAG_GLOBAL_THEME_INPUT_WARNING,
+ TAG_GLOBAL_THEME_DEFAULT,
+ ]
def test_each_dimension_carries_its_own_length(
self,
@@ -106,7 +164,10 @@ def test_each_dimension_carries_its_own_length(
) -> None:
panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.VOLUME, MAX_SEQUENCE_ITEMS + 1)
panel._apply_input_theme(GeneratorName.PULSE1, FeatureKey.ARPEGGIO, 8)
- assert bound_themes == [TAG_GLOBAL_THEME_INPUT_WARNING, TAG_GLOBAL_THEME_DEFAULT]
+ assert bound_themes == [
+ TAG_GLOBAL_THEME_INPUT_WARNING,
+ TAG_GLOBAL_THEME_DEFAULT,
+ ]
class TestInstrumentExport:
@@ -169,3 +230,150 @@ def test_a_sequence_beyond_the_limit_names_the_limit(
message = panel._sequence_status_message(GeneratorName.PULSE1, FeatureKey.VOLUME)
assert "300" in message
assert str(MAX_SEQUENCE_ITEMS) in message
+
+
+class TestSizeFields(BaseTestSuite):
+ """The two read-only byte figures: the sample's above the tabs, each channel's inside its tab."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ channel_footprints: Dict[GeneratorName, InstrumentFootprint]
+ expected: str
+
+ test_cases = (
+ TestCase(
+ label="a single channel spends what its instrument does",
+ channel_footprints={GeneratorName.PULSE1: LARGEST_PULSE},
+ expected="777 B",
+ ),
+ TestCase(
+ label="three channels spend their instruments together",
+ channel_footprints={
+ GeneratorName.PULSE1: LARGEST_PULSE,
+ GeneratorName.TRIANGLE: LARGEST_TRIANGLE,
+ GeneratorName.NOISE: LARGEST_PULSE,
+ },
+ expected="2073 B",
+ ),
+ TestCase(
+ label="a silent channel spends the instrument definition alone",
+ channel_footprints={GeneratorName.TRIANGLE: SILENT_INSTRUMENT},
+ expected="3 B",
+ ),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_sample_size_sums_its_channels(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ written: Dict[str, str],
+ test_case: TestCase,
+ ) -> None:
+ panel.update_view(build_view_model(test_case.channel_footprints))
+ assert written[panel.sample_size_tag] == test_case.expected
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_each_channel_states_its_own_size(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ written: Dict[str, str],
+ test_case: TestCase,
+ ) -> None:
+ panel.update_view(build_view_model(test_case.channel_footprints))
+ assert {
+ generator_name: written[panel._get_instrument_size_tag(generator_name)]
+ for generator_name in test_case.channel_footprints
+ } == {
+ generator_name: f"{footprint.total_bytes} B"
+ for generator_name, footprint in test_case.channel_footprints.items()
+ }
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_a_channel_standing_by_costs_nothing(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ written: Dict[str, str],
+ test_case: TestCase,
+ ) -> None:
+ """A channel that describes no frame is written by no export, so its tab states what that costs."""
+ panel.update_view(build_view_model(test_case.channel_footprints))
+ assert {
+ generator_name: written[panel._get_instrument_size_tag(generator_name)]
+ for generator_name in GeneratorName.items()
+ if generator_name not in test_case.channel_footprints
+ } == {
+ generator_name: "0 B"
+ for generator_name in GeneratorName.items()
+ if generator_name not in test_case.channel_footprints
+ }
+
+
+class TestPlayingChannels:
+ """Every channel keeps a tab; a muted label and a withheld export mark the ones standing by.
+
+ ``update_view`` marks each channel once in channel order, so the recorded bindings read as
+ one theme per channel.
+ """
+
+ def test_every_channel_keeps_its_tab(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ shown: Dict[str, bool],
+ ) -> None:
+ panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE}))
+ assert {
+ generator_name: shown[panel._get_generator_tab_tag(generator_name)]
+ for generator_name in GeneratorName.items()
+ } == {generator_name: True for generator_name in GeneratorName.items()}
+
+ def test_a_channel_standing_by_reads_muted(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ bound_themes: List[str],
+ ) -> None:
+ panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE}))
+ assert dict(zip(GeneratorName.items(), bound_themes)) == {
+ GeneratorName.PULSE1: TAG_GLOBAL_THEME_INSTRUMENT_TABS,
+ GeneratorName.PULSE2: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED,
+ GeneratorName.TRIANGLE: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED,
+ GeneratorName.NOISE: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED,
+ }
+
+ def test_only_a_playing_channel_offers_its_export(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ ) -> None:
+ buttons = {generator_name: MagicMock() for generator_name in GeneratorName.items()}
+ panel._export_buttons.update(cast(Dict[GeneratorName, GUIButton], buttons))
+
+ panel.update_view(build_view_model({GeneratorName.TRIANGLE: LARGEST_TRIANGLE}))
+
+ assert {generator_name: button.set_enabled.call_args.args[0] for generator_name, button in buttons.items()} == {
+ generator_name: generator_name is GeneratorName.TRIANGLE for generator_name in GeneratorName.items()
+ }
+
+
+class TestSizeVisibility:
+ def test_a_loaded_reconstruction_shows_the_sample_size(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ shown: Dict[str, bool],
+ ) -> None:
+ panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE}))
+ assert shown[panel.sample_size_group_tag] is True
+
+ def test_no_reconstruction_hides_the_sample_size(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ shown: Dict[str, bool],
+ ) -> None:
+ panel.update_view(NOT_LOADED)
+ assert shown[panel.sample_size_group_tag] is False
+
+ def test_no_reconstruction_states_no_figures(
+ self,
+ panel: GUIReconstructionInstrumentsPanel,
+ written: Dict[str, str],
+ ) -> None:
+ panel.update_view(NOT_LOADED)
+ assert written == {}
diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py
new file mode 100644
index 00000000..76f4d190
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py
@@ -0,0 +1,249 @@
+from dataclasses import dataclass
+from typing import Dict, FrozenSet, List
+
+import pytest
+
+from sampletones_application.ui.panels.reconstruction import plot as plot_module
+from sampletones_application.ui.panels.reconstruction.plot import (
+ GUIReconstructionPlotPanel,
+)
+from sampletones_application.view_model.reconstruction.reconstruction import (
+ ReconstructionPathState,
+ ReconstructionPathViewModel,
+ ReconstructionViewModel,
+)
+from sampletones_core.constants.enums import GeneratorName
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+ALL_GENERATORS = frozenset(GeneratorName)
+
+
+class StubTheme:
+ """Stands in for a registered theme, recording the items it was bound to."""
+
+ def __init__(self, tag: str, bindings: Dict[str, str]) -> None:
+ self.tag = tag
+ self._bindings = bindings
+
+ def bind_to_item(self, item: str) -> None:
+ self._bindings[item] = self.tag
+
+
+class Harness:
+ """The panel over its generator checkboxes, each shown or disabled as a reconstruction leaves
+ it."""
+
+ def __init__(
+ self,
+ *,
+ selected: FrozenSet[GeneratorName],
+ available: FrozenSet[GeneratorName],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ self.values: Dict[str, bool] = {self._tag(generator): generator in selected for generator in GeneratorName}
+ self.enabled: Dict[str, bool] = {self._tag(generator): generator in available for generator in GeneratorName}
+ self.reported: List[List[GeneratorName]] = []
+ self.bound_themes: Dict[str, str] = {}
+
+ monkeypatch.setattr(plot_module.dpg, "get_value", self.values.__getitem__)
+ monkeypatch.setattr(plot_module.dpg, "is_item_enabled", self.enabled.__getitem__)
+ monkeypatch.setattr(plot_module.dpg, "bind_item_theme", lambda item, theme: self.bound_themes.pop(item, None))
+ monkeypatch.setattr(plot_module, "dpg_set_value", self.values.__setitem__)
+ monkeypatch.setattr(plot_module, "dpg_configure_item", self._configure)
+ monkeypatch.setattr(
+ plot_module.ThemeRegistry,
+ "get",
+ lambda tag: StubTheme(tag, self.bound_themes),
+ )
+
+ self.panel = GUIReconstructionPlotPanel.__new__(GUIReconstructionPlotPanel)
+ self.panel.on_generators_changed = self.reported.append
+
+ def _configure(self, tag: str, *, enabled: bool, default_value: bool) -> None:
+ self.enabled[tag] = enabled
+ self.values[tag] = default_value
+
+ @staticmethod
+ def _tag(generator: GeneratorName) -> str:
+ return GUIReconstructionPlotPanel._get_generator_checkbox_tag(generator)
+
+ def offered(self) -> FrozenSet[GeneratorName]:
+ return frozenset(generator for generator in GeneratorName if self.enabled[self._tag(generator)])
+
+ def selected(self) -> FrozenSet[GeneratorName]:
+ return frozenset(generator for generator in GeneratorName if self.values[self._tag(generator)])
+
+
+def _view_model(
+ playing: FrozenSet[GeneratorName],
+ selected: FrozenSet[GeneratorName],
+) -> ReconstructionViewModel:
+ empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path="")
+ return ReconstructionViewModel(
+ reconstruction_loaded=True,
+ playing_generators=playing,
+ selected_generators=selected,
+ reconstruction_file=empty_path,
+ original_audio=empty_path,
+ )
+
+
+class TestGeneratorCheckboxes:
+ """The checkboxes offer the channels that play and tick the ones the reader keeps on."""
+
+ def test_a_channel_that_plays_is_offered(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = Harness(selected=frozenset(), available=frozenset(), monkeypatch=monkeypatch)
+ playing = frozenset({GeneratorName.PULSE1, GeneratorName.NOISE})
+
+ harness.panel.update_view(_view_model(playing, playing))
+
+ assert harness.offered() == playing
+ assert harness.selected() == playing
+
+ def test_a_channel_switched_off_by_hand_stays_off(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """An edit reports the view again, and the report carries the reader's choice."""
+ harness = Harness(selected=ALL_GENERATORS, available=ALL_GENERATORS, monkeypatch=monkeypatch)
+ playing = frozenset({GeneratorName.PULSE1, GeneratorName.NOISE})
+
+ harness.panel.update_view(_view_model(playing, frozenset({GeneratorName.NOISE})))
+
+ assert harness.offered() == playing
+ assert harness.selected() == frozenset({GeneratorName.NOISE})
+
+ def test_a_channel_standing_by_is_left_unticked(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = Harness(selected=ALL_GENERATORS, available=ALL_GENERATORS, monkeypatch=monkeypatch)
+ playing = frozenset({GeneratorName.PULSE1})
+
+ harness.panel.update_view(_view_model(playing, playing))
+
+ assert harness.selected() == playing
+ assert GeneratorName.PULSE2 not in harness.offered()
+
+ def test_a_channel_that_plays_carries_its_own_tint(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = Harness(selected=frozenset(), available=frozenset(), monkeypatch=monkeypatch)
+ playing = frozenset({GeneratorName.TRIANGLE})
+
+ harness.panel.update_view(_view_model(playing, playing))
+
+ assert set(harness.bound_themes) == {Harness._tag(GeneratorName.TRIANGLE)}
+
+
+class TestToggleGenerator(BaseTestSuite):
+ """The key a channel answers to switches its slice in and out of the waveform."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ selected: FrozenSet[GeneratorName]
+ available: FrozenSet[GeneratorName]
+ generator: GeneratorName
+ expected: FrozenSet[GeneratorName]
+
+ test_cases = (
+ TestCase(
+ label="switching a shown slice out",
+ selected=ALL_GENERATORS,
+ available=ALL_GENERATORS,
+ generator=GeneratorName.PULSE1,
+ expected=ALL_GENERATORS - {GeneratorName.PULSE1},
+ ),
+ TestCase(
+ label="switching a hidden slice back in",
+ selected=frozenset({GeneratorName.NOISE}),
+ available=ALL_GENERATORS,
+ generator=GeneratorName.TRIANGLE,
+ expected=frozenset({GeneratorName.TRIANGLE, GeneratorName.NOISE}),
+ ),
+ TestCase(
+ label="a generator the reconstruction holds none of stays out",
+ selected=frozenset({GeneratorName.PULSE1}),
+ available=frozenset({GeneratorName.PULSE1}),
+ generator=GeneratorName.NOISE,
+ expected=frozenset({GeneratorName.PULSE1}),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_slices_the_checkboxes_show(
+ self,
+ test_case: TestCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = Harness(
+ selected=test_case.selected,
+ available=test_case.available,
+ monkeypatch=monkeypatch,
+ )
+
+ harness.panel.toggle_generator(test_case.generator)
+
+ assert harness.selected() == test_case.expected
+
+ @pytest.mark.parametrize(
+ "test_case",
+ [test_case for test_case in test_cases if test_case.generator in test_case.available],
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_selection_the_panel_reports(
+ self,
+ test_case: TestCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """A switch reaches the waveform and the audio the same way a click does."""
+ harness = Harness(
+ selected=test_case.selected,
+ available=test_case.available,
+ monkeypatch=monkeypatch,
+ )
+
+ harness.panel.toggle_generator(test_case.generator)
+
+ assert harness.reported == [
+ [generator for generator in GeneratorName if generator in test_case.expected],
+ ]
+
+ def test_a_generator_the_reconstruction_holds_none_of_reports_nothing(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """Its checkbox already reads as unavailable, so the key leaves the waveform as it stands."""
+ harness = Harness(
+ selected=frozenset({GeneratorName.PULSE1}),
+ available=frozenset({GeneratorName.PULSE1}),
+ monkeypatch=monkeypatch,
+ )
+
+ harness.panel.toggle_generator(GeneratorName.NOISE)
+
+ assert harness.reported == []
+
+ def test_switching_a_slice_twice_returns_the_waveform_it_started_from(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ harness = Harness(
+ selected=ALL_GENERATORS,
+ available=ALL_GENERATORS,
+ monkeypatch=monkeypatch,
+ )
+
+ harness.panel.toggle_generator(GeneratorName.PULSE2)
+ harness.panel.toggle_generator(GeneratorName.PULSE2)
+
+ assert harness.selected() == ALL_GENERATORS
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py b/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py
new file mode 100644
index 00000000..574a1e83
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/conftest.py
@@ -0,0 +1,22 @@
+from typing import Generator
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer import tracker
+from sampletones_shared.types.callback import VoidCallback
+
+
+@pytest.fixture(autouse=True)
+def immediate_frame_callbacks(monkeypatch: pytest.MonkeyPatch) -> Generator[None, None, None]:
+ """Runs a panel's deferred frame work at once, since a suite renders no frames.
+
+ The tracker holds the playhead's mark back to the frame its scroll lands on, which a running
+ application reaches on its next render and a suite never does. Calling the work as it is
+ handed over keeps what a panel draws observable from the call that asks for it.
+ """
+
+ def run_now(callback: VoidCallback, frame_count: int = 1) -> None:
+ callback()
+
+ monkeypatch.setattr(tracker.FrameCallbackManager, "set_frame_callback", run_now)
+ yield
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_axis.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_axis.py
new file mode 100644
index 00000000..ad0a7328
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_axis.py
@@ -0,0 +1,62 @@
+from typing import List
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer.grid.scroll.axis import (
+ HorizontalScroll,
+ VerticalScroll,
+)
+
+AXIS_MODULE = "sampletones_application.ui.panels.sequencer.grid.scroll.axis.dpg"
+POINTER = [12.0, 34.0]
+SCROLL = 7.0
+SCROLL_MAX = 70.0
+ISSUED = 5.0
+
+
+def _read_pointer(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(f"{AXIS_MODULE}.get_mouse_pos", lambda local: POINTER)
+
+
+class TestVerticalScroll:
+ """A table whose rows run down the screen travels by height."""
+
+ def test_the_pointer_reads_as_its_height(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ _read_pointer(monkeypatch)
+
+ assert VerticalScroll(table="tracker.table").pointer() == POINTER[1]
+
+ def test_the_offsets_are_the_table_s_own(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ issued: List[float] = []
+ monkeypatch.setattr(f"{AXIS_MODULE}.get_y_scroll", lambda table: SCROLL)
+ monkeypatch.setattr(f"{AXIS_MODULE}.get_y_scroll_max", lambda table: SCROLL_MAX)
+ monkeypatch.setattr(f"{AXIS_MODULE}.set_y_scroll", lambda table, offset: issued.append(offset))
+ axis = VerticalScroll(table="tracker.table")
+
+ axis.set_scroll(ISSUED)
+
+ assert axis.scroll() == SCROLL
+ assert axis.scroll_max() == SCROLL_MAX
+ assert issued == [ISSUED]
+
+
+class TestHorizontalScroll:
+ """A table whose columns run across the screen travels by width."""
+
+ def test_the_pointer_reads_as_its_width(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ _read_pointer(monkeypatch)
+
+ assert HorizontalScroll(table="order.table").pointer() == POINTER[0]
+
+ def test_the_offsets_are_the_table_s_own(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ issued: List[float] = []
+ monkeypatch.setattr(f"{AXIS_MODULE}.get_x_scroll", lambda table: SCROLL)
+ monkeypatch.setattr(f"{AXIS_MODULE}.get_x_scroll_max", lambda table: SCROLL_MAX)
+ monkeypatch.setattr(f"{AXIS_MODULE}.set_x_scroll", lambda table, offset: issued.append(offset))
+ axis = HorizontalScroll(table="order.table")
+
+ axis.set_scroll(ISSUED)
+
+ assert axis.scroll() == SCROLL
+ assert axis.scroll_max() == SCROLL_MAX
+ assert issued == [ISSUED]
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_travel.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_travel.py
new file mode 100644
index 00000000..5589d290
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_travel.py
@@ -0,0 +1,231 @@
+from typing import List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand
+from sampletones_application.ui.panels.sequencer.grid.scroll.travel import (
+ TRAVEL_CEILING_CELLS_PER_SECOND,
+ TRAVEL_FLOOR_CELLS_PER_SECOND,
+ TRAVEL_FULL_PACE_OVERSHOOT_CELLS,
+ DragTravel,
+)
+
+FIRST_EDGE = 100.0
+CELL_EXTENT = 20.0
+CELL_COUNT = 60
+SCROLL_MAX = 800.0
+FRAME = 1.0 / 60.0
+BAND = TravelBand(first_edge=FIRST_EDGE, cell_extent=CELL_EXTENT, cell_count=CELL_COUNT)
+BAND_NEAR = FIRST_EDGE
+BAND_FAR = FIRST_EDGE + CELL_COUNT * CELL_EXTENT - SCROLL_MAX
+
+
+class FakeAxis:
+ """An axis that stands wherever the test puts it, and records every offset issued to it."""
+
+ def __init__(self, *, pointer: float, scroll: float = 0.0, scroll_max: float = SCROLL_MAX) -> None:
+ self._pointer = pointer
+ self._scroll = scroll
+ self._scroll_max = scroll_max
+ self.issued: List[float] = []
+
+ def pointer(self) -> float:
+ return self._pointer
+
+ def scroll(self) -> float:
+ return self._scroll
+
+ def scroll_max(self) -> float:
+ return self._scroll_max
+
+ def set_scroll(self, offset: float) -> None:
+ self.issued.append(offset)
+
+ def stand_at(self, pointer: float) -> None:
+ self._pointer = pointer
+
+
+def _travel(
+ axis: FakeAxis,
+ band: Optional[TravelBand] = BAND,
+ frame: float = FRAME,
+) -> DragTravel:
+ return DragTravel(axis=axis, band=lambda: band, elapsed=lambda: frame)
+
+
+def _grid(
+ pointer: float,
+ scroll: float = 0.0,
+ frame: float = FRAME,
+) -> Tuple[FakeAxis, DragTravel]:
+ """A grid drawn at ``scroll``: its first cell stands that far back, so the band holds still."""
+ axis = FakeAxis(pointer=pointer, scroll=scroll)
+ band = TravelBand(
+ first_edge=FIRST_EDGE - scroll,
+ cell_extent=CELL_EXTENT,
+ cell_count=CELL_COUNT,
+ )
+ return axis, _travel(axis, band=band, frame=frame)
+
+
+class TestPointerWithinTheBand:
+ """A pointer standing on the grid leaves it where it is."""
+
+ def test_a_pointer_in_the_middle_travels_nowhere(self) -> None:
+ axis = FakeAxis(pointer=(BAND_NEAR + BAND_FAR) / 2)
+
+ _travel(axis).advance()
+
+ assert axis.issued == []
+
+ def test_a_pointer_on_either_edge_travels_nowhere(self) -> None:
+ for pointer in (BAND_NEAR, BAND_FAR):
+ axis = FakeAxis(pointer=pointer)
+
+ _travel(axis).advance()
+
+ assert axis.issued == []
+
+ def test_a_grid_awaiting_its_layout_travels_nowhere(self) -> None:
+ axis = FakeAxis(pointer=BAND_FAR + 500.0)
+
+ _travel(axis, band=None).advance()
+
+ assert axis.issued == []
+
+ def test_a_grid_that_fits_on_screen_travels_nowhere(self) -> None:
+ axis = FakeAxis(pointer=BAND_FAR + 500.0, scroll_max=0.0)
+
+ _travel(axis).advance()
+
+ assert axis.issued == []
+
+
+class TestPace:
+ """The travel answers how far past the edge the pointer is carried."""
+
+ def test_a_pointer_just_past_the_edge_travels_at_the_floor(self) -> None:
+ axis = FakeAxis(pointer=BAND_FAR + 0.5)
+
+ _travel(axis).advance()
+
+ assert axis.issued == [pytest.approx(TRAVEL_FLOOR_CELLS_PER_SECOND * CELL_EXTENT * FRAME, abs=0.5)]
+
+ def test_a_pointer_carried_further_travels_faster(self) -> None:
+ near_edge = FakeAxis(pointer=BAND_FAR + CELL_EXTENT)
+ far_out = FakeAxis(pointer=BAND_FAR + 3 * CELL_EXTENT)
+
+ _travel(near_edge).advance()
+ _travel(far_out).advance()
+
+ assert far_out.issued[0] > near_edge.issued[0]
+
+ def test_the_pace_stops_rising_at_the_ceiling(self) -> None:
+ at_full_pace = FakeAxis(pointer=BAND_FAR + TRAVEL_FULL_PACE_OVERSHOOT_CELLS * CELL_EXTENT)
+ far_beyond = FakeAxis(pointer=BAND_FAR + 100 * CELL_EXTENT)
+
+ _travel(at_full_pace).advance()
+ _travel(far_beyond).advance()
+
+ ceiling = TRAVEL_CEILING_CELLS_PER_SECOND * CELL_EXTENT * FRAME
+ assert at_full_pace.issued == [pytest.approx(ceiling)]
+ assert far_beyond.issued == [pytest.approx(ceiling)]
+
+ def test_the_same_stretch_passes_however_fast_the_frames_arrive(self) -> None:
+ """Two frames of half the duration carry the grid exactly as far as one full one."""
+ whole = FakeAxis(pointer=BAND_FAR + 200.0)
+ halves = FakeAxis(pointer=BAND_FAR + 200.0)
+
+ _travel(whole).advance()
+ paced = _travel(halves, frame=FRAME / 2)
+ paced.advance()
+ paced.advance()
+
+ assert halves.issued[-1] == pytest.approx(whole.issued[-1])
+
+
+class TestDirection:
+ """The travel carries the grid toward whichever edge the pointer stands past."""
+
+ def test_a_pointer_before_the_near_edge_travels_back(self) -> None:
+ axis, travel = _grid(pointer=BAND_NEAR - 100.0, scroll=400.0)
+
+ travel.advance()
+
+ assert axis.issued[0] < 400.0
+
+ def test_a_pointer_past_the_far_edge_travels_on(self) -> None:
+ axis, travel = _grid(pointer=BAND_FAR + 100.0, scroll=400.0)
+
+ travel.advance()
+
+ assert axis.issued[0] > 400.0
+
+ def test_the_band_travels_with_the_scroll(self) -> None:
+ """A scrolled grid draws its first cell further back, so the band stands where it always did."""
+ axis = FakeAxis(pointer=BAND_NEAR + 10.0, scroll=300.0)
+ scrolled = TravelBand(
+ first_edge=FIRST_EDGE - 300.0,
+ cell_extent=CELL_EXTENT,
+ cell_count=CELL_COUNT,
+ )
+
+ _travel(axis, band=scrolled).advance()
+
+ assert axis.issued == []
+
+
+class TestEnds:
+ """The travel stops where the grid does."""
+
+ def test_the_far_end_stops_at_the_scroll_extent(self) -> None:
+ axis, travel = _grid(pointer=BAND_FAR + 500.0, scroll=SCROLL_MAX - 1.0)
+
+ travel.advance()
+
+ assert axis.issued == [SCROLL_MAX]
+
+ def test_the_near_end_stops_at_the_start(self) -> None:
+ axis, travel = _grid(pointer=BAND_NEAR - 500.0, scroll=1.0)
+
+ travel.advance()
+
+ assert axis.issued == [0.0]
+
+
+class TestRunningOffset:
+ """Each step is added to the offset last issued, since a table reports the one it was drawn with."""
+
+ def test_travel_accumulates_while_the_grid_reports_the_offset_it_was_drawn_with(self) -> None:
+ axis = FakeAxis(pointer=BAND_FAR + 500.0)
+ travel = _travel(axis)
+
+ travel.advance()
+ travel.advance()
+ travel.advance()
+
+ step = axis.issued[0]
+ assert axis.issued == [
+ pytest.approx(step),
+ pytest.approx(2 * step),
+ pytest.approx(3 * step),
+ ]
+
+ def test_a_pointer_returning_to_the_band_ends_the_travel(self) -> None:
+ axis = FakeAxis(pointer=BAND_FAR + 500.0)
+ travel = _travel(axis)
+
+ travel.advance()
+ axis.stand_at(BAND_NEAR + 10.0)
+ travel.advance()
+
+ assert len(axis.issued) == 1
+
+ def test_a_travel_at_rest_sets_out_from_the_offset_the_grid_is_drawn_with(self) -> None:
+ axis, travel = _grid(pointer=BAND_FAR + 500.0, scroll=250.0)
+
+ travel.advance()
+ travel.rest()
+ travel.advance()
+
+ assert axis.issued[0] == pytest.approx(axis.issued[1])
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py
new file mode 100644
index 00000000..5d606de1
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py
@@ -0,0 +1,98 @@
+from dataclasses import dataclass
+from typing import Any, Callable, List
+
+import pytest
+
+from sampletones_application.categories.elements.global_ import ContextElements
+from sampletones_application.ui.panels.sequencer.grid.surface import clipboard as clipboard_module
+from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS
+from tests.suite.shortcuts import shipped_source
+from tests.suite.surface import CLICKED_TARGET, Grid
+
+COPY_ITEM = 0
+CUT_ITEM = 1
+PASTE_ITEM = 2
+DELETE_ITEM = 3
+
+
+@dataclass
+class RecordedItem:
+ """One item as it was registered, which is the whole of what a reader sees and clicks."""
+
+ label: str
+ shortcut: str
+ enabled: bool
+ callback: Callable[[], None]
+
+
+class _MenuRecorder:
+ def __init__(self) -> None:
+ self.items: List[RecordedItem] = []
+
+ def add_menu_item(self, **kwargs: Any) -> int:
+ self.items.append(
+ RecordedItem(
+ label=kwargs["label"],
+ shortcut=kwargs.get("shortcut", ""),
+ enabled=kwargs.get("enabled", True),
+ callback=kwargs["callback"],
+ )
+ )
+ return 0
+
+
+@pytest.fixture
+def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder:
+ recorded = _MenuRecorder()
+ monkeypatch.setattr(clipboard_module.dpg, "add_menu_item", recorded.add_menu_item)
+ return recorded
+
+
+class TestClipboardItems:
+ def test_the_section_reads_as_the_four_clipboard_actions(self, recorder: _MenuRecorder) -> None:
+ Grid().clipboard_items().add_items(CLICKED_TARGET)
+
+ assert [item.label for item in recorder.items] == [
+ CLIPBOARD_LABELS[ContextElements.COPY],
+ CLIPBOARD_LABELS[ContextElements.CUT],
+ CLIPBOARD_LABELS[ContextElements.PASTE],
+ CLIPBOARD_LABELS[ContextElements.DELETE],
+ ]
+
+ def test_the_items_print_the_keys_the_grid_answers_to(self, recorder: _MenuRecorder) -> None:
+ """Each grid states its own three bindings, and an item prints exactly the one it fires."""
+ shortcuts = shipped_source()
+ Grid().clipboard_items().add_items(CLICKED_TARGET)
+
+ assert recorder.items[COPY_ITEM].shortcut == shortcuts.display(TRACKER_BLOCK_SHORTCUTS.copy)
+ assert recorder.items[CUT_ITEM].shortcut == shortcuts.display(TRACKER_BLOCK_SHORTCUTS.cut)
+ assert recorder.items[PASTE_ITEM].shortcut == shortcuts.display(TRACKER_BLOCK_SHORTCUTS.paste)
+
+ def test_delete_prints_no_key_of_its_own(self, recorder: _MenuRecorder) -> None:
+ """``Del`` empties a selection while one stands and clears the cell under the cursor
+ otherwise, so the grid resolves it from the selection rather than from one binding."""
+ Grid().clipboard_items().add_items(CLICKED_TARGET)
+
+ assert recorder.items[DELETE_ITEM].shortcut == ""
+
+ def test_the_items_act_on_the_block_they_were_raised_on(self, recorder: _MenuRecorder) -> None:
+ """A menu item names its target when it is built, so it reaches that block wherever the
+ cursor happens to stand."""
+ grid = Grid()
+ grid.clipboard_items().add_items(CLICKED_TARGET)
+
+ for item in recorder.items:
+ item.callback()
+
+ assert grid.events == [
+ f"copy {CLICKED_TARGET.region}",
+ f"cut {CLICKED_TARGET.region}",
+ f"paste {CLICKED_TARGET.anchor}",
+ f"delete {CLICKED_TARGET.region}",
+ ]
+
+ def test_paste_awaits_a_copy(self, recorder: _MenuRecorder) -> None:
+ Grid(can_paste=False).clipboard_items().add_items(CLICKED_TARGET)
+
+ assert not recorder.items[PASTE_ITEM].enabled
+ assert all(item.enabled for index, item in enumerate(recorder.items) if index != PASTE_ITEM)
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_edit.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_edit.py
new file mode 100644
index 00000000..fc1db3a1
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_edit.py
@@ -0,0 +1,81 @@
+from dataclasses import dataclass
+from typing import Callable, Final, Tuple
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface
+from tests.suite.surface import CURSOR_CELL, CURSOR_TARGET, Grid, Target
+
+
+@dataclass(frozen=True)
+class GestureCase:
+ """One of the four gestures as a key press raises it, at the cursor's own target."""
+
+ name: str
+ at_cursor: Callable[[GridEditSurface[str, str, str, Target]], None]
+ reaches: str
+
+
+CASES: Final[Tuple[GestureCase, ...]] = (
+ GestureCase(
+ name="copy",
+ at_cursor=lambda surface: surface.copy(),
+ reaches=f"copy {CURSOR_TARGET.region}",
+ ),
+ GestureCase(
+ name="cut",
+ at_cursor=lambda surface: surface.cut(),
+ reaches=f"cut {CURSOR_TARGET.region}",
+ ),
+ GestureCase(
+ name="delete",
+ at_cursor=lambda surface: surface.delete(),
+ reaches=f"delete {CURSOR_TARGET.region}",
+ ),
+ GestureCase(
+ name="paste",
+ at_cursor=lambda surface: surface.paste(),
+ reaches=f"paste {CURSOR_TARGET.anchor}",
+ ),
+)
+
+
+@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES])
+class TestAtTheCursor:
+ """A key press acts on the target the cursor names, once the entry being typed has landed."""
+
+ def test_a_gesture_reaches_the_cursor_s_own_target(self, case: GestureCase) -> None:
+ grid = Grid()
+
+ case.at_cursor(grid.edit_surface())
+
+ assert grid.events == ["commit", case.reaches]
+
+ def test_a_grid_holding_no_cursor_settles_its_entry_and_stands(self, case: GestureCase) -> None:
+ grid = Grid(cursor=None)
+
+ case.at_cursor(grid.edit_surface())
+
+ assert grid.events == ["commit"]
+
+
+class TestEditActions:
+ def test_the_cursor_names_the_target_the_actions_are_built_for(self) -> None:
+ grid = Grid()
+
+ grid.edit_surface().build_edit_actions()
+
+ assert grid.events == [f"actions {CURSOR_CELL}"]
+
+ def test_a_grid_holding_no_cursor_builds_nothing(self) -> None:
+ """The menu bar asks whichever grid answers, and one without a cursor states no actions."""
+ grid = Grid(cursor=None)
+
+ grid.edit_surface().build_edit_actions()
+
+ assert grid.events == []
+
+ def test_the_surface_answers_while_the_grid_owns_its_keys(self) -> None:
+ """The menu offers what the next press would reach, so one question decides both."""
+ assert Grid(owns=True).edit_surface().owns_edit_actions()
+ assert not Grid(owns=False).edit_surface().owns_edit_actions()
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_targets.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_targets.py
new file mode 100644
index 00000000..51d1e47c
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_targets.py
@@ -0,0 +1,29 @@
+from tests.suite.surface import CLICKED_CELL, CLICKED_TARGET, CURSOR_TARGET, Grid
+
+
+class TestTargetAtACell:
+ def test_a_cell_is_paired_with_the_block_it_falls_in(self) -> None:
+ assert Grid().cursor_targets().at(CLICKED_CELL) == CLICKED_TARGET
+
+ def test_a_cell_away_from_the_cursor_names_its_own_block(self) -> None:
+ """A menu raised anywhere reaches what it names, so the cursor's cell has no say in it."""
+ assert Grid().cursor_targets().at(CLICKED_CELL) != CURSOR_TARGET
+
+
+class TestTargetAtTheCursor:
+ def test_the_cursor_names_its_own_target(self) -> None:
+ assert Grid().cursor_targets().at_cursor() == CURSOR_TARGET
+
+ def test_a_grid_holding_no_cursor_names_no_target(self) -> None:
+ assert Grid(cursor=None).cursor_targets().at_cursor() is None
+
+ def test_the_state_is_read_on_each_call(self) -> None:
+ """A grid rebinds a frozen state on every edit, so a target resolved once would go stale."""
+ grid = Grid()
+ targets = grid.cursor_targets()
+ first = targets.at_cursor()
+
+ grid.cursor = CLICKED_CELL
+
+ assert first == CURSOR_TARGET
+ assert targets.at_cursor() == CLICKED_TARGET
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py
new file mode 100644
index 00000000..348eaeef
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py
@@ -0,0 +1,92 @@
+from dataclasses import dataclass
+from typing import Callable, Final, List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures
+
+Gestures = BlockGestures[str, str]
+
+
+@dataclass(frozen=True)
+class _Target:
+ """A block and the cell a paste lands at, written as the words each hook reports."""
+
+ region: str
+ anchor: str
+
+
+NAMED_TARGET: Final[_Target] = _Target(region="named block", anchor="named cell")
+
+
+class _Grid:
+ """A grid recording the hooks it announced through, in the order it announced them."""
+
+ def __init__(self, *, can_paste: bool = True) -> None:
+ self.events: List[str] = []
+ self.on_copy_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"copy {region}")
+ self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}")
+ self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}")
+ self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}")
+ self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste
+
+
+@dataclass(frozen=True)
+class GestureCase:
+ """One of the four gestures, raised on the target its door named."""
+
+ name: str
+ at_target: Callable[[Gestures, _Target], None]
+ reaches: str
+
+
+CASES: Final[Tuple[GestureCase, ...]] = (
+ GestureCase(
+ name="copy",
+ at_target=lambda gestures, target: gestures.copy_at(target),
+ reaches="copy named block",
+ ),
+ GestureCase(
+ name="cut",
+ at_target=lambda gestures, target: gestures.cut_at(target),
+ reaches="cut named block",
+ ),
+ GestureCase(
+ name="delete",
+ at_target=lambda gestures, target: gestures.delete_at(target),
+ reaches="delete named block",
+ ),
+ GestureCase(
+ name="paste",
+ at_target=lambda gestures, target: gestures.paste_at(target),
+ reaches="paste named cell",
+ ),
+)
+
+
+@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES])
+class TestOnANamedTarget:
+ """Each door names the target it acts on, and the gesture reaches exactly that block."""
+
+ def test_a_gesture_reaches_the_target_it_was_handed(self, case: GestureCase) -> None:
+ grid = _Grid()
+
+ case.at_target(BlockGestures(grid=grid), NAMED_TARGET)
+
+ assert grid.events == [case.reaches]
+
+
+class TestPasteEnablement:
+ """Paste is offered while a block stands ready for it to write."""
+
+ def test_a_grid_holding_a_block_offers_the_paste(self) -> None:
+ assert BlockGestures(grid=_Grid(can_paste=True)).can_paste() is True
+
+ def test_a_grid_holding_none_offers_no_paste(self) -> None:
+ assert BlockGestures(grid=_Grid(can_paste=False)).can_paste() is False
+
+ def test_a_grid_awaiting_its_wiring_offers_no_paste(self) -> None:
+ grid = _Grid()
+ grid.can_paste_block = None
+
+ assert BlockGestures(grid=grid).can_paste() is False
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py
new file mode 100644
index 00000000..510e60be
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py
@@ -0,0 +1,156 @@
+from dataclasses import dataclass
+
+from sampletones_application.ui.panels.sequencer.input.state import GridInputState
+
+
+@dataclass(frozen=True)
+class _Cell:
+ row: int
+ column: int
+
+
+@dataclass(frozen=True)
+class _Block:
+ first_row: int
+ last_row: int
+ first_column: int
+ last_column: int
+
+
+@dataclass(frozen=True)
+class _GridState(GridInputState[_Cell, _Block]):
+ """A grid of plain rows and columns, which is the coordinate space the shared rules are read in."""
+
+ def _region_between(self, first: _Cell, second: _Cell) -> _Block:
+ return _Block(
+ first_row=min(first.row, second.row),
+ last_row=max(first.row, second.row),
+ first_column=min(first.column, second.column),
+ last_column=max(first.column, second.column),
+ )
+
+ def _covers(self, region: _Block, cell: _Cell) -> bool:
+ return (
+ region.first_row <= cell.row <= region.last_row and region.first_column <= cell.column <= region.last_column
+ )
+
+
+def _state(
+ row: int = 2,
+ column: int = 1,
+ pending: str = "",
+) -> _GridState:
+ return _GridState(cursor=_Cell(row, column), pending=pending)
+
+
+class TestSelection:
+ """A selection stands between the anchor a gesture started on and the cursor it carried to."""
+
+ def test_a_cursor_alone_covers_no_region(self) -> None:
+ assert _state().region is None
+
+ def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None:
+ extended = _state(row=2, column=1).extend_to(_Cell(4, 3))
+
+ assert extended.anchor == _Cell(2, 1)
+ assert extended.region == _Block(first_row=2, last_row=4, first_column=1, last_column=3)
+
+ def test_a_later_extend_keeps_the_anchor_it_began_on(self) -> None:
+ extended = _state(row=2, column=1).extend_to(_Cell(4, 3)).extend_to(_Cell(6, 5))
+
+ assert extended.anchor == _Cell(2, 1)
+ assert extended.region == _Block(first_row=2, last_row=6, first_column=1, last_column=5)
+
+ def test_extending_backwards_names_the_same_region_as_forwards(self) -> None:
+ backwards = _state(row=4, column=3).extend_to(_Cell(2, 1)).region
+ forwards = _state(row=2, column=1).extend_to(_Cell(4, 3)).region
+
+ assert backwards == forwards
+
+ def test_extending_leaves_nothing_pending(self) -> None:
+ assert _state(pending="5").extend_to(_Cell(4, 3)).pending == ""
+
+ def test_collapsing_drops_the_selection_and_holds_the_entry(self) -> None:
+ collapsed = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3)).collapse()
+
+ assert collapsed.region is None
+ assert collapsed.pending == "5"
+
+ def test_dropping_a_partial_entry_holds_the_selection(self) -> None:
+ held = _state(pending="5").extend_to(_Cell(4, 3)).reset_pending()
+
+ assert held.region is not None
+ assert held.pending == ""
+
+ def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None:
+ cancelled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3)).cancel()
+
+ assert cancelled.region is None
+ assert cancelled.pending == ""
+ assert cancelled.cursor == _Cell(2, 1)
+
+ def test_a_committed_entry_leaves_the_cursor_alone(self) -> None:
+ settled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3))._after_entry()
+
+ assert settled.region is None
+ assert settled.pending == ""
+
+ def test_a_transition_answers_as_the_grid_it_came_from(self) -> None:
+ """A grid state states its own rules, so what a shared rule builds is the grid's own state."""
+ assert isinstance(_state().extend_to(_Cell(4, 3)), _GridState)
+ assert isinstance(_state().reset_pending(), _GridState)
+ assert isinstance(_state().collapse(), _GridState)
+ assert isinstance(_state().select_between(_Cell(0, 0), _Cell(4, 3)), _GridState)
+
+
+class TestSelectBetween:
+ """A select gesture names a shape by its corners, which is how each grid states its own shapes."""
+
+ def test_the_selection_covers_the_rectangle_the_two_cells_bound(self) -> None:
+ selected = _state().select_between(_Cell(0, 0), _Cell(6, 5))
+
+ assert selected.region == _Block(first_row=0, last_row=6, first_column=0, last_column=5)
+
+ def test_the_cursor_lands_on_the_far_corner(self) -> None:
+ """The next extending press then works from the edge the reader has just reached."""
+ selected = _state().select_between(_Cell(0, 0), _Cell(6, 5))
+
+ assert selected.anchor == _Cell(0, 0)
+ assert selected.cursor == _Cell(6, 5)
+
+ def test_a_shape_takes_over_from_the_selection_standing(self) -> None:
+ held = _state().extend_to(_Cell(4, 3))
+
+ selected = held.select_between(_Cell(0, 0), _Cell(6, 5))
+
+ assert selected.region == _Block(first_row=0, last_row=6, first_column=0, last_column=5)
+
+ def test_a_shape_settles_a_partial_entry(self) -> None:
+ assert _state(pending="5").select_between(_Cell(0, 0), _Cell(6, 5)).pending == ""
+
+
+class TestTarget:
+ """The region a block gesture acts on, which is the selection wherever one has been made."""
+
+ def test_a_cell_of_a_grid_with_nothing_selected_is_raised_on_itself(self) -> None:
+ assert _state(row=2, column=1).region_at(_Cell(2, 1)) == _Block(
+ first_row=2,
+ last_row=2,
+ first_column=1,
+ last_column=1,
+ )
+
+ def test_a_cell_inside_the_selection_is_raised_on_the_whole_of_it(self) -> None:
+ selected = _state(row=2, column=1).extend_to(_Cell(6, 5))
+
+ assert selected.region_at(_Cell(4, 3)) == selected.region
+
+ def test_a_cell_outside_the_selection_is_raised_on_itself(self) -> None:
+ selected = _state(row=2, column=1).extend_to(_Cell(4, 3))
+
+ assert selected.region_at(_Cell(8, 7)) == _Block(
+ first_row=8,
+ last_row=8,
+ first_column=7,
+ last_column=7,
+ )
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py
new file mode 100644
index 00000000..00e132c8
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py
@@ -0,0 +1,195 @@
+from typing import Optional
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.ui.panels.sequencer.input.order import (
+ OrderCursor,
+ OrderInputState,
+)
+from sampletones_core.constants.enums import GeneratorName
+
+POSITION_COUNT = 8
+
+
+def _state(
+ generator: Optional[GeneratorName] = GeneratorName.PULSE1,
+ position: int = 0,
+ pending: str = "",
+) -> OrderInputState:
+ return OrderInputState(cursor=OrderCursor(generator, position), pending=pending)
+
+
+class TestNavigation:
+ def test_position_clamps_within_bounds(self) -> None:
+ state = _state(position=2)
+ assert state.navigate_position(5, position_count=4).cursor.position == 3
+ assert state.navigate_position(-5, position_count=4).cursor.position == 0
+
+ def test_position_absolute_jump(self) -> None:
+ assert _state(position=0).navigate_position(3, position_count=4, absolute=True).cursor.position == 3
+
+ def test_position_is_a_no_op_without_positions(self) -> None:
+ state = _state()
+ assert state.navigate_position(1, position_count=0) is state
+
+ def test_channel_cycles_master_then_channels_and_wraps(self) -> None:
+ visited = []
+ state = OrderInputState(cursor=OrderCursor(CHANNEL_AXIS[0], 0))
+ for _ in range(len(CHANNEL_AXIS)):
+ visited.append(state.cursor.generator)
+ state = state.navigate_channel(1)
+
+ assert visited == list(CHANNEL_AXIS)
+ assert state.cursor.generator == CHANNEL_AXIS[0]
+
+
+class TestSelection:
+ """Shift-extended moves grow a region from the cell the selection was started on."""
+
+ def test_a_state_without_an_anchor_covers_no_region(self) -> None:
+ assert _state().region is None
+
+ def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None:
+ extended = _state(position=2).extend_position(1, POSITION_COUNT)
+
+ region = extended.region
+ assert region is not None
+ assert (region.first_position, region.last_position) == (2, 3)
+
+ def test_extending_leftwards_names_the_same_region_as_rightwards(self) -> None:
+ leftwards = _state(position=3).extend_position(-1, POSITION_COUNT).region
+ rightwards = _state(position=2).extend_position(1, POSITION_COUNT).region
+
+ assert leftwards == rightwards
+
+ def test_extending_channels_reaches_from_master_down(self) -> None:
+ extended = _state(generator=None).extend_channel(2)
+
+ region = extended.region
+ assert region is not None
+ assert region.generators == (None, GeneratorName.PULSE1, GeneratorName.PULSE2)
+
+ def test_extending_channels_stops_at_either_end_of_the_axis(self) -> None:
+ """A selection covers a run of the table, so its reach stops where plain navigation wraps."""
+ first = _state(generator=CHANNEL_AXIS[0]).extend_channel(-1)
+ last = _state(generator=CHANNEL_AXIS[-1]).extend_channel(1)
+
+ assert first.cursor == OrderCursor(CHANNEL_AXIS[0], 0)
+ assert last.cursor == OrderCursor(CHANNEL_AXIS[-1], 0)
+
+ def test_a_plain_move_collapses_the_selection(self) -> None:
+ moved = _state(position=1).extend_position(2, POSITION_COUNT).navigate_position(1, POSITION_COUNT)
+
+ assert moved.anchor is None
+ assert moved.region is None
+
+ def test_a_plain_channel_move_collapses_the_selection(self) -> None:
+ moved = _state().extend_position(1, POSITION_COUNT).navigate_channel(1)
+
+ assert moved.region is None
+
+ def test_dropping_a_partial_entry_holds_the_selection(self) -> None:
+ held = _state(pending="5").extend_position(1, POSITION_COUNT).reset_pending()
+
+ assert held.region is not None
+
+ def test_typing_an_index_collapses_the_selection(self) -> None:
+ selected = _state(position=1).extend_position(2, POSITION_COUNT)
+
+ partial, first = selected.type_char("0")
+ committed, index = partial.type_char("2")
+
+ assert first is None
+ assert index == 2
+ assert committed.region is None
+
+ def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None:
+ cancelled = _state(pending="5").extend_position(1, POSITION_COUNT).cancel()
+
+ assert cancelled.region is None
+ assert cancelled.pending == ""
+
+
+class TestTarget:
+ """The region a block gesture acts on, which is the selection wherever one has been made."""
+
+ def test_a_cell_of_a_table_with_nothing_selected_is_raised_on_itself(self) -> None:
+ cell = OrderCursor(GeneratorName.PULSE2, 4)
+
+ region = _state(GeneratorName.PULSE2, position=4).region_at(cell)
+
+ assert (region.first_position, region.last_position) == (4, 4)
+ assert region.generators == (GeneratorName.PULSE2,)
+
+ def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None:
+ selected = _state(position=4).extend_position(2, POSITION_COUNT)
+ cell = OrderCursor(GeneratorName.PULSE1, 5)
+
+ assert selected.region_at(cell) == selected.region
+
+
+class TestSelectShapes:
+ """The two shapes the table states, each running every position and ending at its far corner."""
+
+ def test_selecting_all_reaches_every_row_and_every_position(self) -> None:
+ selected = _state(position=2).select_all(POSITION_COUNT)
+
+ region = selected.region
+ assert region is not None
+ assert region.generators == CHANNEL_AXIS
+ assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1)
+
+ def test_selecting_a_row_reaches_the_cursor_s_channel_across_the_order(self) -> None:
+ cell = OrderCursor(GeneratorName.TRIANGLE, 2)
+
+ selected = _state(GeneratorName.TRIANGLE, position=2).select_row(cell, POSITION_COUNT)
+
+ region = selected.region
+ assert region is not None
+ assert region.generators == (GeneratorName.TRIANGLE,)
+ assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1)
+
+ def test_the_master_row_is_a_row_like_any_other(self) -> None:
+ cell = OrderCursor(None, 2)
+
+ selected = _state(None, position=2).select_row(cell, POSITION_COUNT)
+
+ region = selected.region
+ assert region is not None
+ assert region.generators == (None,)
+
+ def test_a_shape_stands_the_cursor_on_the_last_position_it_reaches(self) -> None:
+ """A shape ends where the next Shift+arrow starts, which is the far corner it covers."""
+ selected = _state(position=2).select_all(POSITION_COUNT)
+
+ assert selected.anchor == OrderCursor(CHANNEL_AXIS[0], 0)
+ assert selected.cursor == OrderCursor(CHANNEL_AXIS[-1], POSITION_COUNT - 1)
+
+ def test_an_order_holding_no_positions_selects_nothing(self) -> None:
+ state = _state()
+
+ assert state.select_all(0) is state
+
+
+class TestEntry:
+ def test_type_char_commits_after_two_digits(self) -> None:
+ partial, first = _state().type_char("A")
+ assert first is None
+ assert partial.pending == "A"
+
+ committed, index = partial.type_char("F")
+ assert index == 0xAF
+ assert committed.pending == ""
+
+ def test_commit_partial_pads_a_single_digit(self) -> None:
+ committed, index = _state(pending="5").commit_partial()
+ assert index == 5
+ assert committed.pending == ""
+
+ def test_commit_partial_is_a_no_op_without_pending(self) -> None:
+ state = _state()
+ committed, index = state.commit_partial()
+ assert index is None
+ assert committed is state
+
+ def test_cancel_clears_pending(self) -> None:
+ assert _state(pending="3").cancel().pending == ""
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py
index cd12a80c..b4731cb4 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py
@@ -1,10 +1,12 @@
from typing import Optional
-from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor
-from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
from sampletones_core.constants.enums import GeneratorName
+ROW_COUNT = 64
+
def _state(
subcolumn: SubColumn,
@@ -37,6 +39,173 @@ def test_minus_in_transpose_is_a_sign_not_note_off(self) -> None:
assert new_state.pending.startswith("-")
+class TestSelection:
+ """Shift-extended moves grow a region from the cell the selection was started on."""
+
+ def test_a_state_without_an_anchor_covers_no_region(self) -> None:
+ assert _state(SubColumn.INSTRUMENT).region is None
+
+ def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None:
+ extended = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT)
+
+ region = extended.region
+ assert region is not None
+ assert (region.first_row, region.last_row) == (4, 5)
+
+ def test_extending_upwards_names_the_same_region_as_downwards(self) -> None:
+ """The bounds are ordered by the region, so the direction of the drag leaves no trace."""
+ upwards = _state(SubColumn.INSTRUMENT, row=5).extend_row(-1, ROW_COUNT).region
+ downwards = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT).region
+
+ assert upwards == downwards
+
+ def test_a_further_extend_keeps_the_original_anchor(self) -> None:
+ extended = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT).extend_row(3, ROW_COUNT)
+
+ region = extended.region
+ assert region is not None
+ assert (region.first_row, region.last_row) == (4, 8)
+
+ def test_extending_slots_reaches_across_the_column_boundary(self) -> None:
+ extended = _state(SubColumn.VOLUME, generator=None).extend_slot(1)
+
+ region = extended.region
+ assert region is not None
+ assert (region.first_slot, region.last_slot) == (2, 3)
+ assert extended.cursor is not None
+ assert extended.cursor.generator is GeneratorName.PULSE1
+ assert extended.cursor.subcolumn is SubColumn.INSTRUMENT
+
+ def test_extending_slots_stops_at_either_end_of_the_axis(self) -> None:
+ """A selection covers a run of the grid, so its reach stops where plain navigation wraps."""
+ first = _state(SubColumn.INSTRUMENT, generator=None).extend_slot(-1)
+ last = _state(SubColumn.VOLUME, generator=GeneratorName.NOISE).extend_slot(1)
+
+ assert first.cursor == TrackerCursor(0, None, SubColumn.INSTRUMENT)
+ assert last.cursor == TrackerCursor(0, GeneratorName.NOISE, SubColumn.VOLUME)
+
+ def test_a_plain_move_collapses_the_selection(self) -> None:
+ moved = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT).navigate_row(1, ROW_COUNT)
+
+ assert moved.anchor is None
+ assert moved.region is None
+
+ def test_a_plain_column_move_collapses_the_selection(self) -> None:
+ moved = _state(SubColumn.INSTRUMENT).extend_row(2, ROW_COUNT).navigate_column_by(1)
+
+ assert moved.region is None
+
+ def test_dropping_a_partial_entry_holds_the_selection(self) -> None:
+ """Every move commits what was typed first, the extending ones included."""
+ held = _state(SubColumn.VOLUME, pending="5").extend_row(1, ROW_COUNT).reset_pending()
+
+ assert held.region is not None
+
+ def test_typing_a_value_collapses_the_selection(self) -> None:
+ selected = _state(SubColumn.VOLUME, row=4).extend_row(2, ROW_COUNT)
+
+ typed, action = selected.type_char("7")
+
+ assert action is not None
+ assert typed.region is None
+
+ def test_a_note_off_collapses_the_selection(self) -> None:
+ selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT)
+
+ typed, action = selected.type_char("-")
+
+ assert action is not None
+ assert action.note_off is True
+ assert typed.region is None
+
+ def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None:
+ cancelled = _state(SubColumn.VOLUME, pending="5").extend_row(1, ROW_COUNT).cancel()
+
+ assert cancelled.region is None
+ assert cancelled.pending == ""
+
+ def test_collapse_keeps_the_cursor_where_it_stands(self) -> None:
+ selected = _state(SubColumn.TRANSPOSE, row=4).extend_row(2, ROW_COUNT)
+
+ collapsed = selected.collapse()
+
+ assert collapsed.cursor == selected.cursor
+ assert collapsed.region is None
+
+
+class TestTargetRegion:
+ """The region a block gesture acts on, which is the selection wherever one has been made."""
+
+ def test_a_cell_of_a_grid_with_nothing_selected_is_raised_on_itself(self) -> None:
+ cell = TrackerCursor(4, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+
+ region = _state(SubColumn.TRANSPOSE, row=4).region_at(cell)
+
+ assert (region.first_row, region.last_row) == (4, 4)
+ assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),)
+
+ def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None:
+ selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT)
+ cell = TrackerCursor(5, GeneratorName.PULSE1, SubColumn.INSTRUMENT)
+
+ assert selected.region_at(cell) == selected.region
+
+
+class TestSelectShapes:
+ """The three shapes the grid states, each running the whole frame and ending at its far corner."""
+
+ def test_selecting_all_reaches_every_row_and_every_slot(self) -> None:
+ selected = _state(SubColumn.TRANSPOSE, row=4).select_all(ROW_COUNT)
+
+ region = selected.region
+ assert region is not None
+ assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1)
+ assert (region.first_slot, region.last_slot) == (0, SLOT_COUNT - 1)
+
+ def test_selecting_a_column_reaches_the_cursor_s_channel_and_its_subcolumns(self) -> None:
+ cell = TrackerCursor(4, GeneratorName.TRIANGLE, SubColumn.TRANSPOSE)
+
+ selected = _state(SubColumn.TRANSPOSE, row=4).select_column(cell, ROW_COUNT)
+
+ region = selected.region
+ assert region is not None
+ assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1)
+ assert region.slots == tuple(TrackerSlot(GeneratorName.TRIANGLE, subcolumn) for subcolumn in SubColumn)
+
+ def test_the_sample_column_is_a_column_like_any_other(self) -> None:
+ cell = TrackerCursor(4, None, SubColumn.VOLUME)
+
+ selected = _state(SubColumn.VOLUME, row=4, generator=None).select_column(cell, ROW_COUNT)
+
+ region = selected.region
+ assert region is not None
+ assert region.columns == (None,)
+
+ def test_selecting_a_subcolumn_reaches_the_one_slot_the_cursor_stands_on(self) -> None:
+ cell = TrackerCursor(4, GeneratorName.NOISE, SubColumn.VOLUME)
+
+ selected = _state(SubColumn.VOLUME, row=4, generator=GeneratorName.NOISE).select_subcolumn(cell, ROW_COUNT)
+
+ region = selected.region
+ assert region is not None
+ assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1)
+ assert region.slots == (TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME),)
+
+ def test_a_shape_stands_the_cursor_on_the_last_row_it_reaches(self) -> None:
+ """A shape ends where the next Shift+arrow starts, which is the far corner it covers."""
+ cell = TrackerCursor(4, GeneratorName.PULSE1, SubColumn.INSTRUMENT)
+
+ selected = _state(SubColumn.INSTRUMENT, row=4).select_column(cell, ROW_COUNT)
+
+ assert selected.cursor == TrackerCursor(ROW_COUNT - 1, GeneratorName.PULSE1, SubColumn.VOLUME)
+ assert selected.anchor == TrackerCursor(0, GeneratorName.PULSE1, SubColumn.INSTRUMENT)
+
+ def test_a_frame_holding_no_rows_selects_nothing(self) -> None:
+ state = _state(SubColumn.INSTRUMENT)
+
+ assert state.select_all(0) is state
+
+
class TestColumnNavigation:
def test_tab_preserves_subcolumn(self) -> None:
state = _state(SubColumn.VOLUME, generator=GeneratorName.PULSE1)
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py
new file mode 100644
index 00000000..b82dcdae
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py
@@ -0,0 +1,396 @@
+from dataclasses import dataclass, field
+from typing import List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.ui.elements.table.cells import EditableCells
+from sampletones_application.ui.panels.sequencer import tracker as tracker_module
+from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures
+from sampletones_application.ui.panels.sequencer.input.order import (
+ OrderCursor,
+ OrderInputState,
+)
+from sampletones_application.ui.panels.sequencer.input.target import OrderTarget, TrackerTarget
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.view_model.sequencer.region import (
+ OrderCell,
+ OrderRegion,
+ TrackerCell,
+ TrackerRegion,
+)
+from sampletones_application.view_model.sequencer.slot import TrackerSlot
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP
+from tests.suite.grid import (
+ ORDER_BLOCK_SHORTCUTS,
+ TRACKER_BLOCK_SHORTCUTS,
+ attach_edit_surface,
+)
+from tests.suite.shortcuts import shipped_source
+
+ROW_COUNT = 64
+CURSOR_ROW = 4
+POSITION_COUNT = 8
+CURSOR_POSITION = 2
+MASTER_ROW = CHANNEL_AXIS.index(None)
+PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1)
+
+
+@dataclass
+class Gestures:
+ """What each of the tracker's hooks was handed, which is the whole of what a press reaches the
+ grid with."""
+
+ copied: List[TrackerRegion] = field(default_factory=list)
+ cut: List[TrackerRegion] = field(default_factory=list)
+ deleted: List[TrackerRegion] = field(default_factory=list)
+ pasted: List[TrackerCell] = field(default_factory=list)
+ cleared: List[Tuple[int, Optional[GeneratorName]]] = field(default_factory=list)
+ transposed: List[Tuple[TrackerRegion, int]] = field(default_factory=list)
+ volume_shifted: List[Tuple[TrackerRegion, int]] = field(default_factory=list)
+
+
+@dataclass
+class OrderGestures:
+ """What each of the order's block hooks was handed, read the same way the tracker's are."""
+
+ copied: List[OrderRegion] = field(default_factory=list)
+ cut: List[OrderRegion] = field(default_factory=list)
+ deleted: List[OrderRegion] = field(default_factory=list)
+ pasted: List[OrderCell] = field(default_factory=list)
+ cleared: List[Tuple[GeneratorName, int, Optional[int]]] = field(default_factory=list)
+
+
+def _press(text: str) -> KeyEvent:
+ """The press a written combination names, as the router delivers it."""
+ combination = KeyCombination.parse(text)
+ return KeyEvent(key=combination.key, modifiers=combination.modifiers)
+
+
+def _panel(
+ monkeypatch: pytest.MonkeyPatch,
+ gestures: Gestures,
+ *,
+ generator: Optional[GeneratorName] = GeneratorName.PULSE1,
+ subcolumn: SubColumn = SubColumn.INSTRUMENT,
+) -> GUISequencerTrackerPanel:
+ """A tracker panel reporting the gestures it fires, with its grid left unbuilt.
+
+ Applying a state draws into DearPyGui, which has no table here, so the draw is left out and
+ each gesture is read from what its hook receives.
+ """
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._shortcuts = shipped_source()
+ panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, generator, subcolumn))
+ panel._current_row_count = ROW_COUNT
+ panel._editable_cells = EditableCells()
+ panel.on_copy_block = gestures.copied.append
+ panel.on_cut_block = gestures.cut.append
+ panel.on_delete_block = gestures.deleted.append
+ panel.on_paste_block = gestures.pasted.append
+ panel.on_clear_row = lambda row, generator_name: gestures.cleared.append((row, generator_name))
+ panel.on_adjust_transpose = lambda region, delta: gestures.transposed.append((region, delta))
+ panel.on_adjust_volume = lambda region, delta: gestures.volume_shifted.append((region, delta))
+ panel.can_paste_block = lambda: True
+ panel._blocks = BlockGestures(grid=panel)
+ attach_edit_surface(panel, TRACKER_BLOCK_SHORTCUTS, TrackerTarget)
+ monkeypatch.setattr(panel, "_apply_state", lambda state: None)
+ return panel
+
+
+def _order_panel(
+ monkeypatch: pytest.MonkeyPatch,
+ gestures: OrderGestures,
+ *,
+ generator: Optional[GeneratorName] = GeneratorName.PULSE1,
+) -> GUISequencerOrderPanel:
+ """An order panel reporting the gestures it fires, with its table left unbuilt."""
+ panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
+ panel._shortcuts = shipped_source()
+ panel._input_state = OrderInputState(cursor=OrderCursor(generator, CURSOR_POSITION))
+ panel._position_count = POSITION_COUNT
+ panel.on_copy_block = gestures.copied.append
+ panel.on_cut_block = gestures.cut.append
+ panel.on_delete_block = gestures.deleted.append
+ panel.on_paste_block = gestures.pasted.append
+ panel.on_set_order_entry = lambda channel, position, index: gestures.cleared.append((channel, position, index))
+ panel.can_paste_block = lambda: True
+ panel._blocks = BlockGestures(grid=panel)
+ attach_edit_surface(panel, ORDER_BLOCK_SHORTCUTS, OrderTarget)
+ monkeypatch.setattr(panel, "_apply_state", lambda state, notify=True: None)
+ return panel
+
+
+class TestTrackerCopyKey:
+ def test_a_selection_is_copied_whole(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ gestures = Gestures()
+ panel = _panel(monkeypatch, gestures)
+ panel._input_state = panel._input_state.extend_row(2, ROW_COUNT)
+
+ assert panel._on_key_pressed(_press("Ctrl+C")) is True
+ assert gestures.copied == [
+ TrackerRegion(
+ first_row=CURSOR_ROW,
+ last_row=CURSOR_ROW + 2,
+ first_slot=3,
+ last_slot=3,
+ )
+ ]
+
+ def test_a_cursor_alone_copies_the_cell_it_stands_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ gestures = Gestures()
+ panel = _panel(monkeypatch, gestures, subcolumn=SubColumn.VOLUME)
+
+ assert panel._on_key_pressed(_press("Ctrl+C")) is True
+ assert gestures.copied[-1].rows == range(CURSOR_ROW, CURSOR_ROW + 1)
+ assert gestures.copied[-1].slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),)
+
+ def test_a_grid_with_no_cursor_copies_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ gestures = Gestures()
+ panel = _panel(monkeypatch, gestures)
+ panel._input_state = TrackerInputState()
+
+ assert panel._on_key_pressed(_press("Ctrl+C")) is False
+ assert gestures.copied == []
+
+
+class TestTrackerCutKey:
+ def test_a_selection_is_cut_whole(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ gestures = Gestures()
+ panel = _panel(monkeypatch, gestures)
+ panel._input_state = panel._input_state.extend_row(2, ROW_COUNT)
+
+ assert panel._on_key_pressed(_press("Ctrl+X")) is True
+ assert gestures.cut == [
+ TrackerRegion(
+ first_row=CURSOR_ROW,
+ last_row=CURSOR_ROW + 2,
+ first_slot=3,
+ last_slot=3,
+ )
+ ]
+ assert gestures.copied == []
+
+
+class TestTrackerPasteKey:
+ def test_a_paste_names_the_cell_the_cursor_stands_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """The cell carries a row and a column alone, so the subcolumn under the cursor is left
+ for the block to decide."""
+ gestures = Gestures()
+ panel = _panel(monkeypatch, gestures, subcolumn=SubColumn.VOLUME)
+
+ assert panel._on_key_pressed(_press("Ctrl+V")) is True
+ assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, generator=GeneratorName.PULSE1)]
+
+ def test_the_sample_column_is_a_cell_a_block_lands_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ gestures = Gestures()
+ panel = _panel(monkeypatch, gestures, generator=None)
+
+ assert panel._on_key_pressed(_press("Ctrl+V")) is True
+ assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, generator=None)]
+
+
+class TestTrackerDeleteKey:
+ def test_a_selection_is_deleted_whole(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ gestures = Gestures()
+ panel = _panel(monkeypatch, gestures)
+ panel._input_state = panel._input_state.extend_row(2, ROW_COUNT)
+
+ assert panel._on_key_pressed(_press("Del")) is True
+ assert gestures.deleted == [
+ TrackerRegion(
+ first_row=CURSOR_ROW,
+ last_row=CURSOR_ROW + 2,
+ first_slot=3,
+ last_slot=3,
+ )
+ ]
+ assert gestures.cleared == []
+
+ def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """Delete already means something without a selection, so that meaning is what it keeps."""
+ gestures = Gestures()
+ panel = _panel(monkeypatch, gestures)
+
+ assert panel._on_key_pressed(_press("Del")) is True
+ assert gestures.deleted == []
+ assert gestures.cleared == [(CURSOR_ROW, GeneratorName.PULSE1)]
+
+
+class TestTrackerAdjustKeys:
+ """The shifts reach the same block the clipboard keys do, so a selection moves whole."""
+
+ def test_a_selection_is_transposed_whole(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ gestures = Gestures()
+ panel = _panel(monkeypatch, gestures)
+ panel._input_state = panel._input_state.extend_row(2, ROW_COUNT)
+
+ assert panel._on_key_pressed(_press("Ctrl+Up")) is True
+ assert gestures.transposed == [
+ (
+ TrackerRegion(
+ first_row=CURSOR_ROW,
+ last_row=CURSOR_ROW + 2,
+ first_slot=3,
+ last_slot=3,
+ ),
+ SEMITONE_STEP,
+ )
+ ]
+
+ def test_a_cursor_alone_shifts_the_cell_it_stands_on(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ gestures = Gestures()
+ panel = _panel(monkeypatch, gestures, subcolumn=SubColumn.VOLUME)
+
+ assert panel._on_key_pressed(_press("Alt+Down")) is True
+ region, delta = gestures.volume_shifted[-1]
+ assert region.rows == range(CURSOR_ROW, CURSOR_ROW + 1)
+ assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),)
+ assert delta == -tracker_module.VOLUME_FINE_STEP
+
+ def test_shift_makes_the_step_the_bigger_one(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ gestures = Gestures()
+ panel = _panel(monkeypatch, gestures)
+
+ assert panel._on_key_pressed(_press("Ctrl+Shift+Up")) is True
+ assert panel._on_key_pressed(_press("Alt+Shift+Up")) is True
+ assert gestures.transposed[-1][1] == OCTAVE_SEMITONES
+ assert gestures.volume_shifted[-1][1] == tracker_module.VOLUME_COARSE_STEP
+
+ def test_a_grid_with_no_cursor_shifts_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ gestures = Gestures()
+ panel = _panel(monkeypatch, gestures)
+ panel._input_state = TrackerInputState()
+
+ assert panel._on_key_pressed(_press("Ctrl+Up")) is False
+ assert gestures.transposed == []
+
+
+class TestOrderCopyKey:
+ def test_a_selection_is_copied_whole(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ gestures = OrderGestures()
+ panel = _order_panel(monkeypatch, gestures)
+ panel._input_state = panel._input_state.extend_position(1, POSITION_COUNT)
+
+ assert panel._on_key_pressed(_press("Ctrl+C")) is True
+ assert gestures.copied == [
+ OrderRegion(
+ first_row=PULSE1_ROW,
+ last_row=PULSE1_ROW,
+ first_position=CURSOR_POSITION,
+ last_position=CURSOR_POSITION + 1,
+ )
+ ]
+
+ def test_a_cursor_alone_copies_the_cell_it_stands_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ gestures = OrderGestures()
+ panel = _order_panel(monkeypatch, gestures, generator=None)
+
+ assert panel._on_key_pressed(_press("Ctrl+C")) is True
+ assert gestures.copied == [
+ OrderRegion(
+ first_row=MASTER_ROW,
+ last_row=MASTER_ROW,
+ first_position=CURSOR_POSITION,
+ last_position=CURSOR_POSITION,
+ )
+ ]
+
+ def test_a_table_with_no_cursor_copies_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ gestures = OrderGestures()
+ panel = _order_panel(monkeypatch, gestures)
+ panel._input_state = OrderInputState()
+
+ assert panel._on_key_pressed(_press("Ctrl+C")) is False
+ assert gestures.copied == []
+
+
+class TestOrderCutKey:
+ def test_a_selection_is_cut_whole(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ gestures = OrderGestures()
+ panel = _order_panel(monkeypatch, gestures)
+ panel._input_state = panel._input_state.extend_channel(1)
+
+ assert panel._on_key_pressed(_press("Ctrl+X")) is True
+ assert gestures.cut == [
+ OrderRegion(
+ first_row=PULSE1_ROW,
+ last_row=PULSE1_ROW + 1,
+ first_position=CURSOR_POSITION,
+ last_position=CURSOR_POSITION,
+ )
+ ]
+ assert gestures.copied == []
+
+
+class TestOrderPasteKey:
+ def test_a_paste_names_the_cell_the_cursor_stands_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ gestures = OrderGestures()
+ panel = _order_panel(monkeypatch, gestures)
+
+ assert panel._on_key_pressed(_press("Ctrl+V")) is True
+ assert gestures.pasted == [OrderCell(generator=GeneratorName.PULSE1, position=CURSOR_POSITION)]
+
+ def test_the_master_row_is_a_cell_a_block_lands_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ gestures = OrderGestures()
+ panel = _order_panel(monkeypatch, gestures, generator=None)
+
+ assert panel._on_key_pressed(_press("Ctrl+V")) is True
+ assert gestures.pasted == [OrderCell(generator=None, position=CURSOR_POSITION)]
+
+
+class TestOrderDeleteKey:
+ def test_a_selection_is_deleted_whole(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ gestures = OrderGestures()
+ panel = _order_panel(monkeypatch, gestures)
+ panel._input_state = panel._input_state.extend_position(1, POSITION_COUNT)
+
+ assert panel._on_key_pressed(_press("Del")) is True
+ assert gestures.deleted == [
+ OrderRegion(
+ first_row=PULSE1_ROW,
+ last_row=PULSE1_ROW,
+ first_position=CURSOR_POSITION,
+ last_position=CURSOR_POSITION + 1,
+ )
+ ]
+ assert gestures.cleared == []
+
+ def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """Delete already means something without a selection, so that meaning is what it keeps."""
+ gestures = OrderGestures()
+ panel = _order_panel(monkeypatch, gestures)
+
+ assert panel._on_key_pressed(_press("Del")) is True
+ assert gestures.deleted == []
+ assert gestures.cleared == [(GeneratorName.PULSE1, CURSOR_POSITION, None)]
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py
new file mode 100644
index 00000000..53e5b214
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py
@@ -0,0 +1,602 @@
+import contextlib
+from dataclasses import dataclass, field
+from types import ModuleType
+from typing import Any, Callable, Iterator, List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.ui.panels.sequencer import order as order_module
+from sampletones_application.ui.panels.sequencer import tracker as tracker_module
+from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures
+from sampletones_application.ui.panels.sequencer.grid.surface import clipboard as clipboard_module
+from sampletones_application.ui.panels.sequencer.input.order import (
+ OrderCursor,
+ OrderInputState,
+)
+from sampletones_application.ui.panels.sequencer.input.target import OrderTarget, TrackerTarget
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.view_model.sequencer.region import (
+ OrderCell,
+ OrderRegion,
+ TrackerCell,
+ TrackerRegion,
+)
+from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot, slot_from_flat
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from tests.suite.grid import (
+ ORDER_BLOCK_SHORTCUTS,
+ TRACKER_BLOCK_SHORTCUTS,
+ attach_edit_surface,
+)
+from tests.suite.shortcuts import shipped_source
+
+CLICKED_ROW = 4
+CLICKED_POSITION = 2
+ROW_COUNT = 64
+POSITION_COUNT = 8
+
+COPY_ITEM = 0
+CUT_ITEM = 1
+PASTE_ITEM = 2
+DELETE_ITEM = 3
+
+SELECT_ALL_ITEM = 0
+SELECT_COLUMN_ITEM = 1
+SELECT_SUBCOLUMN_ITEM = 2
+SELECT_ROW_ITEM = 1
+
+PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1)
+
+
+@dataclass
+class MenuItem:
+ """One item as it was registered, which is the whole of what a reader sees and clicks."""
+
+ label: str
+ enabled: bool
+ callback: Callable[[], None]
+ shortcut: str = ""
+
+
+@dataclass
+class Gestures:
+ """What each block hook was handed when its menu item fired."""
+
+ copied: List[Any] = field(default_factory=list)
+ cut: List[Any] = field(default_factory=list)
+ deleted: List[Any] = field(default_factory=list)
+ pasted: List[Any] = field(default_factory=list)
+
+
+def _prints_only() -> None:
+ """Stands in for the callback of an item that only states something, such as an empty list."""
+
+
+class _MenuRecorder:
+ """Captures the items a builder registers, in the order it registers them."""
+
+ def __init__(self) -> None:
+ self.items: List[MenuItem] = []
+
+ def add_menu_item(self, **kwargs: Any) -> int:
+ self.items.append(
+ MenuItem(
+ label=kwargs["label"],
+ enabled=kwargs.get("enabled", True),
+ callback=kwargs.get("callback", _prints_only),
+ shortcut=kwargs.get("shortcut", ""),
+ )
+ )
+ return 0
+
+
+TRACKER_LABELS = (
+ "select_all",
+ "select_column",
+ "select_subcolumn",
+ "note_off",
+ "set_instrument",
+ "no_samples",
+ "clear_subcolumn",
+ "clear_cell",
+ "clear_row",
+)
+
+ORDER_LABELS = (
+ "select_all",
+ "select_row",
+ "duplicate",
+ "clone",
+ "insert",
+ "clear",
+ "remove",
+ "move_left",
+ "move_right",
+ "move_start",
+ "move_end",
+)
+
+
+def _labels(panel: Any, names: Tuple[str, ...]) -> None:
+ """Gives the panel the words its own builders print, each reading as the action it names."""
+ for name in names:
+ setattr(panel, f"_lbl_context_{name}", name)
+
+
+def _adjust_labels(panel: Any) -> None:
+ """Gives the panel the words its transpose and volume items print, each reading as its element."""
+ panel._lbl_adjust = {
+ element: element.value for element, _, _ in (*tracker_module.TRANSPOSE_ACTIONS, *tracker_module.VOLUME_ACTIONS)
+ }
+
+
+def _tracker_panel(
+ gestures: Gestures,
+ *,
+ can_paste: bool = True,
+) -> tracker_module.GUISequencerTrackerPanel:
+ """A tracker panel whose menu builder can run with no DearPyGui context behind it."""
+ panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel)
+ _labels(panel, TRACKER_LABELS)
+ _adjust_labels(panel)
+ panel._shortcuts = shipped_source()
+ panel._input_state = TrackerInputState()
+ panel._current_samples = None
+ panel.on_copy_block = gestures.copied.append
+ panel.on_cut_block = gestures.cut.append
+ panel.on_delete_block = gestures.deleted.append
+ panel.on_paste_block = gestures.pasted.append
+ panel.can_paste_block = lambda: can_paste
+ panel._blocks = BlockGestures(grid=panel)
+ attach_edit_surface(panel, TRACKER_BLOCK_SHORTCUTS, TrackerTarget)
+ return panel
+
+
+def _order_panel(
+ gestures: Gestures,
+ *,
+ can_paste: bool = True,
+) -> order_module.GUISequencerOrderPanel:
+ """An order panel whose menu builder can run with no DearPyGui context behind it."""
+ panel = order_module.GUISequencerOrderPanel.__new__(order_module.GUISequencerOrderPanel)
+ _labels(panel, ORDER_LABELS)
+ panel._shortcuts = shipped_source()
+ panel._input_state = OrderInputState()
+ panel._position_count = POSITION_COUNT
+ panel.on_copy_block = gestures.copied.append
+ panel.on_cut_block = gestures.cut.append
+ panel.on_delete_block = gestures.deleted.append
+ panel.on_paste_block = gestures.pasted.append
+ panel.can_paste_block = lambda: can_paste
+ panel._blocks = BlockGestures(grid=panel)
+ attach_edit_surface(panel, ORDER_BLOCK_SHORTCUTS, OrderTarget)
+ return panel
+
+
+@contextlib.contextmanager
+def _submenu(**_kwargs: Any) -> Iterator[None]:
+ """Stands in for a submenu, whose items land in the same recording as the rest."""
+ yield
+
+
+def _record_into(
+ monkeypatch: pytest.MonkeyPatch,
+ module: ModuleType,
+) -> _MenuRecorder:
+ recorder = _MenuRecorder()
+ for target in (module, clipboard_module):
+ monkeypatch.setattr(target.dpg, "add_menu_item", recorder.add_menu_item)
+ monkeypatch.setattr(target.dpg, "add_separator", lambda **_kwargs: 0)
+ monkeypatch.setattr(target.dpg, "menu", _submenu)
+
+ return recorder
+
+
+@pytest.fixture
+def tracker_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder:
+ return _record_into(monkeypatch, tracker_module)
+
+
+@pytest.fixture
+def order_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder:
+ return _record_into(monkeypatch, order_module)
+
+
+def _tracker_cell(generator: Optional[GeneratorName]) -> TrackerCursor:
+ """The clicked cell the tracker item tests raise their menu on."""
+ return TrackerCursor(CLICKED_ROW, generator, SubColumn.INSTRUMENT)
+
+
+def _order_cell(generator: Optional[GeneratorName]) -> OrderCursor:
+ """The clicked cell the order item tests raise their menu on."""
+ return OrderCursor(generator, CLICKED_POSITION)
+
+
+def _tracker_selections(
+ monkeypatch: pytest.MonkeyPatch,
+ panel: tracker_module.GUISequencerTrackerPanel,
+) -> List[TrackerInputState]:
+ """The states a select item applies, on a grid holding a cursor and the rows to reach."""
+ panel._input_state = TrackerInputState(cursor=_tracker_cell(GeneratorName.PULSE1))
+ panel._current_row_count = ROW_COUNT
+ states: List[TrackerInputState] = []
+ monkeypatch.setattr(panel, "_apply_state", states.append)
+ monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: None)
+ return states
+
+
+def _order_selections(
+ monkeypatch: pytest.MonkeyPatch,
+ panel: order_module.GUISequencerOrderPanel,
+) -> List[OrderInputState]:
+ """The states a select item applies, on a table holding a cursor and the positions to reach."""
+ panel._input_state = OrderInputState(cursor=_order_cell(GeneratorName.PULSE1))
+ states: List[OrderInputState] = []
+ monkeypatch.setattr(panel, "_apply_state", lambda state, notify=True: states.append(state))
+ return states
+
+
+def _selected_tracker_state() -> TrackerInputState:
+ """A selection running from the clicked row down two rows, over Pulse 1's whole cell."""
+ state = TrackerInputState(cursor=TrackerCursor(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT))
+ return state.extend_row(2, ROW_COUNT).extend_slot(2)
+
+
+def _selected_order_state() -> OrderInputState:
+ """A selection running from the clicked position across two positions of Pulse 1's row."""
+ state = OrderInputState(cursor=OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION))
+ return state.extend_position(2, POSITION_COUNT)
+
+
+class TestTrackerTarget:
+ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None:
+ panel = _tracker_panel(Gestures())
+ panel._input_state = _selected_tracker_state()
+
+ target = panel._surface.target_at(
+ TrackerCursor(
+ CLICKED_ROW + 1,
+ GeneratorName.PULSE1,
+ SubColumn.TRANSPOSE,
+ )
+ )
+
+ assert target.region == panel._input_state.region
+
+ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> None:
+ panel = _tracker_panel(Gestures())
+ panel._input_state = _selected_tracker_state()
+
+ target = panel._surface.target_at(
+ TrackerCursor(
+ CLICKED_ROW,
+ GeneratorName.TRIANGLE,
+ SubColumn.VOLUME,
+ )
+ )
+
+ assert target.region == TrackerRegion(
+ first_row=CLICKED_ROW,
+ last_row=CLICKED_ROW,
+ first_slot=TrackerSlot(GeneratorName.TRIANGLE, SubColumn.VOLUME).flat_index,
+ last_slot=TrackerSlot(GeneratorName.TRIANGLE, SubColumn.VOLUME).flat_index,
+ )
+
+ def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None:
+ panel = _tracker_panel(Gestures())
+
+ target = panel._surface.target_at(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT))
+
+ assert target.region.rows == range(CLICKED_ROW, CLICKED_ROW + 1)
+ assert target.region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),)
+
+ def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None:
+ """The menu bar asks for the cursor's own target, which is the standing selection."""
+ panel = _tracker_panel(Gestures())
+ panel._input_state = _selected_tracker_state()
+
+ target = panel._surface.cursor_target()
+
+ assert target is not None
+ assert target.region == panel._input_state.region
+
+ def test_a_grid_holding_no_cursor_names_no_target(self) -> None:
+ assert _tracker_panel(Gestures())._surface.cursor_target() is None
+
+ def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None:
+ panel = _tracker_panel(Gestures())
+ cursor = TrackerCursor(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME)
+ panel._input_state = TrackerInputState(cursor=cursor)
+
+ target = panel._surface.cursor_target()
+
+ assert target is not None
+ assert target.region == TrackerRegion(
+ first_row=CLICKED_ROW,
+ last_row=CLICKED_ROW,
+ first_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index,
+ last_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index,
+ )
+ assert target.anchor == TrackerCell(row=CLICKED_ROW, generator=GeneratorName.NOISE)
+
+
+class TestTrackerMenuItems:
+ def test_the_items_hand_out_the_block_the_menu_was_raised_on(
+ self,
+ tracker_recorder: _MenuRecorder,
+ ) -> None:
+ gestures = Gestures()
+ panel = _tracker_panel(gestures)
+ panel._input_state = _selected_tracker_state()
+ selection = panel._input_state.region
+
+ panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1)))
+ for item in tracker_recorder.items:
+ item.callback()
+
+ assert gestures.copied == [selection]
+ assert gestures.cut == [selection]
+ assert gestures.deleted == [selection]
+
+ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecorder) -> None:
+ """The cell carries a row and a column alone, so the clicked subcolumn is left to the block."""
+ gestures = Gestures()
+ panel = _tracker_panel(gestures)
+
+ panel._surface.add_block_items(
+ panel._surface.target_at(
+ TrackerCursor(
+ CLICKED_ROW,
+ GeneratorName.NOISE,
+ SubColumn.VOLUME,
+ )
+ )
+ )
+ tracker_recorder.items[PASTE_ITEM].callback()
+
+ assert gestures.pasted == [TrackerCell(row=CLICKED_ROW, generator=GeneratorName.NOISE)]
+
+ def test_paste_awaits_a_copy(self, tracker_recorder: _MenuRecorder) -> None:
+ panel = _tracker_panel(Gestures(), can_paste=False)
+
+ panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1)))
+
+ assert tracker_recorder.items[PASTE_ITEM].enabled is False
+ assert [item.enabled for item in tracker_recorder.items] == [True, True, False, True]
+
+ def test_the_section_reads_as_the_four_clipboard_actions(
+ self,
+ tracker_recorder: _MenuRecorder,
+ ) -> None:
+ panel = _tracker_panel(Gestures())
+
+ panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1)))
+
+ assert [item.label for item in tracker_recorder.items] == ["Copy", "Cut", "Paste", "Delete"]
+
+
+class TestOrderTarget:
+ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None:
+ panel = _order_panel(Gestures())
+ panel._input_state = _selected_order_state()
+
+ target = panel._surface.target_at(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1))
+
+ assert target.region == panel._input_state.region
+
+ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> None:
+ panel = _order_panel(Gestures())
+ panel._input_state = _selected_order_state()
+
+ target = panel._surface.target_at(_order_cell(None))
+
+ assert target.region == OrderRegion(
+ first_row=CHANNEL_AXIS.index(None),
+ last_row=CHANNEL_AXIS.index(None),
+ first_position=CLICKED_POSITION,
+ last_position=CLICKED_POSITION,
+ )
+
+ def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None:
+ panel = _order_panel(Gestures())
+
+ target = panel._surface.target_at(_order_cell(GeneratorName.PULSE1))
+
+ assert target.region == OrderRegion(
+ first_row=PULSE1_ROW,
+ last_row=PULSE1_ROW,
+ first_position=CLICKED_POSITION,
+ last_position=CLICKED_POSITION,
+ )
+
+ def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None:
+ """The menu bar asks for the cursor's own target, which is the standing selection."""
+ panel = _order_panel(Gestures())
+ panel._input_state = _selected_order_state()
+
+ target = panel._surface.cursor_target()
+
+ assert target is not None
+ assert target.region == panel._input_state.region
+
+ def test_a_table_holding_no_cursor_names_no_target(self) -> None:
+ assert _order_panel(Gestures())._surface.cursor_target() is None
+
+ def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None:
+ panel = _order_panel(Gestures())
+ cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION)
+ panel._input_state = OrderInputState(cursor=cursor)
+
+ target = panel._surface.cursor_target()
+
+ assert target is not None
+ assert target.region == OrderRegion(
+ first_row=PULSE1_ROW,
+ last_row=PULSE1_ROW,
+ first_position=CLICKED_POSITION,
+ last_position=CLICKED_POSITION,
+ )
+ assert target.anchor == OrderCell(generator=GeneratorName.PULSE1, position=CLICKED_POSITION)
+
+
+class TestOrderMenuItems:
+ def test_the_items_hand_out_the_block_the_menu_was_raised_on(
+ self,
+ order_recorder: _MenuRecorder,
+ ) -> None:
+ gestures = Gestures()
+ panel = _order_panel(gestures)
+ panel._input_state = _selected_order_state()
+ selection = panel._input_state.region
+
+ panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1)))
+ for item in order_recorder.items:
+ item.callback()
+
+ assert gestures.copied == [selection]
+ assert gestures.cut == [selection]
+ assert gestures.deleted == [selection]
+
+ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder) -> None:
+ gestures = Gestures()
+ panel = _order_panel(gestures)
+
+ panel._surface.add_block_items(panel._surface.target_at(_order_cell(None)))
+ order_recorder.items[PASTE_ITEM].callback()
+
+ assert gestures.pasted == [OrderCell(generator=None, position=CLICKED_POSITION)]
+
+ def test_paste_awaits_a_copy(self, order_recorder: _MenuRecorder) -> None:
+ panel = _order_panel(Gestures(), can_paste=False)
+
+ panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1)))
+
+ assert order_recorder.items[PASTE_ITEM].enabled is False
+ assert [item.enabled for item in order_recorder.items] == [True, True, False, True]
+
+ def test_the_section_reads_as_the_four_clipboard_actions(
+ self,
+ order_recorder: _MenuRecorder,
+ ) -> None:
+ panel = _order_panel(Gestures())
+
+ panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1)))
+
+ assert [item.label for item in order_recorder.items] == ["Copy", "Cut", "Paste", "Delete"]
+
+
+class TestActionSet:
+ """One builder states each grid's actions, so every menu offering them prints the same set."""
+
+ def test_the_tracker_action_set_leads_with_the_shapes_a_selection_takes(
+ self,
+ tracker_recorder: _MenuRecorder,
+ ) -> None:
+ panel = _tracker_panel(Gestures())
+
+ panel.add_action_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1)))
+
+ labels = [item.label for item in tracker_recorder.items]
+ assert labels[:3] == ["select_all", "select_column", "select_subcolumn"]
+ assert labels[3:7] == ["Copy", "Cut", "Paste", "Delete"]
+ assert panel._lbl_context_clear_row in labels
+
+ def test_the_order_action_set_leads_with_the_shapes_a_selection_takes(
+ self,
+ order_recorder: _MenuRecorder,
+ ) -> None:
+ panel = _order_panel(Gestures())
+
+ panel.add_action_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1)))
+
+ labels = [item.label for item in order_recorder.items]
+ assert labels[:2] == ["select_all", "select_row"]
+ assert labels[2:6] == ["Copy", "Cut", "Paste", "Delete"]
+ assert panel._lbl_context_move_end in labels
+
+
+class TestMenuItemOrder:
+ """The four items keep the order the indices name, which is what the item tests read them by."""
+
+ def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _MenuRecorder) -> None:
+ panel = _tracker_panel(Gestures())
+
+ panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1)))
+
+ labels = [item.label for item in tracker_recorder.items]
+ assert labels[COPY_ITEM] == "Copy"
+ assert labels[CUT_ITEM] == "Cut"
+ assert labels[PASTE_ITEM] == "Paste"
+ assert labels[DELETE_ITEM] == "Delete"
+
+
+class TestSelectItems:
+ """The shapes each grid states, printed with their keys and firing what those keys fire."""
+
+ def test_the_tracker_items_print_the_keys_they_answer(self, tracker_recorder: _MenuRecorder) -> None:
+ panel = _tracker_panel(Gestures())
+
+ panel._add_select_items(_tracker_cell(GeneratorName.PULSE1))
+
+ assert [item.shortcut for item in tracker_recorder.items] == [
+ "Ctrl+A",
+ "Ctrl+Shift+A",
+ "Ctrl+Alt+A",
+ ]
+
+ def test_a_tracker_item_selects_the_column_the_menu_was_raised_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ tracker_recorder: _MenuRecorder,
+ ) -> None:
+ """A menu names the cell it was raised on, so the shape reaches that cell's own column."""
+ panel = _tracker_panel(Gestures())
+ states = _tracker_selections(monkeypatch, panel)
+
+ panel._add_select_items(_tracker_cell(GeneratorName.TRIANGLE))
+ tracker_recorder.items[SELECT_COLUMN_ITEM].callback()
+
+ region = states[-1].region
+ assert region is not None
+ assert region.columns == (GeneratorName.TRIANGLE,)
+ assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1)
+
+ def test_a_tracker_item_selects_the_whole_frame(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ tracker_recorder: _MenuRecorder,
+ ) -> None:
+ panel = _tracker_panel(Gestures())
+ states = _tracker_selections(monkeypatch, panel)
+
+ panel._add_select_items(_tracker_cell(GeneratorName.TRIANGLE))
+ tracker_recorder.items[SELECT_ALL_ITEM].callback()
+
+ region = states[-1].region
+ assert region is not None
+ assert region.slots == tuple(slot_from_flat(index) for index in range(SLOT_COUNT))
+
+ def test_the_order_items_print_the_keys_they_answer(self, order_recorder: _MenuRecorder) -> None:
+ panel = _order_panel(Gestures())
+
+ panel._add_select_items(_order_cell(GeneratorName.PULSE1))
+
+ assert [item.shortcut for item in order_recorder.items] == ["Ctrl+A", "Ctrl+Shift+A"]
+
+ def test_an_order_item_selects_the_row_the_menu_was_raised_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ order_recorder: _MenuRecorder,
+ ) -> None:
+ panel = _order_panel(Gestures())
+ states = _order_selections(monkeypatch, panel)
+
+ panel._add_select_items(_order_cell(None))
+ order_recorder.items[SELECT_ROW_ITEM].callback()
+
+ region = states[-1].region
+ assert region is not None
+ assert region.generators == (None,)
+ assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1)
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_browser_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_browser_context_menu.py
index f67e0828..87a03b7d 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_browser_context_menu.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_browser_context_menu.py
@@ -5,8 +5,8 @@
import pytest
from sampletones_application.ui.elements.tree import tree as tree_module
-from sampletones_application.ui.panels.sequencer import browser as browser_module
from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
+from sampletones_application.ui.panels.shared import browser as shared_browser_module
from sampletones_core.structures.tree.node import FileSystemNode, NodeType
from tests.suite.language import FakeLanguageManager
@@ -167,7 +167,7 @@ def test_replace_follows_the_add_item(self, monkeypatch: pytest.MonkeyPatch) ->
def _menu() -> Iterator[None]:
yield
- monkeypatch.setattr(browser_module, "context_menu", _menu)
+ monkeypatch.setattr(shared_browser_module, "context_menu", _menu)
panel._show_reconstruction_context_menu(_node(), "node-tag")
@@ -196,7 +196,7 @@ def test_directory_menu_offers_no_replacement(self, monkeypatch: pytest.MonkeyPa
def _menu() -> Iterator[None]:
yield
- monkeypatch.setattr(browser_module, "context_menu", _menu)
+ monkeypatch.setattr(shared_browser_module, "context_menu", _menu)
panel._show_directory_context_menu(_node(NodeType.DIRECTORY))
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py
index aa67653b..15503553 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py
@@ -65,7 +65,12 @@ class TestMenuBeforeTheFirstModel:
def test_a_channel_offers_to_mute_and_to_solo(self, menu: _MenuRecorder) -> None:
_switch().add_menu_items(GeneratorName.TRIANGLE, None)
- assert menu.labels == [LABELS.mute, LABELS.solo, LABELS.mute_all, LABELS.unmute_all]
+ assert menu.labels == [
+ LABELS.mute,
+ LABELS.solo,
+ LABELS.mute_all,
+ LABELS.unmute_all,
+ ]
def test_muting_everything_is_offered_and_restoring_is_withheld(self, menu: _MenuRecorder) -> None:
_switch().add_menu_items(None, None)
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_context_menu.py
deleted file mode 100644
index 484f9d84..00000000
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_context_menu.py
+++ /dev/null
@@ -1,124 +0,0 @@
-import contextlib
-from typing import Any, Iterator, List, Tuple
-
-import pytest
-
-from sampletones_application.ui.panels.sequencer import grid as grid_module
-from sampletones_application.ui.panels.sequencer.grid import (
- OCTAVE_SEMITONES,
- SEMITONE_STEP,
- VOLUME_COARSE_STEP,
- VOLUME_FINE_STEP,
- GUISequencerGridPanel,
-)
-from sampletones_application.view_model.sequencer.samples import (
- SampleEntryViewModel,
- SequencerSamplesViewModel,
-)
-from sampletones_core.constants.enums import GeneratorName
-
-SENDER_WIDGET_ID = 6099
-"""A stand-in for the menu-item widget id DearPyGui passes as the callback's first
-positional argument. The original bug let this id overwrite the step payload."""
-
-
-_CONTEXT_LABELS = (
- "_lbl_context_set_instrument",
- "_lbl_context_no_samples",
- "_lbl_context_transpose_up",
- "_lbl_context_transpose_down",
- "_lbl_context_transpose_octave_up",
- "_lbl_context_transpose_octave_down",
- "_lbl_context_volume_up",
- "_lbl_context_volume_down",
- "_lbl_context_volume_up_coarse",
- "_lbl_context_volume_down_coarse",
-)
-
-
-def _panel() -> GUISequencerGridPanel:
- """Builds a panel without its DearPyGui-dependent constructor.
-
- The menu-dispatch methods touch only their hook attributes, the context
- labels, and ``CallbackMixin.call``, so a fully wired GUI context is
- unnecessary here. Labels carry no behaviour, so any placeholder text serves.
- """
- panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel)
- for label in _CONTEXT_LABELS:
- setattr(panel, label, "")
- return panel
-
-
-class _MenuItemRecorder:
- """Captures the ``user_data``/``callback`` pairs the builders register."""
-
- def __init__(self) -> None:
- self.items: List[Tuple[Any, Any]] = []
-
- def add_menu_item(self, **kwargs: Any) -> int:
- if "callback" in kwargs and "user_data" in kwargs:
- self.items.append((kwargs["user_data"], kwargs["callback"]))
- return 0
-
- def dispatch_as_dpg(self) -> None:
- """Fires each recorded callback the way DearPyGui does: sender first."""
- for user_data, callback in self.items:
- callback(SENDER_WIDGET_ID, None, user_data)
-
-
-@pytest.fixture
-def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuItemRecorder:
- instance = _MenuItemRecorder()
- monkeypatch.setattr(grid_module.dpg, "add_menu_item", instance.add_menu_item)
-
- @contextlib.contextmanager
- def _menu(**kwargs: Any) -> Iterator[None]:
- yield
-
- monkeypatch.setattr(grid_module.dpg, "menu", _menu)
- return instance
-
-
-class TestMenuDispatchPreservesPayload:
- def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None:
- panel = _panel()
- deltas: List[int] = []
- panel.on_adjust_transpose = lambda row, generator, delta: deltas.append(delta)
-
- panel._add_transpose_items(2, GeneratorName.PULSE1)
- recorder.dispatch_as_dpg()
-
- assert deltas == [SEMITONE_STEP, -SEMITONE_STEP, OCTAVE_SEMITONES, -OCTAVE_SEMITONES]
-
- def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None:
- panel = _panel()
- deltas: List[int] = []
- panel.on_adjust_volume = lambda row, generator, delta: deltas.append(delta)
-
- panel._add_volume_items(2, GeneratorName.PULSE1)
- recorder.dispatch_as_dpg()
-
- assert deltas == [VOLUME_FINE_STEP, -VOLUME_FINE_STEP, VOLUME_COARSE_STEP, -VOLUME_COARSE_STEP]
-
- def test_adjust_carries_the_clicked_row_and_channel(self, recorder: _MenuItemRecorder) -> None:
- panel = _panel()
- calls: List[Tuple[int, GeneratorName, int]] = []
- panel.on_adjust_transpose = lambda row, generator, delta: calls.append((row, generator, delta))
-
- panel._add_transpose_items(7, GeneratorName.TRIANGLE)
- recorder.dispatch_as_dpg()
-
- assert calls[0] == (7, GeneratorName.TRIANGLE, SEMITONE_STEP)
-
- def test_instrument_items_pass_the_sample_id(self, recorder: _MenuItemRecorder) -> None:
- panel = _panel()
- panel._current_samples = SequencerSamplesViewModel(
- samples=(SampleEntryViewModel(sample_id="lead-id", name="lead", loop=False),),
- )
- chosen: List[str] = []
- panel.on_set_row = lambda row, generator, sample_id, transpose, volume: chosen.append(sample_id)
-
- panel._add_instrument_submenu(0, GeneratorName.PULSE2)
- recorder.dispatch_as_dpg()
-
- assert chosen == ["lead-id"]
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_navigation.py
deleted file mode 100644
index 9dd78279..00000000
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_navigation.py
+++ /dev/null
@@ -1,48 +0,0 @@
-from types import SimpleNamespace
-from typing import List
-
-import pytest
-
-from sampletones_application.ui.panels.sequencer.grid import GUISequencerGridPanel
-from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor
-from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState
-from sampletones_application.utils.gui.keyboard import KeyEvent
-from sampletones_application.utils.gui.keyboard.modifiers import NO_MODIFIERS
-from sampletones_application.utils.gui.shortcuts.keys import KEY_PAGE_DOWN, KEY_PAGE_UP
-from sampletones_application.view_model.sequencer.subcolumn import SubColumn
-
-PAGE_SIZE = 16
-
-
-def _panel() -> GUISequencerGridPanel:
- panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel)
- panel._input_state = TrackerInputState(cursor=TrackerCursor(5, None, SubColumn.INSTRUMENT), pending="")
- panel._layout = SimpleNamespace(tracker=SimpleNamespace(page_size=PAGE_SIZE))
- return panel
-
-
-class TestGridPageNavigation:
- """PageUp and PageDown jump the cursor a page of rows, matching the key codes DearPyGui delivers,
- and reveal the row they land on."""
-
- def test_page_up_moves_up_one_page_and_scrolls(self, monkeypatch: pytest.MonkeyPatch) -> None:
- panel = _panel()
- moves: List[int] = []
- scrolls: List[None] = []
- monkeypatch.setattr(panel, "_move_row", moves.append)
- monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: scrolls.append(None))
-
- assert panel._on_key_pressed(KeyEvent(key=KEY_PAGE_UP, modifiers=NO_MODIFIERS)) is True
- assert moves == [-PAGE_SIZE]
- assert scrolls == [None]
-
- def test_page_down_moves_down_one_page_and_scrolls(self, monkeypatch: pytest.MonkeyPatch) -> None:
- panel = _panel()
- moves: List[int] = []
- scrolls: List[None] = []
- monkeypatch.setattr(panel, "_move_row", moves.append)
- monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: scrolls.append(None))
-
- assert panel._on_key_pressed(KeyEvent(key=KEY_PAGE_DOWN, modifiers=NO_MODIFIERS)) is True
- assert moves == [PAGE_SIZE]
- assert scrolls == [None]
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_rows.py
deleted file mode 100644
index 3ef3795c..00000000
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_rows.py
+++ /dev/null
@@ -1,239 +0,0 @@
-from types import SimpleNamespace
-from typing import Dict, List, Optional, Sequence, Tuple
-
-import pytest
-
-from sampletones_application.ui.panels.sequencer import grid as grid_module
-from sampletones_application.ui.panels.sequencer.columns import (
- HEADER_TABLE_ROW,
- tracker_table_column,
- tracker_table_row,
-)
-from sampletones_application.ui.panels.sequencer.grid import GUISequencerGridPanel
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_shared.types.application import ColorRGBA
-
-PATTERN_ROWS = 4
-HEADER_AND_PATTERN_ROWS = PATTERN_ROWS + 1
-
-CURSOR_ROW: ColorRGBA = (255, 255, 255, 24)
-CELL_CURSOR: ColorRGBA = (102, 187, 255, 160)
-PATTERN_HIGHLIGHT: ColorRGBA = (255, 255, 255, 64)
-PLAYBACK_ROW: ColorRGBA = (100, 220, 100, 64)
-HEADER_SHADE: ColorRGBA = (70, 65, 92, 255)
-
-
-class _TableRecorder:
- """Captures the row and cell highlight calls, standing in for a live tracker table."""
-
- def __init__(self, *, row_children: Sequence[int]) -> None:
- self.row_children = list(row_children)
- self.highlighted_rows: Dict[int, ColorRGBA] = {}
- self.unhighlighted_rows: List[int] = []
- self.highlighted_cells: Dict[Tuple[int, int], ColorRGBA] = {}
- self.unhighlighted_cells: List[Tuple[int, int]] = []
-
- def does_item_exist(self, item: str) -> bool:
- return True
-
- def get_item_children(self, item: str, slot: int) -> List[int]:
- return self.row_children
-
- def highlight_table_row(self, table: str, row: int, color: ColorRGBA) -> None:
- self.highlighted_rows[row] = color
-
- def unhighlight_table_row(self, table: str, row: int) -> None:
- self.unhighlighted_rows.append(row)
-
- def highlight_table_cell(self, table: str, row: int, column: int, color: ColorRGBA) -> None:
- self.highlighted_cells[(row, column)] = color
-
- def unhighlight_table_cell(self, table: str, row: int, column: int) -> None:
- self.unhighlighted_cells.append((row, column))
-
-
-def _panel() -> GUISequencerGridPanel:
- """Builds a panel around the state the row highlights read, with no DearPyGui context."""
- panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel)
- panel._layout = SimpleNamespace(
- colors=SimpleNamespace(
- cursor_row=CURSOR_ROW,
- cell_cursor=CELL_CURSOR,
- pattern_highlight=PATTERN_HIGHLIGHT,
- playback_row=PLAYBACK_ROW,
- ),
- )
- panel._current_row_count = PATTERN_ROWS
- panel._highlighted_row = None
- panel._playing_row = None
- return panel
-
-
-@pytest.fixture
-def recorder(monkeypatch: pytest.MonkeyPatch) -> _TableRecorder:
- instance = _TableRecorder(row_children=range(HEADER_AND_PATTERN_ROWS))
- monkeypatch.setattr(grid_module.dpg, "does_item_exist", instance.does_item_exist)
- monkeypatch.setattr(grid_module.dpg, "get_item_children", instance.get_item_children)
- monkeypatch.setattr(grid_module.dpg, "highlight_table_row", instance.highlight_table_row)
- monkeypatch.setattr(grid_module.dpg, "unhighlight_table_row", instance.unhighlight_table_row)
- monkeypatch.setattr(grid_module.dpg, "highlight_table_cell", instance.highlight_table_cell)
- monkeypatch.setattr(grid_module.dpg, "unhighlight_table_cell", instance.unhighlight_table_cell)
- return instance
-
-
-class TestLiveRowCount:
- def test_the_count_covers_the_pattern_rows_alone(self, recorder: _TableRecorder) -> None:
- panel = _panel()
-
- assert panel._live_row_count() == PATTERN_ROWS
-
- def test_a_table_holding_only_the_header_reports_no_pattern_rows(self, recorder: _TableRecorder) -> None:
- panel = _panel()
- recorder.row_children = [0]
-
- assert panel._live_row_count() == 0
-
- def test_an_unbuilt_table_reports_no_pattern_rows(self, recorder: _TableRecorder) -> None:
- panel = _panel()
- recorder.row_children = []
-
- assert panel._live_row_count() == 0
-
-
-class TestCursorHighlight:
- @pytest.mark.parametrize("row_index", range(PATTERN_ROWS))
- def test_the_cursor_lands_on_the_mapped_table_row(
- self,
- recorder: _TableRecorder,
- row_index: int,
- ) -> None:
- panel = _panel()
-
- panel._apply_cell_highlight(row_index, GeneratorName.TRIANGLE)
-
- assert recorder.highlighted_rows == {tracker_table_row(row_index): CURSOR_ROW}
-
- def test_the_cursor_cell_lands_on_the_mapped_row_and_column(self, recorder: _TableRecorder) -> None:
- panel = _panel()
-
- panel._apply_cell_highlight(2, GeneratorName.NOISE)
-
- key = (tracker_table_row(2), tracker_table_column(GeneratorName.NOISE))
- assert recorder.highlighted_cells == {key: CELL_CURSOR}
-
- def test_no_cursor_ever_paints_the_header_row(self, recorder: _TableRecorder) -> None:
- panel = _panel()
-
- for row_index in range(PATTERN_ROWS):
- panel._apply_cell_highlight(row_index, None)
-
- assert HEADER_TABLE_ROW not in recorder.highlighted_rows
-
- def test_removing_the_cursor_clears_the_mapped_row_and_cell(self, recorder: _TableRecorder) -> None:
- panel = _panel()
-
- panel._remove_cell_highlight(1, None)
-
- assert recorder.unhighlighted_rows == [tracker_table_row(1)]
- assert recorder.unhighlighted_cells == [(tracker_table_row(1), tracker_table_column(None))]
-
-
-class TestHoverHighlight:
- def test_hover_lands_on_the_mapped_table_row(self, recorder: _TableRecorder) -> None:
- panel = _panel()
-
- panel.highlight_row(3)
-
- assert recorder.highlighted_rows == {tracker_table_row(3): PATTERN_HIGHLIGHT}
-
- def test_moving_the_hover_clears_the_row_it_left(self, recorder: _TableRecorder) -> None:
- panel = _panel()
-
- panel.highlight_row(0)
- panel.highlight_row(2)
-
- assert recorder.unhighlighted_rows == [tracker_table_row(0)]
-
- def test_dropping_the_hover_clears_the_mapped_row(self, recorder: _TableRecorder) -> None:
- panel = _panel()
- panel.highlight_row(2)
-
- panel.highlight_row(None)
-
- assert recorder.unhighlighted_rows == [tracker_table_row(2)]
-
-
-class TestPlayingRowHighlight:
- @pytest.mark.parametrize("row_index", range(PATTERN_ROWS))
- def test_the_playhead_lands_on_the_mapped_table_row(
- self,
- recorder: _TableRecorder,
- row_index: int,
- ) -> None:
- panel = _panel()
-
- panel.set_playing_row(row_index)
-
- assert recorder.highlighted_rows == {tracker_table_row(row_index): PLAYBACK_ROW}
-
- def test_the_last_pattern_row_is_still_within_the_table(self, recorder: _TableRecorder) -> None:
- panel = _panel()
-
- panel.set_playing_row(PATTERN_ROWS - 1)
-
- assert recorder.highlighted_rows
-
- def test_a_row_beyond_the_pattern_is_left_to_the_next_rebuild(self, recorder: _TableRecorder) -> None:
- panel = _panel()
-
- panel.set_playing_row(PATTERN_ROWS)
-
- assert not recorder.highlighted_rows
-
- def test_advancing_the_playhead_clears_the_row_it_left(self, recorder: _TableRecorder) -> None:
- panel = _panel()
-
- panel.set_playing_row(1)
- panel.set_playing_row(2)
-
- assert recorder.unhighlighted_rows == [tracker_table_row(1)]
-
- def test_stopping_clears_the_mapped_row(self, recorder: _TableRecorder) -> None:
- panel = _panel()
- panel.set_playing_row(2)
-
- panel.set_playing_row(None)
-
- assert recorder.unhighlighted_rows == [tracker_table_row(2)]
-
-
-class TestHeaderRowBackground:
- def test_every_table_column_of_the_header_takes_the_header_shade(
- self,
- recorder: _TableRecorder,
- ) -> None:
- panel = _panel()
- panel._layout = SimpleNamespace(colors=SimpleNamespace(header=SimpleNamespace(background=HEADER_SHADE)))
-
- panel._highlight_header_row()
-
- painted = {row for row, _ in recorder.highlighted_cells}
- columns = {column for _, column in recorder.highlighted_cells}
- assert painted == {HEADER_TABLE_ROW}
- assert columns == set(range(grid_module.TRACKER_TABLE_COLUMNS))
-
- def test_the_header_shade_covers_the_sample_and_channel_columns(
- self,
- recorder: _TableRecorder,
- ) -> None:
- """The washes are column highlights, which DearPyGui draws over a row highlight, so the
- header is painted per cell to read as one band."""
- panel = _panel()
- panel._layout = SimpleNamespace(colors=SimpleNamespace(header=SimpleNamespace(background=HEADER_SHADE)))
-
- panel._highlight_header_row()
-
- washed: List[Optional[GeneratorName]] = [None, *GeneratorName.items()]
- for generator in washed:
- key = (HEADER_TABLE_ROW, tracker_table_column(generator))
- assert recorder.highlighted_cells[key] == HEADER_SHADE
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_history_panel.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_history_panel.py
index 08b1d801..36938703 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_history_panel.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_history_panel.py
@@ -6,10 +6,16 @@
from sampletones_application.layout.config import LayoutConfig
from sampletones_application.layout.loader import load_layout_config
from sampletones_application.layout.tabs.sequencer import SequencerLayout
-from sampletones_application.paths import BEHAVIOR_DIRECTORY, LANG_EN, LAYOUT_DIRECTORY, PALETTE_PATH
+from sampletones_application.paths import (
+ BEHAVIOR_DIRECTORY,
+ LANG_EN,
+ LAYOUT_DIRECTORY,
+ PALETTES_DIRECTORY,
+)
from sampletones_application.ui.elements.panel import GUIPanel
from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel
-from sampletones_application.utils.palette import Palette
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.source import PaletteSource
from sampletones_application.view_model.sequencer.history import (
HistoryEntryViewModel,
HistoryViewModel,
@@ -18,7 +24,8 @@
@pytest.fixture
def layout_config() -> LayoutConfig:
- return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, Palette.load(PALETTE_PATH))
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
@pytest.fixture
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py
index 39def1fb..47dbb8ee 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_channels.py
@@ -5,14 +5,21 @@
import pytest
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.tabs.sequencer.colors import ChannelColors
+from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors
from sampletones_application.paths import LANG_EN
from sampletones_application.ui.elements.table.cells import EditableCells
from sampletones_application.ui.panels.sequencer import channels as channels_module
from sampletones_application.ui.panels.sequencer import order as order_module
from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
-from sampletones_application.utils.gui.keyboard.modifiers import CTRL, NO_MODIFIERS, ModifierSet
-from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel
+from sampletones_application.utils.gui.keyboard.modifiers import (
+ CTRL,
+ NO_MODIFIERS,
+ ModifierSet,
+)
+from sampletones_application.utils.palette.colors.written import LiteralColor
+from sampletones_application.view_model.sequencer.channels import (
+ SequencerChannelsViewModel,
+)
from sampletones_core.constants.enums import GeneratorName
from sampletones_shared.types.application import ColorRGBA, Sender
from sampletones_shared.types.callback import VoidCallback
@@ -29,10 +36,10 @@
MUTED_BACKGROUND: ColorRGBA = (10, 8, 18, 96)
CHANNEL_COLORS = ChannelColors(
- pulse1=(240, 146, 86, 255),
- pulse2=(242, 209, 95, 255),
- triangle=(140, 193, 237, 255),
- noise=(187, 184, 194, 255),
+ pulse1=LiteralColor((240, 146, 86, 255)),
+ pulse2=LiteralColor((242, 209, 95, 255)),
+ triangle=LiteralColor((140, 193, 237, 255)),
+ noise=LiteralColor((187, 184, 194, 255)),
)
LABEL_THEME = 1
@@ -136,7 +143,7 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerOrderPanel:
panel._layout = SimpleNamespace(
colors=SimpleNamespace(
channels=CHANNEL_COLORS,
- muted=SimpleNamespace(background=MUTED_BACKGROUND),
+ muted=SimpleNamespace(background=LiteralColor(MUTED_BACKGROUND)),
),
tracker=SimpleNamespace(
channel_column_tint=TINT_FRACTION,
@@ -269,7 +276,12 @@ def test_audible_channel_keeps_its_identity_tint(self, recorder: _DearPyGuiRecor
panel._apply_channel_cues()
- assert recorder.row_tints[CHANNEL_TABLE_ROWS[GeneratorName.PULSE1]] == (240, 146, 86, 128)
+ assert recorder.row_tints[CHANNEL_TABLE_ROWS[GeneratorName.PULSE1]] == (
+ 240,
+ 146,
+ 86,
+ 128,
+ )
def test_muted_channel_takes_the_neutral_wash(self, recorder: _DearPyGuiRecorder) -> None:
panel = _panel(frozenset({GeneratorName.PULSE1}))
@@ -438,7 +450,12 @@ def test_the_items_name_the_change_they_make(self, menu: _MenuRecorder) -> None:
_right_click(panel, GeneratorName.PULSE1)
- assert menu.labels == [LABEL_MUTE, LABEL_UNSOLO, LABEL_MUTE_ALL, LABEL_UNMUTE_ALL]
+ assert menu.labels == [
+ LABEL_MUTE,
+ LABEL_UNSOLO,
+ LABEL_MUTE_ALL,
+ LABEL_UNMUTE_ALL,
+ ]
def test_muting_everything_is_withheld_in_full_silence(self, menu: _MenuRecorder) -> None:
panel = _panel(frozenset(GeneratorName.items()))
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_input.py
deleted file mode 100644
index 314f0f6b..00000000
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_input.py
+++ /dev/null
@@ -1,65 +0,0 @@
-from typing import Optional
-
-from sampletones_application.ui.panels.sequencer.order_input import (
- ORDER_ROWS,
- OrderCursor,
- OrderInputState,
-)
-from sampletones_core.constants.enums import GeneratorName
-
-
-def _state(
- generator: Optional[GeneratorName] = GeneratorName.PULSE1,
- position: int = 0,
- pending: str = "",
-) -> OrderInputState:
- return OrderInputState(cursor=OrderCursor(generator, position), pending=pending)
-
-
-class TestNavigation:
- def test_position_clamps_within_bounds(self) -> None:
- state = _state(position=2)
- assert state.navigate_position(5, position_count=4).cursor.position == 3
- assert state.navigate_position(-5, position_count=4).cursor.position == 0
-
- def test_position_absolute_jump(self) -> None:
- assert _state(position=0).navigate_position(3, position_count=4, absolute=True).cursor.position == 3
-
- def test_position_is_a_no_op_without_positions(self) -> None:
- state = _state()
- assert state.navigate_position(1, position_count=0) is state
-
- def test_channel_cycles_master_then_channels_and_wraps(self) -> None:
- visited = []
- state = OrderInputState(cursor=OrderCursor(ORDER_ROWS[0], 0))
- for _ in range(len(ORDER_ROWS)):
- visited.append(state.cursor.generator)
- state = state.navigate_channel(1)
-
- assert visited == list(ORDER_ROWS)
- assert state.cursor.generator == ORDER_ROWS[0]
-
-
-class TestEntry:
- def test_type_char_commits_after_two_digits(self) -> None:
- partial, first = _state().type_char("A")
- assert first is None
- assert partial.pending == "A"
-
- committed, index = partial.type_char("F")
- assert index == 0xAF
- assert committed.pending == ""
-
- def test_commit_partial_pads_a_single_digit(self) -> None:
- committed, index = _state(pending="5").commit_partial()
- assert index == 5
- assert committed.pending == ""
-
- def test_commit_partial_is_a_no_op_without_pending(self) -> None:
- state = _state()
- committed, index = state.commit_partial()
- assert index is None
- assert committed is state
-
- def test_cancel_clears_pending(self) -> None:
- assert _state(pending="3").cancel().pending == ""
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py
new file mode 100644
index 00000000..204bfd08
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py
@@ -0,0 +1,150 @@
+from dataclasses import dataclass, field
+from typing import List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer.input.order import (
+ OrderCursor,
+ OrderInputState,
+)
+from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_core.constants.enums import GeneratorName
+from tests.suite.shortcuts import shipped_source
+
+POSITION_COUNT = 4
+CURSOR_POSITION = 1
+
+Move = Tuple[int, int]
+
+
+@dataclass
+class OrderPanelFixture:
+ """A panel carrying the state the key path reads, with the calls each action makes recorded."""
+
+ panel: GUISequencerOrderPanel
+ inserted: List[int] = field(default_factory=list)
+ duplicated: List[int] = field(default_factory=list)
+ cloned: List[int] = field(default_factory=list)
+ cleared: List[int] = field(default_factory=list)
+ removed: List[int] = field(default_factory=list)
+ moved: List[Move] = field(default_factory=list)
+ entries: List[Tuple[int, Optional[int]]] = field(default_factory=list)
+ states: List[OrderInputState] = field(default_factory=list)
+
+
+@pytest.fixture
+def order(monkeypatch: pytest.MonkeyPatch) -> OrderPanelFixture:
+ panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
+ panel._shortcuts = shipped_source()
+ panel._input_state = OrderInputState(cursor=OrderCursor(GeneratorName.PULSE1, CURSOR_POSITION))
+ panel._position_count = POSITION_COUNT
+ panel._current_position = CURSOR_POSITION
+ panel._buttons = None
+
+ fixture = OrderPanelFixture(panel=panel)
+ panel.on_insert_requested = fixture.inserted.append
+ panel.on_duplicate_requested = fixture.duplicated.append
+ panel.on_clone_requested = fixture.cloned.append
+ panel.on_clear_requested = fixture.cleared.append
+ panel.on_remove_requested = fixture.removed.append
+ panel.on_move_requested = lambda position, target: fixture.moved.append((position, target))
+ panel.on_set_order_entry = lambda _generator, position, index: fixture.entries.append((position, index))
+ monkeypatch.setattr(panel, "_apply_state", fixture.states.append)
+ return fixture
+
+
+def _press(text: str) -> KeyEvent:
+ """The press a written combination names, as the router delivers it."""
+ combination = KeyCombination.parse(text)
+ return KeyEvent(key=combination.key, modifiers=combination.modifiers)
+
+
+class TestFrameActions:
+ def test_the_duplicate_key_duplicates_the_cursor_frame(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("Ctrl+Ins")) is True
+ assert order.duplicated == [CURSOR_POSITION]
+ assert order.cloned == []
+
+ def test_the_clone_key_clones_the_cursor_frame(self, order: OrderPanelFixture) -> None:
+ """Shift separates the two copies: the plain key repeats, the shifted one clones."""
+ assert order.panel._on_key_pressed(_press("Ctrl+Shift+Ins")) is True
+ assert order.cloned == [CURSOR_POSITION]
+ assert order.duplicated == []
+
+ def test_the_display_settings_key_reaches_the_application(self, order: OrderPanelFixture) -> None:
+ """Ctrl+D belongs to the display settings now, so the table hands it to the shortcut scope."""
+ assert order.panel._on_key_pressed(_press("Ctrl+D")) is False
+ assert order.duplicated == []
+
+ def test_the_insert_key_inserts_at_the_cursor(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("+")) is True
+ assert order.inserted == [CURSOR_POSITION]
+
+ def test_the_numeric_keypad_alias_inserts_the_same_way(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("Num+")) is True
+ assert order.inserted == [CURSOR_POSITION]
+
+ def test_the_remove_key_removes_the_cursor_frame(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("-")) is True
+ assert order.removed == [CURSOR_POSITION]
+
+ def test_the_clear_frame_key_clears_the_whole_frame(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("Shift+Del")) is True
+ assert order.cleared == [CURSOR_POSITION]
+
+ def test_the_add_key_inserts_after_the_cursor(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("Ins")) is True
+ assert order.inserted == [CURSOR_POSITION]
+
+
+class TestFrameMoves:
+ def test_the_move_left_key_moves_the_frame_one_position_back(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("Alt+Left")) is True
+ assert order.moved == [(CURSOR_POSITION, CURSOR_POSITION - 1)]
+
+ def test_the_move_to_end_key_moves_the_frame_last(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("Alt+End")) is True
+ assert order.moved == [(CURSOR_POSITION, POSITION_COUNT - 1)]
+
+ def test_a_move_with_nowhere_to_go_still_consumes_the_key(self, order: OrderPanelFixture) -> None:
+ """A boundary keeps the press, so a repeated move stays out of the global shortcuts."""
+ order.panel._input_state = OrderInputState(cursor=OrderCursor(GeneratorName.PULSE1, 0))
+
+ assert order.panel._on_key_pressed(_press("Alt+Left")) is True
+ assert order.moved == []
+
+
+class TestCursorMoves:
+ def test_the_next_position_key_moves_the_cursor_one_column_on(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("Right")) is True
+ assert order.states[-1].cursor == OrderCursor(GeneratorName.PULSE1, CURSOR_POSITION + 1)
+
+ def test_the_enter_alias_moves_the_cursor_the_same_way(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("Enter")) is True
+ assert order.states[-1].cursor == OrderCursor(GeneratorName.PULSE1, CURSOR_POSITION + 1)
+
+ def test_the_last_position_key_jumps_to_the_final_column(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("End")) is True
+ assert order.states[-1].cursor == OrderCursor(GeneratorName.PULSE1, POSITION_COUNT - 1)
+
+
+class TestCellEntry:
+ def test_a_hex_key_types_into_the_cell_under_the_cursor(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("A")) is True
+ assert order.states[-1].pending == "A"
+
+ def test_a_modified_hex_key_reaches_the_application(self, order: OrderPanelFixture) -> None:
+ """Ctrl+D opens the display settings, so cell entry keeps the plain key alone."""
+ assert order.panel._on_key_pressed(_press("Ctrl+D")) is False
+ assert order.states == []
+
+ def test_the_clear_cell_key_empties_the_cell_and_moves_on(self, order: OrderPanelFixture) -> None:
+ assert order.panel._on_key_pressed(_press("Del")) is True
+ assert order.entries == [(CURSOR_POSITION, None)]
+
+ def test_a_press_without_a_cursor_reaches_the_application(self, order: OrderPanelFixture) -> None:
+ order.panel._input_state = OrderInputState(cursor=None)
+
+ assert order.panel._on_key_pressed(_press("Right")) is False
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py
index b35ed2c7..8308e931 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py
@@ -1,8 +1,11 @@
from dataclasses import dataclass, field
from typing import List, Optional
+from sampletones_application.ui.panels.sequencer.input.order import (
+ OrderCursor,
+ OrderInputState,
+)
from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
-from sampletones_application.ui.panels.sequencer.order_input import OrderCursor, OrderInputState
from sampletones_core.constants.enums import GeneratorName
POSITION_COUNT = 4
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py
index ad600693..0557c7b7 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py
@@ -3,31 +3,36 @@
import dearpygui.dearpygui as dpg
import pytest
-from sampletones_application.ui.panels.sequencer.grid import GUISequencerGridPanel
-from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor
-from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState
+from sampletones_application.ui.panels.sequencer.input.order import (
+ OrderCursor,
+ OrderInputState,
+)
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
-from sampletones_application.ui.panels.sequencer.order_input import OrderCursor, OrderInputState
+from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard import KeyEvent
from sampletones_application.utils.gui.keyboard.modifiers import NO_MODIFIERS
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from tests.suite.shortcuts import shipped_source
def _escape() -> KeyEvent:
return KeyEvent(key=dpg.mvKey_Escape, modifiers=NO_MODIFIERS)
-class TestGridEscapeYieldsToGlobalStop:
- """With no partial cell edit to cancel, the grid lets Escape fall through to global Stop."""
+class TestTrackerEscapeYieldsToGlobalStop:
+ """With no partial cell edit to cancel, the tracker lets Escape fall through to global Stop."""
def test_escape_yields_when_no_pending_edit(self) -> None:
- panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel)
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._shortcuts = shipped_source()
panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.INSTRUMENT), pending="")
assert panel._on_key_pressed(_escape()) is False
def test_escape_cancels_a_pending_edit_and_consumes(self, monkeypatch: pytest.MonkeyPatch) -> None:
- panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel)
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._shortcuts = shipped_source()
panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.INSTRUMENT), pending="3")
applied: List[TrackerInputState] = []
monkeypatch.setattr(panel, "_apply_state", applied.append)
@@ -35,21 +40,53 @@ def test_escape_cancels_a_pending_edit_and_consumes(self, monkeypatch: pytest.Mo
assert panel._on_key_pressed(_escape()) is True
assert applied and applied[0].pending == ""
+ def test_escape_drops_a_selection_and_consumes(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A selection is state the grid holds, so Escape takes it down before it reaches Stop."""
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._shortcuts = shipped_source()
+ panel._current_row_count = 64
+ panel._input_state = TrackerInputState(
+ cursor=TrackerCursor(4, None, SubColumn.INSTRUMENT),
+ anchor=TrackerCursor(2, None, SubColumn.INSTRUMENT),
+ )
+ applied: List[TrackerInputState] = []
+ monkeypatch.setattr(panel, "_apply_state", applied.append)
+
+ assert panel._on_key_pressed(_escape()) is True
+ assert applied and applied[0].region is None
+
class TestOrderEscapeYieldsToGlobalStop:
"""With no partial cell edit to cancel, the order table lets Escape fall through to global Stop."""
def test_escape_yields_when_no_pending_edit(self) -> None:
panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
+ panel._shortcuts = shipped_source()
panel._input_state = OrderInputState(cursor=OrderCursor(None, 0), pending="")
assert panel._on_key_pressed(_escape()) is False
def test_escape_cancels_a_pending_edit_and_consumes(self, monkeypatch: pytest.MonkeyPatch) -> None:
panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
+ panel._shortcuts = shipped_source()
panel._input_state = OrderInputState(cursor=OrderCursor(None, 0), pending="3")
applied: List[OrderInputState] = []
monkeypatch.setattr(panel, "_apply_state", applied.append)
assert panel._on_key_pressed(_escape()) is True
assert applied and applied[0].pending == ""
+
+ def test_escape_drops_a_selection_and_consumes(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A selection is state the table holds, so Escape takes it down before it reaches Stop."""
+ panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
+ panel._shortcuts = shipped_source()
+ panel._position_count = 8
+ panel._input_state = OrderInputState(
+ cursor=OrderCursor(None, 3),
+ anchor=OrderCursor(None, 1),
+ )
+ applied: List[OrderInputState] = []
+ monkeypatch.setattr(panel, "_apply_state", applied.append)
+
+ assert panel._on_key_pressed(_escape()) is True
+ assert applied and applied[0].region is None
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py
new file mode 100644
index 00000000..08b1610a
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py
@@ -0,0 +1,105 @@
+from dataclasses import dataclass
+from typing import Callable, Union
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer.input.order import (
+ OrderCursor,
+ OrderInputState,
+)
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel
+from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.utils.gui.keyboard import ActivePredicate, KeyRouter, focus
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+SequencerPanel = Union[
+ GUISequencerTrackerPanel,
+ GUISequencerOrderPanel,
+ GUISequencerSamplesPanel,
+]
+
+SELECTED_ID = "bass-id"
+
+
+@pytest.fixture(autouse=True)
+def no_focused_field(monkeypatch: pytest.MonkeyPatch) -> None:
+ """No text field is being edited, so the tab is the only thing holding a key back."""
+ monkeypatch.setattr(focus, "is_field_focused", lambda: False)
+
+
+def _tracker(tab_active: ActivePredicate) -> GUISequencerTrackerPanel:
+ """A tracker grid holding a cursor, which is what it keeps across a move to another tab."""
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._router = KeyRouter()
+ panel._tab_active = tab_active
+ panel._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.INSTRUMENT))
+ return panel
+
+
+def _order(tab_active: ActivePredicate) -> GUISequencerOrderPanel:
+ """An order table holding a cursor, which is what it keeps across a move to another tab."""
+ panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
+ panel._router = KeyRouter()
+ panel._tab_active = tab_active
+ panel._input_state = OrderInputState(cursor=OrderCursor(None, 0))
+ return panel
+
+
+def _samples(tab_active: ActivePredicate) -> GUISequencerSamplesPanel:
+ """A samples panel holding a selection, which is what it keeps across a move to another tab."""
+ panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel)
+ panel._router = KeyRouter()
+ panel._tab_active = tab_active
+ panel._selected_sample_id = SELECTED_ID
+ panel._editing_sample_id = None
+ return panel
+
+
+def _renaming_samples(tab_active: ActivePredicate) -> GUISequencerSamplesPanel:
+ """A samples panel mid-rename, the one state that keeps the keyboard on its own tab."""
+ panel = _samples(tab_active)
+ panel._editing_sample_id = SELECTED_ID
+ return panel
+
+
+class TestPanelKeysFollowTheTabInFront(BaseTestSuite):
+ """A sequencer panel answers the keyboard while the Sequencer is the tab in front.
+
+ Each panel keeps its cursor or selection while another tab is worked on, so the tab is what
+ tells a press meant for the song from one meant for whatever stands in front of it.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ build: Callable[[ActivePredicate], SequencerPanel]
+
+ test_cases = (
+ TestCase(label="the tracker grid holds a cursor", build=_tracker),
+ TestCase(label="the order table holds a cursor", build=_order),
+ TestCase(label="the samples panel holds a selection", build=_samples),
+ TestCase(label="the samples panel is mid-rename", build=_renaming_samples),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_a_panel_stands_down_while_another_tab_is_in_front(self, test_case: TestCase) -> None:
+ panel = test_case.build(lambda: False)
+
+ assert panel._keys_active() is False
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_a_panel_answers_while_its_own_tab_is_in_front(self, test_case: TestCase) -> None:
+ panel = test_case.build(lambda: True)
+
+ assert panel._keys_active() is True
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py
new file mode 100644
index 00000000..47cb8e2b
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py
@@ -0,0 +1,205 @@
+import pytest
+
+from sampletones_application.layout.loader import load_layout_config
+from sampletones_application.layout.tabs.sequencer import SequencerLayout
+from sampletones_application.layout.tabs.sequencer.colors.colors import SequencerColors
+from sampletones_application.paths import (
+ BEHAVIOR_DIRECTORY,
+ LAYOUT_DIRECTORY,
+ PALETTES_DIRECTORY,
+)
+from sampletones_application.ui.panels.sequencer.rows import (
+ RowCues,
+ group_color,
+ row_background,
+)
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.colors.layered import LayeredColor
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_application.view_model.sequencer.settings import (
+ SequencerSettingsViewModel,
+)
+
+NO_CUES = RowCues(cursor=None, playing=None)
+
+PATTERN_ROWS = 64
+BEAT_ROWS = 4
+BAR_ROWS = 16
+
+
+def _settings(
+ *,
+ first_highlight: int = BEAT_ROWS,
+ second_highlight: int = BAR_ROWS,
+) -> SequencerSettingsViewModel:
+ """The module settings the row tinting reads, carrying the metre under test."""
+ return SequencerSettingsViewModel(
+ nes_frequency=60,
+ tempo=150,
+ speed=6,
+ rows_per_pattern=PATTERN_ROWS,
+ first_highlight=first_highlight,
+ second_highlight=second_highlight,
+ )
+
+
+@pytest.fixture
+def sequencer_layout() -> SequencerLayout:
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source).tabs.sequencer
+
+
+@pytest.fixture
+def settings() -> SequencerSettingsViewModel:
+ return _settings()
+
+
+@pytest.fixture
+def colors(sequencer_layout: SequencerLayout) -> SequencerColors:
+ return sequencer_layout.colors
+
+
+class TestGrouping:
+ def test_the_row_opening_a_bar_takes_the_bar_shade(
+ self,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+ ) -> None:
+ assert group_color(0, settings, colors) == colors.rows.bar
+ assert group_color(settings.second_highlight, settings, colors) == colors.rows.bar
+
+ def test_the_row_opening_a_beat_takes_the_beat_shade(
+ self,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+ ) -> None:
+ beats = (
+ settings.first_highlight,
+ 2 * settings.first_highlight,
+ settings.second_highlight + settings.first_highlight,
+ )
+
+ for row_index in beats:
+ assert group_color(row_index, settings, colors) == colors.rows.beat
+
+ def test_a_row_inside_a_beat_keeps_its_stripe(
+ self,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+ ) -> None:
+ for row_index in range(settings.rows_per_pattern):
+ if row_index % settings.first_highlight != 0:
+ assert group_color(row_index, settings, colors) is None
+
+ def test_the_bar_shade_outranks_the_beat_shade_where_they_meet(
+ self,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+ ) -> None:
+ """Every bar boundary opens a beat as well, and the row reads as the start of the bar."""
+ assert settings.second_highlight % settings.first_highlight == 0
+ assert group_color(settings.second_highlight, settings, colors) == colors.rows.bar
+
+ def test_a_metre_the_project_states_moves_the_shades(
+ self,
+ colors: SequencerColors,
+ ) -> None:
+ """Three beats of four rows: the bar closes after twelve, where common time runs on to sixteen."""
+ settings = _settings(first_highlight=4, second_highlight=12)
+
+ assert group_color(12, settings, colors) == colors.rows.bar
+ assert group_color(4, settings, colors) == colors.rows.beat
+ assert group_color(8, settings, colors) == colors.rows.beat
+ assert group_color(16, settings, colors) == colors.rows.beat
+ assert group_color(3, settings, colors) is None
+
+ def test_a_bar_shorter_than_a_beat_marks_every_bar_row(
+ self,
+ colors: SequencerColors,
+ ) -> None:
+ """The bar shade wins wherever the two groupings meet, so the shorter span is what shows."""
+ settings = _settings(first_highlight=8, second_highlight=2)
+
+ assert group_color(2, settings, colors) == colors.rows.bar
+ assert group_color(8, settings, colors) == colors.rows.bar
+ assert group_color(1, settings, colors) is None
+
+ def test_a_highlight_of_one_marks_every_row(
+ self,
+ colors: SequencerColors,
+ ) -> None:
+ settings = _settings(first_highlight=1, second_highlight=1)
+
+ for row_index in range(settings.rows_per_pattern):
+ assert group_color(row_index, settings, colors) == colors.rows.bar
+
+
+class TestCues:
+ def test_the_playhead_outranks_the_cursor(
+ self,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+ ) -> None:
+ cues = RowCues(cursor=5, playing=5)
+
+ assert row_background(5, settings, colors, cues) == colors.playback_row
+
+ def test_the_cursor_marks_the_row_it_rests_on(
+ self,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+ ) -> None:
+ cues = RowCues(cursor=5, playing=9)
+
+ assert row_background(5, settings, colors, cues) == colors.cursor_row
+
+ def test_a_row_no_mark_stands_on_keeps_its_stripe(
+ self,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+ ) -> None:
+ cues = RowCues(cursor=5, playing=9)
+
+ assert row_background(6, settings, colors, cues) is None
+
+
+class TestComposition:
+ def test_a_marked_group_row_carries_the_cue_over_the_group_shade(
+ self,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+ ) -> None:
+ row_index = settings.first_highlight
+ cues = RowCues(cursor=row_index, playing=None)
+
+ assert row_background(row_index, settings, colors, cues) == LayeredColor(
+ base=colors.rows.beat,
+ overlay=colors.cursor_row,
+ )
+
+ def test_the_composed_shade_covers_more_than_either_alone(
+ self,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+ ) -> None:
+ row_index = settings.second_highlight
+ cues = RowCues(cursor=None, playing=row_index)
+ composed = row_background(row_index, settings, colors, cues)
+
+ assert composed is not None
+ assert composed.rgba[3] > max(colors.rows.bar.rgba[3], colors.playback_row.rgba[3])
+
+ def test_an_unmarked_group_row_carries_the_group_shade_alone(
+ self,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+ ) -> None:
+ assert row_background(0, settings, colors, NO_CUES) == colors.rows.bar
+ assert row_background(settings.first_highlight, settings, colors, NO_CUES) == colors.rows.beat
+
+ def test_a_plain_unmarked_row_leaves_the_layer_free(
+ self,
+ settings: SequencerSettingsViewModel,
+ colors: SequencerColors,
+ ) -> None:
+ assert row_background(1, settings, colors, NO_CUES) is None
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py
new file mode 100644
index 00000000..2c24e0c1
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py
@@ -0,0 +1,105 @@
+from dataclasses import dataclass, field
+from typing import List, Tuple
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel
+from tests.suite.shortcuts import shipped_source
+
+ENTRIES: Tuple[SampleEntryViewModel, ...] = (
+ SampleEntryViewModel(sample_id="kick-id", name="Kick", loop=False),
+ SampleEntryViewModel(sample_id="bass-id", name="Bass", loop=True),
+ SampleEntryViewModel(sample_id="lead-id", name="Lead", loop=False),
+)
+
+SELECTED_ID = "bass-id"
+SELECTED_ROW = 1
+
+Move = Tuple[str, int]
+
+
+@dataclass
+class SamplesPanelFixture:
+ """A panel carrying the state the key path reads, with the calls each action makes recorded."""
+
+ panel: GUISequencerSamplesPanel
+ removed: List[str] = field(default_factory=list)
+ moved: List[Move] = field(default_factory=list)
+ renamed: List[str] = field(default_factory=list)
+ cancelled: List[None] = field(default_factory=list)
+
+
+@pytest.fixture
+def samples(monkeypatch: pytest.MonkeyPatch) -> SamplesPanelFixture:
+ panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel)
+ panel._shortcuts = shipped_source()
+ panel._entries = ENTRIES
+ panel._selected_sample_id = SELECTED_ID
+ panel._selected_row = SELECTED_ROW
+ panel._editing_sample_id = None
+
+ fixture = SamplesPanelFixture(panel=panel)
+ panel.on_remove_requested = fixture.removed.append
+ panel.on_move_requested = lambda sample_id, target: fixture.moved.append((sample_id, target))
+ monkeypatch.setattr(panel, "_start_rename", fixture.renamed.append)
+ monkeypatch.setattr(panel, "_cancel_rename", lambda: fixture.cancelled.append(None))
+ return fixture
+
+
+def _press(text: str) -> KeyEvent:
+ """The press a written combination names, as the router delivers it."""
+ combination = KeyCombination.parse(text)
+ return KeyEvent(key=combination.key, modifiers=combination.modifiers)
+
+
+class TestSelectedSampleActions:
+ def test_the_remove_key_removes_the_selected_sample(self, samples: SamplesPanelFixture) -> None:
+ assert samples.panel._on_key_pressed(_press("Del")) is True
+ assert samples.removed == [SELECTED_ID]
+
+ def test_the_rename_key_starts_the_rename(self, samples: SamplesPanelFixture) -> None:
+ assert samples.panel._on_key_pressed(_press("F2")) is True
+ assert samples.renamed == [SELECTED_ID]
+
+ def test_a_press_the_panel_leaves_unnamed_reaches_the_application(self, samples: SamplesPanelFixture) -> None:
+ assert samples.panel._on_key_pressed(_press("Ctrl+S")) is False
+ assert samples.removed == []
+
+ def test_a_press_without_a_selection_reaches_the_application(self, samples: SamplesPanelFixture) -> None:
+ samples.panel._selected_sample_id = None
+
+ assert samples.panel._on_key_pressed(_press("Del")) is False
+
+
+class TestSampleMoves:
+ def test_the_move_up_key_moves_the_sample_one_row_back(self, samples: SamplesPanelFixture) -> None:
+ assert samples.panel._on_key_pressed(_press("Alt+Up")) is True
+ assert samples.moved == [(SELECTED_ID, SELECTED_ROW - 1)]
+
+ def test_the_move_to_bottom_key_moves_the_sample_last(self, samples: SamplesPanelFixture) -> None:
+ assert samples.panel._on_key_pressed(_press("Alt+End")) is True
+ assert samples.moved == [(SELECTED_ID, len(ENTRIES) - 1)]
+
+ def test_a_move_with_nowhere_to_go_still_consumes_the_key(self, samples: SamplesPanelFixture) -> None:
+ samples.panel._selected_row = 0
+
+ assert samples.panel._on_key_pressed(_press("Alt+Up")) is True
+ assert samples.moved == []
+
+
+class TestRenameInProgress:
+ def test_the_cancel_key_drops_the_name_being_edited(self, samples: SamplesPanelFixture) -> None:
+ samples.panel._editing_sample_id = SELECTED_ID
+
+ assert samples.panel._on_key_pressed(_press("Esc")) is True
+ assert samples.cancelled == [None]
+
+ def test_every_other_key_stays_with_the_field(self, samples: SamplesPanelFixture) -> None:
+ """A rename keeps the keyboard, so typing a name reaches the input rather than the list."""
+ samples.panel._editing_sample_id = SELECTED_ID
+
+ assert samples.panel._on_key_pressed(_press("Del")) is False
+ assert samples.removed == []
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py
new file mode 100644
index 00000000..9a850400
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py
@@ -0,0 +1,390 @@
+import contextlib
+from dataclasses import dataclass, field
+from typing import Any, Callable, Iterator, List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.categories.elements.global_ import ContextElements
+from sampletones_application.categories.elements.sequencer import SequencerInstrumentsElements
+from sampletones_application.ui.elements import context_menu as context_menu_module
+from sampletones_application.ui.elements.fonts.registry import FontRegistry
+from sampletones_application.ui.panels.sequencer import samples as samples_module
+from sampletones_application.ui.panels.sequencer.samples import SAMPLE_MOVES, GUISequencerSamplesPanel
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_application.utils.palette.colors.literal import LiteralColor
+from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel
+from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.formats.famitracker.footprint import InstrumentFootprint
+from sampletones_core.utils.display import display_sample_label
+from tests.suite.shortcuts import shipped_source
+
+ENTRIES: Tuple[SampleEntryViewModel, ...] = (
+ SampleEntryViewModel(sample_id="kick-id", name="Kick", loop=False),
+ SampleEntryViewModel(sample_id="bass-id", name="Bass", loop=True),
+ SampleEntryViewModel(sample_id="lead-id", name="Lead", loop=False),
+)
+
+SELECTED_ID = "bass-id"
+SELECTED_ROW = 1
+
+SAMPLE_SIZE_LABEL = "Sample size"
+SIZE_TEMPLATE = "{bytes} B"
+SIZE_TOOLTIP = "Bytes a FamiTracker export spends."
+DETAIL_COLOR = LiteralColor((0, 0, 0, 255))
+
+PULSE_1_FOOTPRINT = InstrumentFootprint(instrument_bytes=9, sequence_bytes=32)
+NOISE_FOOTPRINT = InstrumentFootprint(instrument_bytes=7, sequence_bytes=12)
+PULSE_1_BYTES = PULSE_1_FOOTPRINT.total_bytes
+NOISE_BYTES = NOISE_FOOTPRINT.total_bytes
+FOOTPRINT = SampleFootprintViewModel.from_footprints(
+ {
+ GeneratorName.PULSE1: PULSE_1_FOOTPRINT,
+ GeneratorName.NOISE: NOISE_FOOTPRINT,
+ }
+)
+
+EDIT_ITEM = 0
+RENAME_ITEM = 1
+DUPLICATE_ITEM = 2
+REMOVE_ITEM = 3
+MOVE_UP_ITEM = 4
+MOVE_DOWN_ITEM = 5
+MOVE_TOP_ITEM = 6
+MOVE_BOTTOM_ITEM = 7
+
+
+@dataclass
+class MenuItem:
+ """One item as it was registered, which is the whole of what a reader sees and clicks."""
+
+ label: str
+ shortcut: str
+ enabled: bool
+ callback: Callable[[], None]
+
+
+@dataclass
+class Requests:
+ """What each sample hook was handed when its menu item fired."""
+
+ edited: List[str] = field(default_factory=list)
+ renamed: List[str] = field(default_factory=list)
+ duplicated: List[str] = field(default_factory=list)
+ removed: List[str] = field(default_factory=list)
+ moved: List[Tuple[str, Optional[int]]] = field(default_factory=list)
+
+
+class _MenuRecorder:
+ def __init__(self) -> None:
+ self.items: List[MenuItem] = []
+
+ def add_menu_item(self, **kwargs: Any) -> int:
+ self.items.append(
+ MenuItem(
+ label=kwargs["label"],
+ shortcut=kwargs.get("shortcut", ""),
+ enabled=kwargs.get("enabled", True),
+ callback=kwargs["callback"],
+ )
+ )
+ return 0
+
+
+@pytest.fixture
+def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder:
+ recorded = _MenuRecorder()
+ monkeypatch.setattr(samples_module.dpg, "add_menu_item", recorded.add_menu_item)
+ monkeypatch.setattr(samples_module.dpg, "add_separator", lambda **_kwargs: 0)
+ return recorded
+
+
+@dataclass
+class SamplesPanelFixture:
+ """A panel holding a selection, with the calls each menu item makes recorded."""
+
+ panel: GUISequencerSamplesPanel
+ requests: Requests
+
+
+def _panel(
+ monkeypatch: pytest.MonkeyPatch,
+ *,
+ selected_row: Optional[int] = SELECTED_ROW,
+ tab_active: bool = True,
+ editing: Optional[str] = None,
+ field_focused: bool = False,
+ footprint: Optional[SampleFootprintViewModel] = FOOTPRINT,
+ footprint_wired: bool = True,
+) -> SamplesPanelFixture:
+ """A samples panel whose menu builder can run with no DearPyGui context behind it."""
+ panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel)
+ panel._language_manager = _Labels()
+ panel._shortcuts = shipped_source()
+ panel._entries = ENTRIES
+ panel._selected_sample_id = None if selected_row is None else SELECTED_ID
+ panel._selected_row = selected_row
+ panel._editing_sample_id = editing
+ panel._tab_active = lambda: tab_active
+ panel._router = _Router(field_focused=field_focused)
+ panel._detail_color = DETAIL_COLOR
+ panel._lbl_sample_size = SAMPLE_SIZE_LABEL
+ panel._tpl_size_bytes = SIZE_TEMPLATE
+ panel._tip_size_bytes = SIZE_TOOLTIP
+ panel.sample_footprint = (lambda _sample_id: footprint) if footprint_wired else None
+
+ requests = Requests()
+ panel.on_sample_edit_requested = requests.edited.append
+ panel.on_duplicate_requested = requests.duplicated.append
+ panel.on_remove_requested = requests.removed.append
+ panel.on_move_requested = lambda sample_id, target: requests.moved.append((sample_id, target))
+ monkeypatch.setattr(panel, "_start_rename", requests.renamed.append)
+ return SamplesPanelFixture(panel=panel, requests=requests)
+
+
+class _Labels:
+ """A language manager printing each key's own element, so an item reads as the action it names."""
+
+ def __getitem__(self, key: Tuple[Any, ...]) -> str:
+ return str(key[-1].value)
+
+
+@dataclass(frozen=True)
+class MenuWidget:
+ """One widget as the menu registered it, which is the whole of what a reader meets."""
+
+ kind: str
+ text: str
+
+
+class _MenuBuildRecorder:
+ """Every widget a whole menu build registers, in the order they are printed."""
+
+ def __init__(self) -> None:
+ self.widgets: List[MenuWidget] = []
+ self.tooltips: List[str] = []
+
+ def add_text(self, text: str, **_kwargs: Any) -> int:
+ self.widgets.append(MenuWidget(kind="text", text=text))
+ return 0
+
+ def add_tooltip(self, _parent: int, message: str, **_kwargs: Any) -> int:
+ self.tooltips.append(message)
+ return 0
+
+ def add_separator(self, **_kwargs: Any) -> int:
+ self.widgets.append(MenuWidget(kind="separator", text=""))
+ return 0
+
+ def add_menu_item(self, **kwargs: Any) -> int:
+ self.widgets.append(MenuWidget(kind="item", text=kwargs["label"]))
+ return 0
+
+ def texts_before_the_first_item(self) -> List[str]:
+ widgets: List[str] = []
+ for widget in self.widgets:
+ if widget.kind == "item":
+ break
+ if widget.kind == "text":
+ widgets.append(widget.text)
+
+ return widgets
+
+
+@contextlib.contextmanager
+def _null_menu() -> Iterator[None]:
+ yield
+
+
+@pytest.fixture
+def build_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuBuildRecorder:
+ """Records a whole context-menu build, with the DearPyGui calls behind it stood down."""
+ recorded = _MenuBuildRecorder()
+ monkeypatch.setattr(samples_module.dpg, "add_text", recorded.add_text)
+ monkeypatch.setattr(samples_module.dpg, "add_separator", recorded.add_separator)
+ monkeypatch.setattr(samples_module.dpg, "add_menu_item", recorded.add_menu_item)
+ monkeypatch.setattr(samples_module, "context_menu", _null_menu)
+ monkeypatch.setattr(context_menu_module, "dpg_set_palette_color", lambda _item, _color: None)
+ monkeypatch.setattr(context_menu_module, "show_tooltip", recorded.add_tooltip)
+ monkeypatch.setattr(FontRegistry, "bind_to_item", lambda _item, _font: None)
+ return recorded
+
+
+@dataclass(frozen=True)
+class _Router:
+ """The key router as the panel's own scope reads it."""
+
+ field_focused: bool
+
+ @property
+ def is_field_focused(self) -> bool:
+ return self.field_focused
+
+
+class TestActionItems:
+ def test_the_menu_reads_as_the_sample_actions(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ _panel(monkeypatch).panel.build_edit_actions()
+
+ assert [item.label for item in recorder.items] == [
+ SequencerInstrumentsElements.CONTEXT_EDIT.value,
+ SequencerInstrumentsElements.CONTEXT_RENAME.value,
+ SequencerInstrumentsElements.CONTEXT_DUPLICATE.value,
+ SequencerInstrumentsElements.CONTEXT_REMOVE.value,
+ *(move.element.value for move in SAMPLE_MOVES),
+ ]
+
+ def test_the_items_print_the_keys_the_panel_answers_to(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ """The panel has always answered these presses, and an item prints the one it fires."""
+ shortcuts = shipped_source()
+ _panel(monkeypatch).panel.build_edit_actions()
+
+ assert recorder.items[RENAME_ITEM].shortcut == shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE)
+ assert recorder.items[REMOVE_ITEM].shortcut == shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE)
+ assert [item.shortcut for item in recorder.items[MOVE_UP_ITEM:]] == [
+ shortcuts.display(move.shortcut) for move in SAMPLE_MOVES
+ ]
+
+ def test_the_items_act_on_the_sample_they_were_raised_on(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ fixture = _panel(monkeypatch)
+ fixture.panel.build_edit_actions()
+
+ for item in recorder.items:
+ item.callback()
+
+ assert fixture.requests.edited == [SELECTED_ID]
+ assert fixture.requests.renamed == [SELECTED_ID]
+ assert fixture.requests.duplicated == [SELECTED_ID]
+ assert fixture.requests.removed == [SELECTED_ID]
+ assert fixture.requests.moved == [
+ (SELECTED_ID, SELECTED_ROW - 1),
+ (SELECTED_ID, SELECTED_ROW + 1),
+ (SELECTED_ID, 0),
+ (SELECTED_ID, len(ENTRIES) - 1),
+ ]
+
+ def test_a_move_with_nowhere_to_go_is_greyed_out(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ _panel(monkeypatch, selected_row=0).panel.build_edit_actions()
+
+ assert not recorder.items[MOVE_UP_ITEM].enabled
+ assert not recorder.items[MOVE_TOP_ITEM].enabled
+ assert recorder.items[MOVE_DOWN_ITEM].enabled
+ assert recorder.items[MOVE_BOTTOM_ITEM].enabled
+
+
+class TestTheSizeRows:
+ """A sample's menu names the bytes it occupies, so what a pool costs is read where it is edited."""
+
+ def test_the_rows_read_as_the_total_then_each_playing_channel(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ items = _panel(monkeypatch).panel._footprint_items(SELECTED_ID)
+
+ assert items == [
+ (SAMPLE_SIZE_LABEL, f"{PULSE_1_BYTES + NOISE_BYTES} B"),
+ (ContextElements.PULSE_1.value, f"{PULSE_1_BYTES} B"),
+ (ContextElements.NOISE.value, f"{NOISE_BYTES} B"),
+ ]
+
+ def test_a_channel_standing_by_is_named_nowhere(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A channel that does not play is written by no export, so it costs nothing to name."""
+ labels = [label for label, _value in _panel(monkeypatch).panel._footprint_items(SELECTED_ID)]
+
+ assert ContextElements.PULSE_2.value not in labels
+ assert ContextElements.TRIANGLE.value not in labels
+
+ def test_the_figures_name_the_sample_the_pointer_landed_on(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The figures are asked for as the menu opens, so they answer for the row right-clicked."""
+ measured: List[str] = []
+
+ def _measure(sample_id: str) -> SampleFootprintViewModel:
+ measured.append(sample_id)
+ return FOOTPRINT
+
+ fixture = _panel(monkeypatch)
+ fixture.panel.sample_footprint = _measure
+
+ fixture.panel._footprint_items("lead-id")
+
+ assert measured == ["lead-id"]
+
+ def test_a_sample_the_pool_has_dropped_prints_no_rows(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ assert _panel(monkeypatch, footprint=None).panel._footprint_items(SELECTED_ID) == []
+
+ def test_an_unwired_hook_prints_no_rows(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A panel tolerates its hooks being unset until the coordinator wires them."""
+ assert _panel(monkeypatch, footprint_wired=False).panel._footprint_items(SELECTED_ID) == []
+
+
+class TestMenuComposition:
+ def test_the_sizes_sit_between_the_sample_name_and_the_actions(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ build_recorder: _MenuBuildRecorder,
+ ) -> None:
+ """Pins where the figures are printed: under the name they belong to, above what can be done."""
+ _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID)
+
+ assert build_recorder.texts_before_the_first_item() == [
+ display_sample_label(SELECTED_ROW, "Bass"),
+ f"{SAMPLE_SIZE_LABEL}: {PULSE_1_BYTES + NOISE_BYTES} B",
+ f"{ContextElements.PULSE_1.value}: {PULSE_1_BYTES} B",
+ f"{ContextElements.NOISE.value}: {NOISE_BYTES} B",
+ ]
+
+ def test_every_figure_names_the_export_it_measures(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ build_recorder: _MenuBuildRecorder,
+ ) -> None:
+ """A byte count means one export, so each line a reader hovers says which one it counts."""
+ _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID)
+
+ assert build_recorder.tooltips == [SIZE_TOOLTIP] * 3
+
+ def test_a_menu_with_no_figures_reads_as_it_always_has(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ build_recorder: _MenuBuildRecorder,
+ ) -> None:
+ _panel(monkeypatch, footprint=None).panel._show_context_menu(SELECTED_ROW, SELECTED_ID)
+
+ assert build_recorder.texts_before_the_first_item() == [display_sample_label(SELECTED_ROW, "Bass")]
+
+
+class TestEditActions:
+ def test_the_panel_answers_while_it_holds_a_selection(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ assert _panel(monkeypatch).panel.owns_edit_actions()
+
+ def test_a_panel_holding_no_selection_stands_down(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The grids and this panel hold one selection between them, so one of them answers."""
+ assert not _panel(monkeypatch, selected_row=None).panel.owns_edit_actions()
+
+ def test_a_panel_on_a_tab_behind_stands_down(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A selection outlives a move to another tab, and the Edit menu follows the tab in front."""
+ assert not _panel(monkeypatch, tab_active=False).panel.owns_edit_actions()
+
+ def test_a_field_holding_the_keyboard_stands_the_panel_down(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ assert not _panel(monkeypatch, field_focused=True).panel.owns_edit_actions()
+
+ def test_a_panel_holding_no_selection_builds_nothing(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ recorder: _MenuRecorder,
+ ) -> None:
+ _panel(monkeypatch, selected_row=None).panel.build_edit_actions()
+
+ assert recorder.items == []
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py
new file mode 100644
index 00000000..97353acc
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py
@@ -0,0 +1,433 @@
+from typing import List, Optional, Tuple
+
+import pytest
+
+from sampletones_application.layout.config import LayoutConfig
+from sampletones_application.layout.loader import load_layout_config
+from sampletones_application.layout.tabs.sequencer import SequencerLayout
+from sampletones_application.paths import (
+ BEHAVIOR_DIRECTORY,
+ LAYOUT_DIRECTORY,
+ PALETTES_DIRECTORY,
+)
+from sampletones_application.tags.sequencer import (
+ TAG_SEQUENCER_ORDER_TABLE,
+ TAG_SEQUENCER_TRACKER_TABLE,
+)
+from sampletones_application.ui.elements.table.cells import EditableCells
+from sampletones_application.ui.elements.table.selection import TableSelection
+from sampletones_application.ui.panels.sequencer.grid.scroll.axis import (
+ HorizontalScroll,
+ ScrollAxis,
+ VerticalScroll,
+)
+from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand
+from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel
+from sampletones_application.ui.panels.sequencer.input.order import (
+ OrderCursor,
+ OrderInputState,
+)
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel, OrderKey
+from sampletones_application.ui.panels.sequencer.tracker import CellKey, GUISequencerTrackerPanel
+from sampletones_application.utils.gui.keyboard.modifiers import Modifier
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+
+ROW_COUNT = 64
+POSITION_COUNT = 8
+ORIGIN_WIDGET = 101
+ORIGIN_CELL: CellKey = (2, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ORIGIN_ENTRY: OrderKey = (None, 1)
+
+
+@pytest.fixture
+def layout_config() -> LayoutConfig:
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source)
+
+
+@pytest.fixture
+def sequencer_layout(layout_config: LayoutConfig) -> SequencerLayout:
+ return layout_config.tabs.sequencer
+
+
+def _resting_travel(axis: ScrollAxis) -> DragTravel:
+ """A travel over a grid that was never drawn: stating no band, it carries a drag nowhere."""
+ return DragTravel(axis=axis, band=lambda: None, elapsed=lambda: 1.0 / 60.0)
+
+
+def _hold_modifiers(
+ monkeypatch: pytest.MonkeyPatch,
+ module: str,
+ shift: bool,
+) -> None:
+ """Holds Shift down for both readers of it: the drag reads the press, the panel the click."""
+ modifiers = {Modifier.SHIFT} if shift else set()
+ monkeypatch.setattr(
+ f"sampletones_application.ui.panels.sequencer.{module}.capture_modifiers",
+ lambda: modifiers,
+ )
+ monkeypatch.setattr(
+ "sampletones_application.ui.elements.table.drag.capture_modifiers",
+ lambda: modifiers,
+ )
+
+
+def _tracker(
+ monkeypatch: pytest.MonkeyPatch,
+ reached: Optional[CellKey],
+ shift: bool = False,
+) -> Tuple[GUISequencerTrackerPanel, List[TrackerInputState]]:
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._input_state = TrackerInputState()
+ panel._current_row_count = ROW_COUNT
+ panel._editable_cells = EditableCells()
+ panel._editable_cells.register(ORIGIN_CELL, ORIGIN_WIDGET)
+ panel._selection = TableSelection(
+ cells=panel._editable_cells,
+ cell_at=lambda: panel._cell_at(),
+ covered=panel._selected_cells,
+ )
+ panel._travel = _resting_travel(VerticalScroll(table=TAG_SEQUENCER_TRACKER_TABLE))
+
+ states: List[TrackerInputState] = []
+ monkeypatch.setattr(panel, "_apply_state", states.append)
+ monkeypatch.setattr(panel, "_cell_at", lambda: reached)
+ _hold_modifiers(monkeypatch, "tracker", shift)
+ return panel, states
+
+
+def _order(
+ monkeypatch: pytest.MonkeyPatch,
+ reached: Optional[OrderKey],
+ shift: bool = False,
+) -> Tuple[GUISequencerOrderPanel, List[OrderInputState]]:
+ panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
+ panel._input_state = OrderInputState()
+ panel._position_count = POSITION_COUNT
+ panel._order = EditableCells()
+ panel._order.register(ORIGIN_ENTRY, ORIGIN_WIDGET)
+ panel._selection = TableSelection(
+ cells=panel._order,
+ cell_at=lambda: panel._cell_at(),
+ covered=panel._selected_cells,
+ )
+ panel._travel = _resting_travel(HorizontalScroll(table=TAG_SEQUENCER_ORDER_TABLE))
+
+ states: List[OrderInputState] = []
+ monkeypatch.setattr(panel, "_apply_state", states.append)
+ monkeypatch.setattr(panel, "_cell_at", lambda: reached)
+ _hold_modifiers(monkeypatch, "order", shift)
+ return panel, states
+
+
+def _silence_click(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Lets a click run over a grid that was never drawn: the cells it releases hold no widget."""
+ monkeypatch.setattr(
+ "sampletones_application.ui.elements.table.selection.dpg.set_value",
+ lambda widget, value: None,
+ )
+
+
+class TestTrackerDrag:
+ """A press carries the selection with the pointer, and a press that stays put stays a click."""
+
+ def test_a_press_alone_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel, states = _tracker(monkeypatch, reached=ORIGIN_CELL)
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+
+ assert states == []
+
+ def test_a_press_held_on_its_own_cell_stays_a_click(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel, states = _tracker(monkeypatch, reached=ORIGIN_CELL)
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+
+ assert states == []
+
+ def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ reached: CellKey = (5, GeneratorName.TRIANGLE, SubColumn.VOLUME)
+ panel, states = _tracker(monkeypatch, reached=reached)
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+
+ assert states[-1].region == TrackerRegion(
+ first_row=2,
+ last_row=5,
+ first_slot=4,
+ last_slot=11,
+ )
+
+ def test_a_plain_drag_replaces_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ panel, states = _tracker(monkeypatch, reached=reached)
+ panel._input_state = TrackerInputState(
+ cursor=TrackerCursor(20, GeneratorName.NOISE, SubColumn.VOLUME),
+ anchor=TrackerCursor(30, GeneratorName.NOISE, SubColumn.VOLUME),
+ )
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+
+ assert states[-1].anchor == TrackerCursor(*ORIGIN_CELL)
+ assert states[-1].region == TrackerRegion(
+ first_row=2,
+ last_row=5,
+ first_slot=4,
+ last_slot=4,
+ )
+
+ def test_a_shift_press_carries_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ panel, states = _tracker(monkeypatch, reached=reached, shift=True)
+ panel._input_state = TrackerInputState(
+ cursor=TrackerCursor(9, GeneratorName.PULSE1, SubColumn.TRANSPOSE),
+ anchor=TrackerCursor(9, GeneratorName.PULSE2, SubColumn.TRANSPOSE),
+ )
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+
+ assert states[-1].anchor == TrackerCursor(9, GeneratorName.PULSE2, SubColumn.TRANSPOSE)
+
+ def test_a_drag_back_to_its_origin_selects_that_cell(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ panel, states = _tracker(monkeypatch, reached=reached)
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ monkeypatch.setattr(panel, "_cell_at", lambda: ORIGIN_CELL)
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+
+ assert states[-1].region == TrackerRegion(
+ first_row=2,
+ last_row=2,
+ first_slot=4,
+ last_slot=4,
+ )
+
+ def test_a_press_on_a_cell_the_cache_forgot_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel, states = _tracker(monkeypatch, reached=ORIGIN_CELL)
+
+ panel._on_cell_held(0, ORIGIN_WIDGET + 1)
+ panel._on_cell_held(0, ORIGIN_WIDGET + 1)
+
+ assert states == []
+
+ def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The press starting a gesture ends the one before it, so its click places the cursor."""
+ reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ panel, states = _tracker(monkeypatch, reached=reached)
+ _silence_click(monkeypatch)
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_pointer_pressed(0, 0)
+ panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_CELL)
+
+ assert states[-1].cursor == TrackerCursor(*ORIGIN_CELL)
+ assert states[-1].region is None
+
+ def test_the_click_ending_a_drag_leaves_the_selection_alone(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """A drag returning to its own cell releases there, and that release reports a click."""
+ reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE)
+ panel, states = _tracker(monkeypatch, reached=reached)
+ _silence_click(monkeypatch)
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ applied = len(states)
+ panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_CELL)
+
+ assert len(states) == applied
+
+
+class TestTrackerDragHitTest:
+ """The row under the pointer is counted from the first row, and clipped to the rows there are."""
+
+ def test_each_row_answers_for_its_own_band(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ sequencer_layout: SequencerLayout,
+ ) -> None:
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._layout = sequencer_layout
+ panel._current_row_count = ROW_COUNT
+ monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None)
+
+ height = sequencer_layout.tracker.row_height
+ assert panel._row_at(100.0) == 0
+ assert panel._row_at(100.0 + height - 1) == 0
+ assert panel._row_at(100.0 + height) == 1
+ assert panel._row_at(100.0 + 3 * height + 2) == 3
+
+ def test_a_pointer_past_an_edge_reads_as_the_edge(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ sequencer_layout: SequencerLayout,
+ ) -> None:
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._layout = sequencer_layout
+ panel._current_row_count = ROW_COUNT
+ monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None)
+
+ assert panel._row_at(-500.0) == 0
+ assert panel._row_at(100_000.0) == ROW_COUNT - 1
+
+ def test_a_grid_awaiting_its_rows_answers_nothing(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ sequencer_layout: SequencerLayout,
+ ) -> None:
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._layout = sequencer_layout
+ panel._current_row_count = 0
+ monkeypatch.setattr(panel, "_row_top", lambda index: None)
+
+ assert panel._row_at(100.0) is None
+
+
+class TestTravelBands:
+ """Each grid states the band a drag held past an edge travels across, in its own axis."""
+
+ def test_the_tracker_band_runs_from_the_first_row(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ sequencer_layout: SequencerLayout,
+ ) -> None:
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._layout = sequencer_layout
+ panel._current_row_count = ROW_COUNT
+ monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None)
+
+ assert panel._travel_band() == TravelBand(
+ first_edge=100.0,
+ cell_extent=sequencer_layout.tracker.row_height,
+ cell_count=ROW_COUNT,
+ )
+
+ def test_a_tracker_awaiting_its_rows_states_no_band(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ sequencer_layout: SequencerLayout,
+ ) -> None:
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._layout = sequencer_layout
+ panel._current_row_count = ROW_COUNT
+ monkeypatch.setattr(panel, "_row_top", lambda index: None)
+
+ assert panel._travel_band() is None
+
+ def test_an_empty_frame_states_no_band(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ sequencer_layout: SequencerLayout,
+ ) -> None:
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._layout = sequencer_layout
+ panel._current_row_count = 0
+ monkeypatch.setattr(panel, "_row_top", lambda index: 100.0)
+
+ assert panel._travel_band() is None
+
+ def test_the_order_band_runs_across_from_the_first_position(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
+ panel._position_count = POSITION_COUNT
+ monkeypatch.setattr(panel, "_cell_left", lambda position: 40.0 + 25.0 * position)
+
+ assert panel._travel_band() == TravelBand(
+ first_edge=40.0,
+ cell_extent=25.0,
+ cell_count=POSITION_COUNT,
+ )
+
+ def test_an_order_of_one_position_states_no_band(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """A single position holds every width there is, so nothing states the pitch to travel by."""
+ panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
+ panel._position_count = 1
+ monkeypatch.setattr(panel, "_cell_left", lambda position: 40.0 if position == 0 else None)
+
+ assert panel._travel_band() is None
+
+
+class TestOrderDrag:
+ """The order table reads a drag the same way, over its channels and positions."""
+
+ def test_a_press_alone_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel, states = _order(monkeypatch, reached=ORIGIN_ENTRY)
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+
+ assert states == []
+
+ def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ reached: OrderKey = (GeneratorName.PULSE2, 4)
+ panel, states = _order(monkeypatch, reached=reached)
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+
+ assert states[-1].region == OrderRegion(
+ first_row=0,
+ last_row=2,
+ first_position=1,
+ last_position=4,
+ )
+
+ def test_a_shift_press_carries_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ reached: OrderKey = (GeneratorName.PULSE2, 4)
+ panel, states = _order(monkeypatch, reached=reached, shift=True)
+ panel._input_state = OrderInputState(
+ cursor=OrderCursor(GeneratorName.NOISE, 6),
+ anchor=OrderCursor(GeneratorName.NOISE, 6),
+ )
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+
+ assert states[-1].anchor == OrderCursor(GeneratorName.NOISE, 6)
+
+ def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """The press starting a gesture ends the one before it, so its click places the cursor."""
+ reached: OrderKey = (GeneratorName.PULSE2, 4)
+ panel, states = _order(monkeypatch, reached=reached)
+ _silence_click(monkeypatch)
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_pointer_pressed(0, 0)
+ panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_ENTRY)
+
+ assert states[-1].cursor == OrderCursor(*ORIGIN_ENTRY)
+ assert states[-1].region is None
+
+ def test_the_click_ending_a_drag_leaves_the_selection_alone(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ reached: OrderKey = (GeneratorName.PULSE2, 4)
+ panel, states = _order(monkeypatch, reached=reached)
+ _silence_click(monkeypatch)
+
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ panel._on_cell_held(0, ORIGIN_WIDGET)
+ applied = len(states)
+ panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_ENTRY)
+
+ assert len(states) == applied
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py
new file mode 100644
index 00000000..2b2043bf
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py
@@ -0,0 +1,252 @@
+from typing import List, Optional
+
+import pytest
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.ui.panels.sequencer.input.order import (
+ OrderCursor,
+ OrderInputState,
+)
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel
+from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion
+from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from tests.suite.shortcuts import shipped_source
+
+ROW_COUNT = 64
+POSITION_COUNT = 8
+CURSOR_ROW = 4
+CURSOR_POSITION = 2
+
+
+def _press(text: str) -> KeyEvent:
+ """The press a written combination names, as the router delivers it."""
+ combination = KeyCombination.parse(text)
+ return KeyEvent(key=combination.key, modifiers=combination.modifiers)
+
+
+def _tracker(
+ generator: Optional[GeneratorName] = GeneratorName.PULSE1,
+ subcolumn: SubColumn = SubColumn.INSTRUMENT,
+) -> GUISequencerTrackerPanel:
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._shortcuts = shipped_source()
+ panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, generator, subcolumn))
+ panel._current_row_count = ROW_COUNT
+ return panel
+
+
+def _order(generator: Optional[GeneratorName] = GeneratorName.PULSE1) -> GUISequencerOrderPanel:
+ panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel)
+ panel._shortcuts = shipped_source()
+ panel._input_state = OrderInputState(cursor=OrderCursor(generator, CURSOR_POSITION))
+ panel._position_count = POSITION_COUNT
+ return panel
+
+
+def _tracker_states(
+ monkeypatch: pytest.MonkeyPatch,
+ panel: GUISequencerTrackerPanel,
+) -> List[TrackerInputState]:
+ """The states a gesture applies, with the scroll a jump asks for left out."""
+ states: List[TrackerInputState] = []
+ monkeypatch.setattr(panel, "_apply_state", states.append)
+ monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: None)
+ return states
+
+
+def _order_states(
+ monkeypatch: pytest.MonkeyPatch,
+ panel: GUISequencerOrderPanel,
+) -> List[OrderInputState]:
+ states: List[OrderInputState] = []
+ monkeypatch.setattr(panel, "_apply_state", states.append)
+ return states
+
+
+class TestTrackerSelectionKeys:
+ """Shift held with a cursor key selects instead of moving, over the grid the cursor stands in."""
+
+ def test_shift_down_selects_two_rows(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _tracker()
+ states = _tracker_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Shift+Down")) is True
+ assert states[-1].region == TrackerRegion(
+ first_row=CURSOR_ROW,
+ last_row=CURSOR_ROW + 1,
+ first_slot=3,
+ last_slot=3,
+ )
+
+ def test_shift_up_selects_the_row_above(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _tracker()
+ states = _tracker_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Shift+Up")) is True
+ region = states[-1].region
+ assert region is not None
+ assert (region.first_row, region.last_row) == (CURSOR_ROW - 1, CURSOR_ROW)
+
+ def test_shift_right_selects_the_next_subcolumn(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _tracker()
+ states = _tracker_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Shift+Right")) is True
+ region = states[-1].region
+ assert region is not None
+ assert region.slots == (
+ TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),
+ )
+
+ def test_shift_end_selects_to_the_last_row(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _tracker()
+ states = _tracker_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Shift+End")) is True
+ region = states[-1].region
+ assert region is not None
+ assert (region.first_row, region.last_row) == (CURSOR_ROW, ROW_COUNT - 1)
+
+ def test_shift_home_selects_to_the_first_row(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _tracker()
+ states = _tracker_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Shift+Home")) is True
+ region = states[-1].region
+ assert region is not None
+ assert (region.first_row, region.last_row) == (0, CURSOR_ROW)
+
+ def test_a_plain_arrow_still_moves_the_cursor(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _tracker()
+ states = _tracker_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Down")) is True
+ assert states[-1].region is None
+
+
+class TestOrderSelectionKeys:
+ """Shift held with a cursor key selects instead of moving, over the table the cursor stands in."""
+
+ def test_shift_right_selects_two_positions(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _order()
+ states = _order_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Shift+Right")) is True
+ assert states[-1].region == OrderRegion(
+ first_row=1,
+ last_row=1,
+ first_position=CURSOR_POSITION,
+ last_position=CURSOR_POSITION + 1,
+ )
+
+ def test_shift_up_selects_up_to_the_master_row(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _order()
+ states = _order_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Shift+Up")) is True
+ region = states[-1].region
+ assert region is not None
+ assert region.generators == (None, GeneratorName.PULSE1)
+
+ def test_shift_end_selects_to_the_last_position(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _order()
+ states = _order_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Shift+End")) is True
+ region = states[-1].region
+ assert region is not None
+ assert (region.first_position, region.last_position) == (CURSOR_POSITION, POSITION_COUNT - 1)
+
+ def test_shift_home_selects_to_the_first_position(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _order()
+ states = _order_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Shift+Home")) is True
+ region = states[-1].region
+ assert region is not None
+ assert (region.first_position, region.last_position) == (0, CURSOR_POSITION)
+
+ def test_a_plain_arrow_still_moves_the_cursor(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _order()
+ states = _order_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Right")) is True
+ assert states[-1].region is None
+
+
+class TestTrackerSelectKeys:
+ """The A chord selects a shape of the grid, each shape wider than the one Shift and Alt add."""
+
+ def test_ctrl_a_selects_the_whole_frame(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _tracker()
+ states = _tracker_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Ctrl+A")) is True
+ region = states[-1].region
+ assert region is not None
+ assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1)
+ assert (region.first_slot, region.last_slot) == (0, SLOT_COUNT - 1)
+
+ def test_ctrl_shift_a_selects_the_column_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _tracker(generator=GeneratorName.TRIANGLE, subcolumn=SubColumn.VOLUME)
+ states = _tracker_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Ctrl+Shift+A")) is True
+ region = states[-1].region
+ assert region is not None
+ assert region.slots == tuple(TrackerSlot(GeneratorName.TRIANGLE, subcolumn) for subcolumn in SubColumn)
+
+ def test_ctrl_alt_a_selects_the_subcolumn_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _tracker(subcolumn=SubColumn.VOLUME)
+ states = _tracker_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Ctrl+Alt+A")) is True
+ region = states[-1].region
+ assert region is not None
+ assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),)
+
+ def test_a_shape_stands_the_cursor_at_the_end_it_reaches(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A Shift+Up straight after shrinks the selection from the row the shape ended on."""
+ panel = _tracker()
+ states = _tracker_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Ctrl+A")) is True
+ assert states[-1].cursor == TrackerCursor(ROW_COUNT - 1, GeneratorName.NOISE, SubColumn.VOLUME)
+
+
+class TestOrderSelectKeys:
+ """The A chord selects a shape of the table, the whole order or the row the cursor stands in."""
+
+ def test_ctrl_a_selects_the_whole_order(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _order()
+ states = _order_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Ctrl+A")) is True
+ region = states[-1].region
+ assert region is not None
+ assert region.generators == CHANNEL_AXIS
+ assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1)
+
+ def test_ctrl_shift_a_selects_the_row_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _order(generator=None)
+ states = _order_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Ctrl+Shift+A")) is True
+ region = states[-1].region
+ assert region is not None
+ assert region.generators == (None,)
+ assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1)
+
+ def test_a_shape_stands_the_cursor_at_the_end_it_reaches(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _order()
+ states = _order_states(monkeypatch, panel)
+
+ assert panel._on_key_pressed(_press("Ctrl+A")) is True
+ assert states[-1].cursor == OrderCursor(CHANNEL_AXIS[-1], POSITION_COUNT - 1)
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py
similarity index 89%
rename from tests/unit/sampletones_application/ui/panels/sequencer/test_grid_channels.py
rename to tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py
index f36aa9f0..535a1079 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_channels.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py
@@ -4,15 +4,22 @@
import pytest
from sampletones_application.categories.manager import LanguageManager
-from sampletones_application.layout.tabs.sequencer.colors import ChannelColors
+from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors
from sampletones_application.paths import LANG_EN
from sampletones_application.ui.elements.table.cells import EditableCells
from sampletones_application.ui.panels.sequencer import channels as channels_module
-from sampletones_application.ui.panels.sequencer import grid as grid_module
+from sampletones_application.ui.panels.sequencer import tracker as tracker_module
from sampletones_application.ui.panels.sequencer.columns import tracker_table_column
-from sampletones_application.ui.panels.sequencer.grid import GUISequencerGridPanel
-from sampletones_application.utils.gui.keyboard.modifiers import CTRL, NO_MODIFIERS, ModifierSet
-from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel
+from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.utils.gui.keyboard.modifiers import (
+ CTRL,
+ NO_MODIFIERS,
+ ModifierSet,
+)
+from sampletones_application.utils.palette.colors.written import LiteralColor
+from sampletones_application.view_model.sequencer.channels import (
+ SequencerChannelsViewModel,
+)
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
from sampletones_core.constants.enums import GeneratorName
from sampletones_shared.types.application import ColorRGBA, Sender
@@ -26,10 +33,10 @@
MUTED_BACKGROUND: ColorRGBA = (10, 8, 18, 96)
CHANNEL_COLORS = ChannelColors(
- pulse1=(240, 146, 86, 255),
- pulse2=(242, 209, 95, 255),
- triangle=(140, 193, 237, 255),
- noise=(187, 184, 194, 255),
+ pulse1=LiteralColor((240, 146, 86, 255)),
+ pulse2=LiteralColor((242, 209, 95, 255)),
+ triangle=LiteralColor((140, 193, 237, 255)),
+ noise=LiteralColor((187, 184, 194, 255)),
)
HEADER_THEME = 1
@@ -80,17 +87,17 @@ def _cell_widget(generator: GeneratorName, row_index: int, subcolumn: SubColumn)
return 1000 + 100 * GeneratorName.items().index(generator) + 10 * row_index + list(SubColumn).index(subcolumn)
-def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerGridPanel:
+def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerTrackerPanel:
"""Builds a panel around the state the channel cues read, with no DearPyGui context.
The cues touch the layout colours, the theme ids, the header widgets, and the cell
registry, so those are wired directly and the rest of the panel is left out.
"""
- panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel)
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
panel._layout = SimpleNamespace(
colors=SimpleNamespace(
channels=CHANNEL_COLORS,
- muted=SimpleNamespace(background=MUTED_BACKGROUND),
+ muted=SimpleNamespace(background=LiteralColor(MUTED_BACKGROUND)),
),
tracker=SimpleNamespace(
channel_column_tint=TINT_FRACTION,
@@ -120,10 +127,10 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerGridPanel:
@pytest.fixture
def recorder(monkeypatch: pytest.MonkeyPatch) -> _DearPyGuiRecorder:
instance = _DearPyGuiRecorder()
- monkeypatch.setattr(grid_module.dpg, "does_item_exist", instance.does_item_exist)
- monkeypatch.setattr(grid_module.dpg, "highlight_table_column", instance.highlight_table_column)
- monkeypatch.setattr(grid_module.dpg, "bind_item_theme", instance.bind_item_theme)
- monkeypatch.setattr(grid_module.dpg, "set_value", instance.set_value)
+ monkeypatch.setattr(tracker_module.dpg, "does_item_exist", instance.does_item_exist)
+ monkeypatch.setattr(tracker_module.dpg, "highlight_table_column", instance.highlight_table_column)
+ monkeypatch.setattr(tracker_module.dpg, "bind_item_theme", instance.bind_item_theme)
+ monkeypatch.setattr(tracker_module.dpg, "set_value", instance.set_value)
return instance
@@ -324,9 +331,13 @@ class TestCuesAwaitTheTable:
def test_the_model_is_kept_while_the_table_is_absent(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""A mute set pushed before the table exists is reapplied by the next rebuild."""
instance = _DearPyGuiRecorder(table_exists=False)
- monkeypatch.setattr(grid_module.dpg, "does_item_exist", instance.does_item_exist)
- monkeypatch.setattr(grid_module.dpg, "highlight_table_column", instance.highlight_table_column)
- monkeypatch.setattr(grid_module.dpg, "bind_item_theme", instance.bind_item_theme)
+ monkeypatch.setattr(tracker_module.dpg, "does_item_exist", instance.does_item_exist)
+ monkeypatch.setattr(
+ tracker_module.dpg,
+ "highlight_table_column",
+ instance.highlight_table_column,
+ )
+ monkeypatch.setattr(tracker_module.dpg, "bind_item_theme", instance.bind_item_theme)
panel = _panel(frozenset())
panel.update_channels(SequencerChannelsViewModel(muted=frozenset({GeneratorName.PULSE1})))
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py
new file mode 100644
index 00000000..58285015
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py
@@ -0,0 +1,153 @@
+import contextlib
+from typing import Any, Iterator, List, Tuple
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer import tracker as tracker_module
+from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.view_model.sequencer.region import TrackerRegion
+from sampletones_application.view_model.sequencer.samples import (
+ SampleEntryViewModel,
+ SequencerSamplesViewModel,
+)
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP
+from tests.suite.shortcuts import shipped_source
+
+SENDER_WIDGET_ID = 6099
+"""A stand-in for the menu-item widget id DearPyGui passes as the callback's first
+positional argument. The original bug let this id overwrite the step payload."""
+
+
+_CONTEXT_LABELS = (
+ "_lbl_context_set_instrument",
+ "_lbl_context_no_samples",
+)
+
+
+def _panel() -> tracker_module.GUISequencerTrackerPanel:
+ """Builds a panel without its DearPyGui-dependent constructor.
+
+ The menu-dispatch methods touch only their hook attributes, the context
+ labels, the keys each item prints, and ``CallbackMixin.call``, so a fully
+ wired GUI context is unnecessary here. Labels carry no behaviour, so any
+ placeholder text serves.
+ """
+ panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel)
+ for label in _CONTEXT_LABELS:
+ setattr(panel, label, "")
+
+ panel._lbl_adjust = {
+ element: ""
+ for element, _, _ in (
+ *tracker_module.TRANSPOSE_ACTIONS,
+ *tracker_module.VOLUME_ACTIONS,
+ )
+ }
+ panel._shortcuts = shipped_source()
+ return panel
+
+
+class _MenuItemRecorder:
+ """Captures the ``user_data``/``callback`` pairs the builders register."""
+
+ def __init__(self) -> None:
+ self.items: List[Tuple[Any, Any]] = []
+
+ def add_menu_item(self, **kwargs: Any) -> int:
+ if "callback" in kwargs and "user_data" in kwargs:
+ self.items.append((kwargs["user_data"], kwargs["callback"]))
+ return 0
+
+ def dispatch_as_dpg(self) -> None:
+ """Fires each recorded callback the way DearPyGui does: sender first."""
+ for user_data, callback in self.items:
+ callback(SENDER_WIDGET_ID, None, user_data)
+
+
+@pytest.fixture
+def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuItemRecorder:
+ instance = _MenuItemRecorder()
+ monkeypatch.setattr(tracker_module.dpg, "add_menu_item", instance.add_menu_item)
+
+ @contextlib.contextmanager
+ def _menu(**kwargs: Any) -> Iterator[None]:
+ yield
+
+ monkeypatch.setattr(tracker_module.dpg, "menu", _menu)
+ return instance
+
+
+def _cell(row: int, generator: GeneratorName) -> TrackerCursor:
+ """The cell a menu was raised on, which the items carry as their payload."""
+ return TrackerCursor(row, generator, SubColumn.INSTRUMENT)
+
+
+def _target(row: int, generator: GeneratorName) -> TrackerTarget:
+ """The cell a menu was raised on, paired with the block of that cell alone."""
+ cell = _cell(row, generator)
+ return TrackerTarget(cell=cell, region=TrackerInputState().region_at(cell))
+
+
+class TestMenuDispatchPreservesPayload:
+ def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None:
+ panel = _panel()
+ deltas: List[int] = []
+ panel.on_adjust_transpose = lambda region, delta: deltas.append(delta)
+
+ panel._add_transpose_items(_target(2, GeneratorName.PULSE1))
+ recorder.dispatch_as_dpg()
+
+ assert deltas == [
+ SEMITONE_STEP,
+ -SEMITONE_STEP,
+ OCTAVE_SEMITONES,
+ -OCTAVE_SEMITONES,
+ ]
+
+ def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None:
+ panel = _panel()
+ deltas: List[int] = []
+ panel.on_adjust_volume = lambda region, delta: deltas.append(delta)
+
+ panel._add_volume_items(_target(2, GeneratorName.PULSE1))
+ recorder.dispatch_as_dpg()
+
+ assert deltas == [
+ tracker_module.VOLUME_FINE_STEP,
+ -tracker_module.VOLUME_FINE_STEP,
+ tracker_module.VOLUME_COARSE_STEP,
+ -tracker_module.VOLUME_COARSE_STEP,
+ ]
+
+ def test_adjust_carries_the_block_the_menu_was_raised_on(self, recorder: _MenuItemRecorder) -> None:
+ panel = _panel()
+ calls: List[Tuple[TrackerRegion, int]] = []
+ panel.on_adjust_transpose = lambda region, delta: calls.append((region, delta))
+ target = _target(7, GeneratorName.TRIANGLE)
+
+ panel._add_transpose_items(target)
+ recorder.dispatch_as_dpg()
+
+ assert calls[0] == (target.region, SEMITONE_STEP)
+
+ def test_instrument_items_pass_the_sample_id(self, recorder: _MenuItemRecorder) -> None:
+ panel = _panel()
+ panel._current_samples = SequencerSamplesViewModel(
+ samples=(
+ SampleEntryViewModel(
+ sample_id="lead-id",
+ name="lead",
+ loop=False,
+ ),
+ ),
+ )
+ chosen: List[str] = []
+ panel.on_set_row = lambda row, generator, sample_id, transpose, volume: chosen.append(sample_id)
+
+ panel._add_instrument_submenu(_cell(0, GeneratorName.PULSE2))
+ recorder.dispatch_as_dpg()
+
+ assert chosen == ["lead-id"]
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_header_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py
similarity index 92%
rename from tests/unit/sampletones_application/ui/panels/sequencer/test_grid_header_menu.py
rename to tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py
index ad43e679..457758d1 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_header_menu.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_header_menu.py
@@ -6,10 +6,12 @@
from sampletones_application.categories.manager import LanguageManager
from sampletones_application.paths import LANG_EN
-from sampletones_application.ui.panels.sequencer import grid as grid_module
-from sampletones_application.ui.panels.sequencer.grid import GUISequencerGridPanel
+from sampletones_application.ui.panels.sequencer import tracker as tracker_module
+from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard.modifiers import Modifier
-from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel
+from sampletones_application.view_model.sequencer.channels import (
+ SequencerChannelsViewModel,
+)
from sampletones_core.constants.enums import GeneratorName
from sampletones_shared.types.application import Sender
from sampletones_shared.types.callback import VoidCallback
@@ -82,7 +84,7 @@ def click(self, label: str) -> None:
self.callbacks[label]()
-def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerGridPanel:
+def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerTrackerPanel:
"""Builds a panel around the state the header menu reads, with no DearPyGui context.
The menu touches the column labels, the pushed mute set, and the map from header widget to
@@ -90,7 +92,7 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerGridPanel:
the menu is built the way the panel builds it, from the real language file, so the item labels
under test are the ones a user reads.
"""
- panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel)
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
panel._column_labels = dict(COLUMN_LABELS)
panel._header_columns = {widget: column for column, widget in HEADER_WIDGETS.items()}
panel._current_channels = SequencerChannelsViewModel(muted=muted)
@@ -101,20 +103,20 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerGridPanel:
@pytest.fixture
def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder:
instance = _MenuRecorder()
- monkeypatch.setattr(grid_module.dpg, "add_menu_item", instance.add_menu_item)
- monkeypatch.setattr(grid_module.dpg, "add_text", instance.add_text)
- monkeypatch.setattr(grid_module.dpg, "add_separator", instance.add_separator)
- monkeypatch.setattr(grid_module.FontRegistry, "bind_to_item", lambda item, font: None)
+ monkeypatch.setattr(tracker_module.dpg, "add_menu_item", instance.add_menu_item)
+ monkeypatch.setattr(tracker_module.dpg, "add_text", instance.add_text)
+ monkeypatch.setattr(tracker_module.dpg, "add_separator", instance.add_separator)
+ monkeypatch.setattr(tracker_module.FontRegistry, "bind_to_item", lambda item, font: None)
@contextlib.contextmanager
def _popup() -> Iterator[None]:
yield
- monkeypatch.setattr(grid_module, "context_menu", _popup)
+ monkeypatch.setattr(tracker_module, "context_menu", _popup)
return instance
-def _right_click(panel: GUISequencerGridPanel, column: Optional[GeneratorName]) -> None:
+def _right_click(panel: GUISequencerTrackerPanel, column: Optional[GeneratorName]) -> None:
panel._on_header_right_clicked(
SENDER_WIDGET_ID,
(dpg.mvMouseButton_Right, HEADER_WIDGETS[column]),
@@ -378,18 +380,18 @@ def test_a_channel_menu_reaches_the_whole_mix_too(self, recorder: _MenuRecorder)
class TestHeaderTooltips:
@pytest.fixture
- def panel(self) -> GUISequencerGridPanel:
- instance = GUISequencerGridPanel.__new__(GUISequencerGridPanel)
+ def panel(self) -> GUISequencerTrackerPanel:
+ instance = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
instance._load_header_tooltips(LanguageManager(LANG_EN))
return instance
- def test_the_channel_tooltip_names_the_solo_modifier(self, panel: GUISequencerGridPanel) -> None:
+ def test_the_channel_tooltip_names_the_solo_modifier(self, panel: GUISequencerTrackerPanel) -> None:
assert Modifier.CTRL.value in panel._tooltip_header_channel
- def test_the_channel_tooltip_leaves_no_placeholder_behind(self, panel: GUISequencerGridPanel) -> None:
+ def test_the_channel_tooltip_leaves_no_placeholder_behind(self, panel: GUISequencerTrackerPanel) -> None:
assert "{" not in panel._tooltip_header_channel
- def test_both_headers_explain_their_click(self, panel: GUISequencerGridPanel) -> None:
+ def test_both_headers_explain_their_click(self, panel: GUISequencerTrackerPanel) -> None:
assert panel._tooltip_header_channel
assert panel._tooltip_header_sample
assert panel._tooltip_header_channel != panel._tooltip_header_sample
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py
new file mode 100644
index 00000000..2bfec650
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py
@@ -0,0 +1,349 @@
+from dataclasses import dataclass
+from types import SimpleNamespace
+from typing import List, Tuple
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer import tracker
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.utils.gui.keyboard import KeyEvent
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.keys import KEY_PAGE_DOWN, KEY_PAGE_UP
+from sampletones_application.utils.gui.keyboard.modifiers import NO_MODIFIERS
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.project.song_position import SongPosition
+from sampletones_shared.types.callback import VoidCallback
+from tests.suite.shortcuts import shipped_source
+
+PAGE_SIZE = 16
+CURSOR_ROW = 5
+ROW_COUNT = 65
+SCROLL_MAX = 640.0
+ROW_PITCH = 20.0
+BAND_TOP = 100.0
+LAST_HEADING_ROW = 32
+
+SHOWN_FRAME = 3
+OTHER_FRAME = 4
+
+
+def _panel() -> GUISequencerTrackerPanel:
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._shortcuts = shipped_source()
+ panel._input_state = TrackerInputState(
+ cursor=TrackerCursor(CURSOR_ROW, None, SubColumn.INSTRUMENT),
+ pending="",
+ )
+ panel._layout = SimpleNamespace(tracker=SimpleNamespace(page_size=PAGE_SIZE))
+ panel._displayed_frame = SHOWN_FRAME
+ panel._playing_frame = None
+ panel._playing_row = None
+ panel._painted_row = None
+ panel._follows_playing_row = False
+ panel._current_row_count = ROW_COUNT
+ panel._rows = {row_index: f"row_{row_index}" for row_index in range(ROW_COUNT)}
+ return panel
+
+
+def _playhead(frame_index: int, row_index: int) -> SongPosition:
+ """The playhead standing on a row of an order frame."""
+ return SongPosition(order_position=frame_index, row_index=row_index)
+
+
+def _press(text: str) -> KeyEvent:
+ """The press a written combination names, as the router delivers it."""
+ combination = KeyCombination.parse(text)
+ return KeyEvent(key=combination.key, modifiers=combination.modifiers)
+
+
+class TestGridPageNavigation:
+ """PageUp and PageDown jump the cursor a page of rows, matching the key codes DearPyGui delivers,
+ and reveal the row they land on."""
+
+ def test_page_up_moves_up_one_page_and_scrolls(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _panel()
+ moves: List[int] = []
+ scrolls: List[None] = []
+ monkeypatch.setattr(panel, "_move_row", moves.append)
+ monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: scrolls.append(None))
+
+ assert panel._on_key_pressed(KeyEvent(key=KEY_PAGE_UP, modifiers=NO_MODIFIERS)) is True
+ assert moves == [-PAGE_SIZE]
+ assert scrolls == [None]
+
+ def test_page_down_moves_down_one_page_and_scrolls(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _panel()
+ moves: List[int] = []
+ scrolls: List[None] = []
+ monkeypatch.setattr(panel, "_move_row", moves.append)
+ monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: scrolls.append(None))
+
+ assert panel._on_key_pressed(KeyEvent(key=KEY_PAGE_DOWN, modifiers=NO_MODIFIERS)) is True
+ assert moves == [PAGE_SIZE]
+ assert scrolls == [None]
+
+
+@dataclass(frozen=True)
+class RowPlacementCase:
+ """A row of the frame, and the scroll that places it."""
+
+ row_index: int
+ scroll: float
+
+
+ROW_PLACEMENTS = [
+ RowPlacementCase(row_index=0, scroll=0.0),
+ RowPlacementCase(row_index=(ROW_COUNT - 1) // 2, scroll=SCROLL_MAX / 2),
+ RowPlacementCase(row_index=ROW_COUNT - 1, scroll=SCROLL_MAX),
+]
+
+BAND_TOP_PLACEMENTS = [
+ RowPlacementCase(row_index=0, scroll=0.0),
+ RowPlacementCase(row_index=10, scroll=10 * ROW_PITCH),
+ RowPlacementCase(row_index=LAST_HEADING_ROW, scroll=SCROLL_MAX),
+ RowPlacementCase(row_index=LAST_HEADING_ROW + 8, scroll=SCROLL_MAX),
+ RowPlacementCase(row_index=ROW_COUNT - 1, scroll=SCROLL_MAX),
+]
+
+
+def _row_top(tag: str) -> List[float]:
+ """Where a laid-out row stands, the rows stacked one pitch apart below the band's top."""
+ return [0.0, BAND_TOP + int(tag.removeprefix("row_")) * ROW_PITCH]
+
+
+def _record_scrolls(monkeypatch: pytest.MonkeyPatch, scroll_max: float) -> List[float]:
+ """The scrolls a placement asks of a laid-out grid, in the order it asks for them."""
+ scrolls: List[float] = []
+ monkeypatch.setattr(tracker.dpg, "does_item_exist", lambda tag: True)
+ monkeypatch.setattr(tracker.dpg, "get_y_scroll_max", lambda tag: scroll_max)
+ monkeypatch.setattr(tracker.dpg, "get_item_rect_min", _row_top)
+ monkeypatch.setattr(tracker.dpg, "set_y_scroll", lambda tag, value: scrolls.append(value))
+ return scrolls
+
+
+class TestPlayheadFollowing:
+ """The grid carries the sounding row to the head of the band for as long as it follows the
+ playhead."""
+
+ def test_a_followed_row_is_revealed(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _panel()
+ revealed: List[int] = []
+ monkeypatch.setattr(panel, "_paint_row", lambda row_index: None)
+ monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append)
+
+ panel.set_row_following(True)
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 12))
+
+ assert revealed == [12]
+
+ def test_an_unfollowed_row_stays_where_the_reader_left_it(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _panel()
+ revealed: List[int] = []
+ monkeypatch.setattr(panel, "_paint_row", lambda row_index: None)
+ monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append)
+
+ panel.set_row_following(False)
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 12))
+
+ assert revealed == []
+
+ def test_a_row_of_another_frame_holds_the_grid_where_it_is(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A row belongs to its own pattern, so the grid travels to it once that frame is shown."""
+ panel = _panel()
+ revealed: List[int] = []
+ monkeypatch.setattr(panel, "_paint_row", lambda row_index: None)
+ monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append)
+
+ panel.set_row_following(True)
+ panel.set_playing_position(_playhead(OTHER_FRAME, 12))
+
+ assert revealed == []
+
+ def test_a_cleared_playhead_leaves_the_scroll_alone(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Stopping drops the mark, and the grid keeps the position it was scrolled to."""
+ panel = _panel()
+ revealed: List[int] = []
+ monkeypatch.setattr(panel, "_paint_row", lambda row_index: None)
+ monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append)
+
+ panel.set_row_following(True)
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 12))
+ panel.set_playing_position(None)
+
+ assert revealed == [12]
+
+ @pytest.mark.parametrize("case", BAND_TOP_PLACEMENTS, ids=lambda case: f"row_{case.row_index}")
+ def test_a_row_is_carried_to_the_head_of_the_band(
+ self,
+ case: RowPlacementCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """Each row heads the band by the height of the rows above it, as far as the grid scrolls."""
+ panel = _panel()
+ scrolls = _record_scrolls(monkeypatch, SCROLL_MAX)
+
+ panel._scroll_row_to_band_top(case.row_index)
+
+ assert scrolls == [pytest.approx(case.scroll)]
+
+ def test_a_frame_that_fits_the_band_is_left_alone(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A frame shorter than the band shows every row already, so the grid holds still."""
+ panel = _panel()
+ scrolls = _record_scrolls(monkeypatch, 0.0)
+
+ panel._scroll_row_to_band_top(4)
+
+ assert scrolls == []
+
+ def test_a_grid_awaiting_its_layout_is_left_alone(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Rows reach the grid a frame before they are placed, and measure nothing until they are."""
+ panel = _panel()
+ panel._rows = {}
+ scrolls = _record_scrolls(monkeypatch, SCROLL_MAX)
+
+ panel._scroll_row_to_band_top(4)
+
+ assert scrolls == []
+
+
+class TestPlayheadPainting:
+ """The mark is drawn on the frame the grid's scroll lands on, so the two arrive as one."""
+
+ def test_the_mark_waits_for_the_frame_its_scroll_lands_on(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _panel()
+ painted, paint = _deferred_painting(monkeypatch, panel)
+
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 12))
+
+ assert panel._painted_row is None
+ assert painted == []
+
+ paint()
+
+ assert panel._painted_row == 12
+ assert painted == [12]
+
+ def test_the_row_the_playhead_left_is_cleared(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _panel()
+ painted, paint = _deferred_painting(monkeypatch, panel)
+
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 12))
+ paint()
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 13))
+ paint()
+
+ assert painted == [12, 12, 13]
+
+ def test_a_stopped_playhead_clears_the_row_it_stood_on(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _panel()
+ painted, paint = _deferred_painting(monkeypatch, panel)
+
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 12))
+ paint()
+ panel.set_playing_position(None)
+ paint()
+
+ assert panel._painted_row is None
+ assert painted == [12, 12]
+
+ def test_the_mark_arrives_with_the_frame_the_playhead_moved_to(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A followed playhead crossing a frame boundary shows the next frame, then marks its row."""
+ panel = _panel()
+ painted, paint = _deferred_painting(monkeypatch, panel)
+
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 12))
+ paint()
+ panel._show_frame(OTHER_FRAME)
+ panel.set_playing_position(_playhead(OTHER_FRAME, 0))
+ paint()
+
+ assert panel._painted_row == 0
+ assert painted == [12, 12, 0]
+
+
+def _deferred_painting(
+ monkeypatch: pytest.MonkeyPatch,
+ panel: GUISequencerTrackerPanel,
+) -> Tuple[List[int], VoidCallback]:
+ """The rows a panel paints, and the call that runs the frame's painting on demand."""
+ painted: List[int] = []
+ held: List[VoidCallback] = []
+
+ def hold(callback: VoidCallback, frame_count: int = 1) -> None:
+ assert frame_count == tracker.PLAYHEAD_PAINT_FRAMES
+ held.append(callback)
+
+ monkeypatch.setattr(tracker.FrameCallbackManager, "set_frame_callback", hold)
+ monkeypatch.setattr(panel, "_paint_row", painted.append)
+
+ def paint() -> None:
+ held.pop()()
+
+ return painted, paint
+
+
+class TestCursorPlacement:
+ """A cursor jump places the row across the band, from its top on the first row to its bottom on
+ the last."""
+
+ @pytest.mark.parametrize("case", ROW_PLACEMENTS, ids=lambda case: f"row_{case.row_index}")
+ def test_a_row_is_placed_across_the_band(
+ self,
+ case: RowPlacementCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = _panel()
+ scrolls = _record_scrolls(monkeypatch, SCROLL_MAX)
+
+ panel._scroll_row_into_view(case.row_index)
+
+ assert scrolls == [pytest.approx(case.scroll)]
+
+ def test_the_cursor_is_placed_by_that_rule(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _panel()
+ revealed: List[int] = []
+ monkeypatch.setattr(panel, "_scroll_row_into_view", revealed.append)
+
+ panel._scroll_cursor_into_view()
+
+ assert revealed == [CURSOR_ROW]
+
+
+class TestGridColumnNavigation:
+ """Tab steps to the next channel column and Shift+Tab back, each its own action in the scheme."""
+
+ def test_the_next_column_key_steps_forward(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _panel()
+ moves: List[int] = []
+ monkeypatch.setattr(panel, "_move_column", moves.append)
+
+ assert panel._on_key_pressed(_press("Tab")) is True
+ assert moves == [1]
+
+ def test_the_previous_column_key_steps_back(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _panel()
+ moves: List[int] = []
+ monkeypatch.setattr(panel, "_move_column", moves.append)
+
+ assert panel._on_key_pressed(_press("Shift+Tab")) is True
+ assert moves == [-1]
+
+
+class TestGridCellEntry:
+ def test_a_note_key_types_into_the_cell_under_the_cursor(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ panel = _panel()
+ states: List[TrackerInputState] = []
+ monkeypatch.setattr(panel, "_apply_state", states.append)
+
+ assert panel._on_key_pressed(_press("C")) is True
+ assert states[-1].pending == "C"
+
+ def test_a_modified_key_reaches_the_application(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """Ctrl+D opens the display settings, so cell entry keeps the plain hex key alone."""
+ panel = _panel()
+ states: List[TrackerInputState] = []
+ monkeypatch.setattr(panel, "_apply_state", states.append)
+
+ assert panel._on_key_pressed(_press("Ctrl+D")) is False
+ assert states == []
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_play_shortcut.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py
similarity index 77%
rename from tests/unit/sampletones_application/ui/panels/sequencer/test_grid_play_shortcut.py
rename to tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py
index 74b4b4ad..9a6c38c9 100644
--- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_play_shortcut.py
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py
@@ -2,16 +2,17 @@
import dearpygui.dearpygui as dpg
-from sampletones_application.ui.panels.sequencer.grid import GUISequencerGridPanel
-from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor
-from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
from sampletones_application.utils.gui.keyboard import KeyEvent
from sampletones_application.utils.gui.keyboard.modifiers import CTRL, CTRL_SHIFT
from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from tests.suite.shortcuts import shipped_source
-def _panel(cursor: Optional[TrackerCursor]) -> GUISequencerGridPanel:
- panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel)
+def _panel(cursor: Optional[TrackerCursor]) -> GUISequencerTrackerPanel:
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._shortcuts = shipped_source()
panel._input_state = TrackerInputState(cursor=cursor, pending="")
return panel
diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py
new file mode 100644
index 00000000..6ae66a05
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py
@@ -0,0 +1,465 @@
+from types import SimpleNamespace
+from typing import Dict, List, Optional, Sequence, Tuple
+
+import pytest
+
+from sampletones_application.ui.panels.sequencer import tracker as tracker_module
+from sampletones_application.ui.panels.sequencer.columns import (
+ HEADER_TABLE_ROW,
+ tracker_table_column,
+ tracker_table_row,
+)
+from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState
+from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel
+from sampletones_application.utils.palette.colors.written import LiteralColor
+from sampletones_application.view_model.sequencer.settings import (
+ SequencerSettingsViewModel,
+)
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.project.song_position import SongPosition
+from sampletones_shared.types.application import ColorRGBA
+
+PATTERN_ROWS = 4
+HEADER_AND_PATTERN_ROWS = PATTERN_ROWS + 1
+
+SHOWN_FRAME = 3
+OTHER_FRAME = 4
+
+ROWS_PER_BEAT = 2
+ROWS_PER_BAR = 4
+BAR_ROWS = (0,)
+BEAT_ROWS = (2,)
+PLAIN_ROWS = (1, 3)
+
+CURSOR_ROW: ColorRGBA = (255, 255, 255, 24)
+CELL_CURSOR: ColorRGBA = (102, 187, 255, 160)
+PATTERN_HIGHLIGHT: ColorRGBA = (255, 255, 255, 64)
+PLAYBACK_ROW: ColorRGBA = (100, 220, 100, 64)
+BEAT_ROW: ColorRGBA = (255, 255, 255, 14)
+BAR_ROW: ColorRGBA = (255, 255, 255, 30)
+HEADER_SHADE: ColorRGBA = (70, 65, 92, 255)
+
+
+class _TableRecorder:
+ """Captures the row and cell highlight calls, standing in for a live tracker table."""
+
+ def __init__(self, *, row_children: Sequence[int]) -> None:
+ self.row_children = list(row_children)
+ self.highlighted_rows: Dict[int, ColorRGBA] = {}
+ self.unhighlighted_rows: List[int] = []
+ self.highlighted_cells: Dict[Tuple[int, int], ColorRGBA] = {}
+ self.unhighlighted_cells: List[Tuple[int, int]] = []
+
+ def does_item_exist(self, item: str) -> bool:
+ return True
+
+ def get_item_children(self, item: str, slot: int) -> List[int]:
+ return self.row_children
+
+ def highlight_table_row(self, table: str, row: int, color: ColorRGBA) -> None:
+ self.highlighted_rows[row] = color
+ if row in self.unhighlighted_rows:
+ self.unhighlighted_rows.remove(row)
+
+ def unhighlight_table_row(self, table: str, row: int) -> None:
+ self.unhighlighted_rows.append(row)
+ self.highlighted_rows.pop(row, None)
+
+ def highlight_table_cell(self, table: str, row: int, column: int, color: ColorRGBA) -> None:
+ self.highlighted_cells[(row, column)] = color
+
+ def unhighlight_table_cell(self, table: str, row: int, column: int) -> None:
+ self.unhighlighted_cells.append((row, column))
+
+
+def _settings(
+ *,
+ first_highlight: int = ROWS_PER_BEAT,
+ second_highlight: int = ROWS_PER_BAR,
+) -> SequencerSettingsViewModel:
+ """The module settings the panel reads its metre out of."""
+ return SequencerSettingsViewModel(
+ nes_frequency=60,
+ tempo=150,
+ speed=6,
+ rows_per_pattern=PATTERN_ROWS,
+ first_highlight=first_highlight,
+ second_highlight=second_highlight,
+ )
+
+
+def _panel() -> GUISequencerTrackerPanel:
+ """Builds a panel around the state the row backgrounds read, with no DearPyGui context."""
+ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel)
+ panel._settings = _settings()
+ panel._layout = SimpleNamespace(
+ colors=SimpleNamespace(
+ cursor_row=LiteralColor(CURSOR_ROW),
+ cell_cursor=LiteralColor(CELL_CURSOR),
+ pattern_highlight=LiteralColor(PATTERN_HIGHLIGHT),
+ playback_row=LiteralColor(PLAYBACK_ROW),
+ rows=SimpleNamespace(
+ beat=LiteralColor(BEAT_ROW),
+ bar=LiteralColor(BAR_ROW),
+ ),
+ ),
+ )
+ panel._current_row_count = PATTERN_ROWS
+ panel._highlighted_row = None
+ panel._displayed_frame = SHOWN_FRAME
+ panel._playing_frame = None
+ panel._playing_row = None
+ panel._painted_row = None
+ panel._follows_playing_row = False
+ panel._input_state = TrackerInputState()
+ return panel
+
+
+def _playhead(frame_index: int, row_index: int) -> SongPosition:
+ """The playhead standing on a row of an order frame."""
+ return SongPosition(order_position=frame_index, row_index=row_index)
+
+
+def _place_cursor(
+ panel: GUISequencerTrackerPanel,
+ row_index: int,
+ generator: Optional[GeneratorName],
+) -> None:
+ """Puts the cursor where the panel's own state keeps it, the way an edit action does."""
+ panel._input_state = TrackerInputState(
+ cursor=TrackerCursor(row_index, generator, SubColumn.INSTRUMENT),
+ pending="",
+ )
+
+
+@pytest.fixture
+def recorder(monkeypatch: pytest.MonkeyPatch) -> _TableRecorder:
+ instance = _TableRecorder(row_children=range(HEADER_AND_PATTERN_ROWS))
+ monkeypatch.setattr(tracker_module.dpg, "does_item_exist", instance.does_item_exist)
+ monkeypatch.setattr(tracker_module.dpg, "get_item_children", instance.get_item_children)
+ monkeypatch.setattr(tracker_module.dpg, "highlight_table_row", instance.highlight_table_row)
+ monkeypatch.setattr(tracker_module.dpg, "unhighlight_table_row", instance.unhighlight_table_row)
+ monkeypatch.setattr(tracker_module.dpg, "highlight_table_cell", instance.highlight_table_cell)
+ monkeypatch.setattr(tracker_module.dpg, "unhighlight_table_cell", instance.unhighlight_table_cell)
+ return instance
+
+
+class TestLiveRowCount:
+ def test_the_count_covers_the_pattern_rows_alone(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ assert panel._live_row_count() == PATTERN_ROWS
+
+ def test_a_table_holding_only_the_header_reports_no_pattern_rows(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+ recorder.row_children = [0]
+
+ assert panel._live_row_count() == 0
+
+ def test_an_unbuilt_table_reports_no_pattern_rows(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+ recorder.row_children = []
+
+ assert panel._live_row_count() == 0
+
+
+class TestRowGrouping:
+ def test_the_rows_opening_a_bar_and_a_beat_take_their_shades(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel._apply_row_backgrounds()
+
+ assert recorder.highlighted_rows == {
+ tracker_table_row(0): BAR_ROW,
+ tracker_table_row(2): BEAT_ROW,
+ }
+
+ def test_the_rows_between_them_are_left_to_the_stripe(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel._apply_row_backgrounds()
+
+ assert recorder.unhighlighted_rows == [tracker_table_row(row) for row in PLAIN_ROWS]
+
+ def test_the_header_row_takes_no_row_background(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel._apply_row_backgrounds()
+
+ assert HEADER_TABLE_ROW not in recorder.highlighted_rows
+ assert HEADER_TABLE_ROW not in recorder.unhighlighted_rows
+
+ def test_an_edited_metre_retints_the_rows_at_once(self, recorder: _TableRecorder) -> None:
+ """The highlights are the project's, so a change to them reaches the grid as a repaint."""
+ panel = _panel()
+ panel._apply_row_backgrounds()
+
+ panel.update_settings(_settings(first_highlight=1, second_highlight=PATTERN_ROWS))
+
+ assert recorder.highlighted_rows == {
+ tracker_table_row(0): BAR_ROW,
+ tracker_table_row(1): BEAT_ROW,
+ tracker_table_row(2): BEAT_ROW,
+ tracker_table_row(3): BEAT_ROW,
+ }
+
+ def test_a_row_past_the_live_table_never_reaches_dearpygui(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel._paint_row(PATTERN_ROWS)
+
+ assert not recorder.highlighted_rows
+ assert not recorder.unhighlighted_rows
+
+
+class TestCursorHighlight:
+ @pytest.mark.parametrize("row_index", PLAIN_ROWS)
+ def test_the_cursor_lands_on_the_mapped_table_row(
+ self,
+ recorder: _TableRecorder,
+ row_index: int,
+ ) -> None:
+ panel = _panel()
+ _place_cursor(panel, row_index, GeneratorName.TRIANGLE)
+
+ panel._apply_cell_highlight(row_index, GeneratorName.TRIANGLE)
+
+ assert recorder.highlighted_rows == {tracker_table_row(row_index): CURSOR_ROW}
+
+ @pytest.mark.parametrize("row_index", BAR_ROWS + BEAT_ROWS)
+ def test_the_cursor_on_a_group_row_carries_both_shades(
+ self,
+ recorder: _TableRecorder,
+ row_index: int,
+ ) -> None:
+ panel = _panel()
+ _place_cursor(panel, row_index, GeneratorName.TRIANGLE)
+
+ panel._apply_cell_highlight(row_index, GeneratorName.TRIANGLE)
+
+ painted = recorder.highlighted_rows[tracker_table_row(row_index)]
+ assert painted[3] > CURSOR_ROW[3]
+
+ def test_the_cursor_cell_lands_on_the_mapped_row_and_column(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+ _place_cursor(panel, 2, GeneratorName.NOISE)
+
+ panel._apply_cell_highlight(2, GeneratorName.NOISE)
+
+ key = (tracker_table_row(2), tracker_table_column(GeneratorName.NOISE))
+ assert recorder.highlighted_cells == {key: CELL_CURSOR}
+
+ def test_no_cursor_ever_paints_the_header_row(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ for row_index in range(PATTERN_ROWS):
+ _place_cursor(panel, row_index, None)
+ panel._apply_cell_highlight(row_index, None)
+
+ assert HEADER_TABLE_ROW not in recorder.highlighted_rows
+
+ def test_removing_the_cursor_clears_the_cell_and_the_plain_row(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel._remove_cell_highlight(1, None)
+
+ assert recorder.unhighlighted_rows == [tracker_table_row(1)]
+ assert recorder.unhighlighted_cells == [(tracker_table_row(1), tracker_table_column(None))]
+
+ def test_a_group_row_keeps_its_shade_once_the_cursor_leaves(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel._remove_cell_highlight(0, None)
+
+ assert recorder.highlighted_rows == {tracker_table_row(0): BAR_ROW}
+
+
+class TestHoverHighlight:
+ def test_hover_lands_on_the_mapped_table_row(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel.highlight_row(3)
+
+ assert recorder.highlighted_rows == {tracker_table_row(3): PATTERN_HIGHLIGHT}
+
+ def test_hover_on_a_group_row_carries_both_shades(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel.highlight_row(0)
+
+ painted = recorder.highlighted_rows[tracker_table_row(0)]
+ assert painted[3] > PATTERN_HIGHLIGHT[3]
+
+ def test_moving_the_hover_returns_the_row_it_left(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel.highlight_row(1)
+ panel.highlight_row(3)
+
+ assert recorder.unhighlighted_rows == [tracker_table_row(1)]
+ assert recorder.highlighted_rows == {tracker_table_row(3): PATTERN_HIGHLIGHT}
+
+ def test_moving_the_hover_off_a_group_row_gives_it_its_shade_back(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel.highlight_row(0)
+ panel.highlight_row(3)
+
+ assert recorder.highlighted_rows[tracker_table_row(0)] == BAR_ROW
+
+ def test_dropping_the_hover_clears_the_mapped_row(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+ panel.highlight_row(3)
+
+ panel.highlight_row(None)
+
+ assert recorder.unhighlighted_rows == [tracker_table_row(3)]
+
+
+class TestPlayingRowHighlight:
+ @pytest.mark.parametrize("row_index", PLAIN_ROWS)
+ def test_the_playhead_lands_on_the_mapped_table_row(
+ self,
+ recorder: _TableRecorder,
+ row_index: int,
+ ) -> None:
+ panel = _panel()
+
+ panel.set_playing_position(_playhead(SHOWN_FRAME, row_index))
+
+ assert recorder.highlighted_rows == {tracker_table_row(row_index): PLAYBACK_ROW}
+
+ @pytest.mark.parametrize("row_index", BAR_ROWS + BEAT_ROWS)
+ def test_the_playhead_over_a_group_row_carries_both_shades(
+ self,
+ recorder: _TableRecorder,
+ row_index: int,
+ ) -> None:
+ panel = _panel()
+
+ panel.set_playing_position(_playhead(SHOWN_FRAME, row_index))
+
+ painted = recorder.highlighted_rows[tracker_table_row(row_index)]
+ assert painted[3] > PLAYBACK_ROW[3]
+
+ def test_the_playhead_outranks_the_cursor_on_the_same_row(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+ _place_cursor(panel, 1, GeneratorName.PULSE1)
+
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 1))
+
+ assert recorder.highlighted_rows == {tracker_table_row(1): PLAYBACK_ROW}
+
+ def test_the_last_pattern_row_is_still_within_the_table(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel.set_playing_position(_playhead(SHOWN_FRAME, PATTERN_ROWS - 1))
+
+ assert recorder.highlighted_rows
+
+ def test_a_row_beyond_the_pattern_is_left_to_the_next_rebuild(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel.set_playing_position(_playhead(SHOWN_FRAME, PATTERN_ROWS))
+
+ assert not recorder.highlighted_rows
+
+ def test_advancing_the_playhead_returns_the_row_it_left(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 1))
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 3))
+
+ assert recorder.unhighlighted_rows == [tracker_table_row(1)]
+
+ def test_advancing_past_a_group_row_gives_it_its_shade_back(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 0))
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 1))
+
+ assert recorder.highlighted_rows[tracker_table_row(0)] == BAR_ROW
+
+ def test_stopping_clears_the_mapped_row(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 3))
+
+ panel.set_playing_position(None)
+
+ assert recorder.unhighlighted_rows == [tracker_table_row(3)]
+
+
+class TestPlayheadFrame:
+ """The mark reads as the sounding row of the pattern on screen, so it stands on the grid while
+ the frame it shows is the frame the playhead sounds."""
+
+ def test_a_row_of_another_frame_leaves_the_grid_alone(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+
+ panel.set_playing_position(_playhead(OTHER_FRAME, 1))
+
+ assert not recorder.highlighted_rows
+
+ def test_showing_another_frame_returns_the_marked_row(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 3))
+
+ panel._show_frame(OTHER_FRAME)
+
+ assert recorder.unhighlighted_rows == [tracker_table_row(3)]
+
+ def test_returning_to_the_sounding_frame_marks_its_row_again(self, recorder: _TableRecorder) -> None:
+ panel = _panel()
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 3))
+ panel._show_frame(OTHER_FRAME)
+
+ panel._show_frame(SHOWN_FRAME)
+
+ assert recorder.highlighted_rows == {tracker_table_row(3): PLAYBACK_ROW}
+
+ def test_the_cursor_keeps_its_row_on_a_frame_the_playhead_left(self, recorder: _TableRecorder) -> None:
+ """A frame the playhead is away from shows the reader's own cursor on the row it sits on."""
+ panel = _panel()
+ _place_cursor(panel, 3, GeneratorName.PULSE1)
+ panel.set_playing_position(_playhead(SHOWN_FRAME, 3))
+
+ panel._show_frame(OTHER_FRAME)
+
+ assert recorder.highlighted_rows[tracker_table_row(3)] == CURSOR_ROW
+
+
+class TestHeaderRowBackground:
+ def test_every_table_column_of_the_header_takes_the_header_shade(
+ self,
+ recorder: _TableRecorder,
+ ) -> None:
+ panel = _panel()
+ panel._layout = SimpleNamespace(
+ colors=SimpleNamespace(header=SimpleNamespace(background=LiteralColor(HEADER_SHADE)))
+ )
+
+ panel._highlight_header_row()
+
+ painted = {row for row, _ in recorder.highlighted_cells}
+ columns = {column for _, column in recorder.highlighted_cells}
+ assert painted == {HEADER_TABLE_ROW}
+ assert columns == set(range(tracker_module.TRACKER_TABLE_COLUMNS))
+
+ def test_the_header_shade_covers_the_sample_and_channel_columns(
+ self,
+ recorder: _TableRecorder,
+ ) -> None:
+ """The washes are column highlights, which DearPyGui draws over a row highlight, so the
+ header is painted per cell to read as one band."""
+ panel = _panel()
+ panel._layout = SimpleNamespace(
+ colors=SimpleNamespace(header=SimpleNamespace(background=LiteralColor(HEADER_SHADE)))
+ )
+
+ panel._highlight_header_row()
+
+ washed: List[Optional[GeneratorName]] = [None, *GeneratorName.items()]
+ for generator in washed:
+ key = (HEADER_TABLE_ROW, tracker_table_column(generator))
+ assert recorder.highlighted_cells[key] == HEADER_SHADE
diff --git a/tests/unit/sampletones_application/ui/panels/shared/__init__.py b/tests/unit/sampletones_application/ui/panels/shared/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py
new file mode 100644
index 00000000..1eaf6f26
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py
@@ -0,0 +1,437 @@
+import contextlib
+from pathlib import Path
+from typing import Any, Dict, Final, Iterator, List, Optional, Sequence, Tuple
+
+import pytest
+
+from sampletones_application.ui.elements.tree import tree as tree_module
+from sampletones_application.ui.elements.tree.colors import TreeColors
+from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory
+from sampletones_application.ui.elements.tree.tag import compose_node_tag
+from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel
+from sampletones_application.ui.panels.shared import browser as shared_browser_module
+from sampletones_application.utils.palette.colors.literal import LiteralColor
+from sampletones_core.structures.tree.node import FileSystemNode, NodeType, TreeNode
+from tests.suite.language import FakeLanguageManager
+
+PANEL_TAG = "sequencer_browser"
+
+TEXT_COLOR = LiteralColor((128, 128, 128, 255))
+
+EXPAND_LABEL = "Expand all"
+COLLAPSE_LABEL = "Collapse all"
+COPY_NAME_LABEL = "Copy name"
+LOCATE_AUDIO_LABEL = "Locate original audio"
+RECONSTRUCTIONS_LABEL = "Reconstructions"
+
+TEXTS: Final[Dict[str, str]] = {
+ "global.context.label.expand_all": EXPAND_LABEL,
+ "global.context.label.collapse_all": COLLAPSE_LABEL,
+ "global.context.label.copy_name": COPY_NAME_LABEL,
+ "global.context.label.locate_original_audio": LOCATE_AUDIO_LABEL,
+ "global.context.label.detail_reconstructions": RECONSTRUCTIONS_LABEL,
+}
+
+CONTAINER_BUILDERS: Final[Tuple[str, ...]] = (
+ "_add_context_menu_text",
+ "_add_context_menu_reconstruction_count",
+ "_add_context_menu_expansion_items",
+ "_add_context_menu_copy_name_item",
+ "_add_context_menu_sample_audio_item",
+)
+
+
+def _panel() -> GUISequencerBrowserPanel:
+ """Builds a panel without its DearPyGui-dependent constructor.
+
+ The container menu reads the tree, the language manager and the panel tag its node tags are
+ composed under, so a running GUI context is unnecessary.
+ """
+ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel)
+ panel.tag = PANEL_TAG
+ panel._expansion = RowExpansionMemory(set())
+ panel._language_manager = FakeLanguageManager(TEXTS)
+ panel._colors = TreeColors(
+ favorite=TEXT_COLOR,
+ node=TEXT_COLOR,
+ muted=TEXT_COLOR,
+ accent=TEXT_COLOR,
+ )
+ panel.on_locate_original_audio = None
+ return panel
+
+
+def _sample_tree() -> Tuple[TreeNode, TreeNode, Sequence[FileSystemNode]]:
+ """One sample gathering two configuration variants, under a frequency group."""
+ root = TreeNode("root", node_type=NodeType.ROOT)
+ group = TreeNode("44.1 kHz", node_type=NodeType.GROUP, parent=root)
+ sample = TreeNode("kick.wav", node_type=NodeType.SAMPLE, parent=group)
+ variants = [
+ FileSystemNode(
+ name,
+ node_type=NodeType.FILE,
+ filepath=Path("/reconstructions") / name,
+ parent=sample,
+ )
+ for name in ("fft.stn", "cqt.stn")
+ ]
+ return group, sample, variants
+
+
+class _MenuItemRecorder:
+ """Captures the keyword arguments of every menu item the builders register."""
+
+ def __init__(self) -> None:
+ self.items: List[Dict[str, Any]] = []
+ self.separators = 0
+ self.clipboard: List[str] = []
+
+ def add_menu_item(self, **kwargs: Any) -> int:
+ self.items.append(kwargs)
+ return 0
+
+ def add_separator(self, **kwargs: Any) -> int:
+ self.separators += 1
+ return 0
+
+ def set_clipboard_text(self, text: str) -> None:
+ self.clipboard.append(text)
+
+ @property
+ def labels(self) -> List[str]:
+ return [item["label"] for item in self.items]
+
+ def item(self, label: str) -> Dict[str, Any]:
+ return next(item for item in self.items if item["label"] == label)
+
+
+@pytest.fixture
+def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuItemRecorder:
+ instance = _MenuItemRecorder()
+ monkeypatch.setattr(tree_module.dpg, "add_menu_item", instance.add_menu_item)
+ monkeypatch.setattr(tree_module.dpg, "add_separator", instance.add_separator)
+ monkeypatch.setattr(tree_module.dpg, "set_clipboard_text", instance.set_clipboard_text)
+ return instance
+
+
+@pytest.fixture
+def expanded(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]:
+ """Records the tag and open state of every row the expansion items reach."""
+ calls: List[Tuple[str, bool]] = []
+ monkeypatch.setattr(
+ tree_module,
+ "dpg_set_value",
+ lambda tag, value: calls.append((tag, value)),
+ )
+ return calls
+
+
+@pytest.fixture
+def details(monkeypatch: pytest.MonkeyPatch) -> List[Sequence[Tuple[str, str]]]:
+ """Records each block of read-only lines the menu states."""
+ blocks: List[Sequence[Tuple[str, str]]] = []
+ monkeypatch.setattr(
+ shared_browser_module,
+ "add_detail_items",
+ lambda items, **_kwargs: blocks.append(items),
+ )
+ return blocks
+
+
+@pytest.fixture
+def built(monkeypatch: pytest.MonkeyPatch) -> List[str]:
+ """Replaces every container-menu builder with a record of its name, in call order."""
+ names: List[str] = []
+
+ @contextlib.contextmanager
+ def _menu() -> Iterator[None]:
+ yield
+
+ monkeypatch.setattr(shared_browser_module, "context_menu", _menu)
+ return names
+
+
+def _record_builders(
+ panel: GUISequencerBrowserPanel,
+ built: List[str],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ for builder in CONTAINER_BUILDERS:
+ monkeypatch.setattr(panel, builder, lambda _argument, name=builder: built.append(name))
+
+
+class TestContainerMenuComposition:
+ def test_group_row_states_what_it_holds_before_what_it_offers(
+ self,
+ built: List[str],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = _panel()
+ group, _, _ = _sample_tree()
+ _record_builders(panel, built, monkeypatch)
+
+ panel._show_container_context_menu(group)
+
+ assert built == list(CONTAINER_BUILDERS)
+
+ def test_sample_row_offers_the_same_items(
+ self,
+ built: List[str],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ panel = _panel()
+ _, sample, _ = _sample_tree()
+ _record_builders(panel, built, monkeypatch)
+
+ panel._show_container_context_menu(sample)
+
+ assert built == list(CONTAINER_BUILDERS)
+
+ @pytest.mark.parametrize("node_type", [NodeType.FILE, NodeType.DIRECTORY, NodeType.ROOT])
+ def test_row_standing_for_a_path_opens_no_container_menu(
+ self,
+ built: List[str],
+ monkeypatch: pytest.MonkeyPatch,
+ node_type: NodeType,
+ ) -> None:
+ """The rows with a path of their own have menus of their own, offering the path items."""
+ panel = _panel()
+ node = FileSystemNode("kick.stn", node_type=node_type, filepath=Path("/kick.stn"))
+ _record_builders(panel, built, monkeypatch)
+
+ panel._show_container_context_menu(node)
+
+ assert built == []
+
+
+class TestReconstructionCount:
+ def test_sample_row_counts_the_variants_it_gathers(
+ self,
+ details: List[Sequence[Tuple[str, str]]],
+ ) -> None:
+ panel = _panel()
+ _, sample, _ = _sample_tree()
+
+ panel._add_context_menu_reconstruction_count(sample)
+
+ assert details == [[(RECONSTRUCTIONS_LABEL, "2")]]
+
+ def test_group_row_counts_every_reconstruction_below_it(
+ self,
+ details: List[Sequence[Tuple[str, str]]],
+ ) -> None:
+ """A group reports the whole subtree, so the containers between it and the files add nothing."""
+ panel = _panel()
+ group, sample, _ = _sample_tree()
+ second_sample = TreeNode("snare.wav", node_type=NodeType.SAMPLE, parent=group)
+ FileSystemNode(
+ "fft.stn",
+ node_type=NodeType.FILE,
+ filepath=Path("/reconstructions/snare/fft.stn"),
+ parent=second_sample,
+ )
+
+ panel._add_context_menu_reconstruction_count(group)
+
+ assert details == [[(RECONSTRUCTIONS_LABEL, "3")]]
+
+ def test_row_gathering_nothing_reports_no_reconstruction(
+ self,
+ details: List[Sequence[Tuple[str, str]]],
+ ) -> None:
+ panel = _panel()
+ group = TreeNode("44.1 kHz", node_type=NodeType.GROUP)
+
+ panel._add_context_menu_reconstruction_count(group)
+
+ assert details == [[(RECONSTRUCTIONS_LABEL, "0")]]
+
+
+class TestExpansionItems:
+ def test_both_directions_are_offered(self, recorder: _MenuItemRecorder) -> None:
+ panel = _panel()
+ group, _, _ = _sample_tree()
+
+ panel._add_context_menu_expansion_items(group)
+
+ assert recorder.labels == [EXPAND_LABEL, COLLAPSE_LABEL]
+
+ def test_expanding_reaches_the_row_and_every_container_below_it(
+ self,
+ recorder: _MenuItemRecorder,
+ expanded: List[Tuple[str, bool]],
+ ) -> None:
+ panel = _panel()
+ group, sample, _ = _sample_tree()
+
+ panel._add_context_menu_expansion_items(group)
+ recorder.item(EXPAND_LABEL)["callback"]()
+
+ assert expanded == [
+ (compose_node_tag(group, panel_tag=PANEL_TAG), True),
+ (compose_node_tag(sample, panel_tag=PANEL_TAG), True),
+ ]
+
+ def test_collapsing_closes_the_same_rows(
+ self,
+ recorder: _MenuItemRecorder,
+ expanded: List[Tuple[str, bool]],
+ ) -> None:
+ panel = _panel()
+ group, sample, _ = _sample_tree()
+
+ panel._add_context_menu_expansion_items(group)
+ recorder.item(COLLAPSE_LABEL)["callback"]()
+
+ assert expanded == [
+ (compose_node_tag(group, panel_tag=PANEL_TAG), False),
+ (compose_node_tag(sample, panel_tag=PANEL_TAG), False),
+ ]
+
+ def test_the_browser_remembers_the_shape_the_item_left(
+ self,
+ recorder: _MenuItemRecorder,
+ expanded: List[Tuple[str, bool]],
+ ) -> None:
+ """A rebuild brings the subtree back the way the item left it, so what it set is recorded."""
+ panel = _panel()
+ group, sample, _ = _sample_tree()
+ rows = {
+ compose_node_tag(group, panel_tag=PANEL_TAG),
+ compose_node_tag(sample, panel_tag=PANEL_TAG),
+ }
+
+ panel._add_context_menu_expansion_items(group)
+ recorder.item(EXPAND_LABEL)["callback"]()
+
+ assert panel.expanded_rows == rows
+
+ def test_the_browser_forgets_the_shape_the_item_folded(
+ self,
+ recorder: _MenuItemRecorder,
+ expanded: List[Tuple[str, bool]],
+ ) -> None:
+ panel = _panel()
+ group, sample, _ = _sample_tree()
+ panel._expansion = RowExpansionMemory(
+ {
+ compose_node_tag(group, panel_tag=PANEL_TAG),
+ compose_node_tag(sample, panel_tag=PANEL_TAG),
+ }
+ )
+
+ panel._add_context_menu_expansion_items(group)
+ recorder.item(COLLAPSE_LABEL)["callback"]()
+
+ assert panel.expanded_rows == set()
+
+ def test_leaf_rows_are_left_alone(
+ self,
+ recorder: _MenuItemRecorder,
+ expanded: List[Tuple[str, bool]],
+ ) -> None:
+ """A reconstruction row holds nothing to fold, so no expansion state is stated for it."""
+ panel = _panel()
+ _, sample, variants = _sample_tree()
+
+ panel._add_context_menu_expansion_items(sample)
+ recorder.item(EXPAND_LABEL)["callback"]()
+
+ variant_tags = [compose_node_tag(variant, panel_tag=PANEL_TAG) for variant in variants]
+ assert [tag for tag, _ in expanded] == [compose_node_tag(sample, panel_tag=PANEL_TAG)]
+ assert all(tag not in variant_tags for tag, _ in expanded)
+
+
+class TestCopyNameItem:
+ def test_clicking_copies_the_label_the_tree_reads(self, recorder: _MenuItemRecorder) -> None:
+ panel = _panel()
+ group, _, _ = _sample_tree()
+
+ panel._add_context_menu_copy_name_item(group)
+ recorder.item(COPY_NAME_LABEL)["callback"]()
+
+ assert recorder.clipboard == ["44.1 kHz"]
+
+ def test_a_folded_chain_copies_every_level_of_its_label(self, recorder: _MenuItemRecorder) -> None:
+ panel = _panel()
+ folded = TreeNode("44.1 kHz·30 Hz·FFT", node_type=NodeType.GROUP)
+
+ panel._add_context_menu_copy_name_item(folded)
+ recorder.item(COPY_NAME_LABEL)["callback"]()
+
+ assert recorder.clipboard == ["44.1 kHz·30 Hz·FFT"]
+
+
+class TestSampleAudioItem:
+ def test_sample_row_delegates_to_a_reconstruction_below_it(self, recorder: _MenuItemRecorder) -> None:
+ panel = _panel()
+ _, sample, variants = _sample_tree()
+
+ panel._add_context_menu_sample_audio_item(sample)
+
+ assert recorder.labels == [LOCATE_AUDIO_LABEL]
+ assert recorder.item(LOCATE_AUDIO_LABEL)["user_data"] is variants[0]
+
+ def test_clicking_reports_the_reconstruction_path(self, recorder: _MenuItemRecorder) -> None:
+ panel = _panel()
+ _, sample, variants = _sample_tree()
+ located: List[Path] = []
+ panel.on_locate_original_audio = located.append
+
+ panel._add_context_menu_sample_audio_item(sample)
+ item = recorder.item(LOCATE_AUDIO_LABEL)
+ item["callback"](0, None, item["user_data"])
+
+ assert located == [variants[0].filepath]
+
+ def test_group_row_offers_no_audio(self, recorder: _MenuItemRecorder) -> None:
+ """A group gathers reconstructions of many samples, so no one audio stands behind it."""
+ panel = _panel()
+ group, _, _ = _sample_tree()
+
+ panel._add_context_menu_sample_audio_item(group)
+
+ assert recorder.labels == []
+
+ def test_sample_row_holding_no_reconstruction_offers_no_audio(self, recorder: _MenuItemRecorder) -> None:
+ panel = _panel()
+ sample = TreeNode("kick.wav", node_type=NodeType.SAMPLE)
+
+ panel._add_context_menu_sample_audio_item(sample)
+
+ assert recorder.labels == []
+
+
+class TestFirstReconstructionBelow:
+ def test_the_nearest_reconstruction_answers_for_the_row(self) -> None:
+ panel = _panel()
+ _, sample, variants = _sample_tree()
+
+ assert panel._first_reconstruction_below(sample) is variants[0]
+
+ def test_a_row_gathering_none_names_nothing(self) -> None:
+ panel = _panel()
+ sample = TreeNode("kick.wav", node_type=NodeType.SAMPLE)
+
+ assert panel._first_reconstruction_below(sample) is None
+
+ def test_containers_below_the_row_are_passed_over(self) -> None:
+ """A mirrored source folder under a group is not itself a reconstruction."""
+ panel = _panel()
+ group = TreeNode("44.1 kHz", node_type=NodeType.GROUP)
+ directory = FileSystemNode(
+ "drums",
+ node_type=NodeType.DIRECTORY,
+ filepath=Path("/reconstructions/drums"),
+ parent=group,
+ )
+ reconstruction = FileSystemNode(
+ "kick.stn",
+ node_type=NodeType.FILE,
+ filepath=Path("/reconstructions/drums/kick.stn"),
+ parent=directory,
+ )
+
+ found: Optional[FileSystemNode] = panel._first_reconstruction_below(group)
+
+ assert found is reconstruction
diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py
index d26dbe00..a040aac8 100644
--- a/tests/unit/sampletones_application/ui/test_menu.py
+++ b/tests/unit/sampletones_application/ui/test_menu.py
@@ -1,37 +1,52 @@
from contextlib import contextmanager
-from typing import Any, Dict, FrozenSet, Iterator, List
+from typing import Any, Callable, Dict, FrozenSet, Iterator, List, Tuple
import pytest
from sampletones_application.categories.manager import LanguageManager
+from sampletones_application.constants.playback import FollowMode
from sampletones_application.paths import LANG_EN
from sampletones_application.tags.general import (
+ TAG_GLOBAL_MENU_GROUP_EDIT,
+ TAG_GLOBAL_MENU_GROUP_EDIT_MARKER,
TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS,
TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS,
+ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES,
+ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS,
)
from sampletones_application.ui import menu as menu_module
from sampletones_application.ui.menu import MenuBar
from sampletones_application.utils.gui.shortcuts.ids import (
CHANNEL_SHORTCUT_IDS,
+ FOLLOW_MODE_SHORTCUT_IDS,
SAMPLE_EXPORT_SHORTCUT_IDS,
ShortcutId,
)
-from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel
+from sampletones_application.view_model.sequencer.channels import (
+ SequencerChannelsViewModel,
+)
from sampletones_application.view_model.shared.menu import MenuBarViewModel
from sampletones_core.constants.enums import GeneratorName
CHANNEL_NAMES = ["Pulse 1", "Pulse 2", "Triangle", "Noise"]
UNMUTE_ALL = "Unmute all channels"
+FOLLOW_MODE_NAMES = {
+ FollowMode.ROWS: "Follow rows",
+ FollowMode.PATTERNS: "Follow patterns",
+ FollowMode.OFF: "Don't follow",
+}
class _ShortcutManagerRecorder:
"""Records the items a menu asks for, in place of the manager that would create them."""
- def __init__(self) -> None:
+ def __init__(self, built: List[str]) -> None:
self.items: List[Dict[str, Any]] = []
+ self._built = built
def add_menu_item(self, shortcut_id: ShortcutId, **kwargs: Any) -> None:
self.items.append({"shortcut_id": shortcut_id, **kwargs})
+ self._built.append(f"item:{kwargs['label']}")
@property
def labels(self) -> List[str]:
@@ -48,6 +63,10 @@ def __init__(self) -> None:
self.values: Dict[str, bool] = {}
self.enabled: Dict[str, bool] = {}
self.menus: List[Dict[str, Any]] = []
+ self.items: List[Dict[str, Any]] = []
+ self.built: List[str] = []
+ self.containers: List[str] = []
+ self.deleted: List[int] = []
@contextmanager
def menu(self, **kwargs: Any) -> Iterator[int]:
@@ -58,8 +77,38 @@ def submenu(self, tag: str) -> Dict[str, Any]:
return next(entry for entry in self.menus if entry.get("tag") == tag)
def add_separator(self, **kwargs: Any) -> int:
+ self.built.append("separator")
+ return 0
+
+ def add_group(self, *, tag: str) -> int:
+ self.built.append(f"group:{tag}")
+ return 0
+
+ def add_menu_item(self, **kwargs: Any) -> int:
+ self.items.append(kwargs)
+ self.built.append(f"item:{kwargs['label']}")
+ return 0
+
+ @contextmanager
+ def item_handler_registry(self, **kwargs: Any) -> Iterator[int]:
+ yield 0
+
+ def add_item_visible_handler(self, **kwargs: Any) -> int:
return 0
+ def bind_item_handler_registry(self, item: str, registry: str) -> None:
+ return None
+
+ def append_items(self, tag: str, build: Callable[[], None]) -> Tuple[int, ...]:
+ """Stands in for the helper that reports what one build left in the container."""
+ self.containers.append(tag)
+ standing = len(self.items)
+ build()
+ return tuple(range(standing, len(self.items)))
+
+ def delete_item(self, item: int) -> None:
+ self.deleted.append(item)
+
def set_value(self, item: str, value: bool) -> None:
self.values[item] = value
@@ -71,6 +120,9 @@ def _state(
muted: FrozenSet[GeneratorName],
*,
reconstruction_loaded: bool = False,
+ follow_mode: FollowMode = FollowMode.OFF,
+ auto_expand_favorite_reconstructions: bool = False,
+ auto_expand_favorite_directories: bool = False,
) -> MenuBarViewModel:
return MenuBarViewModel(
project_open=True,
@@ -79,6 +131,7 @@ def _state(
reconstruction_in_project=False,
reconstruction_file_backed=False,
reconstruction_audio_recorded=False,
+ operation_active=False,
can_undo=False,
can_redo=False,
play_label="Play",
@@ -89,11 +142,13 @@ def _state(
player_paused=False,
stop_enabled=False,
autoplay=False,
- follow_playback=False,
+ follow_mode=follow_mode,
loop_song=False,
channels=SequencerChannelsViewModel(muted=muted),
fullscreen=False,
advanced_settings=False,
+ auto_expand_favorite_reconstructions=auto_expand_favorite_reconstructions,
+ auto_expand_favorite_directories=auto_expand_favorite_directories,
)
@@ -102,22 +157,43 @@ def framework(monkeypatch: pytest.MonkeyPatch) -> _DearPyGuiRecorder:
instance = _DearPyGuiRecorder()
monkeypatch.setattr(menu_module.dpg, "menu", instance.menu)
monkeypatch.setattr(menu_module.dpg, "add_separator", instance.add_separator)
+ monkeypatch.setattr(menu_module.dpg, "add_menu_item", instance.add_menu_item)
+ monkeypatch.setattr(menu_module.dpg, "add_group", instance.add_group)
+ monkeypatch.setattr(menu_module.dpg, "item_handler_registry", instance.item_handler_registry)
+ monkeypatch.setattr(menu_module.dpg, "add_item_visible_handler", instance.add_item_visible_handler)
+ monkeypatch.setattr(menu_module.dpg, "bind_item_handler_registry", instance.bind_item_handler_registry)
monkeypatch.setattr(menu_module, "dpg_set_value", instance.set_value)
monkeypatch.setattr(menu_module, "dpg_configure_item", instance.configure_item)
+ monkeypatch.setattr(menu_module, "dpg_append_items", instance.append_items)
+ monkeypatch.setattr(menu_module, "dpg_delete_item", instance.delete_item)
return instance
@pytest.fixture
-def shortcuts() -> _ShortcutManagerRecorder:
- return _ShortcutManagerRecorder()
+def shortcuts(framework: _DearPyGuiRecorder) -> _ShortcutManagerRecorder:
+ return _ShortcutManagerRecorder(framework.built)
@pytest.fixture
-def menu_bar(shortcuts: _ShortcutManagerRecorder) -> MenuBar:
- """A bar with the collaborators its Channels submenu reads, from the real language file."""
+def switched() -> List[GeneratorName]:
+ """The channels the bar asks the sequencer to switch, in the order it asks."""
+ return []
+
+
+@pytest.fixture
+def menu_bar(
+ shortcuts: _ShortcutManagerRecorder,
+ switched: List[GeneratorName],
+) -> MenuBar:
+ """A bar with the collaborators its submenus read, from the real language file."""
instance = MenuBar.__new__(MenuBar)
instance._shortcut_manager = shortcuts
instance._language_manager = LanguageManager(LANG_EN)
+ instance._on_channel_muted = switched.append
+ instance._build_edit_actions = lambda: False
+ instance._edit_actions_handler_tag = "handlers"
+ instance._edit_actions_frame = None
+ instance._edit_action_items = ()
return instance
@@ -161,6 +237,70 @@ def test_the_submenu_is_offered_once_a_reconstruction_is_loaded(
assert framework.submenu(TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS)["enabled"] is True
+class TestFollowMenuItems:
+ """The three reaches stand as one choice, so the check names the reach in place."""
+
+ def test_every_reach_is_offered(
+ self,
+ menu_bar: MenuBar,
+ shortcuts: _ShortcutManagerRecorder,
+ framework: _DearPyGuiRecorder,
+ ) -> None:
+ menu_bar._create_follow_menu(_state(frozenset()))
+
+ assert shortcuts.labels == list(FOLLOW_MODE_NAMES.values())
+
+ def test_each_reach_carries_its_own_action(
+ self,
+ menu_bar: MenuBar,
+ shortcuts: _ShortcutManagerRecorder,
+ framework: _DearPyGuiRecorder,
+ ) -> None:
+ menu_bar._create_follow_menu(_state(frozenset()))
+
+ actions = [item["shortcut_id"] for item in shortcuts.items]
+ assert actions == list(FOLLOW_MODE_SHORTCUT_IDS.values())
+
+ def test_each_reach_carries_its_own_tag(
+ self,
+ menu_bar: MenuBar,
+ shortcuts: _ShortcutManagerRecorder,
+ framework: _DearPyGuiRecorder,
+ ) -> None:
+ menu_bar._create_follow_menu(_state(frozenset()))
+
+ tags = [item["tag"] for item in shortcuts.items]
+ assert tags == [MenuBar._follow_menu_item_tag(mode) for mode in FOLLOW_MODE_SHORTCUT_IDS]
+
+ @pytest.mark.parametrize("mode", list(FollowMode), ids=str)
+ def test_the_reach_in_place_is_the_one_checked(
+ self,
+ menu_bar: MenuBar,
+ shortcuts: _ShortcutManagerRecorder,
+ framework: _DearPyGuiRecorder,
+ mode: FollowMode,
+ ) -> None:
+ menu_bar._create_follow_menu(_state(frozenset(), follow_mode=mode))
+
+ checked = [item["label"] for item in shortcuts.items if item["default_value"]]
+ assert checked == [FOLLOW_MODE_NAMES[mode]]
+
+
+class TestFollowMenuUpdate:
+ @pytest.mark.parametrize("mode", list(FollowMode), ids=str)
+ def test_the_check_moves_to_the_reach_in_place(
+ self,
+ menu_bar: MenuBar,
+ framework: _DearPyGuiRecorder,
+ mode: FollowMode,
+ ) -> None:
+ menu_bar._update_follow_mode(_state(frozenset(), follow_mode=mode))
+
+ assert framework.values == {
+ MenuBar._follow_menu_item_tag(candidate): candidate is mode for candidate in FollowMode
+ }
+
+
class TestChannelsMenuItems:
def test_every_channel_is_named_in_the_tracker_order(
self,
@@ -183,6 +323,22 @@ def test_each_channel_carries_its_own_action(
actions = [item["shortcut_id"] for item in shortcuts.items[:-1]]
assert actions == list(CHANNEL_SHORTCUT_IDS.values())
+ def test_choosing_a_channel_switches_the_sequencer_mix(
+ self,
+ menu_bar: MenuBar,
+ shortcuts: _ShortcutManagerRecorder,
+ framework: _DearPyGuiRecorder,
+ switched: List[GeneratorName],
+ ) -> None:
+ """The check beside an item names the sequencer's mix, so the item switches that mix
+ wherever the reader stands, while the key printed beside it reads the tab in front."""
+ menu_bar._create_channels_menu(_state(frozenset()))
+
+ for item in shortcuts.items[:-1]:
+ item["callback"]()
+
+ assert switched == list(CHANNEL_SHORTCUT_IDS)
+
def test_each_channel_carries_its_own_tag(
self,
menu_bar: MenuBar,
@@ -258,3 +414,187 @@ def test_a_full_mix_withholds_the_restore(
menu_bar._update_channels(_state(frozenset()))
assert framework.enabled == {TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS: False}
+
+
+def _edit_bar(build_edit_actions: Callable[[], bool]) -> MenuBar:
+ """A bar holding what the Edit menu's action section reads, and nothing else."""
+ instance = MenuBar.__new__(MenuBar)
+ instance._language_manager = LanguageManager(LANG_EN)
+ instance._build_edit_actions = build_edit_actions
+ instance._edit_actions_frame = None
+ instance._edit_action_items = ()
+ return instance
+
+
+class TestAutoExpandFavoritesMenu:
+ """Each kind of favorite is answered on its own, so the submenu offers one item per kind."""
+
+ def test_both_kinds_are_offered(
+ self,
+ menu_bar: MenuBar,
+ shortcuts: _ShortcutManagerRecorder,
+ ) -> None:
+ menu_bar._create_auto_expand_favorites_menu()
+
+ assert shortcuts.labels == ["Reconstructions", "Directories"]
+
+ def test_each_kind_carries_its_own_action(
+ self,
+ menu_bar: MenuBar,
+ shortcuts: _ShortcutManagerRecorder,
+ ) -> None:
+ menu_bar._create_auto_expand_favorites_menu()
+
+ assert [item["shortcut_id"] for item in shortcuts.items] == [
+ ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS,
+ ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES,
+ ]
+
+ def test_each_kind_is_offered_as_a_check(
+ self,
+ menu_bar: MenuBar,
+ shortcuts: _ShortcutManagerRecorder,
+ ) -> None:
+ menu_bar._create_auto_expand_favorites_menu()
+
+ assert all(item["check"] for item in shortcuts.items)
+
+ def test_the_submenu_is_named_by_what_it_governs(
+ self,
+ menu_bar: MenuBar,
+ framework: _DearPyGuiRecorder,
+ ) -> None:
+ menu_bar._create_auto_expand_favorites_menu()
+
+ assert [entry["label"] for entry in framework.menus] == ["Auto-expand favorites"]
+
+
+class TestAutoExpandFavoritesUpdate:
+ @pytest.mark.parametrize("reconstructions", [True, False])
+ @pytest.mark.parametrize("directories", [True, False])
+ def test_each_check_reads_the_preference_in_place(
+ self,
+ menu_bar: MenuBar,
+ framework: _DearPyGuiRecorder,
+ reconstructions: bool,
+ directories: bool,
+ ) -> None:
+ menu_bar._update_auto_expand_favorites(
+ _state(
+ frozenset(),
+ auto_expand_favorite_reconstructions=reconstructions,
+ auto_expand_favorite_directories=directories,
+ )
+ )
+
+ assert framework.values == {
+ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS: reconstructions,
+ TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES: directories,
+ }
+
+
+class TestEditActionsSection:
+ """The Edit menu carries the actions of the grid holding the cursor, and names them itself
+ while no grid holds one."""
+
+ def test_the_clipboard_actions_are_named_greyed_out_with_no_grid_focused(
+ self,
+ framework: _DearPyGuiRecorder,
+ ) -> None:
+ _edit_bar(lambda: False)._refresh_edit_actions()
+
+ assert [item["label"] for item in framework.items] == ["Copy", "Cut", "Paste", "Delete"]
+ assert [item["enabled"] for item in framework.items] == [False] * 4
+
+ def test_a_focused_grid_states_its_own_actions(
+ self,
+ framework: _DearPyGuiRecorder,
+ ) -> None:
+ requests: List[bool] = []
+
+ def build() -> bool:
+ requests.append(True)
+ return True
+
+ _edit_bar(build)._refresh_edit_actions()
+
+ assert requests == [True]
+ assert framework.items == []
+
+ def test_the_actions_are_stated_into_the_menu_itself(
+ self,
+ framework: _DearPyGuiRecorder,
+ ) -> None:
+ _edit_bar(lambda: False)._refresh_edit_actions()
+
+ assert framework.containers == [TAG_GLOBAL_MENU_GROUP_EDIT]
+
+ def test_a_build_takes_away_only_what_the_one_before_it_stated(
+ self,
+ framework: _DearPyGuiRecorder,
+ ) -> None:
+ menu_bar = _edit_bar(lambda: False)
+
+ menu_bar._refresh_edit_actions()
+ menu_bar._refresh_edit_actions()
+
+ assert framework.deleted == [0, 1, 2, 3]
+
+
+class TestEditMenuOrder:
+ """The marker leads the Edit menu. A container standing below a menu item takes the width the
+ items span as its own, and the popup grows to fit it on every frame it stays open."""
+
+ def test_the_marker_stands_before_every_item(
+ self,
+ menu_bar: MenuBar,
+ framework: _DearPyGuiRecorder,
+ shortcuts: _ShortcutManagerRecorder,
+ ) -> None:
+ menu_bar._create_edit_menu(_state(frozenset()))
+
+ assert framework.built == [
+ f"group:{TAG_GLOBAL_MENU_GROUP_EDIT_MARKER}",
+ "item:Undo",
+ "item:Redo",
+ "separator",
+ "item:Copy",
+ "item:Cut",
+ "item:Paste",
+ "item:Delete",
+ ]
+
+
+class TestEditActionsRefresh:
+ """DearPyGui reports the section drawn once a frame while the menu stands open, so a gap in
+ those reports is what marks a fresh opening."""
+
+ def test_the_actions_are_stated_once_while_the_menu_stays_open(
+ self,
+ framework: _DearPyGuiRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ frames = iter([10, 11, 12, 13])
+ monkeypatch.setattr(menu_module.dpg, "get_frame_count", lambda: next(frames))
+ requests: List[int] = []
+ menu_bar = _edit_bar(lambda: bool(requests.append(1)))
+
+ for _ in range(4):
+ menu_bar._on_edit_actions_drawn(0, 0)
+
+ assert len(requests) == 1
+
+ def test_the_actions_are_stated_afresh_each_time_the_menu_is_opened(
+ self,
+ framework: _DearPyGuiRecorder,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ frames = iter([10, 11, 40, 41])
+ monkeypatch.setattr(menu_module.dpg, "get_frame_count", lambda: next(frames))
+ requests: List[int] = []
+ menu_bar = _edit_bar(lambda: bool(requests.append(1)))
+
+ for _ in range(4):
+ menu_bar._on_edit_actions_drawn(0, 0)
+
+ assert len(requests) == 2
diff --git a/tests/unit/sampletones_application/ui/themes/test_inline.py b/tests/unit/sampletones_application/ui/themes/test_inline.py
index 1f08146b..843cf25e 100644
--- a/tests/unit/sampletones_application/ui/themes/test_inline.py
+++ b/tests/unit/sampletones_application/ui/themes/test_inline.py
@@ -7,11 +7,16 @@
create_header_selectable_theme,
create_selectable_text_theme,
)
+from sampletones_application.utils.palette.colors.written import LiteralColor
from sampletones_shared.types.application import ColorRGBA
-TEXT_COLOR: ColorRGBA = (220, 220, 220, 255)
-HOVERED_COLOR: ColorRGBA = (255, 255, 255, 64)
-ACTIVE_COLOR: ColorRGBA = (255, 255, 255, 102)
+TEXT_RGBA: ColorRGBA = (220, 220, 220, 255)
+HOVERED_RGBA: ColorRGBA = (255, 255, 255, 64)
+ACTIVE_RGBA: ColorRGBA = (255, 255, 255, 102)
+
+TEXT_COLOR = LiteralColor(TEXT_RGBA)
+HOVERED_COLOR = LiteralColor(HOVERED_RGBA)
+ACTIVE_COLOR = LiteralColor(ACTIVE_RGBA)
ENABLED_STATES = (True, False)
@@ -51,13 +56,13 @@ def test_the_text_colour_is_the_theme_s_whole_claim(self, context: None) -> None
"""A cell keeps the hover and selection shades of the table it sits in."""
theme = create_selectable_text_theme(TEXT_COLOR)
- assert _colors(theme, enabled_state=True) == {dpg.mvThemeCol_Text: TEXT_COLOR}
+ assert _colors(theme, enabled_state=True) == {dpg.mvThemeCol_Text: TEXT_RGBA}
@pytest.mark.parametrize("enabled_state", ENABLED_STATES, ids=["enabled", "disabled"])
def test_both_enabled_states_carry_the_colour(self, context: None, enabled_state: bool) -> None:
theme = create_selectable_text_theme(TEXT_COLOR)
- assert _colors(theme, enabled_state=enabled_state)[dpg.mvThemeCol_Text] == TEXT_COLOR
+ assert _colors(theme, enabled_state=enabled_state)[dpg.mvThemeCol_Text] == TEXT_RGBA
def test_the_theme_addresses_selectables(self, context: None) -> None:
theme = create_selectable_text_theme(TEXT_COLOR)
@@ -71,9 +76,9 @@ def test_the_label_carries_its_text_and_pointer_shades(self, context: None, enab
theme = create_header_selectable_theme(TEXT_COLOR, HOVERED_COLOR, ACTIVE_COLOR)
assert _colors(theme, enabled_state=enabled_state) == {
- dpg.mvThemeCol_Text: TEXT_COLOR,
- dpg.mvThemeCol_HeaderHovered: HOVERED_COLOR,
- dpg.mvThemeCol_HeaderActive: ACTIVE_COLOR,
+ dpg.mvThemeCol_Text: TEXT_RGBA,
+ dpg.mvThemeCol_HeaderHovered: HOVERED_RGBA,
+ dpg.mvThemeCol_HeaderActive: ACTIVE_RGBA,
}
def test_the_resting_shade_stays_with_the_table(self, context: None) -> None:
diff --git a/tests/unit/sampletones_application/ui/themes/test_loader.py b/tests/unit/sampletones_application/ui/themes/test_loader.py
index 945d3ee2..2dbdcf40 100644
--- a/tests/unit/sampletones_application/ui/themes/test_loader.py
+++ b/tests/unit/sampletones_application/ui/themes/test_loader.py
@@ -4,12 +4,14 @@
import dearpygui.dearpygui as dpg
import pytest
-from sampletones_application.paths import PALETTE_PATH, THEME_DIRECTORY
+from sampletones_application.paths import PALETTES_DIRECTORY, THEME_DIRECTORY
from sampletones_application.tags.general import TAG_GLOBAL_THEME_DEFAULT
from sampletones_application.ui.themes.loader import ThemeLoader
from sampletones_application.ui.themes.spec import ThemeSpec
from sampletones_application.ui.themes.theme import Theme
-from sampletones_application.utils.palette import Palette
+from sampletones_application.utils.palette.catalog import PaletteCatalog
+from sampletones_application.utils.palette.palette import Palette
+from sampletones_application.utils.palette.source import PaletteSource
_BASE_NAME = "default"
@@ -68,7 +70,8 @@ class TestLoadedInheritance:
@pytest.fixture
def themes(self) -> Dict[str, Theme]:
- return {theme.tag: theme for theme in ThemeLoader(THEME_DIRECTORY, Palette.load(PALETTE_PATH)).load_all()}
+ source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)
+ return {theme.tag: theme for theme in ThemeLoader(THEME_DIRECTORY, source).load_all()}
def test_every_theme_carries_the_base_table_border(self, themes: Dict[str, Theme]) -> None:
dpg.create_context()
@@ -105,10 +108,9 @@ def test_a_theme_keeps_its_own_override_on_top_of_the_base(self, themes: Dict[st
finally:
dpg.destroy_context()
- def test_the_tracker_theme_swaps_the_base_row_stripes(self, themes: Dict[str, Theme]) -> None:
- """The tracker's clickable header is an ordinary row, which advances DearPyGui's
- zebra counter, so the tracker theme swaps the two stripes to land pattern row 0 on
- the shade every other table gives its first row.
+ def test_the_tracker_theme_stands_the_pattern_on_one_even_ground(self, themes: Dict[str, Theme]) -> None:
+ """The tracker gives both stripes the same shade, leaving the row background free to
+ carry the beat and bar grouping that tells the pattern's rows apart.
"""
dpg.create_context()
try:
@@ -117,14 +119,9 @@ def test_the_tracker_theme_swaps_the_base_row_stripes(self, themes: Dict[str, Th
base.create()
pattern.create()
- assert pattern.get_color(dpg.mvTable, dpg.mvThemeCol_TableRowBg) == base.get_color(
- dpg.mvTable,
- dpg.mvThemeCol_TableRowBgAlt,
- )
- assert pattern.get_color(dpg.mvTable, dpg.mvThemeCol_TableRowBgAlt) == base.get_color(
- dpg.mvTable,
- dpg.mvThemeCol_TableRowBg,
- )
+ row = pattern.get_color(dpg.mvTable, dpg.mvThemeCol_TableRowBg)
+ assert row == pattern.get_color(dpg.mvTable, dpg.mvThemeCol_TableRowBgAlt)
+ assert row == base.get_color(dpg.mvTable, dpg.mvThemeCol_TableRowBg)
finally:
dpg.destroy_context()
@@ -148,7 +145,7 @@ def synthetic_theme(self, tmp_path: Path) -> Generator[Theme, None, None]:
palette_path.write_text(_SYNTHETIC_PALETTE)
dpg.create_context()
try:
- theme = ThemeLoader(themes_path, Palette.load(palette_path)).load_all()[0]
+ theme = ThemeLoader(themes_path, PaletteSource(Palette.load(palette_path))).load_all()[0]
theme.create()
yield theme
finally:
diff --git a/tests/unit/sampletones_application/ui/themes/test_registry.py b/tests/unit/sampletones_application/ui/themes/test_registry.py
new file mode 100644
index 00000000..6fa322fe
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/themes/test_registry.py
@@ -0,0 +1,61 @@
+from typing import Iterator
+
+import pytest
+
+from sampletones_application.ui.themes.items import ThemeItems
+from sampletones_application.ui.themes.registry import ThemeRegistry
+from sampletones_application.ui.themes.theme import Theme
+
+
+def _theme(tag: str) -> Theme:
+ return Theme(tag=tag, items=ThemeItems())
+
+
+@pytest.fixture(autouse=True)
+def registry() -> Iterator[None]:
+ ThemeRegistry.clear()
+ yield
+ ThemeRegistry.clear()
+
+
+class TestRegisteredThemes:
+ def test_a_registered_theme_is_found_by_its_tag(self) -> None:
+ theme = _theme("global.theme.default")
+ ThemeRegistry.register(theme)
+
+ assert ThemeRegistry.get("global.theme.default") is theme
+
+ def test_an_unregistered_tag_raises(self) -> None:
+ with pytest.raises(KeyError):
+ ThemeRegistry.get("global.theme.default")
+
+ def test_each_tag_finds_its_own_theme(self) -> None:
+ default = _theme("global.theme.default")
+ table = _theme("global.theme.table")
+ ThemeRegistry.register(default)
+ ThemeRegistry.register(table)
+
+ assert (ThemeRegistry.get(default.tag), ThemeRegistry.get(table.tag)) == (
+ default,
+ table,
+ )
+
+ def test_registering_a_tag_twice_keeps_the_later_theme(self) -> None:
+ replacement = _theme("global.theme.default")
+ ThemeRegistry.register(_theme("global.theme.default"))
+ ThemeRegistry.register(replacement)
+
+ assert ThemeRegistry.get("global.theme.default") is replacement
+
+ def test_a_theme_given_by_hand_is_taken_over_the_default_tag(self) -> None:
+ default = _theme("global.theme.default")
+ given = _theme("global.theme.table")
+ ThemeRegistry.register(default)
+
+ assert ThemeRegistry.resolve(given, default.tag) is given
+
+ def test_the_default_tag_answers_when_no_theme_is_given(self) -> None:
+ default = _theme("global.theme.default")
+ ThemeRegistry.register(default)
+
+ assert ThemeRegistry.resolve(None, default.tag) is default
diff --git a/tests/unit/sampletones_application/ui/themes/test_theme.py b/tests/unit/sampletones_application/ui/themes/test_theme.py
new file mode 100644
index 00000000..396ebe55
--- /dev/null
+++ b/tests/unit/sampletones_application/ui/themes/test_theme.py
@@ -0,0 +1,124 @@
+from pathlib import Path
+from typing import Dict, Generator, NamedTuple
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.ui.themes.loader import ThemeLoader
+from sampletones_application.ui.themes.theme import Theme
+from sampletones_application.utils.gui.palette.palette import PaletteBindings
+from sampletones_application.utils.palette.palette import Palette
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_shared.types.application import ColorRGBA
+
+_THEME = """
+name: default
+tag: synthetic.default
+components:
+ - item_type: All
+ entries:
+ - type: color
+ key: Text
+ value: .text
+ - type: color
+ key: WindowBg
+ value: "#242424ff"
+"""
+
+_STUDIO = """
+name: studio
+
+colors:
+ text: "#dcdcdc"
+"""
+
+_LIGHT = """
+name: light
+
+colors:
+ text: "#1e1e24"
+"""
+
+STUDIO_TEXT: ColorRGBA = (220, 220, 220, 255)
+LIGHT_TEXT: ColorRGBA = (30, 30, 36, 255)
+LITERAL_BACKGROUND: ColorRGBA = (36, 36, 36, 255)
+
+
+class _Styled(NamedTuple):
+ """A created theme and the source whose palette its colours read."""
+
+ theme: Theme
+ source: PaletteSource
+
+
+def _live_colors(theme: Theme) -> Dict[int, ColorRGBA]:
+ """The colours DearPyGui holds for the theme's enabled ``All`` component, keyed by target."""
+ component = dpg.get_item_children(theme.tag, slot=1)[0]
+ return {
+ dpg.get_item_configuration(entry)["target"]: tuple(int(channel) for channel in dpg.get_value(entry))
+ for entry in dpg.get_item_children(component, slot=1)
+ }
+
+
+@pytest.fixture
+def styled(tmp_path: Path) -> Generator[_Styled, None, None]:
+ themes_path = tmp_path / "themes"
+ themes_path.mkdir(parents=True, exist_ok=True)
+ (themes_path / "default.yaml").write_text(_THEME)
+ (tmp_path / "studio.yaml").write_text(_STUDIO)
+ (tmp_path / "light.yaml").write_text(_LIGHT)
+
+ source = PaletteSource(Palette.load(tmp_path / "studio.yaml"))
+ dpg.create_context()
+ try:
+ theme = ThemeLoader(themes_path, source).load_all()[0]
+ theme.create()
+ yield _Styled(theme=theme, source=source)
+ finally:
+ dpg.destroy_context()
+
+
+@pytest.fixture
+def light(tmp_path: Path) -> Palette:
+ return Palette.load(tmp_path / "light.yaml")
+
+
+class TestCreate:
+ def test_the_theme_is_built_once_for_its_tag(self, styled: _Styled) -> None:
+ components = dpg.get_item_children(styled.theme.tag, slot=1)
+
+ styled.theme.create()
+
+ assert dpg.get_item_children(styled.theme.tag, slot=1) == components
+
+ def test_a_referenced_colour_reaches_dearpygui_resolved(self, styled: _Styled) -> None:
+ assert _live_colors(styled.theme)[dpg.mvThemeCol_Text] == STUDIO_TEXT
+
+
+class TestRestyle:
+ """A palette swap reaches themed widgets by rewriting the colour items already created."""
+
+ def test_a_referenced_colour_takes_the_newly_activated_palette(
+ self,
+ styled: _Styled,
+ light: Palette,
+ ) -> None:
+ styled.source.activate(light)
+ PaletteBindings.apply()
+
+ assert _live_colors(styled.theme)[dpg.mvThemeCol_Text] == LIGHT_TEXT
+
+ def test_a_literal_colour_stays_as_written(self, styled: _Styled, light: Palette) -> None:
+ styled.source.activate(light)
+ PaletteBindings.apply()
+
+ assert _live_colors(styled.theme)[dpg.mvThemeCol_WindowBg] == LITERAL_BACKGROUND
+
+ def test_the_reported_colour_follows_the_palette_before_any_restyle(
+ self,
+ styled: _Styled,
+ light: Palette,
+ ) -> None:
+ styled.source.activate(light)
+
+ assert styled.theme.get_color(dpg.mvAll, dpg.mvThemeCol_Text) == LIGHT_TEXT
diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py
index 9112fe24..b2f46114 100644
--- a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py
+++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_backend.py
@@ -12,7 +12,9 @@
MINIMUM_FILE_CHOOSER_VERSION,
PortalBackend,
)
-from sampletones_application.utils.file_dialogs.backends.portal.response import ChooserResult
+from sampletones_application.utils.file_dialogs.backends.portal.response import (
+ ChooserResult,
+)
from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant
from sampletones_application.utils.file_dialogs.destination import SaveDestination
from sampletones_application.utils.file_dialogs.filter import FileFilter
@@ -93,7 +95,10 @@ def test_the_dialog_opens_on_the_first_offered_type(self) -> None:
)
options = client.calls[0][2]
- assert options[CURRENT_FILTER_OPTION] == ("(sa(us))", ("FamiTracker instrument (*.fti)", [(0, "*.fti")]))
+ assert options[CURRENT_FILTER_OPTION] == (
+ "(sa(us))",
+ ("FamiTracker instrument (*.fti)", [(0, "*.fti")]),
+ )
assert CURRENT_NAME_OPTION not in options
assert CURRENT_FOLDER_OPTION not in options
diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py
index 7342d00e..544ee464 100644
--- a/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py
+++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
from collections import deque
from contextlib import contextmanager
from types import SimpleNamespace
@@ -14,7 +16,9 @@
RESPONSE_SIGNAL,
FileChooserClient,
)
-from sampletones_application.utils.file_dialogs.backends.portal.response import ChooserResult
+from sampletones_application.utils.file_dialogs.backends.portal.response import (
+ ChooserResult,
+)
from sampletones_application.utils.file_dialogs.backends.portal.variant import Variant
HANDLE: Final[str] = "/org/freedesktop/portal/desktop/request/1_42/sampletones"
@@ -82,7 +86,7 @@ def __init__(
self.rules: List[object] = []
self.closed = False
- def __enter__(self) -> "FakeConnection":
+ def __enter__(self) -> FakeConnection:
return self
def __exit__(self, *arguments: object) -> None:
@@ -188,7 +192,11 @@ def test_another_request_s_response_is_passed_over(self, monkeypatch: pytest.Mon
connection = FakeConnection(
replies=[_message(("ok",)), _message(("ok",)), _message((HANDLE,))],
signals=[
- _response(0, {"uris": ("as", ["file:///elsewhere/other.json"])}, path=OTHER_HANDLE),
+ _response(
+ 0,
+ {"uris": ("as", ["file:///elsewhere/other.json"])},
+ path=OTHER_HANDLE,
+ ),
_response(0, {"uris": ("as", ["file:///home/user/kick.json"])}),
],
)
diff --git a/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py
index 1673117c..794a70a2 100644
--- a/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py
+++ b/tests/unit/sampletones_application/utils/file_dialogs/backends/test_command.py
@@ -1,7 +1,9 @@
from pathlib import Path
from unittest.mock import MagicMock, patch
-from sampletones_application.utils.file_dialogs.backends.command import run_dialog_command
+from sampletones_application.utils.file_dialogs.backends.command import (
+ run_dialog_command,
+)
MODULE = "sampletones_application.utils.file_dialogs.backends.command"
@@ -29,7 +31,11 @@ def test_the_tool_answers_on_standard_output(self) -> None:
with patch(f"{MODULE}.subprocess.run", return_value=_completed("/audio/clip.wav")) as run:
run_dialog_command(COMMAND)
- assert run.call_args.kwargs == {"capture_output": True, "text": True, "check": False}
+ assert run.call_args.kwargs == {
+ "capture_output": True,
+ "text": True,
+ "check": False,
+ }
def test_surrounding_whitespace_leaves_the_path(self) -> None:
with patch(f"{MODULE}.subprocess.run", return_value=_completed(" /audio/clip.wav \n")):
diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_api.py b/tests/unit/sampletones_application/utils/file_dialogs/test_api.py
index cf58b61f..c0c4e81f 100644
--- a/tests/unit/sampletones_application/utils/file_dialogs/test_api.py
+++ b/tests/unit/sampletones_application/utils/file_dialogs/test_api.py
@@ -37,7 +37,11 @@ def __init__(
self.calls: List[Call] = []
def open_file(
- self, *, title: str, initial_directory: Optional[Path], filters: Tuple[FileFilter, ...]
+ self,
+ *,
+ title: str,
+ initial_directory: Optional[Path],
+ filters: Tuple[FileFilter, ...],
) -> Optional[Path]:
self.calls.append(("open", title, initial_directory, filters))
return self._result
@@ -113,7 +117,9 @@ def test_a_typed_extension_stands_over_the_reported_type(self) -> None:
assert result == Path("/home/user/kick.fti")
- def test_an_extension_outside_the_offered_types_takes_the_reported_one(self) -> None:
+ def test_an_extension_outside_the_offered_types_takes_the_reported_one(
+ self,
+ ) -> None:
backend = FakeBackend(Path("/home/user/kick.xm"), reported_type=PRESET_FILTER)
with patch(f"{MODULE}.select_file_dialog_backend", return_value=backend):
result = save_file_dialog(title="Save", filters=INSTRUMENT_FILTERS)
diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_filter.py b/tests/unit/sampletones_application/utils/file_dialogs/test_filter.py
index c93d0f92..c53ec799 100644
--- a/tests/unit/sampletones_application/utils/file_dialogs/test_filter.py
+++ b/tests/unit/sampletones_application/utils/file_dialogs/test_filter.py
@@ -61,7 +61,10 @@ def test_a_type_names_the_extensions_it_matches(
((FAMITRACKER_INSTRUMENT,), FAMITRACKER_INSTRUMENT),
(
(FAMITRACKER_INSTRUMENT, BITPHASE_PRESET),
- FileFilter(name="FamiTracker instrument, Bitphase preset", patterns=("*.fti", "*.json")),
+ FileFilter(
+ name="FamiTracker instrument, Bitphase preset",
+ patterns=("*.fti", "*.json"),
+ ),
),
],
)
diff --git a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py
index dcbf9424..a16487f2 100644
--- a/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py
+++ b/tests/unit/sampletones_application/utils/file_dialogs/test_selection.py
@@ -6,11 +6,17 @@
import pytest
from sampletones_application.utils.file_dialogs.backends.kdialog import KDialogBackend
-from sampletones_application.utils.file_dialogs.backends.portal.backend import PortalBackend
-from sampletones_application.utils.file_dialogs.backends.portal.client import FileChooserClient
+from sampletones_application.utils.file_dialogs.backends.portal.backend import (
+ PortalBackend,
+)
+from sampletones_application.utils.file_dialogs.backends.portal.client import (
+ FileChooserClient,
+)
from sampletones_application.utils.file_dialogs.backends.tkinter import TkinterBackend
from sampletones_application.utils.file_dialogs.backends.zenity import ZenityBackend
-from sampletones_application.utils.file_dialogs.selection import select_file_dialog_backend
+from sampletones_application.utils.file_dialogs.selection import (
+ select_file_dialog_backend,
+)
from sampletones_shared.exceptions import FileDialogUnavailableError
from sampletones_shared.utils.system.system import System
@@ -89,7 +95,10 @@ def test_kde_without_kdialog_falls_back_to_zenity(self) -> None:
def test_no_linux_tools_uses_tkinter(self) -> None:
with (
patch(f"{MODULE}.System.current", return_value=System.LINUX),
- patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=False, zenity=False)),
+ patch(
+ f"{MODULE}.shutil.which",
+ side_effect=_which(kdialog=False, zenity=False),
+ ),
_portal(None),
patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=False),
):
@@ -99,7 +108,10 @@ def test_linux_tools_win_over_missing_tkinter(self) -> None:
with (
patch(f"{MODULE}.System.current", return_value=System.LINUX),
patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=True, zenity=True)),
- patch(f"{MODULE}.importlib.util.find_spec", side_effect=_find_spec(available=False)),
+ patch(
+ f"{MODULE}.importlib.util.find_spec",
+ side_effect=_find_spec(available=False),
+ ),
patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "KDE"}, clear=False),
):
assert isinstance(select_file_dialog_backend(), KDialogBackend)
@@ -107,8 +119,14 @@ def test_linux_tools_win_over_missing_tkinter(self) -> None:
def test_no_linux_tools_without_tkinter_raises(self) -> None:
with (
patch(f"{MODULE}.System.current", return_value=System.LINUX),
- patch(f"{MODULE}.shutil.which", side_effect=_which(kdialog=False, zenity=False)),
- patch(f"{MODULE}.importlib.util.find_spec", side_effect=_find_spec(available=False)),
+ patch(
+ f"{MODULE}.shutil.which",
+ side_effect=_which(kdialog=False, zenity=False),
+ ),
+ patch(
+ f"{MODULE}.importlib.util.find_spec",
+ side_effect=_find_spec(available=False),
+ ),
patch.dict(os.environ, {"XDG_CURRENT_DESKTOP": "GNOME"}, clear=False),
pytest.raises(FileDialogUnavailableError),
):
@@ -117,7 +135,10 @@ def test_no_linux_tools_without_tkinter_raises(self) -> None:
def test_windows_without_tkinter_raises(self) -> None:
with (
patch(f"{MODULE}.System.current", return_value=System.WINDOWS),
- patch(f"{MODULE}.importlib.util.find_spec", side_effect=_find_spec(available=False)),
+ patch(
+ f"{MODULE}.importlib.util.find_spec",
+ side_effect=_find_spec(available=False),
+ ),
pytest.raises(FileDialogUnavailableError),
):
select_file_dialog_backend()
diff --git a/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py b/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py
index f43e176c..a936841c 100644
--- a/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py
+++ b/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py
@@ -6,26 +6,23 @@
)
from sampletones_application.utils.gui.dialog_navigation.stop import FocusStop
from sampletones_application.utils.gui.keyboard import KeyEvent, KeyRouter
-from sampletones_application.utils.gui.keyboard.modifiers import NO_MODIFIERS, SHIFT
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from tests.suite.shortcuts import shipped_scheme, shipped_source
MODULE = "sampletones_application.utils.gui.dialog_navigation.navigator"
-KEY_TAB = 1
-KEY_RETURN = 2
-KEY_ESCAPE = 3
-
def _dpg(*, exists: bool = True) -> MagicMock:
dpg = MagicMock()
- dpg.mvKey_Tab = KEY_TAB
- dpg.mvKey_Return = KEY_RETURN
- dpg.mvKey_Escape = KEY_ESCAPE
dpg.does_item_exist.return_value = exists
return dpg
-def _event(key: int, *, shift: bool = False) -> KeyEvent:
- return KeyEvent(key=key, modifiers=SHIFT if shift else NO_MODIFIERS)
+def _press(shortcut_id: ShortcutId) -> KeyEvent:
+ """The press the shipped scheme gives a dialog action."""
+ combination = shipped_scheme().shortcut(shortcut_id).combination
+ assert combination is not None
+ return KeyEvent(key=combination.key, modifiers=combination.modifiers)
def _stops() -> List[FocusStop]:
@@ -38,49 +35,63 @@ def _navigator(*, on_escape: MagicMock, router: KeyRouter) -> DialogKeyboardNavi
stops=_stops(),
on_escape=on_escape,
key_router=router,
+ shortcut_source=shipped_source(),
initial_index=0,
)
class TestKeyDispatch:
- def test_tab_cycles_the_ring_forward(self) -> None:
+ def test_the_next_control_action_cycles_the_ring_forward(self) -> None:
navigator = _navigator(on_escape=MagicMock(), router=KeyRouter())
navigator._ring = MagicMock()
with patch(f"{MODULE}.dpg", _dpg()):
- navigator.handle_key(_event(KEY_TAB))
+ navigator.handle_key(_press(ShortcutId.DIALOG_NEXT_CONTROL))
navigator._ring.cycle.assert_called_once_with(1)
- def test_shift_tab_cycles_the_ring_backward(self) -> None:
+ def test_the_previous_control_action_cycles_the_ring_backward(self) -> None:
navigator = _navigator(on_escape=MagicMock(), router=KeyRouter())
navigator._ring = MagicMock()
with patch(f"{MODULE}.dpg", _dpg()):
- navigator.handle_key(_event(KEY_TAB, shift=True))
+ navigator.handle_key(_press(ShortcutId.DIALOG_PREVIOUS_CONTROL))
navigator._ring.cycle.assert_called_once_with(-1)
- def test_enter_activates_the_focused_stop(self) -> None:
+ def test_the_activate_action_activates_the_focused_stop(self) -> None:
navigator = _navigator(on_escape=MagicMock(), router=KeyRouter())
navigator._ring = MagicMock()
with patch(f"{MODULE}.dpg", _dpg()):
- navigator.handle_key(_event(KEY_RETURN))
+ navigator.handle_key(_press(ShortcutId.DIALOG_ACTIVATE))
navigator._ring.activate_focused.assert_called_once_with()
- def test_escape_runs_the_cancel_action(self) -> None:
+ def test_the_cancel_action_runs_the_cancel_callback(self) -> None:
on_escape = MagicMock()
navigator = _navigator(on_escape=on_escape, router=KeyRouter())
navigator._ring = MagicMock()
with patch(f"{MODULE}.dpg", _dpg()):
- navigator.handle_key(_event(KEY_ESCAPE))
+ navigator.handle_key(_press(ShortcutId.DIALOG_CANCEL))
on_escape.assert_called_once_with()
navigator._ring.cycle.assert_not_called()
+ def test_a_press_the_dialog_leaves_unnamed_reaches_the_ring_not_at_all(self) -> None:
+ """A dialog answers its own four actions, so a project shortcut passes the ring by."""
+ on_escape = MagicMock()
+ navigator = _navigator(on_escape=on_escape, router=KeyRouter())
+ navigator._ring = MagicMock()
+
+ with patch(f"{MODULE}.dpg", _dpg()):
+ navigator.handle_key(_press(ShortcutId.SAVE_PROJECT))
+
+ on_escape.assert_not_called()
+ navigator._ring.cycle.assert_not_called()
+ navigator._ring.activate_focused.assert_not_called()
+
def test_key_on_a_closed_dialog_disposes(self) -> None:
router = KeyRouter()
navigator = _navigator(on_escape=MagicMock(), router=router)
@@ -88,7 +99,7 @@ def test_key_on_a_closed_dialog_disposes(self) -> None:
router.push_modal(navigator)
with patch(f"{MODULE}.dpg", _dpg(exists=False)):
- navigator.handle_key(_event(KEY_ESCAPE))
+ navigator.handle_key(_press(ShortcutId.DIALOG_CANCEL))
assert not router.is_modal_open
navigator._ring.activate_focused.assert_not_called()
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/focus/item_tree.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/item_tree.py
index a38b3705..1dd821e3 100644
--- a/tests/unit/sampletones_application/utils/gui/keyboard/focus/item_tree.py
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/focus/item_tree.py
@@ -72,7 +72,10 @@ def install(self, monkeypatch: pytest.MonkeyPatch) -> None:
def _info(self, item: int) -> Dict[str, Any]:
self.read_items.append(item)
fake = self._items[item]
- return {"type": fake.item_type, "children": {0: [], WIDGET_SLOT: list(fake.children)}}
+ return {
+ "type": fake.item_type,
+ "children": {0: [], WIDGET_SLOT: list(fake.children)},
+ }
def _state(self, item: int) -> Dict[str, bool]:
return dict(self._items[item].state)
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py
index 90cca224..6d5b1f04 100644
--- a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py
@@ -3,7 +3,9 @@
import dearpygui.dearpygui as dpg
import pytest
-from sampletones_application.utils.gui.keyboard.focus.consumption import field_consumes_key
+from sampletones_application.utils.gui.keyboard.focus.consumption import (
+ field_consumes_key,
+)
from sampletones_application.utils.gui.keyboard.focus.kind import FieldKind
from sampletones_application.utils.gui.keyboard.modifiers import (
ALT,
@@ -25,14 +27,19 @@ class TestCase(BaseRegularTestCase):
modifiers: ModifierSet = NO_MODIFIERS
expected: bool
- test_cases = [
+ test_cases = (
TestCase(
label="no field lets every key through",
kind=FieldKind.NONE,
key=dpg.mvKey_Spacebar,
expected=False,
),
- TestCase(label="text field types a space", kind=FieldKind.TEXT_ENTRY, key=dpg.mvKey_Spacebar, expected=True),
+ TestCase(
+ label="text field types a space",
+ kind=FieldKind.TEXT_ENTRY,
+ key=dpg.mvKey_Spacebar,
+ expected=True,
+ ),
TestCase(
label="text field types a shifted space",
kind=FieldKind.TEXT_ENTRY,
@@ -54,8 +61,18 @@ class TestCase(BaseRegularTestCase):
modifiers=CTRL_SHIFT,
expected=False,
),
- TestCase(label="text field cancels on Escape", kind=FieldKind.TEXT_ENTRY, key=dpg.mvKey_Escape, expected=True),
- TestCase(label="text field commits on Enter", kind=FieldKind.TEXT_ENTRY, key=dpg.mvKey_Return, expected=True),
+ TestCase(
+ label="text field cancels on Escape",
+ kind=FieldKind.TEXT_ENTRY,
+ key=dpg.mvKey_Escape,
+ expected=True,
+ ),
+ TestCase(
+ label="text field commits on Enter",
+ kind=FieldKind.TEXT_ENTRY,
+ key=dpg.mvKey_Return,
+ expected=True,
+ ),
TestCase(
label="text field selects all on Ctrl+A",
kind=FieldKind.TEXT_ENTRY,
@@ -98,14 +115,24 @@ class TestCase(BaseRegularTestCase):
modifiers=ALT,
expected=False,
),
- TestCase(label="text field yields F11", kind=FieldKind.TEXT_ENTRY, key=dpg.mvKey_F11, expected=False),
+ TestCase(
+ label="text field yields F11",
+ kind=FieldKind.TEXT_ENTRY,
+ key=dpg.mvKey_F11,
+ expected=False,
+ ),
TestCase(
label="open combo yields a plain space",
kind=FieldKind.CHOICE,
key=dpg.mvKey_Spacebar,
expected=False,
),
- TestCase(label="open combo closes on Escape", kind=FieldKind.CHOICE, key=dpg.mvKey_Escape, expected=True),
+ TestCase(
+ label="open combo closes on Escape",
+ kind=FieldKind.CHOICE,
+ key=dpg.mvKey_Escape,
+ expected=True,
+ ),
TestCase(
label="open combo yields Ctrl+A",
kind=FieldKind.CHOICE,
@@ -113,11 +140,19 @@ class TestCase(BaseRegularTestCase):
modifiers=CTRL,
expected=False,
),
- ]
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_field_consumes_key(self, test_case: TestCase) -> None:
- consumed = field_consumes_key(test_case.kind, test_case.key, test_case.modifiers)
+ consumed = field_consumes_key(
+ test_case.kind,
+ test_case.key,
+ test_case.modifiers,
+ )
assert consumed is test_case.expected
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_items.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_items.py
index 68cfe386..dfe4ba57 100644
--- a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_items.py
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_items.py
@@ -33,17 +33,49 @@ class TestCase(BaseRegularTestCase):
item_type: str
expected: FieldKind
- test_cases = [
- TestCase(label="text input types characters", item_type=INPUT_TEXT, expected=FieldKind.TEXT_ENTRY),
- TestCase(label="integer input types characters", item_type=INPUT_INT, expected=FieldKind.TEXT_ENTRY),
- TestCase(label="slider types characters", item_type=SLIDER_INT, expected=FieldKind.TEXT_ENTRY),
- TestCase(label="combo navigates options", item_type=COMBO, expected=FieldKind.CHOICE),
- TestCase(label="button keeps no keys", item_type=BUTTON, expected=FieldKind.NONE),
- TestCase(label="group keeps no keys", item_type=GROUP, expected=FieldKind.NONE),
- TestCase(label="unknown type keeps no keys", item_type=UNKNOWN_ITEM_TYPE, expected=FieldKind.NONE),
- ]
+ test_cases = (
+ TestCase(
+ label="text input types characters",
+ item_type=INPUT_TEXT,
+ expected=FieldKind.TEXT_ENTRY,
+ ),
+ TestCase(
+ label="integer input types characters",
+ item_type=INPUT_INT,
+ expected=FieldKind.TEXT_ENTRY,
+ ),
+ TestCase(
+ label="slider types characters",
+ item_type=SLIDER_INT,
+ expected=FieldKind.TEXT_ENTRY,
+ ),
+ TestCase(
+ label="combo navigates options",
+ item_type=COMBO,
+ expected=FieldKind.CHOICE,
+ ),
+ TestCase(
+ label="button keeps no keys",
+ item_type=BUTTON,
+ expected=FieldKind.NONE,
+ ),
+ TestCase(
+ label="group keeps no keys",
+ item_type=GROUP,
+ expected=FieldKind.NONE,
+ ),
+ TestCase(
+ label="unknown type keeps no keys",
+ item_type=UNKNOWN_ITEM_TYPE,
+ expected=FieldKind.NONE,
+ ),
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_field_kind(self, test_case: TestCase) -> None:
assert field_kind(test_case.item_type) is test_case.expected
@@ -65,14 +97,38 @@ class TestCase(BaseRegularTestCase):
item_type: str
expected: bool
- test_cases = [
- TestCase(label="group carries its children's state", item_type=GROUP, expected=True),
- TestCase(label="child window carries its children's state", item_type=CHILD_WINDOW, expected=True),
- TestCase(label="tab answers for its own header", item_type=TAB, expected=False),
- TestCase(label="tab bar answers for itself", item_type=TAB_BAR, expected=False),
- TestCase(label="table row answers for itself", item_type=TABLE_ROW, expected=False),
- ]
+ test_cases = (
+ TestCase(
+ label="group carries its children's state",
+ item_type=GROUP,
+ expected=True,
+ ),
+ TestCase(
+ label="child window carries its children's state",
+ item_type=CHILD_WINDOW,
+ expected=True,
+ ),
+ TestCase(
+ label="tab answers for its own header",
+ item_type=TAB,
+ expected=False,
+ ),
+ TestCase(
+ label="tab bar answers for itself",
+ item_type=TAB_BAR,
+ expected=False,
+ ),
+ TestCase(
+ label="table row answers for itself",
+ item_type=TABLE_ROW,
+ expected=False,
+ ),
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_reports_child_focus(self, test_case: TestCase) -> None:
assert reports_child_focus(test_case.item_type) is test_case.expected
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_query.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_query.py
index 2aa14120..56a9a81b 100644
--- a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_query.py
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_query.py
@@ -34,8 +34,13 @@ class TestCase(BaseRegularTestCase):
focused_item: int
expected: FieldKind
- test_cases = [
- TestCase(label="nothing focused", items={}, focused_item=NO_ITEM, expected=FieldKind.NONE),
+ test_cases = (
+ TestCase(
+ label="nothing focused",
+ items={},
+ focused_item=NO_ITEM,
+ expected=FieldKind.NONE,
+ ),
TestCase(
label="stale item destroyed by a table rebuild",
items={},
@@ -78,16 +83,32 @@ class TestCase(BaseRegularTestCase):
focused_item=FOCUSED,
expected=FieldKind.NONE,
),
- ]
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
- def test_focused_field_kind(self, test_case: TestCase, monkeypatch: pytest.MonkeyPatch) -> None:
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_focused_field_kind(
+ self,
+ test_case: TestCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
FakeItemTree(test_case.items, focused_item=test_case.focused_item).install(monkeypatch)
assert focused_field_kind() is test_case.expected
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
- def test_is_field_focused_follows_the_kind(self, test_case: TestCase, monkeypatch: pytest.MonkeyPatch) -> None:
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_is_field_focused_follows_the_kind(
+ self,
+ test_case: TestCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
FakeItemTree(test_case.items, focused_item=test_case.focused_item).install(monkeypatch)
assert is_field_focused() == (test_case.expected is not FieldKind.NONE)
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_search.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_search.py
index 1b37e247..4aecd3b3 100644
--- a/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_search.py
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_search.py
@@ -37,46 +37,99 @@ class TestCase(BaseRegularTestCase):
items: Dict[int, FakeItem]
expected: FieldKind
- test_cases = [
+ test_cases = (
TestCase(
- label="actively edited text input", items={FOCUSED: editing(INPUT_TEXT)}, expected=FieldKind.TEXT_ENTRY
+ label="actively edited text input",
+ items={FOCUSED: editing(INPUT_TEXT)},
+ expected=FieldKind.TEXT_ENTRY,
+ ),
+ TestCase(
+ label="actively edited integer input",
+ items={FOCUSED: editing(INPUT_INT)},
+ expected=FieldKind.TEXT_ENTRY,
),
TestCase(
- label="actively edited integer input", items={FOCUSED: editing(INPUT_INT)}, expected=FieldKind.TEXT_ENTRY
+ label="open combo",
+ items={FOCUSED: editing(COMBO)},
+ expected=FieldKind.CHOICE,
+ ),
+ TestCase(
+ label="focused but idle text input",
+ items={FOCUSED: idle(INPUT_TEXT)},
+ expected=FieldKind.NONE,
+ ),
+ TestCase(
+ label="idle slider",
+ items={FOCUSED: idle(SLIDER_INT)},
+ expected=FieldKind.NONE,
+ ),
+ TestCase(
+ label="pressed button",
+ items={FOCUSED: editing(BUTTON)},
+ expected=FieldKind.NONE,
),
- TestCase(label="open combo", items={FOCUSED: editing(COMBO)}, expected=FieldKind.CHOICE),
- TestCase(label="focused but idle text input", items={FOCUSED: idle(INPUT_TEXT)}, expected=FieldKind.NONE),
- TestCase(label="idle slider", items={FOCUSED: idle(SLIDER_INT)}, expected=FieldKind.NONE),
- TestCase(label="pressed button", items={FOCUSED: editing(BUTTON)}, expected=FieldKind.NONE),
TestCase(
label="focused selectable reporting no state",
items={FOCUSED: FakeItem(SELECTABLE)},
expected=FieldKind.NONE,
),
- TestCase(label="sequence input beside its copy button", items=SEQUENCE_ROW, expected=FieldKind.TEXT_ENTRY),
- TestCase(label="input under nested groups", items=NESTED_GROUPS, expected=FieldKind.TEXT_ENTRY),
- TestCase(label="input inside a card inside a group", items=GROUP_OVER_CARD, expected=FieldKind.TEXT_ENTRY),
TestCase(
- label="input inside a table row inside a group", items=GROUP_OVER_TABLE_ROW, expected=FieldKind.TEXT_ENTRY
+ label="sequence input beside its copy button",
+ items=SEQUENCE_ROW,
+ expected=FieldKind.TEXT_ENTRY,
+ ),
+ TestCase(
+ label="input under nested groups",
+ items=NESTED_GROUPS,
+ expected=FieldKind.TEXT_ENTRY,
+ ),
+ TestCase(
+ label="input inside a card inside a group",
+ items=GROUP_OVER_CARD,
+ expected=FieldKind.TEXT_ENTRY,
+ ),
+ TestCase(
+ label="input inside a table row inside a group",
+ items=GROUP_OVER_TABLE_ROW,
+ expected=FieldKind.TEXT_ENTRY,
),
TestCase(
label="sequence input under the instruments card body",
items=INSTRUMENTS_CARD_BODY,
expected=FieldKind.TEXT_ENTRY,
),
- TestCase(label="tracker cell holding the cursor", items=TRACKER_CELLS, expected=FieldKind.NONE),
- TestCase(label="group holding a pressed button", items=GROUP_HOLDING_A_PRESSED_BUTTON, expected=FieldKind.NONE),
- ]
+ TestCase(
+ label="tracker cell holding the cursor",
+ items=TRACKER_CELLS,
+ expected=FieldKind.NONE,
+ ),
+ TestCase(
+ label="group holding a pressed button",
+ items=GROUP_HOLDING_A_PRESSED_BUTTON,
+ expected=FieldKind.NONE,
+ ),
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
- def test_edited_field_kind(self, test_case: TestCase, monkeypatch: pytest.MonkeyPatch) -> None:
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_edited_field_kind(
+ self,
+ test_case: TestCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
FakeItemTree(test_case.items, focused_item=FOCUSED).install(monkeypatch)
assert edited_field_kind(FOCUSED) is test_case.expected
class TestSearchExtent:
- def test_the_search_follows_the_branch_that_reports_focus(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_the_search_follows_the_branch_that_reports_focus(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
"""The instruments card body encloses every generator tab, and only the focused one is read.
DearPyGui names the outermost group around an edited field as the focused item, so the search
@@ -89,7 +142,10 @@ def test_the_search_follows_the_branch_that_reports_focus(self, monkeypatch: pyt
assert edited_field_kind(FOCUSED) is FieldKind.TEXT_ENTRY
assert UNFOCUSED_TAB_CONTENT not in tree.read_items
- def test_an_idle_group_is_answered_without_reading_its_cells(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_an_idle_group_is_answered_without_reading_its_cells(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
"""A tracker cursor leaves the grid focused while nothing is edited, and the cells stay unread.
The sequencer holds a group around a table of hundreds of cells. An interaction anywhere
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py
new file mode 100644
index 00000000..ca2ae9fc
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py
@@ -0,0 +1,249 @@
+from dataclasses import dataclass
+from typing import Final, List, Optional, Tuple
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.utils.gui.keyboard.capture import KeyCapture
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.utils.gui.keyboard.keys import (
+ KEY_MODIFIER_ALT,
+ KEY_MODIFIER_CTRL,
+ KEY_MODIFIER_SHIFT,
+ KEY_MODIFIER_SUPER,
+ KEY_RIGHT_SUPER,
+)
+from sampletones_application.utils.gui.keyboard.modifiers import (
+ ALT,
+ CTRL,
+ CTRL_ALT,
+ NO_MODIFIERS,
+ SHIFT,
+ SUPER,
+ ModifierSet,
+)
+from sampletones_application.utils.gui.keyboard.router import KeyRouter
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+ESCAPE: Final[KeyCombination] = KeyCombination(dpg.mvKey_Escape)
+CANCEL: Final[Tuple[KeyCombination, ...]] = (ESCAPE,)
+
+
+class Harness:
+ """A capture over a router of its own, with the presses a reader makes spelled as methods."""
+
+ def __init__(self) -> None:
+ self.router = KeyRouter()
+ self.captured: List[KeyCombination] = []
+ self.cancelled = 0
+ self.capture = KeyCapture(key_router=self.router, cancel=CANCEL)
+ self.capture.on_captured = self.captured.append
+ self.capture.on_cancelled = self._on_cancelled
+
+ def press(self, key: int, modifiers: ModifierSet = NO_MODIFIERS) -> None:
+ self.router.route(KeyEvent(key=key, modifiers=modifiers))
+
+ def press_all(self, events: Tuple[KeyEvent, ...]) -> None:
+ for event in events:
+ self.press(event.key, event.modifiers)
+
+ def _on_cancelled(self) -> None:
+ self.cancelled += 1
+
+
+@pytest.fixture(name="harness")
+def harness_fixture() -> Harness:
+ harness = Harness()
+ harness.capture.start()
+ return harness
+
+
+class TestListening:
+ def test_a_started_capture_holds_the_keyboard(self, harness: Harness) -> None:
+ assert harness.capture.is_listening
+ assert harness.router.is_modal_open
+
+ def test_stopping_gives_the_keyboard_back(self, harness: Harness) -> None:
+ harness.capture.stop()
+
+ assert not harness.capture.is_listening
+ assert not harness.router.is_modal_open
+
+ def test_starting_twice_claims_the_keyboard_once(self, harness: Harness) -> None:
+ harness.capture.start()
+ harness.capture.stop()
+
+ assert not harness.router.is_modal_open
+
+ def test_stopping_twice_releases_the_claim_once(self, harness: Harness) -> None:
+ """A second release would drop the claim of the dialog the capture sits above."""
+ harness.router.push_modal(harness.capture)
+ harness.capture.stop()
+ harness.capture.stop()
+
+ assert harness.router.is_modal_open
+
+
+class TestCapturedPress(BaseTestSuite):
+ """The combination a reader arrives at, spelled as the presses DearPyGui reports on the way.
+
+ Holding a modifier reports it twice — under the key that carries it, and under the code ImGui
+ reserves for the modifier — so a sequence states both, in the order they arrive.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ presses: Tuple[KeyEvent, ...]
+ expected: Optional[KeyCombination]
+
+ test_cases = (
+ TestCase(
+ label="a plain key",
+ presses=(KeyEvent(key=dpg.mvKey_F5, modifiers=NO_MODIFIERS),),
+ expected=KeyCombination(dpg.mvKey_F5),
+ ),
+ TestCase(
+ label="control and a letter",
+ presses=(
+ KeyEvent(key=dpg.mvKey_LControl, modifiers=CTRL),
+ KeyEvent(key=KEY_MODIFIER_CTRL, modifiers=CTRL),
+ KeyEvent(key=dpg.mvKey_Z, modifiers=CTRL),
+ ),
+ expected=KeyCombination(dpg.mvKey_Z, CTRL),
+ ),
+ TestCase(
+ label="alt and a navigation key",
+ presses=(
+ KeyEvent(key=dpg.mvKey_LAlt, modifiers=ALT),
+ KeyEvent(key=KEY_MODIFIER_ALT, modifiers=ALT),
+ KeyEvent(key=dpg.mvKey_Home, modifiers=ALT),
+ ),
+ expected=KeyCombination(dpg.mvKey_Home, ALT),
+ ),
+ TestCase(
+ label="alt and an arrow key",
+ presses=(
+ KeyEvent(key=dpg.mvKey_LAlt, modifiers=ALT),
+ KeyEvent(key=KEY_MODIFIER_ALT, modifiers=ALT),
+ KeyEvent(key=dpg.mvKey_Up, modifiers=ALT),
+ ),
+ expected=KeyCombination(dpg.mvKey_Up, ALT),
+ ),
+ TestCase(
+ label="two modifiers and a letter",
+ presses=(
+ KeyEvent(key=dpg.mvKey_LControl, modifiers=CTRL),
+ KeyEvent(key=KEY_MODIFIER_CTRL, modifiers=CTRL),
+ KeyEvent(key=dpg.mvKey_LAlt, modifiers=CTRL_ALT),
+ KeyEvent(key=KEY_MODIFIER_ALT, modifiers=CTRL_ALT),
+ KeyEvent(key=dpg.mvKey_G, modifiers=CTRL_ALT),
+ ),
+ expected=KeyCombination(dpg.mvKey_G, CTRL_ALT),
+ ),
+ TestCase(
+ label="alt held on its own",
+ presses=(
+ KeyEvent(key=dpg.mvKey_LAlt, modifiers=ALT),
+ KeyEvent(key=KEY_MODIFIER_ALT, modifiers=ALT),
+ ),
+ expected=None,
+ ),
+ TestCase(
+ label="shift held on its own",
+ presses=(
+ KeyEvent(key=dpg.mvKey_RShift, modifiers=SHIFT),
+ KeyEvent(key=KEY_MODIFIER_SHIFT, modifiers=SHIFT),
+ ),
+ expected=None,
+ ),
+ TestCase(
+ label="super held on its own",
+ presses=(
+ KeyEvent(key=KEY_RIGHT_SUPER, modifiers=SUPER),
+ KeyEvent(key=KEY_MODIFIER_SUPER, modifiers=SUPER),
+ ),
+ expected=None,
+ ),
+ TestCase(
+ label="a key the table names none of",
+ presses=(KeyEvent(key=dpg.mvKey_Browser_Back, modifiers=NO_MODIFIERS),),
+ expected=None,
+ ),
+ TestCase(
+ label="a modifier over a key the table names none of",
+ presses=(
+ KeyEvent(key=dpg.mvKey_LAlt, modifiers=ALT),
+ KeyEvent(key=KEY_MODIFIER_ALT, modifiers=ALT),
+ KeyEvent(key=dpg.mvKey_Browser_Forward, modifiers=ALT),
+ ),
+ expected=None,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_combination_a_sequence_of_presses_reports(
+ self,
+ test_case: TestCase,
+ harness: Harness,
+ ) -> None:
+ harness.press_all(test_case.presses)
+
+ assert harness.captured == ([] if test_case.expected is None else [test_case.expected])
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_capture_listens_on_until_a_combination_arrives(
+ self,
+ test_case: TestCase,
+ harness: Harness,
+ ) -> None:
+ """A press that names nothing leaves the reader free to press again."""
+ harness.press_all(test_case.presses)
+
+ assert harness.capture.is_listening is (test_case.expected is None)
+ assert harness.router.is_modal_open is (test_case.expected is None)
+
+ @pytest.mark.parametrize(
+ "test_case",
+ [test_case for test_case in test_cases if test_case.expected is not None],
+ ids=lambda test_case: test_case.label,
+ )
+ def test_a_reported_combination_is_one_a_binding_can_be_written_from(
+ self,
+ test_case: TestCase,
+ harness: Harness,
+ ) -> None:
+ """What a capture reports is what an editor assigns, so it carries a written form."""
+ harness.press_all(test_case.presses)
+
+ assert all(combination.is_writable for combination in harness.captured)
+
+ def test_a_key_pressed_after_one_the_table_names_none_of_is_read(self, harness: Harness) -> None:
+ harness.press(dpg.mvKey_Browser_Back)
+ harness.press(dpg.mvKey_D, CTRL)
+
+ assert harness.captured == [KeyCombination(dpg.mvKey_D, CTRL)]
+
+
+class TestCancelledCapture:
+ def test_the_cancel_combination_ends_the_capture_without_assigning(self, harness: Harness) -> None:
+ harness.press(dpg.mvKey_Escape)
+
+ assert harness.captured == []
+ assert harness.cancelled == 1
+ assert not harness.capture.is_listening
+
+ def test_the_cancel_key_under_a_modifier_is_a_combination_like_any_other(self, harness: Harness) -> None:
+ harness.press(dpg.mvKey_Escape, CTRL)
+
+ assert harness.captured == [KeyCombination(dpg.mvKey_Escape, CTRL)]
+ assert harness.cancelled == 0
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py
new file mode 100644
index 00000000..f9fe80ae
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py
@@ -0,0 +1,259 @@
+from dataclasses import dataclass
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.utils.gui.keyboard.keys import (
+ KEY_MODIFIER_ALT,
+ KEY_PAGE_DOWN,
+ KEY_PLUS,
+)
+from sampletones_application.utils.gui.keyboard.modifiers import (
+ ALT,
+ CTRL,
+ CTRL_ALT_SHIFT,
+ CTRL_SHIFT,
+ NO_MODIFIERS,
+ SHIFT,
+ ModifierSet,
+)
+from sampletones_shared.constants.symbols import PLUS
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+WRITTEN_COMBINATIONS = (
+ "Ctrl+Shift+Z",
+ "Ctrl+D",
+ "F11",
+ "Ctrl+PgDn",
+ "Alt+Home",
+ "Shift+Del",
+ "Ctrl+Ins",
+ "Plus",
+ "Ctrl+Plus",
+ "NumPlus",
+ "Ctrl+Alt+Shift+Space",
+)
+
+
+class TestMatches(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ combination: KeyCombination
+ event: KeyEvent
+ expected: bool
+
+ test_cases = (
+ TestCase(
+ label="the key under the modifiers it names",
+ combination=KeyCombination(dpg.mvKey_D, CTRL),
+ event=KeyEvent(key=dpg.mvKey_D, modifiers=CTRL),
+ expected=True,
+ ),
+ TestCase(
+ label="a plain key under no modifier",
+ combination=KeyCombination(dpg.mvKey_F1),
+ event=KeyEvent(key=dpg.mvKey_F1, modifiers=NO_MODIFIERS),
+ expected=True,
+ ),
+ TestCase(
+ label="another key under the same modifiers",
+ combination=KeyCombination(dpg.mvKey_D, CTRL),
+ event=KeyEvent(key=dpg.mvKey_E, modifiers=CTRL),
+ expected=False,
+ ),
+ TestCase(
+ label="the key under no modifier",
+ combination=KeyCombination(dpg.mvKey_D, CTRL),
+ event=KeyEvent(key=dpg.mvKey_D, modifiers=NO_MODIFIERS),
+ expected=False,
+ ),
+ TestCase(
+ label="the key under a further modifier",
+ combination=KeyCombination(dpg.mvKey_D, CTRL),
+ event=KeyEvent(key=dpg.mvKey_D, modifiers=CTRL_SHIFT),
+ expected=False,
+ ),
+ TestCase(
+ label="a plain key under a modifier",
+ combination=KeyCombination(dpg.mvKey_F1),
+ event=KeyEvent(key=dpg.mvKey_F1, modifiers=SHIFT),
+ expected=False,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_matches(self, test_case: TestCase) -> None:
+ assert test_case.combination.matches(test_case.event) is test_case.expected
+
+
+class TestDisplay(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ key: int
+ modifiers: ModifierSet
+ expected: str
+
+ test_cases = (
+ TestCase(label="a plain key", key=dpg.mvKey_F1, modifiers=NO_MODIFIERS, expected="F1"),
+ TestCase(label="one modifier", key=dpg.mvKey_D, modifiers=CTRL, expected="Ctrl+D"),
+ TestCase(
+ label="two modifiers in canonical order",
+ key=dpg.mvKey_Z,
+ modifiers=CTRL_SHIFT,
+ expected="Ctrl+Shift+Z",
+ ),
+ TestCase(
+ label="control, alt and shift",
+ key=dpg.mvKey_Spacebar,
+ modifiers=CTRL_ALT_SHIFT,
+ expected="Ctrl+Alt+Shift+Space",
+ ),
+ TestCase(label="a page key", key=KEY_PAGE_DOWN, modifiers=CTRL, expected="Ctrl+PgDn"),
+ TestCase(label="the key the separator glyph sits on", key=KEY_PLUS, modifiers=CTRL, expected="Ctrl+Plus"),
+ TestCase(label="a navigation key", key=dpg.mvKey_Home, modifiers=ALT, expected="Alt+Home"),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_display(self, test_case: TestCase) -> None:
+ assert KeyCombination(test_case.key, test_case.modifiers).display() == test_case.expected
+
+
+class TestWritable(BaseTestSuite):
+ """A combination is storable once its key carries a name, which a press alone does not promise."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ key: int
+ modifiers: ModifierSet
+ expected: bool
+
+ test_cases = (
+ TestCase(label="a letter", key=dpg.mvKey_D, modifiers=CTRL, expected=True),
+ TestCase(label="a navigation key", key=dpg.mvKey_Home, modifiers=ALT, expected=True),
+ TestCase(label="a written key", key=KEY_PAGE_DOWN, modifiers=NO_MODIFIERS, expected=True),
+ TestCase(
+ label="the code reserved for alt",
+ key=KEY_MODIFIER_ALT,
+ modifiers=ALT,
+ expected=False,
+ ),
+ TestCase(
+ label="a key the table names none of",
+ key=dpg.mvKey_Browser_Back,
+ modifiers=NO_MODIFIERS,
+ expected=False,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_is_writable(self, test_case: TestCase) -> None:
+ assert KeyCombination(test_case.key, test_case.modifiers).is_writable is test_case.expected
+
+ @pytest.mark.parametrize(
+ "test_case",
+ [test_case for test_case in test_cases if test_case.expected],
+ ids=lambda test_case: test_case.label,
+ )
+ def test_a_writable_combination_reads_back_as_itself(self, test_case: TestCase) -> None:
+ combination = KeyCombination(test_case.key, test_case.modifiers)
+
+ assert KeyCombination.parse(combination.display()) == combination
+
+ @pytest.mark.parametrize(
+ "test_case",
+ [test_case for test_case in test_cases if not test_case.expected],
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_rest_are_shown_and_left_at_that(self, test_case: TestCase) -> None:
+ """A combination stays displayable whatever a press carries, and stops short of storable."""
+ combination = KeyCombination(test_case.key, test_case.modifiers)
+
+ with pytest.raises(KeyError):
+ KeyCombination.parse(combination.display())
+
+
+class TestParse(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ text: str
+ expected: KeyCombination
+
+ test_cases = (
+ TestCase(label="a plain key", text="F11", expected=KeyCombination(dpg.mvKey_F1 + 10)),
+ TestCase(label="one modifier", text="Ctrl+D", expected=KeyCombination(dpg.mvKey_D, CTRL)),
+ TestCase(
+ label="two modifiers",
+ text="Ctrl+Shift+Z",
+ expected=KeyCombination(dpg.mvKey_Z, CTRL_SHIFT),
+ ),
+ TestCase(
+ label="modifiers named out of canonical order",
+ text="Shift+Ctrl+Z",
+ expected=KeyCombination(dpg.mvKey_Z, CTRL_SHIFT),
+ ),
+ TestCase(
+ label="any capitalisation",
+ text="ctrl+shift+z",
+ expected=KeyCombination(dpg.mvKey_Z, CTRL_SHIFT),
+ ),
+ TestCase(label="a page key", text="Ctrl+PgDn", expected=KeyCombination(KEY_PAGE_DOWN, CTRL)),
+ TestCase(label="the separator alone", text=PLUS, expected=KeyCombination(KEY_PLUS)),
+ TestCase(
+ label="the separator as the key",
+ text="Ctrl++",
+ expected=KeyCombination(KEY_PLUS, CTRL),
+ ),
+ TestCase(label="the key written out", text="Ctrl+Plus", expected=KeyCombination(KEY_PLUS, CTRL)),
+ TestCase(label="a keypad key", text=f"Num{PLUS}", expected=KeyCombination(dpg.mvKey_Add)),
+ TestCase(label="a keypad key written out", text="NumPlus", expected=KeyCombination(dpg.mvKey_Add)),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_parse(self, test_case: TestCase) -> None:
+ assert KeyCombination.parse(test_case.text) == test_case.expected
+
+ @pytest.mark.parametrize("text", ["Hyper+D", "Ctrl+Nonesuch", "Ctrl", ""])
+ def test_a_text_naming_no_key_raises(self, text: str) -> None:
+ with pytest.raises(KeyError):
+ KeyCombination.parse(text)
+
+ @pytest.mark.parametrize("text", WRITTEN_COMBINATIONS)
+ def test_a_written_combination_reads_back_as_itself(self, text: str) -> None:
+ """A binding written in configuration and one declared in code are one value."""
+ assert KeyCombination.parse(text).display() == text
+
+ @pytest.mark.parametrize(
+ ("written", "expected"),
+ [
+ (f"Ctrl{PLUS}{PLUS}", "Ctrl+Plus"),
+ ("Ctrl+=", "Ctrl+Plus"),
+ ("Shift+Ctrl+Z", "Ctrl+Shift+Z"),
+ ("ctrl+pgdn", "Ctrl+PgDn"),
+ ],
+ )
+ def test_a_spelling_reads_back_as_the_one_the_combination_displays_under(
+ self,
+ written: str,
+ expected: str,
+ ) -> None:
+ """A reader writes a combination however they know it and reads back one canonical form."""
+ assert KeyCombination.parse(written).display() == expected
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py
new file mode 100644
index 00000000..c1ebf3e6
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py
@@ -0,0 +1,262 @@
+from dataclasses import dataclass
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.utils.gui.keyboard.keys import (
+ DIGIT_COUNT,
+ FUNCTION_KEY_COUNT,
+ FUNCTION_KEY_NAMES,
+ FUNCTION_KEYS,
+ HEX_KEYS,
+ KEY_CODES,
+ KEY_DISPLAY_NAMES,
+ KEY_LEFT_SUPER,
+ KEY_MODIFIER_ALT,
+ KEY_MODIFIER_CTRL,
+ KEY_MODIFIER_SHIFT,
+ KEY_MODIFIER_SUPER,
+ KEY_NAME_ALIASES,
+ KEY_PAGE_DOWN,
+ KEY_PAGE_UP,
+ KEY_PLUS,
+ KEY_QUOTE,
+ KEY_RIGHT_SUPER,
+ KEY_SEMICOLON,
+ KEY_TILDE,
+ LETTER_COUNT,
+ SIGN_KEYS,
+ UNKNOWN_KEY,
+ is_named_key,
+ key_code,
+ key_display,
+)
+from sampletones_shared.constants.symbols import HEXADECIMAL, MINUS, PLUS
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+UNNAMED_KEY = -1
+
+IMGUI_KEY_BLOCK_START = 512
+
+
+class TestKeyDisplay(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ key: int
+ expected: str
+
+ test_cases = (
+ TestCase(label="letter", key=dpg.mvKey_A, expected="A"),
+ TestCase(label="last letter", key=dpg.mvKey_A + LETTER_COUNT - 1, expected="Z"),
+ TestCase(label="digit", key=dpg.mvKey_0, expected="0"),
+ TestCase(label="last digit", key=dpg.mvKey_0 + DIGIT_COUNT - 1, expected="9"),
+ TestCase(label="first function key", key=dpg.mvKey_F1, expected="F1"),
+ TestCase(
+ label="last function key",
+ key=dpg.mvKey_F1 + FUNCTION_KEY_COUNT - 1,
+ expected="F24",
+ ),
+ TestCase(label="escape", key=dpg.mvKey_Escape, expected="Esc"),
+ TestCase(label="page up", key=KEY_PAGE_UP, expected="PgUp"),
+ TestCase(label="page down", key=KEY_PAGE_DOWN, expected="PgDn"),
+ TestCase(label="plus", key=KEY_PLUS, expected="Plus"),
+ TestCase(label="minus", key=dpg.mvKey_Minus, expected="Minus"),
+ TestCase(label="keypad plus", key=dpg.mvKey_Add, expected="NumPlus"),
+ TestCase(label="keypad minus", key=dpg.mvKey_Subtract, expected="NumMinus"),
+ TestCase(label="keypad digit", key=dpg.mvKey_NumPad0 + 5, expected="Num5"),
+ TestCase(label="punctuation", key=dpg.mvKey_Comma, expected="Comma"),
+ TestCase(label="quote", key=KEY_QUOTE, expected="Quote"),
+ TestCase(label="semicolon", key=KEY_SEMICOLON, expected="Semicolon"),
+ TestCase(label="tilde", key=KEY_TILDE, expected="Tilde"),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_key_display(self, test_case: TestCase) -> None:
+ assert key_display(test_case.key) == test_case.expected
+
+ def test_a_key_the_table_omits_reads_as_a_placeholder(self) -> None:
+ """A combination stays displayable whatever key a press carries."""
+ assert key_display(UNNAMED_KEY) == UNKNOWN_KEY
+
+
+class TestNamedKeys(BaseTestSuite):
+ """A press reports whatever code the keyboard sends, and a binding is written on the named ones."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ key: int
+ expected: bool
+
+ test_cases = (
+ TestCase(label="a letter", key=dpg.mvKey_A, expected=True),
+ TestCase(label="a function key", key=dpg.mvKey_F5, expected=True),
+ TestCase(label="a navigation key", key=dpg.mvKey_Home, expected=True),
+ TestCase(label="a written page key", key=KEY_PAGE_UP, expected=True),
+ TestCase(label="a keypad key", key=dpg.mvKey_Add, expected=True),
+ TestCase(label="the code reserved for control", key=KEY_MODIFIER_CTRL, expected=False),
+ TestCase(label="the code reserved for shift", key=KEY_MODIFIER_SHIFT, expected=False),
+ TestCase(label="the code reserved for alt", key=KEY_MODIFIER_ALT, expected=False),
+ TestCase(label="the code reserved for super", key=KEY_MODIFIER_SUPER, expected=False),
+ TestCase(label="a browser key", key=dpg.mvKey_Browser_Back, expected=False),
+ TestCase(label="a code no key carries", key=UNNAMED_KEY, expected=False),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_is_named_key(self, test_case: TestCase) -> None:
+ assert is_named_key(test_case.key) is test_case.expected
+
+ def test_a_named_key_is_one_a_written_name_reaches(self) -> None:
+ assert all(is_named_key(key) for key in KEY_CODES.values())
+
+
+class TestReservedModifierKeys:
+ """A modifier press reports a second code, the one ImGui keeps for the modifier itself."""
+
+ def test_the_reserved_codes_run_in_the_order_they_are_written_in(self) -> None:
+ assert (KEY_MODIFIER_SHIFT, KEY_MODIFIER_ALT, KEY_MODIFIER_SUPER) == (
+ KEY_MODIFIER_CTRL + 1,
+ KEY_MODIFIER_CTRL + 2,
+ KEY_MODIFIER_CTRL + 3,
+ )
+
+ def test_a_reserved_code_sits_past_every_key_the_table_names(self) -> None:
+ assert KEY_MODIFIER_CTRL > max(KEY_DISPLAY_NAMES)
+
+
+class TestKeyCode(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ name: str
+ expected: int
+
+ test_cases = (
+ TestCase(label="letter", name="A", expected=dpg.mvKey_A),
+ TestCase(label="lower case letter", name="z", expected=dpg.mvKey_A + LETTER_COUNT - 1),
+ TestCase(label="digit", name="7", expected=dpg.mvKey_0 + 7),
+ TestCase(label="function key", name="F11", expected=dpg.mvKey_F1 + 10),
+ TestCase(label="lower case function key", name="f11", expected=dpg.mvKey_F1 + 10),
+ TestCase(label="page down", name="PgDn", expected=KEY_PAGE_DOWN),
+ TestCase(label="upper case page down", name="PGDN", expected=KEY_PAGE_DOWN),
+ TestCase(label="plus", name="Plus", expected=KEY_PLUS),
+ TestCase(label="keypad plus", name="NumPlus", expected=dpg.mvKey_Add),
+ TestCase(label="the plus glyph", name=PLUS, expected=KEY_PLUS),
+ TestCase(label="the key the plus glyph shares", name="=", expected=KEY_PLUS),
+ TestCase(label="the minus glyph", name=MINUS, expected=dpg.mvKey_Minus),
+ TestCase(label="the keypad plus glyph", name=f"Num{PLUS}", expected=dpg.mvKey_Add),
+ TestCase(label="a spelling from the key constant", name="Add", expected=dpg.mvKey_Add),
+ TestCase(label="a written page name", name="PageUp", expected=KEY_PAGE_UP),
+ TestCase(label="a written escape", name="escape", expected=dpg.mvKey_Escape),
+ TestCase(label="a punctuation glyph", name="/", expected=dpg.mvKey_Slash),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_key_code(self, test_case: TestCase) -> None:
+ assert key_code(test_case.name) == test_case.expected
+
+ def test_a_name_the_table_holds_no_key_under_raises(self) -> None:
+ with pytest.raises(KeyError):
+ key_code("Nonesuch")
+
+
+class TestKeyTable:
+ def test_every_named_key_reads_back_as_itself(self) -> None:
+ """A binding written down and read back arrives at the key it was written from."""
+ assert all(key_code(name) == key for key, name in KEY_DISPLAY_NAMES.items())
+
+ def test_each_key_carries_a_name_of_its_own(self) -> None:
+ """Distinct names are what let a written combination name exactly one key."""
+ assert len(set(KEY_DISPLAY_NAMES.values())) == len(KEY_DISPLAY_NAMES)
+
+ def test_every_accepted_spelling_reaches_a_named_key(self) -> None:
+ assert all(alias.casefold() in KEY_CODES for alias in KEY_NAME_ALIASES)
+
+ def test_a_spelling_reaches_the_key_it_names(self) -> None:
+ assert all(key_display(key_code(alias)) == name for alias, name in KEY_NAME_ALIASES.items())
+
+ def test_every_key_sits_in_the_block_a_press_reports_from(self) -> None:
+ """A press reports an ImGuiKey, so every key the table names carries a code from that
+ block."""
+ assert all(key >= IMGUI_KEY_BLOCK_START for key in KEY_DISPLAY_NAMES)
+
+ def test_the_function_keys_are_the_keys_the_function_names_carry(self) -> None:
+ assert FUNCTION_KEYS == frozenset(FUNCTION_KEY_NAMES)
+
+
+class TestWrittenKeys(BaseTestSuite):
+ """The codes written out in the table are the ones a press carries, each seated between the two
+ keys DearPyGui names on either side of it."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ preceding: int
+ key: int
+ following: int
+
+ test_cases = (
+ TestCase(label="page up", preceding=dpg.mvKey_Down, key=KEY_PAGE_UP, following=KEY_PAGE_DOWN),
+ TestCase(label="page down", preceding=KEY_PAGE_UP, key=KEY_PAGE_DOWN, following=dpg.mvKey_Home),
+ TestCase(
+ label="left super",
+ preceding=dpg.mvKey_LAlt,
+ key=KEY_LEFT_SUPER,
+ following=dpg.mvKey_RControl,
+ ),
+ TestCase(
+ label="right super",
+ preceding=dpg.mvKey_RAlt,
+ key=KEY_RIGHT_SUPER,
+ following=dpg.mvKey_Menu,
+ ),
+ TestCase(
+ label="quote",
+ preceding=dpg.mvKey_F1 + FUNCTION_KEY_COUNT - 1,
+ key=KEY_QUOTE,
+ following=dpg.mvKey_Comma,
+ ),
+ TestCase(label="semicolon", preceding=dpg.mvKey_Slash, key=KEY_SEMICOLON, following=KEY_PLUS),
+ TestCase(label="plus", preceding=KEY_SEMICOLON, key=KEY_PLUS, following=dpg.mvKey_Open_Brace),
+ TestCase(
+ label="tilde",
+ preceding=dpg.mvKey_Close_Brace,
+ key=KEY_TILDE,
+ following=dpg.mvKey_CapsLock,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_a_written_key_seats_between_the_keys_it_neighbours(self, test_case: TestCase) -> None:
+ assert test_case.key == test_case.preceding + 1
+ assert test_case.following == test_case.key + 1
+
+
+class TestCharacterKeys:
+ def test_every_hexadecimal_digit_is_reachable(self) -> None:
+ assert set(HEX_KEYS.values()) == set(HEXADECIMAL)
+
+ def test_a_digit_key_enters_its_digit(self) -> None:
+ assert HEX_KEYS[dpg.mvKey_0] == "0"
+
+ def test_a_letter_key_enters_the_digit_it_stands_for(self) -> None:
+ assert HEX_KEYS[dpg.mvKey_A + 5] == "F"
+
+ def test_both_keys_of_a_sign_enter_it(self) -> None:
+ """A keypad key enters the sign its main-row twin does."""
+ assert SIGN_KEYS[dpg.mvKey_Add] == SIGN_KEYS[KEY_PLUS] == PLUS
+ assert SIGN_KEYS[dpg.mvKey_Subtract] == SIGN_KEYS[dpg.mvKey_Minus] == MINUS
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py
index 34e911f3..96a61785 100644
--- a/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py
@@ -1,20 +1,34 @@
+import platform
from dataclasses import dataclass
from typing import List, Tuple
import dearpygui.dearpygui as dpg
import pytest
+from sampletones_application.utils.gui.keyboard.keys import (
+ KEY_LEFT_SUPER,
+ KEY_MODIFIER_ALT,
+ KEY_MODIFIER_CTRL,
+ KEY_MODIFIER_SHIFT,
+ KEY_MODIFIER_SUPER,
+ KEY_RIGHT_SUPER,
+)
from sampletones_application.utils.gui.keyboard.modifiers import (
ALT,
CTRL,
CTRL_ALT,
CTRL_ALT_SHIFT,
CTRL_SHIFT,
+ MODIFIER_NAMES,
NO_MODIFIERS,
+ RESERVED_MODIFIER_KEYS,
SHIFT,
+ SUPER,
Modifier,
ModifierSet,
capture_modifiers,
+ is_modifier_key,
+ modifier_display,
modifiers_display,
)
from tests.suite.base import BaseTestSuite
@@ -26,10 +40,12 @@
R_SHIFT = dpg.mvKey_RShift
L_ALT = dpg.mvKey_LAlt
R_ALT = dpg.mvKey_RAlt
+L_SUPER = KEY_LEFT_SUPER
+R_SUPER = KEY_RIGHT_SUPER
def _hold(monkeypatch: pytest.MonkeyPatch, held: List[int]) -> None:
- """Reports ``held`` as the keys DearPyGui sees down, leaving its key codes as they are."""
+ """Reports ``held`` as the keys DearPyGui sees down."""
monkeypatch.setattr(dpg, "is_key_down", lambda key: key in held)
@@ -39,7 +55,7 @@ class TestCase(BaseRegularTestCase):
held: List[int]
expected: ModifierSet
- test_cases = [
+ test_cases = (
TestCase(label="no modifier held", held=[], expected=NO_MODIFIERS),
TestCase(label="left control", held=[L_CONTROL], expected=CTRL),
TestCase(label="right control", held=[R_CONTROL], expected=CTRL),
@@ -47,43 +63,198 @@ class TestCase(BaseRegularTestCase):
TestCase(label="right shift", held=[R_SHIFT], expected=SHIFT),
TestCase(label="left alt", held=[L_ALT], expected=ALT),
TestCase(label="right alt", held=[R_ALT], expected=ALT),
+ TestCase(label="left super", held=[L_SUPER], expected=SUPER),
+ TestCase(label="right super", held=[R_SUPER], expected=SUPER),
TestCase(label="control and shift", held=[L_CONTROL, R_SHIFT], expected=CTRL_SHIFT),
TestCase(label="control and alt", held=[R_CONTROL, L_ALT], expected=CTRL_ALT),
- TestCase(label="every modifier", held=[L_CONTROL, L_SHIFT, L_ALT], expected=CTRL_ALT_SHIFT),
- ]
+ TestCase(
+ label="control, alt and shift",
+ held=[L_CONTROL, L_SHIFT, L_ALT],
+ expected=CTRL_ALT_SHIFT,
+ ),
+ TestCase(
+ label="every modifier",
+ held=[L_CONTROL, L_SHIFT, L_ALT, L_SUPER],
+ expected=frozenset(Modifier),
+ ),
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
- def test_capture_modifiers(self, test_case: TestCase, monkeypatch: pytest.MonkeyPatch) -> None:
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_capture_modifiers(
+ self,
+ test_case: TestCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
_hold(monkeypatch, test_case.held)
assert capture_modifiers() == test_case.expected
- def test_both_keys_of_one_modifier_report_it_once(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_both_keys_of_one_modifier_report_it_once(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
_hold(monkeypatch, [L_CONTROL, R_CONTROL])
assert capture_modifiers() == CTRL
+class TestModifierKeys(BaseTestSuite):
+ """A modifier reaches a handler twice, under its own key and under the code reserved for it."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ key: int
+ expected: bool
+
+ test_cases = (
+ TestCase(label="left control", key=L_CONTROL, expected=True),
+ TestCase(label="right control", key=R_CONTROL, expected=True),
+ TestCase(label="left shift", key=L_SHIFT, expected=True),
+ TestCase(label="right shift", key=R_SHIFT, expected=True),
+ TestCase(label="left alt", key=L_ALT, expected=True),
+ TestCase(label="right alt", key=R_ALT, expected=True),
+ TestCase(label="left super", key=L_SUPER, expected=True),
+ TestCase(label="right super", key=R_SUPER, expected=True),
+ TestCase(label="the code reserved for control", key=KEY_MODIFIER_CTRL, expected=True),
+ TestCase(label="the code reserved for shift", key=KEY_MODIFIER_SHIFT, expected=True),
+ TestCase(label="the code reserved for alt", key=KEY_MODIFIER_ALT, expected=True),
+ TestCase(label="the code reserved for super", key=KEY_MODIFIER_SUPER, expected=True),
+ TestCase(label="a letter", key=dpg.mvKey_G, expected=False),
+ TestCase(label="a navigation key", key=dpg.mvKey_Home, expected=False),
+ TestCase(label="the menu key", key=dpg.mvKey_Menu, expected=False),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_is_modifier_key(self, test_case: TestCase) -> None:
+ assert is_modifier_key(test_case.key) is test_case.expected
+
+ def test_every_modifier_carries_a_code_of_its_own(self) -> None:
+ assert set(RESERVED_MODIFIER_KEYS) == set(Modifier)
+
+
class TestModifiersDisplay(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
modifiers: ModifierSet
expected: Tuple[str, ...]
- test_cases = [
+ test_cases = (
TestCase(label="no modifier", modifiers=NO_MODIFIERS, expected=()),
TestCase(label="control", modifiers=CTRL, expected=("Ctrl",)),
TestCase(label="shift", modifiers=SHIFT, expected=("Shift",)),
TestCase(label="alt", modifiers=ALT, expected=("Alt",)),
TestCase(label="control and shift", modifiers=CTRL_SHIFT, expected=("Ctrl", "Shift")),
TestCase(label="control and alt", modifiers=CTRL_ALT, expected=("Ctrl", "Alt")),
- TestCase(label="every modifier", modifiers=CTRL_ALT_SHIFT, expected=("Ctrl", "Alt", "Shift")),
- ]
+ TestCase(
+ label="control, alt and shift",
+ modifiers=CTRL_ALT_SHIFT,
+ expected=("Ctrl", "Alt", "Shift"),
+ ),
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_modifiers_display(self, test_case: TestCase) -> None:
assert modifiers_display(test_case.modifiers) == test_case.expected
- def test_the_order_a_caller_names_its_modifiers_leaves_the_display_unchanged(self) -> None:
+ def test_the_order_a_caller_names_its_modifiers_leaves_the_display_unchanged(
+ self,
+ ) -> None:
"""One combination reads the same wherever it is shown, whatever order it was declared in."""
- assert modifiers_display(frozenset({Modifier.SHIFT, Modifier.CTRL})) == ("Ctrl", "Shift")
+ assert modifiers_display(frozenset({Modifier.SHIFT, Modifier.CTRL})) == (
+ "Ctrl",
+ "Shift",
+ )
+
+ def test_the_super_key_leads_the_combination_it_is_part_of(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ monkeypatch.setattr(platform, "system", lambda: "Linux")
+
+ assert modifiers_display(frozenset({Modifier.SHIFT, Modifier.SUPER})) == (
+ "Super",
+ "Shift",
+ )
+
+
+class TestSuperName(BaseTestSuite):
+ """One key wears three names, so a combination reads the way the keyboard is labelled."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ system: str
+ expected: str
+
+ test_cases = (
+ TestCase(label="linux", system="Linux", expected="Super"),
+ TestCase(label="windows", system="Windows", expected="Win"),
+ TestCase(label="macos", system="Darwin", expected="Cmd"),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_super_key_reads_as_the_platform_labels_it(
+ self,
+ test_case: TestCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ monkeypatch.setattr(platform, "system", lambda: test_case.system)
+
+ assert modifier_display(Modifier.SUPER) == test_case.expected
+
+ def test_every_other_modifier_reads_the_same_everywhere(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ monkeypatch.setattr(platform, "system", lambda: "Darwin")
+
+ assert modifier_display(Modifier.CTRL) == "Ctrl"
+
+
+class TestModifierNames(BaseTestSuite):
+ """Every spelling is readable on every platform, which lets one platform's scheme be read on
+ another."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ name: str
+ expected: Modifier
+
+ test_cases = (
+ TestCase(label="ctrl", name="ctrl", expected=Modifier.CTRL),
+ TestCase(label="control", name="control", expected=Modifier.CTRL),
+ TestCase(label="alt", name="alt", expected=Modifier.ALT),
+ TestCase(label="option", name="option", expected=Modifier.ALT),
+ TestCase(label="shift", name="shift", expected=Modifier.SHIFT),
+ TestCase(label="super", name="super", expected=Modifier.SUPER),
+ TestCase(label="cmd", name="cmd", expected=Modifier.SUPER),
+ TestCase(label="command", name="command", expected=Modifier.SUPER),
+ TestCase(label="win", name="win", expected=Modifier.SUPER),
+ TestCase(label="meta", name="meta", expected=Modifier.SUPER),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_a_spelling_names_its_modifier(self, test_case: TestCase) -> None:
+ assert MODIFIER_NAMES[test_case.name] == test_case.expected
+
+ def test_every_modifier_answers_to_the_name_it_displays_under(self) -> None:
+ assert all(modifier.value.casefold() in MODIFIER_NAMES for modifier in Modifier)
diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_router.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_router.py
index 8ea75931..74e0ab33 100644
--- a/tests/unit/sampletones_application/utils/gui/keyboard/test_router.py
+++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_router.py
@@ -68,8 +68,16 @@ def test_walk_continues_until_a_scope_claims(self) -> None:
def test_inactive_scope_is_skipped(self) -> None:
router = KeyRouter()
log: List[str] = []
- router.register(_recorder(log, "inactive", True), priority=PRIORITY_MODAL, active=lambda: False)
- router.register(_recorder(log, "active", True), priority=PRIORITY_SHORTCUT, active=lambda: True)
+ router.register(
+ _recorder(log, "inactive", True),
+ priority=PRIORITY_MODAL,
+ active=lambda: False,
+ )
+ router.register(
+ _recorder(log, "active", True),
+ priority=PRIORITY_SHORTCUT,
+ active=lambda: True,
+ )
router.route(_event())
@@ -77,7 +85,11 @@ def test_inactive_scope_is_skipped(self) -> None:
def test_unclaimed_event_reports_not_handled(self) -> None:
router = KeyRouter()
- router.register(_recorder([], "declines", False), priority=PRIORITY_SHORTCUT, active=lambda: True)
+ router.register(
+ _recorder([], "declines", False),
+ priority=PRIORITY_SHORTCUT,
+ active=lambda: True,
+ )
assert not router.route(_event())
@@ -109,7 +121,11 @@ def test_pop_without_a_modal_stays_closed(self) -> None:
def test_an_open_modal_claims_the_key_and_suppresses_lower_scopes(self) -> None:
router = KeyRouter()
log: List[str] = []
- router.register(_recorder(log, "shortcut", True), priority=PRIORITY_SHORTCUT, active=lambda: True)
+ router.register(
+ _recorder(log, "shortcut", True),
+ priority=PRIORITY_SHORTCUT,
+ active=lambda: True,
+ )
modal = _RecordingModal()
router.push_modal(modal)
@@ -134,7 +150,11 @@ def test_the_topmost_modal_receives_the_key(self) -> None:
def test_a_closed_modal_returns_the_keyboard_to_lower_scopes(self) -> None:
router = KeyRouter()
log: List[str] = []
- router.register(_recorder(log, "shortcut", True), priority=PRIORITY_SHORTCUT, active=lambda: True)
+ router.register(
+ _recorder(log, "shortcut", True),
+ priority=PRIORITY_SHORTCUT,
+ active=lambda: True,
+ )
router.push_modal(_RecordingModal())
router.pop_modal()
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/conftest.py b/tests/unit/sampletones_application/utils/gui/shortcuts/conftest.py
new file mode 100644
index 00000000..b8f50a7a
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/conftest.py
@@ -0,0 +1,38 @@
+from typing import Callable, Dict, Final
+
+import pytest
+
+from sampletones_application.paths import KEYBINDINGS_DIRECTORY
+from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.utils.gui.shortcuts.written import WrittenShortcut
+
+PROBE_SCHEME_NAME: Final[str] = "probe"
+
+RebindScheme = Callable[[Dict[ShortcutId, WrittenShortcut]], ShortcutScheme]
+
+
+@pytest.fixture(scope="session")
+def shipped() -> ShortcutScheme:
+ """The scheme the build ships, which a case starts from since a scheme answers every action."""
+ return ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).default
+
+
+@pytest.fixture
+def rebound(shipped: ShortcutScheme) -> RebindScheme:
+ """Builds a scheme that differs from the shipped one in the actions a case names."""
+
+ def build(overrides: Dict[ShortcutId, WrittenShortcut]) -> ShortcutScheme:
+ return ShortcutScheme(
+ name=PROBE_SCHEME_NAME,
+ bindings={**shipped.bindings, **overrides},
+ )
+
+ return build
+
+
+@pytest.fixture
+def source(shipped: ShortcutScheme) -> ShortcutSource:
+ return ShortcutSource(shipped)
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py
new file mode 100644
index 00000000..75325efe
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py
@@ -0,0 +1,97 @@
+from pathlib import Path
+
+import pytest
+
+from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME
+from sampletones_application.paths import KEYBINDINGS_DIRECTORY
+from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
+
+SHIPPED_FILE = KEYBINDINGS_DIRECTORY / f"{DEFAULT_SCHEME_NAME}{EXT_FILE_YAML}"
+SHIPPED_SCHEME_NAMES = ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).names
+COMPACT_SCHEME_NAME = "compact"
+
+
+def _named(name: str) -> str:
+ """The shipped scheme written under another name, which is what a second scheme differs in."""
+ return SHIPPED_FILE.read_text().replace(f"name: {DEFAULT_SCHEME_NAME}", f"name: {name}", 1)
+
+
+@pytest.fixture
+def directory(tmp_path: Path) -> Path:
+ (tmp_path / f"{DEFAULT_SCHEME_NAME}{EXT_FILE_YAML}").write_text(SHIPPED_FILE.read_text())
+ (tmp_path / f"{COMPACT_SCHEME_NAME}{EXT_FILE_YAML}").write_text(_named(COMPACT_SCHEME_NAME))
+ return tmp_path
+
+
+class TestLoadCatalog:
+ def test_every_scheme_in_the_directory_is_indexed_by_name(
+ self,
+ directory: Path,
+ ) -> None:
+ assert ShortcutCatalog.load(directory).names == (COMPACT_SCHEME_NAME, DEFAULT_SCHEME_NAME)
+
+ def test_an_empty_directory_raises_system_error(
+ self,
+ tmp_path: Path,
+ ) -> None:
+ with pytest.raises(SystemError):
+ ShortcutCatalog.load(tmp_path)
+
+ def test_a_directory_omitting_the_default_scheme_raises_system_error(
+ self,
+ tmp_path: Path,
+ ) -> None:
+ (tmp_path / f"{COMPACT_SCHEME_NAME}{EXT_FILE_YAML}").write_text(_named(COMPACT_SCHEME_NAME))
+
+ with pytest.raises(SystemError):
+ ShortcutCatalog.load(tmp_path)
+
+ def test_a_scheme_named_apart_from_its_file_raises(
+ self,
+ directory: Path,
+ ) -> None:
+ (directory / f"tracker{EXT_FILE_YAML}").write_text(_named(COMPACT_SCHEME_NAME))
+
+ with pytest.raises(ValueError):
+ ShortcutCatalog.load(directory)
+
+
+class TestSelectScheme:
+ def test_a_known_name_selects_that_scheme(
+ self,
+ directory: Path,
+ ) -> None:
+ assert ShortcutCatalog.load(directory).select(COMPACT_SCHEME_NAME).name == COMPACT_SCHEME_NAME
+
+ def test_an_unknown_name_falls_back_to_the_default(
+ self,
+ directory: Path,
+ ) -> None:
+ assert ShortcutCatalog.load(directory).select("vintage").name == DEFAULT_SCHEME_NAME
+
+ def test_an_unknown_name_raises_when_looked_up_directly(
+ self,
+ directory: Path,
+ ) -> None:
+ with pytest.raises(KeyError):
+ ShortcutCatalog.load(directory).get("vintage")
+
+
+class TestShippedSchemes:
+ """Every scheme the build ships, held to what a scheme in use must answer.
+
+ Loading is what proves it: a scheme reads its keys at load, so a name the key table holds none
+ of and a combination two actions of one category claim both fail here, on whichever platform
+ the suite runs.
+ """
+
+ def test_the_build_ships_a_default_scheme(self) -> None:
+ assert DEFAULT_SCHEME_NAME in SHIPPED_SCHEME_NAMES
+
+ @pytest.mark.parametrize("name", SHIPPED_SCHEME_NAMES)
+ def test_a_shipped_scheme_answers_every_action(self, name: str) -> None:
+ catalog = ShortcutCatalog.load(KEYBINDINGS_DIRECTORY)
+
+ assert set(catalog.get(name).bindings) == set(ShortcutId)
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py
new file mode 100644
index 00000000..183e31be
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py
@@ -0,0 +1,367 @@
+from dataclasses import dataclass
+from typing import Dict, Optional
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.keys import (
+ KEY_DISPLAY_NAMES,
+ KEY_MODIFIER_ALT,
+ KEY_MODIFIER_CTRL,
+)
+from sampletones_application.utils.gui.keyboard.modifiers import ALT, CTRL, NO_MODIFIERS
+from sampletones_application.utils.gui.shortcuts.draft import ShortcutDraft
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId
+from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+FREE_COMBINATION = "Ctrl+Alt+B"
+TABLE_COMBINATION = "Del"
+
+UNNAMED_KEY = -1
+
+
+@pytest.fixture
+def draft(shipped: ShortcutScheme) -> ShortcutDraft:
+ """A draft of the shipped scheme, opened on a session that stores no preference of its own."""
+ return ShortcutDraft.open(shipped, {})
+
+
+class TestOpen:
+ def test_a_session_storing_nothing_opens_on_the_keys_the_scheme_ships(self, draft: ShortcutDraft) -> None:
+ assert draft.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Z")
+
+ def test_a_draft_opens_on_what_the_session_holds(self, shipped: ShortcutScheme) -> None:
+ """A dialog asks whether the reader changed anything, which counts from the moment it opened."""
+ assert ShortcutDraft.open(shipped, {"Undo": "Ctrl+Alt+U"}).is_dirty is False
+
+ def test_a_stored_override_opens_as_the_keys_its_action_answers(self, shipped: ShortcutScheme) -> None:
+ draft = ShortcutDraft.open(shipped, {"Undo": "Ctrl+Alt+U"})
+
+ assert draft.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Alt+U")
+
+ def test_a_stored_override_reads_back_as_the_preference_it_came_from(self, shipped: ShortcutScheme) -> None:
+ overrides: Dict[str, Optional[str]] = {"Undo": "Ctrl+Alt+U"}
+
+ assert ShortcutDraft.open(shipped, overrides).overrides() == overrides
+
+ def test_a_stored_override_stating_no_combination_opens_unbound(self, shipped: ShortcutScheme) -> None:
+ draft = ShortcutDraft.open(shipped, {"Undo": None})
+
+ assert draft.combination(ShortcutId.UNDO) is None
+
+ def test_a_stored_override_dropping_the_aliases_alone_opens_as_an_edit(self, shipped: ShortcutScheme) -> None:
+ """An override states the whole of what reaches an action, so the aliases go with it."""
+ draft = ShortcutDraft.open(shipped, {"OrderInsertFrame": "Plus"})
+
+ assert draft.overrides() == {"OrderInsertFrame": "Plus"}
+
+ def test_a_stored_override_this_build_carries_no_action_for_stays_behind(self, shipped: ShortcutScheme) -> None:
+ """A preference outlives the build that stored it, so a stale entry costs only itself."""
+ draft = ShortcutDraft.open(shipped, {"PlayLouder": "Ctrl+K", "Undo": "Ctrl+Alt+U"})
+
+ assert draft.overrides() == {"Undo": "Ctrl+Alt+U"}
+
+ def test_a_stored_override_its_category_already_answers_stays_behind(self, shipped: ShortcutScheme) -> None:
+ draft = ShortcutDraft.open(shipped, {"AboutDialog": "Ctrl+S"})
+
+ assert draft.overrides() == {}
+
+
+class TestCombination:
+ def test_an_untouched_action_reads_the_keys_the_scheme_gives_it(self, draft: ShortcutDraft) -> None:
+ assert draft.combination(ShortcutId.SAVE_PROJECT) == KeyCombination.parse("Ctrl+S")
+
+ def test_an_assigned_action_reads_the_keys_the_reader_gave_it(self, draft: ShortcutDraft) -> None:
+ edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION))
+
+ assert edited.combination(ShortcutId.UNDO) == KeyCombination.parse(FREE_COMBINATION)
+
+ def test_a_cleared_action_reads_as_unbound(self, draft: ShortcutDraft) -> None:
+ edited = draft.clear(ShortcutId.UNDO)
+
+ assert edited.combination(ShortcutId.UNDO) is None
+
+ def test_an_action_the_scheme_leaves_unbound_reads_as_unbound(self, draft: ShortcutDraft) -> None:
+ assert draft.combination(ShortcutId.ABOUT_DIALOG) is None
+
+
+class TestClaimant:
+ def test_the_action_holding_a_combination_answers_for_it(self, draft: ShortcutDraft) -> None:
+ claimant = draft.claimant(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+S"))
+
+ assert claimant is ShortcutId.SAVE_PROJECT
+
+ def test_an_alias_is_held_as_firmly_as_the_combination_it_extends(self, draft: ShortcutDraft) -> None:
+ """An assignment takes every key that reaches the holder, aliases included."""
+ claimant = draft.claimant(ShortcutId.ORDER_ADD_FRAME, KeyCombination.parse("NumPlus"))
+
+ assert claimant is ShortcutId.ORDER_INSERT_FRAME
+
+ def test_a_combination_no_action_of_the_category_holds_is_free(self, draft: ShortcutDraft) -> None:
+ assert draft.claimant(ShortcutId.ABOUT_DIALOG, KeyCombination.parse(FREE_COMBINATION)) is None
+
+ def test_a_combination_another_category_holds_is_free(self, draft: ShortcutDraft) -> None:
+ assert draft.claimant(ShortcutId.ABOUT_DIALOG, KeyCombination.parse(TABLE_COMBINATION)) is None
+
+ def test_an_action_holds_its_own_keys_against_no_one(self, draft: ShortcutDraft) -> None:
+ """Giving an action the keys it already answers is the reader confirming them."""
+ assert draft.claimant(ShortcutId.UNDO, KeyCombination.parse("Ctrl+Z")) is None
+
+ def test_the_keys_an_edit_left_behind_are_free(self, draft: ShortcutDraft) -> None:
+ edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION))
+
+ assert edited.claimant(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+Z")) is None
+
+ def test_the_aliases_an_edit_left_behind_are_free(self, draft: ShortcutDraft) -> None:
+ edited = draft.assign(ShortcutId.ORDER_INSERT_FRAME, KeyCombination.parse("Ctrl+Alt+I"))
+
+ assert edited.claimant(ShortcutId.ORDER_ADD_FRAME, KeyCombination.parse("NumPlus")) is None
+
+
+class TestAssign(BaseTestSuite):
+ """An assignment takes the combination from whichever action of the category holds it."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ shortcut_id: ShortcutId
+ written: str
+ holder: ShortcutId
+
+ test_cases = (
+ TestCase(
+ label="a combination another action displays",
+ shortcut_id=ShortcutId.ABOUT_DIALOG,
+ written="Ctrl+S",
+ holder=ShortcutId.SAVE_PROJECT,
+ ),
+ TestCase(
+ label="an alias another action answers",
+ shortcut_id=ShortcutId.ORDER_ADD_FRAME,
+ written="NumPlus",
+ holder=ShortcutId.ORDER_INSERT_FRAME,
+ ),
+ TestCase(
+ label="a combination held in another category too",
+ shortcut_id=ShortcutId.SAMPLES_MOVE_SAMPLE_UP,
+ written="F2",
+ holder=ShortcutId.SAMPLES_RENAME_SAMPLE,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_action_that_held_the_combination_is_left_unbound(
+ self,
+ test_case: TestCase,
+ draft: ShortcutDraft,
+ ) -> None:
+ edited = draft.assign(test_case.shortcut_id, KeyCombination.parse(test_case.written))
+
+ assert edited.combination(test_case.holder) is None
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_scheme_the_assignment_produces_reaches_the_action_it_named(
+ self,
+ test_case: TestCase,
+ draft: ShortcutDraft,
+ ) -> None:
+ combination = KeyCombination.parse(test_case.written)
+ scheme = draft.assign(test_case.shortcut_id, combination).scheme()
+
+ assert scheme.claimant(test_case.shortcut_id.category, combination) is test_case.shortcut_id
+
+ def test_an_assignment_leaves_the_draft_holding_keys_to_store(self, draft: ShortcutDraft) -> None:
+ edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION))
+
+ assert edited.is_dirty is True
+
+ def test_the_actions_an_assignment_leaves_alone_keep_their_keys(self, draft: ShortcutDraft) -> None:
+ edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION))
+
+ assert edited.combination(ShortcutId.REDO) == KeyCombination.parse("Ctrl+Y")
+
+ def test_an_action_given_the_keys_it_already_answers_keeps_them(self, draft: ShortcutDraft) -> None:
+ edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse("Ctrl+Z"))
+
+ assert edited.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Z")
+
+
+class TestUnwritableCombination(BaseTestSuite):
+ """An edit is held to the keys the table names, which is what a stored preference is written in.
+
+ A press reports whatever code the keyboard sends — a modifier arrives under a code of its own,
+ and a keyboard carries keys past the ones a binding is spelled with — so a combination reaches
+ the draft that no scheme could hold.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ combination: KeyCombination
+
+ test_cases = (
+ TestCase(
+ label="the code reserved for alt",
+ combination=KeyCombination(KEY_MODIFIER_ALT, ALT),
+ ),
+ TestCase(
+ label="the code reserved for control",
+ combination=KeyCombination(KEY_MODIFIER_CTRL, CTRL),
+ ),
+ TestCase(
+ label="a key the table names none of",
+ combination=KeyCombination(dpg.mvKey_Browser_Back, NO_MODIFIERS),
+ ),
+ TestCase(
+ label="a code no key carries",
+ combination=KeyCombination(UNNAMED_KEY, CTRL),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_assigning_it_is_refused(self, test_case: TestCase, draft: ShortcutDraft) -> None:
+ with pytest.raises(KeyError):
+ draft.assign(ShortcutId.UNDO, test_case.combination)
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_action_keeps_the_keys_it_had(self, test_case: TestCase, draft: ShortcutDraft) -> None:
+ with pytest.raises(KeyError):
+ draft.assign(ShortcutId.UNDO, test_case.combination)
+
+ assert draft.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Z")
+ assert draft.is_dirty is False
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_it_reaches_no_action_of_the_scope(self, test_case: TestCase, draft: ShortcutDraft) -> None:
+ """A combination no action can be given is one no action holds, so none is asked for it."""
+ assert draft.claimant(ShortcutId.UNDO, test_case.combination) is None
+
+
+class TestClear:
+ def test_a_cleared_action_stores_as_unbound(self, draft: ShortcutDraft) -> None:
+ edited = draft.clear(ShortcutId.UNDO)
+
+ assert edited.overrides() == {"Undo": None}
+
+ def test_the_keys_a_cleared_action_held_are_free_for_another(self, draft: ShortcutDraft) -> None:
+ edited = draft.clear(ShortcutId.UNDO)
+
+ assert edited.claimant(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+Z")) is None
+
+ def test_the_scheme_a_cleared_action_produces_leaves_its_keys_unclaimed(self, draft: ShortcutDraft) -> None:
+ scheme = draft.clear(ShortcutId.UNDO).scheme()
+
+ assert scheme.claimant(ShortcutCategory.APPLICATION, KeyCombination.parse("Ctrl+Z")) is None
+
+
+class TestReset:
+ def test_a_reset_draft_reads_the_keys_the_scheme_ships(self, shipped: ShortcutScheme) -> None:
+ draft = ShortcutDraft.open(shipped, {"Undo": "Ctrl+Alt+U"}).reset()
+
+ assert draft.combination(ShortcutId.UNDO) == KeyCombination.parse("Ctrl+Z")
+
+ def test_a_reset_draft_stores_no_override(self, shipped: ShortcutScheme) -> None:
+ draft = ShortcutDraft.open(shipped, {"Undo": "Ctrl+Alt+U"}).reset()
+
+ assert draft.overrides() == {}
+
+ def test_a_reset_over_a_stored_preference_leaves_keys_to_store(self, shipped: ShortcutScheme) -> None:
+ draft = ShortcutDraft.open(shipped, {"Undo": "Ctrl+Alt+U"}).reset()
+
+ assert draft.is_dirty is True
+
+ def test_a_reset_of_a_draft_on_the_shipped_keys_leaves_it_as_it_was(self, draft: ShortcutDraft) -> None:
+ assert draft.reset().is_dirty is False
+
+
+class TestScheme:
+ def test_the_scheme_answers_the_keys_the_reader_gave(self, draft: ShortcutDraft) -> None:
+ scheme = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)).scheme()
+
+ assert scheme.shortcut(ShortcutId.UNDO).display() == FREE_COMBINATION
+
+ def test_an_untouched_action_keeps_the_aliases_the_scheme_ships(self, draft: ShortcutDraft) -> None:
+ scheme = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION)).scheme()
+
+ assert scheme.shortcut(ShortcutId.REDO).aliases == (KeyCombination.parse("Ctrl+Shift+Z"),)
+
+ def test_a_touched_action_answers_the_combination_it_was_given_alone(self, draft: ShortcutDraft) -> None:
+ scheme = draft.assign(ShortcutId.ORDER_INSERT_FRAME, KeyCombination.parse("Ctrl+Alt+I")).scheme()
+
+ assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).aliases == ()
+
+ def test_two_actions_trade_the_combinations_they_held(self, draft: ShortcutDraft) -> None:
+ """Every edit is read at once, so a swap arrives without either action holding both keys."""
+ edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse("Ctrl+Y")).assign(
+ ShortcutId.REDO,
+ KeyCombination.parse("Ctrl+Z"),
+ )
+ scheme = edited.scheme()
+
+ assert scheme.claimant(ShortcutCategory.APPLICATION, KeyCombination.parse("Ctrl+Y")) is ShortcutId.UNDO
+ assert scheme.claimant(ShortcutCategory.APPLICATION, KeyCombination.parse("Ctrl+Z")) is ShortcutId.REDO
+
+ def test_a_draft_on_the_shipped_keys_produces_the_scheme_it_opened_on(self, draft: ShortcutDraft) -> None:
+ assert draft.scheme().bindings == draft.base.bindings
+
+ def test_every_key_the_table_names_produces_a_scheme_that_resolves(self, draft: ShortcutDraft) -> None:
+ """What a reader may assign is what the application then runs on, key for key."""
+ assigned = {key: draft.assign(ShortcutId.UNDO, KeyCombination(key, CTRL)).scheme() for key in KEY_DISPLAY_NAMES}
+
+ assert all(
+ scheme.shortcut(ShortcutId.UNDO).combination == KeyCombination(key, CTRL)
+ for key, scheme in assigned.items()
+ )
+
+
+class TestOverrides:
+ def test_an_edit_stores_under_the_name_a_keybinding_file_writes(self, draft: ShortcutDraft) -> None:
+ edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION))
+
+ assert edited.overrides() == {"Undo": FREE_COMBINATION}
+
+ def test_an_edit_stores_the_combination_as_it_reads(self, draft: ShortcutDraft) -> None:
+ """A stored preference is written the way the dialog shows it, whatever the reader typed."""
+ edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse("shift+ctrl+alt+u"))
+
+ assert edited.overrides() == {"Undo": "Ctrl+Alt+Shift+U"}
+
+ def test_a_displaced_action_stores_as_unbound(self, draft: ShortcutDraft) -> None:
+ edited = draft.assign(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+S"))
+
+ assert edited.overrides() == {"AboutDialog": "Ctrl+S", "SaveProject": None}
+
+ def test_the_actions_the_reader_left_alone_store_nothing(self, draft: ShortcutDraft) -> None:
+ """A preference states the actions the reader touched, so the rest follow the scheme."""
+ edited = draft.assign(ShortcutId.UNDO, KeyCombination.parse(FREE_COMBINATION))
+
+ assert set(edited.overrides()) == {"Undo"}
+
+ def test_a_stored_draft_reopens_on_the_keys_it_stored(self, draft: ShortcutDraft) -> None:
+ edited = draft.assign(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+S"))
+
+ reopened = ShortcutDraft.open(draft.base, edited.overrides())
+
+ assert reopened.edits == edited.edits
+ assert reopened.is_dirty is False
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py
index fa253b3d..71eb277c 100644
--- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py
@@ -1,4 +1,4 @@
-from typing import Dict
+from typing import Dict, Iterator
from unittest.mock import Mock
import dearpygui.dearpygui as dpg
@@ -7,17 +7,20 @@
from sampletones_application.utils.gui.keyboard import KeyEvent, KeyRouter
from sampletones_application.utils.gui.keyboard import focus as focus_module
from sampletones_application.utils.gui.keyboard.focus import FieldKind
+from sampletones_application.utils.gui.keyboard.keys import KEY_PAGE_DOWN
from sampletones_application.utils.gui.keyboard.modifiers import (
CTRL,
+ CTRL_ALT,
CTRL_SHIFT,
NO_MODIFIERS,
+ SHIFT,
ModifierSet,
)
from sampletones_application.utils.gui.shortcuts.ids import ShortcutId
from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager
-from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut
-
-KEY = 65
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.utils.gui.shortcuts.written import WrittenShortcut
+from tests.unit.sampletones_application.utils.gui.shortcuts.conftest import RebindScheme
@pytest.fixture(autouse=True)
@@ -27,54 +30,205 @@ def field_kind(monkeypatch: pytest.MonkeyPatch) -> Dict[str, FieldKind]:
return state
-def _manager() -> ShortcutManager:
- return ShortcutManager(key_router=KeyRouter())
+def _manager(source: ShortcutSource, shortcut_id: ShortcutId, callback: Mock) -> ShortcutManager:
+ """A manager holding one action, its combinations read from the scheme the source carries."""
+ manager = ShortcutManager(key_router=KeyRouter(), shortcut_source=source)
+ manager.register(shortcut_id, callback)
+ manager.bind_all()
+ return manager
+
+
+def _menu_manager(source: ShortcutSource) -> ShortcutManager:
+ """A manager holding one action, its menu item created the way the menu bar creates it."""
+ manager = ShortcutManager(key_router=KeyRouter(), shortcut_source=source)
+ manager.register(ShortcutId.SAVE_PROJECT, Mock())
+ with dpg.window(), dpg.menu_bar(), dpg.menu(label="File"):
+ manager.add_menu_item(ShortcutId.SAVE_PROJECT, label="Save")
+ return manager
-def _event(key: int = KEY, *, modifiers: ModifierSet = NO_MODIFIERS) -> KeyEvent:
+
+def _event(key: int, *, modifiers: ModifierSet = NO_MODIFIERS) -> KeyEvent:
return KeyEvent(key=key, modifiers=modifiers)
+@pytest.fixture(name="dpg_context")
+def dpg_context_fixture() -> Iterator[None]:
+ dpg.create_context()
+ try:
+ yield
+ finally:
+ dpg.destroy_context()
+
+
class TestShortcutDispatch:
- def test_matching_shortcut_fires_and_is_claimed(self) -> None:
- manager = _manager()
+ def test_the_combination_the_scheme_gives_an_action_fires_it(self, source: ShortcutSource) -> None:
callback = Mock()
- manager.register(ShortcutId.SAVE_PROJECT, Shortcut(KEY, CTRL), callback)
- manager.bind_all()
+ manager = _manager(source, ShortcutId.SAVE_PROJECT, callback)
- claimed = manager._dispatch(_event(modifiers=CTRL))
+ claimed = manager._dispatch(_event(dpg.mvKey_S, modifiers=CTRL))
assert claimed
callback.assert_called_once()
- def test_modifier_mismatch_does_not_fire(self) -> None:
- manager = _manager()
+ def test_the_key_under_other_modifiers_does_not_fire(self, source: ShortcutSource) -> None:
callback = Mock()
- manager.register(ShortcutId.SAVE_PROJECT, Shortcut(KEY, CTRL), callback)
- manager.bind_all()
+ manager = _manager(source, ShortcutId.SAVE_PROJECT, callback)
- claimed = manager._dispatch(_event())
+ claimed = manager._dispatch(_event(dpg.mvKey_S))
assert not claimed
callback.assert_not_called()
- def test_alias_reaches_the_same_callback(self) -> None:
- manager = _manager()
+ def test_an_alias_reaches_the_same_callback(self, source: ShortcutSource) -> None:
callback = Mock()
- manager.register(ShortcutId.REDO, Shortcut(KEY, CTRL), callback)
- manager.register_alias(ShortcutId.REDO, Shortcut(KEY, CTRL_SHIFT))
- manager.bind_all()
+ manager = _manager(source, ShortcutId.REDO, callback)
- assert manager._dispatch(_event(modifiers=CTRL_SHIFT))
+ assert manager._dispatch(_event(dpg.mvKey_Z, modifiers=CTRL_SHIFT))
callback.assert_called_once()
+ def test_an_action_the_scheme_leaves_unassigned_answers_no_press(self, source: ShortcutSource) -> None:
+ callback = Mock()
+ manager = _manager(source, ShortcutId.ABOUT_DIALOG, callback)
+
+ assert not manager._dispatch(_event(dpg.mvKey_S, modifiers=CTRL))
+ callback.assert_not_called()
-class TestFieldFocusGate:
- def test_text_field_keeps_a_plain_space(self, field_kind: Dict[str, FieldKind]) -> None:
- manager = _manager()
+
+class TestRebind:
+ """A registration names the action, so activating another scheme changes the keys that fire it."""
+
+ def test_the_combination_the_new_scheme_gives_an_action_fires_it(
+ self,
+ source: ShortcutSource,
+ rebound: RebindScheme,
+ ) -> None:
+ callback = Mock()
+ manager = _manager(source, ShortcutId.SAVE_PROJECT, callback)
+
+ source.activate(rebound({ShortcutId.SAVE_PROJECT: WrittenShortcut(combination="Ctrl+Alt+K")}))
+ manager.rebind()
+
+ assert manager._dispatch(_event(dpg.mvKey_K, modifiers=CTRL_ALT))
+ callback.assert_called_once()
+
+ def test_the_combination_the_new_scheme_took_away_stops_firing_it(
+ self,
+ source: ShortcutSource,
+ rebound: RebindScheme,
+ ) -> None:
callback = Mock()
- manager.register(ShortcutId.PLAY, Shortcut(dpg.mvKey_Spacebar), callback)
+ manager = _manager(source, ShortcutId.SAVE_PROJECT, callback)
+
+ source.activate(rebound({ShortcutId.SAVE_PROJECT: WrittenShortcut(combination="Ctrl+Alt+K")}))
+ manager.rebind()
+
+ assert not manager._dispatch(_event(dpg.mvKey_S, modifiers=CTRL))
+ callback.assert_not_called()
+
+ def test_a_rebind_leaves_the_router_the_one_scope_it_was_given(
+ self,
+ source: ShortcutSource,
+ rebound: RebindScheme,
+ ) -> None:
+ """The scope is claimed at bind time, so repeated rebinds keep one handler on the router."""
+ router = KeyRouter()
+ manager = ShortcutManager(key_router=router, shortcut_source=source)
+ manager.register(ShortcutId.SAVE_PROJECT, Mock())
manager.bind_all()
+ scopes = len(router._scopes)
+
+ source.activate(rebound({ShortcutId.SAVE_PROJECT: WrittenShortcut(combination="Ctrl+Alt+K")}))
+ manager.rebind()
+
+ assert len(router._scopes) == scopes
+
+
+class TestMenuAccelerators:
+ """A menu item prints the keys that also fire it, which a rebind keeps true."""
+
+ def test_a_menu_item_prints_the_combination_the_scheme_gives_its_action(
+ self,
+ dpg_context: None,
+ source: ShortcutSource,
+ ) -> None:
+ manager = _menu_manager(source)
+ item = next(iter(manager._menu_items))
+
+ assert dpg.get_item_configuration(item)["shortcut"] == "Ctrl+S"
+
+ def test_a_rebind_prints_the_keys_now_in_place(
+ self,
+ dpg_context: None,
+ source: ShortcutSource,
+ rebound: RebindScheme,
+ ) -> None:
+ manager = _menu_manager(source)
+ item = next(iter(manager._menu_items))
+
+ source.activate(rebound({ShortcutId.SAVE_PROJECT: WrittenShortcut(combination="Ctrl+Alt+K")}))
+ manager.rebind()
+
+ assert dpg.get_item_configuration(item)["shortcut"] == "Ctrl+Alt+K"
+
+ def test_an_item_stating_a_call_of_its_own_makes_that_call(
+ self,
+ dpg_context: None,
+ source: ShortcutSource,
+ ) -> None:
+ """An item carrying a state to show switches the surface its check reads."""
+ action = Mock()
+ chosen = Mock()
+ manager = ShortcutManager(key_router=KeyRouter(), shortcut_source=source)
+ manager.register(ShortcutId.SAVE_PROJECT, action)
+ with dpg.window(), dpg.menu_bar(), dpg.menu(label="File"):
+ manager.add_menu_item(ShortcutId.SAVE_PROJECT, callback=chosen, label="Save")
+
+ item = next(iter(manager._menu_items))
+ dpg.get_item_callback(item)(item, None, None)
+
+ chosen.assert_called_once_with()
+ action.assert_not_called()
+
+ def test_an_item_stating_no_call_makes_the_one_its_action_registered(
+ self,
+ dpg_context: None,
+ source: ShortcutSource,
+ ) -> None:
+ action = Mock()
+ manager = ShortcutManager(key_router=KeyRouter(), shortcut_source=source)
+ manager.register(ShortcutId.SAVE_PROJECT, action)
+ with dpg.window(), dpg.menu_bar(), dpg.menu(label="File"):
+ manager.add_menu_item(ShortcutId.SAVE_PROJECT, label="Save")
+
+ item = next(iter(manager._menu_items))
+ dpg.get_item_callback(item)(item, None, None)
+
+ action.assert_called_once_with()
+
+ def test_an_item_stating_a_call_of_its_own_still_prints_its_action_keys(
+ self,
+ dpg_context: None,
+ source: ShortcutSource,
+ ) -> None:
+ manager = ShortcutManager(key_router=KeyRouter(), shortcut_source=source)
+ manager.register(ShortcutId.SAVE_PROJECT, Mock())
+ with dpg.window(), dpg.menu_bar(), dpg.menu(label="File"):
+ manager.add_menu_item(ShortcutId.SAVE_PROJECT, callback=Mock(), label="Save")
+
+ item = next(iter(manager._menu_items))
+
+ assert dpg.get_item_configuration(item)["shortcut"] == "Ctrl+S"
+
+
+class TestFieldFocusGate:
+ def test_text_field_keeps_a_plain_space(
+ self,
+ source: ShortcutSource,
+ field_kind: Dict[str, FieldKind],
+ ) -> None:
+ callback = Mock()
+ manager = _manager(source, ShortcutId.PLAY, callback)
field_kind["kind"] = FieldKind.TEXT_ENTRY
claimed = manager._dispatch(_event(dpg.mvKey_Spacebar))
@@ -82,11 +236,13 @@ def test_text_field_keeps_a_plain_space(self, field_kind: Dict[str, FieldKind])
assert not claimed
callback.assert_not_called()
- def test_text_field_yields_ctrl_space(self, field_kind: Dict[str, FieldKind]) -> None:
- manager = _manager()
+ def test_text_field_yields_ctrl_space(
+ self,
+ source: ShortcutSource,
+ field_kind: Dict[str, FieldKind],
+ ) -> None:
callback = Mock()
- manager.register(ShortcutId.PLAY_FROM_FRAME, Shortcut(dpg.mvKey_Spacebar, CTRL), callback)
- manager.bind_all()
+ manager = _manager(source, ShortcutId.PLAY_FROM_FRAME, callback)
field_kind["kind"] = FieldKind.TEXT_ENTRY
claimed = manager._dispatch(_event(dpg.mvKey_Spacebar, modifiers=CTRL))
@@ -94,36 +250,43 @@ def test_text_field_yields_ctrl_space(self, field_kind: Dict[str, FieldKind]) ->
assert claimed
callback.assert_called_once()
- def test_text_field_keeps_its_editing_chord(self, field_kind: Dict[str, FieldKind]) -> None:
- manager = _manager()
+ def test_text_field_keeps_its_editing_chord(
+ self,
+ source: ShortcutSource,
+ field_kind: Dict[str, FieldKind],
+ ) -> None:
+ """Ctrl+Z undoes the text being typed, so the application's own undo stays out of it."""
callback = Mock()
- manager.register(ShortcutId.AUDIO_SETTINGS, Shortcut(dpg.mvKey_A, CTRL), callback)
- manager.bind_all()
+ manager = _manager(source, ShortcutId.UNDO, callback)
field_kind["kind"] = FieldKind.TEXT_ENTRY
- claimed = manager._dispatch(_event(dpg.mvKey_A, modifiers=CTRL))
+ claimed = manager._dispatch(_event(dpg.mvKey_Z, modifiers=CTRL))
assert not claimed
callback.assert_not_called()
- def test_text_field_yields_a_shifted_chord_it_has_no_use_for(self, field_kind: Dict[str, FieldKind]) -> None:
- """Ctrl+Shift+A carries a chord letter without being a text chord, so the shortcut fires."""
- manager = _manager()
+ def test_text_field_yields_a_shifted_chord_it_has_no_use_for(
+ self,
+ source: ShortcutSource,
+ field_kind: Dict[str, FieldKind],
+ ) -> None:
+ """Ctrl+Shift+S is no text chord, so the shortcut fires while a field holds the keyboard."""
callback = Mock()
- manager.register(ShortcutId.TOGGLE_ADVANCED_SETTINGS, Shortcut(dpg.mvKey_A, CTRL_SHIFT), callback)
- manager.bind_all()
+ manager = _manager(source, ShortcutId.SAVE_PROJECT_AS, callback)
field_kind["kind"] = FieldKind.TEXT_ENTRY
- claimed = manager._dispatch(_event(dpg.mvKey_A, modifiers=CTRL_SHIFT))
+ claimed = manager._dispatch(_event(dpg.mvKey_S, modifiers=CTRL_SHIFT))
assert claimed
callback.assert_called_once()
- def test_focused_field_keeps_escape(self, field_kind: Dict[str, FieldKind]) -> None:
- manager = _manager()
+ def test_focused_field_keeps_escape(
+ self,
+ source: ShortcutSource,
+ field_kind: Dict[str, FieldKind],
+ ) -> None:
callback = Mock()
- manager.register(ShortcutId.STOP, Shortcut(dpg.mvKey_Escape), callback)
- manager.bind_all()
+ manager = _manager(source, ShortcutId.STOP, callback)
field_kind["kind"] = FieldKind.TEXT_ENTRY
claimed = manager._dispatch(_event(dpg.mvKey_Escape))
@@ -131,18 +294,31 @@ def test_focused_field_keeps_escape(self, field_kind: Dict[str, FieldKind]) -> N
assert not claimed
callback.assert_not_called()
- def test_field_transparent_shortcut_fires_while_focused(self, field_kind: Dict[str, FieldKind]) -> None:
- manager = _manager()
+ def test_field_transparent_shortcut_fires_while_focused(
+ self,
+ source: ShortcutSource,
+ field_kind: Dict[str, FieldKind],
+ ) -> None:
callback = Mock()
- manager.register(
- ShortcutId.NEXT_TAB,
- Shortcut(KEY, CTRL, field_transparent=True),
- callback,
- )
- manager.bind_all()
+ manager = _manager(source, ShortcutId.NEXT_TAB, callback)
field_kind["kind"] = FieldKind.TEXT_ENTRY
- claimed = manager._dispatch(_event(modifiers=CTRL))
+ claimed = manager._dispatch(_event(KEY_PAGE_DOWN, modifiers=CTRL))
assert claimed
callback.assert_called_once()
+
+ def test_text_field_keeps_a_shifted_space(
+ self,
+ source: ShortcutSource,
+ field_kind: Dict[str, FieldKind],
+ ) -> None:
+ """Shift+Space types a space, so the key stays with the field the way a plain Space does."""
+ callback = Mock()
+ manager = _manager(source, ShortcutId.PLAY_FROM_START, callback)
+ field_kind["kind"] = FieldKind.TEXT_ENTRY
+
+ claimed = manager._dispatch(_event(dpg.mvKey_Spacebar, modifiers=SHIFT))
+
+ assert not claimed
+ callback.assert_not_called()
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py
new file mode 100644
index 00000000..868689bf
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py
@@ -0,0 +1,319 @@
+from pathlib import Path
+
+import dearpygui.dearpygui as dpg
+import pytest
+from pydantic import ValidationError
+
+from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME
+from sampletones_application.paths import KEYBINDINGS_DIRECTORY
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.utils.gui.keyboard.modifiers import CTRL, CTRL_SHIFT
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId
+from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
+from sampletones_application.utils.gui.shortcuts.written import WrittenShortcut
+from sampletones_shared.paths.extensions import EXT_FILE_YAML
+from tests.unit.sampletones_application.utils.gui.shortcuts.conftest import (
+ PROBE_SCHEME_NAME,
+ RebindScheme,
+)
+
+SHIPPED_FILE = KEYBINDINGS_DIRECTORY / f"{DEFAULT_SCHEME_NAME}{EXT_FILE_YAML}"
+
+TABLE_COMBINATION = "Del"
+
+UNNAMED_KEY = -1
+
+_PARTIAL_SCHEME_FILE = """
+name: minimal
+bindings:
+ Play: {combination: "Space"}
+"""
+
+
+def _press(text: str) -> KeyEvent:
+ """The press a written combination names, as the router delivers it."""
+ combination = KeyCombination.parse(text)
+ return KeyEvent(key=combination.key, modifiers=combination.modifiers)
+
+
+class TestBindings:
+ def test_an_action_reads_the_binding_the_scheme_gives_it(self, rebound: RebindScheme) -> None:
+ scheme = rebound({ShortcutId.UNDO: WrittenShortcut(combination="Ctrl+Z")})
+
+ assert scheme.shortcut(ShortcutId.UNDO).combination == KeyCombination(dpg.mvKey_Z, CTRL)
+
+ def test_an_alias_reaches_the_action_beside_the_combination_it_displays(
+ self,
+ rebound: RebindScheme,
+ ) -> None:
+ scheme = rebound(
+ {
+ ShortcutId.REDO: WrittenShortcut(
+ combination="Ctrl+Y",
+ aliases=("Ctrl+Shift+Z",),
+ ),
+ },
+ )
+
+ assert scheme.shortcut(ShortcutId.REDO).combinations() == (
+ KeyCombination(dpg.mvKey_Y, CTRL),
+ KeyCombination(dpg.mvKey_Z, CTRL_SHIFT),
+ )
+
+
+class TestCompleteness:
+ def test_a_scheme_answering_every_action_is_accepted(self, rebound: RebindScheme) -> None:
+ assert rebound({}).name == PROBE_SCHEME_NAME
+
+ def test_a_scheme_leaving_an_action_unanswered_raises(self, shipped: ShortcutScheme) -> None:
+ """Every action is answered, so a menu and a panel find an entry for whatever they ask."""
+ bindings = {
+ shortcut_id: written
+ for shortcut_id, written in shipped.bindings.items()
+ if shortcut_id is not ShortcutId.PLAY
+ }
+
+ with pytest.raises(SystemError):
+ ShortcutScheme(name=PROBE_SCHEME_NAME, bindings=bindings)
+
+ def test_a_scheme_naming_an_action_the_application_has_none_of_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ ShortcutScheme.model_validate(
+ {
+ "name": PROBE_SCHEME_NAME,
+ "bindings": {"PlayLouder": {"combination": "Ctrl+K"}},
+ },
+ )
+
+
+class TestCollisions:
+ def test_two_actions_of_one_category_claiming_a_combination_raises(
+ self,
+ rebound: RebindScheme,
+ ) -> None:
+ """A press reaches one action, so the scheme states which one before the application runs."""
+ with pytest.raises(SystemError):
+ rebound({ShortcutId.UNDO: WrittenShortcut(combination="Ctrl+Y")})
+
+ def test_an_alias_claiming_another_action_s_combination_raises(self, rebound: RebindScheme) -> None:
+ with pytest.raises(SystemError):
+ rebound(
+ {
+ ShortcutId.UNDO: WrittenShortcut(
+ combination="Ctrl+K",
+ aliases=("Ctrl+Shift+Z",),
+ ),
+ },
+ )
+
+ def test_one_combination_serves_a_category_of_its_own(self, rebound: RebindScheme) -> None:
+ """Tab moves between dialog controls and between tracker columns, each in its own scope."""
+ scheme = rebound({ShortcutId.SAMPLES_RENAME_SAMPLE: WrittenShortcut(combination="Tab")})
+
+ assert scheme.shortcut(ShortcutId.TRACKER_NEXT_COLUMN).display() == "Tab"
+ assert scheme.shortcut(ShortcutId.SAMPLES_RENAME_SAMPLE).display() == "Tab"
+
+ def test_a_combination_naming_no_key_raises(self, rebound: RebindScheme) -> None:
+ with pytest.raises(KeyError):
+ rebound({ShortcutId.PLAY: WrittenShortcut(combination="Ctrl+Meta")})
+
+
+class TestAction:
+ def test_a_press_resolves_to_the_action_its_category_binds_it_to(self, shipped: ShortcutScheme) -> None:
+ assert shipped.action(ShortcutCategory.ORDER, _press("Alt+Left")) is ShortcutId.ORDER_MOVE_FRAME_LEFT
+
+ def test_an_alias_resolves_to_the_action_it_extends(self, shipped: ShortcutScheme) -> None:
+ assert shipped.action(ShortcutCategory.ORDER, _press("Num+")) is ShortcutId.ORDER_INSERT_FRAME
+
+ def test_a_press_the_category_leaves_unnamed_resolves_to_nothing(self, shipped: ShortcutScheme) -> None:
+ assert shipped.action(ShortcutCategory.SAMPLES, _press("Ctrl+S")) is None
+
+ def test_each_category_answers_a_shared_combination_with_its_own_action(
+ self,
+ shipped: ShortcutScheme,
+ ) -> None:
+ """Escape cancels a pending entry in either editing scope and cancels a dialog in a modal."""
+ assert shipped.action(ShortcutCategory.ORDER, _press("Esc")) is ShortcutId.ORDER_CANCEL_ENTRY
+ assert shipped.action(ShortcutCategory.TRACKER, _press("Esc")) is ShortcutId.TRACKER_CANCEL_ENTRY
+ assert shipped.action(ShortcutCategory.DIALOG, _press("Esc")) is ShortcutId.DIALOG_CANCEL
+
+ def test_a_modifier_the_combination_omits_leaves_the_press_unnamed(self, shipped: ShortcutScheme) -> None:
+ """A binding names the modifiers held with it, so Ctrl+Up is not the plain Up move."""
+ assert shipped.action(ShortcutCategory.ORDER, _press("Ctrl+Up")) is None
+
+ def test_a_modifier_a_binding_does_name_reaches_its_own_action(self, shipped: ShortcutScheme) -> None:
+ """Shift+Up selects where Up moves, which is one combination reaching each of two actions."""
+ assert shipped.action(ShortcutCategory.ORDER, _press("Up")) is ShortcutId.ORDER_PREVIOUS_CHANNEL
+ assert shipped.action(ShortcutCategory.ORDER, _press("Shift+Up")) is ShortcutId.ORDER_EXTEND_SELECTION_UP
+
+
+class TestClaimant:
+ def test_a_combination_the_category_binds_reads_as_the_action_it_reaches(self, shipped: ShortcutScheme) -> None:
+ claimant = shipped.claimant(ShortcutCategory.APPLICATION, KeyCombination.parse("Ctrl+Z"))
+
+ assert claimant is ShortcutId.UNDO
+
+ def test_an_alias_reads_as_the_action_it_extends(self, shipped: ShortcutScheme) -> None:
+ claimant = shipped.claimant(ShortcutCategory.APPLICATION, KeyCombination.parse("Ctrl+Shift+Z"))
+
+ assert claimant is ShortcutId.REDO
+
+ def test_a_combination_the_category_leaves_unclaimed_reads_as_nothing(self, shipped: ShortcutScheme) -> None:
+ assert shipped.claimant(ShortcutCategory.SAMPLES, KeyCombination.parse("Ctrl+Z")) is None
+
+ def test_each_category_answers_a_shared_combination_with_its_own_action(self, shipped: ShortcutScheme) -> None:
+ escape = KeyCombination.parse("Esc")
+
+ assert shipped.claimant(ShortcutCategory.TRACKER, escape) is ShortcutId.TRACKER_CANCEL_ENTRY
+ assert shipped.claimant(ShortcutCategory.DIALOG, escape) is ShortcutId.DIALOG_CANCEL
+
+
+class TestWithBinding:
+ def test_an_action_answers_the_combination_it_is_given(self, shipped: ShortcutScheme) -> None:
+ scheme = shipped.with_binding(ShortcutId.UNDO, KeyCombination.parse("Ctrl+Alt+U"))
+
+ assert scheme.shortcut(ShortcutId.UNDO).display() == "Ctrl+Alt+U"
+
+ def test_an_action_answers_that_combination_alone(self, shipped: ShortcutScheme) -> None:
+ scheme = shipped.with_binding(ShortcutId.ORDER_INSERT_FRAME, KeyCombination.parse("Ctrl+Alt+I"))
+
+ assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).aliases == ()
+
+ def test_an_action_given_no_combination_is_left_unbound(self, shipped: ShortcutScheme) -> None:
+ scheme = shipped.with_binding(ShortcutId.UNDO, None)
+
+ assert scheme.shortcut(ShortcutId.UNDO).combinations() == ()
+
+ def test_a_combination_the_category_already_answers_raises(self, shipped: ShortcutScheme) -> None:
+ """An editor is told which action holds the keys, so the reader decides who keeps them."""
+ with pytest.raises(SystemError):
+ shipped.with_binding(ShortcutId.ABOUT_DIALOG, KeyCombination.parse("Ctrl+S"))
+
+ def test_a_combination_another_category_holds_stands(self, shipped: ShortcutScheme) -> None:
+ scheme = shipped.with_binding(ShortcutId.ABOUT_DIALOG, KeyCombination.parse(TABLE_COMBINATION))
+ combination = KeyCombination.parse(TABLE_COMBINATION)
+
+ assert scheme.claimant(ShortcutCategory.APPLICATION, combination) is ShortcutId.ABOUT_DIALOG
+
+ def test_a_combination_naming_no_key_raises(self, shipped: ShortcutScheme) -> None:
+ with pytest.raises(KeyError):
+ shipped.with_binding(ShortcutId.UNDO, KeyCombination(UNNAMED_KEY))
+
+
+class TestWithBindings:
+ def test_two_actions_trade_the_combinations_they_held(self, shipped: ShortcutScheme) -> None:
+ """A whole set is read at once, so a swap arrives without either action holding both keys."""
+ scheme = shipped.with_bindings(
+ {
+ ShortcutId.UNDO: KeyCombination.parse("Ctrl+Y"),
+ ShortcutId.REDO: KeyCombination.parse("Ctrl+Z"),
+ },
+ )
+
+ assert scheme.claimant(ShortcutCategory.APPLICATION, KeyCombination.parse("Ctrl+Y")) is ShortcutId.UNDO
+ assert scheme.claimant(ShortcutCategory.APPLICATION, KeyCombination.parse("Ctrl+Z")) is ShortcutId.REDO
+
+ def test_the_actions_no_binding_names_keep_the_scheme_s_keys(self, shipped: ShortcutScheme) -> None:
+ scheme = shipped.with_bindings({ShortcutId.UNDO: KeyCombination.parse("Ctrl+Alt+U")})
+
+ assert scheme.shortcut(ShortcutId.REDO) == shipped.shortcut(ShortcutId.REDO)
+
+
+class TestWithOverrides:
+ def test_an_override_gives_the_action_the_keys_it_names(self, shipped: ShortcutScheme) -> None:
+ scheme = shipped.with_overrides({"Undo": "Ctrl+Alt+U"})
+
+ assert scheme.shortcut(ShortcutId.UNDO).display() == "Ctrl+Alt+U"
+ assert scheme.action(ShortcutCategory.APPLICATION, _press("Ctrl+Alt+U")) is ShortcutId.UNDO
+
+ def test_the_keys_the_override_replaces_stop_reaching_the_action(self, shipped: ShortcutScheme) -> None:
+ scheme = shipped.with_overrides({"Undo": "Ctrl+Alt+U"})
+
+ assert scheme.action(ShortcutCategory.APPLICATION, _press("Ctrl+Z")) is None
+
+ def test_an_override_states_the_whole_of_what_reaches_the_action(self, shipped: ShortcutScheme) -> None:
+ """A reader names one combination, so the keypad alias the scheme shipped goes with it."""
+ scheme = shipped.with_overrides({"OrderInsertFrame": "Ctrl+Alt+I"})
+
+ assert scheme.shortcut(ShortcutId.ORDER_INSERT_FRAME).aliases == ()
+ assert scheme.action(ShortcutCategory.ORDER, _press("Num+")) is None
+
+ def test_a_rebound_action_keeps_the_transparency_its_role_carries(self, shipped: ShortcutScheme) -> None:
+ """Switching tabs outranks text entry whichever keys it answers to."""
+ scheme = shipped.with_overrides({"NextTab": "Ctrl+Alt+N"})
+
+ assert scheme.shortcut(ShortcutId.NEXT_TAB).field_transparent
+
+ def test_the_actions_no_override_names_keep_the_scheme_s_keys(self, shipped: ShortcutScheme) -> None:
+ scheme = shipped.with_overrides({"Undo": "Ctrl+Alt+U"})
+
+ assert scheme.shortcut(ShortcutId.REDO) == shipped.shortcut(ShortcutId.REDO)
+
+ def test_an_override_naming_an_action_the_build_has_none_of_is_left_out(self, shipped: ShortcutScheme) -> None:
+ """A preference outlives the build that stored it, so a stale entry costs only itself."""
+ scheme = shipped.with_overrides({"PlayLouder": "Ctrl+K", "Undo": "Ctrl+Alt+U"})
+
+ assert scheme.shortcut(ShortcutId.UNDO).display() == "Ctrl+Alt+U"
+
+ def test_an_override_naming_no_key_is_left_out(self, shipped: ShortcutScheme) -> None:
+ scheme = shipped.with_overrides({"Undo": "Ctrl+Gibberish"})
+
+ assert scheme.shortcut(ShortcutId.UNDO) == shipped.shortcut(ShortcutId.UNDO)
+
+ def test_an_override_its_category_already_answers_is_left_out(self, shipped: ShortcutScheme) -> None:
+ """One press reaches one action, so an override claiming a taken combination stands aside."""
+ scheme = shipped.with_overrides({"AboutDialog": "Ctrl+S"})
+
+ assert scheme.action(ShortcutCategory.APPLICATION, _press("Ctrl+S")) is ShortcutId.SAVE_PROJECT
+ assert scheme.shortcut(ShortcutId.ABOUT_DIALOG) == shipped.shortcut(ShortcutId.ABOUT_DIALOG)
+
+ def test_an_override_taking_a_combination_another_category_holds_stands(self, shipped: ShortcutScheme) -> None:
+ scheme = shipped.with_overrides({"AboutDialog": TABLE_COMBINATION})
+
+ assert scheme.action(ShortcutCategory.APPLICATION, _press(TABLE_COMBINATION)) is ShortcutId.ABOUT_DIALOG
+ assert scheme.action(ShortcutCategory.SAMPLES, _press(TABLE_COMBINATION)) is ShortcutId.SAMPLES_REMOVE_SAMPLE
+
+ def test_an_override_stating_no_combination_leaves_the_action_unbound(self, shipped: ShortcutScheme) -> None:
+ scheme = shipped.with_overrides({"Undo": None})
+
+ assert scheme.shortcut(ShortcutId.UNDO).combinations() == ()
+
+ def test_overrides_passing_a_combination_between_two_actions_both_stand(
+ self,
+ shipped: ShortcutScheme,
+ ) -> None:
+ """An editor stores the action it displaced beside the one that took its keys."""
+ scheme = shipped.with_overrides({"AboutDialog": "Ctrl+S", "SaveProject": None})
+
+ assert scheme.action(ShortcutCategory.APPLICATION, _press("Ctrl+S")) is ShortcutId.ABOUT_DIALOG
+ assert scheme.shortcut(ShortcutId.SAVE_PROJECT).combinations() == ()
+
+ def test_a_scheme_without_overrides_is_the_one_it_started_as(self, shipped: ShortcutScheme) -> None:
+ assert shipped.with_overrides({}) is shipped
+
+
+class TestLoad:
+ def test_a_file_is_read_as_the_scheme_it_holds(self, tmp_path: Path) -> None:
+ path = tmp_path / "copy.yaml"
+ path.write_text(SHIPPED_FILE.read_text())
+
+ assert ShortcutScheme.load(path).name == DEFAULT_SCHEME_NAME
+
+ def test_a_file_answering_part_of_the_actions_raises(self, tmp_path: Path) -> None:
+ path = tmp_path / "minimal.yaml"
+ path.write_text(_PARTIAL_SCHEME_FILE)
+
+ with pytest.raises(SystemError):
+ ShortcutScheme.load(path)
+
+ def test_a_file_holding_no_mapping_raises(self, tmp_path: Path) -> None:
+ path = tmp_path / "list.yaml"
+ path.write_text("- Play\n")
+
+ with pytest.raises(TypeError):
+ ShortcutScheme.load(path)
+
+ def test_an_absent_file_raises(self, tmp_path: Path) -> None:
+ with pytest.raises(SystemError):
+ ShortcutScheme.load(tmp_path / "absent.yaml")
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shortcut.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shortcut.py
new file mode 100644
index 00000000..4612daf6
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shortcut.py
@@ -0,0 +1,127 @@
+from dataclasses import dataclass
+from typing import Tuple
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.utils.gui.keyboard.modifiers import (
+ CTRL,
+ CTRL_SHIFT,
+ NO_MODIFIERS,
+)
+from sampletones_application.utils.gui.shortcuts.shortcut import NO_COMBINATION, Shortcut
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+REDO = KeyCombination(dpg.mvKey_Y, CTRL)
+REDO_ALIAS = KeyCombination(dpg.mvKey_Z, CTRL_SHIFT)
+INSERT = KeyCombination(dpg.mvKey_Plus)
+INSERT_ALIAS = KeyCombination(dpg.mvKey_Add)
+
+
+class TestCombinations(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ shortcut: Shortcut
+ expected: Tuple[KeyCombination, ...]
+
+ test_cases = (
+ TestCase(
+ label="a combination of its own",
+ shortcut=Shortcut(combination=REDO),
+ expected=(REDO,),
+ ),
+ TestCase(
+ label="the displayed combination ahead of its aliases",
+ shortcut=Shortcut(combination=REDO, aliases=(REDO_ALIAS,)),
+ expected=(REDO, REDO_ALIAS),
+ ),
+ TestCase(
+ label="aliases while no combination is assigned",
+ shortcut=Shortcut(combination=None, aliases=(INSERT_ALIAS,)),
+ expected=(INSERT_ALIAS,),
+ ),
+ TestCase(
+ label="no combination at all",
+ shortcut=Shortcut(combination=None),
+ expected=(),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_combinations(self, test_case: TestCase) -> None:
+ assert test_case.shortcut.combinations() == test_case.expected
+
+
+class TestMatches(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ shortcut: Shortcut
+ event: KeyEvent
+ expected: bool
+
+ test_cases = (
+ TestCase(
+ label="the displayed combination",
+ shortcut=Shortcut(combination=REDO, aliases=(REDO_ALIAS,)),
+ event=KeyEvent(key=dpg.mvKey_Y, modifiers=CTRL),
+ expected=True,
+ ),
+ TestCase(
+ label="an alias",
+ shortcut=Shortcut(combination=REDO, aliases=(REDO_ALIAS,)),
+ event=KeyEvent(key=dpg.mvKey_Z, modifiers=CTRL_SHIFT),
+ expected=True,
+ ),
+ TestCase(
+ label="a combination bound elsewhere",
+ shortcut=Shortcut(combination=REDO, aliases=(REDO_ALIAS,)),
+ event=KeyEvent(key=dpg.mvKey_Z, modifiers=CTRL),
+ expected=False,
+ ),
+ TestCase(
+ label="an alias while no combination is assigned",
+ shortcut=Shortcut(combination=None, aliases=(INSERT_ALIAS,)),
+ event=KeyEvent(key=dpg.mvKey_Add, modifiers=NO_MODIFIERS),
+ expected=True,
+ ),
+ TestCase(
+ label="any press while the action carries no combination",
+ shortcut=Shortcut(combination=None),
+ event=KeyEvent(key=dpg.mvKey_Y, modifiers=CTRL),
+ expected=False,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_matches(self, test_case: TestCase) -> None:
+ assert test_case.shortcut.matches(test_case.event) is test_case.expected
+
+
+class TestDisplay:
+ def test_an_action_reads_under_the_combination_it_displays(self) -> None:
+ assert Shortcut(combination=REDO, aliases=(REDO_ALIAS,)).display() == "Ctrl+Y"
+
+ def test_an_action_carrying_no_combination_reads_empty(self) -> None:
+ """A menu lists an action whether or not a combination is assigned to it."""
+ assert Shortcut(combination=None).display() == NO_COMBINATION
+
+
+class TestBinding:
+ def test_an_action_stays_behind_field_focus_unless_it_is_declared_transparent(
+ self,
+ ) -> None:
+ assert Shortcut(combination=INSERT).field_transparent is False
+
+ def test_a_transparent_action_carries_the_declaration(self) -> None:
+ assert Shortcut(combination=INSERT, field_transparent=True).field_transparent is True
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py
new file mode 100644
index 00000000..939a3532
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py
@@ -0,0 +1,76 @@
+from typing import List
+
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.event import KeyEvent
+from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId
+from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme
+from sampletones_application.utils.gui.shortcuts.source import ShortcutSource
+from sampletones_application.utils.gui.shortcuts.written import WrittenShortcut
+from tests.unit.sampletones_application.utils.gui.shortcuts.conftest import RebindScheme
+
+
+def _press(text: str) -> KeyEvent:
+ combination = KeyCombination.parse(text)
+ return KeyEvent(key=combination.key, modifiers=combination.modifiers)
+
+
+class TestBindings:
+ def test_the_source_reports_the_scheme_it_was_built_with(self, shipped: ShortcutScheme) -> None:
+ assert ShortcutSource(shipped).scheme is shipped
+
+ def test_an_action_resolves_its_keys_against_the_scheme_in_place(self, source: ShortcutSource) -> None:
+ assert source.shortcut(ShortcutId.SAVE_PROJECT).display() == "Ctrl+S"
+
+ def test_an_action_reads_under_the_combination_a_menu_prints(self, source: ShortcutSource) -> None:
+ assert source.display(ShortcutId.UNDO) == "Ctrl+Z"
+
+ def test_a_scope_resolves_a_press_to_the_action_its_category_names(self, source: ShortcutSource) -> None:
+ action = source.action(ShortcutCategory.TRACKER, _press("Ctrl+Shift+Space"))
+
+ assert action is ShortcutId.TRACKER_PLAY_FROM_ROW
+
+
+class TestActivate:
+ def test_activating_another_scheme_replaces_the_one_in_place(
+ self,
+ source: ShortcutSource,
+ rebound: RebindScheme,
+ ) -> None:
+ source.activate(rebound({ShortcutId.SAVE_PROJECT: WrittenShortcut(combination="Ctrl+Alt+K")}))
+
+ assert source.display(ShortcutId.SAVE_PROJECT) == "Ctrl+Alt+K"
+
+ def test_a_rebind_changes_what_a_scope_makes_of_a_press(
+ self,
+ source: ShortcutSource,
+ rebound: RebindScheme,
+ ) -> None:
+ source.activate(rebound({ShortcutId.SAMPLES_RENAME_SAMPLE: WrittenShortcut(combination="F6")}))
+
+ assert source.action(ShortcutCategory.SAMPLES, _press("F6")) is ShortcutId.SAMPLES_RENAME_SAMPLE
+ assert source.action(ShortcutCategory.SAMPLES, _press("F2")) is None
+
+ def test_activating_announces_the_scheme_now_in_place(
+ self,
+ source: ShortcutSource,
+ rebound: RebindScheme,
+ ) -> None:
+ activated: List[ShortcutScheme] = []
+ source.on_bindings_changed = activated.append
+ scheme = rebound({ShortcutId.SAVE_PROJECT: WrittenShortcut(combination="Ctrl+Alt+K")})
+
+ source.activate(scheme)
+
+ assert activated == [scheme]
+
+ def test_activating_the_scheme_in_place_announces_nothing(
+ self,
+ source: ShortcutSource,
+ shipped: ShortcutScheme,
+ ) -> None:
+ activated: List[ShortcutScheme] = []
+ source.on_bindings_changed = activated.append
+
+ source.activate(shipped)
+
+ assert activated == []
diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py
new file mode 100644
index 00000000..96e71583
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py
@@ -0,0 +1,59 @@
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.utils.gui.keyboard.combination import KeyCombination
+from sampletones_application.utils.gui.keyboard.modifiers import CTRL, CTRL_SHIFT
+from sampletones_application.utils.gui.shortcuts.written import WrittenShortcut
+
+
+class TestResolve:
+ def test_a_written_combination_becomes_the_one_a_press_is_matched_against(self) -> None:
+ shortcut = WrittenShortcut(combination="Ctrl+Y").resolve()
+
+ assert shortcut.combination == KeyCombination(dpg.mvKey_Y, CTRL)
+
+ def test_every_written_alias_reaches_the_action(self) -> None:
+ shortcut = WrittenShortcut(combination="Ctrl+Y", aliases=("Ctrl+Shift+Z",)).resolve()
+
+ assert shortcut.combinations() == (
+ KeyCombination(dpg.mvKey_Y, CTRL),
+ KeyCombination(dpg.mvKey_Z, CTRL_SHIFT),
+ )
+
+ def test_an_entry_left_unassigned_carries_no_combination(self) -> None:
+ """An action is written down whether or not a key is assigned to it."""
+ shortcut = WrittenShortcut(combination=None).resolve()
+
+ assert shortcut.combinations() == ()
+
+ def test_a_transparent_entry_carries_its_declaration(self) -> None:
+ shortcut = WrittenShortcut(combination="Ctrl+PgDn", field_transparent=True).resolve()
+
+ assert shortcut.field_transparent is True
+
+ def test_an_entry_naming_no_key_raises(self) -> None:
+ with pytest.raises(KeyError):
+ WrittenShortcut(combination="Ctrl+Meta").resolve()
+
+
+class TestRebound:
+ def test_a_rebound_entry_answers_the_combination_the_reader_named(self) -> None:
+ entry = WrittenShortcut(combination="Ctrl+Y").rebound("Ctrl+Alt+R")
+
+ assert entry.combination == "Ctrl+Alt+R"
+
+ def test_a_rebound_entry_answers_that_combination_alone(self) -> None:
+ entry = WrittenShortcut(combination="Ctrl+Y", aliases=("Ctrl+Shift+Z",)).rebound("Ctrl+Alt+R")
+
+ assert entry.aliases == ()
+
+ def test_an_entry_rebound_to_no_combination_is_left_unbound(self) -> None:
+ """A reader takes an action's keys away by giving it none."""
+ entry = WrittenShortcut(combination="Ctrl+Y").rebound(None)
+
+ assert entry.combination is None
+
+ def test_a_rebound_entry_keeps_the_transparency_the_action_carries(self) -> None:
+ entry = WrittenShortcut(combination="Ctrl+PgDn", field_transparent=True).rebound("Ctrl+Alt+N")
+
+ assert entry.field_transparent is True
diff --git a/tests/unit/sampletones_application/utils/gui/test_palette.py b/tests/unit/sampletones_application/utils/gui/test_palette.py
new file mode 100644
index 00000000..d011a430
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/gui/test_palette.py
@@ -0,0 +1,172 @@
+from typing import Dict, Generator
+
+import dearpygui.dearpygui as dpg
+import pytest
+
+from sampletones_application.utils.gui.palette.dpg import (
+ dpg_add_palette_theme_color,
+ dpg_set_palette_color,
+)
+from sampletones_application.utils.gui.palette.palette import PaletteBindings
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.faded import FadedColor
+from sampletones_application.utils.palette.colors.literal import LiteralColor
+from sampletones_application.utils.palette.colors.named import NamedColor
+from sampletones_application.utils.palette.palette import Palette
+from sampletones_application.utils.palette.reference import PaletteReference
+from sampletones_application.utils.palette.source import PaletteSource
+from sampletones_shared.types.application import ColorRGBA, Sender
+from sampletones_shared.utils.color import MAX_CHANNEL_VALUE
+
+STUDIO_ACCENT: ColorRGBA = (169, 127, 227, 255)
+LIGHT_ACCENT: ColorRGBA = (107, 63, 176, 255)
+LITERAL: ColorRGBA = (240, 146, 86, 255)
+
+
+@pytest.fixture
+def source() -> PaletteSource:
+ return PaletteSource(Palette.model_validate({"name": "studio", "colors": {"accent": "#a97fe3"}}))
+
+
+@pytest.fixture
+def light() -> Palette:
+ return Palette.model_validate({"name": "light", "colors": {"accent": "#6b3fb0"}})
+
+
+@pytest.fixture
+def accent(source: PaletteSource) -> BaseColor:
+ return NamedColor(reference=PaletteReference(token="accent"), source=source)
+
+
+@pytest.fixture
+def context() -> Generator[None, None, None]:
+ dpg.create_context()
+ try:
+ yield
+ finally:
+ dpg.destroy_context()
+
+
+def _text_color(item: Sender) -> ColorRGBA:
+ """The item's colour as eight-bit channels, which DearPyGui reports as fractions."""
+ configuration: Dict[str, object] = dpg.get_item_configuration(item)
+ color = configuration["color"]
+ assert isinstance(color, (list, tuple))
+ red, green, blue, alpha = (round(channel * MAX_CHANNEL_VALUE) for channel in color)
+ return red, green, blue, alpha
+
+
+def _add_text() -> Sender:
+ with dpg.window():
+ return dpg.add_text("value")
+
+
+class TestArgumentBinding:
+ def test_the_colour_reaches_the_item_as_it_is_bound(
+ self,
+ context: None,
+ accent: BaseColor,
+ ) -> None:
+ item = _add_text()
+
+ dpg_set_palette_color(item, accent)
+
+ assert _text_color(item) == STUDIO_ACCENT
+
+ def test_the_item_takes_the_newly_activated_palette(
+ self,
+ context: None,
+ source: PaletteSource,
+ accent: BaseColor,
+ light: Palette,
+ ) -> None:
+ item = _add_text()
+ dpg_set_palette_color(item, accent)
+
+ source.activate(light)
+ PaletteBindings.apply()
+
+ assert _text_color(item) == LIGHT_ACCENT
+
+ def test_a_literal_colour_stays_as_written(
+ self,
+ context: None,
+ source: PaletteSource,
+ light: Palette,
+ ) -> None:
+ item = _add_text()
+ dpg_set_palette_color(item, LiteralColor(LITERAL))
+
+ source.activate(light)
+ PaletteBindings.apply()
+
+ assert _text_color(item) == LITERAL
+
+ def test_recolouring_one_argument_leaves_one_entry(
+ self,
+ context: None,
+ accent: BaseColor,
+ ) -> None:
+ """A hovered item is recoloured on every frame it is under the pointer."""
+ item = _add_text()
+
+ for _ in range(5):
+ dpg_set_palette_color(item, accent)
+
+ assert len(list(PaletteBindings.bindings())) == 1
+
+ def test_a_deleted_item_is_dropped(
+ self,
+ context: None,
+ accent: BaseColor,
+ ) -> None:
+ item = _add_text()
+ dpg_set_palette_color(item, accent)
+ dpg.delete_item(item)
+
+ PaletteBindings.apply()
+
+ assert not list(PaletteBindings.bindings())
+
+
+class TestThemeColorBinding:
+ def test_the_theme_colour_takes_the_newly_activated_palette(
+ self,
+ context: None,
+ source: PaletteSource,
+ accent: BaseColor,
+ light: Palette,
+ ) -> None:
+ with dpg.theme():
+ with dpg.theme_component(dpg.mvAll):
+ item = dpg_add_palette_theme_color(dpg.mvThemeCol_Text, accent)
+
+ source.activate(light)
+ PaletteBindings.apply()
+
+ assert tuple(int(channel) for channel in dpg.get_value(item)) == LIGHT_ACCENT
+
+ def test_a_derived_colour_follows_the_colour_it_came_from(
+ self,
+ context: None,
+ source: PaletteSource,
+ accent: BaseColor,
+ light: Palette,
+ ) -> None:
+ with dpg.theme():
+ with dpg.theme_component(dpg.mvAll):
+ item = dpg_add_palette_theme_color(
+ dpg.mvThemeCol_Text,
+ FadedColor(color=accent, fraction=0.5),
+ )
+
+ source.activate(light)
+ PaletteBindings.apply()
+
+ red, green, blue, _ = LIGHT_ACCENT
+ assert tuple(int(channel) for channel in dpg.get_value(item)) == (
+ red,
+ green,
+ blue,
+ 128,
+ )
diff --git a/tests/unit/sampletones_application/utils/palette/__init__.py b/tests/unit/sampletones_application/utils/palette/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_application/utils/palette/conftest.py b/tests/unit/sampletones_application/utils/palette/conftest.py
new file mode 100644
index 00000000..14365f18
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/palette/conftest.py
@@ -0,0 +1,19 @@
+import pytest
+
+from sampletones_application.utils.palette.palette import Palette
+from sampletones_application.utils.palette.source import PaletteSource
+
+
+@pytest.fixture
+def studio() -> Palette:
+ return Palette.model_validate({"name": "studio", "colors": {"accent": "#a97fe3"}})
+
+
+@pytest.fixture
+def light() -> Palette:
+ return Palette.model_validate({"name": "light", "colors": {"accent": "#6b3fb0"}})
+
+
+@pytest.fixture
+def source(studio: Palette) -> PaletteSource:
+ return PaletteSource(studio)
diff --git a/tests/unit/sampletones_application/utils/palette/test_catalog.py b/tests/unit/sampletones_application/utils/palette/test_catalog.py
new file mode 100644
index 00000000..4e58edbc
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/palette/test_catalog.py
@@ -0,0 +1,79 @@
+from pathlib import Path
+
+import pytest
+
+from sampletones_application.paths import PALETTES_DIRECTORY
+from sampletones_application.utils.palette.catalog import (
+ DEFAULT_PALETTE_NAME,
+ PaletteCatalog,
+)
+
+_STUDIO = """
+name: studio
+colors:
+ accent: "#a97fe3"
+"""
+
+_LIGHT = """
+name: light
+colors:
+ accent: "#6b3fb0"
+"""
+
+
+@pytest.fixture
+def directory(tmp_path: Path) -> Path:
+ (tmp_path / f"{DEFAULT_PALETTE_NAME}.yaml").write_text(_STUDIO)
+ (tmp_path / "light.yaml").write_text(_LIGHT)
+ return tmp_path
+
+
+class TestLoadCatalog:
+ def test_every_palette_in_the_directory_is_indexed_by_name(self, directory: Path) -> None:
+ assert PaletteCatalog.load(directory).names == ("light", DEFAULT_PALETTE_NAME)
+
+ def test_an_empty_directory_raises_system_error(self, tmp_path: Path) -> None:
+ with pytest.raises(SystemError):
+ PaletteCatalog.load(tmp_path)
+
+ def test_a_directory_omitting_the_default_palette_raises_system_error(self, tmp_path: Path) -> None:
+ (tmp_path / "light.yaml").write_text(_LIGHT)
+ with pytest.raises(SystemError):
+ PaletteCatalog.load(tmp_path)
+
+ def test_a_palette_named_apart_from_its_file_raises(self, directory: Path) -> None:
+ (directory / "dark.yaml").write_text(_LIGHT)
+ with pytest.raises(ValueError):
+ PaletteCatalog.load(directory)
+
+
+class TestSelectPalette:
+ def test_a_known_name_selects_that_palette(self, directory: Path) -> None:
+ assert PaletteCatalog.load(directory).select("light").name == "light"
+
+ def test_an_unknown_name_falls_back_to_the_default(self, directory: Path) -> None:
+ assert PaletteCatalog.load(directory).select("neon").name == DEFAULT_PALETTE_NAME
+
+ def test_an_unknown_name_raises_when_looked_up_directly(self, directory: Path) -> None:
+ with pytest.raises(KeyError):
+ PaletteCatalog.load(directory).get("neon")
+
+
+class TestShippedPalettes:
+ """Every shipped palette must answer the same tokens.
+
+ A layout or theme entry names one token and every palette resolves it, so a palette
+ that omits a token fails at load in whichever file happens to reference it.
+ """
+
+ @pytest.fixture
+ def catalog(self) -> PaletteCatalog:
+ return PaletteCatalog.load(PALETTES_DIRECTORY)
+
+ def test_the_default_palette_ships(self, catalog: PaletteCatalog) -> None:
+ assert catalog.default.name == DEFAULT_PALETTE_NAME
+
+ def test_every_palette_declares_the_same_tokens(self, catalog: PaletteCatalog) -> None:
+ expected = set(catalog.default.colors)
+ for name, palette in catalog.palettes.items():
+ assert set(palette.colors) == expected, f"Palette {name!r} token set differs from {DEFAULT_PALETTE_NAME!r}"
diff --git a/tests/unit/sampletones_application/utils/palette/test_colors.py b/tests/unit/sampletones_application/utils/palette/test_colors.py
new file mode 100644
index 00000000..f90f20cf
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/palette/test_colors.py
@@ -0,0 +1,90 @@
+import pytest
+
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.blended import BlendedColor
+from sampletones_application.utils.palette.colors.faded import FadedColor
+from sampletones_application.utils.palette.colors.grayscale import GrayscaleColor
+from sampletones_application.utils.palette.colors.literal import LiteralColor
+from sampletones_application.utils.palette.colors.named import NamedColor
+from sampletones_application.utils.palette.palette import Palette
+from sampletones_application.utils.palette.reference import PaletteReference
+from sampletones_application.utils.palette.source import PaletteSource
+
+BLACK = LiteralColor((0, 0, 0, 255))
+WHITE = LiteralColor((255, 255, 255, 255))
+
+
+@pytest.fixture
+def accent(source: PaletteSource) -> BaseColor:
+ return NamedColor(
+ reference=PaletteReference(token="accent"),
+ source=source,
+ )
+
+
+class TestComposedColor:
+ """Fading, desaturating and mixing each answer with a colour that still reads the palette."""
+
+ def test_fading_keeps_the_hue_and_sets_the_opacity(self, accent: BaseColor) -> None:
+ assert FadedColor(color=accent, fraction=0.5).rgba == (169, 127, 227, 128)
+
+ def test_desaturating_collapses_the_channels_to_one_luminance(self, accent: BaseColor) -> None:
+ gray = round(0.299 * 169 + 0.587 * 127 + 0.114 * 227)
+
+ assert GrayscaleColor(color=accent).rgba == (gray, gray, gray, 255)
+
+ def test_mixing_lands_between_the_two_ends(self) -> None:
+ assert BlendedColor(start=BLACK, end=WHITE, fraction=0.5).rgba == (
+ 128,
+ 128,
+ 128,
+ 255,
+ )
+
+ def test_a_composed_colour_answers_with_the_newly_activated_palette(
+ self,
+ source: PaletteSource,
+ light: Palette,
+ accent: BaseColor,
+ ) -> None:
+ faded = FadedColor(color=accent, fraction=0.5)
+
+ source.activate(light)
+
+ assert faded.rgba == (107, 63, 176, 128)
+
+ def test_compositions_nest(
+ self,
+ source: PaletteSource,
+ light: Palette,
+ accent: BaseColor,
+ ) -> None:
+ dimmed = FadedColor(
+ color=GrayscaleColor(color=accent),
+ fraction=0.25,
+ )
+
+ source.activate(light)
+
+ gray = round(0.299 * 107 + 0.587 * 63 + 0.114 * 176)
+ assert dimmed.rgba == (gray, gray, gray, 64)
+
+ def test_the_same_composition_of_the_same_colour_is_one_value(
+ self,
+ accent: BaseColor,
+ ) -> None:
+ """A theme cache keyed by colour holds one entry per shade the application draws."""
+ assert {
+ FadedColor(color=accent, fraction=0.5),
+ FadedColor(color=accent, fraction=0.5),
+ FadedColor(color=accent, fraction=0.25),
+ } == {
+ FadedColor(color=accent, fraction=0.5),
+ FadedColor(color=accent, fraction=0.25),
+ }
+
+ def test_the_same_composition_of_two_colours_stays_two_values(
+ self,
+ accent: BaseColor,
+ ) -> None:
+ assert FadedColor(color=accent, fraction=0.5) != FadedColor(color=WHITE, fraction=0.5)
diff --git a/tests/unit/sampletones_application/utils/palette/test_palette.py b/tests/unit/sampletones_application/utils/palette/test_palette.py
new file mode 100644
index 00000000..eb06d836
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/palette/test_palette.py
@@ -0,0 +1,57 @@
+from pathlib import Path
+
+import pytest
+
+from sampletones_application.utils.palette.palette import Palette
+from sampletones_application.utils.palette.reference import PaletteReference
+
+_PALETTE = """
+name: test
+colors:
+ accent: "#a97fe3"
+ overlay: "#ffffff40"
+"""
+
+
+@pytest.fixture
+def palette() -> Palette:
+ return Palette.model_validate({"name": "test", "colors": {"accent": "#a97fe3", "overlay": "#ffffff40"}})
+
+
+class TestPaletteResolution:
+ def test_a_reference_resolves_to_its_token_colour(self, palette: Palette) -> None:
+ assert palette.resolve(PaletteReference(token="accent")) == (169, 127, 227, 255)
+
+ def test_an_alpha_override_replaces_only_the_alpha_channel(self, palette: Palette) -> None:
+ assert palette.resolve(PaletteReference(token="accent", alpha=0.5)) == (
+ 169,
+ 127,
+ 227,
+ 128,
+ )
+
+ def test_an_unknown_token_raises(self, palette: Palette) -> None:
+ with pytest.raises(KeyError):
+ palette.resolve(PaletteReference(token="missing"))
+
+
+class TestLoadPalette:
+ def test_a_present_palette_file_is_loaded(self, tmp_path: Path) -> None:
+ palette_path = tmp_path / "test.yaml"
+ palette_path.write_text(_PALETTE)
+ assert Palette.load(palette_path).resolve(PaletteReference(token="accent")) == (
+ 169,
+ 127,
+ 227,
+ 255,
+ )
+
+ def test_a_missing_palette_raises_system_error(self, tmp_path: Path) -> None:
+ with pytest.raises(SystemError):
+ Palette.load(tmp_path / "missing")
+
+ def test_a_palette_file_holding_a_sequence_raises_type_error(self, tmp_path: Path) -> None:
+ palette_path = tmp_path / "test.yaml"
+ palette_path.write_text("- accent\n")
+ with pytest.raises(TypeError):
+ Palette.load(palette_path)
diff --git a/tests/unit/sampletones_application/utils/palette/test_reference.py b/tests/unit/sampletones_application/utils/palette/test_reference.py
new file mode 100644
index 00000000..7b0785ce
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/palette/test_reference.py
@@ -0,0 +1,36 @@
+import pytest
+
+from sampletones_application.utils.palette.reference import (
+ PaletteReference,
+ is_reference,
+)
+
+
+class TestPaletteReference:
+ def test_a_bare_token_carries_no_alpha_override(self) -> None:
+ reference = PaletteReference.model_validate(".accent")
+ assert reference.token == "accent"
+ assert reference.alpha is None
+
+ def test_a_token_with_alpha_captures_the_fraction(self) -> None:
+ reference = PaletteReference.model_validate(".accent/0.5")
+ assert reference.token == "accent"
+ assert reference.alpha == 0.5
+
+ def test_a_value_without_the_prefix_is_rejected(self) -> None:
+ with pytest.raises(ValueError):
+ PaletteReference.model_validate("#a97fe3")
+
+ def test_an_alpha_outside_the_unit_range_is_rejected(self) -> None:
+ with pytest.raises(ValueError):
+ PaletteReference.model_validate(".accent/1.5")
+
+
+class TestIsReference:
+ @pytest.mark.parametrize("value", [".accent", ".accent/0.5", " .accent"])
+ def test_a_prefixed_value_reads_as_a_reference(self, value: str) -> None:
+ assert is_reference(value)
+
+ @pytest.mark.parametrize("value", ["#a97fe3", "accent", ""])
+ def test_any_other_value_reads_as_a_literal(self, value: str) -> None:
+ assert not is_reference(value)
diff --git a/tests/unit/sampletones_application/utils/palette/test_source.py b/tests/unit/sampletones_application/utils/palette/test_source.py
new file mode 100644
index 00000000..35d13c7b
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/palette/test_source.py
@@ -0,0 +1,44 @@
+from typing import List
+
+from sampletones_application.utils.palette.palette import Palette
+from sampletones_application.utils.palette.source import PaletteSource
+
+
+class TestActivate:
+ def test_the_source_reports_the_palette_it_was_built_with(self, studio: Palette) -> None:
+ source = PaletteSource(studio)
+
+ assert source.palette is studio
+
+ def test_activating_another_palette_replaces_the_one_in_place(
+ self,
+ studio: Palette,
+ light: Palette,
+ ) -> None:
+ source = PaletteSource(studio)
+
+ source.activate(light)
+
+ assert source.palette is light
+
+ def test_activating_announces_the_palette_now_in_place(
+ self,
+ studio: Palette,
+ light: Palette,
+ ) -> None:
+ activated: List[Palette] = []
+ source = PaletteSource(studio)
+ source.on_palette_changed = activated.append
+
+ source.activate(light)
+
+ assert activated == [light]
+
+ def test_activating_the_palette_in_place_announces_nothing(self, studio: Palette) -> None:
+ activated: List[Palette] = []
+ source = PaletteSource(studio)
+ source.on_palette_changed = activated.append
+
+ source.activate(studio)
+
+ assert activated == []
diff --git a/tests/unit/sampletones_application/utils/palette/test_written.py b/tests/unit/sampletones_application/utils/palette/test_written.py
new file mode 100644
index 00000000..4bd1981d
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/palette/test_written.py
@@ -0,0 +1,90 @@
+import pytest
+from pydantic import BaseModel, ValidationError
+
+from sampletones_application.utils.palette.colors.base import BaseColor
+from sampletones_application.utils.palette.colors.faded import FadedColor
+from sampletones_application.utils.palette.colors.literal import LiteralColor
+from sampletones_application.utils.palette.colors.written import (
+ PALETTE_SOURCE_CONTEXT_KEY,
+ WrittenColor,
+)
+from sampletones_application.utils.palette.palette import Palette
+from sampletones_application.utils.palette.source import PaletteSource
+
+
+class _Swatch(BaseModel, frozen=True):
+ color: WrittenColor
+
+
+def _swatch(written: object, source: PaletteSource) -> _Swatch:
+ return _Swatch.model_validate({"color": written}, context={PALETTE_SOURCE_CONTEXT_KEY: source})
+
+
+class TestWrittenColor:
+ def test_a_hex_literal_resolves_without_a_palette(self) -> None:
+ assert _Swatch.model_validate({"color": "#a97fe3"}).color.rgba == (
+ 169,
+ 127,
+ 227,
+ 255,
+ )
+
+ def test_a_reference_resolves_against_the_source_palette(self, source: PaletteSource) -> None:
+ assert _swatch(".accent", source).color.rgba == (169, 127, 227, 255)
+
+ def test_a_reference_alpha_override_is_applied(self, source: PaletteSource) -> None:
+ assert _swatch(".accent/0.5", source).color.rgba == (169, 127, 227, 128)
+
+ def test_a_colour_built_in_code_stands_as_it_is(self, source: PaletteSource) -> None:
+ """A derived shade reaches a field as the colour it already is."""
+ color: BaseColor = FadedColor(
+ color=LiteralColor((240, 146, 86, 255)),
+ fraction=0.5,
+ )
+
+ assert _swatch(color, source).color is color
+
+ def test_a_value_of_another_kind_raises(self, source: PaletteSource) -> None:
+ with pytest.raises(ValidationError):
+ _swatch(42, source)
+
+ def test_a_reference_without_a_palette_source_context_raises(self) -> None:
+ with pytest.raises(ValidationError):
+ _Swatch.model_validate({"color": ".accent"})
+
+ def test_a_palette_source_context_of_the_wrong_type_raises(self, studio: Palette) -> None:
+ with pytest.raises(TypeError):
+ _Swatch.model_validate({"color": ".accent"}, context={PALETTE_SOURCE_CONTEXT_KEY: studio})
+
+ def test_a_token_the_palette_in_place_omits_raises_at_load(self, source: PaletteSource) -> None:
+ with pytest.raises(KeyError):
+ _swatch(".missing", source)
+
+
+class TestActivatedPalette:
+ """A reference is read at the moment it is drawn with, so a swap needs no reload."""
+
+ def test_a_reference_answers_with_the_newly_activated_palette(
+ self,
+ source: PaletteSource,
+ light: Palette,
+ ) -> None:
+ swatch = _swatch(".accent", source)
+
+ source.activate(light)
+
+ assert swatch.color.rgba == (107, 63, 176, 255)
+
+ def test_an_alpha_override_survives_the_swap(self, source: PaletteSource, light: Palette) -> None:
+ swatch = _swatch(".accent/0.5", source)
+
+ source.activate(light)
+
+ assert swatch.color.rgba == (107, 63, 176, 128)
+
+ def test_a_literal_stands_apart_from_the_palette(self, source: PaletteSource, light: Palette) -> None:
+ swatch = _swatch("#a97fe3", source)
+
+ source.activate(light)
+
+ assert swatch.color.rgba == (169, 127, 227, 255)
diff --git a/tests/unit/sampletones_application/utils/test_frame_limiter.py b/tests/unit/sampletones_application/utils/test_frame_limiter.py
new file mode 100644
index 00000000..286afae5
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/test_frame_limiter.py
@@ -0,0 +1,119 @@
+from typing import List
+
+import pytest
+
+from sampletones_application.utils.frame_limiter import FrameLimiter
+
+SLEEP = "sampletones_application.utils.frame_limiter.time.sleep"
+PERF_COUNTER = "sampletones_application.utils.frame_limiter.time.perf_counter"
+
+
+@pytest.fixture
+def sleeps(monkeypatch: pytest.MonkeyPatch) -> List[float]:
+ recorded: List[float] = []
+ monkeypatch.setattr(SLEEP, recorded.append)
+ return recorded
+
+
+def _advance(monkeypatch: pytest.MonkeyPatch, times: List[float]) -> None:
+ remaining = list(times)
+ monkeypatch.setattr(PERF_COUNTER, lambda: remaining.pop(0))
+
+
+class TestPacing:
+ def test_the_first_frame_sleeps_out_nothing(
+ self,
+ sleeps: List[float],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ _advance(monkeypatch, [0.0])
+ limiter = FrameLimiter(60)
+
+ limiter.tick()
+
+ assert sleeps == []
+
+ def test_a_frame_arriving_early_sleeps_out_the_rest_of_its_budget(
+ self,
+ sleeps: List[float],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ _advance(monkeypatch, [0.0, 0.005])
+ limiter = FrameLimiter(100)
+
+ limiter.tick()
+ limiter.tick()
+
+ assert sleeps == [pytest.approx(0.005)]
+
+ def test_a_frame_arriving_late_sleeps_out_nothing(
+ self,
+ sleeps: List[float],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ _advance(monkeypatch, [0.0, 1.0])
+ limiter = FrameLimiter(60)
+
+ limiter.tick()
+ limiter.tick()
+
+ assert sleeps == []
+
+ def test_an_unlimited_rate_leaves_pacing_to_the_hardware(
+ self,
+ sleeps: List[float],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ _advance(monkeypatch, [])
+ limiter = FrameLimiter(0)
+
+ limiter.tick()
+ limiter.tick()
+
+ assert sleeps == []
+
+
+class TestSetMaxFps:
+ def test_a_new_rate_paces_the_frames_that_follow(
+ self,
+ sleeps: List[float],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ _advance(monkeypatch, [0.0, 0.001, 0.006])
+ limiter = FrameLimiter(1000)
+ limiter.tick()
+
+ limiter.set_max_fps(100)
+ limiter.tick()
+ limiter.tick()
+
+ assert sleeps == [pytest.approx(0.005)]
+
+ def test_lifting_the_cap_stops_the_pacing(
+ self,
+ sleeps: List[float],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ _advance(monkeypatch, [0.0])
+ limiter = FrameLimiter(60)
+ limiter.tick()
+
+ limiter.set_max_fps(0)
+ limiter.tick()
+
+ assert sleeps == []
+
+ def test_the_first_frame_after_a_change_is_timed_from_then(
+ self,
+ sleeps: List[float],
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ """A frame that spans the change is paced by the new budget alone, never the old one."""
+ _advance(monkeypatch, [0.0, 10.0])
+ limiter = FrameLimiter(60)
+ limiter.tick()
+
+ limiter.set_max_fps(30)
+ limiter.tick()
+
+ assert sleeps == []
diff --git a/tests/unit/sampletones_application/utils/test_monitors.py b/tests/unit/sampletones_application/utils/test_monitors.py
new file mode 100644
index 00000000..db16bfdb
--- /dev/null
+++ b/tests/unit/sampletones_application/utils/test_monitors.py
@@ -0,0 +1,118 @@
+from typing import List
+
+import pytest
+from screeninfo import Monitor, ScreenInfoError
+
+from sampletones_application.utils.monitors import (
+ MonitorArea,
+ available_monitors,
+ monitor_area_for_window,
+ monitor_for_window,
+)
+from sampletones_shared.display import Resolution
+
+GET_MONITORS = "sampletones_application.utils.monitors.get_monitors"
+
+PRIMARY = Monitor(x=0, y=0, width=1920, height=1080)
+SECONDARY = Monitor(x=1920, y=0, width=2560, height=1440)
+
+USABLE_RATIO = 0.9
+FALLBACK_MONITOR = Resolution(width=1920, height=1080)
+
+
+def area_for_window(x: int, y: int, width: int, height: int) -> MonitorArea:
+ return monitor_area_for_window(
+ x,
+ y,
+ width,
+ height,
+ usable_ratio=USABLE_RATIO,
+ fallback_monitor=FALLBACK_MONITOR,
+ )
+
+
+class TestAvailableMonitors:
+ def test_the_platform_listing_is_passed_on(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(GET_MONITORS, lambda: [PRIMARY, SECONDARY])
+
+ assert available_monitors() == [PRIMARY, SECONDARY]
+
+ def test_a_platform_without_an_enumerator_reports_none(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ """A headless session makes screeninfo raise, which stays recoverable."""
+
+ def raise_screen_info_error() -> List[Monitor]:
+ raise ScreenInfoError("No enumerators available")
+
+ monkeypatch.setattr(GET_MONITORS, raise_screen_info_error)
+
+ assert available_monitors() == []
+
+
+class TestMonitorForWindow:
+ def test_the_monitor_holding_the_window_is_chosen(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(GET_MONITORS, lambda: [PRIMARY, SECONDARY])
+
+ assert monitor_for_window(2000, 100, 1280, 800) is SECONDARY
+
+ def test_a_window_spanning_two_monitors_takes_the_one_it_covers_most(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ monkeypatch.setattr(GET_MONITORS, lambda: [PRIMARY, SECONDARY])
+
+ assert monitor_for_window(1820, 100, 1280, 800) is SECONDARY
+
+ def test_a_window_away_from_every_monitor_takes_the_first(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(GET_MONITORS, lambda: [PRIMARY, SECONDARY])
+
+ assert monitor_for_window(-5000, -5000, 1280, 800) is PRIMARY
+
+ def test_nothing_is_chosen_where_none_is_reported(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(GET_MONITORS, list)
+
+ assert monitor_for_window(0, 0, 1280, 800) is None
+
+
+class TestMonitorArea:
+ def test_the_usable_size_leaves_the_decoration_frame_room(self) -> None:
+ area = MonitorArea.of(PRIMARY, USABLE_RATIO)
+
+ assert area.usable_width == int(PRIMARY.width * USABLE_RATIO)
+ assert area.usable_height == int(PRIMARY.height * USABLE_RATIO)
+
+ def test_the_area_carries_the_monitor_origin(self) -> None:
+ area = MonitorArea.of(SECONDARY, USABLE_RATIO)
+
+ assert (area.x, area.y) == (SECONDARY.x, SECONDARY.y)
+
+ def test_an_assumed_area_takes_the_given_size_at_the_origin(self) -> None:
+ assert MonitorArea.assumed(FALLBACK_MONITOR, USABLE_RATIO) == MonitorArea(
+ x=0,
+ y=0,
+ width=FALLBACK_MONITOR.width,
+ height=FALLBACK_MONITOR.height,
+ usable_ratio=USABLE_RATIO,
+ )
+
+ def test_a_window_falls_back_to_the_assumed_area(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(GET_MONITORS, list)
+
+ assert area_for_window(0, 0, 1280, 800) == MonitorArea.assumed(FALLBACK_MONITOR, USABLE_RATIO)
+
+ def test_a_window_takes_the_area_of_the_monitor_it_sits_on(self, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(GET_MONITORS, lambda: [PRIMARY, SECONDARY])
+
+ assert area_for_window(2000, 100, 1280, 800) == MonitorArea.of(SECONDARY, USABLE_RATIO)
+
+
+class TestMonitorAreaValidation:
+ @pytest.mark.parametrize("usable_ratio", [0.0, -0.5, 1.5])
+ def test_a_ratio_outside_the_monitor_raises(self, usable_ratio: float) -> None:
+ """A window takes a positive share of its monitor, at most the whole of it."""
+ with pytest.raises(ValueError):
+ MonitorArea(x=0, y=0, width=1920, height=1080, usable_ratio=usable_ratio)
+
+ @pytest.mark.parametrize(("width", "height"), [(0, 1080), (1920, 0), (-1920, -1080)])
+ def test_an_area_without_extent_raises(self, width: int, height: int) -> None:
+ with pytest.raises(ValueError):
+ MonitorArea(x=0, y=0, width=width, height=height, usable_ratio=USABLE_RATIO)
diff --git a/tests/unit/sampletones_application/utils/test_palette.py b/tests/unit/sampletones_application/utils/test_palette.py
deleted file mode 100644
index f2cd4fce..00000000
--- a/tests/unit/sampletones_application/utils/test_palette.py
+++ /dev/null
@@ -1,92 +0,0 @@
-from pathlib import Path
-
-import pytest
-from pydantic import BaseModel, ValidationError
-
-from sampletones_application.utils.palette import (
- PALETTE_CONTEXT_KEY,
- Palette,
- PaletteColor,
- PaletteReference,
-)
-
-
-class _Swatch(BaseModel, frozen=True):
- color: PaletteColor
-
-
-_PALETTE = """
-name: test
-colors:
- accent: "#a97fe3"
- overlay: "#ffffff40"
-"""
-
-
-class TestPaletteReference:
- def test_a_bare_token_carries_no_alpha_override(self) -> None:
- reference = PaletteReference.model_validate(".accent")
- assert reference.token == "accent"
- assert reference.alpha is None
-
- def test_a_token_with_alpha_captures_the_fraction(self) -> None:
- reference = PaletteReference.model_validate(".accent/0.5")
- assert reference.token == "accent"
- assert reference.alpha == 0.5
-
- def test_a_value_without_the_prefix_is_rejected(self) -> None:
- with pytest.raises(ValueError):
- PaletteReference.model_validate("#a97fe3")
-
- def test_an_alpha_outside_the_unit_range_is_rejected(self) -> None:
- with pytest.raises(ValueError):
- PaletteReference.model_validate(".accent/1.5")
-
-
-class TestPaletteResolution:
- @pytest.fixture
- def palette(self) -> Palette:
- return Palette.model_validate({"name": "test", "colors": {"accent": "#a97fe3", "overlay": "#ffffff40"}})
-
- def test_a_reference_resolves_to_its_token_colour(self, palette: Palette) -> None:
- assert palette.resolve(PaletteReference(token="accent")) == (169, 127, 227, 255)
-
- def test_an_alpha_override_replaces_only_the_alpha_channel(self, palette: Palette) -> None:
- assert palette.resolve(PaletteReference(token="accent", alpha=0.5)) == (169, 127, 227, 128)
-
- def test_an_unknown_token_raises(self, palette: Palette) -> None:
- with pytest.raises(KeyError):
- palette.resolve(PaletteReference(token="missing"))
-
-
-class TestLoadPalette:
- def test_a_present_palette_file_is_loaded(self, tmp_path: Path) -> None:
- palette_path = tmp_path / "palette.yaml"
- palette_path.write_text(_PALETTE)
- palette = Palette.load(palette_path)
- assert palette.resolve(PaletteReference(token="accent")) == (169, 127, 227, 255)
-
- def test_a_missing_palette_raises_system_error(self, tmp_path: Path) -> None:
- with pytest.raises(SystemError):
- Palette.load(tmp_path / "missing")
-
-
-class TestPaletteColor:
- @pytest.fixture
- def palette(self) -> Palette:
- return Palette.model_validate({"name": "test", "colors": {"accent": "#a97fe3", "overlay": "#ffffff40"}})
-
- def test_a_hex_literal_resolves_without_a_palette(self) -> None:
- assert _Swatch.model_validate({"color": "#a97fe3"}).color == (169, 127, 227, 255)
-
- def test_a_reference_resolves_against_the_context_palette(self, palette: Palette) -> None:
- swatch = _Swatch.model_validate({"color": ".accent"}, context={PALETTE_CONTEXT_KEY: palette})
- assert swatch.color == (169, 127, 227, 255)
-
- def test_a_reference_alpha_override_is_applied(self, palette: Palette) -> None:
- swatch = _Swatch.model_validate({"color": ".accent/0.5"}, context={PALETTE_CONTEXT_KEY: palette})
- assert swatch.color == (169, 127, 227, 128)
-
- def test_a_reference_without_a_palette_context_raises(self) -> None:
- with pytest.raises(ValidationError):
- _Swatch.model_validate({"color": ".accent"})
diff --git a/tests/unit/sampletones_application/view_model/instruction/test_library.py b/tests/unit/sampletones_application/view_model/instruction/test_library.py
index 7e8e6926..7de6cd61 100644
--- a/tests/unit/sampletones_application/view_model/instruction/test_library.py
+++ b/tests/unit/sampletones_application/view_model/instruction/test_library.py
@@ -33,7 +33,14 @@ class TestProgressOverlay:
@pytest.mark.parametrize(
("progress", "overlay"),
- [(-0.5, "0%"), (0.0, "0%"), (0.333, "33%"), (0.5, "50%"), (1.0, "100%"), (1.5, "100%")],
+ [
+ (-0.5, "0%"),
+ (0.0, "0%"),
+ (0.333, "33%"),
+ (0.5, "50%"),
+ (1.0, "100%"),
+ (1.5, "100%"),
+ ],
)
def test_overlay_renders_the_clamped_percentage(self, progress: float, overlay: str) -> None:
view_model = _view_model(generating=True, progress=progress)
diff --git a/tests/unit/sampletones_application/view_model/main/test_converter.py b/tests/unit/sampletones_application/view_model/main/test_converter.py
index c9c02223..7a1f61ac 100644
--- a/tests/unit/sampletones_application/view_model/main/test_converter.py
+++ b/tests/unit/sampletones_application/view_model/main/test_converter.py
@@ -46,7 +46,14 @@ class TestProgressOverlay:
@pytest.mark.parametrize(
("progress", "overlay"),
- [(-0.5, "0%"), (0.0, "0%"), (0.333, "33%"), (0.5, "50%"), (1.0, "100%"), (1.5, "100%")],
+ [
+ (-0.5, "0%"),
+ (0.0, "0%"),
+ (0.333, "33%"),
+ (0.5, "50%"),
+ (1.0, "100%"),
+ (1.5, "100%"),
+ ],
)
def test_overlay_renders_the_clamped_percentage(self, progress: float, overlay: str) -> None:
view_model = _view_model(phase=ConversionPhase.RUNNING, progress=progress)
@@ -55,7 +62,8 @@ def test_overlay_renders_the_clamped_percentage(self, progress: float, overlay:
class TestPrimaryAction:
"""The one action button cancels while a conversion holds resources and otherwise offers to
- convert; terminal phases present the convert action as they fall back to idle on their own."""
+ convert; terminal phases present the convert action as they fall back to idle on their own.
+ """
@pytest.mark.parametrize(
("phase", "action"),
diff --git a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py
index 379f9199..0a07b786 100644
--- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py
+++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py
@@ -60,11 +60,19 @@ class TestReconstructionViewModelEnablement:
and its explanatory hint; the hint accompanies exactly the disabled button of
a loaded reconstruction that keeps no original audio path."""
- @pytest.mark.parametrize("case", enablement_cases, ids=lambda case: case.label)
- def test_enablement_follows_original_audio_state(self, case: EnablementCase) -> None:
+ @pytest.mark.parametrize(
+ "case",
+ enablement_cases,
+ ids=lambda case: case.label,
+ )
+ def test_enablement_follows_original_audio_state(
+ self,
+ case: EnablementCase,
+ ) -> None:
view_model = ReconstructionViewModel(
reconstruction_loaded=case.reconstruction_loaded,
- available_generators=frozenset(),
+ playing_generators=frozenset(),
+ selected_generators=frozenset(),
reconstruction_file=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path=""),
original_audio=ReconstructionPathViewModel(state=case.original_audio_state, path=""),
)
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_channels.py b/tests/unit/sampletones_application/view_model/sequencer/test_channels.py
index f2130392..b76b1693 100644
--- a/tests/unit/sampletones_application/view_model/sequencer/test_channels.py
+++ b/tests/unit/sampletones_application/view_model/sequencer/test_channels.py
@@ -5,6 +5,7 @@
from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel
from sampletones_core.constants.enums import GeneratorName
+from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
ALL_CHANNELS = frozenset(GeneratorName.items())
@@ -15,38 +16,76 @@
NOISE = GeneratorName.NOISE
-@dataclass(frozen=True, kw_only=True)
-class AllMutedCase(BaseRegularTestCase):
- muted: FrozenSet[GeneratorName]
- expected: bool
-
-
-ALL_MUTED_CASES = [
- AllMutedCase(label="nothing silenced", muted=frozenset(), expected=False),
- AllMutedCase(label="one silenced", muted=frozenset({PULSE1}), expected=False),
- AllMutedCase(label="three silenced", muted=frozenset({PULSE1, PULSE2, NOISE}), expected=False),
- AllMutedCase(label="every channel silenced", muted=ALL_CHANNELS, expected=True),
-]
-
-ANY_MUTED_CASES = [
- AllMutedCase(label="nothing silenced", muted=frozenset(), expected=False),
- AllMutedCase(label="one silenced", muted=frozenset({PULSE1}), expected=True),
- AllMutedCase(label="three silenced", muted=frozenset({PULSE1, PULSE2, NOISE}), expected=True),
- AllMutedCase(label="every channel silenced", muted=ALL_CHANNELS, expected=True),
-]
-
-
-class TestAllMuted:
- @pytest.mark.parametrize("case", ALL_MUTED_CASES, ids=lambda case: case.label)
+class TestAllMuted(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class AllMutedCase(BaseRegularTestCase):
+ muted: FrozenSet[GeneratorName]
+ expected: bool
+
+ test_cases = (
+ AllMutedCase(
+ label="nothing silenced",
+ muted=frozenset(),
+ expected=False,
+ ),
+ AllMutedCase(
+ label="one silenced",
+ muted=frozenset({PULSE1}),
+ expected=False,
+ ),
+ AllMutedCase(
+ label="three silenced",
+ muted=frozenset({PULSE1, PULSE2, NOISE}),
+ expected=False,
+ ),
+ AllMutedCase(
+ label="every channel silenced",
+ muted=ALL_CHANNELS,
+ expected=True,
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
def test_all_muted_reports_full_silence(self, case: AllMutedCase) -> None:
view_model = SequencerChannelsViewModel(muted=case.muted)
assert view_model.all_muted is case.expected
-class TestAnyMuted:
- @pytest.mark.parametrize("case", ANY_MUTED_CASES, ids=lambda case: case.label)
- def test_any_muted_reports_a_silenced_channel(self, case: AllMutedCase) -> None:
+class TestAnyMuted(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class AllMutedCase(BaseRegularTestCase):
+ muted: FrozenSet[GeneratorName]
+ expected: bool
+
+ test_cases = (
+ AllMutedCase(
+ label="nothing silenced",
+ muted=frozenset(),
+ expected=False,
+ ),
+ AllMutedCase(
+ label="one silenced",
+ muted=frozenset({PULSE1}),
+ expected=True,
+ ),
+ AllMutedCase(
+ label="three silenced",
+ muted=frozenset({PULSE1, PULSE2, NOISE}),
+ expected=True,
+ ),
+ AllMutedCase(
+ label="every channel silenced",
+ muted=ALL_CHANNELS,
+ expected=True,
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
+ def test_any_muted_reports_a_silenced_channel(
+ self,
+ case: AllMutedCase,
+ ) -> None:
view_model = SequencerChannelsViewModel(muted=case.muted)
assert view_model.any_muted is case.expected
@@ -58,8 +97,15 @@ def test_the_two_readings_agree_in_full_silence(self) -> None:
class TestIsSoloed:
- @pytest.mark.parametrize("generator", GeneratorName.items(), ids=lambda generator: generator.value)
- def test_the_one_audible_channel_reads_as_soloed(self, generator: GeneratorName) -> None:
+ @pytest.mark.parametrize(
+ "generator",
+ GeneratorName.items(),
+ ids=lambda generator: generator.value,
+ )
+ def test_the_one_audible_channel_reads_as_soloed(
+ self,
+ generator: GeneratorName,
+ ) -> None:
view_model = SequencerChannelsViewModel(muted=ALL_CHANNELS - {generator})
soloed = {other for other in GeneratorName.items() if view_model.is_soloed(other)}
@@ -84,8 +130,15 @@ def test_two_audible_channels_leave_neither_soloed(self) -> None:
class TestIsMuted:
- @pytest.mark.parametrize("generator", GeneratorName.items(), ids=lambda generator: generator.value)
- def test_is_muted_reports_membership_of_the_mute_set(self, generator: GeneratorName) -> None:
+ @pytest.mark.parametrize(
+ "generator",
+ GeneratorName.items(),
+ ids=lambda generator: generator.value,
+ )
+ def test_is_muted_reports_membership_of_the_mute_set(
+ self,
+ generator: GeneratorName,
+ ) -> None:
view_model = SequencerChannelsViewModel(muted=frozenset({TRIANGLE, NOISE}))
assert view_model.is_muted(generator) is (generator in {TRIANGLE, NOISE})
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_grid.py b/tests/unit/sampletones_application/view_model/sequencer/test_grid.py
deleted file mode 100644
index fbd35bc0..00000000
--- a/tests/unit/sampletones_application/view_model/sequencer/test_grid.py
+++ /dev/null
@@ -1,134 +0,0 @@
-from dataclasses import dataclass
-from typing import Dict, FrozenSet
-
-import pytest
-
-from sampletones_application.view_model.sequencer.grid import (
- SequencerCellViewModel,
- SequencerRowViewModel,
-)
-from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.utils.display import NOTE_OFF, display_id, display_transpose, display_volume
-from sampletones_shared.constants.symbols import MIXED
-
-_EMPTY_INSTRUMENT = display_id(None)
-_EMPTY_TRANSPOSE = display_transpose(None)
-_EMPTY_VOLUME = display_volume(None)
-
-
-def _cell(
- *,
- instrument: str = _EMPTY_INSTRUMENT,
- transpose: str = _EMPTY_TRANSPOSE,
- volume: str = _EMPTY_VOLUME,
-) -> SequencerCellViewModel:
- return SequencerCellViewModel(instrument=instrument, transpose=transpose, volume=volume)
-
-
-def _empty_cell() -> SequencerCellViewModel:
- return _cell()
-
-
-@dataclass(frozen=True)
-class AggregateCase:
- name: str
- cells: Dict[GeneratorName, SequencerCellViewModel]
- relevant_generators: FrozenSet[GeneratorName]
- expected_instrument: str
- expected_transpose: str
- expected_volume: str
-
-
-_OCCUPIED = _cell(instrument=display_id(0), transpose=display_transpose(5), volume=display_volume(8))
-
-
-def _row_cells(**overrides: SequencerCellViewModel) -> Dict[GeneratorName, SequencerCellViewModel]:
- cells = {generator: _empty_cell() for generator in GeneratorName.items()}
- for name, cell in overrides.items():
- cells[GeneratorName[name.upper()]] = cell
-
- return cells
-
-
-_CASES = [
- AggregateCase(
- name="no_relevant_channels_fall_back_to_defaults",
- cells=_row_cells(),
- relevant_generators=frozenset(),
- expected_instrument=_EMPTY_INSTRUMENT,
- expected_transpose=_EMPTY_TRANSPOSE,
- expected_volume=_EMPTY_VOLUME,
- ),
- AggregateCase(
- name="transpose_and_volume_span_all_channels_when_no_sample_is_present",
- cells={generator: _cell(volume=display_volume(8)) for generator in GeneratorName.items()},
- relevant_generators=frozenset(),
- expected_instrument=_EMPTY_INSTRUMENT,
- expected_transpose=_EMPTY_TRANSPOSE,
- expected_volume=display_volume(8),
- ),
- AggregateCase(
- name="single_relevant_channel_present",
- cells=_row_cells(pulse1=_OCCUPIED),
- relevant_generators=frozenset({GeneratorName.PULSE1}),
- expected_instrument=display_id(0),
- expected_transpose=display_transpose(5),
- expected_volume=display_volume(8),
- ),
- AggregateCase(
- name="sample_present_across_all_its_relevant_channels",
- cells=_row_cells(pulse1=_OCCUPIED, triangle=_OCCUPIED),
- relevant_generators=frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE}),
- expected_instrument=display_id(0),
- expected_transpose=display_transpose(5),
- expected_volume=display_volume(8),
- ),
- AggregateCase(
- name="sample_missing_from_one_relevant_channel_is_mixed",
- cells=_row_cells(pulse1=_OCCUPIED),
- relevant_generators=frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE}),
- expected_instrument=MIXED,
- expected_transpose=MIXED,
- expected_volume=MIXED,
- ),
- AggregateCase(
- name="diverging_transpose_is_mixed_while_instrument_is_uniform",
- cells=_row_cells(
- pulse1=_OCCUPIED,
- triangle=_cell(instrument=display_id(0), transpose=_EMPTY_TRANSPOSE, volume=display_volume(8)),
- ),
- relevant_generators=frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE}),
- expected_instrument=display_id(0),
- expected_transpose=MIXED,
- expected_volume=display_volume(8),
- ),
- AggregateCase(
- name="all_channels_note_off_reads_as_note_off",
- cells={generator: _cell(instrument=NOTE_OFF) for generator in GeneratorName.items()},
- relevant_generators=frozenset(),
- expected_instrument=NOTE_OFF,
- expected_transpose=_EMPTY_TRANSPOSE,
- expected_volume=_EMPTY_VOLUME,
- ),
- AggregateCase(
- name="partial_note_off_reads_as_empty",
- cells=_row_cells(pulse1=_cell(instrument=NOTE_OFF)),
- relevant_generators=frozenset(),
- expected_instrument=_EMPTY_INSTRUMENT,
- expected_transpose=_EMPTY_TRANSPOSE,
- expected_volume=_EMPTY_VOLUME,
- ),
-]
-
-
-@pytest.mark.parametrize("case", _CASES, ids=lambda case: case.name)
-def test_sample_column_aggregates_over_relevant_channels(case: AggregateCase) -> None:
- row = SequencerRowViewModel(
- index=0,
- cells=case.cells,
- relevant_generators=case.relevant_generators,
- )
-
- assert row.sample_instrument == case.expected_instrument
- assert row.sample_transpose == case.expected_transpose
- assert row.sample_volume == case.expected_volume
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_order.py b/tests/unit/sampletones_application/view_model/sequencer/test_order.py
index 9a5c0adf..678d2c8d 100644
--- a/tests/unit/sampletones_application/view_model/sequencer/test_order.py
+++ b/tests/unit/sampletones_application/view_model/sequencer/test_order.py
@@ -5,28 +5,42 @@
from sampletones_application.view_model.sequencer.order import (
OrderEntryViewModel,
- SequencerOrderGridViewModel,
+ SequencerOrderTrackerViewModel,
SequencerOrderViewModel,
)
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.utils.display import display_id
from sampletones_shared.constants.symbols import MIXED
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
_EMPTY = display_id(None)
-def _grid(channels: Dict[GeneratorName, List[Optional[int]]]) -> SequencerOrderGridViewModel:
+def _tracker(
+ channels: Dict[GeneratorName, List[Optional[int]]],
+) -> SequencerOrderTrackerViewModel:
views = {
generator: SequencerOrderViewModel(
generator=generator,
entries=tuple(
- OrderEntryViewModel(position=position, pattern_index=index) for position, index in enumerate(indices)
+ OrderEntryViewModel(
+ position=position,
+ pattern_index=index,
+ )
+ for position, index in enumerate(indices)
),
)
for generator, indices in channels.items()
}
- position_count = max((len(view.entries) for view in views.values()), default=0)
- return SequencerOrderGridViewModel(position_count=position_count, channels=views)
+ position_count = max(
+ (len(view.entries) for view in views.values()),
+ default=0,
+ )
+ return SequencerOrderTrackerViewModel(
+ position_count=position_count,
+ channels=views,
+ )
def _uniform(*indices: Optional[int]) -> Dict[GeneratorName, List[Optional[int]]]:
@@ -34,47 +48,57 @@ def _uniform(*indices: Optional[int]) -> Dict[GeneratorName, List[Optional[int]]
def test_entry_label_renders_index_and_empty_slot() -> None:
- grid = _grid(_uniform(5, None))
-
- assert grid.entry_label(GeneratorName.PULSE1, 0) == display_id(5)
- assert grid.entry_label(GeneratorName.PULSE1, 1) == _EMPTY
-
-
-@dataclass(frozen=True)
-class MasterCase:
- name: str
- channels: Dict[GeneratorName, List[Optional[int]]]
- expected: str
-
-
-_CASES = [
- MasterCase("shared_index", _uniform(5), display_id(5)),
- MasterCase("all_empty", _uniform(None), _EMPTY),
- MasterCase(
- "divergent_index",
- {
- GeneratorName.PULSE1: [5],
- GeneratorName.PULSE2: [5],
- GeneratorName.TRIANGLE: [7],
- GeneratorName.NOISE: [5],
- },
- MIXED,
- ),
- MasterCase(
- "index_versus_empty",
- {
- GeneratorName.PULSE1: [5],
- GeneratorName.PULSE2: [5],
- GeneratorName.TRIANGLE: [None],
- GeneratorName.NOISE: [5],
- },
- MIXED,
- ),
-]
-
-
-@pytest.mark.parametrize("case", _CASES, ids=lambda case: case.name)
-def test_master_label_aggregates_across_channels(case: MasterCase) -> None:
- grid = _grid(case.channels)
-
- assert grid.master_label(0) == case.expected
+ tracker = _tracker(_uniform(5, None))
+
+ assert tracker.entry_label(GeneratorName.PULSE1, 0) == display_id(5)
+ assert tracker.entry_label(GeneratorName.PULSE1, 1) == _EMPTY
+
+
+class TestMasterLabel(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class MasterCase(BaseRegularTestCase):
+ label: str
+ channels: Dict[GeneratorName, List[Optional[int]]]
+ expected: str
+
+ test_cases = (
+ MasterCase(
+ label="shared_index",
+ channels=_uniform(5),
+ expected=display_id(5),
+ ),
+ MasterCase(
+ label="all_empty",
+ channels=_uniform(None),
+ expected=_EMPTY,
+ ),
+ MasterCase(
+ label="divergent_index",
+ channels={
+ GeneratorName.PULSE1: [5],
+ GeneratorName.PULSE2: [5],
+ GeneratorName.TRIANGLE: [7],
+ GeneratorName.NOISE: [5],
+ },
+ expected=MIXED,
+ ),
+ MasterCase(
+ label="index_versus_empty",
+ channels={
+ GeneratorName.PULSE1: [5],
+ GeneratorName.PULSE2: [5],
+ GeneratorName.TRIANGLE: [None],
+ GeneratorName.NOISE: [5],
+ },
+ expected=MIXED,
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
+ def test_master_label_aggregates_across_channels(
+ self,
+ case: MasterCase,
+ ) -> None:
+ tracker = _tracker(case.channels)
+
+ assert tracker.master_label(0) == case.expected
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_region.py b/tests/unit/sampletones_application/view_model/sequencer/test_region.py
new file mode 100644
index 00000000..ebd29eda
--- /dev/null
+++ b/tests/unit/sampletones_application/view_model/sequencer/test_region.py
@@ -0,0 +1,174 @@
+from typing import Optional
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.view_model.sequencer.region import (
+ OrderRegion,
+ TrackerRegion,
+)
+from sampletones_application.view_model.sequencer.slot import (
+ SLOT_COUNT,
+ TrackerSlot,
+ slot_from_flat,
+)
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+
+
+class TestTrackerRegion:
+ def test_a_single_cell_region_covers_that_cell(self) -> None:
+ region = TrackerRegion(first_row=3, last_row=3, first_slot=4, last_slot=4)
+
+ assert tuple(region.rows) == (3,)
+ assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),)
+
+ def test_the_slots_read_as_the_columns_and_subcolumns_they_address(self) -> None:
+ """A region's edges are subcolumns, so a run reaches across a column boundary mid-cell."""
+ region = TrackerRegion(first_row=0, last_row=0, first_slot=2, last_slot=3)
+
+ assert region.slots == (
+ TrackerSlot(None, SubColumn.VOLUME),
+ TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT),
+ )
+
+ def test_a_region_spans_the_whole_axis(self) -> None:
+ region = TrackerRegion(first_row=0, last_row=63, first_slot=0, last_slot=SLOT_COUNT - 1)
+
+ assert tuple(region.rows) == tuple(range(64))
+ assert region.slots == tuple(slot_from_flat(index) for index in range(SLOT_COUNT))
+
+ def test_inverted_rows_are_rejected(self) -> None:
+ with pytest.raises(ValidationError):
+ TrackerRegion(first_row=5, last_row=2, first_slot=0, last_slot=0)
+
+ def test_inverted_slots_are_rejected(self) -> None:
+ with pytest.raises(ValidationError):
+ TrackerRegion(first_row=0, last_row=0, first_slot=5, last_slot=2)
+
+ @pytest.mark.parametrize("slot", [-1, SLOT_COUNT])
+ def test_a_slot_off_the_axis_is_rejected(self, slot: int) -> None:
+ with pytest.raises(ValidationError):
+ TrackerRegion(first_row=0, last_row=0, first_slot=slot, last_slot=slot)
+
+ def test_a_negative_row_is_rejected(self) -> None:
+ with pytest.raises(ValidationError):
+ TrackerRegion(first_row=-1, last_row=0, first_slot=0, last_slot=0)
+
+
+class TestOrderRegion:
+ def test_a_single_cell_region_covers_that_cell(self) -> None:
+ region = OrderRegion(first_row=0, last_row=0, first_position=2, last_position=2)
+
+ assert region.generators == (None,)
+ assert tuple(region.positions) == (2,)
+
+ def test_the_rows_read_as_the_channels_they_address(self) -> None:
+ region = OrderRegion(first_row=0, last_row=2, first_position=0, last_position=0)
+
+ assert region.generators == (None, GeneratorName.PULSE1, GeneratorName.PULSE2)
+
+ def test_a_region_spans_the_whole_channel_axis(self) -> None:
+ region = OrderRegion(
+ first_row=0,
+ last_row=len(CHANNEL_AXIS) - 1,
+ first_position=0,
+ last_position=7,
+ )
+
+ assert region.generators == CHANNEL_AXIS
+ assert tuple(region.positions) == tuple(range(8))
+
+ def test_inverted_positions_are_rejected(self) -> None:
+ with pytest.raises(ValidationError):
+ OrderRegion(first_row=0, last_row=0, first_position=5, last_position=2)
+
+ def test_a_row_off_the_channel_axis_is_rejected(self) -> None:
+ with pytest.raises(ValidationError):
+ OrderRegion(
+ first_row=0,
+ last_row=len(CHANNEL_AXIS),
+ first_position=0,
+ last_position=0,
+ )
+
+
+class TestTrackerRegionMembership:
+ """Which cells a rectangle holds, which is what a gesture raised on one asks."""
+
+ @pytest.fixture
+ def region(self) -> TrackerRegion:
+ return TrackerRegion(first_row=2, last_row=5, first_slot=3, last_slot=7)
+
+ @pytest.mark.parametrize(
+ ("row", "slot_index"),
+ [
+ (2, 3),
+ (5, 7),
+ (3, 5),
+ ],
+ )
+ def test_a_cell_inside_the_rectangle_belongs_to_it(
+ self,
+ region: TrackerRegion,
+ row: int,
+ slot_index: int,
+ ) -> None:
+ assert region.covers(row, slot_from_flat(slot_index)) is True
+
+ @pytest.mark.parametrize(
+ ("row", "slot_index"),
+ [
+ (1, 5),
+ (6, 5),
+ (3, 2),
+ (3, 8),
+ ],
+ )
+ def test_a_cell_outside_the_rectangle_stands_on_its_own(
+ self,
+ region: TrackerRegion,
+ row: int,
+ slot_index: int,
+ ) -> None:
+ assert region.covers(row, slot_from_flat(slot_index)) is False
+
+
+class TestOrderRegionMembership:
+ @pytest.fixture
+ def region(self) -> OrderRegion:
+ return OrderRegion(first_row=1, last_row=2, first_position=3, last_position=6)
+
+ @pytest.mark.parametrize(
+ ("generator", "position"),
+ [
+ (GeneratorName.PULSE1, 3),
+ (GeneratorName.PULSE2, 6),
+ (GeneratorName.PULSE1, 5),
+ ],
+ )
+ def test_a_cell_inside_the_rectangle_belongs_to_it(
+ self,
+ region: OrderRegion,
+ generator: GeneratorName,
+ position: int,
+ ) -> None:
+ assert region.covers(generator, position) is True
+
+ @pytest.mark.parametrize(
+ ("generator", "position"),
+ [
+ (None, 5),
+ (GeneratorName.TRIANGLE, 5),
+ (GeneratorName.PULSE1, 2),
+ (GeneratorName.PULSE1, 7),
+ ],
+ )
+ def test_a_cell_outside_the_rectangle_stands_on_its_own(
+ self,
+ region: OrderRegion,
+ generator: Optional[GeneratorName],
+ position: int,
+ ) -> None:
+ assert region.covers(generator, position) is False
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_slot.py b/tests/unit/sampletones_application/view_model/sequencer/test_slot.py
new file mode 100644
index 00000000..34fb7cc8
--- /dev/null
+++ b/tests/unit/sampletones_application/view_model/sequencer/test_slot.py
@@ -0,0 +1,59 @@
+from typing import Optional
+
+import pytest
+
+from sampletones_application.constants.sequencer import CHANNEL_AXIS
+from sampletones_application.view_model.sequencer.slot import (
+ SLOT_COUNT,
+ SUBCOLUMNS,
+ TrackerSlot,
+ column_slot_base,
+ slot_from_flat,
+)
+from sampletones_application.view_model.sequencer.subcolumn import SubColumn
+from sampletones_core.constants.enums import GeneratorName
+
+_OUT_OF_RANGE = [-1, -SLOT_COUNT, SLOT_COUNT, SLOT_COUNT + 1]
+
+
+class TestAxis:
+ def test_the_sample_column_leads_the_four_channels(self) -> None:
+ assert CHANNEL_AXIS == (None, *GeneratorName.items())
+
+ def test_the_axis_covers_every_column_once_over(self) -> None:
+ assert SLOT_COUNT == len(CHANNEL_AXIS) * len(SUBCOLUMNS)
+
+
+class TestFlatIndex:
+ @pytest.mark.parametrize("index", range(SLOT_COUNT))
+ def test_every_index_round_trips_through_its_slot(self, index: int) -> None:
+ assert slot_from_flat(index).flat_index == index
+
+ def test_the_axis_maps_onto_the_whole_index_range(self) -> None:
+ indices = {
+ TrackerSlot(generator, subcolumn).flat_index for generator in CHANNEL_AXIS for subcolumn in SUBCOLUMNS
+ }
+
+ assert indices == set(range(SLOT_COUNT))
+
+ def test_the_sample_columns_instrument_opens_the_axis(self) -> None:
+ assert TrackerSlot(None, SubColumn.INSTRUMENT).flat_index == 0
+
+
+class TestColumnBase:
+ @pytest.mark.parametrize("generator", CHANNEL_AXIS)
+ def test_every_base_starts_a_whole_column(self, generator: Optional[GeneratorName]) -> None:
+ """Kind alignment rests on this: an offset from any base addresses the same subcolumn."""
+ assert column_slot_base(generator) % len(SUBCOLUMNS) == 0
+
+ @pytest.mark.parametrize("generator", CHANNEL_AXIS)
+ def test_a_base_addresses_its_columns_first_subcolumn(self, generator: Optional[GeneratorName]) -> None:
+ assert slot_from_flat(column_slot_base(generator)) == TrackerSlot(generator, SUBCOLUMNS[0])
+
+
+class TestBounds:
+ @pytest.mark.parametrize("index", _OUT_OF_RANGE)
+ def test_an_index_off_the_axis_is_rejected(self, index: int) -> None:
+ """A selection clips at the edge, so a slot outside the axis is a caller's mistake."""
+ with pytest.raises(IndexError):
+ slot_from_flat(index)
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_song_player.py b/tests/unit/sampletones_application/view_model/sequencer/test_song_player.py
index 96f2dbc8..8e3966d1 100644
--- a/tests/unit/sampletones_application/view_model/sequencer/test_song_player.py
+++ b/tests/unit/sampletones_application/view_model/sequencer/test_song_player.py
@@ -3,6 +3,7 @@
import pytest
from pydantic import ValidationError
+from sampletones_application.constants.playback import FollowMode
from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel
@@ -11,7 +12,7 @@ def _view_model(
is_loaded: bool = True,
is_playing: bool = False,
is_paused: bool = False,
- follow_playback: bool = True,
+ follow_mode: FollowMode = FollowMode.ROWS,
order_position: int = 0,
row_index: int = 0,
error: Optional[str] = None,
@@ -20,7 +21,7 @@ def _view_model(
is_loaded=is_loaded,
is_playing=is_playing,
is_paused=is_paused,
- follow_playback=follow_playback,
+ follow_mode=follow_mode,
order_position=order_position,
row_index=row_index,
error=error,
diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py
new file mode 100644
index 00000000..e9dd5c3a
--- /dev/null
+++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py
@@ -0,0 +1,187 @@
+from dataclasses import dataclass
+from typing import Dict, FrozenSet
+
+import pytest
+
+from sampletones_application.view_model.sequencer.tracker import (
+ SequencerCellViewModel,
+ SequencerRowViewModel,
+)
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.utils.display import (
+ NOTE_OFF,
+ display_id,
+ display_transpose,
+ display_volume,
+)
+from sampletones_shared.constants.symbols import MIXED
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+_EMPTY_INSTRUMENT = display_id(None)
+_EMPTY_TRANSPOSE = display_transpose(None)
+_EMPTY_VOLUME = display_volume(None)
+
+
+def _cell(
+ *,
+ instrument: str = _EMPTY_INSTRUMENT,
+ transpose: str = _EMPTY_TRANSPOSE,
+ volume: str = _EMPTY_VOLUME,
+) -> SequencerCellViewModel:
+ return SequencerCellViewModel(
+ instrument=instrument,
+ transpose=transpose,
+ volume=volume,
+ )
+
+
+def _empty_cell() -> SequencerCellViewModel:
+ return _cell()
+
+
+_OCCUPIED = _cell(
+ instrument=display_id(0),
+ transpose=display_transpose(5),
+ volume=display_volume(8),
+)
+
+
+def _row_cells(
+ **overrides: SequencerCellViewModel,
+) -> Dict[GeneratorName, SequencerCellViewModel]:
+ cells = {generator: _empty_cell() for generator in GeneratorName.items()}
+ for name, cell in overrides.items():
+ cells[GeneratorName[name.upper()]] = cell
+
+ return cells
+
+
+class TestSampleColumnAggregate(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class AggregateCase(BaseRegularTestCase):
+ cells: Dict[GeneratorName, SequencerCellViewModel]
+ relevant_generators: FrozenSet[GeneratorName]
+ expected_instrument: str
+ expected_transpose: str
+ expected_volume: str
+
+ test_cases = (
+ AggregateCase(
+ label="no_relevant_channels_fall_back_to_defaults",
+ cells=_row_cells(),
+ relevant_generators=frozenset(),
+ expected_instrument=_EMPTY_INSTRUMENT,
+ expected_transpose=_EMPTY_TRANSPOSE,
+ expected_volume=_EMPTY_VOLUME,
+ ),
+ AggregateCase(
+ label="transpose_and_volume_span_all_channels_when_no_sample_is_present",
+ cells={generator: _cell(volume=display_volume(8)) for generator in GeneratorName.items()},
+ relevant_generators=frozenset(),
+ expected_instrument=_EMPTY_INSTRUMENT,
+ expected_transpose=_EMPTY_TRANSPOSE,
+ expected_volume=display_volume(8),
+ ),
+ AggregateCase(
+ label="single_relevant_channel_present",
+ cells=_row_cells(pulse1=_OCCUPIED),
+ relevant_generators=frozenset({GeneratorName.PULSE1}),
+ expected_instrument=display_id(0),
+ expected_transpose=display_transpose(5),
+ expected_volume=display_volume(8),
+ ),
+ AggregateCase(
+ label="sample_present_across_all_its_relevant_channels",
+ cells=_row_cells(pulse1=_OCCUPIED, triangle=_OCCUPIED),
+ relevant_generators=frozenset(
+ {
+ GeneratorName.PULSE1,
+ GeneratorName.TRIANGLE,
+ }
+ ),
+ expected_instrument=display_id(0),
+ expected_transpose=display_transpose(5),
+ expected_volume=display_volume(8),
+ ),
+ AggregateCase(
+ label="sample_missing_from_one_relevant_channel_is_mixed",
+ cells=_row_cells(pulse1=_OCCUPIED),
+ relevant_generators=frozenset(
+ {
+ GeneratorName.PULSE1,
+ GeneratorName.TRIANGLE,
+ }
+ ),
+ expected_instrument=MIXED,
+ expected_transpose=MIXED,
+ expected_volume=MIXED,
+ ),
+ AggregateCase(
+ label="diverging_transpose_is_mixed_while_instrument_is_uniform",
+ cells=_row_cells(
+ pulse1=_OCCUPIED,
+ triangle=_cell(
+ instrument=display_id(0),
+ transpose=_EMPTY_TRANSPOSE,
+ volume=display_volume(8),
+ ),
+ ),
+ relevant_generators=frozenset(
+ {
+ GeneratorName.PULSE1,
+ GeneratorName.TRIANGLE,
+ }
+ ),
+ expected_instrument=display_id(0),
+ expected_transpose=MIXED,
+ expected_volume=display_volume(8),
+ ),
+ AggregateCase(
+ label="all_channels_note_off_reads_as_note_off",
+ cells={generator: _cell(instrument=NOTE_OFF) for generator in GeneratorName.items()},
+ relevant_generators=frozenset(),
+ expected_instrument=NOTE_OFF,
+ expected_transpose=_EMPTY_TRANSPOSE,
+ expected_volume=_EMPTY_VOLUME,
+ ),
+ AggregateCase(
+ label="half_cut_row_is_mixed",
+ cells=_row_cells(pulse1=_cell(instrument=NOTE_OFF)),
+ relevant_generators=frozenset(),
+ expected_instrument=MIXED,
+ expected_transpose=_EMPTY_TRANSPOSE,
+ expected_volume=_EMPTY_VOLUME,
+ ),
+ AggregateCase(
+ label="zero_transpose_beside_an_empty_one_is_mixed",
+ cells=_row_cells(pulse1=_cell(transpose=display_transpose(0))),
+ relevant_generators=frozenset(),
+ expected_instrument=_EMPTY_INSTRUMENT,
+ expected_transpose=MIXED,
+ expected_volume=_EMPTY_VOLUME,
+ ),
+ AggregateCase(
+ label="zero_transpose_shared_by_every_channel_reads_as_zero",
+ cells={generator: _cell(transpose=display_transpose(0)) for generator in GeneratorName.items()},
+ relevant_generators=frozenset(),
+ expected_instrument=_EMPTY_INSTRUMENT,
+ expected_transpose=display_transpose(0),
+ expected_volume=_EMPTY_VOLUME,
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
+ def test_sample_column_aggregates_over_relevant_channels(
+ self,
+ case: AggregateCase,
+ ) -> None:
+ row = SequencerRowViewModel(
+ index=0,
+ cells=case.cells,
+ relevant_generators=case.relevant_generators,
+ )
+
+ assert row.sample_instrument == case.expected_instrument
+ assert row.sample_transpose == case.expected_transpose
+ assert row.sample_volume == case.expected_volume
diff --git a/tests/unit/sampletones_application/view_model/shared/test_audio_settings.py b/tests/unit/sampletones_application/view_model/shared/test_audio_settings.py
index 03346f65..f412e6b9 100644
--- a/tests/unit/sampletones_application/view_model/shared/test_audio_settings.py
+++ b/tests/unit/sampletones_application/view_model/shared/test_audio_settings.py
@@ -79,7 +79,8 @@ class MappingCase:
class TestFromDeviceManager:
"""The projection carries display labels alongside the typed values a selection commits, so
- the window renders and resolves selections without formatting or parsing of its own."""
+ the window renders and resolves selections without formatting or parsing of its own.
+ """
@pytest.mark.parametrize("case", MAPPING_CASES, ids=lambda case: case.label)
def test_projects_the_manager_state(self, case: MappingCase) -> None:
@@ -91,7 +92,12 @@ def test_projects_the_manager_state(self, case: MappingCase) -> None:
assert view_model.buffer_size == case.buffer_size
def test_carries_the_master_gain(self) -> None:
- view_model = _view_model({0: _device(0, "Speakers")}, MAPPING_CASES[0].current_device, 512, master_gain=1.5)
+ view_model = _view_model(
+ {0: _device(0, "Speakers")},
+ MAPPING_CASES[0].current_device,
+ 512,
+ master_gain=1.5,
+ )
assert view_model.master_gain == 1.5
@@ -121,7 +127,11 @@ def test_labels_pair_with_their_values(self) -> None:
item = AudioDeviceItem.from_device(_device(3, "Headphones"))
assert item.label(DEVICE_LABEL_FORMAT) == "3: Headphones"
- assert item.sample_rate_labels(SAMPLE_RATE_FORMAT) == ("22050 Hz", "44100 Hz", "48000 Hz")
+ assert item.sample_rate_labels(SAMPLE_RATE_FORMAT) == (
+ "22050 Hz",
+ "44100 Hz",
+ "48000 Hz",
+ )
assert item.default_sample_rate_label(SAMPLE_RATE_FORMAT) == "44100 Hz"
@@ -189,7 +199,8 @@ class ReadoutCase:
class TestMasterGainReadout:
"""The readout projects a linear gain to the decibel label a slider shows and the boost
- fraction a warning gradient follows: ``0`` at unity or quieter, ramping to ``1`` at maximum."""
+ fraction a warning gradient follows: ``0`` at unity or quieter, ramping to ``1`` at maximum.
+ """
@pytest.mark.parametrize("case", READOUT_CASES, ids=lambda case: case.label)
def test_projects_the_gain(self, case: ReadoutCase) -> None:
diff --git a/tests/unit/sampletones_application/view_model/shared/test_display_settings.py b/tests/unit/sampletones_application/view_model/shared/test_display_settings.py
new file mode 100644
index 00000000..1eb195f7
--- /dev/null
+++ b/tests/unit/sampletones_application/view_model/shared/test_display_settings.py
@@ -0,0 +1,240 @@
+from typing import Tuple
+
+import pytest
+
+from sampletones_application.view_model.shared.display_settings import (
+ DisplaySettings,
+ DisplaySettingsViewModel,
+ WindowMode,
+ available_resolutions,
+ frame_rate_label,
+ frame_rate_labels,
+ nearest_frame_rate,
+ nearest_resolution,
+ resolution_labels,
+)
+from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution
+
+UNLIMITED_LABEL = "Unlimited"
+
+DESKTOP_BOUND = (1728, 972)
+LAPTOP_BOUND = (1229, 691)
+WIDE_BOUND = (3456, 1944)
+
+MIN_WIDTH = 1024
+MIN_HEIGHT = 640
+
+RESOLUTIONS: Tuple[Resolution, ...] = (
+ Resolution(width=1024, height=768),
+ Resolution(width=1152, height=648),
+ Resolution(width=1280, height=800),
+ Resolution(width=1600, height=900),
+ Resolution(width=1920, height=1080),
+ Resolution(width=2560, height=1440),
+ Resolution(width=3840, height=2160),
+)
+
+FRAME_RATES: Tuple[int, ...] = (UNLIMITED_FRAME_RATE, 30, 60, 90, 120, 240)
+
+
+def offered(
+ bound: Tuple[int, int],
+ *,
+ min_width: int = MIN_WIDTH,
+ min_height: int = MIN_HEIGHT,
+) -> Tuple[Resolution, ...]:
+ max_width, max_height = bound
+ return available_resolutions(
+ RESOLUTIONS,
+ min_width=min_width,
+ min_height=min_height,
+ max_width=max_width,
+ max_height=max_height,
+ )
+
+
+class TestAvailableResolutions:
+ def test_every_offered_size_stays_within_the_bounds(self) -> None:
+ assert all(resolution.fits_within(*DESKTOP_BOUND) for resolution in offered(DESKTOP_BOUND))
+
+ def test_every_offered_size_meets_the_window_minimum(self) -> None:
+ resolutions = offered(WIDE_BOUND, min_width=1600, min_height=900)
+
+ assert all(resolution.reaches(1600, 900) for resolution in resolutions)
+
+ def test_a_size_beyond_the_bounds_is_left_to_fullscreen(self) -> None:
+ """A window is held below the monitor so its frame stays on screen."""
+ assert Resolution(width=1920, height=1080) not in offered(DESKTOP_BOUND)
+
+ def test_a_larger_bound_offers_more(self) -> None:
+ assert set(offered(DESKTOP_BOUND)) < set(offered(WIDE_BOUND))
+
+ def test_a_small_laptop_is_offered_a_size_of_its_own(self) -> None:
+ """A screen with room for few sizes still picks from the offered list."""
+ assert set(offered(LAPTOP_BOUND)) <= set(RESOLUTIONS)
+
+ def test_the_offered_sizes_keep_the_order_they_are_given_in(self) -> None:
+ resolutions = offered(WIDE_BOUND)
+
+ assert list(resolutions) == [resolution for resolution in RESOLUTIONS if resolution in resolutions]
+
+ def test_bounds_with_room_for_none_offer_the_window_minimum(self) -> None:
+ assert offered((800, 600)) == (Resolution(width=MIN_WIDTH, height=MIN_HEIGHT),)
+
+ def test_a_label_spells_the_size(self) -> None:
+ assert resolution_labels((Resolution(width=1280, height=800),)) == ("1280x800",)
+
+
+class TestFrameRates:
+ def test_the_unlimited_setting_is_offered_by_name(self) -> None:
+ assert frame_rate_label(UNLIMITED_FRAME_RATE, unlimited_label=UNLIMITED_LABEL) == UNLIMITED_LABEL
+
+ def test_a_capped_rate_is_offered_by_number(self) -> None:
+ assert frame_rate_label(60, unlimited_label=UNLIMITED_LABEL) == "60"
+
+ def test_every_offered_rate_carries_a_label(self) -> None:
+ labels = frame_rate_labels(FRAME_RATES, unlimited_label=UNLIMITED_LABEL)
+
+ assert len(labels) == len(FRAME_RATES)
+
+ def test_a_stored_rate_that_is_offered_selects_itself(self) -> None:
+ assert nearest_frame_rate(60, FRAME_RATES) == 60
+
+ def test_a_stored_rate_the_build_stopped_offering_selects_the_closest(self) -> None:
+ """A preference outlives the list offered when it was written."""
+ assert nearest_frame_rate(100, FRAME_RATES) == 90
+
+ def test_a_rate_beyond_the_offered_ones_selects_the_highest(self) -> None:
+ assert nearest_frame_rate(1000, FRAME_RATES) == max(FRAME_RATES)
+
+ def test_selecting_without_an_offered_rate_raises(self) -> None:
+ with pytest.raises(ValueError):
+ nearest_frame_rate(60, ())
+
+
+class TestNearestResolution:
+ def test_a_window_at_an_offered_size_selects_it(self) -> None:
+ assert nearest_resolution(1280, 800, offered(DESKTOP_BOUND)) == Resolution(width=1280, height=800)
+
+ def test_a_window_between_two_sizes_selects_the_closer(self) -> None:
+ assert nearest_resolution(1290, 810, offered(WIDE_BOUND)) == Resolution(width=1280, height=800)
+
+ def test_selecting_without_an_offered_size_raises(self) -> None:
+ with pytest.raises(ValueError):
+ nearest_resolution(1280, 800, ())
+
+
+PALETTES: Tuple[str, ...] = ("dark", "light", "studio")
+
+
+def settings(
+ *,
+ resolution: Resolution = Resolution(width=1280, height=800),
+ borderless: bool = False,
+ fullscreen: bool = False,
+ frame_rate: int = 60,
+) -> DisplaySettings:
+ return DisplaySettings(
+ palette="studio",
+ window=WindowMode(
+ resolution=resolution,
+ borderless=borderless,
+ fullscreen=fullscreen,
+ ),
+ vsync=True,
+ frame_rate=frame_rate,
+ )
+
+
+def view_model(
+ display_settings: DisplaySettings,
+ bound: Tuple[int, int] = WIDE_BOUND,
+) -> DisplaySettingsViewModel:
+ max_width, max_height = bound
+ return DisplaySettingsViewModel.build(
+ display_settings,
+ resolutions=RESOLUTIONS,
+ frame_rates=FRAME_RATES,
+ palettes=PALETTES,
+ min_width=MIN_WIDTH,
+ min_height=MIN_HEIGHT,
+ max_width=max_width,
+ max_height=max_height,
+ )
+
+
+class TestSettingsChanges:
+ def test_changing_one_entry_leaves_the_rest_standing(self) -> None:
+ changed = settings().with_palette("dark")
+
+ assert changed.palette == "dark"
+ assert changed.window == settings().window
+
+ def test_changing_one_part_of_the_window_mode_leaves_the_rest_standing(
+ self,
+ ) -> None:
+ window = settings().window.with_borderless(True)
+
+ assert window.borderless is True
+ assert window.resolution == Resolution(width=1280, height=800)
+
+ def test_the_settings_a_change_was_asked_of_stay_as_they_were(self) -> None:
+ """A snapshot taken before an edit still reads the state it was taken from."""
+ snapshot = settings()
+ snapshot.with_vsync(False)
+
+ assert snapshot.vsync is True
+
+
+class TestDisplaySettingsViewModel:
+ def test_the_offer_holds_only_what_the_monitor_leaves_room_for(self) -> None:
+ assert view_model(settings(), DESKTOP_BOUND).resolutions == offered(DESKTOP_BOUND)
+
+ def test_a_window_at_a_size_of_its_own_selects_the_nearest_offered_one(
+ self,
+ ) -> None:
+ built = view_model(settings(resolution=Resolution(width=1290, height=810)))
+
+ assert built.settings.window.resolution == Resolution(width=1280, height=800)
+
+ def test_a_stored_rate_the_build_stopped_offering_selects_the_closest(self) -> None:
+ assert view_model(settings(frame_rate=100)).settings.frame_rate == 90
+
+ def test_a_windowed_window_offers_its_size_and_frame(self) -> None:
+ assert view_model(settings()).window_controls_enabled
+
+ def test_a_fullscreen_window_offers_neither_a_size_nor_a_frame(self) -> None:
+ assert not view_model(settings(fullscreen=True)).window_controls_enabled
+
+ def test_every_offered_size_carries_an_item(self) -> None:
+ built = view_model(settings())
+
+ assert len(built.resolution_items) == len(built.resolutions)
+
+ def test_the_selected_size_reads_as_one_of_the_offered_items(self) -> None:
+ built = view_model(settings())
+
+ assert built.current_resolution_item in built.resolution_items
+
+ def test_the_selected_rate_reads_as_one_of_the_offered_items(self) -> None:
+ built = view_model(settings())
+
+ assert built.current_frame_rate_item(UNLIMITED_LABEL) in built.frame_rate_items(UNLIMITED_LABEL)
+
+ def test_an_item_leads_back_to_the_size_it_stands_for(self) -> None:
+ built = view_model(settings())
+
+ assert built.resolution_for_item("1600x900") == Resolution(width=1600, height=900)
+
+ def test_an_item_leads_back_to_the_rate_it_stands_for(self) -> None:
+ built = view_model(settings())
+
+ assert built.frame_rate_for_item(UNLIMITED_LABEL, UNLIMITED_LABEL) == UNLIMITED_FRAME_RATE
+
+ def test_an_item_no_size_carries_raises(self) -> None:
+ with pytest.raises(KeyError):
+ view_model(settings()).resolution_for_item("640x480")
+
+ def test_an_item_no_rate_carries_raises(self) -> None:
+ with pytest.raises(KeyError):
+ view_model(settings()).frame_rate_for_item("360", UNLIMITED_LABEL)
diff --git a/tests/unit/sampletones_application/view_model/shared/test_menu.py b/tests/unit/sampletones_application/view_model/shared/test_menu.py
index 52b16c53..f85be234 100644
--- a/tests/unit/sampletones_application/view_model/shared/test_menu.py
+++ b/tests/unit/sampletones_application/view_model/shared/test_menu.py
@@ -2,61 +2,64 @@
import pytest
+from sampletones_application.constants.playback import FollowMode
from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel
from sampletones_application.view_model.shared.menu import MenuBarViewModel
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
EVERY_CHANNEL_AUDIBLE = SequencerChannelsViewModel(muted=frozenset())
-@dataclass(frozen=True, kw_only=True)
-class EnablementCase:
- label: str
- project_open: bool
- can_undo: bool
- can_redo: bool
- undo_enabled: bool
- redo_enabled: bool
-
-
-ENABLEMENT_CASES = [
- EnablementCase(
- label="closed_project_disables_both",
- project_open=False,
- can_undo=True,
- can_redo=True,
- undo_enabled=False,
- redo_enabled=False,
- ),
- EnablementCase(
- label="baseline_history_disables_both",
- project_open=True,
- can_undo=False,
- can_redo=False,
- undo_enabled=False,
- redo_enabled=False,
- ),
- EnablementCase(
- label="undoable_edit_enables_undo",
- project_open=True,
- can_undo=True,
- can_redo=False,
- undo_enabled=True,
- redo_enabled=False,
- ),
- EnablementCase(
- label="undone_edit_enables_redo",
- project_open=True,
- can_undo=False,
- can_redo=True,
- undo_enabled=False,
- redo_enabled=True,
- ),
-]
+class TestUndoRedoEnablement(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class EnablementCase(BaseRegularTestCase):
+ project_open: bool
+ can_undo: bool
+ can_redo: bool
+ undo_enabled: bool
+ redo_enabled: bool
+ test_cases = (
+ EnablementCase(
+ label="closed_project_disables_both",
+ project_open=False,
+ can_undo=True,
+ can_redo=True,
+ undo_enabled=False,
+ redo_enabled=False,
+ ),
+ EnablementCase(
+ label="baseline_history_disables_both",
+ project_open=True,
+ can_undo=False,
+ can_redo=False,
+ undo_enabled=False,
+ redo_enabled=False,
+ ),
+ EnablementCase(
+ label="undoable_edit_enables_undo",
+ project_open=True,
+ can_undo=True,
+ can_redo=False,
+ undo_enabled=True,
+ redo_enabled=False,
+ ),
+ EnablementCase(
+ label="undone_edit_enables_redo",
+ project_open=True,
+ can_undo=False,
+ can_redo=True,
+ undo_enabled=False,
+ redo_enabled=True,
+ ),
+ )
-class TestUndoRedoEnablement:
- @pytest.mark.parametrize("case", ENABLEMENT_CASES, ids=lambda case: case.label)
- def test_enablement_follows_project_and_history_state(self, case: EnablementCase) -> None:
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
+ def test_enablement_follows_project_and_history_state(
+ self,
+ case: EnablementCase,
+ ) -> None:
view_model = MenuBarViewModel(
project_open=case.project_open,
reconstruction_loaded=False,
@@ -64,6 +67,7 @@ def test_enablement_follows_project_and_history_state(self, case: EnablementCase
reconstruction_in_project=False,
reconstruction_file_backed=False,
reconstruction_audio_recorded=False,
+ operation_active=False,
can_undo=case.can_undo,
can_redo=case.can_redo,
play_label="Play",
@@ -74,11 +78,13 @@ def test_enablement_follows_project_and_history_state(self, case: EnablementCase
player_paused=False,
stop_enabled=False,
autoplay=False,
- follow_playback=False,
+ follow_mode=FollowMode.OFF,
loop_song=False,
channels=EVERY_CHANNEL_AUDIBLE,
fullscreen=False,
advanced_settings=False,
+ auto_expand_favorite_reconstructions=False,
+ auto_expand_favorite_directories=False,
)
assert view_model.undo_enabled is case.undo_enabled
@@ -100,6 +106,7 @@ def test_save_flag_is_carried_verbatim(
reconstruction_in_project=False,
reconstruction_file_backed=False,
reconstruction_audio_recorded=False,
+ operation_active=False,
can_undo=False,
can_redo=False,
play_label="Play",
@@ -110,11 +117,13 @@ def test_save_flag_is_carried_verbatim(
player_paused=False,
stop_enabled=False,
autoplay=False,
- follow_playback=False,
+ follow_mode=FollowMode.OFF,
loop_song=False,
channels=EVERY_CHANNEL_AUDIBLE,
fullscreen=False,
advanced_settings=False,
+ auto_expand_favorite_reconstructions=False,
+ auto_expand_favorite_directories=False,
)
assert view_model.reconstruction_saveable is reconstruction_saveable
diff --git a/tests/unit/sampletones_application/view_model/shared/test_nearest.py b/tests/unit/sampletones_application/view_model/shared/test_nearest.py
new file mode 100644
index 00000000..09f03256
--- /dev/null
+++ b/tests/unit/sampletones_application/view_model/shared/test_nearest.py
@@ -0,0 +1,18 @@
+import pytest
+
+from sampletones_application.view_model.shared.nearest import nearest_offered
+
+
+class TestNearestOffered:
+ def test_an_offered_value_selects_itself(self) -> None:
+ assert nearest_offered(48000, (8000, 44100, 48000)) == 48000
+
+ def test_a_value_between_offers_selects_the_closer_one(self) -> None:
+ assert nearest_offered(96000, (8000, 44100, 48000)) == 48000
+
+ def test_two_offers_equally_close_select_the_smaller(self) -> None:
+ assert nearest_offered(30, (20, 40)) == 20
+
+ def test_nothing_offered_reports_the_empty_choice(self) -> None:
+ with pytest.raises(ValueError):
+ nearest_offered(44100, ())
diff --git a/tests/unit/sampletones_application/view_model/shared/test_render.py b/tests/unit/sampletones_application/view_model/shared/test_render.py
new file mode 100644
index 00000000..f2f37bf0
--- /dev/null
+++ b/tests/unit/sampletones_application/view_model/shared/test_render.py
@@ -0,0 +1,158 @@
+from pathlib import Path
+from typing import Final
+
+from sampletones_application.view_model.shared.render import (
+ RenderPhase,
+ SongRenderSettings,
+ SongRenderViewModel,
+)
+from sampletones_core.audio.writers import (
+ AudioDepth,
+ AudioFormat,
+ Mp3OutputSpec,
+ WaveOutputSpec,
+)
+
+DESTINATION: Final[Path] = Path("/home/user/song.wav")
+TOTAL_SAMPLES: Final[int] = 44100
+
+
+def wave_settings(
+ *,
+ sample_rate: int = 44100,
+ depth: AudioDepth = AudioDepth.PCM_16,
+) -> SongRenderSettings:
+ return SongRenderSettings(
+ spec=WaveOutputSpec(sample_rate=sample_rate, depth=depth),
+ normalize=False,
+ )
+
+
+def view_model(
+ settings: SongRenderSettings,
+ *,
+ phase: RenderPhase = RenderPhase.CONFIGURING,
+ total_samples: int = TOTAL_SAMPLES,
+) -> SongRenderViewModel:
+ return SongRenderViewModel(
+ phase=phase,
+ formats=(AudioFormat.WAVE, AudioFormat.MP3),
+ depths=(AudioDepth.PCM_16, AudioDepth.PCM_24),
+ settings=settings,
+ destination=DESTINATION,
+ total_samples=total_samples,
+ status_text="",
+ progress=0.0,
+ )
+
+
+class TestChoicesFollowTheFormat:
+ """Every choice a dialog stands at is reconciled against what the container accepts."""
+
+ def test_a_rate_the_new_format_encodes_is_kept(self) -> None:
+ settings = wave_settings(sample_rate=48000).with_format(AudioFormat.MP3)
+
+ assert settings.spec.audio_format == AudioFormat.MP3
+ assert settings.spec.sample_rate == 48000
+
+ def test_a_rate_the_new_format_leaves_behind_moves_to_the_nearest(self) -> None:
+ settings = wave_settings(sample_rate=192000).with_format(AudioFormat.MP3)
+
+ assert settings.spec.sample_rate == 48000
+
+ def test_a_container_storing_samples_opens_on_a_depth(self) -> None:
+ settings = SongRenderSettings.initial(AudioFormat.MP3).with_format(AudioFormat.WAVE)
+
+ assert settings.depth is not None
+ assert settings.bitrate is None
+
+ def test_a_depth_survives_a_rate_change(self) -> None:
+ settings = wave_settings(depth=AudioDepth.PCM_U8).with_sample_rate(8000)
+
+ assert settings.spec.sample_rate == 8000
+ assert settings.depth == AudioDepth.PCM_U8
+
+ def test_the_normalise_choice_stands_through_a_format_change(self) -> None:
+ settings = wave_settings().with_normalize(True).with_format(AudioFormat.MP3)
+
+ assert settings.normalize
+
+
+class TestBitratesFollowTheRate:
+ """Each MPEG version defines its own ladder, so the bitrate follows the rate that selects it."""
+
+ def test_a_bitrate_the_new_rate_reaches_is_kept(self) -> None:
+ settings = SongRenderSettings(
+ spec=Mp3OutputSpec(sample_rate=44100, bitrate=64),
+ normalize=False,
+ ).with_sample_rate(22050)
+
+ assert settings.bitrate == 64
+
+ def test_a_bitrate_beyond_the_new_ladder_moves_onto_it(self) -> None:
+ settings = SongRenderSettings(
+ spec=Mp3OutputSpec(sample_rate=44100, bitrate=320),
+ normalize=False,
+ ).with_sample_rate(8000)
+
+ assert settings.bitrate == 64
+
+ def test_the_chosen_bitrate_is_taken(self) -> None:
+ settings = SongRenderSettings.initial(AudioFormat.MP3).with_bitrate(96)
+
+ assert settings.bitrate == 96
+
+
+class TestWhatTheDialogDraws:
+ def test_a_container_storing_samples_offers_depths_and_no_bitrates(self) -> None:
+ view = view_model(wave_settings())
+
+ assert view.stores_samples
+ assert view.bitrates == ()
+
+ def test_a_container_encoding_to_a_bitrate_offers_the_ladder_of_its_rate(self) -> None:
+ view = view_model(SongRenderSettings.initial(AudioFormat.MP3).with_sample_rate(8000))
+
+ assert not view.stores_samples
+ assert view.bitrates == (64, 56, 48, 40, 32, 24, 16, 8)
+
+ def test_the_offered_rates_are_the_containers_own(self) -> None:
+ view = view_model(SongRenderSettings.initial(AudioFormat.MP3))
+
+ assert view.sample_rates == (8000, 16000, 22050, 44100, 48000)
+
+ def test_the_projected_duration_is_the_song_at_the_chosen_rate(self) -> None:
+ view = view_model(wave_settings(sample_rate=44100), total_samples=88200)
+
+ assert view.duration_seconds == 2.0
+
+ def test_setting_up_shows_the_setup_alone(self) -> None:
+ view = view_model(wave_settings(), phase=RenderPhase.CONFIGURING)
+
+ assert view.setup_visible
+ assert not view.progress_visible
+ assert view.render_enabled
+ assert view.is_active
+
+ def test_rendering_shows_the_progress_alone(self) -> None:
+ view = view_model(wave_settings(), phase=RenderPhase.RENDERING)
+
+ assert view.progress_visible
+ assert not view.setup_visible
+ assert not view.render_enabled
+ assert view.cancel_enabled
+
+ def test_a_render_already_stopping_takes_no_further_stop(self) -> None:
+ view = view_model(wave_settings(), phase=RenderPhase.CANCELLING)
+
+ assert view.is_active
+ assert not view.cancel_enabled
+
+ def test_a_song_holding_no_rows_starts_no_render(self) -> None:
+ view = view_model(wave_settings(), total_samples=0)
+
+ assert not view.render_enabled
+
+ def test_an_outcome_releases_the_application(self) -> None:
+ for phase in (RenderPhase.COMPLETED, RenderPhase.CANCELLED, RenderPhase.FAILED):
+ assert not view_model(wave_settings(), phase=phase).is_active
diff --git a/tests/unit/sampletones_assets/__init__.py b/tests/unit/sampletones_assets/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_assets/mark/__init__.py b/tests/unit/sampletones_assets/mark/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_assets/mark/test_geometry.py b/tests/unit/sampletones_assets/mark/test_geometry.py
new file mode 100644
index 00000000..78279edb
--- /dev/null
+++ b/tests/unit/sampletones_assets/mark/test_geometry.py
@@ -0,0 +1,69 @@
+import itertools
+from typing import Final
+
+import pytest
+
+from sampletones_assets.mark.geometry import Rectangle, sine_points, square_rectangles
+from sampletones_assets.mark.specification import Mark
+
+SAMPLES: Final[int] = 5
+
+
+def _overlap(first: Rectangle, second: Rectangle) -> float:
+ width = min(first.right, second.right) - max(first.left, second.left)
+ height = min(first.bottom, second.bottom) - max(first.top, second.top)
+ return min(width, height)
+
+
+class TestSinePoints:
+ def test_the_polyline_runs_from_the_start_to_the_handover(self) -> None:
+ sine = Mark.load().waves.sine
+ points = sine_points(sine, SAMPLES)
+
+ assert points[0] == sine.start
+ assert points[-1].x == pytest.approx(sine.end.x)
+ assert points[-1].y == pytest.approx(sine.end.y)
+
+ def test_every_segment_contributes_its_samples(self) -> None:
+ sine = Mark.load().waves.sine
+ assert len(sine_points(sine, SAMPLES)) == len(sine.curves) * SAMPLES + 1
+
+ def test_the_polyline_stays_within_the_curve_the_definition_draws(self) -> None:
+ """The wave swings between the extremes its control points reach, keeping it inside the frame."""
+ sine = Mark.load().waves.sine
+ controls = [sine.start] + [
+ point for curve in sine.curves for point in (curve.control_start, curve.control_end, curve.end)
+ ]
+ lowest = min(point.y for point in controls)
+ highest = max(point.y for point in controls)
+
+ for point in sine_points(sine, SAMPLES):
+ assert lowest <= point.y <= highest
+
+
+class TestSquareRectangles:
+ def test_one_rectangle_covers_each_segment(self) -> None:
+ square = Mark.load().waves.square
+ assert len(square_rectangles(square, width=4.0)) == len(square.points) - 1
+
+ def test_every_rectangle_reads_left_to_right_and_top_to_bottom(self) -> None:
+ square = Mark.load().waves.square
+ for rectangle in square_rectangles(square, width=4.0):
+ assert rectangle.left < rectangle.right
+ assert rectangle.top < rectangle.bottom
+
+ def test_a_segment_carries_the_stroke_width_across_its_run(self) -> None:
+ width = 4.0
+ square = Mark.load().waves.square
+ for (start, end), rectangle in zip(
+ itertools.pairwise(square.points),
+ square_rectangles(square, width=width),
+ ):
+ across = rectangle.bottom - rectangle.top if start.y == end.y else rectangle.right - rectangle.left
+ assert across == pytest.approx(width)
+
+ def test_consecutive_rectangles_meet_at_the_corner_they_turn_on(self) -> None:
+ """Overlapping rectangles fill the right-angle miter, so the stepped half draws as one stroke."""
+ square = Mark.load().waves.square
+ for first, second in itertools.pairwise(square_rectangles(square, width=4.0)):
+ assert _overlap(first, second) > 0.0
diff --git a/tests/unit/sampletones_assets/mark/test_raster.py b/tests/unit/sampletones_assets/mark/test_raster.py
new file mode 100644
index 00000000..5ae61ddf
--- /dev/null
+++ b/tests/unit/sampletones_assets/mark/test_raster.py
@@ -0,0 +1,47 @@
+from typing import Final, Tuple
+
+import pytest
+
+from sampletones_assets.mark.raster import MarkRaster
+from sampletones_assets.mark.specification import Mark
+from sampletones_shared.utils.color import parse_hex_color
+
+CORNER: Final[Tuple[int, int]] = (0, 0)
+ALPHA: Final[int] = 3
+CHANNELS: Final[int] = 3
+
+
+@pytest.fixture(name="mark", scope="module")
+def mark_fixture() -> Mark:
+ return Mark.load()
+
+
+class TestMarkRaster:
+ def test_the_image_covers_the_supersampled_grid(self, mark: Mark) -> None:
+ image = MarkRaster(mark).render()
+ edge = mark.frame.grid * mark.render.supersample
+ assert image.size == (edge, edge)
+
+ def test_the_image_corner_stays_clear_of_the_rounded_frame(self, mark: Mark) -> None:
+ image = MarkRaster(mark).render()
+ assert image.getpixel(CORNER)[ALPHA] == 0
+
+ def test_the_frame_centre_carries_the_background(self, mark: Mark) -> None:
+ """The frame reaches the top edge between its rounded corners, so the ground there is opaque."""
+ image = MarkRaster(mark).render()
+ centre = image.size[0] // 2
+ assert image.getpixel((centre, 1))[ALPHA] == 255
+
+ def test_the_smooth_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None:
+ image = MarkRaster(mark).render()
+ scale = mark.render.supersample
+ start = mark.waves.sine.start
+ pixel = image.getpixel((round(start.x * scale), round(start.y * scale)))
+ assert pixel[:CHANNELS] == parse_hex_color(mark.colors.sine)[:CHANNELS]
+
+ def test_the_stepped_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None:
+ image = MarkRaster(mark).render()
+ scale = mark.render.supersample
+ corner = mark.waves.square.points[1]
+ pixel = image.getpixel((round(corner.x * scale), round(corner.y * scale)))
+ assert pixel[:CHANNELS] == parse_hex_color(mark.colors.square)[:CHANNELS]
diff --git a/tests/unit/sampletones_assets/mark/test_specification.py b/tests/unit/sampletones_assets/mark/test_specification.py
new file mode 100644
index 00000000..a3f51d36
--- /dev/null
+++ b/tests/unit/sampletones_assets/mark/test_specification.py
@@ -0,0 +1,176 @@
+from dataclasses import dataclass
+from typing import Any, Dict, Final
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_assets.mark.specification import Mark
+from tests.suite.case import BaseRegularTestCase
+
+VALID_FRAME: Final[Dict[str, Any]] = {
+ "grid": 64,
+ "corner_radius": 14,
+ "rim": {"inset": 1, "width": 2, "opacity": 0.14},
+}
+
+VALID_COLORS: Final[Dict[str, Any]] = {
+ "background": {"top": "#3a3650", "bottom": "#211d30"},
+ "sine": "#64c8ff",
+ "square": "#ffc864",
+ "rim": "#cdb6ff",
+}
+
+VALID_SINE: Final[Dict[str, Any]] = {
+ "start": {"x": 8, "y": 32},
+ "curves": [
+ {
+ "control_start": {"x": 11, "y": 16},
+ "control_end": {"x": 15, "y": 16},
+ "end": {"x": 18, "y": 32},
+ },
+ ],
+}
+
+VALID_SQUARE: Final[Dict[str, Any]] = {
+ "points": [
+ {"x": 18, "y": 32},
+ {"x": 18, "y": 20},
+ {"x": 28, "y": 20},
+ ],
+}
+
+VALID_WAVES: Final[Dict[str, Any]] = {
+ "width": 4,
+ "sine": VALID_SINE,
+ "square": VALID_SQUARE,
+}
+
+VALID_RENDER: Final[Dict[str, Any]] = {
+ "supersample": 16,
+ "curve_samples": 96,
+ "raster_size": 256,
+ "windows_sizes": [256, 128, 64],
+}
+
+VALID_FIELDS: Final[Dict[str, Any]] = {
+ "frame": VALID_FRAME,
+ "colors": VALID_COLORS,
+ "waves": VALID_WAVES,
+ "render": VALID_RENDER,
+}
+
+
+class TestMark:
+ @dataclass(frozen=True, kw_only=True)
+ class InvalidFieldCase(BaseRegularTestCase):
+ field: str
+ value: Any
+
+ test_cases = (
+ InvalidFieldCase(
+ field="frame",
+ value={**VALID_FRAME, "grid": 0},
+ label="empty_grid",
+ ),
+ InvalidFieldCase(
+ field="frame",
+ value={**VALID_FRAME, "corner_radius": 33},
+ label="corner_radius_over_half_the_grid",
+ ),
+ InvalidFieldCase(
+ field="frame",
+ value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "inset": 14}},
+ label="rim_inset_outside_the_corner_radius",
+ ),
+ InvalidFieldCase(
+ field="frame",
+ value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "opacity": 1.5}},
+ label="rim_opacity_over_full",
+ ),
+ InvalidFieldCase(
+ field="colors",
+ value={**VALID_COLORS, "sine": "64c8ff"},
+ label="color_without_a_hash",
+ ),
+ InvalidFieldCase(
+ field="colors",
+ value={**VALID_COLORS, "sine": "#64c8"},
+ label="color_of_four_hex_digits",
+ ),
+ InvalidFieldCase(
+ field="waves",
+ value={**VALID_WAVES, "width": 0},
+ label="wave_without_width",
+ ),
+ InvalidFieldCase(
+ field="waves",
+ value={**VALID_WAVES, "sine": {**VALID_SINE, "curves": []}},
+ label="smooth_half_without_curves",
+ ),
+ InvalidFieldCase(
+ field="waves",
+ value={**VALID_WAVES, "square": {"points": [{"x": 18, "y": 32}]}},
+ label="stepped_half_without_a_segment",
+ ),
+ InvalidFieldCase(
+ field="waves",
+ value={
+ **VALID_WAVES,
+ "square": {"points": [{"x": 18, "y": 32}, {"x": 28, "y": 20}]},
+ },
+ label="stepped_segment_turning_on_both_axes",
+ ),
+ InvalidFieldCase(
+ field="waves",
+ value={
+ **VALID_WAVES,
+ "square": {"points": [{"x": 40, "y": 32}, {"x": 40, "y": 20}]},
+ },
+ label="halves_meeting_apart",
+ ),
+ InvalidFieldCase(
+ field="render",
+ value={**VALID_RENDER, "supersample": 0},
+ label="drawing_below_the_design_grid",
+ ),
+ InvalidFieldCase(
+ field="render",
+ value={**VALID_RENDER, "windows_sizes": []},
+ label="windows_icon_without_a_frame",
+ ),
+ InvalidFieldCase(
+ field="render",
+ value={**VALID_RENDER, "windows_sizes": [64, 128, 256]},
+ label="windows_sizes_in_ascending_order",
+ ),
+ InvalidFieldCase(
+ field="render",
+ value={**VALID_RENDER, "windows_sizes": [256, 256, 128]},
+ label="repeated_windows_size",
+ ),
+ )
+
+ def test_the_packaged_definition_loads(self) -> None:
+ mark = Mark.load()
+ assert isinstance(mark, Mark)
+
+ def test_the_sample_of_the_packaged_definition_leaves_as_it_entered(self) -> None:
+ """The mark draws one wave, so the stepped half carries on from where the smooth half arrives."""
+ mark = Mark.load()
+ assert mark.waves.square.points[0] == mark.waves.sine.end
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
+ def test_an_invalid_field_is_rejected(self, case: InvalidFieldCase) -> None:
+ fields = {**VALID_FIELDS, case.field: case.value}
+ with pytest.raises(ValidationError):
+ Mark.model_validate(fields)
+
+ @pytest.mark.parametrize("field", sorted(VALID_FIELDS))
+ def test_a_missing_field_is_rejected(self, field: str) -> None:
+ fields = {key: value for key, value in VALID_FIELDS.items() if key != field}
+ with pytest.raises(ValidationError):
+ Mark.model_validate(fields)
+
+ def test_an_unknown_field_is_rejected(self) -> None:
+ with pytest.raises(ValidationError):
+ Mark.model_validate({**VALID_FIELDS, "shadow": {"blur": 4}})
diff --git a/tests/unit/sampletones_assets/mark/test_suite.py b/tests/unit/sampletones_assets/mark/test_suite.py
new file mode 100644
index 00000000..b0a8f3e3
--- /dev/null
+++ b/tests/unit/sampletones_assets/mark/test_suite.py
@@ -0,0 +1,55 @@
+from pathlib import Path
+
+import pytest
+from PIL import Image
+
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.suite import write_icon_suite
+from sampletones_shared.paths.resources import (
+ ICON_UNIX_FILENAME,
+ ICON_VECTOR_FILENAME,
+ ICON_WIN_FILENAME,
+)
+
+RGBA_MODE = "RGBA"
+ICO_SIZES_KEY = "sizes"
+
+
+@pytest.fixture(name="mark", scope="module")
+def mark_fixture() -> Mark:
+ return Mark.load()
+
+
+class TestWriteIconSuite:
+ def test_the_suite_holds_every_file_the_application_ships(self, tmp_path: Path, mark: Mark) -> None:
+ paths = write_icon_suite(tmp_path, mark)
+ assert [path.name for path in paths] == [
+ ICON_VECTOR_FILENAME,
+ ICON_UNIX_FILENAME,
+ ICON_WIN_FILENAME,
+ ]
+ assert all(path.is_file() for path in paths)
+
+ def test_the_directory_is_created_where_it_is_missing(self, tmp_path: Path, mark: Mark) -> None:
+ directory = tmp_path / "icons"
+ write_icon_suite(directory, mark)
+ assert directory.is_dir()
+
+ def test_the_raster_is_the_size_the_definition_declares(self, tmp_path: Path, mark: Mark) -> None:
+ write_icon_suite(tmp_path, mark)
+ with Image.open(tmp_path / ICON_UNIX_FILENAME) as image:
+ assert image.size == (mark.render.raster_size, mark.render.raster_size)
+ assert image.mode == RGBA_MODE
+
+ def test_the_windows_icon_carries_every_declared_size(self, tmp_path: Path, mark: Mark) -> None:
+ write_icon_suite(tmp_path, mark)
+ with Image.open(tmp_path / ICON_WIN_FILENAME) as image:
+ carried = {width for width, _ in image.info[ICO_SIZES_KEY]}
+
+ assert carried == set(mark.render.windows_sizes)
+
+ def test_the_same_definition_writes_the_same_files(self, tmp_path: Path, mark: Mark) -> None:
+ """One definition produces one suite, so a rebuild leaves the shipped files as they were."""
+ first = write_icon_suite(tmp_path / "first", mark)
+ second = write_icon_suite(tmp_path / "second", mark)
+ assert [path.read_bytes() for path in first] == [path.read_bytes() for path in second]
diff --git a/tests/unit/sampletones_assets/mark/test_vector.py b/tests/unit/sampletones_assets/mark/test_vector.py
new file mode 100644
index 00000000..081cb682
--- /dev/null
+++ b/tests/unit/sampletones_assets/mark/test_vector.py
@@ -0,0 +1,47 @@
+from importlib.resources import files
+from pathlib import Path
+
+from sampletones_assets.mark.specification import Mark
+from sampletones_assets.mark.vector import render_vector
+from sampletones_shared.paths.resources import ICON_VECTOR_FILENAME
+
+PLACEHOLDER_PREFIX = "$"
+REPLACEMENT_COLOR = "#010203"
+
+
+class TestRenderVector:
+ def test_the_shipped_vector_is_what_the_definition_renders(self) -> None:
+ """The committed vector is the mark's design source, so it stays in step with the definition."""
+ shipped = Path(str(files("sampletones_assets.icons"))) / ICON_VECTOR_FILENAME
+ assert render_vector(Mark.load()) == shipped.read_text(encoding="utf-8")
+
+ def test_the_template_is_filled_throughout(self) -> None:
+ assert PLACEHOLDER_PREFIX not in render_vector(Mark.load())
+
+ def test_every_colour_reaches_the_document(self) -> None:
+ mark = Mark.load()
+ document = render_vector(mark)
+ colors = (
+ mark.colors.background.top,
+ mark.colors.background.bottom,
+ mark.colors.sine,
+ mark.colors.square,
+ mark.colors.rim,
+ )
+
+ for color in colors:
+ assert color in document
+
+ def test_the_document_follows_the_definition(self) -> None:
+ """A colour changed in the definition is the colour the vector is drawn with."""
+ mark = Mark.load()
+ recolored = mark.model_copy(update={"colors": mark.colors.model_copy(update={"sine": REPLACEMENT_COLOR})})
+ document = render_vector(recolored)
+
+ assert REPLACEMENT_COLOR in document
+ assert mark.colors.sine not in document
+
+ def test_the_wave_starts_where_the_definition_places_it(self) -> None:
+ mark = Mark.load()
+ start = mark.waves.sine.start
+ assert f'd="M{start.x:g} {start.y:g}' in render_vector(mark)
diff --git a/tests/unit/sampletones_core/audio/test_manager.py b/tests/unit/sampletones_core/audio/test_manager.py
index bd12cec8..27902d37 100644
--- a/tests/unit/sampletones_core/audio/test_manager.py
+++ b/tests/unit/sampletones_core/audio/test_manager.py
@@ -1,12 +1,16 @@
import threading
+from typing import Callable, Final, List
from unittest.mock import MagicMock, patch
import numpy as np
+import pytest
from sampletones_core.audio.manager import AudioDeviceManager
+from sampletones_shared.exceptions import PlaybackError
_LOW = 0
_HIGH = 1
+_RELEASE_TIMEOUT: Final[float] = 5.0
def _manager() -> AudioDeviceManager:
@@ -22,11 +26,42 @@ def _manager() -> AudioDeviceManager:
manager._resume_event = threading.Event()
manager._playing = False
manager._active_priority = 0
+ manager._stream_owners = {}
manager.on_acquire_output = None
manager.external_output_priority = None
return manager
+def _holding_manager(release: Callable[[], None]) -> AudioDeviceManager:
+ """A manager that handed out one output stream against ``release``."""
+ manager = _manager()
+ manager.stop = MagicMock()
+ manager._stream_owners = {MagicMock(): release}
+ return manager
+
+
+class _ThreadedOwner:
+ """A stream owner that hands its stream back from the thread that was writing to it.
+
+ Mirrors the song player: the release runs on the caller's thread while the hand-back comes
+ from the writer, so the two meet only while the manager holds no lock across a release.
+ """
+
+ def __init__(self, manager: AudioDeviceManager, stream: MagicMock) -> None:
+ self._manager = manager
+ self._stream = stream
+ self.handed_back = threading.Event()
+
+ def release(self) -> None:
+ writer = threading.Thread(target=self._hand_back, daemon=True)
+ writer.start()
+ writer.join(timeout=_RELEASE_TIMEOUT)
+
+ def _hand_back(self) -> None:
+ self._manager.close_output_stream(self._stream)
+ self.handed_back.set()
+
+
class TestSingleOutputExclusion:
"""The output device allows one open stream, so the two playback paths must release each other."""
@@ -34,7 +69,7 @@ def test_open_output_stream_stops_internal_playback(self) -> None:
manager = _manager()
manager.stop = MagicMock()
- manager.open_output_stream(sample_rate=48000, buffer_size=800)
+ manager.open_output_stream(sample_rate=48000, buffer_size=800, release=MagicMock())
manager.stop.assert_called_once()
manager._pyaudio.open.assert_called_once()
@@ -131,3 +166,68 @@ def test_preview_owned_by_nobody_is_not_owned_by_a_source(self) -> None:
manager._playing = True
assert manager.is_owned_by(object()) is False
+
+
+class TestBackendTeardown:
+ """The backend is torn down only once every handed-out stream has come back."""
+
+ def test_a_handed_out_stream_is_outstanding_until_it_comes_back(self) -> None:
+ manager = _manager()
+ manager.stop = MagicMock()
+ stream = manager.open_output_stream(sample_rate=48000, buffer_size=800, release=MagicMock())
+ assert stream in manager._stream_owners
+
+ manager.close_output_stream(stream)
+
+ assert manager._stream_owners == {}
+ stream.stop_stream.assert_called_once()
+ stream.close.assert_called_once()
+
+ def test_terminate_releases_a_handed_out_stream_first(self) -> None:
+ events: List[str] = []
+ manager = _manager()
+ manager.stop = MagicMock()
+ instance = manager._pyaudio
+ instance.terminate.side_effect = lambda: events.append("terminate")
+ stream = MagicMock()
+
+ def release() -> None:
+ events.append("release")
+ manager.close_output_stream(stream)
+
+ manager._stream_owners = {stream: release}
+ manager.terminate()
+
+ assert events == ["release", "terminate"]
+ assert manager._pyaudio is None
+
+ def test_terminate_keeps_the_backend_while_a_stream_outlives_its_release(self) -> None:
+ manager = _holding_manager(lambda: None)
+ instance = manager._pyaudio
+
+ manager.terminate()
+
+ instance.terminate.assert_not_called()
+ assert manager._pyaudio is instance
+
+ def test_reinitialize_refuses_while_a_stream_outlives_its_release(self) -> None:
+ manager = _holding_manager(lambda: None)
+ instance = manager._pyaudio
+
+ with pytest.raises(PlaybackError):
+ manager.reinitialize()
+
+ instance.terminate.assert_not_called()
+ assert manager._pyaudio is instance
+
+ def test_a_release_may_hand_its_stream_back_from_the_writing_thread(self) -> None:
+ manager = _manager()
+ manager.stop = MagicMock()
+ stream = MagicMock()
+ owner = _ThreadedOwner(manager, stream)
+ manager._stream_owners = {stream: owner.release}
+
+ manager.terminate()
+
+ assert owner.handed_back.is_set()
+ assert manager._pyaudio is None
diff --git a/tests/unit/sampletones_core/audio/test_processing.py b/tests/unit/sampletones_core/audio/test_processing.py
index aa773d76..e83b7799 100644
--- a/tests/unit/sampletones_core/audio/test_processing.py
+++ b/tests/unit/sampletones_core/audio/test_processing.py
@@ -45,7 +45,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[np.ndarray, Type[Exception]]
audio: Any
- test_cases = [
+ test_cases = (
TestCase(
label="within_range",
audio=np.array([0.5, -0.5, 0.0]),
@@ -106,7 +106,7 @@ class TestCase(BaseRegularTestCase):
audio={"audio": [1.0]},
expected=TypeError,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -127,7 +127,7 @@ class TestCase(BaseRegularTestCase):
audio: np.ndarray
expected: np.ndarray
- test_cases = [
+ test_cases = (
TestCase(
label="clips_above_and_below",
audio=np.array([1.5, -1.5, 0.5], dtype=np.float32),
@@ -143,7 +143,7 @@ class TestCase(BaseRegularTestCase):
audio=np.array([2.0, -3.0], dtype=np.float64),
expected=np.array([1.0, -1.0], dtype=np.float64),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -171,7 +171,7 @@ class TestCase(BaseRegularTestCase):
expected: np.ndarray
audio: Any
- test_cases = [
+ test_cases = (
TestCase(
label="already_mono",
audio=np.array([1.0, 2.0, 3.0]),
@@ -207,7 +207,7 @@ class TestCase(BaseRegularTestCase):
audio=np.array([[1.0], [2.0], [3.0]]),
expected=np.array([1.0, 2.0, 3.0]),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -249,7 +249,7 @@ class TestCase(BaseRegularTestCase):
data: Any
target_length: Any
- test_cases = [
+ test_cases = (
TestCase(
label="same_length",
data=np.array([1.0, 2.0, 3.0, 4.0]),
@@ -358,7 +358,7 @@ class TestCase(BaseRegularTestCase):
target_length=5,
expected=ValueError,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -386,7 +386,7 @@ class TestCase(BaseRegularTestCase):
data: Any
num_buckets: Any
- test_cases = [
+ test_cases = (
TestCase(
label="divisible_six_elements_three_buckets",
data=np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]),
@@ -452,8 +452,42 @@ class TestCase(BaseRegularTestCase):
data=np.array([1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0]),
num_buckets=7,
expected=(
- np.array([0.0, 0.0, 1.0, 1.0, 2.0, 2.0, 4.0, 4.0, 5.0, 5.0, 7.0, 7.0, 8.0, 8.0]),
- np.array([1.0, 1.0, 2.0, 2.0, 3.0, 4.0, 5.0, 5.0, 6.0, 7.0, 8.0, 8.0, 9.0, 10.0]),
+ np.array(
+ [
+ 0.0,
+ 0.0,
+ 1.0,
+ 1.0,
+ 2.0,
+ 2.0,
+ 4.0,
+ 4.0,
+ 5.0,
+ 5.0,
+ 7.0,
+ 7.0,
+ 8.0,
+ 8.0,
+ ]
+ ),
+ np.array(
+ [
+ 1.0,
+ 1.0,
+ 2.0,
+ 2.0,
+ 3.0,
+ 4.0,
+ 5.0,
+ 5.0,
+ 6.0,
+ 7.0,
+ 8.0,
+ 8.0,
+ 9.0,
+ 10.0,
+ ]
+ ),
),
),
TestCase(
@@ -543,7 +577,7 @@ class TestCase(BaseRegularTestCase):
num_buckets=5,
expected=ValueError,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -571,7 +605,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[np.ndarray, Type[Exception]]
audio: Any
- test_cases = [
+ test_cases = (
TestCase(
label="normalize_half_range",
audio=np.array([0.5, -0.5, 0.25]),
@@ -652,7 +686,7 @@ class TestCase(BaseRegularTestCase):
audio=np.array([[1, 2], [3, 4]]),
expected=ValueError,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -675,7 +709,7 @@ class TestCase(BaseRegularTestCase):
audio: Any
levels: Any
- test_cases = [
+ test_cases = (
TestCase(
label="three_levels",
audio=np.array([0.0, 0.6, 1.0, -0.6, -1.0]),
@@ -784,7 +818,7 @@ class TestCase(BaseRegularTestCase):
levels=3,
expected=ValueError,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -792,7 +826,12 @@ class TestCase(BaseRegularTestCase):
ids=lambda test_case: test_case.label,
)
def test_quantize(self, test_case: TestCase) -> None:
- if expect_error(quantize, test_case.expected, test_case.audio, levels=test_case.levels):
+ if expect_error(
+ quantize,
+ test_case.expected,
+ test_case.audio,
+ levels=test_case.levels,
+ ):
return
assert isinstance(test_case.expected, np.ndarray)
diff --git a/tests/unit/sampletones_core/audio/test_validation.py b/tests/unit/sampletones_core/audio/test_validation.py
index 14feb187..0cc1e439 100644
--- a/tests/unit/sampletones_core/audio/test_validation.py
+++ b/tests/unit/sampletones_core/audio/test_validation.py
@@ -21,7 +21,7 @@ class TestCase(BaseRegularTestCase):
audio: Any
allowed_dims: Tuple[int, ...] = (1,)
- test_cases = [
+ test_cases = (
TestCase(
label="valid_float64_array",
audio=np.array([1.0, 2.0, 3.0]),
@@ -95,7 +95,7 @@ class TestCase(BaseRegularTestCase):
expected=ValueError,
allowed_dims=(),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -111,7 +111,10 @@ def test_validate_audio_array(self, test_case: TestCase) -> None:
):
return
- validate_audio_array(test_case.audio, allowed_dims=test_case.allowed_dims)
+ validate_audio_array(
+ test_case.audio,
+ allowed_dims=test_case.allowed_dims,
+ )
class TestValidateSampleRate(BaseTestSuite):
@@ -120,7 +123,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[None, Type[Exception]]
sample_rate: Any
- test_cases = [
+ test_cases = (
TestCase(
label="valid_8000",
sample_rate=8000,
@@ -191,7 +194,7 @@ class TestCase(BaseRegularTestCase):
sample_rate=[44100],
expected=TypeError,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -199,7 +202,11 @@ class TestCase(BaseRegularTestCase):
ids=lambda test_case: test_case.label,
)
def test_validate_sample_rate(self, test_case: TestCase) -> None:
- if expect_error(validate_sample_rate, test_case.expected, test_case.sample_rate):
+ if expect_error(
+ validate_sample_rate,
+ test_case.expected,
+ test_case.sample_rate,
+ ):
return
validate_sample_rate(test_case.sample_rate)
@@ -211,7 +218,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[None, Type[Exception]]
buffer_size: Any
- test_cases = [
+ test_cases = (
TestCase(
label="valid_256",
buffer_size=256,
@@ -267,7 +274,7 @@ class TestCase(BaseRegularTestCase):
buffer_size=[1024],
expected=TypeError,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -275,7 +282,11 @@ class TestCase(BaseRegularTestCase):
ids=lambda test_case: test_case.label,
)
def test_validate_buffer_size(self, test_case: TestCase) -> None:
- if expect_error(validate_buffer_size, test_case.expected, test_case.buffer_size):
+ if expect_error(
+ validate_buffer_size,
+ test_case.expected,
+ test_case.buffer_size,
+ ):
return
validate_buffer_size(test_case.buffer_size)
diff --git a/tests/unit/sampletones_core/audio/writers/__init__.py b/tests/unit/sampletones_core/audio/writers/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_core/audio/writers/test_spec.py b/tests/unit/sampletones_core/audio/writers/test_spec.py
new file mode 100644
index 00000000..cdbc328f
--- /dev/null
+++ b/tests/unit/sampletones_core/audio/writers/test_spec.py
@@ -0,0 +1,107 @@
+from typing import Final
+
+import pytest
+from pydantic import ValidationError
+
+from sampletones_core.audio.writers import (
+ AUDIO_DEPTHS,
+ MP3_SAMPLE_RATES,
+ AudioDepth,
+ AudioFormat,
+ Mp3OutputSpec,
+ WaveOutputSpec,
+ capability_of,
+ default_mp3_bitrate,
+ mp3_bitrates,
+)
+from sampletones_core.constants.audio import SAMPLE_RATES
+from sampletones_shared.paths.extensions import EXT_FILE_MP3, EXT_FILE_WAVE
+from tests.suite.base import BaseTestSuite
+
+MPEG_1_RATE: Final[int] = 44100
+MPEG_2_RATE: Final[int] = 22050
+MPEG_2_5_RATE: Final[int] = 8000
+
+
+class TestWaveOutputSpec(BaseTestSuite):
+ @pytest.mark.parametrize("sample_rate", SAMPLE_RATES)
+ @pytest.mark.parametrize("depth", AUDIO_DEPTHS)
+ def test_every_rate_and_depth_is_accepted(self, sample_rate: int, depth: AudioDepth) -> None:
+ spec = WaveOutputSpec(sample_rate=sample_rate, depth=depth)
+
+ assert spec.audio_format is AudioFormat.WAVE
+ assert spec.sample_rate == sample_rate
+ assert spec.depth is depth
+
+ def test_the_extension_comes_from_the_capability(self) -> None:
+ assert WaveOutputSpec(sample_rate=MPEG_1_RATE).extension == EXT_FILE_WAVE
+
+ def test_a_rate_outside_the_offered_set_is_rejected(self) -> None:
+ with pytest.raises(ValidationError, match="does not encode at 44101 Hz"):
+ WaveOutputSpec(sample_rate=44101)
+
+ def test_a_specification_is_frozen(self) -> None:
+ spec = WaveOutputSpec(sample_rate=MPEG_1_RATE)
+
+ with pytest.raises(ValidationError):
+ spec.sample_rate = MPEG_2_RATE
+
+
+class TestMp3OutputSpec(BaseTestSuite):
+ @pytest.mark.parametrize("sample_rate", MP3_SAMPLE_RATES)
+ def test_every_bitrate_on_the_ladder_is_accepted(self, sample_rate: int) -> None:
+ for bitrate in mp3_bitrates(sample_rate):
+ spec = Mp3OutputSpec(sample_rate=sample_rate, bitrate=bitrate)
+
+ assert spec.audio_format is AudioFormat.MP3
+ assert spec.bitrate == bitrate
+
+ def test_the_extension_comes_from_the_capability(self) -> None:
+ assert Mp3OutputSpec.at(MPEG_1_RATE).extension == EXT_FILE_MP3
+
+ @pytest.mark.parametrize("sample_rate", (96000, 192000))
+ def test_a_rate_the_encoder_rejects_is_rejected_here(self, sample_rate: int) -> None:
+ """MPEG audio defines its sample rates, and 96 kHz is not among them."""
+ with pytest.raises(ValidationError, match="does not encode at"):
+ Mp3OutputSpec(sample_rate=sample_rate, bitrate=192)
+
+ def test_a_bitrate_above_the_rate_s_ladder_is_rejected(self) -> None:
+ with pytest.raises(ValidationError, match="does not encode at 320 kbps"):
+ Mp3OutputSpec(sample_rate=MPEG_2_RATE, bitrate=320)
+
+ def test_a_bitrate_off_the_ladder_is_rejected(self) -> None:
+ with pytest.raises(ValidationError, match="does not encode at 200 kbps"):
+ Mp3OutputSpec(sample_rate=MPEG_1_RATE, bitrate=200)
+
+ @pytest.mark.parametrize(
+ ("sample_rate", "expected"),
+ (
+ (MPEG_1_RATE, 192),
+ (48000, 192),
+ (MPEG_2_RATE, 160),
+ (16000, 160),
+ (MPEG_2_5_RATE, 64),
+ ),
+ )
+ def test_the_default_bitrate_is_the_best_the_rate_reaches(self, sample_rate: int, expected: int) -> None:
+ assert default_mp3_bitrate(sample_rate) == expected
+ assert Mp3OutputSpec.at(sample_rate).bitrate == expected
+
+
+class TestFormatCapabilities(BaseTestSuite):
+ def test_wave_stores_samples_and_mp3_does_not(self) -> None:
+ assert capability_of(AudioFormat.WAVE).stores_samples
+ assert not capability_of(AudioFormat.MP3).stores_samples
+
+ def test_the_mp3_rates_are_the_rates_a_ladder_is_declared_for(self) -> None:
+ assert capability_of(AudioFormat.MP3).sample_rates == MP3_SAMPLE_RATES
+ assert all(mp3_bitrates(sample_rate) for sample_rate in MP3_SAMPLE_RATES)
+
+ def test_the_ladders_run_from_highest_to_lowest(self) -> None:
+ for sample_rate in MP3_SAMPLE_RATES:
+ bitrates = mp3_bitrates(sample_rate)
+
+ assert list(bitrates) == sorted(bitrates, reverse=True)
+
+ def test_the_wave_rates_are_the_rates_the_application_offers(self) -> None:
+ assert capability_of(AudioFormat.WAVE).sample_rates == tuple(SAMPLE_RATES)
diff --git a/tests/unit/sampletones_core/audio/writers/test_writer.py b/tests/unit/sampletones_core/audio/writers/test_writer.py
new file mode 100644
index 00000000..1a3b58ee
--- /dev/null
+++ b/tests/unit/sampletones_core/audio/writers/test_writer.py
@@ -0,0 +1,174 @@
+from pathlib import Path
+from typing import Dict, Final, Tuple
+
+import numpy as np
+import pytest
+import soundfile
+
+from sampletones_core.audio.writers import (
+ AUDIO_DEPTHS,
+ MP3_SAMPLE_RATES,
+ AudioDepth,
+ AudioFormat,
+ AudioOutputSpec,
+ Mp3OutputSpec,
+ WaveOutputSpec,
+ available_audio_formats,
+ available_depths,
+ open_audio_writer,
+)
+from sampletones_shared.exceptions import AudioWriteError
+from tests.suite.base import BaseTestSuite
+
+SAMPLE_RATE: Final[int] = 44100
+SECONDS: Final[float] = 1.0
+CHUNK: Final[int] = 367
+TONE_FREQUENCY: Final[float] = 440.0
+DEPTH_TOLERANCES: Final[Dict[AudioDepth, float]] = {
+ AudioDepth.PCM_U8: 1.0 / 128,
+ AudioDepth.PCM_16: 1.0 / 32768,
+ AudioDepth.PCM_24: 1.0 / 8388608,
+ AudioDepth.PCM_32: 1.0 / 8388608,
+ AudioDepth.FLOAT_32: 1e-6,
+}
+
+
+def _tone(sample_rate: int, seconds: float = SECONDS) -> np.ndarray:
+ samples = int(sample_rate * seconds)
+ return (0.5 * np.sin(2 * np.pi * TONE_FREQUENCY * np.arange(samples) / sample_rate)).astype(np.float32)
+
+
+def _chunks(audio: np.ndarray, size: int = CHUNK) -> Tuple[np.ndarray, ...]:
+ return tuple(audio[offset : offset + size] for offset in range(0, len(audio), size))
+
+
+def _write(path: Path, spec: AudioOutputSpec, audio: np.ndarray) -> None:
+ with open_audio_writer(path, spec) as writer:
+ for chunk in _chunks(audio):
+ writer.write(chunk)
+
+
+class TestTheEncoderIsProbed(BaseTestSuite):
+ """What the registry declares is offered only where the installed encoder also writes it."""
+
+ def test_wave_is_always_available(self) -> None:
+ assert AudioFormat.WAVE in available_audio_formats()
+
+ def test_the_offered_depths_are_the_declared_ones_the_encoder_writes(self) -> None:
+ assert set(available_depths(AudioFormat.WAVE)) <= set(AUDIO_DEPTHS)
+
+ def test_a_format_that_sets_its_own_depth_offers_none(self) -> None:
+ assert available_depths(AudioFormat.MP3) == ()
+
+
+class TestWaveRoundTrip(BaseTestSuite):
+ """Audio written a chunk at a time reads back whole, at the depth it was asked for."""
+
+ @pytest.mark.parametrize("depth", AUDIO_DEPTHS)
+ def test_a_render_reads_back_at_its_depth(self, tmp_path: Path, depth: AudioDepth) -> None:
+ audio = _tone(SAMPLE_RATE)
+ path = tmp_path / f"render{WaveOutputSpec(sample_rate=SAMPLE_RATE).extension}"
+
+ _write(path, WaveOutputSpec(sample_rate=SAMPLE_RATE, depth=depth), audio)
+ restored, sample_rate = soundfile.read(path, dtype="float32")
+
+ assert sample_rate == SAMPLE_RATE
+ assert len(restored) == len(audio)
+ assert float(np.abs(restored - audio).max()) <= DEPTH_TOLERANCES[depth]
+
+ @pytest.mark.parametrize("sample_rate", (8000, 22050, 48000, 96000, 192000))
+ def test_every_offered_rate_is_written(self, tmp_path: Path, sample_rate: int) -> None:
+ audio = _tone(sample_rate, seconds=0.1)
+ path = tmp_path / "render.wav"
+
+ _write(path, WaveOutputSpec(sample_rate=sample_rate), audio)
+ info = soundfile.info(path)
+
+ assert info.samplerate == sample_rate
+ assert info.frames == len(audio)
+
+ def test_chunks_of_differing_lengths_are_written_whole(self, tmp_path: Path) -> None:
+ """A row varies in length where the tick clock spreads a fraction, so chunks do too."""
+ path = tmp_path / "render.wav"
+ lengths = (367, 368, 367, 1, 4096, 12)
+ audio = _tone(SAMPLE_RATE)
+
+ offset = 0
+ with open_audio_writer(path, WaveOutputSpec(sample_rate=SAMPLE_RATE)) as writer:
+ for length in lengths:
+ writer.write(audio[offset : offset + length])
+ offset += length
+
+ assert soundfile.info(path).frames == sum(lengths)
+
+ def test_a_finished_file_stands_on_its_own(self, tmp_path: Path) -> None:
+ path = tmp_path / "render.wav"
+
+ _write(path, WaveOutputSpec(sample_rate=SAMPLE_RATE), _tone(SAMPLE_RATE))
+
+ assert path.exists()
+ assert path.stat().st_size > 0
+
+
+class TestMp3RoundTrip(BaseTestSuite):
+ @pytest.mark.parametrize("sample_rate", MP3_SAMPLE_RATES)
+ def test_a_render_reads_back_at_its_rate(self, tmp_path: Path, sample_rate: int) -> None:
+ audio = _tone(sample_rate)
+ path = tmp_path / "render.mp3"
+
+ _write(path, Mp3OutputSpec.at(sample_rate), audio)
+ info = soundfile.info(path)
+
+ assert info.samplerate == sample_rate
+ assert info.frames == len(audio)
+
+ @pytest.mark.parametrize("bitrate", (320, 192, 128, 64))
+ def test_the_encoded_rate_follows_the_chosen_bitrate(self, tmp_path: Path, bitrate: int) -> None:
+ """The ladder is what makes a bitrate choice mean something, so it is measured."""
+ seconds = 8.0
+ audio = _tone(SAMPLE_RATE, seconds=seconds)
+ path = tmp_path / "render.mp3"
+
+ _write(path, Mp3OutputSpec(sample_rate=SAMPLE_RATE, bitrate=bitrate), audio)
+ measured = path.stat().st_size * 8 / seconds / 1000
+
+ assert abs(measured - bitrate) < 0.05 * bitrate
+
+ def test_a_higher_bitrate_makes_a_larger_file(self, tmp_path: Path) -> None:
+ audio = _tone(SAMPLE_RATE, seconds=4.0)
+ sizes = []
+ for bitrate in (64, 128, 320):
+ path = tmp_path / f"render_{bitrate}.mp3"
+ _write(path, Mp3OutputSpec(sample_rate=SAMPLE_RATE, bitrate=bitrate), audio)
+ sizes.append(path.stat().st_size)
+
+ assert sizes == sorted(sizes)
+
+
+class TestTheWriterOwnsItsFile(BaseTestSuite):
+ def test_writing_outside_the_block_is_refused(self, tmp_path: Path) -> None:
+ writer = open_audio_writer(tmp_path / "render.wav", WaveOutputSpec(sample_rate=SAMPLE_RATE))
+
+ with pytest.raises(AudioWriteError, match="write within the writer's context"):
+ writer.write(_tone(SAMPLE_RATE, seconds=0.01))
+
+ def test_writing_after_the_block_is_refused(self, tmp_path: Path) -> None:
+ path = tmp_path / "render.wav"
+ with open_audio_writer(path, WaveOutputSpec(sample_rate=SAMPLE_RATE)) as writer:
+ writer.write(_tone(SAMPLE_RATE, seconds=0.01))
+
+ with pytest.raises(AudioWriteError, match="write within the writer's context"):
+ writer.write(_tone(SAMPLE_RATE, seconds=0.01))
+
+ def test_a_render_interrupted_partway_leaves_a_readable_file(self, tmp_path: Path) -> None:
+ """A cancel leaves the file finalized, so the caller decides whether to keep it."""
+ path = tmp_path / "render.wav"
+ audio = _tone(SAMPLE_RATE)
+ written = 0
+
+ with open_audio_writer(path, WaveOutputSpec(sample_rate=SAMPLE_RATE)) as writer:
+ for chunk in _chunks(audio)[:10]:
+ writer.write(chunk)
+ written += len(chunk)
+
+ assert soundfile.info(path).frames == written
diff --git a/tests/unit/sampletones_core/calibration/config/test_corpus.py b/tests/unit/sampletones_core/calibration/config/test_corpus.py
index 53fa85e9..b09e60af 100644
--- a/tests/unit/sampletones_core/calibration/config/test_corpus.py
+++ b/tests/unit/sampletones_core/calibration/config/test_corpus.py
@@ -1,10 +1,11 @@
from dataclasses import dataclass
-from typing import Any, Dict, Final, Tuple
+from typing import Any, Dict, Final
import pytest
from pydantic import ValidationError
from sampletones_core.calibration.config.corpus import CorpusConfig
+from tests.suite.case import BaseRegularTestCase
VALID_TRANSIENT: Final[Dict[str, Any]] = {
"snare_decay_seconds": 0.15,
@@ -27,57 +28,90 @@
}
-@dataclass(frozen=True)
-class InvalidFieldCase:
- name: str
- field: str
- value: Any
-
-
-INVALID_FIELD_CASES: Final[Tuple[InvalidFieldCase, ...]] = (
- InvalidFieldCase(name="negative_seed", field="seed", value=-1),
- InvalidFieldCase(name="zero_item_seconds", field="item_seconds", value=0.0),
- InvalidFieldCase(name="zero_amplitude", field="amplitude", value=0.0),
- InvalidFieldCase(name="amplitude_above_full_scale", field="amplitude", value=1.5),
- InvalidFieldCase(name="zero_reference_frequency", field="reference_frequency", value=0.0),
- InvalidFieldCase(name="empty_tone_frequencies", field="tone", value={"frequencies": ()}),
- InvalidFieldCase(name="nonpositive_tone_frequency", field="tone", value={"frequencies": (440.0, 0.0)}),
- InvalidFieldCase(
- name="empty_duty_cycles",
- field="timbre",
- value={"duty_cycles": (), "frequency": 220.0},
- ),
- InvalidFieldCase(
- name="duty_cycle_at_full_width",
- field="timbre",
- value={"duty_cycles": (1.0,), "frequency": 220.0},
- ),
- InvalidFieldCase(
- name="zero_timbre_frequency",
- field="timbre",
- value={"duty_cycles": (0.5,), "frequency": 0.0},
- ),
- InvalidFieldCase(name="zero_white_noise_level", field="noise", value={"white_level": 0.0}),
- InvalidFieldCase(name="empty_mix_noise_levels", field="mix", value={"noise_levels": ()}),
- InvalidFieldCase(
- name="zero_snare_decay",
- field="transient",
- value={**VALID_TRANSIENT, "snare_decay_seconds": 0.0},
- ),
- InvalidFieldCase(
- name="zero_attack",
- field="transient",
- value={**VALID_TRANSIENT, "attack_seconds": 0.0},
- ),
-)
+class TestCorpusConfig:
+ @dataclass(frozen=True, kw_only=True)
+ class InvalidFieldCase(BaseRegularTestCase):
+ field: str
+ value: Any
+ test_cases = (
+ InvalidFieldCase(
+ field="seed",
+ value=-1,
+ label="negative_seed",
+ ),
+ InvalidFieldCase(
+ field="item_seconds",
+ value=0.0,
+ label="zero_item_seconds",
+ ),
+ InvalidFieldCase(
+ field="amplitude",
+ value=0.0,
+ label="zero_amplitude",
+ ),
+ InvalidFieldCase(
+ field="amplitude",
+ value=1.5,
+ label="amplitude_above_full_scale",
+ ),
+ InvalidFieldCase(
+ field="reference_frequency",
+ value=0.0,
+ label="zero_reference_frequency",
+ ),
+ InvalidFieldCase(
+ field="tone",
+ value={"frequencies": ()},
+ label="empty_tone_frequencies",
+ ),
+ InvalidFieldCase(
+ field="tone",
+ value={"frequencies": (440.0, 0.0)},
+ label="nonpositive_tone_frequency",
+ ),
+ InvalidFieldCase(
+ field="timbre",
+ value={"duty_cycles": (), "frequency": 220.0},
+ label="empty_duty_cycles",
+ ),
+ InvalidFieldCase(
+ field="timbre",
+ value={"duty_cycles": (1.0,), "frequency": 220.0},
+ label="duty_cycle_at_full_width",
+ ),
+ InvalidFieldCase(
+ field="timbre",
+ value={"duty_cycles": (0.5,), "frequency": 0.0},
+ label="zero_timbre_frequency",
+ ),
+ InvalidFieldCase(
+ field="noise",
+ value={"white_level": 0.0},
+ label="zero_white_noise_level",
+ ),
+ InvalidFieldCase(
+ field="mix",
+ value={"noise_levels": ()},
+ label="empty_mix_noise_levels",
+ ),
+ InvalidFieldCase(
+ field="transient",
+ value={**VALID_TRANSIENT, "snare_decay_seconds": 0.0},
+ label="zero_snare_decay",
+ ),
+ InvalidFieldCase(
+ field="transient",
+ value={**VALID_TRANSIENT, "attack_seconds": 0.0},
+ label="zero_attack",
+ ),
+ )
-class TestCorpusConfig:
def test_packaged_configuration_loads(self) -> None:
config = CorpusConfig.load()
assert isinstance(config, CorpusConfig)
- @pytest.mark.parametrize("case", INVALID_FIELD_CASES, ids=lambda case: case.name)
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
def test_out_of_bounds_field_is_rejected(self, case: InvalidFieldCase) -> None:
fields = {**VALID_FIELDS, case.field: case.value}
with pytest.raises(ValidationError):
diff --git a/tests/unit/sampletones_core/calibration/config/test_referee.py b/tests/unit/sampletones_core/calibration/config/test_referee.py
index a9a8d441..21120e5a 100644
--- a/tests/unit/sampletones_core/calibration/config/test_referee.py
+++ b/tests/unit/sampletones_core/calibration/config/test_referee.py
@@ -1,10 +1,11 @@
from dataclasses import dataclass
-from typing import Any, Dict, Final, Tuple
+from typing import Any, Dict, Final
import pytest
from pydantic import ValidationError
from sampletones_core.calibration.config.referee import RefereeConfig
+from tests.suite.case import BaseRegularTestCase
VALID_FIELDS: Final[Dict[str, Any]] = {
"window_sizes": (512, 2048),
@@ -16,30 +17,55 @@
}
-@dataclass(frozen=True)
-class InvalidFieldCase:
- name: str
- field: str
- value: Any
-
-
-INVALID_FIELD_CASES: Final[Tuple[InvalidFieldCase, ...]] = (
- InvalidFieldCase(name="empty_window_sizes", field="window_sizes", value=()),
- InvalidFieldCase(name="nonpositive_window_size", field="window_sizes", value=(512, 0)),
- InvalidFieldCase(name="zero_hop_divisor", field="hop_divisor", value=0),
- InvalidFieldCase(name="zero_band_count", field="band_count", value=0),
- InvalidFieldCase(name="zero_low_frequency", field="low_frequency", value=0.0),
- InvalidFieldCase(name="zero_energy_floor", field="energy_floor", value=0.0),
- InvalidFieldCase(name="zero_audibility_range", field="audibility_range_decibels", value=0.0),
-)
+class TestRefereeConfig:
+ @dataclass(frozen=True, kw_only=True)
+ class InvalidFieldCase(BaseRegularTestCase):
+ field: str
+ value: Any
+ test_cases = (
+ InvalidFieldCase(
+ field="window_sizes",
+ value=(),
+ label="empty_window_sizes",
+ ),
+ InvalidFieldCase(
+ field="window_sizes",
+ value=(512, 0),
+ label="nonpositive_window_size",
+ ),
+ InvalidFieldCase(
+ field="hop_divisor",
+ value=0,
+ label="zero_hop_divisor",
+ ),
+ InvalidFieldCase(
+ field="band_count",
+ value=0,
+ label="zero_band_count",
+ ),
+ InvalidFieldCase(
+ field="low_frequency",
+ value=0.0,
+ label="zero_low_frequency",
+ ),
+ InvalidFieldCase(
+ field="energy_floor",
+ value=0.0,
+ label="zero_energy_floor",
+ ),
+ InvalidFieldCase(
+ field="audibility_range_decibels",
+ value=0.0,
+ label="zero_audibility_range",
+ ),
+ )
-class TestRefereeConfig:
def test_packaged_configuration_loads(self) -> None:
config = RefereeConfig.load()
assert isinstance(config, RefereeConfig)
- @pytest.mark.parametrize("case", INVALID_FIELD_CASES, ids=lambda case: case.name)
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
def test_out_of_bounds_field_is_rejected(self, case: InvalidFieldCase) -> None:
fields = {**VALID_FIELDS, case.field: case.value}
with pytest.raises(ValidationError):
diff --git a/tests/unit/sampletones_core/calibration/test_runner.py b/tests/unit/sampletones_core/calibration/test_runner.py
new file mode 100644
index 00000000..2eb447e3
--- /dev/null
+++ b/tests/unit/sampletones_core/calibration/test_runner.py
@@ -0,0 +1,34 @@
+import warnings
+from typing import Final, List
+
+import pytest
+
+from sampletones_core.calibration.runner import build_variants
+from sampletones_core.configs import Config
+from sampletones_core.constants.enums import SpectrumMethod
+
+METHODS: Final[List[SpectrumMethod]] = [SpectrumMethod.FFT, SpectrumMethod.CQT]
+EXPONENTS: Final[List[float]] = [1.0]
+
+
+class TestBuildVariants:
+ def test_sweeps_every_combination(self) -> None:
+ variants = build_variants(Config(), METHODS, [1.0, 0.5], [0.25])
+ assert [variant.label for variant in variants] == [
+ "fft-pe1-tw0.25",
+ "fft-pe0.5-tw0.25",
+ "cqt-pe1-tw0.25",
+ "cqt-pe0.5-tw0.25",
+ ]
+
+ @pytest.mark.parametrize("method", METHODS, ids=lambda method: method.value)
+ def test_variant_holds_the_spectrum_method_member(self, method: SpectrumMethod) -> None:
+ (variant,) = build_variants(Config(), [method], EXPONENTS, [])
+ assert variant.config.library.spectrum_method is method
+
+ def test_variant_configuration_serializes_cleanly(self) -> None:
+ variants = build_variants(Config(), METHODS, EXPONENTS, [])
+ with warnings.catch_warnings():
+ warnings.simplefilter("error", UserWarning)
+ for variant in variants:
+ variant.config.model_dump()
diff --git a/tests/unit/sampletones_core/configs/test_display.py b/tests/unit/sampletones_core/configs/test_display.py
index 29b35d48..6d3d0617 100644
--- a/tests/unit/sampletones_core/configs/test_display.py
+++ b/tests/unit/sampletones_core/configs/test_display.py
@@ -1,11 +1,21 @@
+from typing import List, Tuple
+
import pytest
from sampletones_core.configs.display import (
DISPLAY_HASH_LENGTH,
+ DISPLAY_SEPARATOR,
+ disambiguated_display_name,
+ format_frequencies,
+ format_generators,
format_nes_frequency,
format_sample_rate,
+ format_transformation,
+ format_transformation_gamma,
short_hash,
+ unique_display_names,
)
+from sampletones_core.constants.enums import GeneratorName, SpectrumMethod
class TestFormatSampleRate:
@@ -28,8 +38,84 @@ def test_appends_hertz_unit(self) -> None:
assert format_nes_frequency(30) == "30 Hz"
+class TestFormatTransformationGamma:
+ def test_marks_the_gamma(self) -> None:
+ assert format_transformation_gamma(0) == "γ0"
+
+
+class TestFormatGenerators:
+ def test_reads_the_generators_in_the_order_they_are_given(self) -> None:
+ assert (
+ format_generators(
+ [
+ GeneratorName.PULSE1,
+ GeneratorName.TRIANGLE,
+ GeneratorName.NOISE,
+ ],
+ )
+ == "Pulse 1, Triangle, Noise"
+ )
+
+ def test_a_lone_generator_reads_as_its_own_name(self) -> None:
+ assert format_generators([GeneratorName.PULSE2]) == "Pulse 2"
+
+ def test_no_generator_reads_as_nothing(self) -> None:
+ assert format_generators([]) == ""
+
+
+class TestFormatFrequencies:
+ def test_reads_audio_rate_then_frame_rate(self) -> None:
+ assert format_frequencies(44100, 30) == "44.1 kHz·30 Hz"
+
+
+class TestFormatTransformation:
+ def test_reads_method_then_gamma(self) -> None:
+ assert format_transformation(SpectrumMethod.FFT, 2) == "FFT·γ2"
+
+
class TestShortHash:
def test_truncates_to_display_length(self) -> None:
full = "6edf7c948606917a78b45d153c7ca7e0"
assert short_hash(full) == full[:DISPLAY_HASH_LENGTH]
assert len(short_hash(full)) == DISPLAY_HASH_LENGTH
+
+
+class TestUniqueDisplayNames:
+ @pytest.mark.parametrize(
+ "entries, expected",
+ [
+ ([], []),
+ ([("PTN", "aaaa1111")], ["PTN"]),
+ ([("PTN", "aaaa1111"), ("PN", "bbbb2222")], ["PTN", "PN"]),
+ (
+ [("PTN", "aaaa1111"), ("PTN", "bbbb2222")],
+ [
+ disambiguated_display_name("PTN", "aaaa1111"),
+ disambiguated_display_name("PTN", "bbbb2222"),
+ ],
+ ),
+ (
+ [("PTN", "aaaa1111"), ("PN", "bbbb2222"), ("PTN", "cccc3333")],
+ [
+ disambiguated_display_name("PTN", "aaaa1111"),
+ "PN",
+ disambiguated_display_name("PTN", "cccc3333"),
+ ],
+ ),
+ ],
+ )
+ def test_marks_only_the_shared_names(
+ self,
+ entries: List[Tuple[str, str]],
+ expected: List[str],
+ ) -> None:
+ assert unique_display_names(entries) == tuple(expected)
+
+ def test_keeps_the_given_order(self) -> None:
+ entries = [("second", "aaaa1111"), ("first", "bbbb2222"), ("second", "cccc3333")]
+ names = unique_display_names(entries)
+ assert [name.split(DISPLAY_SEPARATOR)[0] for name in names] == ["second", "first", "second"]
+
+ def test_names_stay_distinct(self) -> None:
+ entries = [("PTN", "aaaa1111"), ("PTN", "bbbb2222"), ("PTN", "cccc3333")]
+ assert len(set(unique_display_names(entries))) == len(entries)
diff --git a/tests/unit/sampletones_core/exporters/implementation/test_noise.py b/tests/unit/sampletones_core/exporters/implementation/test_noise.py
index a2852ea5..eb1c426e 100644
--- a/tests/unit/sampletones_core/exporters/implementation/test_noise.py
+++ b/tests/unit/sampletones_core/exporters/implementation/test_noise.py
@@ -70,7 +70,11 @@ def test_empty_instruction_list(self) -> None:
class TestNoiseExporterDeriveInitialPitch:
def test_reference_is_the_first_sounding_period(self) -> None:
- instructions = [_off(), _noise(period=7, volume=10), _noise(period=2, volume=10)]
+ instructions = [
+ _off(),
+ _noise(period=7, volume=10),
+ _noise(period=2, volume=10),
+ ]
assert NoiseExporter.derive_initial_pitch(instructions) == 7
def test_empty_instruction_list_references_period_zero(self) -> None:
@@ -85,7 +89,9 @@ def test_feature_map_contains_all_required_keys(self) -> None:
assert FeatureKey.ARPEGGIO in feature_map
assert FeatureKey.DUTY_CYCLE in feature_map
- def test_arpeggio_is_relative_to_the_given_reference_modulo_num_periods(self) -> None:
+ def test_arpeggio_is_relative_to_the_given_reference_modulo_num_periods(
+ self,
+ ) -> None:
instructions = [
_noise(period=2, volume=10),
_noise(period=5, volume=8),
diff --git a/tests/unit/sampletones_core/exporters/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py
index 9b818b23..4b07b395 100644
--- a/tests/unit/sampletones_core/exporters/test_exporter.py
+++ b/tests/unit/sampletones_core/exporters/test_exporter.py
@@ -1,10 +1,11 @@
from dataclasses import dataclass
-from typing import Any, Callable, Final, List, Sequence
+from typing import Any, Callable, Dict, Final, List, Optional, Sequence, Tuple
import numpy as np
import pytest
from sampletones_core.constants.enums import FeatureKey
+from sampletones_core.constants.general import MAX_VOLUME
from sampletones_core.exporters import (
ExporterTypeUnion,
Features,
@@ -40,6 +41,37 @@ def _read_period(instruction: Any) -> int:
return period
+def _read_volume(instruction: Any) -> int:
+ volume: int = instruction.volume
+ return volume
+
+
+def _read_duty_cycle(instruction: Any) -> int:
+ duty_cycle: int = instruction.duty_cycle
+ return duty_cycle
+
+
+def _read_short(instruction: Any) -> int:
+ return int(instruction.short)
+
+
+def _features(
+ *,
+ initial_pitch: int,
+ volume: Tuple[int, ...],
+ arpeggio: Tuple[int, ...],
+ duty_cycle: Optional[Tuple[int, ...]],
+) -> Features:
+ return Features(
+ initial_pitch=initial_pitch,
+ volume=np.array(volume, dtype=np.int8),
+ arpeggio=np.array(arpeggio, dtype=np.int8),
+ pitch=None,
+ hi_pitch=None,
+ duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=np.int8),
+ )
+
+
def _pulse_line(pitch: int) -> List[PulseInstruction]:
return [PulseInstruction(on=True, pitch=pitch, volume=PULSE_VOLUME, duty_cycle=0) for _ in range(SOUNDING_FRAMES)]
@@ -71,7 +103,7 @@ class TestCase(BaseRegularTestCase):
arpeggio: np.ndarray
edited_pitches: List[int]
- test_cases = [
+ test_cases = (
TestCase(
label="pulse",
exporter=PulseExporter,
@@ -99,11 +131,15 @@ class TestCase(BaseRegularTestCase):
edited_pitches=[REFERENCE_PERIOD + PERIOD_STEP] + [REFERENCE_PERIOD] * SOUNDING_FRAMES,
expected=REFERENCE_PERIOD,
),
- ]
+ )
@staticmethod
def _export(test_case: TestCase, instructions: Sequence[InstructionUnion]) -> Features:
- return test_case.exporter().to_features(list(instructions), test_case.expected)
+ return test_case.exporter().to_features(
+ list(instructions),
+ test_case.expected,
+ (),
+ )
@classmethod
def _edited(cls, test_case: TestCase) -> List[InstructionUnion]:
@@ -197,7 +233,7 @@ class TestCase(BaseRegularTestCase):
features: Features
read_pitch: Callable[[Any], int]
- test_cases = [
+ test_cases = (
TestCase(
label="pulse",
exporter=PulseExporter,
@@ -240,7 +276,7 @@ class TestCase(BaseRegularTestCase):
read_pitch=_read_period,
expected=REFERENCE_PERIOD,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -263,3 +299,251 @@ def test_audible_frames_stay_audible(self, test_case: TestCase) -> None:
assert instructions[0].on is True
assert instructions[-1].on is False
+
+
+class TestChannelHeldDimensions(BaseTestSuite):
+ """A dimension left to the channel sounds at the value a channel holds from a song's start.
+
+ An instruction states every dimension of its frame, so rebuilding a sequence from envelopes
+ that leave one out still has to state it. The value stated is the channel's own — full volume,
+ no arpeggio offset, the first timbre — which is what the instrument sounds like played alone.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ exporter: ExporterTypeUnion
+ features: Features
+ read_value: Callable[[Any], int]
+ expected: int
+
+ test_cases = (
+ TestCase(
+ label="pulse_volume",
+ exporter=PulseExporter,
+ features=_features(
+ initial_pitch=REFERENCE_PITCH,
+ volume=(),
+ arpeggio=(0, 0, 0),
+ duty_cycle=(1,),
+ ),
+ read_value=_read_volume,
+ expected=MAX_VOLUME,
+ ),
+ TestCase(
+ label="pulse_duty_cycle",
+ exporter=PulseExporter,
+ features=_features(
+ initial_pitch=REFERENCE_PITCH,
+ volume=(PULSE_VOLUME, PULSE_VOLUME, 0),
+ arpeggio=(0,),
+ duty_cycle=(),
+ ),
+ read_value=_read_duty_cycle,
+ expected=0,
+ ),
+ TestCase(
+ label="noise_volume",
+ exporter=NoiseExporter,
+ features=_features(
+ initial_pitch=REFERENCE_PERIOD,
+ volume=(),
+ arpeggio=(0, 0, 0),
+ duty_cycle=(0,),
+ ),
+ read_value=_read_volume,
+ expected=MAX_VOLUME,
+ ),
+ TestCase(
+ label="noise_mode",
+ exporter=NoiseExporter,
+ features=_features(
+ initial_pitch=REFERENCE_PERIOD,
+ volume=(NOISE_VOLUME, NOISE_VOLUME, 0),
+ arpeggio=(0,),
+ duty_cycle=(),
+ ),
+ read_value=_read_short,
+ expected=0,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_every_frame_states_the_value_the_channel_holds(self, test_case: TestCase) -> None:
+ instructions = test_case.exporter.from_features(test_case.features)
+
+ assert [test_case.read_value(instruction) for instruction in instructions] == [test_case.expected] * len(
+ instructions
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_written_dimensions_set_the_frame_count(self, test_case: TestCase) -> None:
+ instructions = test_case.exporter.from_features(test_case.features)
+
+ assert len(instructions) == test_case.features.frame_count
+
+
+class TestHeldDimensionRoundTrip:
+ """A dimension the channel governs comes back empty, telling it apart from one holding a zero."""
+
+ def test_a_held_dimension_comes_back_empty(self) -> None:
+ features = _features(
+ initial_pitch=REFERENCE_PITCH,
+ volume=(PULSE_VOLUME, PULSE_VOLUME, 0),
+ arpeggio=(),
+ duty_cycle=(1,),
+ )
+ instructions = PulseExporter.from_features(features)
+
+ exported = PulseExporter().to_features(
+ instructions,
+ REFERENCE_PITCH,
+ features.held_features,
+ )
+
+ assert exported.arpeggio.size == 0
+ assert exported.held_features == (FeatureKey.ARPEGGIO,)
+
+ def test_a_written_dimension_comes_back_with_its_items(self) -> None:
+ features = _features(
+ initial_pitch=REFERENCE_PITCH,
+ volume=(PULSE_VOLUME, PULSE_VOLUME, 0),
+ arpeggio=(),
+ duty_cycle=(1,),
+ )
+ instructions = PulseExporter.from_features(features)
+
+ exported = PulseExporter().to_features(
+ instructions,
+ REFERENCE_PITCH,
+ features.held_features,
+ )
+
+ assert exported.volume.tolist() == [PULSE_VOLUME, PULSE_VOLUME, 0]
+ assert exported.duty_cycle is not None
+ assert exported.duty_cycle.tolist() == [1]
+
+ def test_an_instrument_holding_every_dimension_describes_no_frame(self) -> None:
+ features = _features(
+ initial_pitch=REFERENCE_PITCH,
+ volume=(),
+ arpeggio=(),
+ duty_cycle=(),
+ )
+
+ assert PulseExporter.from_features(features) == []
+
+
+class TestSingleFrameReading(BaseTestSuite):
+ """One frame reads into envelope values and back, which is what a player works a tick in.
+
+ A song plays a sample frame by frame and fills in the dimensions its instrument leaves to
+ the channel, so the two directions `to_features` and `from_features` run over a whole
+ sequence are needed over a single frame as well.
+ """
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ exporter: ExporterTypeUnion
+ instruction: InstructionUnion
+ silent: InstructionUnion
+ reference: int
+ expected: Dict[FeatureKey, int]
+
+ test_cases = (
+ TestCase(
+ label="pulse",
+ exporter=PulseExporter,
+ instruction=PulseInstruction(
+ on=True,
+ pitch=REFERENCE_PITCH + OCTAVE,
+ volume=PULSE_VOLUME,
+ duty_cycle=1,
+ ),
+ silent=PulseInstruction.null_instruction(),
+ reference=REFERENCE_PITCH,
+ expected={
+ FeatureKey.VOLUME: PULSE_VOLUME,
+ FeatureKey.ARPEGGIO: OCTAVE,
+ FeatureKey.DUTY_CYCLE: 1,
+ },
+ ),
+ TestCase(
+ label="triangle",
+ exporter=TriangleExporter,
+ instruction=TriangleInstruction(on=True, pitch=REFERENCE_PITCH - OCTAVE),
+ silent=TriangleInstruction.null_instruction(),
+ reference=REFERENCE_PITCH,
+ expected={
+ FeatureKey.VOLUME: MAX_VOLUME,
+ FeatureKey.ARPEGGIO: -OCTAVE,
+ },
+ ),
+ TestCase(
+ label="noise",
+ exporter=NoiseExporter,
+ instruction=NoiseInstruction(
+ on=True,
+ period=REFERENCE_PERIOD + PERIOD_STEP,
+ volume=NOISE_VOLUME,
+ short=True,
+ ),
+ silent=NoiseInstruction.null_instruction(),
+ reference=REFERENCE_PERIOD,
+ expected={
+ FeatureKey.VOLUME: NOISE_VOLUME,
+ FeatureKey.ARPEGGIO: PERIOD_STEP,
+ FeatureKey.DUTY_CYCLE: 1,
+ },
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_a_sounding_frame_states_every_dimension(self, test_case: TestCase) -> None:
+ values = test_case.exporter.feature_values(test_case.instruction, test_case.reference)
+
+ assert values == test_case.expected
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_a_silent_frame_states_its_level_alone(self, test_case: TestCase) -> None:
+ """The rest is the channel's, which is how a sequence holds its pitch across a rest."""
+ values = test_case.exporter.feature_values(test_case.silent, test_case.reference)
+
+ assert values == {FeatureKey.VOLUME: 0}
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_values_a_frame_states_sound_it_back(self, test_case: TestCase) -> None:
+ values = test_case.exporter.feature_values(test_case.instruction, test_case.reference)
+
+ assert test_case.exporter.instruction_from_values(values, test_case.reference) == test_case.instruction
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_a_dimension_the_channel_reads_nothing_from_is_passed_over(self, test_case: TestCase) -> None:
+ """One set of channel values serves every channel, so each takes the dimensions it reads."""
+ values = dict(test_case.expected)
+ values[FeatureKey.HI_PITCH] = 3
+
+ assert test_case.exporter.instruction_from_values(values, test_case.reference) == test_case.instruction
diff --git a/tests/unit/sampletones_core/exporters/test_feature.py b/tests/unit/sampletones_core/exporters/test_feature.py
index 192f830e..3c4372c3 100644
--- a/tests/unit/sampletones_core/exporters/test_feature.py
+++ b/tests/unit/sampletones_core/exporters/test_feature.py
@@ -2,6 +2,7 @@
import numpy as np
+from sampletones_core.constants.enums import FeatureKey
from sampletones_core.exporters import Features
@@ -26,3 +27,33 @@ def test_absent_dimensions_leave_the_count_to_the_others(self) -> None:
def test_empty_envelopes_count_no_frames(self) -> None:
assert build_features(0).frame_count == 0
+
+
+class TestHeldFeatures:
+ """The dimensions an instrument leaves to the channel, read off the envelopes."""
+
+ def test_an_instrument_writing_every_dimension_leaves_none(self) -> None:
+ assert build_features(8, duty_cycle_frames=8).held_features == ()
+
+ def test_an_empty_envelope_marks_a_dimension_the_channel_governs(self) -> None:
+ features = build_features(8, duty_cycle_frames=8)
+ features[FeatureKey.ARPEGGIO] = np.array([], dtype=np.int8)
+ assert features.held_features == (FeatureKey.ARPEGGIO,)
+
+ def test_a_dimension_the_channel_lacks_stays_out_of_the_listing(self) -> None:
+ """The triangle channel offers no duty cycle, which is a different absence."""
+ assert build_features(8).held_features == ()
+
+ def test_leaving_a_dimension_to_the_channel_empties_its_envelope(self) -> None:
+ features = build_features(8, duty_cycle_frames=8)
+ features.leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE))
+ assert features.volume.size == 0
+ assert features.duty_cycle is not None and features.duty_cycle.size == 0
+ assert features.held_features == (FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE)
+
+ def test_leaving_a_dimension_the_channel_lacks_keeps_it_absent(self) -> None:
+ """A record naming a duty cycle on the triangle channel leaves the channel's shape intact."""
+ features = build_features(8)
+ features.leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE))
+ assert features.duty_cycle is None
+ assert features.held_features == (FeatureKey.VOLUME,)
diff --git a/tests/unit/sampletones_core/exporters/test_lengths.py b/tests/unit/sampletones_core/exporters/test_lengths.py
index b3c4bb0c..785563b0 100644
--- a/tests/unit/sampletones_core/exporters/test_lengths.py
+++ b/tests/unit/sampletones_core/exporters/test_lengths.py
@@ -3,7 +3,7 @@
import pytest
-from sampletones_core.exporters.lengths import equalize_lengths
+from sampletones_core.exporters.lengths import equalize_lengths, limit_lengths
VOLUME: Final[str] = "volume"
ARPEGGIO: Final[str] = "arpeggio"
@@ -44,6 +44,34 @@ def test_all_dimensions_empty_stay_empty(self) -> None:
assert all(items == () for items in equalized.values())
+class TestLimitLengths:
+ def test_every_dimension_keeps_its_own_length(self) -> None:
+ limited = limit_lengths({VOLUME: (15, 12, 9, 0), ARPEGGIO: (0, 2, 4)}, limit=ITEM_LIMIT)
+ assert limited[VOLUME] == (15, 12, 9, 0)
+ assert limited[ARPEGGIO] == (0, 2, 4)
+
+ def test_empty_dimensions_stay_empty(self) -> None:
+ limited = limit_lengths({VOLUME: (15, 12, 0), ARPEGGIO: ()}, limit=ITEM_LIMIT)
+ assert limited[ARPEGGIO] == ()
+
+ def test_an_over_long_envelope_keeps_its_opening_items(self) -> None:
+ limited = limit_lengths(volume_and_arpeggio(ITEM_LIMIT + 48), limit=ITEM_LIMIT)
+ assert limited[VOLUME] == items_of(ITEM_LIMIT)
+ assert len(limited[ARPEGGIO]) == ITEM_LIMIT
+
+ def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None:
+ with caplog.at_level(logging.WARNING):
+ limit_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), limit=ITEM_LIMIT)
+
+ assert str(ITEM_LIMIT) in caplog.text
+
+ def test_an_envelope_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None:
+ with caplog.at_level(logging.WARNING):
+ limit_lengths(volume_and_arpeggio(ITEM_LIMIT), limit=ITEM_LIMIT)
+
+ assert caplog.text == ""
+
+
class TestItemLimit:
@pytest.mark.parametrize("loop", [False, True], ids=["one_shot", "loop"])
def test_an_over_long_envelope_keeps_its_opening_items(self, loop: bool) -> None:
diff --git a/tests/unit/sampletones_core/exporters/test_naming.py b/tests/unit/sampletones_core/exporters/test_naming.py
index 001e7151..a3ffb93a 100644
--- a/tests/unit/sampletones_core/exporters/test_naming.py
+++ b/tests/unit/sampletones_core/exporters/test_naming.py
@@ -1,31 +1,50 @@
from dataclasses import dataclass
-from typing import Final, List
+from typing import Final
import pytest
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.exporters.naming import instrument_slice_name
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
BASE_NAME: Final[str] = "Kick"
-@dataclass(frozen=True)
-class NameCase:
- generator: GeneratorName
- expected: str
-
-
-NAME_CASES: Final[List[NameCase]] = [
- NameCase(generator=GeneratorName.PULSE1, expected="Kick (pulse1)"),
- NameCase(generator=GeneratorName.PULSE2, expected="Kick (pulse2)"),
- NameCase(generator=GeneratorName.TRIANGLE, expected="Kick (triangle)"),
- NameCase(generator=GeneratorName.NOISE, expected="Kick (noise)"),
-]
-
-
-class TestInstrumentSliceName:
- @pytest.mark.parametrize("case", NAME_CASES, ids=lambda case: case.generator.value)
- def test_the_generator_follows_the_base_name_in_parentheses(self, case: NameCase) -> None:
+class TestInstrumentSliceName(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class NameCase(BaseRegularTestCase):
+ generator: GeneratorName
+ expected: str
+
+ test_cases = (
+ NameCase(
+ generator=GeneratorName.PULSE1,
+ expected="Kick (pulse1)",
+ label=GeneratorName.PULSE1.value,
+ ),
+ NameCase(
+ generator=GeneratorName.PULSE2,
+ expected="Kick (pulse2)",
+ label=GeneratorName.PULSE2.value,
+ ),
+ NameCase(
+ generator=GeneratorName.TRIANGLE,
+ expected="Kick (triangle)",
+ label=GeneratorName.TRIANGLE.value,
+ ),
+ NameCase(
+ generator=GeneratorName.NOISE,
+ expected="Kick (noise)",
+ label=GeneratorName.NOISE.value,
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
+ def test_the_generator_follows_the_base_name_in_parentheses(
+ self,
+ case: NameCase,
+ ) -> None:
assert instrument_slice_name(BASE_NAME, case.generator) == case.expected
def test_every_generator_gets_a_distinct_name(self) -> None:
diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py
new file mode 100644
index 00000000..856bb3bd
--- /dev/null
+++ b/tests/unit/sampletones_core/exporters/test_slices.py
@@ -0,0 +1,72 @@
+from typing import List, Sequence
+
+import numpy as np
+
+from sampletones_core.constants.enums import FeatureKey, GeneratorName
+from sampletones_core.exporters.slices import iterate_sample_slices
+from sampletones_core.project.instruments.sample import Sample
+from sampletones_core.project.project import Project
+from sampletones_core.project.settings import ProjectSettings
+from sampletones_core.structures import IdentifiedCollection
+from tests.suite.sequencer import sample_reconstruction
+
+
+def _project(samples: Sequence[Sample]) -> Project:
+ collection: IdentifiedCollection[Sample] = IdentifiedCollection()
+ for sample in samples:
+ collection.append(sample)
+
+ project = Project.create(title="Slices", author="Tester", settings=ProjectSettings())
+ project.samples = collection
+ return project
+
+
+def _sample(name: str, generators: Sequence[GeneratorName]) -> Sample:
+ return Sample(name=name, reconstruction=sample_reconstruction(list(generators)))
+
+
+class TestSampleSlices:
+ """The walk numbers the instruments a module writes, so it visits the channels that play.
+
+ A sample carries every channel whatever it sounds, and one standing by is written nowhere,
+ so it takes no place in the instrument table and shifts no index behind it.
+ """
+
+ def test_a_sample_contributes_one_slice_per_playing_channel(self) -> None:
+ project = _project([_sample("lead", [GeneratorName.PULSE1, GeneratorName.NOISE])])
+
+ slices = list(iterate_sample_slices(project))
+
+ assert [sample_slice.generator for sample_slice in slices] == [
+ GeneratorName.PULSE1,
+ GeneratorName.NOISE,
+ ]
+
+ def test_a_channel_standing_by_takes_no_place_in_the_table(self) -> None:
+ sample = _sample("lead", [GeneratorName.PULSE1, GeneratorName.PULSE2])
+ sample.reconstruction.update_generator_data(
+ GeneratorName.PULSE1,
+ [],
+ np.zeros(0, dtype=np.float32),
+ sample.reconstruction.initial_pitches[GeneratorName.PULSE1],
+ (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE),
+ )
+ project = _project([sample])
+
+ slices = list(iterate_sample_slices(project))
+
+ assert [(sample_slice.index, sample_slice.generator) for sample_slice in slices] == [
+ (0, GeneratorName.PULSE2),
+ ]
+
+ def test_slices_are_numbered_across_the_samples_in_order(self) -> None:
+ project = _project(
+ [
+ _sample("lead", [GeneratorName.PULSE1]),
+ _sample("pad", [GeneratorName.TRIANGLE, GeneratorName.NOISE]),
+ ]
+ )
+
+ indices: List[int] = [sample_slice.index for sample_slice in iterate_sample_slices(project)]
+
+ assert indices == [0, 1, 2]
diff --git a/tests/unit/sampletones_core/features/test_spec.py b/tests/unit/sampletones_core/features/test_spec.py
index a1fb2061..012d2734 100644
--- a/tests/unit/sampletones_core/features/test_spec.py
+++ b/tests/unit/sampletones_core/features/test_spec.py
@@ -1,4 +1,8 @@
-from sampletones_core.constants.enums import FeatureKey, GeneratorName, LibraryGeneratorName
+from sampletones_core.constants.enums import (
+ FeatureKey,
+ GeneratorName,
+ LibraryGeneratorName,
+)
from sampletones_core.exporters.implementation.noise import NoiseExporter
from sampletones_core.exporters.implementation.pulse import PulseExporter
from sampletones_core.exporters.implementation.triangle import TriangleExporter
@@ -9,7 +13,10 @@
supported_features,
supports,
)
-from sampletones_core.formats.famitracker.specification.sequences import FEATURE_KEY_TO_SEQUENCE_KIND, SequenceKind
+from sampletones_core.formats.famitracker.specification.sequences import (
+ FEATURE_KEY_TO_SEQUENCE_KIND,
+ SequenceKind,
+)
def test_supported_features_follow_dimension_order() -> None:
diff --git a/tests/unit/sampletones_core/fft/cqt/test_geometry.py b/tests/unit/sampletones_core/fft/cqt/test_geometry.py
index 4b75ca2e..96ad8c86 100644
--- a/tests/unit/sampletones_core/fft/cqt/test_geometry.py
+++ b/tests/unit/sampletones_core/fft/cqt/test_geometry.py
@@ -25,11 +25,11 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"bpo_{self.bins_per_octave}"
- test_cases = [
+ test_cases = (
TestCase(bins_per_octave=1, expected=1.0),
TestCase(bins_per_octave=12, expected=16.817154),
TestCase(bins_per_octave=24, expected=34.127088),
- ]
+ )
@pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
def test_quality_factor(self, test_case: TestCase) -> None:
@@ -50,7 +50,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"rate_{self.sample_rate}"
- test_cases = [
+ test_cases = (
TestCase(
sample_rate=11025,
frequencies=[55.0, 110.0, 220.0, 440.0],
@@ -61,7 +61,7 @@ def label(self) -> str:
frequencies=[55.0, 110.0, 220.0, 440.0],
expected=[6743.0, 3372.0, 1686.0, 843.0],
),
- ]
+ )
@pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
def test_calculate_wavelet_lengths(self, test_case: TestCase) -> None:
@@ -87,16 +87,21 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"rate_{self.sample_rate}_len_{self.signal_length}"
- test_cases = [
+ test_cases = (
TestCase(sample_rate=11025, signal_length=3395, expected=0),
TestCase(sample_rate=11025, signal_length=848, expected=25),
- ]
+ )
@pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
def test_unresolvable_count(self, test_case: TestCase) -> None:
n_bins = calculate_n_bins(test_case.sample_rate, self.CUTOFF, bins_per_octave=12)
frequencies = calculate_cqt_frequencies(n_bins, self.CUTOFF, bins_per_octave=12)
- mask = resolvable_bins(frequencies, test_case.sample_rate, test_case.signal_length, bins_per_octave=12)
+ mask = resolvable_bins(
+ frequencies,
+ test_case.sample_rate,
+ test_case.signal_length,
+ bins_per_octave=12,
+ )
assert int((~mask).sum()) == test_case.expected
def test_unresolvable_bins_are_the_lowest(self) -> None:
diff --git a/tests/unit/sampletones_core/fft/test_spectrum_scaling.py b/tests/unit/sampletones_core/fft/test_spectrum_scaling.py
index a7bcafb4..5a8a5e6c 100644
--- a/tests/unit/sampletones_core/fft/test_spectrum_scaling.py
+++ b/tests/unit/sampletones_core/fft/test_spectrum_scaling.py
@@ -34,80 +34,58 @@
)
-def probe(method: SpectrumMethod, nes_frequency: int = NES_FREQUENCY) -> SpectrumProbe:
- return SpectrumProbe(sample_rate=SAMPLE_RATE, nes_frequency=nes_frequency, method=method)
-
-
-@dataclass(frozen=True, kw_only=True)
-class FlatnessCase(BaseTestCase):
- label: str
- method: SpectrumMethod
- frequencies: Tuple[float, ...]
- tolerance_ratio: float
-
-
-FLATNESS_CASES: Final[Tuple[FlatnessCase, ...]] = (
- FlatnessCase(
- label="fft",
- method=SpectrumMethod.FFT,
- frequencies=(110.0, 440.0, 1760.0, 7040.0),
- tolerance_ratio=1.35,
- ),
- FlatnessCase(
- label="logfft",
- method=SpectrumMethod.LOG_SPACED_FFT,
- frequencies=(110.0, 440.0, 1760.0, 7040.0),
- tolerance_ratio=1.35,
- ),
- FlatnessCase(
- label="cqt",
- method=SpectrumMethod.CQT,
- frequencies=(110.0, 440.0, 1760.0, 7040.0),
- tolerance_ratio=1.2,
- ),
-)
+def probe(
+ method: SpectrumMethod,
+ nes_frequency: int = NES_FREQUENCY,
+) -> SpectrumProbe:
+ return SpectrumProbe(
+ sample_rate=SAMPLE_RATE,
+ nes_frequency=nes_frequency,
+ method=method,
+ )
-@dataclass(frozen=True, kw_only=True)
-class NoiseScalingCase(BaseTestCase):
- label: str
- method: SpectrumMethod
- lower_frequency: float
- upper_frequency: float
- expected_ratio_range: Tuple[float, float]
-
-
-NOISE_SCALING_CASES: Final[Tuple[NoiseScalingCase, ...]] = (
- NoiseScalingCase(
- label="fft-flat-per-bin",
- method=SpectrumMethod.FFT,
- lower_frequency=440.0,
- upper_frequency=7040.0,
- expected_ratio_range=(0.25, 4.0),
- ),
- NoiseScalingCase(
- label="logfft-proportional-to-bandwidth",
- method=SpectrumMethod.LOG_SPACED_FFT,
- lower_frequency=440.0,
- upper_frequency=7040.0,
- expected_ratio_range=(8.0, 32.0),
- ),
- NoiseScalingCase(
- label="cqt-proportional-to-bandwidth",
- method=SpectrumMethod.CQT,
- lower_frequency=440.0,
- upper_frequency=7040.0,
- expected_ratio_range=(8.0, 32.0),
- ),
-)
+class TestToneResponseFlatness:
+ @dataclass(frozen=True, kw_only=True)
+ class FlatnessCase(BaseTestCase):
+ label: str
+ method: SpectrumMethod
+ frequencies: Tuple[float, ...]
+ tolerance_ratio: float
+ test_cases = (
+ FlatnessCase(
+ label="fft",
+ method=SpectrumMethod.FFT,
+ frequencies=(110.0, 440.0, 1760.0, 7040.0),
+ tolerance_ratio=1.35,
+ ),
+ FlatnessCase(
+ label="logfft",
+ method=SpectrumMethod.LOG_SPACED_FFT,
+ frequencies=(110.0, 440.0, 1760.0, 7040.0),
+ tolerance_ratio=1.35,
+ ),
+ FlatnessCase(
+ label="cqt",
+ method=SpectrumMethod.CQT,
+ frequencies=(110.0, 440.0, 1760.0, 7040.0),
+ tolerance_ratio=1.2,
+ ),
+ )
-class TestToneResponseFlatness:
- @pytest.mark.parametrize("case", FLATNESS_CASES, ids=lambda case: case.label)
- def test_tone_band_energy_is_flat_across_frequency(self, case: FlatnessCase) -> None:
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
+ def test_tone_band_energy_is_flat_across_frequency(
+ self,
+ case: FlatnessCase,
+ ) -> None:
spectrum_probe = probe(case.method)
responses = [
- band_energy(spectrum_probe.tone_spectrum(frequency), frequency, radius=BAND_RADIUS)
+ band_energy(
+ spectrum_probe.tone_spectrum(frequency),
+ frequency,
+ radius=BAND_RADIUS,
+ )
for frequency in case.frequencies
]
assert max(responses) / min(responses) < case.tolerance_ratio
@@ -119,14 +97,57 @@ def test_low_tones_stay_compact_on_the_resolution_floored_log_axis(self) -> None
energy the same tone produces higher up the axis.
"""
spectrum_probe = probe(SpectrumMethod.LOG_SPACED_FFT)
- low = band_energy(spectrum_probe.tone_spectrum(110.0), 110.0, radius=BAND_RADIUS)
- reference = band_energy(spectrum_probe.tone_spectrum(440.0), 440.0, radius=BAND_RADIUS)
+ low = band_energy(
+ spectrum_probe.tone_spectrum(110.0),
+ 110.0,
+ radius=BAND_RADIUS,
+ )
+ reference = band_energy(
+ spectrum_probe.tone_spectrum(440.0),
+ 440.0,
+ radius=BAND_RADIUS,
+ )
assert 0.75 < low / reference < 1.35
class TestNoiseScaling:
- @pytest.mark.parametrize("case", NOISE_SCALING_CASES, ids=lambda case: case.label)
- def test_noise_bin_values_scale_with_the_bin_bandwidth(self, case: NoiseScalingCase) -> None:
+ @dataclass(frozen=True, kw_only=True)
+ class NoiseScalingCase(BaseTestCase):
+ label: str
+ method: SpectrumMethod
+ lower_frequency: float
+ upper_frequency: float
+ expected_ratio_range: Tuple[float, float]
+
+ test_cases = (
+ NoiseScalingCase(
+ label="fft-flat-per-bin",
+ method=SpectrumMethod.FFT,
+ lower_frequency=440.0,
+ upper_frequency=7040.0,
+ expected_ratio_range=(0.25, 4.0),
+ ),
+ NoiseScalingCase(
+ label="logfft-proportional-to-bandwidth",
+ method=SpectrumMethod.LOG_SPACED_FFT,
+ lower_frequency=440.0,
+ upper_frequency=7040.0,
+ expected_ratio_range=(8.0, 32.0),
+ ),
+ NoiseScalingCase(
+ label="cqt-proportional-to-bandwidth",
+ method=SpectrumMethod.CQT,
+ lower_frequency=440.0,
+ upper_frequency=7040.0,
+ expected_ratio_range=(8.0, 32.0),
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
+ def test_noise_bin_values_scale_with_the_bin_bandwidth(
+ self,
+ case: NoiseScalingCase,
+ ) -> None:
"""
White noise reads flat per bin on the linear axis and proportionally to the
bin bandwidth on the logarithmic axes, where each bin integrates the noise
@@ -149,10 +170,18 @@ def test_weight_shares_match_across_spectrum_methods(self) -> None:
fastest (the K-weighting shelf knee around 2 kHz).
"""
shares_per_method = []
- for method in (SpectrumMethod.FFT, SpectrumMethod.LOG_SPACED_FFT, SpectrumMethod.CQT):
+ for method in (
+ SpectrumMethod.FFT,
+ SpectrumMethod.LOG_SPACED_FFT,
+ SpectrumMethod.CQT,
+ ):
edges = np.asarray(probe(method).tone_spectrum(440.0).edges)
shares = np.asarray(
- octave_weight_shares(edges, perceptual_exponent=PERCEPTUAL_EXPONENT, bands=OCTAVE_BANDS)
+ octave_weight_shares(
+ edges,
+ perceptual_exponent=PERCEPTUAL_EXPONENT,
+ bands=OCTAVE_BANDS,
+ )
)
shares_per_method.append(shares / shares.sum())
@@ -164,7 +193,11 @@ def test_weight_shares_are_stable_across_window_sizes(self) -> None:
for nes_frequency in (15, 30):
edges = np.asarray(probe(SpectrumMethod.FFT, nes_frequency).tone_spectrum(440.0).edges)
shares = np.asarray(
- octave_weight_shares(edges, perceptual_exponent=PERCEPTUAL_EXPONENT, bands=OCTAVE_BANDS)
+ octave_weight_shares(
+ edges,
+ perceptual_exponent=PERCEPTUAL_EXPONENT,
+ bands=OCTAVE_BANDS,
+ )
)
shares_per_window.append(shares / shares.sum())
@@ -179,7 +212,11 @@ def test_cqt_tone_response_is_frame_length_invariant(self) -> None:
across NES frequencies.
"""
responses = [
- band_energy(probe(SpectrumMethod.CQT, nes_frequency).tone_spectrum(440.0), 440.0, radius=BAND_RADIUS)
+ band_energy(
+ probe(SpectrumMethod.CQT, nes_frequency).tone_spectrum(440.0),
+ 440.0,
+ radius=BAND_RADIUS,
+ )
for nes_frequency in (30, 60, 300)
]
assert max(responses) / min(responses) < 1.2
@@ -190,7 +227,11 @@ def test_fft_tone_response_is_frame_length_invariant(self) -> None:
same tone energy at every NES frequency, matching the constant-Q behavior.
"""
responses = [
- band_energy(probe(SpectrumMethod.FFT, nes_frequency).tone_spectrum(440.0), 440.0, radius=BAND_RADIUS)
+ band_energy(
+ probe(SpectrumMethod.FFT, nes_frequency).tone_spectrum(440.0),
+ 440.0,
+ radius=BAND_RADIUS,
+ )
for nes_frequency in (30, 60, 300)
]
assert max(responses) / min(responses) < 1.2
@@ -215,7 +256,10 @@ def test_fft_bin_centered_tone_reports_half_of_the_squared_amplitude(self) -> No
frequency = 30 * bin_width
spectrum = spectrum_probe.tone_spectrum(frequency)
expected = PROBE_TONE_AMPLITUDE**2 / 2.0
- assert bin_value_at(spectrum, frequency) == pytest.approx(expected, rel=0.05)
+ assert bin_value_at(spectrum, frequency) == pytest.approx(
+ expected,
+ rel=0.05,
+ )
def test_cqt_bin_centered_tone_reports_half_of_the_squared_amplitude(self) -> None:
"""
@@ -223,9 +267,20 @@ def test_cqt_bin_centered_tone_reports_half_of_the_squared_amplitude(self) -> No
as ``A ** 2 / 2`` — the tone's mean-square power — matching the linear-FFT
convention.
"""
- n_bins = calculate_n_bins(SAMPLE_RATE, CQT_CUTOFF_FREQUENCY, BINS_PER_OCTAVE)
- frequencies = calculate_cqt_frequencies(n_bins, CQT_CUTOFF_FREQUENCY, BINS_PER_OCTAVE)
+ n_bins = calculate_n_bins(
+ SAMPLE_RATE,
+ CQT_CUTOFF_FREQUENCY,
+ BINS_PER_OCTAVE,
+ )
+ frequencies = calculate_cqt_frequencies(
+ n_bins,
+ CQT_CUTOFF_FREQUENCY,
+ BINS_PER_OCTAVE,
+ )
frequency = float(frequencies[int(np.argmin(np.abs(frequencies - 440.0)))])
spectrum = probe(SpectrumMethod.CQT).tone_spectrum(frequency)
expected = PROBE_TONE_AMPLITUDE**2 / 2.0
- assert bin_value_at(spectrum, frequency) == pytest.approx(expected, rel=0.15)
+ assert bin_value_at(spectrum, frequency) == pytest.approx(
+ expected,
+ rel=0.15,
+ )
diff --git a/tests/unit/sampletones_core/fft/test_transformer.py b/tests/unit/sampletones_core/fft/test_transformer.py
index 05a389c2..8958abe4 100644
--- a/tests/unit/sampletones_core/fft/test_transformer.py
+++ b/tests/unit/sampletones_core/fft/test_transformer.py
@@ -19,9 +19,7 @@
)
from sampletones_shared.utils.transformations.functions import power, power_inverse
from sampletones_shared.utils.transformations.morpher import LogMorpher
-from sampletones_shared.utils.transformations.transformation import (
- Transformation,
-)
+from sampletones_shared.utils.transformations.transformation import Transformation
from tests.suite.arrays import assert_array_equal
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
@@ -36,7 +34,10 @@ def transformer_identity() -> FFTTransformer:
@pytest.fixture
def transformer_square() -> FFTTransformer:
- transformation = Transformation(partial(power, a=0.5), partial(power_inverse, a=0.5))
+ transformation = Transformation(
+ partial(power, a=0.5),
+ partial(power_inverse, a=0.5),
+ )
return FFTTransformer(transformation=transformation, sample_rate=44100)
@@ -56,7 +57,7 @@ class TestCase(BaseRegularTestCase):
sample_rate: int
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
label="gamma_0_identity",
gamma=0,
@@ -153,7 +154,7 @@ class TestCase(BaseRegularTestCase):
sample_rate=1000000,
expected=ValueError,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -168,12 +169,21 @@ def test_from_gamma(self, test_case: TestCase) -> None:
sample_rate=test_case.sample_rate,
match=test_case.match,
):
- result = FFTTransformer.from_gamma(gamma=test_case.gamma, sample_rate=test_case.sample_rate)
+ result = FFTTransformer.from_gamma(
+ gamma=test_case.gamma,
+ sample_rate=test_case.sample_rate,
+ )
assert isinstance(result, FFTTransformer)
assert result.sample_rate == test_case.sample_rate
assert isinstance(test_case.expected, Transformation)
- assert compare_functions(result.transformation.forward, test_case.expected.forward)
- assert compare_functions(result.transformation.backward, test_case.expected.backward)
+ assert compare_functions(
+ result.transformation.forward,
+ test_case.expected.forward,
+ )
+ assert compare_functions(
+ result.transformation.backward,
+ test_case.expected.backward,
+ )
test_value = np.array([4.0, 9.0, 16.0], dtype=np.float32)
expected_forward = test_case.expected.forward(test_value)
@@ -195,7 +205,7 @@ class TestCase(BaseRegularTestCase):
mock_spectrum: Histogram
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
audio=np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32),
sample_rate=44100,
@@ -250,14 +260,18 @@ class TestCase(BaseRegularTestCase):
match="negative values",
label="spectrum_with_negative_values_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
test_cases,
ids=lambda test_case: test_case.label,
)
- def test_calculate_spectrum(self, test_case: TestCase, transformer_identity: FFTTransformer) -> None:
+ def test_calculate_spectrum(
+ self,
+ test_case: TestCase,
+ transformer_identity: FFTTransformer,
+ ) -> None:
with patch(
"sampletones_core.fft.transformer.calculate_spectrum",
return_value=test_case.mock_spectrum,
@@ -292,7 +306,7 @@ class TestCase(BaseRegularTestCase):
transformer: TransformerFixture
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
audio=np.array([0.1, 0.2, 0.3, 0.4], dtype=np.float32),
sample_rate=44100,
@@ -366,14 +380,18 @@ class TestCase(BaseRegularTestCase):
match="negative values",
label="negative_spectrum_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
test_cases,
ids=lambda test_case: test_case.label,
)
- def test_calculate_feature(self, test_case: TestCase, request: pytest.FixtureRequest) -> None:
+ def test_calculate_feature(
+ self,
+ test_case: TestCase,
+ request: pytest.FixtureRequest,
+ ) -> None:
transformer = test_case.transformer.get_fixture(request)
with patch(
@@ -407,7 +425,7 @@ class TestCase(BaseRegularTestCase):
transformer: TransformerFixture
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
input_data=Histogram(
edges=np.array([0.0, 100.0, 200.0, 300.0], dtype=np.float32),
@@ -463,14 +481,18 @@ class TestCase(BaseRegularTestCase):
match="must be a Histogram or Array/Numeric",
label="invalid_type_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
test_cases,
ids=lambda test_case: test_case.label,
)
- def test_forward(self, test_case: TestCase, request: pytest.FixtureRequest) -> None:
+ def test_forward(
+ self,
+ test_case: TestCase,
+ request: pytest.FixtureRequest,
+ ) -> None:
transformer = test_case.transformer.get_fixture(request)
if not expect_error(
@@ -503,7 +525,7 @@ class TestCase(BaseRegularTestCase):
transformer: TransformerFixture
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
input_data=Histogram(
edges=np.array([0.0, 100.0, 200.0, 300.0], dtype=np.float32),
@@ -559,14 +581,18 @@ class TestCase(BaseRegularTestCase):
match="must be a Histogram or Array/Numeric",
label="invalid_type_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
test_cases,
ids=lambda test_case: test_case.label,
)
- def test_backward(self, test_case: TestCase, request: pytest.FixtureRequest) -> None:
+ def test_backward(
+ self,
+ test_case: TestCase,
+ request: pytest.FixtureRequest,
+ ) -> None:
transformer = test_case.transformer.get_fixture(request)
if not expect_error(
@@ -599,7 +625,7 @@ class TestCase(BaseRegularTestCase):
operation: MultaryTransformation[Union[Numeric, Array]]
arguments: Tuple[Union[Numeric, Array], ...]
- test_cases = [
+ test_cases = (
TestCase(
transformer=TransformerFixture.IDENTITY,
operation=lambda x: x * 2.0,
@@ -677,14 +703,18 @@ class TestCase(BaseRegularTestCase):
expected=np.float64(8.0),
label="square_scalar_float64",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
test_cases,
ids=lambda test_case: test_case.label,
)
- def test_compose_function(self, test_case: TestCase, request: pytest.FixtureRequest) -> None:
+ def test_compose_function(
+ self,
+ test_case: TestCase,
+ request: pytest.FixtureRequest,
+ ) -> None:
transformer = test_case.transformer.get_fixture(request)
composed_function = transformer.compose_function(test_case.operation)
result = composed_function(*test_case.arguments)
@@ -707,7 +737,7 @@ class TestCase(BaseRegularTestCase):
operation: MultaryTransformation
arguments: Tuple[Histogram, ...]
- test_cases = [
+ test_cases = (
TestCase(
transformer=TransformerFixture.SQUARE,
operation=np.add,
@@ -817,7 +847,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="non_histogram_feature",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -848,7 +878,7 @@ class TestCase(BaseRegularTestCase):
operation: MultaryTransformation
arguments: Tuple[Histogram, ...]
- test_cases = [
+ test_cases = (
TestCase(
transformer=TransformerFixture.SQUARE,
operation=np.add,
@@ -961,14 +991,18 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="non_histogram_feature",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
test_cases,
ids=lambda test_case: test_case.label,
)
- def test_reduce(self, test_case: TestCase, request: pytest.FixtureRequest) -> None:
+ def test_reduce(
+ self,
+ test_case: TestCase,
+ request: pytest.FixtureRequest,
+ ) -> None:
transformer = test_case.transformer.get_fixture(request)
if not expect_error(
@@ -977,7 +1011,10 @@ def test_reduce(self, test_case: TestCase, request: pytest.FixtureRequest) -> No
test_case.operation,
*test_case.arguments,
):
- result = transformer.reduce(test_case.operation, *test_case.arguments)
+ result = transformer.reduce(
+ test_case.operation,
+ *test_case.arguments,
+ )
assert isinstance(result, Histogram)
assert isinstance(test_case.expected, Histogram)
assert_array_equal(result.edges, test_case.expected.edges)
@@ -991,7 +1028,7 @@ class TestCase(BaseRegularTestCase):
transformer: TransformerFixture
inputs: Tuple[Union[Histogram, Numeric], ...]
- test_cases = [
+ test_cases = (
TestCase(
transformer=TransformerFixture.SQUARE,
inputs=(
@@ -1191,7 +1228,7 @@ class TestCase(BaseRegularTestCase):
expected=ValueError,
label="inconsistent_edges",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1225,7 +1262,7 @@ class TestCase(BaseRegularTestCase):
transformer: TransformerFixture
inputs: Tuple[Union[Histogram, Numeric], ...]
- test_cases = [
+ test_cases = (
TestCase(
transformer=TransformerFixture.SQUARE,
inputs=(
@@ -1359,7 +1396,7 @@ class TestCase(BaseRegularTestCase):
expected=ValueError,
label="inconsistent_edges",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1389,7 +1426,7 @@ class TestCase(BaseRegularTestCase):
input1: Union[Histogram, Numeric]
input2: Union[Histogram, Numeric]
- test_cases = [
+ test_cases = (
TestCase(
transformer=TransformerFixture.SQUARE,
input1=Histogram(
@@ -1484,7 +1521,7 @@ class TestCase(BaseRegularTestCase):
expected=ValueError,
label="inconsistent_edges",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1514,7 +1551,7 @@ class TestCase(BaseRegularTestCase):
transformer: TransformerFixture
inputs: Tuple[Union[Histogram, Numeric], ...]
- test_cases = [
+ test_cases = (
TestCase(
transformer=TransformerFixture.SQUARE,
inputs=(
@@ -1618,7 +1655,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="invalid_type",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1648,7 +1685,7 @@ class TestCase(BaseRegularTestCase):
input1: Union[Histogram, Numeric]
input2: Union[Histogram, Numeric]
- test_cases = [
+ test_cases = (
TestCase(
transformer=TransformerFixture.SQUARE,
input1=Histogram(
@@ -1727,7 +1764,7 @@ class TestCase(BaseRegularTestCase):
expected=ValueError,
label="inconsistent_edges",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1757,7 +1794,7 @@ class TestCase(BaseRegularTestCase):
transformer: TransformerFixture
features: Tuple[Histogram, ...]
- test_cases = [
+ test_cases = (
TestCase(
transformer=TransformerFixture.SQUARE,
features=(
@@ -1851,7 +1888,7 @@ class TestCase(BaseRegularTestCase):
expected=ValueError,
label="inconsistent_edges",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_core/fft/test_utils.py b/tests/unit/sampletones_core/fft/test_utils.py
index dbd72ad1..5b42e5c1 100644
--- a/tests/unit/sampletones_core/fft/test_utils.py
+++ b/tests/unit/sampletones_core/fft/test_utils.py
@@ -27,7 +27,7 @@ def label(self) -> str:
error_suffix = "_error" if isinstance(self.expected, type) and issubclass(self.expected, Exception) else ""
return f"rate_{self.sample_rate}_cutoff_{self.cutoff}_bpo_{self.bins_per_octave}{error_suffix}"
- test_cases = [
+ test_cases = (
TestCase(
sample_rate=44100,
cutoff=55.0,
@@ -79,7 +79,7 @@ def label(self) -> str:
expected=ValueError,
match="number of bins is not positive",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -119,7 +119,7 @@ def label(self) -> str:
dtype_str = self.bands.dtype if hasattr(self.bands, "dtype") else type(self.bands).__name__
return f"dtype_{dtype_str}_cutoff_{self.cutoff}_bpo_{self.bins_per_octave}{error_suffix}"
- test_cases = [
+ test_cases = (
TestCase(
bands=np.linspace(0.0, 22050.0, 809, dtype=np.float32),
cutoff=54.6,
@@ -194,7 +194,7 @@ def label(self) -> str:
expected=ValueError,
match="must be less than the maximum band frequency",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py
index 21b5ba4d..dc7b3aba 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_btp.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py
@@ -9,8 +9,11 @@
from sampletones_core.formats.bitphase.btp import project_to_bytes, write_btp
from sampletones_core.formats.bitphase.builder import sample_to_bitphase
from sampletones_core.formats.bitphase.model.project import BitphaseProject
-from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES, TUNING_TABLE_LENGTH
-from sampletones_core.paths import EXT_FILE_BITPHASE
+from sampletones_core.formats.bitphase.specification.chip import (
+ CHIP_TYPE_NES,
+ TUNING_TABLE_LENGTH,
+)
+from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE
from .conftest import build_features, build_instrument, build_sample
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_builder.py
index 1ca42272..7494578f 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_builder.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_builder.py
@@ -19,9 +19,16 @@
note_index_to_note_cell,
pitch_to_note_index,
)
-from sampletones_core.formats.bitphase.specification.channels import CHANNEL_COUNT, ChannelIndex
+from sampletones_core.formats.bitphase.specification.channels import (
+ CHANNEL_COUNT,
+ ChannelIndex,
+)
from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES
-from sampletones_core.formats.bitphase.specification.instruments import MAX_TABLE_ID, MIN_INSTRUMENT_ID, MIN_TABLE_ID
+from sampletones_core.formats.bitphase.specification.instruments import (
+ MAX_TABLE_ID,
+ MIN_INSTRUMENT_ID,
+ MIN_TABLE_ID,
+)
from sampletones_core.formats.bitphase.specification.patterns import (
FIRST_PATTERN_ID,
FULL_VOLUME,
@@ -33,7 +40,13 @@
NoteName,
)
-from .conftest import NES_FREQUENCY, REFERENCE_PITCH, build_features, build_instrument, build_sample
+from .conftest import (
+ NES_FREQUENCY,
+ REFERENCE_PITCH,
+ build_features,
+ build_instrument,
+ build_sample,
+)
VOLUME_ENVELOPE: Final[List[int]] = [15, 10, 5, 0]
NOISE_PERIOD: Final[int] = 4
@@ -57,10 +70,16 @@ def project_fixture() -> BitphaseProject:
class TestEverySliceBecomesAVoice:
def test_each_slice_yields_one_instrument(self, project: BitphaseProject) -> None:
- assert [instrument.name for instrument in project.instruments] == ["Kick (pulse1)", "Kick (noise)"]
+ assert [instrument.name for instrument in project.instruments] == [
+ "Kick (pulse1)",
+ "Kick (noise)",
+ ]
def test_each_slice_yields_the_table_that_carries_its_contour(self, project: BitphaseProject) -> None:
- assert [table.name for table in project.tables] == ["Kick (pulse1)", "Kick (noise)"]
+ assert [table.name for table in project.tables] == [
+ "Kick (pulse1)",
+ "Kick (noise)",
+ ]
def test_instruments_are_numbered_from_the_first_the_column_names(self, project: BitphaseProject) -> None:
assert [instrument.id for instrument in project.instruments] == [
@@ -69,7 +88,10 @@ def test_instruments_are_numbered_from_the_first_the_column_names(self, project:
]
def test_tables_are_numbered_alongside_the_instruments(self, project: BitphaseProject) -> None:
- assert [table.id for table in project.tables] == [MIN_TABLE_ID, MIN_TABLE_ID + 1]
+ assert [table.id for table in project.tables] == [
+ MIN_TABLE_ID,
+ MIN_TABLE_ID + 1,
+ ]
def test_every_instrument_declares_the_chip_whose_rows_it_holds(self, project: BitphaseProject) -> None:
"""A document that leaves the chip unnamed loads as an AY instrument, so the
@@ -195,7 +217,11 @@ def test_a_noise_table_holds_offsets_within_one_period_cycle(self) -> None:
project = instrument_to_bitphase(
build_instrument(
"Hat",
- build_features(VOLUME_ENVELOPE, arpeggio=[0, -1, -2, -3], initial_pitch=NOISE_PERIOD),
+ build_features(
+ VOLUME_ENVELOPE,
+ arpeggio=[0, -1, -2, -3],
+ initial_pitch=NOISE_PERIOD,
+ ),
generator=GeneratorName.NOISE,
)
)
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py
index 28e4da5d..414cbc07 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py
@@ -5,10 +5,14 @@
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.constants.general import NUM_PERIODS
-from sampletones_core.formats.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes
+from sampletones_core.formats.bitphase.envelopes import (
+ ChannelEnvelopes,
+ features_to_envelopes,
+)
from sampletones_core.formats.bitphase.specification.instruments import (
FLAT_PULSE_WIDTH,
LOOP_FROM_START,
+ MAX_VOLUME_OR_RATE,
NO_TABLE_OFFSET,
NOISE_MODE_LONG,
NOISE_MODE_SHORT,
@@ -163,6 +167,54 @@ def test_the_loop_row_exists_in_both_lists(self, loop: bool) -> None:
assert envelopes.loop < len(envelopes.table_rows)
+class TestASliceThatLeavesItsVolumeToTheChannel:
+ """An instrument with no volume envelope sounds at the level its channel carries, so
+ every frame it describes reaches Bitphase as a full-level row.
+ """
+
+ def test_it_holds_a_full_row_per_frame(self) -> None:
+ envelopes = features_to_envelopes(
+ build_features([], arpeggio=PITCH_CONTOUR),
+ GeneratorName.PULSE1,
+ loop=False,
+ )
+ assert [row.volume_or_rate for row in envelopes.rows] == [MAX_VOLUME_OR_RATE] * len(PITCH_CONTOUR)
+
+ def test_its_contour_still_moves_the_note(self) -> None:
+ envelopes = features_to_envelopes(
+ build_features([], arpeggio=PITCH_CONTOUR),
+ GeneratorName.PULSE1,
+ loop=False,
+ )
+ assert list(envelopes.table_rows) == PITCH_CONTOUR
+
+ def test_its_duty_envelope_still_reaches_the_rows(self) -> None:
+ duty_cycles = [0, 1, 2, 3]
+ envelopes = features_to_envelopes(
+ build_features([], duty_cycle=duty_cycles),
+ GeneratorName.PULSE1,
+ loop=False,
+ )
+ assert [row.pulse_width for row in envelopes.rows] == duty_cycles
+
+ def test_a_one_shot_rests_at_the_level_the_channel_holds(self) -> None:
+ envelopes = features_to_envelopes(
+ build_features([], arpeggio=PITCH_CONTOUR),
+ GeneratorName.PULSE1,
+ loop=False,
+ )
+ assert envelopes.rows[envelopes.loop].volume_or_rate == MAX_VOLUME_OR_RATE
+
+ def test_a_looping_slice_takes_the_length_its_contour_states(self) -> None:
+ envelopes = features_to_envelopes(
+ build_features([], arpeggio=PITCH_CONTOUR),
+ GeneratorName.PULSE1,
+ loop=True,
+ )
+ assert len(envelopes.rows) == len(PITCH_CONTOUR)
+ assert envelopes.loop == LOOP_FROM_START
+
+
class TestAnEmptySlice:
"""An instrument holds at least one row, so a slice with no volume envelope still
reaches Bitphase as a playable silent instrument.
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_identifiers.py b/tests/unit/sampletones_core/formats/bitphase/test_identifiers.py
index ec152295..31accbc6 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_identifiers.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_identifiers.py
@@ -1,5 +1,4 @@
from dataclasses import dataclass
-from typing import List
import pytest
@@ -10,6 +9,8 @@
MIN_INSTRUMENT_ID,
)
from sampletones_core.formats.bitphase.specification.patterns import SYMBOL_BASE
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
@dataclass
@@ -18,17 +19,25 @@ class IdentifierCase:
identifier: str
-IDENTIFIER_CASES: List[IdentifierCase] = [
- IdentifierCase(number=1, identifier="01"),
- IdentifierCase(number=10, identifier="0A"),
- IdentifierCase(number=35, identifier="0Z"),
- IdentifierCase(number=36, identifier="10"),
- IdentifierCase(number=MAX_INSTRUMENT_ID, identifier="ZZ"),
-]
-
-
-class TestFormatInstrumentId:
- @pytest.mark.parametrize("case", IDENTIFIER_CASES, ids=lambda case: str(case.number))
+class TestFormatInstrumentId(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class IdentifierCase(BaseRegularTestCase):
+ number: int
+ identifier: str
+
+ test_cases = (
+ IdentifierCase(number=1, identifier="01", label="1"),
+ IdentifierCase(number=10, identifier="0A", label="10"),
+ IdentifierCase(number=35, identifier="0Z", label="35"),
+ IdentifierCase(number=36, identifier="10", label="36"),
+ IdentifierCase(
+ number=MAX_INSTRUMENT_ID,
+ identifier="ZZ",
+ label=str(MAX_INSTRUMENT_ID),
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
def test_a_number_renders_as_its_base36_text(self, case: IdentifierCase) -> None:
assert format_instrument_id(case.number) == case.identifier
@@ -40,5 +49,11 @@ def test_bitphase_parses_the_written_text_back(self) -> None:
assert int(format_instrument_id(number), SYMBOL_BASE) == number
def test_every_identifier_fills_the_column(self) -> None:
- widths = {len(format_instrument_id(number)) for number in range(MIN_INSTRUMENT_ID, MAX_INSTRUMENT_ID + 1)}
+ widths = {
+ len(format_instrument_id(number))
+ for number in range(
+ MIN_INSTRUMENT_ID,
+ MAX_INSTRUMENT_ID + 1,
+ )
+ }
assert widths == {INSTRUMENT_ID_DIGITS}
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_notes.py b/tests/unit/sampletones_core/formats/bitphase/test_notes.py
index 2649fece..0f69224f 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_notes.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_notes.py
@@ -1,5 +1,5 @@
from dataclasses import dataclass
-from typing import Final, List
+from typing import Final
import pytest
@@ -19,35 +19,7 @@
NOTE_RANGE,
NoteName,
)
-
-
-@dataclass
-class PitchCase:
- pitch: int
- index: int
-
-
-@dataclass
-class NoteCellCase:
- index: int
- name: int
- octave: int
-
-
-PITCH_CASES: List[PitchCase] = [
- PitchCase(pitch=24, index=0),
- PitchCase(pitch=60, index=36),
- PitchCase(pitch=119, index=95),
- PitchCase(pitch=0, index=0),
- PitchCase(pitch=200, index=95),
-]
-
-NOTE_CELL_CASES: List[NoteCellCase] = [
- NoteCellCase(index=0, name=int(NoteName.C), octave=1),
- NoteCellCase(index=36, name=int(NoteName.C), octave=4),
- NoteCellCase(index=45, name=11, octave=4),
- NoteCellCase(index=95, name=int(NoteName.B), octave=8),
-]
+from tests.suite.case import BaseRegularTestCase
LOWEST_STEP: Final[int] = -NUM_PERIODS
HIGHEST_STEP: Final[int] = NUM_PERIODS
@@ -64,7 +36,20 @@ def bitphase_noise_period(index: int) -> int:
class TestPitchToNoteIndex:
- @pytest.mark.parametrize("case", PITCH_CASES, ids=lambda case: str(case.pitch))
+ @dataclass(frozen=True, kw_only=True)
+ class PitchCase(BaseRegularTestCase):
+ pitch: int
+ index: int
+
+ test_cases = (
+ PitchCase(pitch=24, index=0, label="24"),
+ PitchCase(pitch=60, index=36, label="60"),
+ PitchCase(pitch=119, index=95, label="119"),
+ PitchCase(pitch=0, index=0, label="0"),
+ PitchCase(pitch=200, index=95, label="200"),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
def test_a_pitch_lands_on_its_tuning_table_index(self, case: PitchCase) -> None:
assert pitch_to_note_index(case.pitch) == case.index
@@ -78,7 +63,20 @@ def test_the_playable_span_keeps_its_distance_from_the_pitch(self) -> None:
class TestNoteIndexToNoteCell:
- @pytest.mark.parametrize("case", NOTE_CELL_CASES, ids=lambda case: str(case.index))
+ @dataclass(frozen=True, kw_only=True)
+ class NoteCellCase(BaseRegularTestCase):
+ index: int
+ name: int
+ octave: int
+
+ test_cases = (
+ NoteCellCase(index=0, name=int(NoteName.C), octave=1, label="0"),
+ NoteCellCase(index=36, name=int(NoteName.C), octave=4, label="36"),
+ NoteCellCase(index=45, name=11, octave=4, label="45"),
+ NoteCellCase(index=95, name=int(NoteName.B), octave=8, label="95"),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
def test_an_index_names_a_semitone_and_an_octave(self, case: NoteCellCase) -> None:
cell = note_index_to_note_cell(case.index)
assert (cell.name, cell.octave) == (case.name, case.octave)
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py
index 6643d59b..ed3b8e11 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py
@@ -7,7 +7,11 @@
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrumentPreset
from sampletones_core.formats.bitphase.notes import pitch_to_note_index
-from sampletones_core.formats.bitphase.preset import PRESET_TUNING_TABLE, instrument_to_preset, write_preset
+from sampletones_core.formats.bitphase.preset import (
+ PRESET_TUNING_TABLE,
+ instrument_to_preset,
+ write_preset,
+)
from sampletones_core.formats.bitphase.specification.chip import CHIP_TYPE_NES
from sampletones_core.formats.bitphase.specification.instruments import (
LOOP_FROM_START,
@@ -15,7 +19,7 @@
MIN_TONE_ADD,
NO_TONE_OFFSET,
)
-from sampletones_core.paths import EXT_FILE_JSON
+from sampletones_shared.paths.extensions import EXT_FILE_JSON
from .conftest import REFERENCE_PITCH, build_features, build_instrument
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py
index cfd42ecb..c1ff20a1 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py
@@ -1,21 +1,33 @@
from pathlib import Path
-from typing import Dict, Final, List, Mapping, Optional, Sequence
+from typing import Dict, Final, List, Mapping, Optional, Sequence, Tuple
import numpy as np
import pytest
from sampletones_core.configs import Config
from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.general import SILENT_VOLUME
from sampletones_core.formats.bitphase.builder import project_to_bitphase
+from sampletones_core.formats.bitphase.model.pattern import BitphaseRow, EffectCell
from sampletones_core.formats.bitphase.model.project import BitphaseProject
-from sampletones_core.formats.bitphase.notes import note_index_to_note_cell, pitch_to_note_index
+from sampletones_core.formats.bitphase.notes import (
+ note_index_to_note_cell,
+ pitch_to_note_index,
+)
from sampletones_core.formats.bitphase.specification.channels import ChannelIndex
+from sampletones_core.formats.bitphase.specification.effects import (
+ NO_EFFECT_PARAMETER,
+ SPEED_EFFECT_DELAY,
+ EffectId,
+)
+from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START
from sampletones_core.formats.bitphase.specification.patterns import (
NO_INSTRUMENT_CHANGE,
NO_TABLE_CHANGE,
NO_VOLUME_CHANGE,
SYMBOL_BASE,
TABLE_COLUMN_OFFSET,
+ VOLUME_OFF,
NoteName,
)
from sampletones_core.instructions.implementation.pulse import PulseInstruction
@@ -43,9 +55,14 @@
NOTE_OFF_ROW: Final[int] = 2
TRANSPOSED_ROW: Final[int] = 4
EMPTY_ROW: Final[int] = 6
+SILENCED_ROW: Final[int] = 7
+GROOVE_TEMPO: Final[int] = 210
+GROOVE_TICKS: Final[Tuple[int, ...]] = (5, 4, 4, 4, 5, 4, 4, 4)
-def build_reconstruction(instructions: Mapping[GeneratorName, Sequence[Instruction]]) -> Reconstruction:
+def build_reconstruction(
+ instructions: Mapping[GeneratorName, Sequence[Instruction]],
+) -> Reconstruction:
approximations = {generator: np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32) for generator in instructions}
return Reconstruction.create(
approximation=np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32),
@@ -59,12 +76,18 @@ def build_reconstruction(instructions: Mapping[GeneratorName, Sequence[Instructi
def pulse_sample(name: str, pitch: int) -> Sample:
instructions = [PulseInstruction(on=True, pitch=pitch, volume=15, duty_cycle=0)]
- return Sample(name=name, reconstruction=build_reconstruction({GeneratorName.PULSE1: instructions}))
+ return Sample(
+ name=name,
+ reconstruction=build_reconstruction({GeneratorName.PULSE1: instructions}),
+ )
def triangle_sample(name: str, pitch: int) -> Sample:
instructions = [TriangleInstruction(on=True, pitch=pitch)]
- return Sample(name=name, reconstruction=build_reconstruction({GeneratorName.TRIANGLE: instructions}))
+ return Sample(
+ name=name,
+ reconstruction=build_reconstruction({GeneratorName.TRIANGLE: instructions}),
+ )
@pytest.fixture(name="lead")
@@ -94,6 +117,7 @@ def source_fixture(lead: Sample, bass: Sample) -> Project:
command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1),
transpose=TRANSPOSE,
)
+ pulse_rows[SILENCED_ROW] = Row(volume=SILENT_VOLUME)
triangle_rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)]
triangle_rows[TRIGGER_ROW] = Row(
@@ -123,9 +147,24 @@ def document_fixture(source: Project) -> BitphaseProject:
return project_to_bitphase(source)
+@pytest.fixture(name="grooved_document")
+def grooved_document_fixture(source: Project) -> BitphaseProject:
+ """The same project at a tempo whose row rate falls between two whole tick counts."""
+ source.settings.tempo = GROOVE_TEMPO
+ return project_to_bitphase(source)
+
+
+def groove_channel_rows(document: BitphaseProject, pattern_index: int) -> Tuple[BitphaseRow, ...]:
+ """The lines of the channel the groove rides, within one pattern."""
+ return document.songs[0].patterns[pattern_index].channels[int(ChannelIndex.DPCM)].rows
+
+
class TestTheDocumentCarriesTheProject:
def test_the_title_and_author_cross_over(self, document: BitphaseProject, source: Project) -> None:
- assert (document.name, document.author) == (source.info.title, source.info.author)
+ assert (document.name, document.author) == (
+ source.info.title,
+ source.info.author,
+ )
def test_the_speed_and_tick_rate_cross_over(self, document: BitphaseProject, source: Project) -> None:
song = document.songs[0]
@@ -133,7 +172,10 @@ def test_the_speed_and_tick_rate_cross_over(self, document: BitphaseProject, sou
assert song.interrupt_frequency == source.settings.nes_frequency
def test_every_sample_slice_becomes_an_instrument(self, document: BitphaseProject) -> None:
- assert [instrument.name for instrument in document.instruments] == ["Lead (pulse1)", "Bass (triangle)"]
+ assert [instrument.name for instrument in document.instruments] == [
+ "Lead (pulse1)",
+ "Bass (triangle)",
+ ]
class TestTheOrderFlattens:
@@ -192,6 +234,15 @@ def test_a_row_that_sets_no_volume_leaves_the_column_alone(self, document: Bitph
row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRANSPOSED_ROW]
assert row.volume == NO_VOLUME_CHANGE
+ def test_a_row_asking_for_silence_silences_the_channel(self, document: BitphaseProject) -> None:
+ """Bitphase reads a stored volume of ``0`` as "carry the level forward", so silence
+ is the value below it — the one its editor prints as the digit ``0`` — and a row
+ asking for silence has to reach a different column than a row asking for nothing.
+ """
+ rows = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows
+ assert rows[SILENCED_ROW].volume == VOLUME_OFF
+ assert rows[SILENCED_ROW].volume != rows[TRANSPOSED_ROW].volume
+
def test_a_note_off_stops_the_channel(self, document: BitphaseProject) -> None:
row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[NOTE_OFF_ROW]
assert row.note.name == int(NoteName.OFF)
@@ -207,6 +258,63 @@ def test_a_blank_line_leaves_every_column_alone(self, document: BitphaseProject)
)
+class TestTheTempoBecomesAGroove:
+ """A Bitphase song holds one speed value per row, so the fractional row rate most tempi
+ ask for is carried by a groove: whole tick counts that vary from row to row. The groove
+ reaches the engine as a table a speed effect reads a row at a time, triggered from the
+ channel this exporter leaves silent. A tempo whose rows all last alike is carried by the
+ song's initial speed alone.
+ """
+
+ def test_a_tempo_the_speed_column_states_needs_no_table(self, document: BitphaseProject) -> None:
+ assert len(document.tables) == len(document.instruments)
+
+ def test_a_tempo_the_speed_column_states_leaves_the_groove_channel_resting(
+ self,
+ document: BitphaseProject,
+ ) -> None:
+ assert all(row == BitphaseRow() for row in groove_channel_rows(document, 0))
+
+ def test_a_groove_takes_the_table_above_the_slices(self, grooved_document: BitphaseProject) -> None:
+ table = grooved_document.tables[-1]
+ assert table.id == len(grooved_document.instruments)
+ assert table.loop == LOOP_FROM_START
+
+ def test_the_table_holds_the_ticks_each_row_lasts(self, grooved_document: BitphaseProject) -> None:
+ assert grooved_document.tables[-1].rows == GROOVE_TICKS
+
+ def test_the_song_starts_on_the_ticks_its_first_row_lasts(self, grooved_document: BitphaseProject) -> None:
+ assert grooved_document.songs[0].initial_speed == GROOVE_TICKS[TRIGGER_ROW]
+
+ def test_every_pattern_triggers_the_groove_on_its_first_row(self, grooved_document: BitphaseProject) -> None:
+ """The speed table advances one entry per row and returns to the entry the trigger
+ names, so triggering it again at each pattern start holds every row on the entry that
+ describes it however the order jumps.
+ """
+ trigger = EffectCell(
+ effect=int(EffectId.SPEED),
+ delay=SPEED_EFFECT_DELAY,
+ parameter=NO_EFFECT_PARAMETER,
+ table_index=grooved_document.tables[-1].id,
+ )
+ triggers = [
+ groove_channel_rows(grooved_document, index)[TRIGGER_ROW].effects
+ for index in range(len(grooved_document.songs[0].patterns))
+ ]
+ assert triggers == [(trigger,)] * len(triggers)
+
+ def test_the_groove_channel_carries_nothing_but_the_trigger(self, grooved_document: BitphaseProject) -> None:
+ rows = groove_channel_rows(grooved_document, 0)
+ assert all(row == BitphaseRow() for row in rows[TRIGGER_ROW + 1 :])
+
+ def test_the_sounding_channels_keep_their_effect_columns(self, grooved_document: BitphaseProject) -> None:
+ """The groove rides the silent channel, so every channel that plays keeps the one
+ effect column the chip gives it.
+ """
+ channels = grooved_document.songs[0].patterns[0].channels[: int(ChannelIndex.DPCM)]
+ assert all(row.effects == (None,) for channel in channels for row in channel.rows)
+
+
class TestAnUnbuildableRow:
def test_a_row_naming_a_slice_with_no_instrument_is_refused(self, source: Project, lead: Sample) -> None:
rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)]
diff --git a/tests/unit/sampletones_core/formats/bitphase/test_tuning.py b/tests/unit/sampletones_core/formats/bitphase/test_tuning.py
index 7c187ec7..7763628a 100644
--- a/tests/unit/sampletones_core/formats/bitphase/test_tuning.py
+++ b/tests/unit/sampletones_core/formats/bitphase/test_tuning.py
@@ -1,5 +1,6 @@
+import itertools
from dataclasses import dataclass
-from typing import Final, List, Tuple
+from typing import Final, Tuple
import pytest
@@ -13,6 +14,7 @@
ChipVariant,
)
from sampletones_core.formats.bitphase.tuning import generate_tuning_table
+from tests.suite.case import BaseRegularTestCase
BITPHASE_NTSC_TABLE: Final[Tuple[int, ...]] = (
2047, 2047, 2047, 2047, 2047, 2047, 2047, 2047, 2047, 2034, 1920, 1812,
@@ -33,18 +35,6 @@ class PeriodCase:
period: int
-VARIANT_CASES: List[PeriodCase] = [
- PeriodCase(variant=ChipVariant.NTSC, index=9, period=2034),
- PeriodCase(variant=ChipVariant.NTSC, index=45, period=254),
- PeriodCase(variant=ChipVariant.NTSC, index=95, period=14),
- PeriodCase(variant=ChipVariant.PAL, index=9, period=1889),
- PeriodCase(variant=ChipVariant.PAL, index=45, period=236),
- PeriodCase(variant=ChipVariant.PAL, index=95, period=13),
- PeriodCase(variant=ChipVariant.DENDY, index=9, period=2015),
- PeriodCase(variant=ChipVariant.DENDY, index=45, period=252),
- PeriodCase(variant=ChipVariant.DENDY, index=95, period=14),
-]
-
SLOW_CLOCK: Final[int] = 1000
RAISED_A4_TUNING: Final[float] = 432.0
RAISED_A4_PERIOD: Final[int] = 259
@@ -65,12 +55,84 @@ class TestTheTableMatchesBitphase:
the reconstruction it came from. These numbers come from Bitphase's own generator.
"""
- def test_the_ntsc_table_equals_the_one_bitphase_derives(self, ntsc_table: Tuple[int, ...]) -> None:
+ def test_the_ntsc_table_equals_the_one_bitphase_derives(
+ self,
+ ntsc_table: Tuple[int, ...],
+ ) -> None:
assert ntsc_table == BITPHASE_NTSC_TABLE
- @pytest.mark.parametrize("case", VARIANT_CASES, ids=lambda case: f"{case.variant}-{case.index}")
- def test_each_system_clock_yields_bitphase_periods(self, case: PeriodCase) -> None:
- table = generate_tuning_table(CPU_FREQUENCIES[case.variant], a4_tuning=DEFAULT_A4_TUNING)
+ @dataclass(frozen=True, kw_only=True)
+ class PeriodCase(BaseRegularTestCase):
+ variant: ChipVariant
+ index: int
+ period: int
+
+ test_cases = (
+ PeriodCase(
+ variant=ChipVariant.NTSC,
+ index=9,
+ period=2034,
+ label="NTSC-9",
+ ),
+ PeriodCase(
+ variant=ChipVariant.NTSC,
+ index=45,
+ period=254,
+ label="NTSC-45",
+ ),
+ PeriodCase(
+ variant=ChipVariant.NTSC,
+ index=95,
+ period=14,
+ label="NTSC-95",
+ ),
+ PeriodCase(
+ variant=ChipVariant.PAL,
+ index=9,
+ period=1889,
+ label="PAL-9",
+ ),
+ PeriodCase(
+ variant=ChipVariant.PAL,
+ index=45,
+ period=236,
+ label="PAL-45",
+ ),
+ PeriodCase(
+ variant=ChipVariant.PAL,
+ index=95,
+ period=13,
+ label="PAL-95",
+ ),
+ PeriodCase(
+ variant=ChipVariant.DENDY,
+ index=9,
+ period=2015,
+ label="DENDY-9",
+ ),
+ PeriodCase(
+ variant=ChipVariant.DENDY,
+ index=45,
+ period=252,
+ label="DENDY-45",
+ ),
+ PeriodCase(
+ variant=ChipVariant.DENDY,
+ index=95,
+ period=14,
+ label="DENDY-95",
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
+ def test_each_system_clock_yields_bitphase_periods(
+ self,
+ case: PeriodCase,
+ ) -> None:
+ table = generate_tuning_table(
+ CPU_FREQUENCIES[case.variant],
+ a4_tuning=DEFAULT_A4_TUNING,
+ )
assert table[case.index] == case.period
def test_a_shifted_concert_pitch_moves_the_whole_table(self) -> None:
@@ -82,15 +144,27 @@ def test_a_shifted_concert_pitch_moves_the_whole_table(self) -> None:
class TestTableShape:
- def test_the_table_covers_every_note_index(self, ntsc_table: Tuple[int, ...]) -> None:
+ def test_the_table_covers_every_note_index(
+ self,
+ ntsc_table: Tuple[int, ...],
+ ) -> None:
assert len(ntsc_table) == TUNING_TABLE_LENGTH
- def test_a_rising_note_index_shortens_the_period(self, ntsc_table: Tuple[int, ...]) -> None:
- assert all(later <= earlier for earlier, later in zip(ntsc_table, ntsc_table[1:]))
+ def test_a_rising_note_index_shortens_the_period(
+ self,
+ ntsc_table: Tuple[int, ...],
+ ) -> None:
+ assert all(later <= earlier for earlier, later in itertools.pairwise(ntsc_table))
@pytest.mark.parametrize("variant", list(ChipVariant))
- def test_every_period_fits_the_channel_timer(self, variant: ChipVariant) -> None:
- table = generate_tuning_table(CPU_FREQUENCIES[variant], a4_tuning=DEFAULT_A4_TUNING)
+ def test_every_period_fits_the_channel_timer(
+ self,
+ variant: ChipVariant,
+ ) -> None:
+ table = generate_tuning_table(
+ CPU_FREQUENCIES[variant],
+ a4_tuning=DEFAULT_A4_TUNING,
+ )
assert all(MIN_TUNING_PERIOD <= period <= MAX_TUNING_PERIOD for period in table)
def test_a_clock_too_slow_for_the_top_notes_holds_the_shortest_period(self) -> None:
diff --git a/tests/unit/sampletones_core/formats/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py
index 1cb7e559..aaf85800 100644
--- a/tests/unit/sampletones_core/formats/famitracker/conftest.py
+++ b/tests/unit/sampletones_core/formats/famitracker/conftest.py
@@ -26,7 +26,9 @@
RECONSTRUCTION_LENGTH = 8
-def build_reconstruction(instructions: Mapping[GeneratorName, Sequence[Instruction]]) -> Reconstruction:
+def build_reconstruction(
+ instructions: Mapping[GeneratorName, Sequence[Instruction]],
+) -> Reconstruction:
approximations = {generator: np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32) for generator in instructions}
return Reconstruction.create(
approximation=np.zeros(RECONSTRUCTION_LENGTH, dtype=np.float32),
@@ -43,12 +45,19 @@ def pulse_sample(name: str, pitch: int, *, loop: bool = False) -> Sample:
PulseInstruction(on=True, pitch=pitch, volume=15, duty_cycle=0),
PulseInstruction(on=True, pitch=pitch, volume=8, duty_cycle=0),
]
- return Sample(name=name, reconstruction=build_reconstruction({GeneratorName.PULSE1: instructions}), loop=loop)
+ return Sample(
+ name=name,
+ reconstruction=build_reconstruction({GeneratorName.PULSE1: instructions}),
+ loop=loop,
+ )
def noise_sample(name: str, period: int) -> Sample:
instructions = [NoiseInstruction(on=True, period=period, volume=15, short=False)]
- return Sample(name=name, reconstruction=build_reconstruction({GeneratorName.NOISE: instructions}))
+ return Sample(
+ name=name,
+ reconstruction=build_reconstruction({GeneratorName.NOISE: instructions}),
+ )
def dual_generator_sample(name: str, pulse_pitch: int, triangle_pitch: int) -> Sample:
@@ -81,14 +90,18 @@ def project_fixture() -> ProjectFixture:
pulse_rows: List[Row] = [Row() for _ in range(8)]
pulse_rows[0] = Row(
- command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1), transpose=0, volume=10
+ command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1),
+ transpose=0,
+ volume=10,
)
pulse_rows[2] = Row(command=NoteOff())
pulse_rows[4] = Row(volume=5)
noise_rows: List[Row] = [Row() for _ in range(8)]
noise_rows[0] = Row(
- command=Instrument(sample_id=drum.id, generator_name=GeneratorName.NOISE), transpose=0, volume=15
+ command=Instrument(sample_id=drum.id, generator_name=GeneratorName.NOISE),
+ transpose=0,
+ volume=15,
)
channels = {
diff --git a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py
index 46d6dabf..9f625a53 100644
--- a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py
+++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py
@@ -1,7 +1,8 @@
import numpy as np
-import pytest
-from sampletones_core.formats.famitracker.sequences.features import features_to_instrument_sequences
+from sampletones_core.formats.famitracker.sequences.features import (
+ features_to_instrument_sequences,
+)
from sampletones_core.formats.famitracker.specification.sequences import (
LOOP_FROM_START,
MAX_SEQUENCE_ITEMS,
@@ -95,7 +96,7 @@ def test_no_loop_leaves_loop_point_disabled(self) -> None:
assert sequences[SequenceKind.VOLUME].loop_point == NO_LOOP_POINT
-class TestSequenceLengthsAreEqualized:
+class TestSequenceLengths:
def test_loop_drops_the_trailing_note_off_volume_item(self) -> None:
sequences = features_to_instrument_sequences(
volume=np.array([15, 12, 9, 0]),
@@ -109,31 +110,31 @@ def test_loop_drops_the_trailing_note_off_volume_item(self) -> None:
assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4)
assert sequences[SequenceKind.DUTY].items == (1, 1, 2)
- def test_one_shot_holds_the_shorter_dimensions_final_value(self) -> None:
+ def test_one_shot_carries_each_dimension_as_written(self) -> None:
+ """A halted sequence holds its final value, so a shorter dimension governs the rest itself."""
sequences = features_to_instrument_sequences(
volume=np.array([15, 12, 9, 0]),
arpeggio=np.array([0, 2, 4]),
pitch=None,
hi_pitch=None,
- duty_cycle=np.array([1, 1, 2]),
+ duty_cycle=np.array([1]),
loop=False,
)
assert sequences[SequenceKind.VOLUME].items == (15, 12, 9, 0)
- assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4, 4)
- assert sequences[SequenceKind.DUTY].items == (1, 1, 2, 2)
+ assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4)
+ assert sequences[SequenceKind.DUTY].items == (1,)
- @pytest.mark.parametrize("loop", [False, True], ids=["one_shot", "loop"])
- def test_every_populated_dimension_shares_one_length(self, loop: bool) -> None:
+ def test_a_loop_brings_every_populated_dimension_to_one_length(self) -> None:
sequences = features_to_instrument_sequences(
volume=np.array([15, 12, 9, 0]),
arpeggio=np.array([0, 2, 4]),
pitch=np.array([0, 1]),
hi_pitch=None,
duty_cycle=np.array([1, 1, 2]),
- loop=loop,
+ loop=True,
)
lengths = {len(sequence.items) for sequence in sequences.values() if sequence.enabled}
- assert len(lengths) == 1
+ assert lengths == {2}
def test_disabled_dimensions_stay_empty(self) -> None:
sequences = features_to_instrument_sequences(
@@ -147,6 +148,28 @@ def test_disabled_dimensions_stay_empty(self) -> None:
assert sequences[SequenceKind.ARPEGGIO].items == ()
assert sequences[SequenceKind.PITCH].items == ()
+ def test_an_empty_envelope_differs_from_one_holding_a_single_zero(self) -> None:
+ """An empty dimension leaves its sequence disabled; a single zero is a value the instrument sets."""
+ cleared = features_to_instrument_sequences(
+ volume=np.array([15, 0]),
+ arpeggio=np.array([], dtype=int),
+ pitch=None,
+ hi_pitch=None,
+ duty_cycle=None,
+ loop=False,
+ )
+ zeroed = features_to_instrument_sequences(
+ volume=np.array([15, 0]),
+ arpeggio=np.array([0]),
+ pitch=None,
+ hi_pitch=None,
+ duty_cycle=None,
+ loop=False,
+ )
+ assert cleared[SequenceKind.ARPEGGIO].enabled is False
+ assert zeroed[SequenceKind.ARPEGGIO].enabled is True
+ assert zeroed[SequenceKind.ARPEGGIO].items == (0,)
+
def test_all_dimensions_empty_stays_empty(self) -> None:
sequences = features_to_instrument_sequences(
volume=np.array([], dtype=int),
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_binary.py b/tests/unit/sampletones_core/formats/famitracker/test_binary.py
index bc1c8f21..b0774349 100644
--- a/tests/unit/sampletones_core/formats/famitracker/test_binary.py
+++ b/tests/unit/sampletones_core/formats/famitracker/test_binary.py
@@ -5,7 +5,10 @@
import pytest
from sampletones_core.formats.famitracker.binary import BinaryWriter
-from sampletones_core.formats.famitracker.specification.blocks import BLOCK_NAME_LENGTH, Block
+from sampletones_core.formats.famitracker.specification.blocks import (
+ BLOCK_NAME_LENGTH,
+ Block,
+)
@dataclass
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_builder.py b/tests/unit/sampletones_core/formats/famitracker/test_builder.py
index cc95bf02..0d04f281 100644
--- a/tests/unit/sampletones_core/formats/famitracker/test_builder.py
+++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py
@@ -2,11 +2,26 @@
import pytest
from sampletones_core.constants.enums import GeneratorName
-from sampletones_core.formats.famitracker.builder import build_instrument_table, project_to_module
-from sampletones_core.formats.famitracker.specification.channels import CHANNEL_COUNT_2A03, ChannelId
-from sampletones_core.formats.famitracker.specification.instruments import MAX_INSTRUMENTS
-from sampletones_core.formats.famitracker.specification.parameters import EXPANSION_NONE, Machine
-from sampletones_core.formats.famitracker.specification.patterns import EMPTY_INSTRUMENT, NoteValue
+from sampletones_core.formats.famitracker.builder import (
+ build_instrument_table,
+ project_to_module,
+)
+from sampletones_core.formats.famitracker.specification.channels import (
+ CHANNEL_COUNT_2A03,
+ ChannelId,
+)
+from sampletones_core.formats.famitracker.specification.instruments import (
+ MAX_INSTRUMENTS,
+)
+from sampletones_core.formats.famitracker.specification.parameters import (
+ ENGINE_SPEED_MACHINE_DEFAULT,
+ EXPANSION_NONE,
+ Machine,
+)
+from sampletones_core.formats.famitracker.specification.patterns import (
+ EMPTY_INSTRUMENT,
+ NoteValue,
+)
from sampletones_core.formats.famitracker.specification.sequences import (
LOOP_FROM_START,
NO_LOOP_POINT,
@@ -20,6 +35,7 @@
LEAD_PITCH = 60
OCTAVE = 12
+CUSTOM_NES_FREQUENCY = 30
class TestBuildInstrumentTable:
@@ -53,6 +69,7 @@ def test_slot_keeps_its_pitch_after_an_arpeggio_edit(self, project_fixture: Proj
arpeggiated,
np.ones(RECONSTRUCTION_LENGTH, dtype=np.float32),
LEAD_PITCH,
+ (),
)
instruments, slots = build_instrument_table(project_fixture.project)
@@ -89,10 +106,17 @@ def test_expansion_and_channel_count(self, project_fixture: ProjectFixture) -> N
assert module.parameters.channel_count == CHANNEL_COUNT_2A03
def test_machine_and_engine_speed_from_default_frequency(self, project_fixture: ProjectFixture) -> None:
- # default nes_frequency is 30 -> NTSC with an explicit engine-speed override
module = project_to_module(project_fixture.project)
assert module.parameters.machine == Machine.NTSC
- assert module.parameters.engine_speed == project_fixture.project.settings.nes_frequency
+ assert module.parameters.engine_speed == ENGINE_SPEED_MACHINE_DEFAULT
+
+ def test_machine_and_engine_speed_from_a_custom_frequency(self, project_fixture: ProjectFixture) -> None:
+ project_fixture.project.settings.nes_frequency = CUSTOM_NES_FREQUENCY
+
+ module = project_to_module(project_fixture.project)
+
+ assert module.parameters.machine == Machine.NTSC
+ assert module.parameters.engine_speed == CUSTOM_NES_FREQUENCY
def test_information_and_comment_carry_through(self, project_fixture: ProjectFixture) -> None:
module = project_to_module(project_fixture.project)
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py
new file mode 100644
index 00000000..ca7a69fc
--- /dev/null
+++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py
@@ -0,0 +1,191 @@
+from dataclasses import dataclass
+from typing import Final, Optional, Sequence
+
+import numpy as np
+import pytest
+
+from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.exporters.feature import Features
+from sampletones_core.formats.famitracker.builder import build_instrument
+from sampletones_core.formats.famitracker.footprint import (
+ InstrumentFootprint,
+ features_footprint,
+ instrument_footprint,
+ reconstruction_footprints,
+ sequence_footprint,
+ sequences_footprint,
+ total_footprint,
+)
+from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence
+from sampletones_core.formats.famitracker.specification.memory import (
+ INSTRUMENT_DEFINITION_BYTES,
+ SEQUENCE_HEADER_BYTES,
+ SEQUENCE_POINTER_BYTES,
+)
+from sampletones_core.formats.famitracker.specification.sequences import (
+ MAX_SEQUENCE_ITEMS,
+ SequenceKind,
+)
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+from .conftest import dual_generator_sample, pulse_sample
+
+REFERENCE_PITCH: Final[int] = 60
+OVER_LONG_LENGTH: Final[int] = MAX_SEQUENCE_ITEMS + 48
+
+
+def build_features(
+ volume: Sequence[int],
+ arpeggio: Sequence[int],
+ duty_cycle: Optional[Sequence[int]],
+) -> Features:
+ """Builds the envelopes of one generator slice, leaving the pitch dimensions unused."""
+ return Features(
+ initial_pitch=REFERENCE_PITCH,
+ volume=np.array(volume, dtype=int),
+ arpeggio=np.array(arpeggio, dtype=int),
+ pitch=None,
+ hi_pitch=None,
+ duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=int),
+ )
+
+
+class TestFeaturesFootprint(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ features: Features
+ loop: bool
+ expected: InstrumentFootprint
+
+ test_cases = (
+ TestCase(
+ features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]),
+ loop=False,
+ expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=22),
+ label="pulse_one_shot",
+ ),
+ TestCase(
+ features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]),
+ loop=True,
+ expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=21),
+ label="pulse_loop",
+ ),
+ TestCase(
+ features=build_features([15, 0], [0], [0]),
+ loop=False,
+ expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=16),
+ label="dimensions_of_differing_lengths",
+ ),
+ TestCase(
+ features=build_features([15, 12, 0], [0, 1], None),
+ loop=False,
+ expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=13),
+ label="triangle",
+ ),
+ TestCase(
+ features=build_features([], [], None),
+ loop=False,
+ expected=InstrumentFootprint(instrument_bytes=3, sequence_bytes=0),
+ label="silent",
+ ),
+ TestCase(
+ features=build_features(
+ list(range(OVER_LONG_LENGTH)),
+ [0] * OVER_LONG_LENGTH,
+ None,
+ ),
+ loop=False,
+ expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=512),
+ label="capped_at_the_sequence_limit",
+ ),
+ TestCase(
+ features=build_features(
+ [0] * MAX_SEQUENCE_ITEMS,
+ [0] * MAX_SEQUENCE_ITEMS,
+ [0] * MAX_SEQUENCE_ITEMS,
+ ),
+ loop=False,
+ expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=768),
+ label="largest_instrument_famitracker_holds",
+ ),
+ )
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_both_regions_are_measured_from_the_populated_sequences(
+ self,
+ test_case: TestCase,
+ ) -> None:
+ assert features_footprint(test_case.features, loop=test_case.loop) == test_case.expected
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_built_instrument_measures_the_same(self, test_case: TestCase) -> None:
+ """Both entry points measure one export, so a slice reads the same either way."""
+ instrument = build_instrument(0, test_case.label, test_case.features, loop=test_case.loop)
+ assert instrument_footprint(instrument) == test_case.expected
+
+ @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ def test_the_total_sums_both_regions(self, test_case: TestCase) -> None:
+ footprint = features_footprint(test_case.features, loop=test_case.loop)
+ assert footprint.total_bytes == test_case.expected.instrument_bytes + test_case.expected.sequence_bytes
+
+
+class TestSequenceFootprint:
+ def test_a_sequence_holds_its_header_and_one_byte_per_item(self) -> None:
+ sequence = InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 12, 9))
+ assert sequence_footprint(sequence) == SEQUENCE_HEADER_BYTES + 3
+
+ def test_a_disabled_sequence_costs_nothing(self) -> None:
+ sequences = (
+ InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 12)),
+ InstrumentSequence(kind=SequenceKind.PITCH, items=()),
+ )
+ footprint = sequences_footprint(sequences)
+ assert footprint.instrument_bytes == INSTRUMENT_DEFINITION_BYTES + SEQUENCE_POINTER_BYTES
+ assert footprint.sequence_bytes == SEQUENCE_HEADER_BYTES + 2
+
+
+class TestTotalFootprint:
+ def test_regions_are_summed_separately(self) -> None:
+ footprints = (
+ InstrumentFootprint(instrument_bytes=9, sequence_bytes=24),
+ InstrumentFootprint(instrument_bytes=7, sequence_bytes=16),
+ )
+ assert total_footprint(footprints) == InstrumentFootprint(instrument_bytes=16, sequence_bytes=40)
+
+ def test_no_instruments_cost_nothing(self) -> None:
+ assert total_footprint(()) == InstrumentFootprint(instrument_bytes=0, sequence_bytes=0)
+
+
+class TestReconstructionFootprints:
+ def test_one_entry_per_playing_channel(self) -> None:
+ """The sample holds every channel; the two that play are the two an export writes."""
+ sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36)
+ footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loop)
+ assert set(footprints) == {GeneratorName.PULSE1, GeneratorName.TRIANGLE}
+
+ def test_a_triangle_slice_carries_one_sequence_less_than_a_pulse_slice(self) -> None:
+ """Triangle exports volume and arpeggio; pulse adds duty, hence one more pointer."""
+ sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36)
+ footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loop)
+ pulse = footprints[GeneratorName.PULSE1]
+ triangle = footprints[GeneratorName.TRIANGLE]
+ assert pulse.instrument_bytes - triangle.instrument_bytes == SEQUENCE_POINTER_BYTES
+
+ def test_each_channel_is_measured_under_the_given_loop_flag(self) -> None:
+ sample = pulse_sample("lead", pitch=60)
+ features = sample.reconstruction.export()
+ for loop in (False, True):
+ assert reconstruction_footprints(sample.reconstruction, loop=loop) == {
+ generator_name: features_footprint(feature, loop=loop)
+ for generator_name, feature in features.items()
+ if feature.has_frames
+ }
+
+ def test_looping_costs_the_shortest_dimensions_length(self) -> None:
+ """A looping instrument shares the shortest dimension's length, so it stores fewer items."""
+ sample = pulse_sample("lead", pitch=60)
+ one_shot = total_footprint(reconstruction_footprints(sample.reconstruction, loop=False).values())
+ looping = total_footprint(reconstruction_footprints(sample.reconstruction, loop=True).values())
+ assert one_shot.instrument_bytes == looping.instrument_bytes
+ assert looping.sequence_bytes < one_shot.sequence_bytes
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_fti.py b/tests/unit/sampletones_core/formats/famitracker/test_fti.py
index dd59b6b0..8503bd4b 100644
--- a/tests/unit/sampletones_core/formats/famitracker/test_fti.py
+++ b/tests/unit/sampletones_core/formats/famitracker/test_fti.py
@@ -7,7 +7,9 @@
from sampletones_core.formats.famitracker.instrument import write_fti
from sampletones_core.formats.famitracker.model.instrument import Instrument2A03
-from sampletones_core.formats.famitracker.sequences.features import features_to_instrument_sequences
+from sampletones_core.formats.famitracker.sequences.features import (
+ features_to_instrument_sequences,
+)
GOLDEN_INSTRUMENT_NAME = "Test Instrument"
GOLDEN_VOLUME = np.array([15, 12, 8, 0])
@@ -17,9 +19,9 @@
GOLDEN_FTI_BYTES = (
b"FTI2.4\x01\x0f\x00\x00\x00Test Instrument\x05"
b"\x01\x04\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x0f\x0c\x08\x00"
- b"\x01\x04\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x02\xfd\xfd"
+ b"\x01\x03\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x02\xfd"
b"\x00\x00"
- b"\x01\x04\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x01\x01\x01"
+ b"\x01\x02\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x01"
b"\x00\x00\x00\x00\x00\x00\x00\x00"
)
@@ -116,9 +118,9 @@ def parse_fti(data: bytes) -> ParsedFti:
class TestWriteFtiGoldenBytes:
- """Pins the byte output so a change in the writer is caught. Every populated
- sequence carries the same item count, the arpeggio and duty envelopes holding
- their final value through the volume envelope's trailing note-off item."""
+ """Pins the byte output so a change in the writer is caught. Each populated
+ sequence carries the items its own envelope was written with, the shorter
+ arpeggio and duty envelopes ending before the volume envelope does."""
def test_output_matches_golden(self, tmp_path: Path) -> None:
path = tmp_path / "golden.fti"
@@ -160,7 +162,7 @@ def test_enabled_sequence_items_round_trip(self, tmp_path: Path) -> None:
parsed = parse_fti(path.read_bytes())
assert parsed.sequences[0].enabled is True
assert parsed.sequences[0].items == [15, 12, 8, 0]
- assert parsed.sequences[1].items == [0, 2, -3, -3]
+ assert parsed.sequences[1].items == [0, 2, -3]
def test_missing_sequences_are_disabled(self, tmp_path: Path) -> None:
path = tmp_path / "instrument.fti"
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_ftm.py b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py
index 1abf9360..815bd750 100644
--- a/tests/unit/sampletones_core/formats/famitracker/test_ftm.py
+++ b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py
@@ -15,9 +15,13 @@
BLOCK_SEQUENCES,
)
from sampletones_core.formats.famitracker.specification.channels import ChannelId
-from sampletones_core.formats.famitracker.specification.file import FTM_END_MARKER, FTM_VERSION
+from sampletones_core.formats.famitracker.specification.file import (
+ FTM_END_MARKER,
+ FTM_VERSION,
+)
from sampletones_core.formats.famitracker.specification.parameters import (
DEFAULT_SPEED_SPLIT_POINT,
+ ENGINE_SPEED_MACHINE_DEFAULT,
EXPANSION_NONE,
Machine,
)
@@ -82,7 +86,7 @@ def test_expansion_and_channels(self, project_fixture: ProjectFixture) -> None:
def test_machine_and_engine_speed(self, project_fixture: ProjectFixture) -> None:
params = _parsed(project_fixture).params
assert params.machine == int(Machine.NTSC)
- assert params.engine_speed == project_fixture.project.settings.nes_frequency
+ assert params.engine_speed == ENGINE_SPEED_MACHINE_DEFAULT
def test_speed_split_point(self, project_fixture: ProjectFixture) -> None:
assert _parsed(project_fixture).params.speed_split_point == DEFAULT_SPEED_SPLIT_POINT
diff --git a/tests/unit/sampletones_core/formats/famitracker/test_notes.py b/tests/unit/sampletones_core/formats/famitracker/test_notes.py
index 8abfb321..fbbc0d63 100644
--- a/tests/unit/sampletones_core/formats/famitracker/test_notes.py
+++ b/tests/unit/sampletones_core/formats/famitracker/test_notes.py
@@ -1,5 +1,4 @@
from dataclasses import dataclass
-from typing import List
import pytest
@@ -21,25 +20,17 @@ class NoteCase:
octave: int
-PITCH_CASES: List[NoteCase] = [
- NoteCase(pitch=24, note=1, octave=0), # C-0, lowest representable
- NoteCase(pitch=33, note=10, octave=0), # A-0
- NoteCase(pitch=60, note=1, octave=3), # C-3
- NoteCase(pitch=119, note=12, octave=7), # B-7, highest representable
- NoteCase(pitch=12, note=1, octave=0), # below range clamps up to C-0
- NoteCase(pitch=200, note=12, octave=7), # above range clamps down to B-7
-]
-
-PERIOD_CASES: List[NoteCase] = [
- NoteCase(pitch=0, note=1, octave=0),
- NoteCase(pitch=11, note=12, octave=0),
- NoteCase(pitch=15, note=4, octave=1),
- NoteCase(pitch=16, note=1, octave=0), # wraps into the 16 noise periods
-]
-
-
class TestPitchToNoteCell:
- @pytest.mark.parametrize("case", PITCH_CASES)
+ test_cases = (
+ NoteCase(pitch=24, note=1, octave=0), # C-0, lowest representable
+ NoteCase(pitch=33, note=10, octave=0), # A-0
+ NoteCase(pitch=60, note=1, octave=3), # C-3
+ NoteCase(pitch=119, note=12, octave=7), # B-7, highest representable
+ NoteCase(pitch=12, note=1, octave=0), # below range clamps up to C-0
+ NoteCase(pitch=200, note=12, octave=7), # above range clamps down to B-7
+ )
+
+ @pytest.mark.parametrize("case", test_cases)
def test_pitch_maps_to_note_and_octave(self, case: NoteCase) -> None:
cell = pitch_to_note_cell(case.pitch)
assert cell.note == case.note
@@ -53,7 +44,14 @@ def test_octave_stays_within_range(self) -> None:
class TestPeriodToNoteCell:
- @pytest.mark.parametrize("case", PERIOD_CASES)
+ test_cases = (
+ NoteCase(pitch=0, note=1, octave=0),
+ NoteCase(pitch=11, note=12, octave=0),
+ NoteCase(pitch=15, note=4, octave=1),
+ NoteCase(pitch=16, note=1, octave=0), # wraps into the 16 noise periods
+ )
+
+ @pytest.mark.parametrize("case", test_cases)
def test_period_maps_to_note_and_octave(self, case: NoteCase) -> None:
cell = period_to_note_cell(case.pitch)
assert cell.note == case.note
diff --git a/tests/unit/sampletones_core/generators/test_utils.py b/tests/unit/sampletones_core/generators/test_utils.py
index c7f5610d..e3043269 100644
--- a/tests/unit/sampletones_core/generators/test_utils.py
+++ b/tests/unit/sampletones_core/generators/test_utils.py
@@ -13,7 +13,11 @@
get_generators_map,
get_remaining_generator_classes,
)
-from sampletones_core.instructions import NoiseInstruction, PulseInstruction, TriangleInstruction
+from sampletones_core.instructions import (
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+)
@pytest.fixture
diff --git a/tests/unit/sampletones_core/library/filename/test_fields.py b/tests/unit/sampletones_core/library/filename/test_fields.py
index 95674258..1dbc7b07 100644
--- a/tests/unit/sampletones_core/library/filename/test_fields.py
+++ b/tests/unit/sampletones_core/library/filename/test_fields.py
@@ -3,8 +3,11 @@
import pytest
-from sampletones_core.library.filename.fields import FILENAME_SEPARATOR, InstructionsFilenameFields
-from sampletones_core.paths import EXT_FILE_LIBRARY
+from sampletones_core.library.filename.fields import (
+ FILENAME_SEPARATOR,
+ InstructionsFilenameFields,
+)
+from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
from tests.suite.errors import expect_error
@@ -42,7 +45,7 @@ class TestCase(BaseRegularTestCase):
fields: InstructionsFilenameFields
expected: str
- test_cases = [
+ test_cases = (
TestCase(
label="standard_values",
fields=_fields(),
@@ -73,7 +76,7 @@ class TestCase(BaseRegularTestCase):
fields=_fields(sm="cqt"),
expected=_stem(sm="cqt"),
),
- ]
+ )
@pytest.mark.parametrize("test_case", test_cases, ids=lambda tc: tc.label)
def test_stem(self, test_case: TestCase) -> None:
@@ -86,7 +89,7 @@ class TestCase(BaseRegularTestCase):
fields: InstructionsFilenameFields
expected: str
- test_cases = [
+ test_cases = (
TestCase(
label="appends_extension",
fields=_fields(),
@@ -97,7 +100,7 @@ class TestCase(BaseRegularTestCase):
fields=_fields(sr=22050, nf=30),
expected=_stem(sr=22050, nf=30) + EXT_FILE_LIBRARY,
),
- ]
+ )
@pytest.mark.parametrize("test_case", test_cases, ids=lambda tc: tc.label)
def test_filename(self, test_case: TestCase) -> None:
@@ -111,7 +114,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[InstructionsFilenameFields, Type[Exception]]
match: str = ""
- test_cases = [
+ test_cases = (
TestCase(
label="valid_stem",
pathlike=_stem(),
@@ -164,7 +167,7 @@ class TestCase(BaseRegularTestCase):
pathlike=f"sr_44100_nf_60_ws_2048_tg_0_sm_fft_ch_abc",
expected=ValueError,
),
- ]
+ )
@pytest.mark.parametrize("test_case", test_cases, ids=lambda tc: tc.label)
def test_create(self, test_case: TestCase) -> None:
@@ -190,7 +193,7 @@ class TestCase(BaseRegularTestCase):
fields: InstructionsFilenameFields
expected: str
- test_cases = [
+ test_cases = (
TestCase(
label="standard",
fields=_fields(),
@@ -206,7 +209,7 @@ class TestCase(BaseRegularTestCase):
fields=_fields(sm="cqt"),
expected=_stem(sm="cqt"),
),
- ]
+ )
@pytest.mark.parametrize("test_case", test_cases, ids=lambda tc: tc.label)
def test_round_trip(self, test_case: TestCase) -> None:
diff --git a/tests/unit/sampletones_core/library/test_data.py b/tests/unit/sampletones_core/library/test_data.py
index aa518334..4f3f70d6 100644
--- a/tests/unit/sampletones_core/library/test_data.py
+++ b/tests/unit/sampletones_core/library/test_data.py
@@ -59,7 +59,7 @@ class TestLoadFileAccess(BaseTestSuite):
class TestCase(BaseRegularTestCase):
make_path: Callable[[Path], Path]
- test_cases = [
+ test_cases = (
TestCase(
label="missing_file",
make_path=lambda root: root / "fake.ins",
@@ -70,7 +70,7 @@ class TestCase(BaseRegularTestCase):
make_path=lambda root: root,
expected=DIRECTORY_READ_ERRORS,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -112,7 +112,7 @@ class TestLoadWrapping(BaseTestSuite):
class TestCase(BaseRegularTestCase):
side_effect: Exception
- test_cases = [
+ test_cases = (
TestCase(
label="invalid_values_wrapped",
side_effect=TypeError("bad field"),
@@ -128,7 +128,7 @@ class TestCase(BaseRegularTestCase):
side_effect=DeserializationError("missing getter"),
expected=DeserializationError,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_core/project/patterns/test_channel.py b/tests/unit/sampletones_core/project/patterns/test_channel.py
index 9d2998fe..bf55412c 100644
--- a/tests/unit/sampletones_core/project/patterns/test_channel.py
+++ b/tests/unit/sampletones_core/project/patterns/test_channel.py
@@ -17,7 +17,7 @@ def test_add_pattern_appends_with_requested_length(self) -> None:
assert index in channel.patterns
assert channel.pattern(index).length == 8
- def test_duplicate_pattern_copies_rows_with_new_identity(self) -> None:
+ def test_clone_pattern_copies_rows_with_new_identity(self) -> None:
channel = _channel()
source = channel.patterns[0]
source.rows[0] = Row(
@@ -25,17 +25,17 @@ def test_duplicate_pattern_copies_rows_with_new_identity(self) -> None:
volume=10,
)
- clone_index = channel.duplicate_pattern(0)
+ clone_index = channel.clone_pattern(0)
clone = channel.pattern(clone_index)
assert clone_index != 0
assert clone is not source
assert clone.rows[0] == source.rows[0]
- def test_duplicate_pattern_avoids_reserved_indices(self) -> None:
+ def test_clone_pattern_avoids_reserved_indices(self) -> None:
channel = _channel()
- clone_index = channel.duplicate_pattern(0, reserved_indices={1, 2})
+ clone_index = channel.clone_pattern(0, reserved_indices={1, 2})
assert clone_index == 3
diff --git a/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py
index 1ede6558..22051afa 100644
--- a/tests/unit/sampletones_core/project/test_container.py
+++ b/tests/unit/sampletones_core/project/test_container.py
@@ -6,6 +6,7 @@
import pytest
from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.data import Metadata
from sampletones_core.project.container import ProjectContainer
from sampletones_core.project.instruments.instrument import Instrument
from sampletones_core.project.instruments.sample import Sample
@@ -27,10 +28,6 @@
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"
-)
-
def _rewrite_format_version(source: Path, target: Path, *, format_version: str) -> None:
with zipfile.ZipFile(source, "r") as archive:
@@ -99,7 +96,10 @@ def test_full_round_trip(
loaded_song = loaded.song
assert loaded_song.order == project.song.order
pulse1_index_at_0 = loaded_song.order[0].get(GeneratorName.PULSE1)
- first_pattern = loaded_song.pattern(GeneratorName.PULSE1, pulse1_index_at_0)
+ first_pattern = loaded_song.pattern(
+ GeneratorName.PULSE1,
+ pulse1_index_at_0,
+ )
assert first_pattern.name == "intro"
row = first_pattern.rows[0]
assert row.transpose == 0
@@ -190,22 +190,34 @@ def test_round_trip_without_instruments(self, tmp_path: Path) -> None:
class TestLoadRejectsInvalidArchives:
- def test_missing_file_raises_file_not_found(self, tmp_path: Path) -> None:
+ 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_directory_read_error(self, tmp_path: Path) -> None:
+ 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:
+ def test_non_zip_raises_not_a_valid_archive(
+ self,
+ tmp_path: Path,
+ ) -> None:
path = tmp_path / "broken.stp"
path.write_bytes(b"this is not a zip archive")
with pytest.raises(NotAValidArchiveError):
ProjectContainer.load(path)
- def test_missing_document_raises_missing_data_file(self, tmp_path: Path) -> None:
+ def test_missing_document_raises_missing_data_file(
+ self,
+ tmp_path: Path,
+ ) -> None:
path = tmp_path / "nodoc.stp"
with zipfile.ZipFile(path, "w") as archive:
archive.writestr("other.txt", "hello")
@@ -213,7 +225,10 @@ def test_missing_document_raises_missing_data_file(self, tmp_path: Path) -> None
with pytest.raises(MissingProjectDataFileError):
ProjectContainer.load(path)
- def test_malformed_document_raises_invalid_values(self, tmp_path: Path) -> None:
+ def test_malformed_document_raises_invalid_values(
+ self,
+ tmp_path: Path,
+ ) -> None:
path = tmp_path / "baddoc.stp"
with zipfile.ZipFile(path, "w") as archive:
archive.writestr(PROJECT_DOCUMENT_NAME, b"{ not valid json")
@@ -254,11 +269,18 @@ def test_missing_reconstruction_reference_raises_missing_data_file(
with pytest.raises(MissingProjectDataFileError):
ProjectContainer.load(stripped)
- def test_unexpected_error_wrapped_as_unhandled(self, tmp_path: Path) -> None:
+ def test_unexpected_error_wrapped_as_unhandled(
+ self,
+ tmp_path: Path,
+ ) -> None:
path = tmp_path / "demo.stp"
ProjectContainer.save(Project.create(title="Demo"), path)
- with patch.object(ProjectContainer, "_build_project", side_effect=RuntimeError("runtime_error")):
+ with patch.object(
+ ProjectContainer,
+ "_build_project",
+ side_effect=RuntimeError("runtime_error"),
+ ):
with pytest.raises(UnhandledProjectError):
ProjectContainer.load(path)
@@ -286,10 +308,13 @@ def test_incompatible_embedded_reconstruction_version_rejected(
tmp_path: Path,
reconstruction_factory: ReconstructionFactory,
) -> None:
+ """A project carrying a reconstruction from another build is refused as it opens."""
project = _populated_project(reconstruction_factory)
+ project.samples[0].reconstruction = project.samples[0].reconstruction.model_copy(
+ update={"metadata": Metadata(reconstruction_data_version="0.0")},
+ )
path = tmp_path / "demo.stp"
ProjectContainer.save(project, path)
- with patch(_RECONSTRUCTION_VERSION_CONSTANT, "9.0"):
- with pytest.raises(IncorrectReconstructionDataError):
- ProjectContainer.load(path)
+ with pytest.raises(IncorrectReconstructionDataError):
+ ProjectContainer.load(path)
diff --git a/tests/unit/sampletones_core/project/test_models.py b/tests/unit/sampletones_core/project/test_models.py
index 2d595842..b48949bc 100644
--- a/tests/unit/sampletones_core/project/test_models.py
+++ b/tests/unit/sampletones_core/project/test_models.py
@@ -63,12 +63,12 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"transpose={self.expected.transpose}_command={self.expected.command is not None}"
- test_cases = [
+ test_cases = (
TestCase(expected=Row()),
TestCase(expected=Row(transpose=0, volume=15)),
TestCase(expected=Row(transpose=12, command=_instrument(), volume=8)),
TestCase(expected=Row(command=NoteOff())),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_core/project/test_serialization.py b/tests/unit/sampletones_core/project/test_serialization.py
index 0f5e2a6e..3883e2a8 100644
--- a/tests/unit/sampletones_core/project/test_serialization.py
+++ b/tests/unit/sampletones_core/project/test_serialization.py
@@ -7,7 +7,10 @@
from sampletones_core.project.patterns.pattern import Pattern
from sampletones_core.project.patterns.row import Row
from sampletones_core.project.song import Song
-from sampletones_shared.constants.project import MAX_ROWS_PER_PATTERN, MIN_ROWS_PER_PATTERN
+from sampletones_shared.constants.project import (
+ MAX_ROWS_PER_PATTERN,
+ MIN_ROWS_PER_PATTERN,
+)
def _pattern_with_instrument() -> Pattern:
diff --git a/tests/unit/sampletones_core/project/test_settings.py b/tests/unit/sampletones_core/project/test_settings.py
index 9a2bccc8..fb74bb52 100644
--- a/tests/unit/sampletones_core/project/test_settings.py
+++ b/tests/unit/sampletones_core/project/test_settings.py
@@ -3,14 +3,18 @@
import pytest
from pydantic import ValidationError
-from sampletones_core.constants.general import (
+from sampletones_core.project.settings import ProjectSettings
+from sampletones_shared.constants.nes import (
MAX_NES_FREQUENCY,
MIN_NES_FREQUENCY,
)
-from sampletones_core.project.settings import ProjectSettings
from sampletones_shared.constants.project import (
+ DEFAULT_FIRST_HIGHLIGHT,
+ DEFAULT_SECOND_HIGHLIGHT,
+ MAX_HIGHLIGHT,
MAX_SPEED,
MAX_TEMPO,
+ MIN_HIGHLIGHT,
MIN_SPEED,
MIN_TEMPO,
)
@@ -30,27 +34,115 @@ def label(self) -> str:
verdict = "valid" if self.expected else "invalid"
return f"{self.field}={self.value}_{verdict}"
- test_cases = [
- TestCase(field="nes_frequency", value=MIN_NES_FREQUENCY, expected=True),
- TestCase(field="nes_frequency", value=MAX_NES_FREQUENCY, expected=True),
- TestCase(field="nes_frequency", value=MIN_NES_FREQUENCY - 1, expected=False),
- TestCase(field="nes_frequency", value=MAX_NES_FREQUENCY + 1, expected=False),
- TestCase(field="tempo", value=MIN_TEMPO, expected=True),
- TestCase(field="tempo", value=MAX_TEMPO, expected=True),
- TestCase(field="tempo", value=MIN_TEMPO - 1, expected=False),
- TestCase(field="tempo", value=MAX_TEMPO + 1, expected=False),
- TestCase(field="speed", value=MIN_SPEED, expected=True),
- TestCase(field="speed", value=MAX_SPEED, expected=True),
- TestCase(field="speed", value=MIN_SPEED - 1, expected=False),
- TestCase(field="speed", value=MAX_SPEED + 1, expected=False),
- ]
+ test_cases = (
+ TestCase(
+ field="nes_frequency",
+ value=MIN_NES_FREQUENCY,
+ expected=True,
+ ),
+ TestCase(
+ field="nes_frequency",
+ value=MAX_NES_FREQUENCY,
+ expected=True,
+ ),
+ TestCase(
+ field="nes_frequency",
+ value=MIN_NES_FREQUENCY - 1,
+ expected=False,
+ ),
+ TestCase(
+ field="nes_frequency",
+ value=MAX_NES_FREQUENCY + 1,
+ expected=False,
+ ),
+ TestCase(
+ field="tempo",
+ value=MIN_TEMPO,
+ expected=True,
+ ),
+ TestCase(
+ field="tempo",
+ value=MAX_TEMPO,
+ expected=True,
+ ),
+ TestCase(
+ field="tempo",
+ value=MIN_TEMPO - 1,
+ expected=False,
+ ),
+ TestCase(
+ field="tempo",
+ value=MAX_TEMPO + 1,
+ expected=False,
+ ),
+ TestCase(
+ field="speed",
+ value=MIN_SPEED,
+ expected=True,
+ ),
+ TestCase(
+ field="speed",
+ value=MAX_SPEED,
+ expected=True,
+ ),
+ TestCase(
+ field="speed",
+ value=MIN_SPEED - 1,
+ expected=False,
+ ),
+ TestCase(
+ field="speed",
+ value=MAX_SPEED + 1,
+ expected=False,
+ ),
+ TestCase(
+ field="first_highlight",
+ value=MIN_HIGHLIGHT,
+ expected=True,
+ ),
+ TestCase(
+ field="first_highlight",
+ value=MAX_HIGHLIGHT,
+ expected=True,
+ ),
+ TestCase(
+ field="first_highlight",
+ value=MIN_HIGHLIGHT - 1,
+ expected=False,
+ ),
+ TestCase(
+ field="first_highlight",
+ value=MAX_HIGHLIGHT + 1,
+ expected=False,
+ ),
+ TestCase(
+ field="second_highlight",
+ value=MIN_HIGHLIGHT,
+ expected=True,
+ ),
+ TestCase(
+ field="second_highlight",
+ value=MAX_HIGHLIGHT,
+ expected=True,
+ ),
+ TestCase(
+ field="second_highlight",
+ value=MIN_HIGHLIGHT - 1,
+ expected=False,
+ ),
+ TestCase(
+ field="second_highlight",
+ value=MAX_HIGHLIGHT + 1,
+ expected=False,
+ ),
+ )
@pytest.mark.parametrize(
"test_case",
test_cases,
ids=lambda test_case: test_case.label,
)
- def test_bounds(self, test_case: "TestBounds.TestCase") -> None:
+ def test_bounds(self, test_case: TestCase) -> None:
kwargs = {test_case.field: test_case.value}
if test_case.expected:
settings = ProjectSettings(**kwargs)
@@ -76,6 +168,24 @@ def test_round_trip(self) -> None:
restored = ProjectSettings.model_validate(settings.model_dump())
assert restored == settings
+ def test_highlights_round_trip(self) -> None:
+ settings = ProjectSettings(first_highlight=3, second_highlight=12)
+ restored = ProjectSettings.model_validate(settings.model_dump())
+ assert (restored.first_highlight, restored.second_highlight) == (3, 12)
+
+ def test_settings_without_highlights_load_on_common_time(self) -> None:
+ """A project saved before the highlights existed reads as the 4/16 grouping it was played in."""
+ document = ProjectSettings(tempo=120).model_dump()
+ del document["first_highlight"]
+ del document["second_highlight"]
+
+ restored = ProjectSettings.model_validate(document)
+
+ assert (restored.first_highlight, restored.second_highlight) == (
+ DEFAULT_FIRST_HIGHLIGHT,
+ DEFAULT_SECOND_HIGHLIGHT,
+ )
+
def test_json_round_trip(self) -> None:
settings = ProjectSettings(nes_frequency=50)
restored = ProjectSettings.model_validate_json(settings.model_dump_json())
diff --git a/tests/unit/sampletones_core/project/test_song.py b/tests/unit/sampletones_core/project/test_song.py
index 4313d67c..c6a38264 100644
--- a/tests/unit/sampletones_core/project/test_song.py
+++ b/tests/unit/sampletones_core/project/test_song.py
@@ -168,38 +168,105 @@ def test_duplicate_inserts_frame_after_position(self) -> None:
assert song.order_length() == 3
assert song.order[2][GeneratorName.PULSE1] == 3
- def test_duplicate_points_channels_at_fresh_patterns(self) -> None:
+ def test_duplicate_points_channels_at_the_same_patterns(self) -> None:
song = _song()
song.duplicate_frame(0)
source_index = song.order[0][GeneratorName.PULSE1]
duplicate_index = song.order[1][GeneratorName.PULSE1]
- assert duplicate_index != source_index
+ assert duplicate_index == source_index
- def test_duplicate_avoids_indices_referenced_by_other_frames(self) -> None:
+ def test_duplicate_allocates_no_pattern(self) -> None:
song = _song()
- song.append_frame()
- song.set_order_entry(1, GeneratorName.PULSE1, 7)
+ pattern_count = len(song[GeneratorName.PULSE1].patterns)
song.duplicate_frame(0)
+ assert len(song[GeneratorName.PULSE1].patterns) == pattern_count
+
+ def test_editing_a_shared_pattern_is_heard_in_both_frames(self) -> None:
+ song = _song()
+ song.duplicate_frame(0)
duplicate_index = song.order[1][GeneratorName.PULSE1]
- assert duplicate_index != 7
+ assert duplicate_index is not None
+
+ _place_instrument(song, GeneratorName.PULSE1, "sample-a", row_index=0)
+
+ shared_pattern = song.pattern(GeneratorName.PULSE1, duplicate_index)
+ assert shared_pattern is not None
+ assert shared_pattern.rows[0].command is not None
- def test_editing_duplicated_pattern_leaves_the_source_untouched(self) -> None:
+ def test_repointing_one_frame_leaves_the_other_where_it_was(self) -> None:
+ """The copy is a fresh mapping, so the two frames' slots move independently."""
+ song = _song()
+ song.duplicate_frame(0)
+
+ song.set_order_entry(1, GeneratorName.PULSE1, 9)
+
+ assert song.order[0][GeneratorName.PULSE1] == 0
+
+ def test_duplicate_carries_an_unmaterialised_index_across(self) -> None:
+ song = _song()
+ song.set_order_entry(0, GeneratorName.PULSE1, 7)
+
+ song.duplicate_frame(0)
+
+ assert song.order[1][GeneratorName.PULSE1] == 7
+ assert song.pattern(GeneratorName.PULSE1, 7) is None
+
+
+class TestSongCloneFrame:
+ def test_clone_inserts_frame_after_position(self) -> None:
+ song = _song()
+ song.append_frame()
+ song.set_order_entry(1, GeneratorName.PULSE1, 3)
+
+ song.clone_frame(0)
+
+ assert song.order_length() == 3
+ assert song.order[2][GeneratorName.PULSE1] == 3
+
+ def test_clone_points_channels_at_fresh_patterns(self) -> None:
+ song = _song()
+
+ song.clone_frame(0)
+
+ source_index = song.order[0][GeneratorName.PULSE1]
+ clone_index = song.order[1][GeneratorName.PULSE1]
+ assert clone_index != source_index
+
+ def test_clone_avoids_indices_referenced_by_other_frames(self) -> None:
+ song = _song()
+ song.append_frame()
+ song.set_order_entry(1, GeneratorName.PULSE1, 7)
+
+ song.clone_frame(0)
+
+ clone_index = song.order[1][GeneratorName.PULSE1]
+ assert clone_index != 7
+
+ def test_editing_a_cloned_pattern_leaves_the_source_untouched(self) -> None:
song = _song()
_place_instrument(song, GeneratorName.PULSE1, "sample-a", row_index=0)
source_index = song.order[0][GeneratorName.PULSE1]
- song.duplicate_frame(0)
- duplicate_index = song.order[1][GeneratorName.PULSE1]
- song[GeneratorName.PULSE1].set_row(duplicate_index, 0, Row())
+ song.clone_frame(0)
+ clone_index = song.order[1][GeneratorName.PULSE1]
+ song[GeneratorName.PULSE1].set_row(clone_index, 0, Row())
source_pattern = song.pattern(GeneratorName.PULSE1, source_index)
assert source_pattern is not None
assert source_pattern.rows[0].command is not None
+ def test_clone_keeps_a_silent_slot_silent(self) -> None:
+ song = _song()
+ song.set_order_entry(0, GeneratorName.NOISE, None)
+
+ song.clone_frame(0)
+
+ assert song.order[1][GeneratorName.NOISE] is None
+
class TestSongPatternAllocation:
def test_add_pattern_skips_indices_referenced_by_the_order(self) -> None:
@@ -211,12 +278,12 @@ def test_add_pattern_skips_indices_referenced_by_the_order(self) -> None:
assert index != 4
assert index in song[GeneratorName.PULSE1].patterns
- def test_duplicate_pattern_skips_indices_referenced_by_the_order(self) -> None:
+ def test_clone_pattern_skips_indices_referenced_by_the_order(self) -> None:
song = _song()
song.append_frame()
song.set_order_entry(1, GeneratorName.PULSE1, 6)
- clone_index = song.duplicate_pattern(GeneratorName.PULSE1, 0)
+ clone_index = song.clone_pattern(GeneratorName.PULSE1, 0)
assert clone_index != 6
diff --git a/tests/unit/sampletones_core/project/test_song_position.py b/tests/unit/sampletones_core/project/test_song_position.py
index d0343ddc..cc8da216 100644
--- a/tests/unit/sampletones_core/project/test_song_position.py
+++ b/tests/unit/sampletones_core/project/test_song_position.py
@@ -42,7 +42,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return str(self.expected)
- test_cases = [
+ test_cases = (
TestCase(
expected="mid_pattern_increments_row",
start_order=0,
@@ -79,7 +79,7 @@ def label(self) -> str:
expected_order=1,
expected_row=0,
),
- ]
+ )
@pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label)
def test_advance(self, test_case: TestCase) -> None:
@@ -130,7 +130,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return str(self.expected)
- test_cases = [
+ test_cases = (
TestCase(
expected="row_within_pattern_is_unchanged",
start_order=0,
@@ -163,7 +163,7 @@ def label(self) -> str:
expected_order=1,
expected_row=0,
),
- ]
+ )
@pytest.mark.parametrize("test_case", test_cases, ids=lambda case: case.label)
def test_wrap_overflow(self, test_case: TestCase) -> None:
diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py
index b3040208..ce127850 100644
--- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py
+++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py
@@ -3,10 +3,10 @@
from sampletones_core.configs import Config
from sampletones_core.configs.display import (
DISPLAY_SEPARATOR,
- GAMMA_PREFIX,
format_nes_frequency,
format_sample_rate,
format_spectrum_method,
+ format_transformation_gamma,
)
from sampletones_core.constants.enums import GeneratorName, abbreviate_generator_names
from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
@@ -89,6 +89,6 @@ def test_display_name_combines_formatted_parts(self, config: Config) -> None:
assert format_sample_rate(config.library.sample_rate) in display
assert format_nes_frequency(config.library.nes_frequency) in display
assert format_spectrum_method(config.library.spectrum_method) in display
- assert f"{GAMMA_PREFIX}{config.library.transformation_gamma}" in display
+ assert format_transformation_gamma(config.library.transformation_gamma) in display
assert abbreviate_generator_names(list(config.generation.generators)) in display
assert DISPLAY_SEPARATOR in display
diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py
index 4c38eec0..6955b77c 100644
--- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py
+++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py
@@ -4,13 +4,13 @@
import pytest
from sampletones_core.configs import Config
-from sampletones_core.paths import EXT_FILE_RECONSTRUCTION
from sampletones_core.reconstructions.converter.paths import (
filter_files,
get_audio_files,
get_output_path,
get_relative_path,
)
+from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION
@pytest.fixture(scope="module")
diff --git a/tests/unit/sampletones_core/reconstructions/criterion/test_criterion.py b/tests/unit/sampletones_core/reconstructions/criterion/test_criterion.py
index b56ba5df..5cf9168e 100644
--- a/tests/unit/sampletones_core/reconstructions/criterion/test_criterion.py
+++ b/tests/unit/sampletones_core/reconstructions/criterion/test_criterion.py
@@ -176,7 +176,13 @@ def test_spectral_loss_shape_matches_candidate_count(
for distance in SpectralDistance:
criterion = _criterion_with_distance(config, window, distance)
reference = np.linspace(0.1, 1.0, bins, dtype=np.float32)
- candidates = np.stack([reference, np.full(bins, 0.3, dtype=np.float32), np.zeros(bins, dtype=np.float32)])
+ candidates = np.stack(
+ [
+ reference,
+ np.full(bins, 0.3, dtype=np.float32),
+ np.zeros(bins, dtype=np.float32),
+ ]
+ )
loss = criterion.spectral_loss(reference, candidates)
assert to_numpy(loss).shape == (3,)
diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py
index df570d85..fe7f269e 100644
--- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py
+++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py
@@ -7,13 +7,16 @@
import pytest
from sampletones_core.configs import Config
-from sampletones_core.constants.enums import GeneratorName
+from sampletones_core.constants.enums import FeatureKey, GeneratorName
from sampletones_core.data import Metadata
+from sampletones_core.features import resting_reference
from sampletones_core.instructions import PulseInstruction
from sampletones_core.reconstructions import Reconstruction
+from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem
from sampletones_shared.application import (
SAMPLETONES_RECONSTRUCTION_DATA_VERSION,
)
+from sampletones_shared.constants.nes import DEFAULT_NES_FREQUENCY
from sampletones_shared.exceptions import (
DeserializationError,
IncompatibleReconstructionVersionError,
@@ -28,8 +31,8 @@
from tests.suite.case import BaseRegularTestCase
from tests.suite.errors import DIRECTORY_READ_ERRORS
-_RETUNED_FREQUENCY: Final[int] = 60
-_FASTER_FREQUENCY: Final[int] = 120
+_RETUNED_FREQUENCY: Final[int] = DEFAULT_NES_FREQUENCY // 2
+_FASTER_FREQUENCY: Final[int] = DEFAULT_NES_FREQUENCY * 2
_AUDIO_LENGTH: Final[int] = 64
_BASE_PITCH: Final[int] = 60
@@ -53,6 +56,20 @@ def _reconstruction(instructions: List[PulseInstruction]) -> Reconstruction:
)
+def _saved_playing_channels_only(path: Path) -> Path:
+ """Writes a reconstruction the way a file saved before the channel set holds one.
+
+ Such a file names a stream for the channels it plays, leaving the rest to be filled in
+ on the way back.
+ """
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
+ reconstruction.instructions_data = [
+ item for item in reconstruction.instructions_data if item.generator_name == GeneratorName.PULSE1
+ ]
+ reconstruction.save(path)
+ return path
+
+
class TestRoundTrip:
def test_save_load_round_trip(
self,
@@ -119,7 +136,7 @@ class TestLoadFileAccess(BaseTestSuite):
class TestCase(BaseRegularTestCase):
make_path: Callable[[Path], Path]
- test_cases = [
+ test_cases = (
TestCase(
label="missing_file",
make_path=lambda root: root / "nope.stn",
@@ -130,7 +147,7 @@ class TestCase(BaseRegularTestCase):
make_path=lambda root: root,
expected=DIRECTORY_READ_ERRORS,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -188,7 +205,7 @@ class TestDeserializeDataWrapping(BaseTestSuite):
class TestCase(BaseRegularTestCase):
side_effect: Exception
- test_cases = [
+ test_cases = (
TestCase(
label="unexpected_wrapped_as_unhandled",
side_effect=RuntimeError("runtime_error"),
@@ -199,7 +216,7 @@ class TestCase(BaseRegularTestCase):
side_effect=DeserializationError("missing getter"),
expected=DeserializationError,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -236,12 +253,17 @@ def test_export_measures_the_arpeggio_against_the_stored_reference(self) -> None
the stored reference and reads the octave straight back.
"""
reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
- arpeggiated = [_pulse(_BASE_PITCH + _OCTAVE), _pulse(_BASE_PITCH), _pulse(_BASE_PITCH)]
+ arpeggiated = [
+ _pulse(_BASE_PITCH + _OCTAVE),
+ _pulse(_BASE_PITCH),
+ _pulse(_BASE_PITCH),
+ ]
reconstruction.update_generator_data(
GeneratorName.PULSE1,
arpeggiated,
np.ones(_AUDIO_LENGTH, dtype=np.float32),
_BASE_PITCH,
+ (),
)
features = reconstruction.export()[GeneratorName.PULSE1]
@@ -257,6 +279,7 @@ def test_update_generator_data_replaces_the_reference(self) -> None:
[_pulse(_RESET_PITCH)],
np.ones(_AUDIO_LENGTH, dtype=np.float32),
_RESET_PITCH,
+ (),
)
assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _RESET_PITCH
@@ -271,6 +294,223 @@ def test_reference_survives_a_save_load_round_trip(self, tmp_path: Path) -> None
assert loaded.initial_pitches == reconstruction.initial_pitches
+class TestHeldFeatures:
+ """The dimensions each generator leaves to the channel travel with its instructions.
+
+ A frame states every dimension, so an export reads which of them the instrument itself
+ wrote from the reconstruction rather than from the frames.
+ """
+
+ def test_a_fresh_reconstruction_writes_every_dimension(self) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
+
+ assert reconstruction.held_features[GeneratorName.PULSE1] == ()
+
+ def test_a_held_dimension_exports_an_empty_envelope(self) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
+
+ reconstruction.update_generator_data(
+ GeneratorName.PULSE1,
+ [_pulse(_BASE_PITCH)] * 3,
+ np.ones(_AUDIO_LENGTH, dtype=np.float32),
+ _BASE_PITCH,
+ (FeatureKey.ARPEGGIO,),
+ )
+
+ features = reconstruction.export()[GeneratorName.PULSE1]
+ assert features.arpeggio.size == 0
+ assert features.volume.size > 0
+
+ def test_the_written_dimensions_export_their_items(self) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
+
+ reconstruction.update_generator_data(
+ GeneratorName.PULSE1,
+ [_pulse(_BASE_PITCH)] * 3,
+ np.ones(_AUDIO_LENGTH, dtype=np.float32),
+ _BASE_PITCH,
+ (FeatureKey.ARPEGGIO,),
+ )
+
+ features = reconstruction.export()[GeneratorName.PULSE1]
+ assert features.duty_cycle is not None
+ assert features.duty_cycle.size > 0
+
+ def test_the_record_reads_back_off_the_exported_envelopes(self) -> None:
+ """What a reconstruction says it holds is what its export shows, on every channel.
+
+ The record is the only place an empty envelope's meaning is kept, so a channel in play
+ and one standing by both have to state the dimensions their export leaves empty.
+ """
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
+
+ reconstruction.update_generator_data(
+ GeneratorName.PULSE1,
+ [_pulse(_BASE_PITCH)] * 3,
+ np.ones(_AUDIO_LENGTH, dtype=np.float32),
+ _BASE_PITCH,
+ (FeatureKey.ARPEGGIO,),
+ )
+
+ exported = reconstruction.export()
+ assert reconstruction.held_features == {
+ generator_name: features.held_features for generator_name, features in exported.items()
+ }
+
+ def test_a_channel_standing_by_leaves_every_dimension_it_offers(self) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
+
+ assert reconstruction.held_features[GeneratorName.TRIANGLE] == (
+ FeatureKey.VOLUME,
+ FeatureKey.ARPEGGIO,
+ )
+ assert reconstruction.held_features[GeneratorName.NOISE] == (
+ FeatureKey.VOLUME,
+ FeatureKey.ARPEGGIO,
+ FeatureKey.DUTY_CYCLE,
+ )
+
+ def test_clearing_the_last_frame_records_what_standing_by_records(self) -> None:
+ """A channel edited out of play reads the same as one that never played."""
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
+
+ reconstruction.update_generator_data(
+ GeneratorName.PULSE1,
+ [],
+ np.zeros(0, dtype=np.float32),
+ resting_reference(GeneratorName.PULSE1),
+ (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE),
+ )
+
+ assert reconstruction.streams[GeneratorName.PULSE1] == InstructionsItem.resting(GeneratorName.PULSE1)
+
+ def test_held_dimensions_survive_a_save_load_round_trip(self, tmp_path: Path) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
+ reconstruction.update_generator_data(
+ GeneratorName.PULSE1,
+ [_pulse(_BASE_PITCH)] * 3,
+ np.ones(_AUDIO_LENGTH, dtype=np.float32),
+ _BASE_PITCH,
+ (FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE),
+ )
+ path = tmp_path / "held.stn"
+
+ reconstruction.save(path)
+ loaded = Reconstruction.load(path)
+
+ assert loaded.held_features == reconstruction.held_features
+
+
+class TestChannelSet:
+ """A reconstruction holds every channel, so one that stands by stays editable.
+
+ An instruction stream describing no frame is what a channel standing by looks like: it
+ exports empty envelopes, costs nothing, and gaining a frame is what puts it in play.
+ """
+
+ def test_a_fresh_reconstruction_holds_every_channel(self) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
+
+ assert set(reconstruction.instructions) == set(GeneratorName.items())
+ assert reconstruction.playing_generators == (GeneratorName.PULSE1,)
+
+ def test_a_channel_standing_by_rests_at_the_shared_reference(self) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
+
+ assert reconstruction.initial_pitches[GeneratorName.TRIANGLE] == resting_reference(GeneratorName.TRIANGLE)
+ assert reconstruction.initial_pitches[GeneratorName.NOISE] == resting_reference(GeneratorName.NOISE)
+
+ def test_a_channel_standing_by_exports_empty_envelopes(self) -> None:
+ features = _reconstruction([_pulse(_BASE_PITCH)]).export()[GeneratorName.PULSE2]
+
+ assert not features.has_frames
+ assert features.volume.size == 0
+ assert features.arpeggio.size == 0
+
+ def test_a_channel_standing_by_renders_no_audio(self) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
+
+ assert GeneratorName.PULSE2 not in reconstruction.approximations
+
+ def test_clearing_every_frame_keeps_the_channel(self) -> None:
+ """Taking a channel out of play leaves its stream in place, so the edit is reversible."""
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3)
+
+ reconstruction.update_generator_data(
+ GeneratorName.PULSE1,
+ [],
+ np.zeros(0, dtype=np.float32),
+ _BASE_PITCH,
+ (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE),
+ )
+
+ assert reconstruction.playing_generators == ()
+ assert GeneratorName.PULSE1 in reconstruction.instructions
+ assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _BASE_PITCH
+ assert not reconstruction.export()[GeneratorName.PULSE1].has_frames
+
+ def test_a_frame_puts_a_channel_standing_by_into_play(self) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
+
+ reconstruction.update_generator_data(
+ GeneratorName.PULSE2,
+ [_pulse(_BASE_PITCH)] * 2,
+ np.ones(_AUDIO_LENGTH, dtype=np.float32),
+ _BASE_PITCH,
+ (),
+ )
+
+ assert reconstruction.playing_generators == (GeneratorName.PULSE1, GeneratorName.PULSE2)
+ assert reconstruction.export()[GeneratorName.PULSE2].has_frames
+ assert GeneratorName.PULSE2 in reconstruction.approximations
+
+ def test_a_reconstruction_of_channels_standing_by_stays_valid(self) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
+
+ reconstruction.update_generator_data(
+ GeneratorName.PULSE1,
+ [],
+ np.zeros(0, dtype=np.float32),
+ _BASE_PITCH,
+ (),
+ )
+
+ assert reconstruction.approximations == {}
+ assert reconstruction.approximation.size == 0
+
+ def test_the_channel_set_survives_a_save_load_round_trip(self, tmp_path: Path) -> None:
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
+ path = tmp_path / "channels.stn"
+
+ reconstruction.save(path)
+ loaded = Reconstruction.load(path)
+
+ assert set(loaded.instructions) == set(GeneratorName.items())
+ assert loaded.playing_generators == reconstruction.playing_generators
+ assert loaded.initial_pitches == reconstruction.initial_pitches
+
+ def test_a_file_storing_fewer_streams_reads_as_the_whole_channel_set(self, tmp_path: Path) -> None:
+ loaded = Reconstruction.load(_saved_playing_channels_only(tmp_path / "one_channel.stn"))
+
+ assert set(loaded.instructions) == set(GeneratorName.items())
+ assert loaded.playing_generators == (GeneratorName.PULSE1,)
+ assert loaded.initial_pitches[GeneratorName.NOISE] == resting_reference(GeneratorName.NOISE)
+ assert not loaded.export()[GeneratorName.TRIANGLE].has_frames
+
+ def test_editing_such_a_file_writes_the_whole_channel_set(self, tmp_path: Path) -> None:
+ loaded = Reconstruction.load(_saved_playing_channels_only(tmp_path / "one_channel.stn"))
+
+ loaded.update_generator_data(
+ GeneratorName.PULSE2,
+ [_pulse(_BASE_PITCH)],
+ np.ones(_AUDIO_LENGTH, dtype=np.float32),
+ _BASE_PITCH,
+ (),
+ )
+
+ assert [item.generator_name for item in loaded.instructions_data] == list(GeneratorName.items())
+
+
class TestWithNesFrequency:
def test_rebuilds_config(self, reconstruction_factory: ReconstructionFactory) -> None:
reconstruction = reconstruction_factory()
@@ -309,6 +549,37 @@ def test_leaves_original_untouched(self, reconstruction_factory: ReconstructionF
assert reconstruction.config.nes_frequency == original_frequency
assert len(reconstruction.approximation) == original_length
+ def test_a_channel_standing_by_stays_standing_by(
+ self,
+ reconstruction_factory: ReconstructionFactory,
+ ) -> None:
+ """A channel describing no frame renders nothing, so a retuned copy holds audio for the rest."""
+ reconstruction = reconstruction_factory()
+
+ retuned = reconstruction.with_nes_frequency(_RETUNED_FREQUENCY)
+
+ assert set(retuned.approximations) == {GeneratorName.PULSE1}
+ assert set(retuned.instructions) == set(GeneratorName.items())
+ assert retuned.playing_generators == (GeneratorName.PULSE1,)
+
+ def test_a_reconstruction_of_channels_standing_by_retunes_to_silence(self) -> None:
+ """Every channel standing by leaves nothing to render, and the retuned copy says so."""
+ reconstruction = _reconstruction([_pulse(_BASE_PITCH)])
+ reconstruction.update_generator_data(
+ GeneratorName.PULSE1,
+ [],
+ np.zeros(0, dtype=np.float32),
+ _BASE_PITCH,
+ (),
+ )
+
+ retuned = reconstruction.with_nes_frequency(_RETUNED_FREQUENCY)
+
+ assert retuned.config.nes_frequency == _RETUNED_FREQUENCY
+ assert retuned.approximations == {}
+ assert retuned.approximation.size == 0
+ assert retuned.playing_generators == ()
+
def test_matching_rate_returns_self(self, reconstruction_factory: ReconstructionFactory) -> None:
reconstruction = reconstruction_factory()
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py b/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py
index 8f0d90e5..b6b96838 100644
--- a/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/selector/test_viterbi.py
@@ -8,7 +8,9 @@
from sampletones_core.generators import GeneratorUnion
from sampletones_core.instructions import PulseInstruction
from sampletones_core.reconstructions.reconstructor.selector.base import ScoredCandidate
-from sampletones_core.reconstructions.reconstructor.selector.viterbi import ViterbiSelector
+from sampletones_core.reconstructions.reconstructor.selector.viterbi import (
+ ViterbiSelector,
+)
from sampletones_core.reconstructions.reconstructor.worker import ReconstructorWorker
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py
index 363b2a9c..50ec2fd1 100644
--- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_reconstructor.py
@@ -10,7 +10,9 @@
from sampletones_core.fft import Fragment, FragmentedAudio, Window
from sampletones_core.generators import MIXER_LEVELS
from sampletones_core.library import InstructionLibraryData
-from sampletones_core.reconstructions.reconstructor.approximation import ApproximationData
+from sampletones_core.reconstructions.reconstructor.approximation import (
+ ApproximationData,
+)
from sampletones_core.reconstructions.reconstructor.reconstructor import Reconstructor
from sampletones_core.reconstructions.reconstructor.state import ReconstructionState
from sampletones_shared.exceptions import NoLibraryDataError
@@ -125,8 +127,14 @@ def test_reset_clears_generator_states(
) -> None:
from sampletones_core.generators.implementation.noise import NoiseGenerator
from sampletones_core.generators.implementation.pulse import PulseGenerator
- from sampletones_core.generators.implementation.triangle import TriangleGenerator
- from sampletones_core.instructions import NoiseInstruction, PulseInstruction, TriangleInstruction
+ from sampletones_core.generators.implementation.triangle import (
+ TriangleGenerator,
+ )
+ from sampletones_core.instructions import (
+ NoiseInstruction,
+ PulseInstruction,
+ TriangleInstruction,
+ )
reconstructor = _make_reconstructor(config, library_data)
for generator in reconstructor.generators.values():
@@ -271,7 +279,9 @@ def test_returns_reconstruction_for_valid_audio_path(
tmp_path: Path,
) -> None:
from sampletones_core.audio import write_wave
- from sampletones_core.reconstructions.reconstruction.reconstruction import Reconstruction
+ from sampletones_core.reconstructions.reconstruction.reconstruction import (
+ Reconstruction,
+ )
audio_path = tmp_path / "test.wav"
audio = np.tile(synthetic_fragment.audio, 3).astype(np.float32)
diff --git a/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py b/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py
index c9af2cfe..acb46285 100644
--- a/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py
+++ b/tests/unit/sampletones_core/reconstructions/reconstructor/test_state.py
@@ -10,7 +10,9 @@
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.fft import Fragment
from sampletones_core.instructions import PulseInstruction
-from sampletones_core.reconstructions.reconstructor.approximation import ApproximationData
+from sampletones_core.reconstructions.reconstructor.approximation import (
+ ApproximationData,
+)
from sampletones_core.reconstructions.reconstructor.state import ReconstructionState
from tests.suite.case import BaseTestCase
diff --git a/tests/unit/sampletones_core/structures/histogram/test_histogram.py b/tests/unit/sampletones_core/structures/histogram/test_histogram.py
index c8c00b8f..89be8946 100644
--- a/tests/unit/sampletones_core/structures/histogram/test_histogram.py
+++ b/tests/unit/sampletones_core/structures/histogram/test_histogram.py
@@ -27,7 +27,7 @@ class TestCase(BaseRegularTestCase):
values: Any
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
edges=np.array([0.0, 1.0, 2.0], dtype=np.float64),
values=np.array([1.0, 2.0], dtype=np.float64),
@@ -255,27 +255,27 @@ class TestCase(BaseRegularTestCase):
match="edges must contain only finite values",
label="interval_unbounded_both_sides",
),
- ]
+ )
if CUPY_AVAILABLE:
- test_cases.extend(
- [
- TestCase(
- edges=np.array([0.0, 1.0, 2.0], dtype=np.float32),
- values=xp.array([1.0, 2.0], dtype=xp.float32),
- expected=TypeError,
- match="edges and values must be of the same type",
- label="edges_numpy_values_cupy_type_mismatch",
- ),
- TestCase(
- edges=xp.array([0.0, 1.0, 2.0]),
- values=np.array([1.0, 2.0]),
- expected=TypeError,
- match="edges and values must be of the same type",
- label="mismatched_types_cupy_edges_numpy_values",
- ),
- ]
- )
+ cupy_cases = [
+ TestCase(
+ edges=np.array([0.0, 1.0, 2.0], dtype=np.float32),
+ values=xp.array([1.0, 2.0], dtype=xp.float32),
+ expected=TypeError,
+ match="edges and values must be of the same type",
+ label="edges_numpy_values_cupy_type_mismatch",
+ ),
+ TestCase(
+ edges=xp.array([0.0, 1.0, 2.0]),
+ values=np.array([1.0, 2.0]),
+ expected=TypeError,
+ match="edges and values must be of the same type",
+ label="mismatched_types_cupy_edges_numpy_values",
+ ),
+ ]
+
+ test_cases = (*test_cases, *cupy_cases)
@pytest.mark.parametrize(
"test_case",
@@ -306,7 +306,7 @@ class TestCase(BaseRegularTestCase):
equal_edges: bool
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
histograms=(
Histogram(edges=np.array([0.0, 1.0, 2.0]), values=np.array([1.0, 2.0])),
@@ -396,49 +396,49 @@ class TestCase(BaseRegularTestCase):
match="At least one histogram is required",
label="no_histograms_raises",
),
- ]
+ )
if CUPY_AVAILABLE:
- test_cases.extend(
- [
- TestCase(
- histograms=(
- Histogram(
- edges=np.array([0.0, 1.0, 2.0]),
- values=np.array([1.0, 2.0]),
- ),
- Histogram(
- edges=xp.array([0.0, 1.0, 2.0]),
- values=xp.array([3.0, 4.0]),
- ),
+ cupy_cases = [
+ TestCase(
+ histograms=(
+ Histogram(
+ edges=np.array([0.0, 1.0, 2.0]),
+ values=np.array([1.0, 2.0]),
+ ),
+ Histogram(
+ edges=xp.array([0.0, 1.0, 2.0]),
+ values=xp.array([3.0, 4.0]),
),
- equal_edges=True,
- expected=TypeError,
- match="All histograms must be of the same array type",
- label="mixed_numpy_cupy_raises",
),
- TestCase(
- histograms=(
- Histogram(
- edges=xp.array([0.0, 1.0, 2.0]),
- values=xp.array([1.0, 2.0]),
- ),
- Histogram(
- edges=np.array([0.0, 1.0, 2.0]),
- values=np.array([3.0, 4.0]),
- ),
- Histogram(
- edges=np.array([0.0, 1.0, 2.0]),
- values=np.array([5.0, 6.0]),
- ),
+ equal_edges=True,
+ expected=TypeError,
+ match="All histograms must be of the same array type",
+ label="mixed_numpy_cupy_raises",
+ ),
+ TestCase(
+ histograms=(
+ Histogram(
+ edges=xp.array([0.0, 1.0, 2.0]),
+ values=xp.array([1.0, 2.0]),
+ ),
+ Histogram(
+ edges=np.array([0.0, 1.0, 2.0]),
+ values=np.array([3.0, 4.0]),
+ ),
+ Histogram(
+ edges=np.array([0.0, 1.0, 2.0]),
+ values=np.array([5.0, 6.0]),
),
- equal_edges=False,
- expected=TypeError,
- match="All histograms must be of the same array type",
- label="mixed_numpy_cupy_multiple_histograms_raises",
),
- ]
- )
+ equal_edges=False,
+ expected=TypeError,
+ match="All histograms must be of the same array type",
+ label="mixed_numpy_cupy_multiple_histograms_raises",
+ ),
+ ]
+
+ test_cases = (*test_cases, *cupy_cases)
@pytest.mark.parametrize(
"test_case",
@@ -463,7 +463,7 @@ class TestCase(BaseRegularTestCase):
arrays: Tuple[Array, ...]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(edges=np.array([0.0, 1.0, 2.0]), values=np.array([1.0, 2.0])),
arrays=(np.array([3.0, 4.0]), np.array([5.0, 6.0])),
@@ -522,7 +522,7 @@ class TestCase(BaseRegularTestCase):
expected=None,
label="matching_lengths_cupy_arrays",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -546,7 +546,7 @@ class TestCase(BaseRegularTestCase):
exponent: Union[Numeric, Array, Histogram]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
base=2.0,
exponent=-1.0,
@@ -675,10 +675,10 @@ class TestCase(BaseRegularTestCase):
match="Unsupported exponent type",
label="unsupported_exponent_type_raises",
),
- ]
+ )
if CUPY_AVAILABLE:
- test_cases.append(
+ cupy_cases = [
TestCase(
base=np.array([1.0, 2.0, 3.0]),
exponent=xp.array([1.0, 2.0, 3.0]),
@@ -686,7 +686,9 @@ class TestCase(BaseRegularTestCase):
match="Base and exponent must be of the same array type",
label="mismatched_array_modules_raises",
)
- )
+ ]
+
+ test_cases = (*test_cases, *cupy_cases)
@pytest.mark.parametrize(
"test_case",
@@ -709,7 +711,7 @@ class TestCase(BaseRegularTestCase):
expected: ModuleType
obj: Union[Histogram, Array, Numeric]
- test_cases = [
+ test_cases = (
TestCase(
obj=Histogram(edges=np.array([0.0, 1.0, 2.0]), values=np.array([1.0, 2.0])),
expected=np,
@@ -745,7 +747,7 @@ class TestCase(BaseRegularTestCase):
expected=xp,
label="histogram_cupy_returns_cupy",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -768,7 +770,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"{self.edges.dtype.name}"
- test_cases = [
+ test_cases = (
TestCase(
edges=np.array([0.0, 1.0, 4.0], dtype=np.float64),
values=np.array([1.0, 2.0], dtype=np.float64),
@@ -785,7 +787,7 @@ def label(self) -> str:
edges=np.array([42, 137, 404], dtype=np.int32),
values=np.array([1, 2], dtype=np.int32),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -820,7 +822,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"{self.edges.dtype.name}"
- test_cases = [
+ test_cases = (
TestCase(
edges=np.array([0.0, 1.0, 4.0], dtype=np.float64),
density=3.0,
@@ -857,7 +859,7 @@ def label(self) -> str:
expected=np.array([2.0, 6.0, 4.0], dtype=np.float32),
expected_densities=np.array([4.0, 4.0, 4.0], dtype=np.float32),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -882,7 +884,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return self.description
- test_cases = [
+ test_cases = (
TestCase(
histogram1=Histogram(
edges=np.array([0.0, 1.0, 2.0], dtype=np.float64),
@@ -1027,7 +1029,7 @@ def label(self) -> str:
expected=False,
description="histogram_vs_int",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1051,7 +1053,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"{self.histogram.edges.dtype.name}"
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(
edges=np.array([0.0, 1.0, 2.0], dtype=np.float64),
@@ -1076,7 +1078,7 @@ def label(self) -> str:
values=np.array([1, 2], dtype=np.int32),
),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1111,7 +1113,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"{self.histogram.edges.dtype.name}"
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(
edges=np.array([0.0, 1.0, 2.0], dtype=np.float64),
@@ -1136,7 +1138,7 @@ def label(self) -> str:
values=np.array([1, 2], dtype=np.int32),
),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1172,7 +1174,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"{self.histogram.edges.dtype.name}_len_{self.expected}"
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(
edges=np.array([0.0, 1.0, 2.0, 3.0], dtype=np.float64),
@@ -1201,7 +1203,7 @@ def label(self) -> str:
),
expected=2,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1229,7 +1231,7 @@ def label(self) -> str:
return base
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(
edges=np.array([0.0, 1.0, 3.0], dtype=np.float64),
@@ -1321,7 +1323,7 @@ def label(self) -> str:
expected=IndexError,
match="out of bounds",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1354,7 +1356,7 @@ def label(self) -> str:
return f"{dtype}_index_{self.index}_error"
return f"{dtype}_index_{self.index}"
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(
edges=np.array([0.0, 1.0, 2.0, 3.0], dtype=np.float64),
@@ -1446,7 +1448,7 @@ def label(self) -> str:
expected=IndexError,
match="out of (range|bounds)",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1485,7 +1487,7 @@ def label(self) -> str:
return base
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(
edges=np.array([0.0, 1.0, 4.0], dtype=np.float64),
@@ -1577,7 +1579,7 @@ def label(self) -> str:
expected=IndexError,
match="out of (range|bounds)",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1609,7 +1611,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"{self.histogram.edges.dtype.name}"
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(
edges=np.array([0.0, 1.0, 4.0], dtype=np.float64),
@@ -1638,7 +1640,7 @@ def label(self) -> str:
),
expected=np.array([2.0, 3.0]),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1659,7 +1661,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"{self.histogram.edges.dtype.name}"
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(
edges=np.array([0.0, 1.0, 4.0, 7.0], dtype=np.float64),
@@ -1688,7 +1690,7 @@ def label(self) -> str:
),
expected=np.array([3, 7], dtype=np.int32),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1709,7 +1711,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"{self.histogram.edges.dtype.name}"
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(
edges=np.array([0.0, 1.0, 2.0], dtype=np.float64),
@@ -1738,7 +1740,7 @@ def label(self) -> str:
),
expected=Interval(-10, 10),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1759,7 +1761,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"{self.histogram.edges.dtype.name}_{len(self.histogram.values)}bins"
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(
edges=np.array([0.0, 1.0, 4.0, 7.0], dtype=np.float64),
@@ -1802,7 +1804,7 @@ def label(self) -> str:
),
expected=np.float32(12.25),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1870,7 +1872,7 @@ class TestCase(BaseRegularTestCase):
expected: Array
histogram: Histogram
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(edges=np.array([0.0, 1.0, 2.0]), values=np.array([1.0, 2.0])),
expected_edges=xp.array([0.0, 1.0, 2.0]),
@@ -1892,7 +1894,7 @@ class TestCase(BaseRegularTestCase):
expected=xp.array([2.0, 4.0, 6.0, 8.0]),
label="larger_numpy_histogram_converts_to_cupy",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1915,7 +1917,7 @@ class TestCase(BaseRegularTestCase):
density: Union[Numeric, Array]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
edges=Histogram(edges=np.array([0.0, 2.0, 5.0]), values=np.array([4.0, 9.0])),
density=3.0,
@@ -1953,7 +1955,7 @@ class TestCase(BaseRegularTestCase):
match="edges must be an Array or Histogram",
label="invalid_edges_type_raises",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1985,7 +1987,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return self.description
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(
edges=np.array([2.0, 3.0, 5.0, 7.0], dtype=np.float64),
@@ -2279,7 +2281,7 @@ def label(self) -> str:
description="rebin_with_histogram_float32",
expect_warning=True,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -2319,7 +2321,7 @@ class TestCase(BaseRegularTestCase):
target_bins: Union[Array, Any]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(edges=np.array([0.0, 1.0, 2.0]), values=np.array([2.0, 4.0])),
target_bins=np.array([0.0, 2.0]),
@@ -2374,7 +2376,7 @@ class TestCase(BaseRegularTestCase):
match="strictly increasing",
label="duplicate_values_raises",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -2402,7 +2404,7 @@ class TestCase(BaseRegularTestCase):
expect_warning: bool
expected: None = None
- test_cases = [
+ test_cases = (
TestCase(
histogram=Histogram(
edges=np.array([2.0, 5.0, 8.0], dtype=np.float64),
@@ -2520,7 +2522,7 @@ class TestCase(BaseRegularTestCase):
expect_warning=True,
label="disjoint_far_above_warning",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -2546,7 +2548,7 @@ class TestCase(BaseRegularTestCase):
histograms: Tuple[Histogram, ...]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
histograms=tuple(),
expected=ValueError,
@@ -2725,7 +2727,7 @@ class TestCase(BaseRegularTestCase):
expected=np.array([10.0, 15.0, 20.0, 25.0, 30.0, 35.0, 40.0, 45.0, 50.0]),
label="four_histograms",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -2756,7 +2758,7 @@ class TestCase(BaseRegularTestCase):
histograms: Tuple[Histogram, ...]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
function=lambda d: d**2,
histograms=(Histogram(edges=np.array([0.0, 1.0, 4.0]), values=np.array([2.0, 6.0])),),
@@ -2859,7 +2861,7 @@ class TestCase(BaseRegularTestCase):
match="same edges",
label="mismatched_edges_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -2887,7 +2889,7 @@ class TestCase(BaseRegularTestCase):
other_histograms: Tuple[Histogram, ...]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
function=lambda d1, d2: d1 * d2,
histogram=Histogram(edges=np.array([0.0, 1.0, 2.0]), values=np.array([1.0, 2.0])),
@@ -2946,7 +2948,7 @@ class TestCase(BaseRegularTestCase):
match="same edges",
label="mismatched_edges_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -2973,7 +2975,7 @@ class TestCase(BaseRegularTestCase):
histograms: Tuple[Histogram, ...]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
function=np.add,
histograms=(Histogram(edges=np.array([0.0, 1.0, 2.0]), values=np.array([1.0, 2.0])),),
@@ -3079,7 +3081,7 @@ class TestCase(BaseRegularTestCase):
match="same edges",
label="mismatched_edges_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -3107,7 +3109,7 @@ class TestCase(BaseRegularTestCase):
other_histograms: Tuple[Histogram, ...]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
function=np.multiply,
histogram=Histogram(edges=np.array([0.0, 1.0, 2.0]), values=np.array([2.0, 4.0])),
@@ -3170,7 +3172,7 @@ class TestCase(BaseRegularTestCase):
match="same edges",
label="mismatched_edges_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -3197,7 +3199,7 @@ class TestCase(BaseRegularTestCase):
right: Union[Histogram, Array, Numeric]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
left=Histogram(edges=np.array([0.0, 2.0, 7.0]), values=np.array([4.0, 15.0])),
right=Histogram(edges=np.array([0.0, 2.0, 7.0]), values=np.array([6.0, 20.0])),
@@ -3308,7 +3310,7 @@ class TestCase(BaseRegularTestCase):
match="Unsupported type for addition",
label="invalid_type_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -3336,7 +3338,7 @@ class TestCase(BaseRegularTestCase):
right: Union[Histogram, Array, Numeric]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
left=Histogram(edges=np.array([0.0, 2.0, 7.0]), values=np.array([10.0, 25.0])),
right=Histogram(edges=np.array([0.0, 2.0, 7.0]), values=np.array([4.0, 10.0])),
@@ -3427,7 +3429,7 @@ class TestCase(BaseRegularTestCase):
expected=Histogram(edges=np.array([0.0, 2.0, 7.0]), values=np.array([6.0, 10.0])),
label="array_minus_histogram",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -3455,7 +3457,7 @@ class TestCase(BaseRegularTestCase):
right: Union[Histogram, Array, Numeric]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
left=Histogram(edges=np.array([0.0, 2.0, 7.0]), values=np.array([4.0, 15.0])),
right=Histogram(edges=np.array([0.0, 2.0, 7.0]), values=np.array([6.0, 20.0])),
@@ -3566,7 +3568,7 @@ class TestCase(BaseRegularTestCase):
match="Unsupported type for multiplication",
label="invalid_type_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -3594,7 +3596,7 @@ class TestCase(BaseRegularTestCase):
right: Union[Histogram, Array, Numeric]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
left=Histogram(edges=np.array([0.0, 3.0, 10.0]), values=np.array([12.0, 42.0])),
right=2.0,
@@ -3724,7 +3726,7 @@ class TestCase(BaseRegularTestCase):
match="Unsupported type for division",
label="invalid_type_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -3752,7 +3754,7 @@ class TestCase(BaseRegularTestCase):
right: Union[Histogram, Array, Numeric]
match: Optional[str] = None
- test_cases = [
+ test_cases = (
TestCase(
left=Histogram(edges=np.array([0.0, 3.0, 10.0]), values=np.array([6.0, 21.0])),
right=2,
@@ -3983,7 +3985,7 @@ class TestCase(BaseRegularTestCase):
match="Zero densities cannot be raised to negative powers",
label="base_and_exponent_disjoint_ranges_zero_density",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_core/structures/histogram/test_interval.py b/tests/unit/sampletones_core/structures/histogram/test_interval.py
index 4188cc06..6949def3 100644
--- a/tests/unit/sampletones_core/structures/histogram/test_interval.py
+++ b/tests/unit/sampletones_core/structures/histogram/test_interval.py
@@ -22,29 +22,80 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"[{self.interval.left},{self.interval.right}]_expect_{self.expected}"
- test_cases = [
- TestCase(interval=Interval(0.0, 1.0), expected=True),
- TestCase(interval=Interval(1.0, 5.0), expected=True),
- TestCase(interval=Interval(-10.0, 10.0), expected=True),
- TestCase(interval=Interval(np.float32(0.0), np.float32(1.0)), expected=True),
- TestCase(interval=Interval(np.float64(5.5), np.float64(10.5)), expected=True),
- TestCase(interval=Interval(-np.inf, 0.0), expected=True),
- TestCase(interval=Interval(0.0, np.inf), expected=True),
- TestCase(interval=Interval(-np.inf, np.inf), expected=True),
- TestCase(interval=Interval(1.0, 1.0), expected=False),
- TestCase(interval=Interval(5.0, 5.0), expected=False),
- TestCase(interval=Interval(5.0, 3.0), expected=False),
- TestCase(interval=Interval(np.float32(2.0), np.float32(1.0)), expected=False),
+ test_cases = (
+ TestCase(
+ interval=Interval(0.0, 1.0),
+ expected=True,
+ ),
+ TestCase(
+ interval=Interval(1.0, 5.0),
+ expected=True,
+ ),
+ TestCase(
+ interval=Interval(-10.0, 10.0),
+ expected=True,
+ ),
+ TestCase(
+ interval=Interval(np.float32(0.0), np.float32(1.0)),
+ expected=True,
+ ),
+ TestCase(
+ interval=Interval(np.float64(5.5), np.float64(10.5)),
+ expected=True,
+ ),
+ TestCase(
+ interval=Interval(-np.inf, 0.0),
+ expected=True,
+ ),
+ TestCase(
+ interval=Interval(0.0, np.inf),
+ expected=True,
+ ),
+ TestCase(
+ interval=Interval(-np.inf, np.inf),
+ expected=True,
+ ),
+ TestCase(
+ interval=Interval(1.0, 1.0),
+ expected=False,
+ ),
+ TestCase(
+ interval=Interval(5.0, 5.0),
+ expected=False,
+ ),
+ TestCase(
+ interval=Interval(5.0, 3.0),
+ expected=False,
+ ),
+ TestCase(
+ interval=Interval(np.float32(2.0), np.float32(1.0)),
+ expected=False,
+ ),
TestCase(
interval=Interval(np.float64(10.0), np.float64(10.0)),
expected=False,
),
- TestCase(interval=Interval(np.inf, np.inf), expected=False),
- TestCase(interval=Interval(np.inf, -np.inf), expected=False),
- TestCase(interval=Interval(np.nan, 1.0), expected=False),
- TestCase(interval=Interval(0.0, np.nan), expected=False),
- TestCase(interval=Interval(np.nan, np.nan), expected=False),
- ]
+ TestCase(
+ interval=Interval(np.inf, np.inf),
+ expected=False,
+ ),
+ TestCase(
+ interval=Interval(np.inf, -np.inf),
+ expected=False,
+ ),
+ TestCase(
+ interval=Interval(np.nan, 1.0),
+ expected=False,
+ ),
+ TestCase(
+ interval=Interval(0.0, np.nan),
+ expected=False,
+ ),
+ TestCase(
+ interval=Interval(np.nan, np.nan),
+ expected=False,
+ ),
+ )
@pytest.mark.parametrize(
"test_case",
@@ -65,11 +116,23 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"[{self.interval.left},{self.interval.right}]"
- test_cases = [
- TestCase(interval=Interval(0.0, 1.0), expected=1.0),
- TestCase(interval=Interval(0.0, 5.0), expected=5.0),
- TestCase(interval=Interval(2.0, 7.0), expected=5.0),
- TestCase(interval=Interval(-5.0, 5.0), expected=10.0),
+ test_cases = (
+ TestCase(
+ interval=Interval(0.0, 1.0),
+ expected=1.0,
+ ),
+ TestCase(
+ interval=Interval(0.0, 5.0),
+ expected=5.0,
+ ),
+ TestCase(
+ interval=Interval(2.0, 7.0),
+ expected=5.0,
+ ),
+ TestCase(
+ interval=Interval(-5.0, 5.0),
+ expected=10.0,
+ ),
TestCase(
interval=Interval(np.float32(1.5), np.float32(3.5)),
expected=np.float32(2.0),
@@ -78,17 +141,35 @@ def label(self) -> str:
interval=Interval(np.float64(10.0), np.float64(15.0)),
expected=np.float64(5.0),
),
- TestCase(interval=Interval(-np.inf, 0.0), expected=np.inf),
- TestCase(interval=Interval(0.0, np.inf), expected=np.inf),
- TestCase(interval=Interval(-np.inf, np.inf), expected=np.inf),
- TestCase(interval=Interval(1.0, 1.0), expected=0.0),
- TestCase(interval=Interval(5.0, 3.0), expected=0.0),
+ TestCase(
+ interval=Interval(-np.inf, 0.0),
+ expected=np.inf,
+ ),
+ TestCase(
+ interval=Interval(0.0, np.inf),
+ expected=np.inf,
+ ),
+ TestCase(
+ interval=Interval(-np.inf, np.inf),
+ expected=np.inf,
+ ),
+ TestCase(
+ interval=Interval(1.0, 1.0),
+ expected=0.0,
+ ),
+ TestCase(
+ interval=Interval(5.0, 3.0),
+ expected=0.0,
+ ),
TestCase(
interval=Interval(np.float32(10.0), np.float32(5.0)),
expected=np.float32(0.0),
),
- TestCase(interval=Interval(np.inf, np.inf), expected=0.0),
- ]
+ TestCase(
+ interval=Interval(np.inf, np.inf),
+ expected=0.0,
+ ),
+ )
@pytest.mark.parametrize(
"test_case",
@@ -112,13 +193,31 @@ def label(self) -> str:
right_type = type(self.interval.right).__name__
return f"{left_type}__{right_type}"
- test_cases = [
- TestCase(interval=Interval(True, True), expected=int),
- TestCase(interval=Interval(1, 2), expected=int),
- TestCase(interval=Interval(1.0, 2.0), expected=float),
- TestCase(interval=Interval(np.int8(1), np.int8(2)), expected=np.int8),
- TestCase(interval=Interval(np.int32(1), np.int32(2)), expected=np.int32),
- TestCase(interval=Interval(np.int64(1), np.int64(2)), expected=np.int64),
+ test_cases = (
+ TestCase(
+ interval=Interval(True, True),
+ expected=int,
+ ),
+ TestCase(
+ interval=Interval(1, 2),
+ expected=int,
+ ),
+ TestCase(
+ interval=Interval(1.0, 2.0),
+ expected=float,
+ ),
+ TestCase(
+ interval=Interval(np.int8(1), np.int8(2)),
+ expected=np.int8,
+ ),
+ TestCase(
+ interval=Interval(np.int32(1), np.int32(2)),
+ expected=np.int32,
+ ),
+ TestCase(
+ interval=Interval(np.int64(1), np.int64(2)),
+ expected=np.int64,
+ ),
TestCase(
interval=Interval(np.float32(1.0), np.float32(2.0)),
expected=np.float32,
@@ -133,12 +232,30 @@ def label(self) -> str:
TestCase(interval=Interval(1.0, True), expected=float),
TestCase(interval=Interval(1, 1.0), expected=float),
TestCase(interval=Interval(1.0, 1), expected=float),
- TestCase(interval=Interval(np.int8(1), np.int32(2)), expected=np.int32),
- TestCase(interval=Interval(np.int32(1), np.int8(2)), expected=np.int32),
- TestCase(interval=Interval(np.int8(1), np.int64(2)), expected=np.int64),
- TestCase(interval=Interval(np.int64(1), np.int8(2)), expected=np.int64),
- TestCase(interval=Interval(np.int32(1), np.int64(2)), expected=np.int64),
- TestCase(interval=Interval(np.int64(1), np.int32(2)), expected=np.int64),
+ TestCase(
+ interval=Interval(np.int8(1), np.int32(2)),
+ expected=np.int32,
+ ),
+ TestCase(
+ interval=Interval(np.int32(1), np.int8(2)),
+ expected=np.int32,
+ ),
+ TestCase(
+ interval=Interval(np.int8(1), np.int64(2)),
+ expected=np.int64,
+ ),
+ TestCase(
+ interval=Interval(np.int64(1), np.int8(2)),
+ expected=np.int64,
+ ),
+ TestCase(
+ interval=Interval(np.int32(1), np.int64(2)),
+ expected=np.int64,
+ ),
+ TestCase(
+ interval=Interval(np.int64(1), np.int32(2)),
+ expected=np.int64,
+ ),
TestCase(
interval=Interval(np.float32(1.0), np.float64(2.0)),
expected=np.float64,
@@ -147,31 +264,103 @@ def label(self) -> str:
interval=Interval(np.float64(1.0), np.float32(2.0)),
expected=np.float64,
),
- TestCase(interval=Interval(1, np.int8(2)), expected=np.int8),
- TestCase(interval=Interval(np.int8(1), 257), expected=np.int8),
- TestCase(interval=Interval(1, np.int32(2)), expected=np.int32),
- TestCase(interval=Interval(np.int32(1), 2), expected=np.int32),
- TestCase(interval=Interval(1.0, np.float32(2.0)), expected=np.float32),
- TestCase(interval=Interval(np.float32(1.0), 2.0), expected=np.float32),
- TestCase(interval=Interval(1.0, np.float64(2.0)), expected=np.float64),
- TestCase(interval=Interval(np.float64(1.0), 2.0), expected=np.float64),
- TestCase(interval=Interval(1, np.float32(2.0)), expected=np.float32),
- TestCase(interval=Interval(np.float32(1.0), 2), expected=np.float32),
- TestCase(interval=Interval(1, np.float64(2.0)), expected=np.float64),
- TestCase(interval=Interval(np.float64(1.0), 2), expected=np.float64),
- TestCase(interval=Interval(np.int8(1), np.float32(2.0)), expected=np.float32),
- TestCase(interval=Interval(np.float32(1.0), np.int8(2)), expected=np.float32),
- TestCase(interval=Interval(np.int32(1), np.float32(2.0)), expected=np.float64),
- TestCase(interval=Interval(np.float32(1.0), np.int32(2)), expected=np.float64),
- TestCase(interval=Interval(np.int64(1), np.float32(2.0)), expected=np.float64),
- TestCase(interval=Interval(np.float32(1.0), np.int64(2)), expected=np.float64),
- TestCase(interval=Interval(np.int8(1), np.float64(2.0)), expected=np.float64),
- TestCase(interval=Interval(np.float64(1.0), np.int8(2)), expected=np.float64),
- TestCase(interval=Interval(np.int32(1), np.float64(2.0)), expected=np.float64),
- TestCase(interval=Interval(np.float64(1.0), np.int32(2)), expected=np.float64),
- TestCase(interval=Interval(np.int64(1), np.float64(2.0)), expected=np.float64),
- TestCase(interval=Interval(np.float64(1.0), np.int64(2)), expected=np.float64),
- ]
+ TestCase(
+ interval=Interval(1, np.int8(2)),
+ expected=np.int8,
+ ),
+ TestCase(
+ interval=Interval(np.int8(1), 257),
+ expected=np.int8,
+ ),
+ TestCase(
+ interval=Interval(1, np.int32(2)),
+ expected=np.int32,
+ ),
+ TestCase(
+ interval=Interval(np.int32(1), 2),
+ expected=np.int32,
+ ),
+ TestCase(
+ interval=Interval(1.0, np.float32(2.0)),
+ expected=np.float32,
+ ),
+ TestCase(
+ interval=Interval(np.float32(1.0), 2.0),
+ expected=np.float32,
+ ),
+ TestCase(
+ interval=Interval(1.0, np.float64(2.0)),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(np.float64(1.0), 2.0),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(1, np.float32(2.0)),
+ expected=np.float32,
+ ),
+ TestCase(
+ interval=Interval(np.float32(1.0), 2),
+ expected=np.float32,
+ ),
+ TestCase(
+ interval=Interval(1, np.float64(2.0)),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(np.float64(1.0), 2),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(np.int8(1), np.float32(2.0)),
+ expected=np.float32,
+ ),
+ TestCase(
+ interval=Interval(np.float32(1.0), np.int8(2)),
+ expected=np.float32,
+ ),
+ TestCase(
+ interval=Interval(np.int32(1), np.float32(2.0)),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(np.float32(1.0), np.int32(2)),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(np.int64(1), np.float32(2.0)),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(np.float32(1.0), np.int64(2)),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(np.int8(1), np.float64(2.0)),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(np.float64(1.0), np.int8(2)),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(np.int32(1), np.float64(2.0)),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(np.float64(1.0), np.int32(2)),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(np.int64(1), np.float64(2.0)),
+ expected=np.float64,
+ ),
+ TestCase(
+ interval=Interval(np.float64(1.0), np.int64(2)),
+ expected=np.float64,
+ ),
+ )
@pytest.mark.parametrize(
"test_case",
@@ -194,7 +383,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"[{self.interval.left},{self.interval.right}]"
- test_cases = [
+ test_cases = (
TestCase(interval=Interval(0.0, 2.0), expected=1.0),
TestCase(interval=Interval(1.0, 5.0), expected=3.0),
TestCase(interval=Interval(-10.0, 10.0), expected=0.0),
@@ -212,11 +401,14 @@ def label(self) -> str:
TestCase(interval=Interval(-np.inf, np.inf), expected=np.nan),
TestCase(interval=Interval(1.0, 1.0), expected=None),
TestCase(interval=Interval(5.0, 3.0), expected=None),
- TestCase(interval=Interval(np.float32(10.0), np.float32(5.0)), expected=None),
+ TestCase(
+ interval=Interval(np.float32(10.0), np.float32(5.0)),
+ expected=None,
+ ),
TestCase(interval=Interval(np.inf, np.inf), expected=None),
TestCase(interval=Interval(np.nan, 1.0), expected=None),
TestCase(interval=Interval(0.0, np.nan), expected=None),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -249,7 +441,7 @@ def label(self) -> str:
)
return f"[{self.interval1.left},{self.interval1.right}]_{interval2_str}{error_suffix}"
- test_cases = [
+ test_cases = (
TestCase(
interval1=Interval(1.0, 5.0),
interval2=Interval(3.0, 7.0),
@@ -316,7 +508,7 @@ def label(self) -> str:
expected=TypeError,
match="Expected Interval",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -324,7 +516,7 @@ def label(self) -> str:
ids=lambda test_case: test_case.label,
)
def test_intersection(self, test_case: TestCase) -> None:
- other = test_case.interval2 if isinstance(test_case.interval2, Interval) else test_case.interval2
+ other = test_case.interval2
if not expect_error(
test_case.interval1.intersection,
@@ -348,7 +540,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"[{self.interval1.left},{self.interval1.right}]_contains_[{self.interval2.left},{self.interval2.right}]_{self.expected}"
- test_cases = [
+ test_cases = (
TestCase(
interval1=Interval(0.0, 10.0),
interval2=Interval(2.0, 8.0),
@@ -429,7 +621,7 @@ def label(self) -> str:
interval2=Interval(np.float32(3.0), np.float32(5.0)),
expected=False,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -449,7 +641,7 @@ class TestCase(BaseAutolabelTestCase):
def label(self) -> str:
return f"left_{type(self.expected.left).__name__}_right_{type(self.expected.right).__name__}"
- test_cases = [
+ test_cases = (
TestCase(expected=Interval(np.float32(1.5), np.float32(3.5))),
TestCase(expected=Interval(np.float64(2.0), np.float64(8.0))),
TestCase(expected=Interval(np.float32(-5.0), np.float32(5.0))),
@@ -457,7 +649,7 @@ def label(self) -> str:
TestCase(expected=Interval(np.float64(10.5), np.float64(20.5))),
TestCase(expected=Interval(-np.inf, np.inf)),
TestCase(expected=Interval(np.float32(-np.inf), np.float32(5.0))),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -498,15 +690,27 @@ def label(self) -> str:
dtype_name = self.edges.dtype.name if isinstance(self.edges, np.ndarray) else type(self.edges).__name__
return f"dtype_{dtype_name}_len_{len(self.edges)}{error_suffix}"
- test_cases = [
- TestCase(edges=np.array([0.0, 1.0, 2.0], dtype=np.float32), expected=2),
- TestCase(edges=np.array([0.0, 2.0, 5.0, 10.0], dtype=np.float64), expected=3),
+ test_cases = (
+ TestCase(
+ edges=np.array([0.0, 1.0, 2.0], dtype=np.float32),
+ expected=2,
+ ),
+ TestCase(
+ edges=np.array([0.0, 2.0, 5.0, 10.0], dtype=np.float64),
+ expected=3,
+ ),
TestCase(
edges=np.array([1.0, 3.0, 7.0, 15.0, 31.0], dtype=np.float32),
expected=4,
),
- TestCase(edges=np.array([-10.0, 0.0, 10.0], dtype=np.float64), expected=2),
- TestCase(edges=np.array([0.0, 1.0], dtype=np.float32), expected=1),
+ TestCase(
+ edges=np.array([-10.0, 0.0, 10.0], dtype=np.float64),
+ expected=2,
+ ),
+ TestCase(
+ edges=np.array([0.0, 1.0], dtype=np.float32),
+ expected=1,
+ ),
TestCase(
edges=[0.0, 1.0, 2.0],
expected=TypeError,
@@ -537,7 +741,7 @@ def label(self) -> str:
expected=ValueError,
match="strictly increasing",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_core/structures/tree/test_arguments.py b/tests/unit/sampletones_core/structures/tree/test_arguments.py
index b0425e4e..0673c41a 100644
--- a/tests/unit/sampletones_core/structures/tree/test_arguments.py
+++ b/tests/unit/sampletones_core/structures/tree/test_arguments.py
@@ -21,55 +21,99 @@ class TestArgumentsCreateMethod:
class TestCase(BaseTestCase):
label: str
args: List[Any]
- exc: Type[Exception]
-
- INVALID_ARGS_CASES = [
- TestCase(label="empty", args=[], exc=ValueError),
- TestCase(label="missing_node", args=[object()], exc=ValueError),
- TestCase(label="self_is_none", args=[None, Node("placeholder")], exc=TypeError),
- ]
+ exception: Type[Exception]
+
+ test_cases = (
+ TestCase(
+ label="empty",
+ args=[],
+ exception=ValueError,
+ ),
+ TestCase(
+ label="missing_node",
+ args=[object()],
+ exception=ValueError,
+ ),
+ TestCase(
+ label="self_is_none",
+ args=[None, Node("placeholder")],
+ exception=TypeError,
+ ),
+ )
def test_creates_with_self_and_node(self, a_node: TreeNode) -> None:
self_ = object()
- result = Arguments.create(method=True, args=[self_, a_node], kwargs={})
+ result = Arguments.create(
+ method=True,
+ args=[self_, a_node],
+ kwargs={},
+ )
assert result.self is self_
assert result.node is a_node
assert result.args == []
def test_extra_args_captured_in_args(self, a_node: TreeNode) -> None:
self_ = object()
- result = Arguments.create(method=True, args=[self_, a_node, "x", 42], kwargs={})
+ result = Arguments.create(
+ method=True,
+ args=[self_, a_node, "x", 42],
+ kwargs={},
+ )
assert result.args == ["x", 42]
def test_kwargs_preserved(self, a_node: TreeNode) -> None:
self_ = object()
- result = Arguments.create(method=True, args=[self_, a_node], kwargs={"k": "v"})
+ result = Arguments.create(
+ method=True,
+ args=[self_, a_node],
+ kwargs={"k": "v"},
+ )
assert result.kwargs == {"k": "v"}
def test_method_property_is_true(self, a_node: TreeNode) -> None:
self_ = object()
- result = Arguments.create(method=True, args=[self_, a_node], kwargs={})
+ result = Arguments.create(
+ method=True,
+ args=[self_, a_node],
+ kwargs={},
+ )
assert result.method is True
- @pytest.mark.parametrize("case", INVALID_ARGS_CASES, ids=lambda c: c.label)
- def test_invalid_args_raises(self, case: "TestArgumentsCreateMethod.TestCase") -> None:
- with pytest.raises(case.exc):
+ @pytest.mark.parametrize(
+ "case",
+ test_cases,
+ ids=lambda c: c.label,
+ )
+ def test_invalid_args_raises(self, case: TestCase) -> None:
+ with pytest.raises(case.exception):
Arguments.create(method=True, args=case.args, kwargs={})
class TestArgumentsCreateFunction:
def test_creates_with_node(self, a_node: TreeNode) -> None:
- result = Arguments.create(method=False, args=[a_node], kwargs={})
+ result = Arguments.create(
+ method=False,
+ args=[a_node],
+ kwargs={},
+ )
assert result.node is a_node
assert result.self is None
assert result.args == []
def test_extra_args_captured_in_args(self, a_node: TreeNode) -> None:
- result = Arguments.create(method=False, args=[a_node, "x", 42], kwargs={})
+ result = Arguments.create(
+ method=False,
+ args=[a_node, "x", 42],
+ kwargs={},
+ )
assert result.args == ["x", 42]
def test_method_property_is_false(self, a_node: TreeNode) -> None:
- result = Arguments.create(method=False, args=[a_node], kwargs={})
+ result = Arguments.create(
+ method=False,
+ args=[a_node],
+ kwargs={},
+ )
assert result.method is False
def test_empty_args_raises_value_error(self) -> None:
@@ -84,15 +128,35 @@ class TestCase(BaseTestCase):
method: bool
extra_args: List[Any]
- EXECUTE_CASES = [
- TestCase(label="function_node_only", method=False, extra_args=[]),
- TestCase(label="function_with_extra", method=False, extra_args=["x"]),
- TestCase(label="method_self_and_node", method=True, extra_args=[]),
- TestCase(label="method_with_extra", method=True, extra_args=["x"]),
- ]
-
- @pytest.mark.parametrize("case", EXECUTE_CASES, ids=lambda c: c.label)
- def test_execute_dispatches_correctly(self, a_node: TreeNode, case: "TestArgumentsExecute.TestCase") -> None:
+ test_cases = (
+ TestCase(
+ label="function_node_only",
+ method=False,
+ extra_args=[],
+ ),
+ TestCase(
+ label="function_with_extra",
+ method=False,
+ extra_args=["x"],
+ ),
+ TestCase(
+ label="method_self_and_node",
+ method=True,
+ extra_args=[],
+ ),
+ TestCase(
+ label="method_with_extra",
+ method=True,
+ extra_args=["x"],
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda c: c.label)
+ def test_execute_dispatches_correctly(
+ self,
+ a_node: TreeNode,
+ case: TestCase,
+ ) -> None:
self_ = object()
args_list = ([self_, a_node] if case.method else [a_node]) + case.extra_args
arguments = Arguments.create(method=case.method, args=args_list, kwargs={})
diff --git a/tests/unit/sampletones_core/structures/tree/test_factory.py b/tests/unit/sampletones_core/structures/tree/test_factory.py
new file mode 100644
index 00000000..993da11e
--- /dev/null
+++ b/tests/unit/sampletones_core/structures/tree/test_factory.py
@@ -0,0 +1,56 @@
+from pathlib import Path
+
+from sampletones_core.configs import Config
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
+from sampletones_core.structures.tree.factory import create_directory_node
+from sampletones_core.structures.tree.node import ConfigNode, FileSystemNode, TreeNode
+from sampletones_core.structures.tree.type import NodeType
+
+CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config())
+RECONSTRUCTIONS_DIRECTORY = Path("/reconstructions")
+
+
+class TestCreateDirectoryNode:
+ def test_stated_configuration_becomes_a_config_node(self) -> None:
+ directory = RECONSTRUCTIONS_DIRECTORY / CONFIG_FIELDS.directory_name
+ node = create_directory_node(
+ directory,
+ name=directory.name,
+ config=CONFIG_FIELDS,
+ parent=None,
+ )
+ assert isinstance(node, ConfigNode)
+ assert node.config == CONFIG_FIELDS
+
+ def test_folder_stating_no_configuration_becomes_a_file_system_node(self) -> None:
+ directory = RECONSTRUCTIONS_DIRECTORY / "my_songs"
+ node = create_directory_node(
+ directory,
+ name=directory.name,
+ config=None,
+ parent=None,
+ )
+ assert isinstance(node, FileSystemNode)
+ assert not isinstance(node, ConfigNode)
+
+ def test_node_carries_the_given_name_and_path(self) -> None:
+ directory = RECONSTRUCTIONS_DIRECTORY / CONFIG_FIELDS.directory_name
+ node = create_directory_node(
+ directory,
+ name="friendly",
+ config=CONFIG_FIELDS,
+ parent=None,
+ )
+ assert node.name == "friendly"
+ assert node.filepath == directory
+ assert node.node_type == NodeType.DIRECTORY
+
+ def test_node_attaches_to_the_given_parent(self) -> None:
+ parent = TreeNode("root", NodeType.ROOT)
+ node = create_directory_node(
+ RECONSTRUCTIONS_DIRECTORY / "my_songs",
+ name="my_songs",
+ config=None,
+ parent=parent,
+ )
+ assert node.parent is parent
diff --git a/tests/unit/sampletones_core/structures/tree/test_node.py b/tests/unit/sampletones_core/structures/tree/test_node.py
index d9da8d53..2f91cc30 100644
--- a/tests/unit/sampletones_core/structures/tree/test_node.py
+++ b/tests/unit/sampletones_core/structures/tree/test_node.py
@@ -3,7 +3,9 @@
from sampletones_core.configs import Config
from sampletones_core.constants.enums import LibraryGeneratorName
from sampletones_core.library import InstructionLibraryKey
+from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields
from sampletones_core.structures.tree.node import (
+ ConfigNode,
FileSystemNode,
GeneratorNode,
LibraryNode,
@@ -12,6 +14,7 @@
from sampletones_core.structures.tree.type import NodeType
LIBRARY_KEY = InstructionLibraryKey.from_config(Config())
+CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config())
class TestTreeNode:
@@ -61,6 +64,43 @@ def test_copy_preserves_filepath_and_type(self) -> None:
assert copied.node_type == NodeType.FILE
+class TestConfigNode:
+ def test_config_is_stored(self) -> None:
+ node = ConfigNode(
+ "config",
+ NodeType.DIRECTORY,
+ filepath=Path("/reconstructions") / CONFIG_FIELDS.directory_name,
+ config=CONFIG_FIELDS,
+ )
+ assert node.config == CONFIG_FIELDS
+
+ def test_config_survives_a_filename_of_its_own(self) -> None:
+ node = ConfigNode(
+ "variant",
+ NodeType.FILE,
+ filepath=Path("/reconstructions") / CONFIG_FIELDS.directory_name / "song.stn",
+ config=CONFIG_FIELDS,
+ )
+ assert node.config == CONFIG_FIELDS
+
+ def test_copy_preserves_config_filepath_and_type(self) -> None:
+ path = Path("/reconstructions") / CONFIG_FIELDS.directory_name
+ node = ConfigNode("config", NodeType.DIRECTORY, filepath=path, config=CONFIG_FIELDS)
+ copied = node.copy()
+ assert copied.config == CONFIG_FIELDS
+ assert copied.filepath == path
+ assert copied.node_type == NodeType.DIRECTORY
+
+ def test_node_is_a_file_system_node(self) -> None:
+ node = ConfigNode(
+ "config",
+ NodeType.DIRECTORY,
+ filepath=Path("/reconstructions") / CONFIG_FIELDS.directory_name,
+ config=CONFIG_FIELDS,
+ )
+ assert isinstance(node, FileSystemNode)
+
+
class TestLibraryNode:
def test_library_key_is_stored(self) -> None:
node = LibraryNode("lib", library_key=LIBRARY_KEY)
diff --git a/tests/unit/sampletones_core/structures/tree/test_tree.py b/tests/unit/sampletones_core/structures/tree/test_tree.py
index c0544112..9ee14082 100644
--- a/tests/unit/sampletones_core/structures/tree/test_tree.py
+++ b/tests/unit/sampletones_core/structures/tree/test_tree.py
@@ -1,19 +1,17 @@
-from dataclasses import dataclass
+from pathlib import Path
+from typing import Final, List
import pytest
-from sampletones_core.structures.tree.node import TreeNode
+from sampletones_core.structures.tree.node import FileSystemNode, TreeNode
from sampletones_core.structures.tree.tree import Tree
from sampletones_core.structures.tree.type import NodeType
-from tests.suite.case import BaseTestCase
-
-def name_predicate(node: TreeNode, query: str) -> bool:
- return query in node.name
+SONG_PATH: Final[Path] = Path("/reconstructions/song.stn")
@pytest.fixture
-def all_nodes() -> list:
+def all_nodes() -> List[TreeNode]:
root = TreeNode("root", NodeType.ROOT)
child_a = TreeNode("child_a", NodeType.DIRECTORY, parent=root)
child_b = TreeNode("child_b", NodeType.DIRECTORY, parent=root)
@@ -24,7 +22,7 @@ def all_nodes() -> list:
@pytest.fixture
-def tree(all_nodes: list) -> Tree:
+def tree(all_nodes: List[TreeNode]) -> Tree:
return Tree(root=all_nodes[0])
@@ -32,104 +30,50 @@ class TestTreeRootManagement:
def test_empty_tree_root_is_none(self) -> None:
assert Tree().root is None
- def test_set_root_stores_root(self, all_nodes: list) -> None:
+ def test_set_root_stores_root(self, all_nodes: List[TreeNode]) -> None:
t = Tree()
t.set_root(all_nodes[0])
assert t.root is all_nodes[0]
- def test_get_root_returns_root(self, all_nodes: list, tree: Tree) -> None:
+ def test_get_root_returns_root(
+ self,
+ all_nodes: List[TreeNode],
+ tree: Tree,
+ ) -> None:
assert tree.get_root() is all_nodes[0]
- def test_set_root_clears_existing_filter(self, all_nodes: list, tree: Tree) -> None:
- tree.apply_filter("child_a", name_predicate)
- assert tree.is_filtered()
- tree.set_root(all_nodes[0])
- assert not tree.is_filtered()
-
-
-class TestTreeFilter:
- @dataclass(frozen=True, kw_only=True)
- class TestCase(BaseTestCase):
- label: str
- query: str
- expected_visible_names: frozenset
- expected_hidden_names: frozenset
-
- FILTER_VISIBILITY_CASES = [
- TestCase(
- label="match_leaf",
- query="leaf_ba",
- expected_visible_names=frozenset({"root", "child_b", "leaf_ba"}),
- expected_hidden_names=frozenset({"child_a", "leaf_aa", "leaf_ab"}),
- ),
- TestCase(
- label="match_internal",
- query="child_a",
- expected_visible_names=frozenset({"root", "child_a", "leaf_aa", "leaf_ab"}),
- expected_hidden_names=frozenset({"child_b", "leaf_ba"}),
- ),
- TestCase(
- label="no_match",
- query="xyz",
- expected_visible_names=frozenset(),
- expected_hidden_names=frozenset({"root", "child_a", "child_b", "leaf_aa", "leaf_ab", "leaf_ba"}),
- ),
- ]
-
- def test_no_filter_all_nodes_visible(self, tree: Tree, all_nodes: list) -> None:
- for node in all_nodes:
- assert tree.is_node_visible(node)
-
- def test_is_filtered_false_initially(self, tree: Tree) -> None:
- assert not tree.is_filtered()
-
- def test_is_filtered_true_after_apply(self, tree: Tree) -> None:
- tree.apply_filter("root", name_predicate)
- assert tree.is_filtered()
-
- def test_filter_empty_query_clears_filter(self, tree: Tree) -> None:
- tree.apply_filter("child_a", name_predicate)
- tree.apply_filter("", name_predicate)
- assert not tree.is_filtered()
-
- def test_clear_filter_makes_all_nodes_visible(self, tree: Tree, all_nodes: list) -> None:
- tree.apply_filter("leaf_ba", name_predicate)
- tree.clear_filter()
- for node in all_nodes:
- assert tree.is_node_visible(node)
-
- def test_filter_on_empty_tree_is_active(self) -> None:
- t = Tree()
- t.apply_filter("x", name_predicate)
- assert t.is_filtered()
-
- @pytest.mark.parametrize("case", FILTER_VISIBILITY_CASES, ids=lambda c: c.label)
- def test_filter_visibility(self, tree: Tree, all_nodes: list, case: TestCase) -> None:
- tree.apply_filter(case.query, name_predicate)
- for node in all_nodes:
- if node.name in case.expected_visible_names:
- assert tree.is_node_visible(node), f"{node.name!r} should be visible for query {case.query!r}"
- elif node.name in case.expected_hidden_names:
- assert not tree.is_node_visible(node), f"{node.name!r} should be hidden for query {case.query!r}"
-
+ def test_set_root_replaces_the_shape(self, tree: Tree) -> None:
+ replacement = TreeNode("replacement", NodeType.ROOT)
+ tree.set_root(replacement)
+ assert tree.get_root() is replacement
-class TestTreeCollectLeaves:
- def test_returns_empty_for_empty_tree(self) -> None:
- assert Tree().collect_leaves() == []
- def test_singleton_root_is_its_own_leaf(self) -> None:
+class TestTreeFindNodes:
+ @staticmethod
+ def _tree_with_twins() -> Tree:
root = TreeNode("root", NodeType.ROOT)
- t = Tree(root=root)
- leaves = t.collect_leaves()
- assert len(leaves) == 1
- assert leaves[0] is root
-
- def test_returns_all_leaves_without_filter(self, tree: Tree) -> None:
- leaf_names = {leaf.name for leaf in tree.collect_leaves()}
- assert leaf_names == {"leaf_aa", "leaf_ab", "leaf_ba"}
-
- def test_filtered_leaves_exclude_hidden(self, tree: Tree) -> None:
- tree.apply_filter("leaf_ba", name_predicate)
- leaves = tree.collect_leaves()
- assert len(leaves) == 1
- assert leaves[0].name == "leaf_ba"
+ by_configuration = TreeNode("by_configuration", NodeType.GROUP, parent=root)
+ by_sample = TreeNode("by_sample", NodeType.GROUP, parent=root)
+ FileSystemNode("song", NodeType.FILE, SONG_PATH, parent=by_configuration)
+ FileSystemNode("44.1 kHz", NodeType.FILE, SONG_PATH, parent=by_sample)
+ FileSystemNode("other", NodeType.FILE, Path("/reconstructions/other.stn"), parent=by_sample)
+ return Tree(root=root)
+
+ def test_empty_tree_answers_nothing(self) -> None:
+ assert Tree().find_nodes(TreeNode, lambda node: True) == ()
+
+ def test_every_node_standing_for_one_path_is_answered(self) -> None:
+ tree = self._tree_with_twins()
+ twins = tree.find_nodes(FileSystemNode, lambda node: node.filepath == SONG_PATH)
+ assert [twin.name for twin in twins] == ["song", "44.1 kHz"]
+
+ def test_nodes_of_other_classes_stay_out(self) -> None:
+ tree = self._tree_with_twins()
+ assert all(isinstance(node, FileSystemNode) for node in tree.find_nodes(FileSystemNode, lambda node: True))
+
+ def test_the_answer_reads_in_tree_order(self, tree: Tree) -> None:
+ found = tree.find_nodes(TreeNode, lambda node: node.node_type == NodeType.FILE)
+ assert [node.name for node in found] == ["leaf_aa", "leaf_ab", "leaf_ba"]
+
+ def test_a_predicate_nothing_answers_gives_nothing(self, tree: Tree) -> None:
+ assert tree.find_nodes(FileSystemNode, lambda node: True) == ()
diff --git a/tests/unit/sampletones_core/structures/tree/test_visibility.py b/tests/unit/sampletones_core/structures/tree/test_visibility.py
new file mode 100644
index 00000000..abd27830
--- /dev/null
+++ b/tests/unit/sampletones_core/structures/tree/test_visibility.py
@@ -0,0 +1,123 @@
+from dataclasses import dataclass
+from typing import Dict, List
+
+import pytest
+
+from sampletones_core.structures.tree.node import TreeNode
+from sampletones_core.structures.tree.type import NodeType
+from sampletones_core.structures.tree.visibility import TreeVisibility, resolve_visibility
+from tests.suite.case import BaseTestCase
+
+
+@pytest.fixture
+def nodes() -> Dict[str, TreeNode]:
+ root = TreeNode("root", NodeType.ROOT)
+ child_a = TreeNode("child_a", NodeType.DIRECTORY, parent=root)
+ child_b = TreeNode("child_b", NodeType.DIRECTORY, parent=root)
+ leaf_aa = TreeNode("leaf_aa", NodeType.FILE, parent=child_a)
+ leaf_ab = TreeNode("leaf_ab", NodeType.FILE, parent=child_a)
+ leaf_ba = TreeNode("leaf_ba", NodeType.FILE, parent=child_b)
+ return {
+ node.name: node
+ for node in (
+ root,
+ child_a,
+ child_b,
+ leaf_aa,
+ leaf_ab,
+ leaf_ba,
+ )
+ }
+
+
+def visibility_of(
+ nodes: Dict[str, TreeNode],
+ matched_names: List[str],
+) -> TreeVisibility:
+ return resolve_visibility(nodes[name] for name in matched_names)
+
+
+class TestVisibleRows:
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseTestCase):
+ label: str
+ matched_names: List[str]
+ expected_visible_names: frozenset[str]
+
+ test_cases = (
+ TestCase(
+ label="a_named_leaf_is_read_under_the_rows_holding_it",
+ matched_names=["leaf_ba"],
+ expected_visible_names=frozenset({"root", "child_b", "leaf_ba"}),
+ ),
+ TestCase(
+ label="a_named_row_shows_what_it_gathers",
+ matched_names=["child_a"],
+ expected_visible_names=frozenset({"root", "child_a", "leaf_aa", "leaf_ab"}),
+ ),
+ TestCase(
+ label="two_named_rows_each_keep_their_own_way_in",
+ matched_names=["leaf_aa", "leaf_ba"],
+ expected_visible_names=frozenset(
+ {
+ "root",
+ "child_a",
+ "leaf_aa",
+ "child_b",
+ "leaf_ba",
+ }
+ ),
+ ),
+ TestCase(
+ label="nothing_named_keeps_nothing",
+ matched_names=[],
+ expected_visible_names=frozenset(),
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
+ def test_the_rows_a_match_keeps(
+ self,
+ nodes: Dict[str, TreeNode],
+ case: TestCase,
+ ) -> None:
+ visibility = visibility_of(nodes, case.matched_names)
+ visible_names = {name for name, node in nodes.items() if visibility.is_visible(node)}
+ assert visible_names == case.expected_visible_names
+
+
+class TestOpenRows:
+ def test_every_row_above_a_match_stands_open(self, nodes: Dict[str, TreeNode]) -> None:
+ visibility = visibility_of(nodes, ["leaf_ba"])
+ open_names = {name for name, node in nodes.items() if visibility.should_expand(node)}
+ assert open_names == {"root", "child_b", "leaf_ba"}
+
+ def test_a_row_beside_the_way_in_stays_folded(self, nodes: Dict[str, TreeNode]) -> None:
+ visibility = visibility_of(nodes, ["leaf_ba"])
+ assert not visibility.should_expand(nodes["child_a"])
+
+ def test_a_row_below_a_match_stays_folded(self, nodes: Dict[str, TreeNode]) -> None:
+ """A match shows what it gathers as it stands, so its own rows keep the shape they had."""
+ visibility = visibility_of(nodes, ["child_a"])
+ assert visibility.is_visible(nodes["leaf_aa"])
+ assert not visibility.should_expand(nodes["leaf_aa"])
+
+ def test_nothing_named_leaves_every_row_folded(self, nodes: Dict[str, TreeNode]) -> None:
+ visibility = visibility_of(nodes, [])
+ assert not any(visibility.should_expand(node) for node in nodes.values())
+
+
+class TestResolvedSets:
+ def test_the_named_rows_are_held_as_they_were_given(self, nodes: Dict[str, TreeNode]) -> None:
+ visibility = visibility_of(nodes, ["leaf_aa", "leaf_ab"])
+ assert visibility.matches == frozenset({nodes["leaf_aa"], nodes["leaf_ab"]})
+
+ def test_only_the_rows_above_a_match_are_held_beside_them(self, nodes: Dict[str, TreeNode]) -> None:
+ """What a match holds is answered from a path, so the sets stay the size of what was found."""
+ visibility = visibility_of(nodes, ["child_a"])
+ assert visibility.ancestors == frozenset({nodes["root"]})
+
+ def test_a_match_above_another_is_held_in_both_sets(self, nodes: Dict[str, TreeNode]) -> None:
+ visibility = visibility_of(nodes, ["child_a", "leaf_aa"])
+ assert nodes["child_a"] in visibility.matches
+ assert nodes["child_a"] in visibility.ancestors
diff --git a/tests/unit/sampletones_core/timers/implementation/test_phase.py b/tests/unit/sampletones_core/timers/implementation/test_phase.py
index d957102c..b0512259 100644
--- a/tests/unit/sampletones_core/timers/implementation/test_phase.py
+++ b/tests/unit/sampletones_core/timers/implementation/test_phase.py
@@ -25,7 +25,13 @@ class TestFrequencyToTimer:
(0.001, 0x7FF),
(1e9, 0),
],
- ids=["zero_frequency", "negative_frequency", "a4_440hz", "very_low_clamps_at_max", "very_high_clamps_at_zero"],
+ ids=[
+ "zero_frequency",
+ "negative_frequency",
+ "a4_440hz",
+ "very_low_clamps_at_max",
+ "very_high_clamps_at_zero",
+ ],
)
def test_timer_value_correct(self, frequency: float, expected: int) -> None:
assert PhaseTimer.frequency_to_timer(frequency) == expected
@@ -40,7 +46,12 @@ class TestGetTimerTicks:
(1, 32),
(100, 1616),
],
- ids=["zero_returns_zero", "negative_returns_zero", "timer_1_gives_32", "timer_100_gives_1616"],
+ ids=[
+ "zero_returns_zero",
+ "negative_returns_zero",
+ "timer_1_gives_32",
+ "timer_100_gives_1616",
+ ],
)
def test_tick_count_correct(self, timer: int, expected: int) -> None:
assert PhaseTimer.get_timer_ticks(timer) == expected
diff --git a/tests/unit/sampletones_core/timing/__init__.py b/tests/unit/sampletones_core/timing/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_core/timing/test_clock.py b/tests/unit/sampletones_core/timing/test_clock.py
new file mode 100644
index 00000000..93a2dff8
--- /dev/null
+++ b/tests/unit/sampletones_core/timing/test_clock.py
@@ -0,0 +1,200 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from fractions import Fraction
+from typing import Final, Tuple
+
+import pytest
+
+from sampletones_core.constants.audio import SAMPLE_RATES
+from sampletones_core.timing.clock import TickClock
+from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+LONG_RUN_TICKS: Final[int] = 36000
+NES_FREQUENCIES: Final[Tuple[int, ...]] = (15, 24, 25, 30, 50, 60, 100, 120, 199, 300)
+
+
+class TestTickClock(BaseTestSuite):
+ """One case table, read both for the frame lengths it produces and for the rules they obey."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Tuple[int, ...]
+ sample_rate: int
+ nes_frequency: int
+
+ @property
+ def label(self) -> str:
+ return f"{self.sample_rate}hz_{self.nes_frequency}tick"
+
+ @property
+ def clock(self) -> TickClock:
+ return TickClock.from_parameters(
+ sample_rate=self.sample_rate,
+ nes_frequency=self.nes_frequency,
+ )
+
+ test_cases = (
+ TestCase(sample_rate=44100, nes_frequency=60, expected=(735,) * 8),
+ TestCase(sample_rate=48000, nes_frequency=60, expected=(800,) * 8),
+ TestCase(sample_rate=96000, nes_frequency=60, expected=(1600,) * 8),
+ TestCase(sample_rate=44100, nes_frequency=30, expected=(1470,) * 8),
+ TestCase(
+ sample_rate=22050,
+ nes_frequency=60,
+ expected=(367, 368, 367, 368, 367, 368, 367, 368),
+ ),
+ TestCase(
+ sample_rate=8000,
+ nes_frequency=60,
+ expected=(133, 133, 134, 133, 133, 134, 133, 133),
+ ),
+ TestCase(
+ sample_rate=16000,
+ nes_frequency=60,
+ expected=(266, 267, 267, 266, 267, 267, 266, 267),
+ ),
+ TestCase(
+ sample_rate=44100,
+ nes_frequency=120,
+ expected=(367, 368, 367, 368, 367, 368, 367, 368),
+ ),
+ TestCase(
+ sample_rate=8000,
+ nes_frequency=300,
+ expected=(26, 27, 27, 26, 27, 27, 26, 27),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_frame_lengths_match(self, test_case: TestCase) -> None:
+ clock = test_case.clock
+ lengths = tuple(clock.frame_length(tick) for tick in range(len(test_case.expected)))
+ assert lengths == test_case.expected
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_frame_lengths_sum_to_the_cumulative_count(self, test_case: TestCase) -> None:
+ clock = test_case.clock
+ assert sum(clock.frame_length(tick) for tick in range(len(test_case.expected))) == clock.samples_at(
+ len(test_case.expected)
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_only_the_floor_and_the_ceiling_appear(self, test_case: TestCase) -> None:
+ """Consecutive ticks differ by at most one sample, so no tick is audibly off on its own."""
+ clock = test_case.clock
+ lengths = {clock.frame_length(tick) for tick in range(LONG_RUN_TICKS)}
+ assert max(lengths) - min(lengths) <= 1
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_is_exact_reports_a_uniform_run(self, test_case: TestCase) -> None:
+ clock = test_case.clock
+ lengths = {clock.frame_length(tick) for tick in range(LONG_RUN_TICKS)}
+ assert clock.is_exact == (len(lengths) == 1)
+
+
+class TestTheClockHoldsTheTempo(BaseTestSuite):
+ """The property the whole clock exists for: a run of ticks lands on its exact duration."""
+
+ @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES)
+ @pytest.mark.parametrize("sample_rate", SAMPLE_RATES)
+ def test_a_long_run_lands_on_the_exact_sample_count(
+ self,
+ sample_rate: int,
+ nes_frequency: int,
+ ) -> None:
+ clock = TickClock.from_parameters(
+ sample_rate=sample_rate,
+ nes_frequency=nes_frequency,
+ )
+ rendered = sum(clock.frame_length(tick) for tick in range(LONG_RUN_TICKS))
+ exact = Fraction(sample_rate, nes_frequency) * LONG_RUN_TICKS
+ assert rendered == int(exact) if exact.denominator == 1 else abs(rendered - exact) < 1
+
+ @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES)
+ @pytest.mark.parametrize("sample_rate", SAMPLE_RATES)
+ def test_the_cumulative_count_never_drifts_past_one_sample(
+ self,
+ sample_rate: int,
+ nes_frequency: int,
+ ) -> None:
+ clock = TickClock.from_parameters(
+ sample_rate=sample_rate,
+ nes_frequency=nes_frequency,
+ )
+ rate = Fraction(sample_rate, nes_frequency)
+ assert all(abs(clock.samples_at(ticks) - rate * ticks) < 1 for ticks in range(0, LONG_RUN_TICKS, 97))
+
+ @pytest.mark.parametrize("sample_rate", SAMPLE_RATES)
+ def test_a_whole_division_gives_every_tick_the_rounded_length(self, sample_rate: int) -> None:
+ """Where the division is whole the clock agrees with the length a timer is built with."""
+ for nes_frequency in NES_FREQUENCIES:
+ if sample_rate % nes_frequency:
+ continue
+
+ clock = TickClock.from_parameters(
+ sample_rate=sample_rate,
+ nes_frequency=nes_frequency,
+ )
+ expected = round(sample_rate / nes_frequency)
+ assert clock.is_exact
+ assert all(clock.frame_length(tick) == expected for tick in range(64))
+
+
+class TestTickClockBounds(BaseTestSuite):
+ def test_the_first_tick_starts_at_zero(self) -> None:
+ clock = TickClock.from_parameters(sample_rate=44100, nes_frequency=60)
+ assert clock.samples_at(0) == 0
+
+ def test_every_tick_spans_at_least_one_sample(self) -> None:
+ clock = TickClock.from_parameters(
+ sample_rate=min(SAMPLE_RATES),
+ nes_frequency=MAX_NES_FREQUENCY,
+ )
+ assert all(clock.frame_length(tick) >= 1 for tick in range(1024))
+
+ @pytest.mark.parametrize("nes_frequency", (MIN_NES_FREQUENCY, MAX_NES_FREQUENCY))
+ def test_the_engine_range_is_covered_at_every_rate(self, nes_frequency: int) -> None:
+ for sample_rate in SAMPLE_RATES:
+ clock = TickClock.from_parameters(
+ sample_rate=sample_rate,
+ nes_frequency=nes_frequency,
+ )
+ assert clock.samples_per_tick == Fraction(sample_rate, nes_frequency)
+
+ def test_a_tick_shorter_than_a_sample_is_rejected(self) -> None:
+ with pytest.raises(ValueError, match="samples_per_tick must be at least 1"):
+ TickClock.from_parameters(sample_rate=100, nes_frequency=300)
+
+ @pytest.mark.parametrize("sample_rate", (0, -1))
+ def test_a_rate_below_one_is_rejected(self, sample_rate: int) -> None:
+ with pytest.raises(ValueError, match="sample_rate must be at least 1"):
+ TickClock.from_parameters(sample_rate=sample_rate, nes_frequency=60)
+
+ @pytest.mark.parametrize("nes_frequency", (0, -1))
+ def test_a_tick_rate_below_one_is_rejected(self, nes_frequency: int) -> None:
+ with pytest.raises(ValueError, match="nes_frequency must be at least 1"):
+ TickClock.from_parameters(sample_rate=44100, nes_frequency=nes_frequency)
+
+ def test_a_negative_tick_count_is_rejected(self) -> None:
+ clock = TickClock.from_parameters(sample_rate=44100, nes_frequency=60)
+ with pytest.raises(ValueError, match="ticks must be at least 0"):
+ clock.samples_at(-1)
diff --git a/tests/unit/sampletones_core/timing/test_distribution.py b/tests/unit/sampletones_core/timing/test_distribution.py
new file mode 100644
index 00000000..f659f4b8
--- /dev/null
+++ b/tests/unit/sampletones_core/timing/test_distribution.py
@@ -0,0 +1,181 @@
+from dataclasses import dataclass
+from typing import Tuple
+
+import pytest
+
+from sampletones_core.timing.distribution import distribute_by_halving, distribute_proportionally
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+
+class TestDistributeProportionally(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Tuple[int, ...]
+ total: int
+ lengths: Tuple[int, ...]
+
+ @property
+ def label(self) -> str:
+ spans = "_".join(str(length) for length in self.lengths)
+ return f"total_{self.total}_over_{spans}"
+
+ test_cases = (
+ TestCase(
+ total=69,
+ lengths=(4, 4, 4, 4),
+ expected=(18, 17, 17, 17),
+ ),
+ TestCase(
+ total=69,
+ lengths=(8, 8),
+ expected=(35, 34),
+ ),
+ TestCase(
+ total=274,
+ lengths=(16, 16, 16, 16),
+ expected=(69, 68, 69, 68),
+ ),
+ TestCase(
+ total=17,
+ lengths=(2, 2),
+ expected=(9, 8),
+ ),
+ TestCase(
+ total=18,
+ lengths=(2, 2),
+ expected=(9, 9),
+ ),
+ TestCase(
+ total=100,
+ lengths=(1,),
+ expected=(100,),
+ ),
+ TestCase(
+ total=0,
+ lengths=(4, 4),
+ expected=(0, 0),
+ ),
+ TestCase(
+ total=69,
+ lengths=(12, 4),
+ expected=(52, 17),
+ ),
+ TestCase(
+ total=10,
+ lengths=(1, 1, 1),
+ expected=(4, 3, 3),
+ ),
+ TestCase(
+ total=11,
+ lengths=(1, 1, 1),
+ expected=(4, 4, 3),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_shares_match(self, test_case: TestCase) -> None:
+ shares = distribute_proportionally(test_case.total, test_case.lengths)
+ assert shares == test_case.expected
+ assert sum(shares) == test_case.total
+
+ def test_no_span_is_rejected(self) -> None:
+ with pytest.raises(ValueError, match="At least one span"):
+ distribute_proportionally(10, ())
+
+ def test_empty_span_is_rejected(self) -> None:
+ with pytest.raises(ValueError, match="at least 1 row"):
+ distribute_proportionally(10, (4, 0))
+
+
+class TestDistributeByHalving(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Tuple[int, ...]
+ total: int
+ rows: int
+
+ @property
+ def label(self) -> str:
+ return f"total_{self.total}_over_{self.rows}_rows"
+
+ test_cases = (
+ TestCase(
+ total=22,
+ rows=4,
+ expected=(6, 5, 6, 5),
+ ),
+ TestCase(
+ total=69,
+ rows=16,
+ expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4),
+ ),
+ TestCase(
+ total=18,
+ rows=4,
+ expected=(5, 4, 5, 4),
+ ),
+ TestCase(
+ total=17,
+ rows=4,
+ expected=(5, 4, 4, 4),
+ ),
+ TestCase(
+ total=9,
+ rows=2,
+ expected=(5, 4),
+ ),
+ TestCase(
+ total=100,
+ rows=1,
+ expected=(100,),
+ ),
+ TestCase(
+ total=0,
+ rows=5,
+ expected=(0, 0, 0, 0, 0),
+ ),
+ TestCase(
+ total=13,
+ rows=3,
+ expected=(5, 4, 4),
+ ),
+ TestCase(
+ total=22,
+ rows=5,
+ expected=(5, 5, 4, 4, 4),
+ ),
+ TestCase(
+ total=30,
+ rows=7,
+ expected=(5, 4, 5, 4, 4, 4, 4),
+ ),
+ TestCase(
+ total=52,
+ rows=12,
+ expected=(5, 4, 4, 5, 4, 4, 5, 4, 4, 5, 4, 4),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_ticks_match(self, test_case: TestCase) -> None:
+ ticks = distribute_by_halving(test_case.total, test_case.rows)
+ assert ticks == test_case.expected
+ assert sum(ticks) == test_case.total
+
+ def test_absent_rows_are_rejected(self) -> None:
+ with pytest.raises(ValueError, match="rows must be at least 1"):
+ distribute_by_halving(10, 0)
+
+ @pytest.mark.parametrize("rows", (1, 2, 3, 4, 5, 7, 8, 12, 16, 31, 64))
+ def test_earlier_rows_run_at_least_as_long(self, rows: int) -> None:
+ ticks = distribute_by_halving(rows * 4 + 1, rows)
+ assert ticks[0] == max(ticks)
diff --git a/tests/unit/sampletones_core/timing/test_groove.py b/tests/unit/sampletones_core/timing/test_groove.py
new file mode 100644
index 00000000..f728dd2b
--- /dev/null
+++ b/tests/unit/sampletones_core/timing/test_groove.py
@@ -0,0 +1,883 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from fractions import Fraction
+from typing import Final, Tuple
+
+import pytest
+
+from sampletones_core.timing.groove import Groove, calculate_groove
+from sampletones_core.timing.metre import Metre
+from sampletones_core.timing.rate import RowRate
+from sampletones_shared.constants.project import REFERENCE_NES_FREQUENCY, REFERENCE_TEMPO
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+MINIMUM_TICKS: Final[int] = 1
+MAXIMUM_TICKS: Final[int] = 255
+
+COMMON_TIME_BEAT: Final[int] = 4
+COMMON_TIME_BAR: Final[int] = 16
+
+REFERENCE_SPEED: Final[int] = 6
+
+
+class TestGroove(BaseTestSuite):
+ """One case table, read both for the ticks it produces and for the rules they obey."""
+
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Tuple[int, ...]
+ tempo: int
+ speed: int
+ nes_frequency: int
+ rows: int
+ first_highlight: int
+ second_highlight: int
+
+ @property
+ def label(self) -> str:
+ return (
+ f"tempo_{self.tempo}_speed_{self.speed}_{self.nes_frequency}hz"
+ f"_{self.rows}r_{self.first_highlight}_{self.second_highlight}"
+ )
+
+ @property
+ def metre(self) -> Metre:
+ return Metre(
+ rows=self.rows,
+ first_highlight=self.first_highlight,
+ second_highlight=self.second_highlight,
+ )
+
+ @property
+ def groove(self) -> Groove:
+ return calculate_groove(
+ RowRate.from_parameters(
+ tempo=self.tempo,
+ speed=self.speed,
+ nes_frequency=self.nes_frequency,
+ ),
+ self.metre,
+ minimum_ticks=MINIMUM_TICKS,
+ maximum_ticks=MAXIMUM_TICKS,
+ )
+
+ test_cases = (
+ TestCase(
+ tempo=150,
+ speed=6,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(6,) * 16,
+ ),
+ TestCase(
+ tempo=150,
+ speed=6,
+ nes_frequency=30,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(3,) * 16,
+ ),
+ TestCase(
+ tempo=75,
+ speed=6,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(12,) * 16,
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=15,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=24,
+ rows=12,
+ first_highlight=3,
+ second_highlight=12,
+ expected=(2, 2, 2, 2, 2, 1, 2, 2, 1, 2, 2, 1),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=25,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 1),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=30,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(3, 2, 2, 2, 2, 2, 2, 2, 3, 2, 2, 2, 2, 2, 2, 2),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=50,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(4, 4, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=100,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(8, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 7, 7, 7, 7),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=120,
+ rows=8,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(9, 9, 9, 8, 9, 8, 9, 8),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=300,
+ rows=8,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(22, 21, 22, 21, 22, 21, 21, 21),
+ ),
+ TestCase(
+ tempo=37,
+ speed=13,
+ nes_frequency=25,
+ rows=11,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(22,) * 11,
+ ),
+ TestCase(
+ tempo=33,
+ speed=7,
+ nes_frequency=17,
+ rows=13,
+ first_highlight=3,
+ second_highlight=12,
+ expected=(9,) * 13,
+ ),
+ TestCase(
+ tempo=137,
+ speed=11,
+ nes_frequency=23,
+ rows=7,
+ first_highlight=2,
+ second_highlight=3,
+ expected=(5, 5, 4, 5, 5, 4, 4),
+ ),
+ TestCase(
+ tempo=251,
+ speed=13,
+ nes_frequency=199,
+ rows=17,
+ first_highlight=5,
+ second_highlight=7,
+ expected=(26, 26, 26, 26, 26, 26, 25, 26, 26, 26, 26, 25, 26, 25, 26, 26, 25),
+ ),
+ TestCase(
+ tempo=97,
+ speed=3,
+ nes_frequency=41,
+ rows=9,
+ first_highlight=4,
+ second_highlight=6,
+ expected=(4, 3, 4, 3, 3, 3, 3, 3, 3),
+ ),
+ TestCase(
+ tempo=128,
+ speed=5,
+ nes_frequency=96,
+ rows=15,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(10, 9, 10, 9, 10, 9, 10, 9, 10, 9, 9, 9, 10, 9, 9),
+ ),
+ TestCase(
+ tempo=100,
+ speed=7,
+ nes_frequency=45,
+ rows=13,
+ first_highlight=7,
+ second_highlight=7,
+ expected=(8, 8, 8, 8, 8, 8, 7, 8, 8, 8, 8, 8, 7),
+ ),
+ TestCase(
+ tempo=43,
+ speed=29,
+ nes_frequency=31,
+ rows=11,
+ first_highlight=3,
+ second_highlight=8,
+ expected=(53, 53, 52, 53, 52, 52, 52, 52, 52, 52, 52),
+ ),
+ TestCase(
+ tempo=199,
+ speed=17,
+ nes_frequency=47,
+ rows=19,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(11, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=1,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(4,),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=2,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(5, 4),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=3,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(5, 4, 4),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=5,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(5, 4, 4, 4, 4),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=7,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(5, 4, 5, 4, 4, 4, 4),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=12,
+ first_highlight=3,
+ second_highlight=12,
+ expected=(5, 4, 4, 5, 4, 4, 5, 4, 4, 4, 4, 4),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=12,
+ first_highlight=6,
+ second_highlight=12,
+ expected=(5, 4, 4, 5, 4, 4, 5, 4, 4, 4, 4, 4),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=13,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 4),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=17,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 4),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=23,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 5, 4, 4, 4, 4),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=32,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=60,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(
+ 5,
+ 4,
+ 5,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 5,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ ),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ rows=64,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(
+ 5,
+ 4,
+ 5,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 5,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ 5,
+ 4,
+ 4,
+ 4,
+ ),
+ ),
+ TestCase(
+ tempo=105,
+ speed=6,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=1,
+ second_highlight=1,
+ expected=(9, 9, 8, 9, 8, 9, 8, 9, 9, 8, 9, 8, 9, 8, 9, 8),
+ ),
+ TestCase(
+ tempo=105,
+ speed=6,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=2,
+ second_highlight=4,
+ expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8),
+ ),
+ TestCase(
+ tempo=105,
+ speed=6,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=3,
+ second_highlight=4,
+ expected=(9, 9, 9, 8, 9, 9, 8, 8, 9, 9, 8, 8, 9, 9, 8, 8),
+ ),
+ TestCase(
+ tempo=105,
+ speed=6,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=5,
+ second_highlight=3,
+ expected=(9, 9, 8, 9, 9, 8, 9, 9, 8, 9, 8, 8, 9, 9, 8, 8),
+ ),
+ TestCase(
+ tempo=105,
+ speed=6,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=16,
+ second_highlight=4,
+ expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8),
+ ),
+ TestCase(
+ tempo=105,
+ speed=6,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=64,
+ second_highlight=64,
+ expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8),
+ ),
+ TestCase(
+ tempo=105,
+ speed=6,
+ nes_frequency=60,
+ rows=12,
+ first_highlight=4,
+ second_highlight=6,
+ expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8),
+ ),
+ TestCase(
+ tempo=105,
+ speed=6,
+ nes_frequency=60,
+ rows=20,
+ first_highlight=4,
+ second_highlight=8,
+ expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8),
+ ),
+ TestCase(
+ tempo=105,
+ speed=6,
+ nes_frequency=60,
+ rows=9,
+ first_highlight=2,
+ second_highlight=6,
+ expected=(9, 9, 9, 8, 9, 8, 9, 8, 8),
+ ),
+ TestCase(
+ tempo=105,
+ speed=6,
+ nes_frequency=60,
+ rows=15,
+ first_highlight=5,
+ second_highlight=15,
+ expected=(9, 9, 8, 9, 8, 9, 9, 8, 9, 8, 9, 9, 8, 9, 8),
+ ),
+ TestCase(
+ tempo=150,
+ speed=1,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(1,) * 16,
+ ),
+ TestCase(
+ tempo=151,
+ speed=1,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(1,) * 16,
+ ),
+ TestCase(
+ tempo=140,
+ speed=1,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1),
+ ),
+ TestCase(
+ tempo=300,
+ speed=1,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(1,) * 16,
+ ),
+ TestCase(
+ tempo=255,
+ speed=1,
+ nes_frequency=60,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(1,) * 16,
+ ),
+ TestCase(
+ tempo=255,
+ speed=1,
+ nes_frequency=15,
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(1,) * 16,
+ ),
+ TestCase(
+ tempo=300,
+ speed=1,
+ nes_frequency=15,
+ rows=7,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(1,) * 7,
+ ),
+ TestCase(
+ tempo=50,
+ speed=17,
+ nes_frequency=300,
+ rows=8,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(255,) * 8,
+ ),
+ TestCase(
+ tempo=19,
+ speed=31,
+ nes_frequency=60,
+ rows=8,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(245, 245, 245, 244, 245, 245, 245, 244),
+ ),
+ TestCase(
+ tempo=32,
+ speed=31,
+ nes_frequency=300,
+ rows=8,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(255,) * 8,
+ ),
+ TestCase(
+ tempo=1,
+ speed=31,
+ nes_frequency=300,
+ rows=4,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(255,) * 4,
+ ),
+ TestCase(
+ tempo=1,
+ speed=1,
+ nes_frequency=300,
+ rows=5,
+ first_highlight=4,
+ second_highlight=16,
+ expected=(255,) * 5,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_ticks_match(self, test_case: TestCase) -> None:
+ assert test_case.groove.ticks == test_case.expected
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_groove_fills_the_pattern(self, test_case: TestCase) -> None:
+ assert len(test_case.groove.ticks) == test_case.rows
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_total_and_the_mean_describe_the_rows(self, test_case: TestCase) -> None:
+ groove = test_case.groove
+ assert groove.total_ticks == sum(groove.ticks)
+ assert groove.mean_ticks_per_row == Fraction(groove.total_ticks, test_case.rows)
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_the_rate_is_reached_as_closely_as_the_range_allows(self, test_case: TestCase) -> None:
+ rate = RowRate.from_parameters(
+ tempo=test_case.tempo,
+ speed=test_case.speed,
+ nes_frequency=test_case.nes_frequency,
+ )
+ reachable = min(max(rate.ticks_per_row, MINIMUM_TICKS), MAXIMUM_TICKS)
+ assert abs(test_case.groove.mean_ticks_per_row - reachable) <= Fraction(1, 2 * test_case.rows)
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_every_row_lies_within_the_engine_range(self, test_case: TestCase) -> None:
+ assert all(MINIMUM_TICKS <= ticks <= MAXIMUM_TICKS for ticks in test_case.groove.ticks)
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_every_row_neighbours_the_average(self, test_case: TestCase) -> None:
+ groove = test_case.groove
+ shorter, remainder = divmod(groove.total_ticks, test_case.rows)
+ longer = shorter + 1 if remainder else shorter
+ assert set(groove.ticks) <= {shorter, longer}
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_longer_rows_come_first(self, test_case: TestCase) -> None:
+ groove = test_case.groove
+ elapsed = 0
+ for index, ticks in enumerate(groove.ticks, start=1):
+ elapsed += ticks
+ assert elapsed >= groove.total_ticks * index // test_case.rows
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_each_beat_opens_on_its_longest_row(self, test_case: TestCase) -> None:
+ ticks = test_case.groove.ticks
+ start = 0
+ for beats in test_case.metre.spans:
+ for beat_rows in beats:
+ beat = ticks[start : start + beat_rows]
+ assert beat[0] == max(beat)
+ start += beat_rows
+
+
+class TestReferenceCalibration(BaseTestSuite):
+ """At the reference tempo and tick rate the speed alone states every row's length."""
+
+ @pytest.mark.parametrize("speed", tuple(range(1, 32)))
+ @pytest.mark.parametrize("rows", (1, 2, 7, 16, 60, 64, 256))
+ def test_every_row_lasts_speed_ticks(self, speed: int, rows: int) -> None:
+ groove = calculate_groove(
+ RowRate.from_parameters(
+ tempo=REFERENCE_TEMPO,
+ speed=speed,
+ nes_frequency=REFERENCE_NES_FREQUENCY,
+ ),
+ Metre(
+ rows=rows,
+ first_highlight=COMMON_TIME_BEAT,
+ second_highlight=COMMON_TIME_BAR,
+ ),
+ minimum_ticks=MINIMUM_TICKS,
+ maximum_ticks=MAXIMUM_TICKS,
+ )
+ assert groove.ticks == (speed,) * rows
+ assert groove.is_uniform
+
+
+class TestSecondHighlight(BaseTestSuite):
+ """The bar organizes where the surplus ticks fall, leaving the tempo to the beat."""
+
+ @staticmethod
+ def _groove(rows: int, first_highlight: int, second_highlight: int, tempo: int) -> Groove:
+ return calculate_groove(
+ RowRate.from_parameters(
+ tempo=tempo,
+ speed=REFERENCE_SPEED,
+ nes_frequency=60,
+ ),
+ Metre(
+ rows=rows,
+ first_highlight=first_highlight,
+ second_highlight=second_highlight,
+ ),
+ minimum_ticks=MINIMUM_TICKS,
+ maximum_ticks=MAXIMUM_TICKS,
+ )
+
+ @pytest.mark.parametrize("second_highlight", (1, 2, 3, 4, 7, 8, 12, 16, 20, 32, 64))
+ @pytest.mark.parametrize("rows", (5, 12, 16, 17, 20, 23, 64))
+ @pytest.mark.parametrize("tempo", (32, 105, 210, 255))
+ def test_the_bar_leaves_the_tempo_alone(self, tempo: int, rows: int, second_highlight: int) -> None:
+ grouped = self._groove(rows, COMMON_TIME_BEAT, second_highlight, tempo)
+ pattern_wide = self._groove(rows, COMMON_TIME_BEAT, rows, tempo)
+ assert grouped.total_ticks == pattern_wide.total_ticks
+
+ def test_a_bar_cutting_across_the_beat_reorganizes_the_groove(self) -> None:
+ across = self._groove(4, 2, 3, 105)
+ pattern_wide = self._groove(4, 2, 4, 105)
+ assert across.ticks == (9, 9, 8, 8)
+ assert pattern_wide.ticks == (9, 8, 9, 8)
+ assert across.total_ticks == pattern_wide.total_ticks
+
+ def test_a_bar_shorter_than_the_beat_reorganizes_the_groove(self) -> None:
+ across = self._groove(5, 5, 4, 105)
+ aligned = self._groove(5, 5, 8, 105)
+ assert across.ticks == (9, 9, 9, 8, 8)
+ assert aligned.ticks == (9, 9, 8, 9, 8)
+ assert across.total_ticks == aligned.total_ticks
+
+ def test_a_bar_of_whole_beats_reorganizes_the_groove_too(self) -> None:
+ barred = self._groove(64, COMMON_TIME_BEAT, COMMON_TIME_BAR, 105)
+ pattern_wide = self._groove(64, COMMON_TIME_BEAT, 64, 105)
+ assert barred.ticks == (
+ 9, 9, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8,
+ 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8,
+ 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8,
+ 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8,
+ ) # fmt: skip
+ assert pattern_wide.ticks == (
+ 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8,
+ 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8,
+ 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8,
+ 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8,
+ ) # fmt: skip
+ assert barred.total_ticks == pattern_wide.total_ticks
+
+
+class TestGrooveProperties(BaseTestSuite):
+ def test_total_ticks_sums_the_rows(self) -> None:
+ assert Groove(ticks=(5, 4, 4, 4)).total_ticks == 17
+
+ def test_mean_ticks_per_row_is_exact(self) -> None:
+ assert Groove(ticks=(5, 4, 4, 4)).mean_ticks_per_row == Fraction(17, 4)
+
+ def test_a_varying_groove_is_not_uniform(self) -> None:
+ assert not Groove(ticks=(5, 4, 4, 4)).is_uniform
+
+ def test_a_constant_groove_is_uniform(self) -> None:
+ assert Groove(ticks=(4, 4, 4, 4)).is_uniform
+
+ def test_a_single_row_groove_is_uniform(self) -> None:
+ assert Groove(ticks=(4,)).is_uniform
diff --git a/tests/unit/sampletones_core/timing/test_metre.py b/tests/unit/sampletones_core/timing/test_metre.py
new file mode 100644
index 00000000..a9d7711e
--- /dev/null
+++ b/tests/unit/sampletones_core/timing/test_metre.py
@@ -0,0 +1,161 @@
+from dataclasses import dataclass
+from typing import Tuple
+
+import pytest
+
+from sampletones_core.project.settings import ProjectSettings
+from sampletones_core.timing.metre import Metre
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+
+class TestSpans(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Tuple[Tuple[int, ...], ...]
+ rows: int
+ first_highlight: int
+ second_highlight: int
+
+ @property
+ def label(self) -> str:
+ return f"{self.rows}_rows_at_{self.first_highlight}_{self.second_highlight}"
+
+ test_cases = (
+ TestCase(
+ rows=16,
+ first_highlight=4,
+ second_highlight=16,
+ expected=((4, 4, 4, 4),),
+ ),
+ TestCase(
+ rows=64,
+ first_highlight=4,
+ second_highlight=16,
+ expected=((4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4, 4)),
+ ),
+ TestCase(
+ rows=60,
+ first_highlight=4,
+ second_highlight=16,
+ expected=((4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4)),
+ ),
+ TestCase(
+ rows=17,
+ first_highlight=4,
+ second_highlight=16,
+ expected=((4, 4, 4, 4), (1,)),
+ ),
+ TestCase(
+ rows=12,
+ first_highlight=3,
+ second_highlight=12,
+ expected=((3, 3, 3, 3),),
+ ),
+ TestCase(
+ rows=16,
+ first_highlight=6,
+ second_highlight=12,
+ expected=((6, 6), (4,)),
+ ),
+ TestCase(
+ rows=1,
+ first_highlight=4,
+ second_highlight=16,
+ expected=((1,),),
+ ),
+ TestCase(
+ rows=8,
+ first_highlight=1,
+ second_highlight=1,
+ expected=((1,), (1,), (1,), (1,), (1,), (1,), (1,), (1,)),
+ ),
+ TestCase(
+ rows=8,
+ first_highlight=16,
+ second_highlight=4,
+ expected=((4,), (4,)),
+ ),
+ TestCase(
+ rows=8,
+ first_highlight=64,
+ second_highlight=64,
+ expected=((8,),),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_spans_match(self, test_case: TestCase) -> None:
+ metre = Metre(
+ rows=test_case.rows,
+ first_highlight=test_case.first_highlight,
+ second_highlight=test_case.second_highlight,
+ )
+ assert metre.spans == test_case.expected
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_spans_cover_the_pattern(self, test_case: TestCase) -> None:
+ metre = Metre(
+ rows=test_case.rows,
+ first_highlight=test_case.first_highlight,
+ second_highlight=test_case.second_highlight,
+ )
+ assert sum(sum(beats) for beats in metre.spans) == test_case.rows
+
+
+class TestBounds(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: str
+ field: str
+
+ @property
+ def label(self) -> str:
+ return f"{self.field}_below_one"
+
+ test_cases = (
+ TestCase(
+ field="rows",
+ expected="rows must be at least 1",
+ ),
+ TestCase(
+ field="first_highlight",
+ expected="first_highlight must be at least 1",
+ ),
+ TestCase(
+ field="second_highlight",
+ expected="second_highlight must be at least 1",
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_field_below_one_is_rejected(self, test_case: TestCase) -> None:
+ fields = {"rows": 16, "first_highlight": 4, "second_highlight": 16, test_case.field: 0}
+ with pytest.raises(ValueError, match=test_case.expected):
+ Metre(**fields)
+
+
+class TestProjectSettings:
+ def test_settings_state_the_highlights(self) -> None:
+ settings = ProjectSettings(first_highlight=3, second_highlight=12)
+ assert Metre.from_settings(settings, rows=24) == Metre(
+ rows=24,
+ first_highlight=3,
+ second_highlight=12,
+ )
+
+ def test_the_default_settings_state_common_time(self) -> None:
+ metre = Metre.from_settings(ProjectSettings(), rows=16)
+ assert metre.spans == ((4, 4, 4, 4),)
diff --git a/tests/unit/sampletones_core/timing/test_rate.py b/tests/unit/sampletones_core/timing/test_rate.py
new file mode 100644
index 00000000..2b2cc410
--- /dev/null
+++ b/tests/unit/sampletones_core/timing/test_rate.py
@@ -0,0 +1,126 @@
+from dataclasses import dataclass
+from fractions import Fraction
+
+import pytest
+
+from sampletones_core.project.settings import ProjectSettings
+from sampletones_core.timing.rate import RowRate
+from sampletones_shared.constants.project import REFERENCE_NES_FREQUENCY, REFERENCE_TEMPO
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseAutolabelTestCase
+
+
+class TestRowRate(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseAutolabelTestCase):
+ expected: Fraction
+ tempo: int
+ speed: int
+ nes_frequency: int
+
+ @property
+ def label(self) -> str:
+ return f"tempo_{self.tempo}_speed_{self.speed}_at_{self.nes_frequency}hz"
+
+ test_cases = (
+ TestCase(
+ tempo=REFERENCE_TEMPO,
+ speed=6,
+ nes_frequency=REFERENCE_NES_FREQUENCY,
+ expected=Fraction(6),
+ ),
+ TestCase(
+ tempo=REFERENCE_TEMPO,
+ speed=1,
+ nes_frequency=REFERENCE_NES_FREQUENCY,
+ expected=Fraction(1),
+ ),
+ TestCase(
+ tempo=REFERENCE_TEMPO,
+ speed=31,
+ nes_frequency=REFERENCE_NES_FREQUENCY,
+ expected=Fraction(31),
+ ),
+ TestCase(
+ tempo=75,
+ speed=6,
+ nes_frequency=60,
+ expected=Fraction(12),
+ ),
+ TestCase(
+ tempo=210,
+ speed=6,
+ nes_frequency=60,
+ expected=Fraction(30, 7),
+ ),
+ TestCase(
+ tempo=150,
+ speed=6,
+ nes_frequency=50,
+ expected=Fraction(5),
+ ),
+ TestCase(
+ tempo=150,
+ speed=6,
+ nes_frequency=30,
+ expected=Fraction(3),
+ ),
+ TestCase(
+ tempo=32,
+ speed=1,
+ nes_frequency=60,
+ expected=Fraction(75, 16),
+ ),
+ TestCase(
+ tempo=255,
+ speed=31,
+ nes_frequency=300,
+ expected=Fraction(1550, 17),
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_ticks_per_row(self, test_case: TestCase) -> None:
+ rate = RowRate.from_parameters(
+ tempo=test_case.tempo,
+ speed=test_case.speed,
+ nes_frequency=test_case.nes_frequency,
+ )
+ assert rate.ticks_per_row == test_case.expected
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_ticks_per_row_follows_the_reference_formula(self, test_case: TestCase) -> None:
+ rate = RowRate.from_parameters(
+ tempo=test_case.tempo,
+ speed=test_case.speed,
+ nes_frequency=test_case.nes_frequency,
+ )
+ assert rate.ticks_per_row == Fraction(
+ test_case.speed * test_case.nes_frequency * REFERENCE_TEMPO,
+ test_case.tempo * REFERENCE_NES_FREQUENCY,
+ )
+
+ def test_speed_states_the_tick_count_at_the_reference(self) -> None:
+ for speed in range(1, 32):
+ rate = RowRate.from_parameters(
+ tempo=REFERENCE_TEMPO,
+ speed=speed,
+ nes_frequency=REFERENCE_NES_FREQUENCY,
+ )
+ assert rate.ticks_per_row == speed
+
+ def test_settings_and_parameters_agree(self) -> None:
+ settings = ProjectSettings(tempo=210, speed=6, nes_frequency=60)
+ assert RowRate.from_settings(settings) == RowRate.from_parameters(
+ tempo=settings.tempo,
+ speed=settings.speed,
+ nes_frequency=settings.nes_frequency,
+ )
diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/trackers/test_bitphase.py
index 9d3fca4c..4c9aa41f 100644
--- a/tests/unit/sampletones_core/trackers/test_bitphase.py
+++ b/tests/unit/sampletones_core/trackers/test_bitphase.py
@@ -9,13 +9,20 @@
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.exporters import Features
-from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON
from sampletones_core.project.project import Project
from sampletones_core.project.settings import ProjectSettings
from sampletones_core.trackers.format import TrackerFormat
-from sampletones_core.trackers.implementation.bitphase import BitphaseBackend, BitphasePresetBackend
-from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport
+from sampletones_core.trackers.implementation.bitphase import (
+ BitphaseBackend,
+ BitphasePresetBackend,
+)
+from sampletones_core.trackers.request import (
+ InstrumentExport,
+ ProjectExport,
+ SampleExport,
+)
from sampletones_core.trackers.scope import ExportScope
+from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON
NES_FREQUENCY: Final[int] = 60
REFERENCE_PITCH: Final[int] = 60
diff --git a/tests/unit/sampletones_core/trackers/test_extensions.py b/tests/unit/sampletones_core/trackers/test_extensions.py
index 10ac1651..b9cc907d 100644
--- a/tests/unit/sampletones_core/trackers/test_extensions.py
+++ b/tests/unit/sampletones_core/trackers/test_extensions.py
@@ -3,17 +3,17 @@
import pytest
-from sampletones_core.paths import (
- EXT_FILE_BITPHASE,
- EXT_FILE_INSTRUMENT,
- EXT_FILE_JSON,
- EXT_FILE_MODULE,
-)
from sampletones_core.trackers.backend import TrackerBackend
from sampletones_core.trackers.extensions import format_for_extension
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.registry import build_tracker_backends
from sampletones_core.trackers.scope import ExportScope
+from sampletones_shared.paths.extensions import (
+ EXT_FILE_BITPHASE,
+ EXT_FILE_INSTRUMENT,
+ EXT_FILE_JSON,
+ EXT_FILE_MODULE,
+)
UNKNOWN_EXTENSION: Final[str] = ".xm"
NO_EXTENSION: Final[str] = ""
diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py
index 9b889d10..3f036b02 100644
--- a/tests/unit/sampletones_core/trackers/test_famitracker.py
+++ b/tests/unit/sampletones_core/trackers/test_famitracker.py
@@ -7,12 +7,14 @@
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.exporters import Features
from sampletones_core.exporters.truncation import EnvelopeTruncation
-from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS
-from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE
+from sampletones_core.formats.famitracker.specification.sequences import (
+ MAX_SEQUENCE_ITEMS,
+)
from sampletones_core.trackers.format import TrackerFormat
from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend
from sampletones_core.trackers.request import InstrumentExport, SampleExport
from sampletones_core.trackers.scope import ExportScope
+from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE
NES_FREQUENCY: Final[int] = 60
@@ -116,7 +118,11 @@ def test_each_slice_lands_beside_the_destination_named_after_its_instrument(
tmp_path: Path,
) -> None:
destination = tmp_path / f"Kick{EXT_FILE_INSTRUMENT}"
- request = build_sample("Kick", build_instrument("Kick (pulse1)", 16), build_instrument("Kick (noise)", 16))
+ request = build_sample(
+ "Kick",
+ build_instrument("Kick (pulse1)", 16),
+ build_instrument("Kick (noise)", 16),
+ )
artifact = backend.write_sample(destination, request)
diff --git a/tests/unit/sampletones_core/utils/test_display.py b/tests/unit/sampletones_core/utils/test_display.py
index 57faf551..c342f7e1 100644
--- a/tests/unit/sampletones_core/utils/test_display.py
+++ b/tests/unit/sampletones_core/utils/test_display.py
@@ -1,17 +1,22 @@
-from typing import List, Tuple
+from typing import List, Optional, Tuple
from unittest.mock import Mock
+import pytest
+
from sampletones_core.constants.enums import GeneratorName
from sampletones_core.project import Project
from sampletones_core.project.instruments.instrument import Instrument
from sampletones_core.project.instruments.note_off import NoteOff
from sampletones_core.project.instruments.sample import Sample
from sampletones_core.utils.display import (
+ NOTE_BLANK,
NOTE_OFF,
display_command,
display_id,
display_sample,
display_sample_label,
+ display_transpose,
+ display_volume,
)
@@ -121,3 +126,41 @@ def test_note_off_renders_dashes(self) -> None:
)
== NOTE_OFF
)
+
+
+_TRANSPOSE_CASES = [
+ (5, "+05"),
+ (-5, "-05"),
+ (26, "+1A"),
+ (-26, "-1A"),
+]
+
+
+class TestDisplayTranspose:
+ @pytest.mark.parametrize(("value", "expected"), _TRANSPOSE_CASES)
+ def test_signed_offset_is_two_hexadecimal_digits(self, value: int, expected: str) -> None:
+ assert display_transpose(value) == expected
+
+ def test_explicit_zero_reads_as_a_zero_offset(self) -> None:
+ """A row storing zero resets the channel's transpose, so the cell shows the reset."""
+ assert display_transpose(0) == "+00"
+
+ def test_absent_transpose_is_placeholder(self) -> None:
+ assert display_transpose(None) == NOTE_BLANK
+
+ def test_zero_and_absent_read_apart(self) -> None:
+ assert display_transpose(0) != display_transpose(None)
+
+ @pytest.mark.parametrize("value", [None, 0, 5, -5, 26, -26])
+ def test_every_rendering_is_the_same_width(self, value: Optional[int]) -> None:
+ """The grid lays transpose out in a fixed field, so every value fills it exactly."""
+ assert len(display_transpose(value)) == len(NOTE_BLANK)
+
+
+class TestDisplayVolume:
+ def test_silent_volume_reads_as_zero(self) -> None:
+ """Volume already tells a stored zero apart from an empty cell; this pins it."""
+ assert display_volume(0) == "0"
+
+ def test_absent_volume_is_placeholder(self) -> None:
+ assert display_volume(None) == "."
diff --git a/tests/unit/sampletones_core/utils/test_frequencies.py b/tests/unit/sampletones_core/utils/test_frequencies.py
index 2369660d..8f41a2cd 100644
--- a/tests/unit/sampletones_core/utils/test_frequencies.py
+++ b/tests/unit/sampletones_core/utils/test_frequencies.py
@@ -37,7 +37,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[None, Type[Exception]]
pitch: Any
- test_cases = [
+ test_cases = (
TestCase(
pitch=LIMIT_MIN_PITCH,
expected=None,
@@ -113,7 +113,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="pitch_dict",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -133,7 +133,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[None, Type[Exception]]
frequency: Any
- test_cases = [
+ test_cases = (
TestCase(
frequency=440.0,
expected=None,
@@ -219,7 +219,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="frequency_dict",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -227,7 +227,11 @@ class TestCase(BaseRegularTestCase):
ids=lambda test_case: test_case.label,
)
def test_validate_frequency(self, test_case: TestCase) -> None:
- if expect_error(validate_frequency, test_case.expected, test_case.frequency):
+ if expect_error(
+ validate_frequency,
+ test_case.expected,
+ test_case.frequency,
+ ):
return
validate_frequency(test_case.frequency)
@@ -257,7 +261,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[None, Type[Exception]]
period: Any
- test_cases = [
+ test_cases = (
TestCase(
period=0,
expected=None,
@@ -333,7 +337,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="period_dict",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -355,7 +359,7 @@ class TestCase(BaseRegularTestCase):
a4_frequency: Any
a4_pitch: Any
- test_cases = [
+ test_cases = (
TestCase(
pitch=69,
a4_frequency=440.0,
@@ -566,7 +570,7 @@ class TestCase(BaseRegularTestCase):
expected=ValueError,
label="a4_frequency_nan",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -583,7 +587,11 @@ def test_pitch_to_frequency(self, test_case: TestCase) -> None:
):
return
- result = pitch_to_frequency(test_case.pitch, test_case.a4_frequency, test_case.a4_pitch)
+ result = pitch_to_frequency(
+ test_case.pitch,
+ test_case.a4_frequency,
+ test_case.a4_pitch,
+ )
if isinstance(test_case.expected, float) and np.isnan(test_case.expected):
assert np.isnan(result)
else:
@@ -599,7 +607,7 @@ class TestCase(BaseRegularTestCase):
a4_frequency: Any
a4_pitch: Any
- test_cases = [
+ test_cases = (
TestCase(
frequency=440.0,
a4_frequency=440.0,
@@ -775,7 +783,7 @@ class TestCase(BaseRegularTestCase):
expected=ValueError,
label="frequency_nan",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -792,7 +800,11 @@ def test_frequency_to_pitch(self, test_case: TestCase) -> None:
):
return
- result = frequency_to_pitch(test_case.frequency, test_case.a4_frequency, test_case.a4_pitch)
+ result = frequency_to_pitch(
+ test_case.frequency,
+ test_case.a4_frequency,
+ test_case.a4_pitch,
+ )
assert result == test_case.expected
assert isinstance(result, int)
@@ -804,7 +816,7 @@ class TestCase(BaseRegularTestCase):
pitch: Any
transpose: Any
- test_cases = [
+ test_cases = (
TestCase(
pitch=60,
transpose=0,
@@ -955,7 +967,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="transpose_float",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -981,7 +993,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[str, Type[Exception]]
period: Any
- test_cases = [
+ test_cases = (
TestCase(
period=0,
expected="0-#",
@@ -1047,7 +1059,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="period_float",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1070,7 +1082,7 @@ class TestCase(BaseRegularTestCase):
min_pitch: Any
max_pitch: Any
- test_cases = [
+ test_cases = (
TestCase(
pitch=60,
min_pitch=MIN_PITCH,
@@ -1246,7 +1258,7 @@ class TestCase(BaseRegularTestCase):
expected=ValueError,
label="max_pitch_out_of_bounds",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1274,7 +1286,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[int, Type[Exception]]
period: Any
- test_cases = [
+ test_cases = (
TestCase(
period=5,
expected=5,
@@ -1330,7 +1342,7 @@ class TestCase(BaseRegularTestCase):
expected=5,
label="period_float",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1356,7 +1368,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[str, Type[Exception]]
name: Any
- test_cases = [
+ test_cases = (
TestCase(
name=" hello world ",
expected="HELLO WORLD",
@@ -1432,7 +1444,7 @@ class TestCase(BaseRegularTestCase):
expected=AttributeError,
label="name_dict",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1453,7 +1465,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[str, Type[Exception]]
name: Any
- test_cases = [
+ test_cases = (
TestCase(
name="C#4",
expected="C#4",
@@ -1544,7 +1556,7 @@ class TestCase(BaseRegularTestCase):
expected=AttributeError,
label="name_dict",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1565,7 +1577,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[str, Type[Exception]]
name: Any
- test_cases = [
+ test_cases = (
TestCase(
name="0A",
expected="0A",
@@ -1656,7 +1668,7 @@ class TestCase(BaseRegularTestCase):
expected=AttributeError,
label="name_dict",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_core/utils/test_pitch_kind.py b/tests/unit/sampletones_core/utils/test_pitch_kind.py
index 52963d67..3b309b1b 100644
--- a/tests/unit/sampletones_core/utils/test_pitch_kind.py
+++ b/tests/unit/sampletones_core/utils/test_pitch_kind.py
@@ -3,7 +3,11 @@
import pytest
from sampletones_core.constants.general import MAX_PERIOD, MAX_PITCH, MIN_PITCH
-from sampletones_core.utils.pitch_kind import PERIOD_VALUE_KIND, PITCH_VALUE_KIND, PitchValueKind
+from sampletones_core.utils.pitch_kind import (
+ PERIOD_VALUE_KIND,
+ PITCH_VALUE_KIND,
+ PitchValueKind,
+)
@dataclass(frozen=True)
diff --git a/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py b/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py
index 86dda1e3..2145ce5b 100644
--- a/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py
+++ b/tests/unit/sampletones_shared/meta/source/bindings/test_containers.py
@@ -39,18 +39,28 @@ def typed_names(
accessor: Optional[str],
item_types: Tuple[str, ...],
) -> List[Tuple[str, str]]:
- types = iterated_types(loop_target(f"for {target} in container: pass"), accessor, item_types)
+ types = iterated_types(
+ loop_target(f"for {target} in container: pass"),
+ accessor,
+ item_types,
+ )
return [(ast.unparse(entry.target), entry.type_name) for entry in types]
class TestIteratedContainer:
def test_a_mapping_walked_directly_is_read(self) -> None:
container = iterated_container(expression("FILTERS"))
- assert container is not None and (container.spelling, container.accessor) == ("FILTERS", None)
+ assert container is not None and (container.spelling, container.accessor) == (
+ "FILTERS",
+ None,
+ )
def test_an_accessor_travels_with_the_container(self) -> None:
container = iterated_container(expression("self._filters.items()"))
- assert container is not None and (container.spelling, container.accessor) == ("self._filters", "items")
+ assert container is not None and (container.spelling, container.accessor) == (
+ "self._filters",
+ "items",
+ )
def test_a_call_of_another_kind_reads_no_container(self) -> None:
assert iterated_container(expression("enumerate(FILTERS)")) is None
@@ -70,13 +80,30 @@ def test_walking_items_onto_one_target_types_nothing(self) -> None:
assert typed_names("pair", "items", FILTER_TYPES) == []
def test_walking_values_types_the_target_from_the_value_type(self) -> None:
- assert typed_names("element", "values", FILTER_TYPES) == [("element", "FileFilterElements")]
+ assert typed_names("element", "values", FILTER_TYPES) == [
+ (
+ "element",
+ "FileFilterElements",
+ )
+ ]
def test_walking_keys_types_the_target_from_the_key_type(self) -> None:
- assert typed_names("tracker_format", "keys", FILTER_TYPES) == [("tracker_format", "TrackerFormat")]
+ assert typed_names("tracker_format", "keys", FILTER_TYPES) == [
+ (
+ "tracker_format",
+ "TrackerFormat",
+ )
+ ]
- def test_walking_a_container_directly_types_the_target_from_the_key_type(self) -> None:
- assert typed_names("tracker_format", None, FILTER_TYPES) == [("tracker_format", "TrackerFormat")]
+ def test_walking_a_container_directly_types_the_target_from_the_key_type(
+ self,
+ ) -> None:
+ assert typed_names("tracker_format", None, FILTER_TYPES) == [
+ (
+ "tracker_format",
+ "TrackerFormat",
+ )
+ ]
def test_walking_a_sequence_types_the_target_from_its_item_type(self) -> None:
assert typed_names("name", None, ("str",)) == [("name", "str")]
diff --git a/tests/unit/sampletones_shared/meta/source/bindings/test_environment.py b/tests/unit/sampletones_shared/meta/source/bindings/test_environment.py
index 6a67af53..79bc0bb9 100644
--- a/tests/unit/sampletones_shared/meta/source/bindings/test_environment.py
+++ b/tests/unit/sampletones_shared/meta/source/bindings/test_environment.py
@@ -24,10 +24,18 @@ def test_a_spelling_the_environment_omits_states_nothing(self) -> None:
class TestSpellingsOf:
def test_every_holder_of_a_type_is_named(self) -> None:
- assert ENVIRONMENT.spellings_of("LanguageManager") == ("language_manager", "self._language_manager")
+ assert ENVIRONMENT.spellings_of("LanguageManager") == (
+ "language_manager",
+ "self._language_manager",
+ )
def test_holders_arrive_in_the_order_they_were_read(self) -> None:
- environment = TypeEnvironment(types={"second": "Manager", "first": "Manager"})
+ environment = TypeEnvironment(
+ types={
+ "second": "Manager",
+ "first": "Manager",
+ }
+ )
assert environment.spellings_of("Manager") == ("second", "first")
def test_a_type_no_spelling_holds_names_nobody(self) -> None:
diff --git a/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py b/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py
index 97b4dc35..22ee5cc5 100644
--- a/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py
+++ b/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py
@@ -23,7 +23,7 @@ def _context_label(self, element: ContextElements) -> str:
return self._language_manager[element]
def _load(self, language_manager: LanguageManager) -> None:
- def label(element: SequencerGridElements) -> str:
+ def label(element: SequencerTrackerElements) -> str:
return language_manager[element]
self._labels = [label(item) for item in FILTERS.values()]
@@ -59,11 +59,18 @@ def create() -> None:
}
-def read_scopes(source: str, imported_item_types: Mapping[str, Tuple[str, ...]]) -> List[Scope]:
+def read_scopes(
+ source: str,
+ imported_item_types: Mapping[str, Tuple[str, ...]],
+) -> List[Scope]:
return module_scopes(parse_source(source), imported_item_types=imported_item_types)
-def environment_of(source: str, name: str, imported_item_types: Mapping[str, Tuple[str, ...]]) -> TypeEnvironment:
+def environment_of(
+ source: str,
+ name: str,
+ imported_item_types: Mapping[str, Tuple[str, ...]],
+) -> TypeEnvironment:
return scope_named(read_scopes(source, imported_item_types), name).environment
@@ -90,7 +97,7 @@ def test_a_nested_parameter_stays_out_of_the_enclosing_scope(self) -> None:
assert panel_environment("_load").type_of("element") is None
def test_a_nested_scope_states_its_own_parameter(self) -> None:
- assert panel_environment("label").type_of("element") == "SequencerGridElements"
+ assert panel_environment("label").type_of("element") == "SequencerTrackerElements"
def test_a_nested_scope_sees_the_enclosing_parameters(self) -> None:
assert panel_environment("label").type_of("language_manager") == "LanguageManager"
@@ -129,7 +136,10 @@ def test_the_spellings_of_a_type_leave_other_names_aside(self) -> None:
class TestLoopTargets:
def test_walking_items_states_the_key_and_the_value_type(self) -> None:
environment = panel_environment("_filters")
- assert (environment.type_of("tracker_format"), environment.type_of("element")) == (
+ assert (
+ environment.type_of("tracker_format"),
+ environment.type_of("element"),
+ ) == (
"TrackerFormat",
"FileFilterElements",
)
@@ -141,8 +151,19 @@ def test_walking_a_mapping_states_the_key_type(self) -> None:
assert panel_environment("_names").type_of("name") == "TrackerFormat"
def test_an_imported_container_states_its_item_types(self) -> None:
- assert environment_of(IMPORTED_SOURCE, "create", IMPORTED_FILTERS).type_of("element") == "FileFilterElements"
+ assert (
+ environment_of(
+ IMPORTED_SOURCE,
+ "create",
+ IMPORTED_FILTERS,
+ ).type_of("element")
+ == "FileFilterElements"
+ )
def test_a_container_the_module_annotates_states_its_own_item_types(self) -> None:
- environment = environment_of(LOCAL_OVER_IMPORTED_SOURCE, "create", IMPORTED_FILTERS)
+ environment = environment_of(
+ LOCAL_OVER_IMPORTED_SOURCE,
+ "create",
+ IMPORTED_FILTERS,
+ )
assert environment.type_of("element") == "MenuElements"
diff --git a/tests/unit/sampletones_shared/meta/source/bindings/test_statements.py b/tests/unit/sampletones_shared/meta/source/bindings/test_statements.py
index 09576ead..8aa1043f 100644
--- a/tests/unit/sampletones_shared/meta/source/bindings/test_statements.py
+++ b/tests/unit/sampletones_shared/meta/source/bindings/test_statements.py
@@ -23,7 +23,10 @@ class TestAnnotations:
def test_an_annotated_parameter_names_its_type(self) -> None:
statement = statement_of("def label(element: MenuElements) -> str:\n return ''", ast.arg)
assert isinstance(statement, TypeStatement)
- assert (statement.spelling, statement.type_name) == ("element", "MenuElements")
+ assert (statement.spelling, statement.type_name) == (
+ "element",
+ "MenuElements",
+ )
def test_a_parameter_without_an_annotation_states_nothing(self) -> None:
assert statement_of("def label(element):\n return ''", ast.arg) is None
@@ -31,12 +34,18 @@ def test_a_parameter_without_an_annotation_states_nothing(self) -> None:
def test_an_annotated_attribute_names_its_type(self) -> None:
statement = statement_of("self._manager: Optional[LanguageManager] = None", ast.AnnAssign)
assert isinstance(statement, TypeStatement)
- assert (statement.spelling, statement.type_name) == ("self._manager", "LanguageManager")
+ assert (statement.spelling, statement.type_name) == (
+ "self._manager",
+ "LanguageManager",
+ )
def test_an_annotation_alone_names_its_type(self) -> None:
statement = statement_of("manager: LanguageManager", ast.AnnAssign)
assert isinstance(statement, TypeStatement)
- assert (statement.spelling, statement.type_name) == ("manager", "LanguageManager")
+ assert (statement.spelling, statement.type_name) == (
+ "manager",
+ "LanguageManager",
+ )
def test_an_annotated_subscript_states_nothing(self) -> None:
assert statement_of("managers['first']: LanguageManager = build()", ast.AnnAssign) is None
@@ -46,22 +55,34 @@ class TestAssignments:
def test_a_construction_names_the_type_it_builds(self) -> None:
statement = statement_of("self._manager = LanguageManager(path)", ast.Assign)
assert isinstance(statement, TypeStatement)
- assert (statement.spelling, statement.type_name) == ("self._manager", "LanguageManager")
+ assert (statement.spelling, statement.type_name) == (
+ "self._manager",
+ "LanguageManager",
+ )
def test_a_construction_through_a_module_names_the_type(self) -> None:
statement = statement_of("manager = categories.LanguageManager(path)", ast.Assign)
assert isinstance(statement, TypeStatement)
- assert (statement.spelling, statement.type_name) == ("manager", "LanguageManager")
+ assert (statement.spelling, statement.type_name) == (
+ "manager",
+ "LanguageManager",
+ )
def test_an_assignment_from_a_name_passes_that_name_along(self) -> None:
statement = statement_of("self._manager = language_manager", ast.Assign)
assert isinstance(statement, AliasStatement)
- assert (statement.target, statement.source) == ("self._manager", "language_manager")
+ assert (statement.target, statement.source) == (
+ "self._manager",
+ "language_manager",
+ )
def test_an_assignment_from_an_attribute_passes_the_chain_along(self) -> None:
statement = statement_of("self._same = self._manager", ast.Assign)
assert isinstance(statement, AliasStatement)
- assert (statement.target, statement.source) == ("self._same", "self._manager")
+ assert (statement.target, statement.source) == (
+ "self._same",
+ "self._manager",
+ )
def test_an_assignment_from_a_literal_states_nothing(self) -> None:
assert statement_of("TAG_MAIN = 'main'", ast.Assign) is None
@@ -77,12 +98,18 @@ class TestLoops:
def test_a_for_statement_binds_its_target_to_a_container(self) -> None:
statement = statement_of("for element in FILTERS.values():\n print(element)", ast.For)
assert isinstance(statement, LoopStatement)
- assert (ast.unparse(statement.target), ast.unparse(statement.iterable)) == ("element", "FILTERS.values()")
+ assert (ast.unparse(statement.target), ast.unparse(statement.iterable)) == (
+ "element",
+ "FILTERS.values()",
+ )
def test_a_comprehension_binds_its_target_to_a_container(self) -> None:
statement = statement_of("labels = [label(item) for item in FILTERS]", ast.comprehension)
assert isinstance(statement, LoopStatement)
- assert (ast.unparse(statement.target), ast.unparse(statement.iterable)) == ("item", "FILTERS")
+ assert (ast.unparse(statement.target), ast.unparse(statement.iterable)) == (
+ "item",
+ "FILTERS",
+ )
class TestOtherNodes:
diff --git a/tests/unit/sampletones_shared/meta/source/test_annotations.py b/tests/unit/sampletones_shared/meta/source/test_annotations.py
index a5ab04a9..e08917df 100644
--- a/tests/unit/sampletones_shared/meta/source/test_annotations.py
+++ b/tests/unit/sampletones_shared/meta/source/test_annotations.py
@@ -23,25 +23,85 @@ class TestCase(BaseRegularTestCase):
annotation: str
expected: Optional[str]
- test_cases = [
+ test_cases = (
TestCase(label="plain_name", annotation="LanguageManager", expected="LanguageManager"),
- TestCase(label="optional", annotation="Optional[LanguageManager]", expected="LanguageManager"),
- TestCase(label="final", annotation="Final[str]", expected="str"),
- TestCase(label="class_variable", annotation="ClassVar[Page]", expected="Page"),
- TestCase(label="annotated", annotation="Annotated[Page, 'unit']", expected="Page"),
- TestCase(label="qualified_wrapper", annotation="typing.Optional[LanguageManager]", expected="LanguageManager"),
- TestCase(label="qualified_name", annotation="categories.LanguageManager", expected="LanguageManager"),
- TestCase(label="generic_states_itself", annotation="Dict[str, int]", expected="Dict"),
- TestCase(label="wrapped_generic", annotation="Final[Dict[Page, Panel]]", expected="Dict"),
- TestCase(label="nested_wrappers", annotation="Final[Optional[LanguageManager]]", expected="LanguageManager"),
- TestCase(label="quoted", annotation="'LanguageManager'", expected="LanguageManager"),
- TestCase(label="quoted_inside_wrapper", annotation="Optional['LanguageManager']", expected="LanguageManager"),
- TestCase(label="none", annotation="None", expected=None),
- TestCase(label="call", annotation="build()", expected=None),
- TestCase(label="quoted_beyond_python", annotation="'not python('", expected=None),
- ]
-
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ TestCase(
+ label="optional",
+ annotation="Optional[LanguageManager]",
+ expected="LanguageManager",
+ ),
+ TestCase(
+ label="final",
+ annotation="Final[str]",
+ expected="str",
+ ),
+ TestCase(
+ label="class_variable",
+ annotation="ClassVar[Page]",
+ expected="Page",
+ ),
+ TestCase(
+ label="annotated",
+ annotation="Annotated[Page, 'unit']",
+ expected="Page",
+ ),
+ TestCase(
+ label="qualified_wrapper",
+ annotation="typing.Optional[LanguageManager]",
+ expected="LanguageManager",
+ ),
+ TestCase(
+ label="qualified_name",
+ annotation="categories.LanguageManager",
+ expected="LanguageManager",
+ ),
+ TestCase(
+ label="generic_states_itself",
+ annotation="Dict[str, int]",
+ expected="Dict",
+ ),
+ TestCase(
+ label="wrapped_generic",
+ annotation="Final[Dict[Page, Panel]]",
+ expected="Dict",
+ ),
+ TestCase(
+ label="nested_wrappers",
+ annotation="Final[Optional[LanguageManager]]",
+ expected="LanguageManager",
+ ),
+ TestCase(
+ label="quoted",
+ annotation="'LanguageManager'",
+ expected="LanguageManager",
+ ),
+ TestCase(
+ label="quoted_inside_wrapper",
+ annotation="Optional['LanguageManager']",
+ expected="LanguageManager",
+ ),
+ TestCase(
+ label="none",
+ annotation="None",
+ expected=None,
+ ),
+ TestCase(
+ label="call",
+ annotation="build()",
+ expected=None,
+ ),
+ TestCase(
+ label="quoted_beyond_python",
+ annotation="'not python('",
+ expected=None,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_annotation_type_name(self, test_case: TestCase) -> None:
assert annotation_type_name(annotation(test_case.annotation)) == test_case.expected
@@ -55,7 +115,7 @@ class TestCase(BaseRegularTestCase):
annotation: str
expected: Tuple[str, ...]
- test_cases = [
+ test_cases = (
TestCase(
label="mapping_states_key_then_value",
annotation="Dict[TrackerFormat, FileFilterElements]",
@@ -66,15 +126,43 @@ class TestCase(BaseRegularTestCase):
annotation="Final[Dict[TrackerFormat, FileFilterElements]]",
expected=("TrackerFormat", "FileFilterElements"),
),
- TestCase(label="homogeneous_tuple", annotation="Tuple[MenuElements, ...]", expected=("MenuElements",)),
- TestCase(label="list", annotation="List[MenuElements]", expected=("MenuElements",)),
- TestCase(label="optional_item", annotation="List[Optional[MenuElements]]", expected=("MenuElements",)),
- TestCase(label="nested_mapping", annotation="Dict[str, Dict[str, MenuElements]]", expected=("str", "Dict")),
- TestCase(label="plain_name_holds_nothing", annotation="str", expected=()),
- TestCase(label="unwrapped_scalar_holds_nothing", annotation="Optional[MenuElements]", expected=()),
- ]
+ TestCase(
+ label="homogeneous_tuple",
+ annotation="Tuple[MenuElements, ...]",
+ expected=("MenuElements",),
+ ),
+ TestCase(
+ label="list",
+ annotation="List[MenuElements]",
+ expected=("MenuElements",),
+ ),
+ TestCase(
+ label="optional_item",
+ annotation="List[Optional[MenuElements]]",
+ expected=("MenuElements",),
+ ),
+ TestCase(
+ label="nested_mapping",
+ annotation="Dict[str, Dict[str, MenuElements]]",
+ expected=("str", "Dict"),
+ ),
+ TestCase(
+ label="plain_name_holds_nothing",
+ annotation="str",
+ expected=(),
+ ),
+ TestCase(
+ label="unwrapped_scalar_holds_nothing",
+ annotation="Optional[MenuElements]",
+ expected=(),
+ ),
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_annotation_item_types(self, test_case: TestCase) -> None:
assert annotation_item_types(annotation(test_case.annotation)) == test_case.expected
diff --git a/tests/unit/sampletones_shared/meta/source/test_classes.py b/tests/unit/sampletones_shared/meta/source/test_classes.py
new file mode 100644
index 00000000..00603297
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/source/test_classes.py
@@ -0,0 +1,73 @@
+from typing import Final, List
+
+from sampletones_shared.meta.source.classes import declared_subclasses
+from tests.suite.source import parse_source
+
+CLASSES_SOURCE: Final[str] = """
+from package import AbstractElement
+import package
+
+
+class DialogElements(AbstractElement):
+ OK = "ok"
+
+
+class QualifiedElements(package.AbstractElement):
+ EXIT = "exit"
+
+
+class MixedElements(Mixin, AbstractElement):
+ HELP = "help"
+
+
+class Panel(StrEnum):
+ MENU = "menu"
+
+
+class Holder:
+ class NestedElements(AbstractElement):
+ INNER = "inner"
+
+
+def build() -> None:
+ class LocalElements(AbstractElement):
+ LOCAL = "local"
+"""
+
+ELEMENT_BASE: Final[str] = "AbstractElement"
+
+
+def names(source: str, base: str) -> List[str]:
+ return declared_subclasses(parse_source(source), base)
+
+
+class TestDeclaredSubclasses:
+ def test_a_class_over_the_base_is_read(self) -> None:
+ assert "DialogElements" in names(CLASSES_SOURCE, ELEMENT_BASE)
+
+ def test_a_base_written_as_an_attribute_chain_is_read(self) -> None:
+ assert "QualifiedElements" in names(CLASSES_SOURCE, ELEMENT_BASE)
+
+ def test_a_base_beside_another_is_read(self) -> None:
+ assert "MixedElements" in names(CLASSES_SOURCE, ELEMENT_BASE)
+
+ def test_a_class_over_another_base_stays_aside(self) -> None:
+ assert "Panel" not in names(CLASSES_SOURCE, ELEMENT_BASE)
+
+ def test_a_class_nested_in_another_is_read(self) -> None:
+ assert "NestedElements" in names(CLASSES_SOURCE, ELEMENT_BASE)
+
+ def test_a_class_declared_inside_a_function_is_read(self) -> None:
+ assert "LocalElements" in names(CLASSES_SOURCE, ELEMENT_BASE)
+
+ def test_classes_are_read_outermost_first(self) -> None:
+ assert names(CLASSES_SOURCE, ELEMENT_BASE) == [
+ "DialogElements",
+ "QualifiedElements",
+ "MixedElements",
+ "NestedElements",
+ "LocalElements",
+ ]
+
+ def test_a_base_nothing_derives_from_is_read_from_nowhere(self) -> None:
+ assert names(CLASSES_SOURCE, "AbsentBase") == []
diff --git a/tests/unit/sampletones_shared/meta/source/test_constants.py b/tests/unit/sampletones_shared/meta/source/test_constants.py
index eaee591c..1c76fadd 100644
--- a/tests/unit/sampletones_shared/meta/source/test_constants.py
+++ b/tests/unit/sampletones_shared/meta/source/test_constants.py
@@ -71,4 +71,10 @@ def test_the_line_names_where_the_statement_sits(self) -> None:
assert by_name(CONSTANTS_SOURCE)["TAG_MAIN_WINDOW"].line == 4
def test_constants_are_read_in_source_order(self) -> None:
- assert names(CONSTANTS_SOURCE) == ["TAG_MAIN_WINDOW", "SUF_BUTTON", "FIRST", "SECOND", "FILTERS"]
+ assert names(CONSTANTS_SOURCE) == [
+ "TAG_MAIN_WINDOW",
+ "SUF_BUTTON",
+ "FIRST",
+ "SECOND",
+ "FILTERS",
+ ]
diff --git a/tests/unit/sampletones_shared/meta/source/test_index.py b/tests/unit/sampletones_shared/meta/source/test_index.py
index 4961cbd6..a2da5abb 100644
--- a/tests/unit/sampletones_shared/meta/source/test_index.py
+++ b/tests/unit/sampletones_shared/meta/source/test_index.py
@@ -31,7 +31,10 @@ def index_of(*sources: str) -> SourceIndex:
class TestSourceIndex:
def test_a_container_states_its_item_types(self) -> None:
- assert index_of(TAGS_SOURCE).item_types["FILTERS"] == ("TrackerFormat", "FileFilterElements")
+ assert index_of(TAGS_SOURCE).item_types["FILTERS"] == (
+ "TrackerFormat",
+ "FileFilterElements",
+ )
def test_a_constant_states_its_value(self) -> None:
value = index_of(TAGS_SOURCE).constants["TAG_MAIN_WINDOW"]
diff --git a/tests/unit/sampletones_shared/meta/source/test_lookups.py b/tests/unit/sampletones_shared/meta/source/test_lookups.py
index 37278e9a..1db807db 100644
--- a/tests/unit/sampletones_shared/meta/source/test_lookups.py
+++ b/tests/unit/sampletones_shared/meta/source/test_lookups.py
@@ -5,7 +5,12 @@
import pytest
from sampletones_shared.meta.source.index import source_index
-from sampletones_shared.meta.source.lookups import LookupSite, composed_values, module_lookups, tree_lookups
+from sampletones_shared.meta.source.lookups import (
+ LookupSite,
+ composed_values,
+ module_lookups,
+ tree_lookups,
+)
from sampletones_shared.meta.source.modules import SourceModule
from sampletones_shared.meta.source.values import UNRESOLVED, EnumTable, ResolvedValues
from tests.suite.base import BaseTestSuite
@@ -95,7 +100,7 @@ class TestCase(BaseRegularTestCase):
literal_dialog = ResolvedValues(values=("dialog",), exact=True)
literal_label = ResolvedValues(values=("label",), exact=True)
- test_cases = [
+ test_cases = (
TestCase(
label="one_part",
resolutions=(ResolvedValues(values=("global.dialog.label.ok",), exact=True),),
@@ -146,10 +151,14 @@ class TestCase(BaseRegularTestCase):
resolutions=(),
expected=("",),
),
- ]
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
- def test_composed_values(self, test_case: "TestComposedValues.TestCase") -> None:
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_composed_values(self, test_case: TestCase) -> None:
assert composed_values(test_case.resolutions, SEPARATOR) == test_case.expected
@@ -164,14 +173,21 @@ def test_parts_of_literal_members_state_one_value(self) -> None:
def test_a_part_arriving_in_a_variable_states_every_member(self) -> None:
site = site_on_line(PANEL_SOURCE, 8)
- assert set(site.values) == {"global.dialog.label.ok", "global.dialog.label.exit"}
+ assert set(site.values) == {
+ "global.dialog.label.ok",
+ "global.dialog.label.exit",
+ }
def test_a_value_reached_through_an_enum_is_no_literal(self) -> None:
assert site_on_line(PANEL_SOURCE, 8).exact is False
def test_a_part_typed_by_an_enum_without_members_reaches_nothing(self) -> None:
site = site_on_line(PANEL_SOURCE, 14)
- assert (site.values, site.unresolved_parts, site.resolved) == ((), ("'element'",), False)
+ assert (site.values, site.unresolved_parts, site.resolved) == (
+ (),
+ ("'element'",),
+ False,
+ )
def test_a_value_an_f_string_builds_reaches_nothing(self) -> None:
site = site_on_line(PANEL_SOURCE, 17)
@@ -199,7 +215,9 @@ def test_a_constant_declared_in_the_same_module_states_its_value(self) -> None:
(site,) = lookups(CONSTANT_SOURCE)
assert site.values == ("global.dialog.label.ok",)
- def test_a_container_annotated_in_another_module_types_a_walked_target(self) -> None:
+ def test_a_container_annotated_in_another_module_types_a_walked_target(
+ self,
+ ) -> None:
declaring = "from typing import Dict, Final\n\nFILTERS: Final[Dict[str, DialogElements]] = {}\n"
reading = (
"def labels(language_manager: LanguageManager) -> None:\n"
@@ -207,7 +225,10 @@ def test_a_container_annotated_in_another_module_types_a_walked_target(self) ->
" print(language_manager[Page.GLOBAL, Panel.DIALOG, TextType.LABEL, element])\n"
)
(site,) = lookups(declaring, reading)
- assert set(site.values) == {"global.dialog.label.ok", "global.dialog.label.exit"}
+ assert set(site.values) == {
+ "global.dialog.label.ok",
+ "global.dialog.label.exit",
+ }
def test_every_module_of_the_tree_is_read(self) -> None:
first = "def label(language_manager: LanguageManager) -> str:\n return language_manager['global.dialog.label.ok']"
diff --git a/tests/unit/sampletones_shared/meta/source/test_modules.py b/tests/unit/sampletones_shared/meta/source/test_modules.py
index b3ce9a82..777586cc 100644
--- a/tests/unit/sampletones_shared/meta/source/test_modules.py
+++ b/tests/unit/sampletones_shared/meta/source/test_modules.py
@@ -1,4 +1,5 @@
import ast
+import re
from pathlib import Path
from typing import Final
@@ -7,6 +8,7 @@
from sampletones_shared.meta.source.modules import (
discover_modules,
is_visible,
+ module_name,
parse_module,
source_paths,
)
@@ -59,6 +61,21 @@ def test_a_hidden_file_is_hidden(self) -> None:
assert not is_visible(Path("src/.generated.py"))
+class TestModuleName:
+ def test_a_module_is_named_by_the_path_reaching_it(self) -> None:
+ assert module_name(Path("/src/package/inner/module.py"), Path("/src")) == "package.inner.module"
+
+ def test_a_module_at_the_root_is_named_alone(self) -> None:
+ assert module_name(Path("/src/module.py"), Path("/src")) == "module"
+
+ def test_an_initializer_names_the_package_holding_it(self) -> None:
+ assert module_name(Path("/src/package/inner/__init__.py"), Path("/src")) == "package.inner"
+
+ def test_a_file_outside_the_root_raises(self) -> None:
+ with pytest.raises(ValueError):
+ module_name(Path("/elsewhere/module.py"), Path("/src"))
+
+
class TestSourcePaths:
def test_every_module_under_a_root_is_found(self, tmp_path: Path) -> None:
first = write_module(tmp_path / "package", "first.py", MODULE_BODY)
@@ -80,9 +97,42 @@ def test_a_file_of_another_kind_stays_aside(self, tmp_path: Path) -> None:
assert source_paths([tmp_path]) == [visible]
+class TestSweptRoots:
+ """A sweep reading nothing leaves a check reporting nothing, which reads as a clean tree."""
+
+ def test_an_absent_root_raises(self, tmp_path: Path) -> None:
+ with pytest.raises(NotADirectoryError):
+ source_paths([tmp_path / "absent"])
+
+ def test_a_root_naming_a_module_raises(self, tmp_path: Path) -> None:
+ """A package resource resolves to `__init__.py`, which a sweep reads nothing under."""
+ path = write_module(tmp_path, "first.py", MODULE_BODY)
+ with pytest.raises(NotADirectoryError):
+ source_paths([path])
+
+ def test_a_root_beside_a_readable_one_is_held_to_the_same_rule(self, tmp_path: Path) -> None:
+ write_module(tmp_path / "package", "first.py", MODULE_BODY)
+ with pytest.raises(NotADirectoryError):
+ source_paths([tmp_path / "package", tmp_path / "absent"])
+
+ def test_roots_holding_no_source_raise(self, tmp_path: Path) -> None:
+ with pytest.raises(FileNotFoundError):
+ source_paths([tmp_path])
+
+ def test_the_report_names_the_root_it_read_nothing_under(self, tmp_path: Path) -> None:
+ """A Windows root spells separators and drive letters a regex reads as escapes, so the path
+ is quoted before it is matched."""
+ with pytest.raises(FileNotFoundError, match=re.escape(str(tmp_path))):
+ source_paths([tmp_path])
+
+
class TestDiscoverModules:
def test_every_module_found_is_parsed(self, tmp_path: Path) -> None:
write_module(tmp_path, "first.py", MODULE_BODY)
write_module(tmp_path / "inner", "second.py", MODULE_BODY)
modules = discover_modules([tmp_path])
assert [module.path.name for module in modules] == ["first.py", "second.py"]
+
+ def test_a_root_holding_no_module_raises(self, tmp_path: Path) -> None:
+ with pytest.raises(FileNotFoundError):
+ discover_modules([tmp_path])
diff --git a/tests/unit/sampletones_shared/meta/source/test_nodes.py b/tests/unit/sampletones_shared/meta/source/test_nodes.py
index c01ea152..1d68f1b3 100644
--- a/tests/unit/sampletones_shared/meta/source/test_nodes.py
+++ b/tests/unit/sampletones_shared/meta/source/test_nodes.py
@@ -111,7 +111,10 @@ def test_a_function_owns_its_parameters(self) -> None:
node for node in nested_scopes(parse_source(SCOPED_SOURCE)) if function_names([node]) == ["method"]
)
owned = list(own_nodes(method))
- assert [node.arg for node in owned if isinstance(node, ast.arg)] == ["self", "key"]
+ assert [node.arg for node in owned if isinstance(node, ast.arg)] == [
+ "self",
+ "key",
+ ]
def test_a_function_leaves_a_nested_function_aside(self) -> None:
outer = next(node for node in nested_scopes(parse_source(SCOPED_SOURCE)) if function_names([node]) == ["outer"])
@@ -120,7 +123,10 @@ def test_a_function_leaves_a_nested_function_aside(self) -> None:
class TestNestedScopes:
def test_a_module_opens_the_methods_of_its_classes(self) -> None:
- assert function_names(nested_scopes(parse_source(SCOPED_SOURCE))) == ["method", "outer"]
+ assert function_names(nested_scopes(parse_source(SCOPED_SOURCE))) == [
+ "method",
+ "outer",
+ ]
def test_a_function_opens_the_function_it_holds(self) -> None:
outer = next(node for node in nested_scopes(parse_source(SCOPED_SOURCE)) if function_names([node]) == ["outer"])
diff --git a/tests/unit/sampletones_shared/meta/source/test_packages.py b/tests/unit/sampletones_shared/meta/source/test_packages.py
new file mode 100644
index 00000000..e18e8b85
--- /dev/null
+++ b/tests/unit/sampletones_shared/meta/source/test_packages.py
@@ -0,0 +1,31 @@
+import pytest
+
+from sampletones_shared.meta.source.packages import package_directory
+from sampletones_shared.paths.source import SOURCE_ROOT
+
+SHARED_PACKAGE = "sampletones_shared"
+APPLICATION_PACKAGE = "sampletones_application"
+
+
+class TestPackageDirectory:
+ def test_a_top_level_package_sits_under_the_source_root(self) -> None:
+ assert package_directory(SHARED_PACKAGE) == SOURCE_ROOT / SHARED_PACKAGE
+
+ def test_a_subpackage_is_named_part_by_part(self) -> None:
+ assert package_directory(SHARED_PACKAGE, "meta", "source") == SOURCE_ROOT / SHARED_PACKAGE / "meta" / "source"
+
+ def test_the_answer_is_a_directory_a_sweep_reads_under(self) -> None:
+ """A package resource resolves to `__init__.py`, which a sweep reads nothing under."""
+ assert package_directory(APPLICATION_PACKAGE, "tags").is_dir()
+
+ def test_a_package_the_source_root_holds_no_directory_for_raises(self) -> None:
+ with pytest.raises(NotADirectoryError):
+ package_directory("sampletones_absent")
+
+ def test_a_module_named_as_a_package_raises(self) -> None:
+ with pytest.raises(NotADirectoryError):
+ package_directory(SHARED_PACKAGE, "paths.py")
+
+ def test_the_report_names_the_path_it_looked_at(self) -> None:
+ with pytest.raises(NotADirectoryError, match="sampletones_absent"):
+ package_directory("sampletones_absent")
diff --git a/tests/unit/sampletones_shared/meta/source/test_subscripts.py b/tests/unit/sampletones_shared/meta/source/test_subscripts.py
index 432ae68d..5285a216 100644
--- a/tests/unit/sampletones_shared/meta/source/test_subscripts.py
+++ b/tests/unit/sampletones_shared/meta/source/test_subscripts.py
@@ -35,7 +35,10 @@ def sites_of(name: str) -> List[SubscriptSite]:
class TestFindSubscripts:
def test_a_lookup_on_a_parameter_is_found(self) -> None:
- assert [site.receiver for site in sites_of("__init__")] == ["language_manager", "language_manager"]
+ assert [site.receiver for site in sites_of("__init__")] == [
+ "language_manager",
+ "language_manager",
+ ]
def test_a_lookup_on_an_attribute_is_found(self) -> None:
assert [site.receiver for site in sites_of("_label")] == ["self._language_manager"]
diff --git a/tests/unit/sampletones_shared/meta/source/test_values.py b/tests/unit/sampletones_shared/meta/source/test_values.py
index d829f222..733ad0e9 100644
--- a/tests/unit/sampletones_shared/meta/source/test_values.py
+++ b/tests/unit/sampletones_shared/meta/source/test_values.py
@@ -5,7 +5,12 @@
import pytest
from sampletones_shared.meta.source.bindings.environment import TypeEnvironment
-from sampletones_shared.meta.source.values import UNRESOLVED, EnumTable, ResolvedValues, ValueResolver
+from sampletones_shared.meta.source.values import (
+ UNRESOLVED,
+ EnumTable,
+ ResolvedValues,
+ ValueResolver,
+)
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
@@ -37,7 +42,11 @@ def expression(text: str) -> ast.expr:
"BUILT": expression("build('global')"),
}
-RESOLVER: Final[ValueResolver] = ValueResolver(environment=ENVIRONMENT, enums=ENUMS, constants=CONSTANTS)
+RESOLVER: Final[ValueResolver] = ValueResolver(
+ environment=ENVIRONMENT,
+ enums=ENUMS,
+ constants=CONSTANTS,
+)
class TestResolveValues(BaseTestSuite):
@@ -46,13 +55,17 @@ class TestCase(BaseRegularTestCase):
expression: str
expected: Tuple[Tuple[str, ...], bool]
- test_cases = [
+ test_cases = (
TestCase(
label="string_literal",
expression="'global.dialog.label.ok'",
expected=(("global.dialog.label.ok",), True),
),
- TestCase(label="enum_member", expression="Page.GLOBAL", expected=(("global",), True)),
+ TestCase(
+ label="enum_member",
+ expression="Page.GLOBAL",
+ expected=(("global",), True),
+ ),
TestCase(
label="enum_typed_name",
expression="element",
@@ -88,23 +101,59 @@ class TestCase(BaseRegularTestCase):
expression="CHAINED_KEY",
expected=(("global.dialog.label.ok",), True),
),
- TestCase(label="member_absent_from_its_enum", expression="Page.MISSING", expected=((), False)),
- TestCase(label="attribute_of_another_type", expression="settings.value", expected=((), False)),
- TestCase(label="call_of_another_kind", expression="str(key)", expected=((), False)),
- TestCase(label="format_string", expression="f'global.dialog.label.{name}'", expected=((), False)),
+ TestCase(
+ label="member_absent_from_its_enum",
+ expression="Page.MISSING",
+ expected=((), False),
+ ),
+ TestCase(
+ label="attribute_of_another_type",
+ expression="settings.value",
+ expected=((), False),
+ ),
+ TestCase(
+ label="call_of_another_kind",
+ expression="str(key)",
+ expected=((), False),
+ ),
+ TestCase(
+ label="format_string",
+ expression="f'global.dialog.label.{name}'",
+ expected=((), False),
+ ),
TestCase(label="number", expression="4", expected=((), False)),
- TestCase(label="name_of_another_type", expression="path", expected=((), False)),
- TestCase(label="name_the_source_never_states", expression="unknown", expected=((), False)),
- TestCase(label="constant_naming_itself", expression="CYCLE", expected=((), False)),
- TestCase(label="constant_built_by_a_call", expression="BUILT", expected=((), False)),
+ TestCase(
+ label="name_of_another_type",
+ expression="path",
+ expected=((), False),
+ ),
+ TestCase(
+ label="name_the_source_never_states",
+ expression="unknown",
+ expected=((), False),
+ ),
+ TestCase(
+ label="constant_naming_itself",
+ expression="CYCLE",
+ expected=((), False),
+ ),
+ TestCase(
+ label="constant_built_by_a_call",
+ expression="BUILT",
+ expected=((), False),
+ ),
TestCase(
label="conditional_reaching_an_unknown_branch",
expression="DialogElements.OK if is_confirmation else unknown",
expected=((), False),
),
- ]
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_resolve(self, test_case: TestCase) -> None:
resolved = RESOLVER.resolve(expression(test_case.expression))
assert (resolved.values, resolved.exact) == test_case.expected
@@ -120,11 +169,19 @@ def test_an_empty_resolution_reaches_nothing(self) -> None:
class TestValueResolverTables:
def test_an_enum_absent_from_the_table_resolves_nothing(self) -> None:
- resolver = ValueResolver(environment=ENVIRONMENT, enums={}, constants={})
+ resolver = ValueResolver(
+ environment=ENVIRONMENT,
+ enums={},
+ constants={},
+ )
assert resolver.resolve(expression("Page.GLOBAL")) == UNRESOLVED
def test_an_enum_holding_no_member_resolves_nothing(self) -> None:
enums: Dict[str, Dict[str, str]] = {"AbstractElement": {}}
environment = TypeEnvironment(types={"element": "AbstractElement"})
- resolver = ValueResolver(environment=environment, enums=enums, constants={})
+ resolver = ValueResolver(
+ environment=environment,
+ enums=enums,
+ constants={},
+ )
assert resolver.resolve(expression("element")) == UNRESOLVED
diff --git a/tests/unit/sampletones_shared/paths/__init__.py b/tests/unit/sampletones_shared/paths/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/unit/sampletones_shared/paths/test_resources.py b/tests/unit/sampletones_shared/paths/test_resources.py
new file mode 100644
index 00000000..53ca661a
--- /dev/null
+++ b/tests/unit/sampletones_shared/paths/test_resources.py
@@ -0,0 +1,7 @@
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
+
+
+class TestConfigDirectory:
+ def test_the_configuration_directory_holds_the_shipped_files(self) -> None:
+ """Read as a package resource, so the bundle finds it beside the executable."""
+ assert list(CONFIG_DIRECTORY.rglob("*.yaml"))
diff --git a/tests/unit/sampletones_shared/paths/test_source.py b/tests/unit/sampletones_shared/paths/test_source.py
new file mode 100644
index 00000000..6d5f1ec8
--- /dev/null
+++ b/tests/unit/sampletones_shared/paths/test_source.py
@@ -0,0 +1,21 @@
+from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT
+
+PROJECT_FILE = "pyproject.toml"
+SHARED_PACKAGE = "sampletones_shared"
+
+
+class TestSourceRoot:
+ def test_the_source_root_holds_the_packages(self) -> None:
+ assert (SOURCE_ROOT / SHARED_PACKAGE).is_dir()
+
+ def test_the_source_root_is_where_this_package_lives(self) -> None:
+ """Reading the root off the package keeps it right wherever the packages are installed."""
+ assert (SOURCE_ROOT / SHARED_PACKAGE / "paths" / "source.py").is_file()
+
+
+class TestRepositoryRoot:
+ def test_the_repository_root_holds_the_project_file(self) -> None:
+ assert (REPOSITORY_ROOT / PROJECT_FILE).is_file()
+
+ def test_the_repository_root_holds_the_scripts_the_checks_run_from(self) -> None:
+ assert (REPOSITORY_ROOT / "scripts" / "checks").is_dir()
diff --git a/tests/unit/sampletones_shared/paths/test_user.py b/tests/unit/sampletones_shared/paths/test_user.py
new file mode 100644
index 00000000..d26807b2
--- /dev/null
+++ b/tests/unit/sampletones_shared/paths/test_user.py
@@ -0,0 +1,16 @@
+from sampletones_shared.paths.user import (
+ LIBRARY_DIRECTORY,
+ PROJECTS_DIRECTORY,
+ RECONSTRUCTIONS_DIRECTORY,
+)
+
+
+class TestUserDirectories:
+ def test_the_user_directories_exist_after_import(self) -> None:
+ """Importing the module creates the directories the application saves into."""
+ for directory in (
+ LIBRARY_DIRECTORY,
+ PROJECTS_DIRECTORY,
+ RECONSTRUCTIONS_DIRECTORY,
+ ):
+ assert directory.is_dir()
diff --git a/tests/unit/sampletones_shared/utils/system/test_filesystem.py b/tests/unit/sampletones_shared/utils/system/test_filesystem.py
index 99dbeec5..d682af54 100644
--- a/tests/unit/sampletones_shared/utils/system/test_filesystem.py
+++ b/tests/unit/sampletones_shared/utils/system/test_filesystem.py
@@ -47,8 +47,14 @@ 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:
+ @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()
(target / "tone.strec").write_text("data")
diff --git a/tests/unit/sampletones_shared/utils/system/test_locales.py b/tests/unit/sampletones_shared/utils/system/test_locales.py
index c8801cd1..e10cfe69 100644
--- a/tests/unit/sampletones_shared/utils/system/test_locales.py
+++ b/tests/unit/sampletones_shared/utils/system/test_locales.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
from dataclasses import dataclass
import pytest
@@ -16,7 +14,7 @@ class TestCase(BaseRegularTestCase):
input_string: str
encoding: str
- test_cases = [
+ test_cases = (
TestCase(
input_string="Device Name",
encoding="utf-8",
@@ -83,7 +81,7 @@ class TestCase(BaseRegularTestCase):
expected="",
label="empty_string",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_shared/utils/system/test_paths.py b/tests/unit/sampletones_shared/utils/system/test_paths.py
index 2ae5874d..b0ab5a04 100644
--- a/tests/unit/sampletones_shared/utils/system/test_paths.py
+++ b/tests/unit/sampletones_shared/utils/system/test_paths.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
import tempfile
from dataclasses import dataclass
from pathlib import Path, PurePosixPath, PureWindowsPath
@@ -16,6 +14,7 @@
open_directory_in_explorer_linux,
open_file_in_explorer_linux,
open_path_in_explorer,
+ replace_suffix,
shorten_filename,
shorten_path,
to_path,
@@ -32,7 +31,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[str, Type[Exception]]
input_path: Any
- test_cases = [
+ test_cases = (
TestCase(
input_path="/home/user/file.txt",
expected="/home/user/file.txt",
@@ -133,7 +132,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="dict_raises_type_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -160,7 +159,7 @@ class TestCase(BaseRegularTestCase):
extension: str
expected: str
- test_cases = [
+ test_cases = (
TestCase(
name="song",
extension=".stp",
@@ -185,7 +184,7 @@ class TestCase(BaseRegularTestCase):
expected="song.stp.stp",
label="appends_to_a_name_already_ending_in_the_extension",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -203,7 +202,7 @@ class TestCase(BaseRegularTestCase):
suffix: str
expected: str
- test_cases = [
+ test_cases = (
TestCase(
input_path="song",
suffix=".stp",
@@ -252,7 +251,7 @@ class TestCase(BaseRegularTestCase):
expected="/home/user/song.stp",
label="full_path_keeps_matching_suffix",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -266,6 +265,67 @@ def test_ensure_suffix(self, test_case: TestCase) -> None:
assert result == Path(test_case.expected)
+class TestReplaceSuffix(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class TestCase(BaseRegularTestCase):
+ input_path: str
+ previous: str
+ suffix: str
+ expected: str
+
+ test_cases = (
+ TestCase(
+ input_path="song.wav",
+ previous=".wav",
+ suffix=".mp3",
+ expected="song.mp3",
+ label="replaces_the_previous_extension",
+ ),
+ TestCase(
+ input_path="song.WAV",
+ previous=".wav",
+ suffix=".mp3",
+ expected="song.mp3",
+ label="replaces_case_insensitively",
+ ),
+ TestCase(
+ input_path="/home/user/my song v1.2.wav",
+ previous=".wav",
+ suffix=".mp3",
+ expected="/home/user/my song v1.2.mp3",
+ label="keeps_incidental_dots_and_directory",
+ ),
+ TestCase(
+ input_path="my.mix",
+ previous=".wav",
+ suffix=".mp3",
+ expected="my.mix.mp3",
+ label="appends_where_the_name_ends_otherwise",
+ ),
+ TestCase(
+ input_path="song",
+ previous=".wav",
+ suffix=".wav",
+ expected="song.wav",
+ label="appends_where_the_name_carries_no_extension",
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_replace_suffix(self, test_case: TestCase) -> None:
+ result = replace_suffix(
+ Path(test_case.input_path),
+ test_case.previous,
+ test_case.suffix,
+ )
+
+ assert result == Path(test_case.expected)
+
+
class TestShortenPath(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
@@ -275,7 +335,7 @@ class TestCase(BaseRegularTestCase):
levels: Any
os_sep: str = "/"
- test_cases = [
+ test_cases = (
TestCase(
input_path=PurePosixPath("/home/user/file.txt"),
resolved_path=PurePosixPath("/home/user/file.txt"),
@@ -469,7 +529,7 @@ class TestCase(BaseRegularTestCase):
expected=ValueError,
label="string_levels_raises_value_error",
),
- ]
+ )
def _create_resolved_mock(self, resolved_path: Any) -> MagicMock:
"""Stands in for the resolved path, keeping the path flavour each case declares.
@@ -634,7 +694,7 @@ class TestCase(BaseRegularTestCase):
command_returncode: int
should_fallback: bool
- test_cases = [
+ test_cases = (
TestCase(
label="dolphin_kde",
desktop_file="org.kde.dolphin.desktop",
@@ -725,7 +785,7 @@ class TestCase(BaseRegularTestCase):
command_returncode=1,
should_fallback=True,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -778,7 +838,7 @@ class TestCase(BaseRegularTestCase):
system: System
is_file: bool
- test_cases = [
+ test_cases = (
TestCase(
label="windows_file",
system=System.WINDOWS,
@@ -803,7 +863,7 @@ class TestCase(BaseRegularTestCase):
is_file=False,
expected=["open", ""],
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_shared/utils/test_agreement.py b/tests/unit/sampletones_shared/utils/test_agreement.py
new file mode 100644
index 00000000..5429883f
--- /dev/null
+++ b/tests/unit/sampletones_shared/utils/test_agreement.py
@@ -0,0 +1,101 @@
+from dataclasses import dataclass
+from typing import Optional, Tuple
+
+import pytest
+
+from sampletones_shared.utils.agreement import Agreement
+from tests.suite.base import BaseTestSuite
+from tests.suite.case import BaseRegularTestCase
+
+_ABSENT = -1
+_MIXED = -2
+
+
+class TestAgreementOutcomes(BaseTestSuite):
+ @dataclass(frozen=True, kw_only=True)
+ class OutcomeCase(BaseRegularTestCase):
+ values: Tuple[Optional[int], ...]
+ expected_absent: bool
+ expected_unanimous: bool
+ expected_mixed: bool
+ expected_resolved: Optional[int]
+
+ test_cases = (
+ OutcomeCase(
+ label="no_sources_are_absent",
+ values=(),
+ expected_absent=True,
+ expected_unanimous=False,
+ expected_mixed=False,
+ expected_resolved=_ABSENT,
+ ),
+ OutcomeCase(
+ label="one_source_is_unanimous",
+ values=(5,),
+ expected_absent=False,
+ expected_unanimous=True,
+ expected_mixed=False,
+ expected_resolved=5,
+ ),
+ OutcomeCase(
+ label="repeated_value_is_unanimous",
+ values=(5, 5, 5),
+ expected_absent=False,
+ expected_unanimous=True,
+ expected_mixed=False,
+ expected_resolved=5,
+ ),
+ OutcomeCase(
+ label="differing_values_are_mixed",
+ values=(5, 7),
+ expected_absent=False,
+ expected_unanimous=False,
+ expected_mixed=True,
+ expected_resolved=_MIXED,
+ ),
+ OutcomeCase(
+ label="every_source_absent_is_unanimous_on_absence",
+ values=(None, None),
+ expected_absent=False,
+ expected_unanimous=True,
+ expected_mixed=False,
+ expected_resolved=None,
+ ),
+ OutcomeCase(
+ label="absence_beside_a_value_is_mixed",
+ values=(None, 5),
+ expected_absent=False,
+ expected_unanimous=False,
+ expected_mixed=True,
+ expected_resolved=_MIXED,
+ ),
+ )
+
+ @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label)
+ def test_outcome_flags_and_resolution(self, case: OutcomeCase) -> None:
+ agreement: Agreement[Optional[int]] = Agreement.collapse(case.values)
+
+ assert agreement.is_absent is case.expected_absent
+ assert agreement.is_unanimous is case.expected_unanimous
+ assert agreement.is_mixed is case.expected_mixed
+ assert agreement.resolve(absent=_ABSENT, mixed=_MIXED) == case.expected_resolved
+
+
+class TestAgreementValue:
+ def test_unanimous_absence_reports_absence_as_the_agreed_value(self) -> None:
+ """The outcome and the agreed value are read apart, so ``None`` can be what they share."""
+ agreement: Agreement[Optional[int]] = Agreement.collapse((None, None))
+
+ assert agreement.is_unanimous
+ assert agreement.value is None
+
+ def test_no_sources_have_no_agreed_value(self) -> None:
+ with pytest.raises(ValueError):
+ Agreement.collapse(()).value
+
+ def test_differing_sources_have_no_agreed_value(self) -> None:
+ with pytest.raises(ValueError):
+ Agreement.collapse((5, 7)).value
+
+ def test_order_of_sources_leaves_the_agreement_equal(self) -> None:
+ assert Agreement.collapse((5, 7)) == Agreement.collapse((7, 5))
diff --git a/tests/unit/sampletones_shared/utils/test_arrays.py b/tests/unit/sampletones_shared/utils/test_arrays.py
index 4183aff7..70ff1d02 100644
--- a/tests/unit/sampletones_shared/utils/test_arrays.py
+++ b/tests/unit/sampletones_shared/utils/test_arrays.py
@@ -1,5 +1,3 @@
-from __future__ import annotations
-
from dataclasses import dataclass
from typing import Any, Type, Union
@@ -32,7 +30,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[bool, Type[Exception]]
value: Any
- test_cases = [
+ test_cases = (
TestCase(
value=None,
expected=True,
@@ -263,7 +261,7 @@ class TestCase(BaseRegularTestCase):
expected=True,
label="matrix_float64_all_nan",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -284,7 +282,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[bool, Type[Exception]]
value: Any
- test_cases = [
+ test_cases = (
TestCase(
value=None,
expected=False,
@@ -565,7 +563,7 @@ class TestCase(BaseRegularTestCase):
expected=False,
label="matrix_float64_with_nan_and_inf_not_all_finite",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -586,7 +584,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[Any, Type[Exception]]
value: Any
- test_cases = [
+ test_cases = (
TestCase(
value=True,
expected=1.0,
@@ -802,7 +800,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="none_raises_type_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -822,7 +820,7 @@ class TestCase(BaseRegularTestCase):
value: Any
dtype: Any
- test_cases = [
+ test_cases = (
TestCase(
value=None,
dtype=np.int32,
@@ -991,7 +989,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="numpy_string_object_array_raises_type_error",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1014,7 +1012,7 @@ class TestCase(BaseRegularTestCase):
min_value: Any
max_value: Any
- test_cases = [
+ test_cases = (
TestCase(
value=5,
min_value=0,
@@ -1414,7 +1412,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="dict_max_bound",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1444,7 +1442,7 @@ class TestCase(BaseRegularTestCase):
right: Any
value: Any
- test_cases = [
+ test_cases = (
TestCase(
array=np.array([], dtype=np.int64),
left=0,
@@ -1597,7 +1595,7 @@ class TestCase(BaseRegularTestCase):
expected=ValueError,
label="out_of_order_padding",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1625,7 +1623,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[np.ndarray, Type[Exception]]
input_array: Any
- test_cases = [
+ test_cases = (
TestCase(
input_array=np.array([1, 1, 2, 2, 3, 3, 3, 3]),
expected=np.array([1, 1, 2, 2, 3]),
@@ -1676,7 +1674,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="not_an_array",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1699,7 +1697,7 @@ class TestCase(BaseRegularTestCase):
index: Any
default: Any
- test_cases = [
+ test_cases = (
TestCase(
array=np.array([12, 5, 0]),
index=0,
@@ -1784,7 +1782,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="list_not_array",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1815,7 +1813,7 @@ class TestCase(BaseRegularTestCase):
start_value: Any
end_value: Any
- test_cases = [
+ test_cases = (
TestCase(
array=np.zeros(5, dtype=np.float32),
start_index=0,
@@ -1915,7 +1913,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="float_index_rejected",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -1950,7 +1948,7 @@ class TestCase(BaseRegularTestCase):
expected: bool
input_array: Any
- test_cases = [
+ test_cases = (
TestCase(
input_array=np.array([], dtype=np.int32),
expected=True,
@@ -2036,7 +2034,7 @@ class TestCase(BaseRegularTestCase):
expected=False,
label="strictly_decreasing_to_negative_int32",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_shared/utils/test_callbacks.py b/tests/unit/sampletones_shared/utils/test_callbacks.py
index da21bbdf..a00ef0e5 100644
--- a/tests/unit/sampletones_shared/utils/test_callbacks.py
+++ b/tests/unit/sampletones_shared/utils/test_callbacks.py
@@ -19,7 +19,10 @@ def __init__(self) -> None:
self.on_error: Optional[Any] = None
-def assert_callbacks_match(instance: TestableCallbackClass, expected: Dict[str, Optional[Any]]) -> None:
+def assert_callbacks_match(
+ instance: TestableCallbackClass,
+ expected: Dict[str, Optional[Any]],
+) -> None:
for attr_name in ["on_event", "on_data", "on_error"]:
actual_callback = getattr(instance, attr_name)
expected_callback = expected[attr_name]
@@ -40,7 +43,7 @@ class TestCase(BaseRegularTestCase):
args: Tuple[Any, ...]
kwargs: Dict[str, Any]
- test_cases = [
+ test_cases = (
TestCase(
callback=lambda: 42,
args=(),
@@ -153,7 +156,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="non_callable_object",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -249,7 +252,7 @@ class TestCase(BaseRegularTestCase):
initial_callbacks: Dict[str, Optional[Any]]
set_kwargs: Dict[str, Optional[Any]]
- test_cases = [
+ test_cases = (
TestCase(
initial_callbacks={
"on_event": None,
@@ -400,7 +403,7 @@ class TestCase(BaseRegularTestCase):
expected=AttributeError,
label="one_valid_one_invalid_raises",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -413,7 +416,11 @@ def test_set_callbacks(self, test_case: TestCase) -> None:
instance.on_data = test_case.initial_callbacks["on_data"]
instance.on_error = test_case.initial_callbacks["on_error"]
- if expect_error(instance.set_callbacks, test_case.expected, **test_case.set_kwargs):
+ if expect_error(
+ instance.set_callbacks,
+ test_case.expected,
+ **test_case.set_kwargs,
+ ):
return
assert not isinstance(test_case.expected, type)
@@ -436,7 +443,7 @@ class TestCase(BaseRegularTestCase):
initial_callbacks: Dict[str, Optional[Any]]
reset_names: Tuple[str, ...]
- test_cases = [
+ test_cases = (
TestCase(
initial_callbacks={
"on_event": lambda: 1,
@@ -553,7 +560,7 @@ class TestCase(BaseRegularTestCase):
expected=AttributeError,
label="reset_multiple_invalid_raises",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -566,7 +573,11 @@ def test_reset_callbacks(self, test_case: TestCase) -> None:
instance.on_data = test_case.initial_callbacks["on_data"]
instance.on_error = test_case.initial_callbacks["on_error"]
- if expect_error(instance.reset_callbacks, test_case.expected, *test_case.reset_names):
+ if expect_error(
+ instance.reset_callbacks,
+ test_case.expected,
+ *test_case.reset_names,
+ ):
return
assert not isinstance(test_case.expected, type)
diff --git a/tests/unit/sampletones_shared/utils/test_color.py b/tests/unit/sampletones_shared/utils/test_color.py
index f4773954..a4de4b93 100644
--- a/tests/unit/sampletones_shared/utils/test_color.py
+++ b/tests/unit/sampletones_shared/utils/test_color.py
@@ -3,7 +3,7 @@
import pytest
-from sampletones_shared.utils.color import blend, parse_hex_color, to_grayscale
+from sampletones_shared.utils.color import blend, composite, parse_hex_color, to_grayscale
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
from tests.suite.errors import expect_error
@@ -15,7 +15,7 @@ class TestCase(BaseRegularTestCase):
value: str
expected: Union[Tuple[int, int, int, int], Type[Exception]]
- test_cases = [
+ test_cases = (
# --- valid 6-digit (opaque, alpha defaults to 255) ---
TestCase(
label="black",
@@ -142,7 +142,7 @@ class TestCase(BaseRegularTestCase):
value="#ff ff ff",
expected=ValueError,
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -167,6 +167,30 @@ def test_gray_stays_gray(self) -> None:
assert to_grayscale((128, 128, 128, 255)) == (128, 128, 128, 255)
+class TestComposite:
+ TRANSPARENT = (0, 0, 0, 0)
+ FAINT_WHITE = (255, 255, 255, 16)
+ GREEN = (100, 220, 100, 64)
+
+ def test_an_opaque_overlay_covers_what_is_under_it(self) -> None:
+ assert composite(self.GREEN, (10, 20, 30, 255)) == (10, 20, 30, 255)
+
+ def test_a_transparent_overlay_leaves_the_base(self) -> None:
+ assert composite(self.GREEN, self.TRANSPARENT) == self.GREEN
+
+ def test_a_transparent_base_leaves_the_overlay(self) -> None:
+ assert composite(self.TRANSPARENT, self.GREEN) == self.GREEN
+
+ def test_two_transparent_colours_stay_transparent(self) -> None:
+ assert composite(self.TRANSPARENT, self.TRANSPARENT) == self.TRANSPARENT
+
+ def test_stacked_washes_cover_more_than_either_alone(self) -> None:
+ red, green, blue, alpha = composite(self.FAINT_WHITE, self.GREEN)
+
+ assert alpha == 76
+ assert (red, green, blue) == (124, 226, 124)
+
+
class TestBlend:
START = (0, 0, 0, 0)
END = (100, 200, 40, 255)
diff --git a/tests/unit/sampletones_shared/utils/test_common.py b/tests/unit/sampletones_shared/utils/test_common.py
index e9d1c89c..0b1bb103 100644
--- a/tests/unit/sampletones_shared/utils/test_common.py
+++ b/tests/unit/sampletones_shared/utils/test_common.py
@@ -20,7 +20,7 @@ class TestCase(BaseRegularTestCase):
expected: Union[int, type]
input_value: int
- test_cases = [
+ test_cases = (
TestCase(input_value=0, expected=1, label="zero"),
TestCase(input_value=1, expected=1, label="one"),
TestCase(input_value=2, expected=2, label="already_power_of_two"),
@@ -45,7 +45,7 @@ class TestCase(BaseRegularTestCase):
expected=OverflowError,
label="too_large_overflow",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -53,7 +53,11 @@ class TestCase(BaseRegularTestCase):
ids=lambda test_case: test_case.label,
)
def test_next_power_of_two(self, test_case: TestCase) -> None:
- if expect_error(next_power_of_two, test_case.expected, test_case.input_value):
+ if expect_error(
+ next_power_of_two,
+ test_case.expected,
+ test_case.input_value,
+ ):
return
result = next_power_of_two(test_case.input_value)
@@ -68,7 +72,7 @@ class TestCase(BaseRegularTestCase):
dictionary: Any
target: Any
- test_cases = [
+ test_cases = (
TestCase(
dictionary={"a": 1, "b": 2, "c": 3},
target=2,
@@ -135,7 +139,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="not_a_dictionary",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_shared/utils/test_text.py b/tests/unit/sampletones_shared/utils/test_text.py
new file mode 100644
index 00000000..c5b96079
--- /dev/null
+++ b/tests/unit/sampletones_shared/utils/test_text.py
@@ -0,0 +1,53 @@
+from typing import List
+
+import pytest
+
+from sampletones_shared.utils.text import natural_sort_key
+
+
+class TestNumbers:
+ @pytest.mark.parametrize(
+ ("names", "expected"),
+ [
+ (["44.1 kHz", "8 kHz"], ["8 kHz", "44.1 kHz"]),
+ (["track10", "track2"], ["track2", "track10"]),
+ (["10", "9", "100"], ["9", "10", "100"]),
+ (["γ10", "γ2"], ["γ2", "γ10"]),
+ ],
+ )
+ def test_digit_runs_compare_as_numbers(
+ self,
+ names: List[str],
+ expected: List[str],
+ ) -> None:
+ assert sorted(names, key=natural_sort_key) == expected
+
+ def test_leading_zeros_keep_a_fixed_order(self) -> None:
+ """``01`` and ``1`` state the same number, and the text itself settles which reads first."""
+ assert sorted(["1", "01"], key=natural_sort_key) == ["01", "1"]
+
+ def test_a_number_reads_before_the_text_beside_it(self) -> None:
+ assert sorted(["kick", "2 kick"], key=natural_sort_key) == ["2 kick", "kick"]
+
+
+class TestText:
+ def test_case_states_nothing_about_order(self) -> None:
+ assert sorted(["Beats", "amen", "Cymbals"], key=natural_sort_key) == ["amen", "Beats", "Cymbals"]
+
+ def test_names_reading_alike_keep_a_fixed_order(self) -> None:
+ assert sorted(["song", "Song"], key=natural_sort_key) == ["Song", "song"]
+
+ def test_a_shorter_name_reads_first(self) -> None:
+ assert sorted(["amen breaks", "amen"], key=natural_sort_key) == ["amen", "amen breaks"]
+
+ def test_the_empty_name_reads_first(self) -> None:
+ assert sorted(["", "a"], key=natural_sort_key) == ["", "a"]
+
+
+class TestKey:
+ def test_one_name_reaches_one_key(self) -> None:
+ assert natural_sort_key("44.1 kHz") == natural_sort_key("44.1 kHz")
+
+ def test_a_name_the_reader_alone_can_spell(self) -> None:
+ """A digit-like glyph outside the decimal digits is text, and the key states it as text."""
+ assert sorted(["m²", "m1"], key=natural_sort_key) == ["m1", "m²"]
diff --git a/tests/unit/sampletones_shared/utils/test_validation.py b/tests/unit/sampletones_shared/utils/test_validation.py
index 41ab2483..fa867de3 100644
--- a/tests/unit/sampletones_shared/utils/test_validation.py
+++ b/tests/unit/sampletones_shared/utils/test_validation.py
@@ -62,14 +62,25 @@ class TestCase(BaseRegularTestCase):
raw: Dict[str, Any]
dropped: Tuple[Location, ...]
- test_cases = [
+ test_cases = (
TestCase(
label="valid_input_is_preserved",
raw={
- "branch": {"leaf": {"value": 7, "name": "x"}, "ratio": 2.0, "tags": ["a"]},
+ "branch": {
+ "leaf": {"value": 7, "name": "x"},
+ "ratio": 2.0,
+ "tags": ["a"],
+ },
"count": 9,
},
- expected=Root(branch=Branch(leaf=Leaf(value=7, name="x"), ratio=2.0, tags=["a"]), count=9),
+ expected=Root(
+ branch=Branch(
+ leaf=Leaf(value=7, name="x"),
+ ratio=2.0,
+ tags=["a"],
+ ),
+ count=9,
+ ),
dropped=(),
),
TestCase(
@@ -80,8 +91,18 @@ class TestCase(BaseRegularTestCase):
),
TestCase(
label="nested_bad_leaf_keeps_its_siblings",
- raw={"branch": {"leaf": {"value": 99, "name": "keep"}, "ratio": 3.0}},
- expected=Root(branch=Branch(leaf=Leaf(name="keep"), ratio=3.0)),
+ raw={
+ "branch": {
+ "leaf": {"value": 99, "name": "keep"},
+ "ratio": 3.0,
+ }
+ },
+ expected=Root(
+ branch=Branch(
+ leaf=Leaf(name="keep"),
+ ratio=3.0,
+ )
+ ),
dropped=(("branch", "leaf", "value"),),
),
TestCase(
@@ -100,7 +121,11 @@ class TestCase(BaseRegularTestCase):
label="multiple_independent_failures_all_drop",
raw={"branch": {"leaf": {"value": 99}, "ratio": -1.0}, "count": 0},
expected=Root(),
- dropped=(("branch", "leaf", "value"), ("branch", "ratio"), ("count",)),
+ dropped=(
+ ("branch", "leaf", "value"),
+ ("branch", "ratio"),
+ ("count",),
+ ),
),
TestCase(
label="bad_list_element_keeps_valid_elements",
@@ -120,7 +145,7 @@ class TestCase(BaseRegularTestCase):
expected=Root(),
dropped=(),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -156,12 +181,24 @@ class TestCase(BaseRegularTestCase):
expected: str
location: Location
- test_cases = [
+ test_cases = (
TestCase(label="single_key", location=("count",), expected="count"),
- TestCase(label="nested_keys", location=("generation", "drive"), expected="generation.drive"),
- TestCase(label="list_index", location=("generators", 2), expected="generators[2]"),
- TestCase(label="nested_with_index", location=("branch", "tags", 1), expected="branch.tags[1]"),
- ]
+ TestCase(
+ label="nested_keys",
+ location=("generation", "drive"),
+ expected="generation.drive",
+ ),
+ TestCase(
+ label="list_index",
+ location=("generators", 2),
+ expected="generators[2]",
+ ),
+ TestCase(
+ label="nested_with_index",
+ location=("branch", "tags", 1),
+ expected="branch.tags[1]",
+ ),
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_shared/utils/transformations/test_functions.py b/tests/unit/sampletones_shared/utils/transformations/test_functions.py
index 2046d498..19e2891c 100644
--- a/tests/unit/sampletones_shared/utils/transformations/test_functions.py
+++ b/tests/unit/sampletones_shared/utils/transformations/test_functions.py
@@ -23,10 +23,9 @@
class TestIdentity(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
- expected: Any
value: Any
- test_cases = [
+ test_cases = (
TestCase(
value=True,
expected=True,
@@ -122,7 +121,7 @@ class TestCase(BaseRegularTestCase):
expected=xp.array([4, 5, 6]),
label="xp_array_int",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -137,10 +136,9 @@ def test_identity(self, test_case: TestCase) -> None:
class TestEnergy(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
- expected: Any
value: Any
- test_cases = [
+ test_cases = (
TestCase(
value=True,
expected=np.int8(1),
@@ -241,7 +239,7 @@ class TestCase(BaseRegularTestCase):
expected=xp.array([6.25, 9.0, 17.64]),
label="xp_array_float",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -256,10 +254,9 @@ def test_energy(self, test_case: TestCase) -> None:
class TestExp(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
- expected: Any
value: Any
- test_cases = [
+ test_cases = (
TestCase(
value=True,
expected=np.float16(np.e),
@@ -365,7 +362,7 @@ class TestCase(BaseRegularTestCase):
expected=xp.array([np.e**1.5, np.e**2.7, np.e**3.1]),
label="xp_array_float",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -380,12 +377,11 @@ def test_exp(self, test_case: TestCase) -> None:
class TestPower(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
- expected: Any
value: Any
a: float
expected_warning: Any = None
- test_cases = [
+ test_cases = (
TestCase(
value=True,
a=2.0,
@@ -609,7 +605,7 @@ class TestCase(BaseRegularTestCase):
expected=xp.array([2.2**0.7, 4.5**0.7, 8.1**0.7]),
label="xp_array_float",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -617,19 +613,23 @@ class TestCase(BaseRegularTestCase):
ids=lambda test_case: test_case.label,
)
def test_power(self, test_case: TestCase) -> None:
- result = expect_warning(power, test_case.expected_warning, test_case.value, test_case.a)
+ result = expect_warning(
+ power,
+ test_case.expected_warning,
+ test_case.value,
+ test_case.a,
+ )
assert_array_equal(result, test_case.expected)
class TestPowerInverse(BaseTestSuite):
@dataclass(frozen=True, kw_only=True)
class TestCase(BaseRegularTestCase):
- expected: Any
value: Any
a: float
expected_warning: Optional[Any] = None
- test_cases = [
+ test_cases = (
TestCase(
value=True,
a=2.0,
@@ -826,7 +826,7 @@ class TestCase(BaseRegularTestCase):
expected=xp.array([16.5 ** (1 / 1.9), 81.2 ** (1 / 1.9), 256.8 ** (1 / 1.9)]),
label="xp_array_float",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -834,7 +834,12 @@ class TestCase(BaseRegularTestCase):
ids=lambda test_case: test_case.label,
)
def test_power_inverse(self, test_case: TestCase) -> None:
- if expect_error(power_inverse, test_case.expected, test_case.value, test_case.a):
+ if expect_error(
+ power_inverse,
+ test_case.expected,
+ test_case.value,
+ test_case.a,
+ ):
return
result = expect_warning(
diff --git a/tests/unit/sampletones_shared/utils/transformations/test_morpher.py b/tests/unit/sampletones_shared/utils/transformations/test_morpher.py
index aa5144b0..a371f444 100644
--- a/tests/unit/sampletones_shared/utils/transformations/test_morpher.py
+++ b/tests/unit/sampletones_shared/utils/transformations/test_morpher.py
@@ -8,9 +8,7 @@
from sampletones_shared.utils.transformations.functions import identity
from sampletones_shared.utils.transformations.morpher import PowerMorpher
-from sampletones_shared.utils.transformations.transformation import (
- Transformation,
-)
+from sampletones_shared.utils.transformations.transformation import Transformation
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
from tests.suite.errors import expect_error
@@ -23,7 +21,7 @@ class TestCase(BaseRegularTestCase):
gamma: float
should_be_identity: bool
- test_cases = [
+ test_cases = (
TestCase(
gamma=0.0,
expected=0.25,
@@ -54,7 +52,7 @@ class TestCase(BaseRegularTestCase):
should_be_identity=False,
label="gamma_1_sharp",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -80,7 +78,7 @@ class TestCase(BaseRegularTestCase):
gamma: float
expected: None = None
- test_cases = [
+ test_cases = (
TestCase(
gamma=np.nan,
label="gamma_nan",
@@ -105,7 +103,7 @@ class TestCase(BaseRegularTestCase):
gamma=2.0,
label="gamma_two",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -132,5 +130,8 @@ def test_cached_property_transformation(self) -> None:
def test_frozen_model(self) -> None:
morpher = PowerMorpher(gamma=0.5)
- if expect_error(lambda: setattr(morpher, "gamma", 0.75), ValidationError):
+ if expect_error(
+ lambda: setattr(morpher, "gamma", 0.75),
+ ValidationError,
+ ):
return
diff --git a/tests/unit/sampletones_shared/utils/transformations/test_transformation.py b/tests/unit/sampletones_shared/utils/transformations/test_transformation.py
index 5ba66c93..d46af2f3 100644
--- a/tests/unit/sampletones_shared/utils/transformations/test_transformation.py
+++ b/tests/unit/sampletones_shared/utils/transformations/test_transformation.py
@@ -6,14 +6,13 @@
import numpy as np
import pytest
+from sampletones_shared.types.data import SerializedData
from sampletones_shared.utils.transformations.functions import (
exp,
identity,
power,
)
-from sampletones_shared.utils.transformations.transformation import (
- Transformation,
-)
+from sampletones_shared.utils.transformations.transformation import Transformation
from tests.suite.arrays import assert_array_equal
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
@@ -35,7 +34,7 @@ class ReduceTestCase(BaseRegularTestCase):
identity_transformation = Transformation(forward=identity, backward=identity)
- apply_test_cases = [
+ apply_test_cases = (
ApplyTestCase(
transformation=identity_transformation,
operation=np.add,
@@ -106,9 +105,9 @@ class ReduceTestCase(BaseRegularTestCase):
expected=np.array([3.0, 7.5, 14.0]),
label="identity_multiply_numpy_array",
),
- ]
+ )
- reduce_test_cases = [
+ reduce_test_cases = (
ReduceTestCase(
transformation=identity_transformation,
operation=np.add,
@@ -158,7 +157,7 @@ class ReduceTestCase(BaseRegularTestCase):
expected=np.array([8.0, 15.0]),
label="identity_reduce_multiply_two_arrays",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -166,7 +165,10 @@ class ReduceTestCase(BaseRegularTestCase):
ids=lambda tc: tc.label,
)
def test_apply(self, test_case: ApplyTestCase) -> None:
- result = test_case.transformation.apply(test_case.operation, *test_case.inputs)
+ result = test_case.transformation.apply(
+ test_case.operation,
+ *test_case.inputs,
+ )
assert_array_equal(result, test_case.expected)
@pytest.mark.parametrize(
@@ -175,7 +177,10 @@ def test_apply(self, test_case: ApplyTestCase) -> None:
ids=lambda tc: tc.label,
)
def test_reduce(self, test_case: ReduceTestCase) -> None:
- result = test_case.transformation.reduce(test_case.operation, *test_case.inputs)
+ result = test_case.transformation.reduce(
+ test_case.operation,
+ *test_case.inputs,
+ )
assert_array_equal(result, test_case.expected)
@@ -194,7 +199,7 @@ class ReduceTestCase(BaseRegularTestCase):
exp_transformation = Transformation(forward=exp, backward=np.log)
- apply_test_cases = [
+ apply_test_cases = (
ApplyTestCase(
transformation=exp_transformation,
operation=np.add,
@@ -258,9 +263,9 @@ class ReduceTestCase(BaseRegularTestCase):
expected=np.exp(np.log(np.array([2.0, 3.0])) * np.log(np.array([1.5, 2.5]))),
label="exp_multiply_numpy_array",
),
- ]
+ )
- reduce_test_cases = [
+ reduce_test_cases = (
ReduceTestCase(
transformation=exp_transformation,
operation=np.add,
@@ -307,7 +312,7 @@ class ReduceTestCase(BaseRegularTestCase):
expected=np.array([48.0, 105.0]),
label="exp_reduce_add_three_arrays",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -315,7 +320,10 @@ class ReduceTestCase(BaseRegularTestCase):
ids=lambda tc: tc.label,
)
def test_apply(self, test_case: ApplyTestCase) -> None:
- result = test_case.transformation.apply(test_case.operation, *test_case.inputs)
+ result = test_case.transformation.apply(
+ test_case.operation,
+ *test_case.inputs,
+ )
assert_array_equal(result, test_case.expected)
@pytest.mark.parametrize(
@@ -324,7 +332,10 @@ def test_apply(self, test_case: ApplyTestCase) -> None:
ids=lambda tc: tc.label,
)
def test_reduce(self, test_case: ReduceTestCase) -> None:
- result = test_case.transformation.reduce(test_case.operation, *test_case.inputs)
+ result = test_case.transformation.reduce(
+ test_case.operation,
+ *test_case.inputs,
+ )
assert_array_equal(result, test_case.expected)
@@ -346,7 +357,7 @@ class ReduceTestCase(BaseRegularTestCase):
backward=lambda x: power(x, 0.5),
)
- apply_test_cases = [
+ apply_test_cases = (
ApplyTestCase(
transformation=square_transformation,
operation=np.add,
@@ -396,9 +407,9 @@ class ReduceTestCase(BaseRegularTestCase):
expected=(np.sqrt(np.array([4.0, 9.0, 16.0])) + np.sqrt(np.array([1.0, 4.0, 9.0]))) ** 2,
label="square_add_numpy_array",
),
- ]
+ )
- reduce_test_cases = [
+ reduce_test_cases = (
ReduceTestCase(
transformation=square_transformation,
operation=np.add,
@@ -432,7 +443,7 @@ class ReduceTestCase(BaseRegularTestCase):
** 2,
label="square_reduce_add_three_arrays",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -440,7 +451,10 @@ class ReduceTestCase(BaseRegularTestCase):
ids=lambda tc: tc.label,
)
def test_apply(self, test_case: ApplyTestCase) -> None:
- result = test_case.transformation.apply(test_case.operation, *test_case.inputs)
+ result = test_case.transformation.apply(
+ test_case.operation,
+ *test_case.inputs,
+ )
assert_array_equal(result, test_case.expected)
@pytest.mark.parametrize(
@@ -449,7 +463,10 @@ def test_apply(self, test_case: ApplyTestCase) -> None:
ids=lambda tc: tc.label,
)
def test_reduce(self, test_case: ReduceTestCase) -> None:
- result = test_case.transformation.reduce(test_case.operation, *test_case.inputs)
+ result = test_case.transformation.reduce(
+ test_case.operation,
+ *test_case.inputs,
+ )
assert_array_equal(result, test_case.expected)
@@ -463,7 +480,7 @@ class TestCase(BaseRegularTestCase):
identity_transformation = Transformation(forward=identity, backward=identity)
exp_transformation = Transformation(forward=exp, backward=np.log)
- test_cases = [
+ test_cases = (
TestCase(
transformation=identity_transformation,
operation=np.add,
@@ -530,7 +547,7 @@ class TestCase(BaseRegularTestCase):
expected=np.array([8.0, 15.0]),
label="identity_reduce_multiply_two_arrays",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -538,7 +555,10 @@ class TestCase(BaseRegularTestCase):
ids=lambda test_case: test_case.label,
)
def test_reduce(self, test_case: TestCase) -> None:
- result = test_case.transformation.reduce(test_case.operation, *test_case.inputs)
+ result = test_case.transformation.reduce(
+ test_case.operation,
+ *test_case.inputs,
+ )
assert_array_equal(result, test_case.expected)
@@ -552,7 +572,7 @@ class TestCase(BaseRegularTestCase):
identity_transformation = Transformation(forward=identity, backward=identity)
exp_transformation = Transformation(forward=exp, backward=np.log)
- test_cases = [
+ test_cases = (
TestCase(
transformation=identity_transformation,
method_name="add",
@@ -648,7 +668,7 @@ class TestCase(BaseRegularTestCase):
expected=np.array([48.0, 105.0]),
label="identity_multiply_three_arrays",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -678,7 +698,7 @@ class TestCase(BaseRegularTestCase):
backward=lambda x: power(x, 0.5),
)
- test_cases = [
+ test_cases = (
TestCase(
label="identity_compose_add",
transformation=identity_transformation,
@@ -727,7 +747,7 @@ class TestCase(BaseRegularTestCase):
backward_inputs=(9.0,),
expected_backward_result=np.float64(3.0),
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
@@ -759,7 +779,7 @@ class TestCase(BaseRegularTestCase):
identity_transformation = Transformation(forward=identity, backward=identity)
- test_cases = [
+ test_cases = (
TestCase(
transformation=identity_transformation,
method_name="reduce",
@@ -784,7 +804,7 @@ class TestCase(BaseRegularTestCase):
expected=TypeError,
label="compose_function_int",
),
- ]
+ )
@pytest.mark.parametrize(
"test_case",
diff --git a/tests/unit/sampletones_synthesis/oscillators/test_sweeps.py b/tests/unit/sampletones_synthesis/oscillators/test_sweeps.py
index bd7028d0..49c1a351 100644
--- a/tests/unit/sampletones_synthesis/oscillators/test_sweeps.py
+++ b/tests/unit/sampletones_synthesis/oscillators/test_sweeps.py
@@ -81,7 +81,11 @@ def test_closed_form_matches_numerical_phase_integration(
initial=0.0,
)
)
- assert np.allclose(audio, np.sin(numerical_phase), atol=PHASE_TOLERANCE_RADIANS)
+ assert np.allclose(
+ audio,
+ np.sin(numerical_phase),
+ atol=PHASE_TOLERANCE_RADIANS,
+ )
def test_equal_endpoints_render_a_steady_tone(
self,
diff --git a/tests/unit/sampletones_synthesis/test_unions.py b/tests/unit/sampletones_synthesis/test_unions.py
index fcb55ccd..1b70adaa 100644
--- a/tests/unit/sampletones_synthesis/test_unions.py
+++ b/tests/unit/sampletones_synthesis/test_unions.py
@@ -38,7 +38,11 @@ class DiscriminationCase:
DiscriminationCase(
name="geometric_sweep",
adapter=OSCILLATOR_ADAPTER,
- payload={"kind": "geometric_sweep", "frequency_start": 67, "frequency_end": 29},
+ payload={
+ "kind": "geometric_sweep",
+ "frequency_start": 67,
+ "frequency_end": 29,
+ },
expected_type=GeometricSweepOscillator,
),
DiscriminationCase(
@@ -92,9 +96,19 @@ class DiscriminationCase:
class TestUnionDiscrimination:
- @pytest.mark.parametrize("case", DISCRIMINATION_CASES, ids=lambda case: case.name)
- def test_kind_selects_the_member_class(self, case: DiscriminationCase) -> None:
- assert isinstance(case.adapter.validate_python(case.payload), case.expected_type)
+ @pytest.mark.parametrize(
+ "case",
+ DISCRIMINATION_CASES,
+ ids=lambda case: case.name,
+ )
+ def test_kind_selects_the_member_class(
+ self,
+ case: DiscriminationCase,
+ ) -> None:
+ assert isinstance(
+ case.adapter.validate_python(case.payload),
+ case.expected_type,
+ )
@pytest.mark.parametrize(
"adapter",
@@ -107,4 +121,10 @@ def test_unknown_kind_is_rejected(self, adapter: TypeAdapter[Any]) -> None:
def test_extra_field_is_rejected(self) -> None:
with pytest.raises(ValidationError):
- OSCILLATOR_ADAPTER.validate_python({"kind": "sine", "frequency": 440.0, "volume": 1.0})
+ OSCILLATOR_ADAPTER.validate_python(
+ {
+ "kind": "sine",
+ "frequency": 440.0,
+ "volume": 1.0,
+ }
+ )
diff --git a/tests/unit/sampletones_synthesis/voice/test_layer.py b/tests/unit/sampletones_synthesis/voice/test_layer.py
index 68aecde9..92ee232e 100644
--- a/tests/unit/sampletones_synthesis/voice/test_layer.py
+++ b/tests/unit/sampletones_synthesis/voice/test_layer.py
@@ -14,27 +14,57 @@
class TestLayer:
- def test_gain_scales_the_waveform(self, time_axis: np.ndarray, generator: np.random.Generator) -> None:
+ def test_gain_scales_the_waveform(
+ self,
+ time_axis: np.ndarray,
+ generator: np.random.Generator,
+ ) -> None:
oscillator = SineOscillator(kind="sine", frequency=FREQUENCY)
- unit = Layer(oscillator=oscillator, envelopes=(), gain=1.0).render(time_axis, generator=generator)
- halved = Layer(oscillator=oscillator, envelopes=(), gain=0.5).render(time_axis, generator=generator)
+ unit = Layer(oscillator=oscillator, envelopes=(), gain=1.0).render(
+ time_axis,
+ generator=generator,
+ )
+ halved = Layer(oscillator=oscillator, envelopes=(), gain=0.5).render(
+ time_axis,
+ generator=generator,
+ )
assert np.allclose(halved, 0.5 * unit)
- def test_envelopes_stack_multiplicatively(self, time_axis: np.ndarray, generator: np.random.Generator) -> None:
- attack = LinearAttackEnvelope(kind="linear_attack", attack_seconds=ATTACK_SECONDS)
- decay = ExponentialDecayEnvelope(kind="exponential_decay", time_constant_seconds=TIME_CONSTANT_SECONDS)
+ def test_envelopes_stack_multiplicatively(
+ self,
+ time_axis: np.ndarray,
+ generator: np.random.Generator,
+ ) -> None:
+ attack = LinearAttackEnvelope(
+ kind="linear_attack",
+ attack_seconds=ATTACK_SECONDS,
+ )
+ decay = ExponentialDecayEnvelope(
+ kind="exponential_decay",
+ time_constant_seconds=TIME_CONSTANT_SECONDS,
+ )
layer = Layer(
oscillator=SineOscillator(kind="sine", frequency=FREQUENCY),
envelopes=(attack, decay),
gain=1.0,
)
expected = (
- SineOscillator(kind="sine", frequency=FREQUENCY).render(time_axis, generator=generator)
+ SineOscillator(kind="sine", frequency=FREQUENCY).render(
+ time_axis,
+ generator=generator,
+ )
* attack.render(time_axis)
* decay.render(time_axis)
)
- assert np.allclose(layer.render(time_axis, generator=generator), expected)
+ assert np.allclose(
+ layer.render(time_axis, generator=generator),
+ expected,
+ )
def test_nonpositive_gain_is_rejected(self) -> None:
with pytest.raises(ValueError):
- Layer(oscillator=SineOscillator(kind="sine", frequency=FREQUENCY), envelopes=(), gain=0.0)
+ Layer(
+ oscillator=SineOscillator(kind="sine", frequency=FREQUENCY),
+ envelopes=(),
+ gain=0.0,
+ )
diff --git a/tests/unit/sampletones_synthesis/voice/test_voice.py b/tests/unit/sampletones_synthesis/voice/test_voice.py
index fc7a8a73..6323253a 100644
--- a/tests/unit/sampletones_synthesis/voice/test_voice.py
+++ b/tests/unit/sampletones_synthesis/voice/test_voice.py
@@ -16,8 +16,17 @@
"duration_seconds": DURATION_SECONDS,
"layers": [
{
- "oscillator": {"kind": "geometric_sweep", "frequency_start": 67, "frequency_end": 29},
- "envelopes": [{"kind": "exponential_decay", "time_constant_seconds": 0.066}],
+ "oscillator": {
+ "kind": "geometric_sweep",
+ "frequency_start": 67,
+ "frequency_end": 29,
+ },
+ "envelopes": [
+ {
+ "kind": "exponential_decay",
+ "time_constant_seconds": 0.066,
+ }
+ ],
"gain": 1.0,
},
{
@@ -26,19 +35,37 @@
"gain": 0.15,
},
],
- "filters": [{"kind": "butterworth_highpass", "cutoff_hz": 5000.0, "order": 4}],
+ "filters": [
+ {
+ "kind": "butterworth_highpass",
+ "cutoff_hz": 5000.0,
+ "order": 4,
+ }
+ ],
}
def _tone_layer(frequency: float, gain: float) -> Layer:
- return Layer(oscillator=SineOscillator(kind="sine", frequency=frequency), envelopes=(), gain=gain)
+ return Layer(
+ oscillator=SineOscillator(kind="sine", frequency=frequency),
+ envelopes=(),
+ gain=gain,
+ )
class TestVoice:
- def test_layers_sum(self, sample_rate: int, generator: np.random.Generator) -> None:
+ def test_layers_sum(
+ self,
+ sample_rate: int,
+ generator: np.random.Generator,
+ ) -> None:
low = _tone_layer(LOW_FREQUENCY, gain=1.0)
high = _tone_layer(HIGH_FREQUENCY, gain=0.25)
- voice = Voice(duration_seconds=DURATION_SECONDS, layers=(low, high), filters=())
+ voice = Voice(
+ duration_seconds=DURATION_SECONDS,
+ layers=(low, high),
+ filters=(),
+ )
audio = voice.render(sample_rate=sample_rate, generator=generator)
time = np.arange(round(DURATION_SECONDS * sample_rate), dtype=np.float64) / sample_rate
@@ -50,15 +77,25 @@ def test_output_is_float64_of_the_configured_length(
sample_rate: int,
generator: np.random.Generator,
) -> None:
- voice = Voice(duration_seconds=DURATION_SECONDS, layers=(_tone_layer(LOW_FREQUENCY, 1.0),), filters=())
+ voice = Voice(
+ duration_seconds=DURATION_SECONDS,
+ layers=(_tone_layer(LOW_FREQUENCY, 1.0),),
+ filters=(),
+ )
audio = voice.render(sample_rate=sample_rate, generator=generator)
assert audio.dtype == np.float64
assert audio.shape == (round(DURATION_SECONDS * sample_rate),)
def test_seeded_render_is_deterministic(self, sample_rate: int) -> None:
voice = Voice.model_validate(VOICE_MAPPING)
- first = voice.render(sample_rate=sample_rate, generator=np.random.default_rng(7))
- second = voice.render(sample_rate=sample_rate, generator=np.random.default_rng(7))
+ first = voice.render(
+ sample_rate=sample_rate,
+ generator=np.random.default_rng(7),
+ )
+ second = voice.render(
+ sample_rate=sample_rate,
+ generator=np.random.default_rng(7),
+ )
assert np.array_equal(first, second)
def test_mapping_round_trip_preserves_the_voice(self) -> None:
@@ -69,7 +106,14 @@ def test_voice_without_layers_is_rejected(self) -> None:
with pytest.raises(ValidationError):
Voice(duration_seconds=DURATION_SECONDS, layers=(), filters=())
- def test_duration_below_two_samples_is_rejected(self, generator: np.random.Generator) -> None:
- voice = Voice(duration_seconds=1e-6, layers=(_tone_layer(LOW_FREQUENCY, 1.0),), filters=())
+ def test_duration_below_two_samples_is_rejected(
+ self,
+ generator: np.random.Generator,
+ ) -> None:
+ voice = Voice(
+ duration_seconds=1e-6,
+ layers=(_tone_layer(LOW_FREQUENCY, 1.0),),
+ filters=(),
+ )
with pytest.raises(ValueError):
voice.render(sample_rate=22050, generator=generator)
diff --git a/tests/unit/scripts/checks/test_import_boundary.py b/tests/unit/scripts/checks/test_import_boundary.py
new file mode 100644
index 00000000..e2aa92fc
--- /dev/null
+++ b/tests/unit/scripts/checks/test_import_boundary.py
@@ -0,0 +1,157 @@
+from pathlib import Path
+from typing import Final, List
+
+import pytest
+
+from sampletones_shared.meta.source.modules import source_paths
+from tests.suite.scripts import load_script
+
+check_import_boundary = load_script("checks/import_boundary.py")
+
+LOGIC_RULE: Final[str] = "logic/**/*.py"
+
+FORBIDDEN_IMPORT: Final[str] = "import dearpygui.dearpygui as dpg\n"
+CONTRACT_IMPORT: Final[str] = "from sampletones_application.services.result import ServiceResult\n"
+PLAIN_IMPORT: Final[str] = "from sampletones_core.project.project import Project\n"
+PANEL_SUFFIX: Final[str] = "def build() -> None:\n dpg.add_group(parent=SUF_PANEL_LEFT)\n"
+
+
+def write_module(directory: Path, name: str, body: str) -> Path:
+ directory.mkdir(parents=True, exist_ok=True)
+ path = directory / name
+ path.write_text(body, encoding="utf-8")
+ return path
+
+
+def swept(package: Path) -> List[Path]:
+ return [path.resolve() for path in source_paths([package])]
+
+
+class TestRuleModules:
+ def test_a_module_directly_under_the_rule_directory_is_reached(self, tmp_path: Path) -> None:
+ """`logic/**/*.py` names `logic/direct.py` as surely as `logic/inner/deep.py`."""
+ direct = write_module(tmp_path / "logic", "direct.py", PLAIN_IMPORT)
+
+ reached = check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, set(swept(tmp_path)), None)
+
+ assert reached == [direct.resolve()]
+
+ def test_a_module_nested_under_the_rule_directory_is_reached(self, tmp_path: Path) -> None:
+ deep = write_module(tmp_path / "logic" / "inner", "deep.py", PLAIN_IMPORT)
+
+ reached = check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, set(swept(tmp_path)), None)
+
+ assert reached == [deep.resolve()]
+
+ def test_a_module_outside_the_rule_directory_stays_aside(self, tmp_path: Path) -> None:
+ write_module(tmp_path / "services", "conversion.py", PLAIN_IMPORT)
+
+ assert check_import_boundary.rule_modules(tmp_path, LOGIC_RULE, set(swept(tmp_path)), None) == []
+
+ def test_a_selection_narrows_the_rule_to_the_files_it_names(self, tmp_path: Path) -> None:
+ named = write_module(tmp_path / "logic", "named.py", PLAIN_IMPORT)
+ write_module(tmp_path / "logic", "other.py", PLAIN_IMPORT)
+
+ reached = check_import_boundary.rule_modules(
+ tmp_path,
+ LOGIC_RULE,
+ set(swept(tmp_path)),
+ {named.resolve()},
+ )
+
+ assert reached == [named.resolve()]
+
+
+class TestCheckBoundaries:
+ def test_a_forbidden_import_is_reported(self, tmp_path: Path) -> None:
+ write_module(tmp_path / "logic", "direct.py", FORBIDDEN_IMPORT)
+
+ violations = check_import_boundary.check_boundaries(tmp_path, None)
+
+ assert [violation.kind for violation in violations] == ["dearpygui"]
+
+ def test_the_report_names_the_line_the_import_sits_on(self, tmp_path: Path) -> None:
+ path = write_module(tmp_path / "logic", "direct.py", f"{PLAIN_IMPORT}{FORBIDDEN_IMPORT}")
+
+ violations = check_import_boundary.check_boundaries(tmp_path, None)
+
+ assert violations[0].location.startswith(f"{path}:2")
+
+ def test_a_contract_module_stays_reachable(self, tmp_path: Path) -> None:
+ """A layer reads another layer's data contract while its implementation stays out of reach."""
+ write_module(tmp_path / "logic", "direct.py", CONTRACT_IMPORT)
+
+ assert check_import_boundary.check_boundaries(tmp_path, None) == []
+
+ def test_an_allowed_import_reports_nothing(self, tmp_path: Path) -> None:
+ write_module(tmp_path / "logic", "direct.py", PLAIN_IMPORT)
+
+ assert check_import_boundary.check_boundaries(tmp_path, None) == []
+
+ def test_a_forbidden_token_is_reported(self, tmp_path: Path) -> None:
+ write_module(tmp_path / "ui" / "panels", "left.py", PANEL_SUFFIX)
+
+ violations = check_import_boundary.check_boundaries(tmp_path, None)
+
+ assert len(violations) == 1
+
+ def test_a_selection_narrows_the_check(self, tmp_path: Path) -> None:
+ checked = write_module(tmp_path / "logic", "checked.py", FORBIDDEN_IMPORT)
+ write_module(tmp_path / "logic", "other.py", FORBIDDEN_IMPORT)
+
+ violations = check_import_boundary.check_boundaries(tmp_path, {checked.resolve()})
+
+ assert len(violations) == 1
+
+
+class TestSweptRoots:
+ """A root the sweep reads nothing under reports nothing, which reads as a clean tree."""
+
+ def test_the_application_package_holds_modules(self) -> None:
+ assert source_paths([check_import_boundary.APP_ROOT])
+
+ def test_every_boundary_rule_reaches_a_module(self) -> None:
+ package = check_import_boundary.APP_ROOT
+ assert all(list(package.glob(rule.pattern)) for rule in check_import_boundary.RULES)
+
+ def test_every_token_rule_reaches_a_module(self) -> None:
+ package = check_import_boundary.APP_ROOT
+ assert all(list(package.glob(rule.pattern)) for rule in check_import_boundary.TOKEN_RULES)
+
+ def test_a_package_holding_no_module_stops_the_check(self, tmp_path: Path) -> None:
+ with pytest.raises(FileNotFoundError):
+ check_import_boundary.check_boundaries(tmp_path, None)
+
+ def test_an_absent_package_stops_the_check(self, tmp_path: Path) -> None:
+ with pytest.raises(NotADirectoryError):
+ check_import_boundary.check_boundaries(tmp_path / "absent", None)
+
+
+class TestMain:
+ def test_the_repository_holds_its_layer_boundaries(self) -> None:
+ assert check_import_boundary.main(["--all"]) == 0
+
+ def test_a_forbidden_import_is_reported_where_it_sits(
+ self,
+ tmp_path: Path,
+ capsys: pytest.CaptureFixture[str],
+ ) -> None:
+ path = write_module(tmp_path / "logic", "direct.py", FORBIDDEN_IMPORT)
+
+ exit_code = check_import_boundary.main(["--all", "--package", str(tmp_path)])
+
+ assert exit_code == 1
+ error = capsys.readouterr().err
+ assert f"{path}:1" in error
+ assert "dearpygui" in error
+
+ def test_named_files_narrow_the_run_to_themselves(
+ self,
+ tmp_path: Path,
+ capsys: pytest.CaptureFixture[str],
+ ) -> None:
+ write_module(tmp_path / "logic", "reported.py", FORBIDDEN_IMPORT)
+ clean = write_module(tmp_path / "logic", "clean.py", PLAIN_IMPORT)
+
+ assert check_import_boundary.main([str(clean), "--package", str(tmp_path)]) == 0
+ assert capsys.readouterr().err == ""
diff --git a/tests/unit/scripts/checks/test_language_keys.py b/tests/unit/scripts/checks/test_language_keys.py
index 5c6c8574..3e74ffab 100644
--- a/tests/unit/scripts/checks/test_language_keys.py
+++ b/tests/unit/scripts/checks/test_language_keys.py
@@ -4,11 +4,14 @@
import pytest
from sampletones_application.categories.elements.global_ import DialogElements
+from sampletones_application.logic.history.action import HistoryAction
+from sampletones_application.paths import LANG_EN
from sampletones_shared.meta.source.lookups import LookupSite
+from sampletones_shared.meta.source.modules import source_paths
from sampletones_shared.meta.source.values import EnumTable
from tests.suite.scripts import load_script
-check_language_keys = load_script("scripts/checks/language_keys.py")
+check_language_keys = load_script("checks/language_keys.py")
ENUMS: Final[EnumTable] = check_language_keys.enum_table()
@@ -65,19 +68,39 @@ def test_the_elements_package_states_its_members(self) -> None:
assert ENUMS["DialogElements"]["OK"] == DialogElements.OK.value
def test_every_element_enum_of_the_package_is_read(self) -> None:
- assert {"MenuElements", "SequencerGridElements", "InstructionsLibraryElements"}.issubset(ENUMS)
+ assert {
+ "MenuElements",
+ "SequencerTrackerElements",
+ "InstructionsLibraryElements",
+ }.issubset(ENUMS)
- def test_the_element_base_states_no_members(self) -> None:
- assert ENUMS[check_language_keys.ELEMENT_BASE] == {}
+ def test_an_element_enum_declared_beside_its_domain_is_read(self) -> None:
+ assert ENUMS["HistoryAction"] == {member.name: member.value for member in HistoryAction}
+
+ def test_an_enum_a_module_imports_is_read_from_the_module_declaring_it(self) -> None:
+ assert "StrEnum" not in ENUMS
+
+ def test_the_element_base_is_no_concrete_enum(self) -> None:
+ assert check_language_keys.ELEMENT_BASE not in ENUMS
class TestLanguageEntries:
def test_every_entry_is_read_with_its_line(self, tmp_path: Path) -> None:
- entries: Dict[str, int] = check_language_keys.language_entries(language_file(tmp_path, LANGUAGE_FILE))
+ entries: Dict[str, int] = check_language_keys.language_entries(
+ language_file(
+ tmp_path,
+ LANGUAGE_FILE,
+ )
+ )
assert entries == {OK_KEY: 4, EXIT_KEY: 5}
def test_a_comment_states_no_entry(self, tmp_path: Path) -> None:
- entries: Dict[str, int] = check_language_keys.language_entries(language_file(tmp_path, LANGUAGE_FILE))
+ entries: Dict[str, int] = check_language_keys.language_entries(
+ language_file(
+ tmp_path,
+ LANGUAGE_FILE,
+ )
+ )
assert all(not key.startswith("#") for key in entries)
def test_a_file_holding_no_mapping_is_refused(self, tmp_path: Path) -> None:
@@ -140,21 +163,39 @@ def test_an_entry_reached_through_an_enum_is_no_finding(self) -> None:
class TestCheckLanguageKeys:
- def test_a_tree_asking_for_every_entry_reports_nothing(self, tmp_path: Path) -> None:
+ def test_a_tree_asking_for_every_entry_reports_nothing(
+ self,
+ tmp_path: Path,
+ ) -> None:
source = source_tree(tmp_path, LOOKUP_SOURCE.format(key=OK_KEY))
entries = language_file(tmp_path, f'{OK_KEY}: "OK"\n')
assert check_language_keys.check_language_keys(source, entries) == []
- def test_a_key_the_file_omits_is_a_broken_lookup(self, tmp_path: Path) -> None:
+ def test_a_key_the_file_omits_is_a_broken_lookup(
+ self,
+ tmp_path: Path,
+ ) -> None:
source = source_tree(tmp_path, LOOKUP_SOURCE.format(key=ABSENT_KEY))
entries = language_file(tmp_path, f'{OK_KEY}: "OK"\n')
- kinds = [finding.kind for finding in check_language_keys.check_language_keys(source, entries)]
+ kinds = [
+ finding.kind
+ for finding in check_language_keys.check_language_keys(
+ source,
+ entries,
+ )
+ ]
- assert kinds == [check_language_keys.BROKEN_LOOKUP, check_language_keys.UNREACHED_ENTRY]
+ assert kinds == [
+ check_language_keys.BROKEN_LOOKUP,
+ check_language_keys.UNREACHED_ENTRY,
+ ]
- def test_an_entry_nobody_asks_for_is_unreached(self, tmp_path: Path) -> None:
+ def test_an_entry_nobody_asks_for_is_unreached(
+ self,
+ tmp_path: Path,
+ ) -> None:
source = source_tree(tmp_path, LOOKUP_SOURCE.format(key=OK_KEY))
entries = language_file(tmp_path, f'{OK_KEY}: "OK"\n{EXIT_KEY}: "Exit"\n')
@@ -163,7 +204,10 @@ def test_an_entry_nobody_asks_for_is_unreached(self, tmp_path: Path) -> None:
assert [finding.kind for finding in findings] == [check_language_keys.UNREACHED_ENTRY]
assert findings[0].location.endswith("en.yaml:2")
- def test_a_dynamic_part_reaching_no_enum_is_unresolved(self, tmp_path: Path) -> None:
+ def test_a_dynamic_part_reaching_no_enum_is_unresolved(
+ self,
+ tmp_path: Path,
+ ) -> None:
source = source_tree(
tmp_path,
"def label(language_manager: LanguageManager, element: AbstractElement) -> str:\n"
@@ -171,11 +215,20 @@ def test_a_dynamic_part_reaching_no_enum_is_unresolved(self, tmp_path: Path) ->
)
entries = language_file(tmp_path, f'{OK_KEY}: "OK"\n')
- kinds = [finding.kind for finding in check_language_keys.check_language_keys(source, entries)]
+ kinds = [
+ finding.kind
+ for finding in check_language_keys.check_language_keys(
+ source,
+ entries,
+ )
+ ]
assert check_language_keys.UNRESOLVED_PART in kinds
- def test_a_dynamic_part_of_a_concrete_enum_reaches_its_entries(self, tmp_path: Path) -> None:
+ def test_a_dynamic_part_of_a_concrete_enum_reaches_its_entries(
+ self,
+ tmp_path: Path,
+ ) -> None:
source = source_tree(
tmp_path,
"def label(language_manager: LanguageManager, element: DialogElements) -> str:\n"
@@ -186,6 +239,16 @@ def test_a_dynamic_part_of_a_concrete_enum_reaches_its_entries(self, tmp_path: P
assert check_language_keys.check_language_keys(source, entries) == []
+class TestSweptRoots:
+ """A root the sweep reads nothing under reports nothing, which reads as a clean tree."""
+
+ def test_the_source_root_holds_modules(self) -> None:
+ assert source_paths([check_language_keys.SOURCE_ROOT])
+
+ def test_the_language_file_is_there_to_read(self) -> None:
+ assert LANG_EN.is_file()
+
+
class TestMain:
def test_the_repository_and_its_language_file_agree(self) -> None:
assert check_language_keys.main([]) == 0
diff --git a/tests/unit/scripts/checks/test_palette_colors.py b/tests/unit/scripts/checks/test_palette_colors.py
new file mode 100644
index 00000000..2056eb0d
--- /dev/null
+++ b/tests/unit/scripts/checks/test_palette_colors.py
@@ -0,0 +1,155 @@
+from pathlib import Path
+from typing import Final, List, Tuple
+
+from pytest import fixture
+
+from sampletones_application.paths import PALETTES_DIRECTORY
+from sampletones_shared.meta.source.modules import SourceModule, source_paths
+from sampletones_shared.paths.resources import CONFIG_DIRECTORY
+from scripts.checks.palette_colors import dpg_module_helper
+from tests.suite.scripts import load_script
+from tests.suite.source import parse_source
+
+check_palette_colors = load_script("checks/palette_colors.py")
+
+PANEL_MODULE: Final[Path] = Path("ui/panel.py")
+
+PANEL_SOURCE: Final[str] = """
+class GUIPanel:
+ def __init__(self, layout) -> None:
+ self._tint = layout.colors.accent.rgba
+ self._colors = layout.colors
+ cls._border: ColorRGBA = layout.colors.border.rgba
+ self._theme = create_theme(layout.colors.text.rgba)
+ local = layout.colors.text.rgba
+
+ def draw(self) -> None:
+ dpg.add_text("value", color=self._colors.accent.rgba)
+"""
+
+PALETTE_FILE: Final[str] = 'colors:\n accent: "#a97fe3"\n'
+LAYOUT_FILE: Final[str] = 'label_color: .accent\nclip_color: "#ff5555"\n'
+
+
+@fixture
+def module_helpers() -> Tuple[Path, str]:
+ return dpg_module_helper()
+
+
+def messages(source: str) -> List[str]:
+ module = SourceModule(path=PANEL_MODULE, tree=parse_source(source))
+ return [finding.message for finding in check_palette_colors.stored_colors(module)]
+
+
+def locations(source: str) -> List[str]:
+ module = SourceModule(path=PANEL_MODULE, tree=parse_source(source))
+ return [finding.location for finding in check_palette_colors.stored_colors(module)]
+
+
+def theme_color_messages(
+ source: str,
+ module_helpers: Tuple[Path, str],
+ path: Path = PANEL_MODULE,
+) -> List[str]:
+ bindings_module, theme_color_helper = module_helpers
+ module = SourceModule(path=path, tree=parse_source(source))
+ return [
+ finding.message
+ for finding in check_palette_colors.unregistered_theme_colors(
+ module,
+ bindings_module=bindings_module,
+ theme_color_helper=theme_color_helper,
+ )
+ ]
+
+
+class TestStoredColors:
+ def test_an_attribute_assigned_the_resolved_value_is_reported(self) -> None:
+ assert len(messages(PANEL_SOURCE)) == 2
+
+ def test_the_report_names_the_assignment_line(self) -> None:
+ assert locations(PANEL_SOURCE) == [f"{PANEL_MODULE}:4", f"{PANEL_MODULE}:6"]
+
+ def test_an_attribute_holding_the_palette_colour_passes(self) -> None:
+ source = "class GUIPanel:\n def __init__(self, layout) -> None:\n self._c = layout.colors\n"
+
+ assert not messages(source)
+
+ def test_a_resolved_value_handed_straight_to_dearpygui_passes(self) -> None:
+ assert not messages('def draw(self) -> None:\n dpg.add_text("v", color=self._colors.accent.rgba)\n')
+
+ def test_a_resolved_value_reaching_a_call_passes(self) -> None:
+ assert not messages("class P:\n def f(self, layout) -> None:\n self._t = build(layout.c.text.rgba)\n")
+
+ def test_a_local_holding_the_resolved_value_passes(self) -> None:
+ assert not messages("def f(layout) -> None:\n local = layout.colors.text.rgba\n")
+
+
+class TestUnregisteredThemeColors:
+ def test_a_theme_colour_filled_directly_is_reported(
+ self,
+ module_helpers: Tuple[Path, str],
+ ) -> None:
+ source = "def build() -> None:\n dpg.add_theme_color(dpg.mvThemeCol_Text, color.rgba)\n"
+
+ assert len(theme_color_messages(source, module_helpers)) == 1
+
+ def test_the_helper_that_records_it_passes(
+ self,
+ module_helpers: Tuple[Path, str],
+ ) -> None:
+ source = "def build() -> None:\n dpg_add_palette_theme_color(dpg.mvThemeCol_Text, color)\n"
+
+ assert not theme_color_messages(source, module_helpers)
+
+ def test_the_bindings_module_may_fill_it(
+ self,
+ module_helpers: Tuple[Path, str],
+ ) -> None:
+ """The helper is where the call belongs, since it records the token in the same breath."""
+ bindings_module, _ = module_helpers
+ source = "def build() -> None:\n dpg.add_theme_color(dpg.mvThemeCol_Text, color.rgba)\n"
+
+ assert not theme_color_messages(source, module_helpers, bindings_module)
+
+ def test_a_theme_style_passes(
+ self,
+ module_helpers: Tuple[Path, str],
+ ) -> None:
+ source = "def build() -> None:\n dpg.add_theme_style(dpg.mvStyleVar_ItemSpacing, 0, 0)\n"
+
+ assert not theme_color_messages(source, module_helpers)
+
+
+class TestSweptRoots:
+ """A root the sweep reads nothing under reports nothing, which reads as a clean tree."""
+
+ def test_the_application_package_holds_modules(self) -> None:
+ assert source_paths([check_palette_colors.APPLICATION_PACKAGE])
+
+ def test_the_configuration_package_holds_files_to_read(self) -> None:
+ assert list(CONFIG_DIRECTORY.rglob(check_palette_colors.CONFIG_PATTERN))
+
+ def test_the_palettes_sit_inside_the_configuration_package(self) -> None:
+ assert CONFIG_DIRECTORY in PALETTES_DIRECTORY.parents
+
+
+class TestLiteralColors:
+ def test_a_hex_colour_outside_the_palettes_is_reported(self, tmp_path: Path) -> None:
+ (tmp_path / "settings.yaml").write_text(LAYOUT_FILE)
+
+ findings = check_palette_colors.find_literal_colors(tmp_path, tmp_path / "palettes")
+
+ assert [finding.location for finding in findings] == [f"{tmp_path / 'settings.yaml'}:2"]
+
+ def test_a_palette_carries_its_colours_as_values(self, tmp_path: Path) -> None:
+ palettes = tmp_path / "palettes"
+ palettes.mkdir()
+ (palettes / "studio.yaml").write_text(PALETTE_FILE)
+
+ assert check_palette_colors.find_literal_colors(tmp_path, palettes) == []
+
+ def test_a_token_reference_passes(self, tmp_path: Path) -> None:
+ (tmp_path / "settings.yaml").write_text("label_color: .accent\n")
+
+ assert check_palette_colors.find_literal_colors(tmp_path, tmp_path / "palettes") == []
diff --git a/tests/unit/scripts/checks/test_tag_names.py b/tests/unit/scripts/checks/test_tag_names.py
index 569118c6..3b03f4fc 100644
--- a/tests/unit/scripts/checks/test_tag_names.py
+++ b/tests/unit/scripts/checks/test_tag_names.py
@@ -6,13 +6,14 @@
from sampletones_application.categories.hierarchy import Page, Panel, Widget
from sampletones_application.categories.key.tag import TagName
-from sampletones_shared.meta.source.modules import SourceModule
+from sampletones_shared.meta.source.modules import SourceModule, source_paths
+from sampletones_shared.paths.source import SOURCE_ROOT
from tests.suite.base import BaseTestSuite
from tests.suite.case import BaseRegularTestCase
from tests.suite.scripts import load_script
from tests.suite.source import parse_source
-check_tag_names = load_script("scripts/checks/tag_names.py")
+check_tag_names = load_script("checks/tag_names.py")
MODULE_PATH: Final[Path] = Path("src/sampletones_application/tags/general.py")
@@ -44,7 +45,7 @@ class TestCase(BaseRegularTestCase):
source: str
expected: Tuple[str, ...]
- test_cases = [
+ test_cases = (
TestCase(
label="well_named_tag",
source=f"TAG_GLOBAL_WINDOW_MAIN = {WINDOW_TAG}",
@@ -129,9 +130,13 @@ class TestCase(BaseRegularTestCase):
source=f"def build() -> TagName:\n tag = {WINDOW_TAG}\n return tag",
expected=(),
),
- ]
+ )
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
def test_check_module(self, test_case: "TestCheckModule.TestCase") -> None:
found = messages(test_case.source)
@@ -140,6 +145,16 @@ def test_check_module(self, test_case: "TestCheckModule.TestCase") -> None:
assert fragment in message
+class TestSweptRoots:
+ """A root the sweep reads nothing under reports nothing, which reads as a clean tree."""
+
+ def test_the_tags_package_holds_modules(self) -> None:
+ assert source_paths([check_tag_names.TAGS_PACKAGE])
+
+ def test_the_tags_package_sits_under_the_source_root(self) -> None:
+ assert SOURCE_ROOT in check_tag_names.TAGS_PACKAGE.parents
+
+
class TestMain:
def test_the_tags_package_names_its_tags_after_them(self) -> None:
assert check_tag_names.main(["--all"]) == 0
diff --git a/tests/unit/scripts/checks/test_unused_tags.py b/tests/unit/scripts/checks/test_unused_tags.py
index 8a30e3a6..167e4a90 100644
--- a/tests/unit/scripts/checks/test_unused_tags.py
+++ b/tests/unit/scripts/checks/test_unused_tags.py
@@ -3,11 +3,12 @@
import pytest
-from sampletones_shared.meta.source.modules import SourceModule
+from sampletones_shared.meta.source.modules import SourceModule, source_paths
+from sampletones_shared.paths.source import SOURCE_ROOT
from tests.suite.scripts import load_script
from tests.suite.source import parse_source
-check_unused_tags = load_script("scripts/checks/unused_tags.py")
+check_unused_tags = load_script("checks/unused_tags.py")
TAGS_MODULE: Final[Path] = Path("tags/general.py")
PANEL_MODULE: Final[Path] = Path("ui/panel.py")
@@ -86,6 +87,19 @@ def test_a_tree_reading_every_fragment_reports_nothing(self) -> None:
assert unread(TAGS_SOURCE, panel) == []
+class TestSweptRoots:
+ """A root the sweep reads nothing under reports nothing, which reads as a clean tree."""
+
+ def test_the_tags_package_holds_modules(self) -> None:
+ assert source_paths([check_unused_tags.TAGS_PACKAGE])
+
+ def test_every_reference_root_holds_modules(self) -> None:
+ assert all(source_paths([root]) for root in check_unused_tags.REFERENCE_ROOTS)
+
+ def test_the_tags_package_sits_under_the_source_root(self) -> None:
+ assert SOURCE_ROOT in check_unused_tags.TAGS_PACKAGE.parents
+
+
class TestMain:
def test_the_repository_reads_every_fragment_it_declares(self) -> None:
assert check_unused_tags.main([]) == 0
diff --git a/tests/unit/scripts/ci/checks/test_bundle.py b/tests/unit/scripts/ci/checks/test_bundle.py
index a2dd6b24..06e339dc 100644
--- a/tests/unit/scripts/ci/checks/test_bundle.py
+++ b/tests/unit/scripts/ci/checks/test_bundle.py
@@ -9,7 +9,7 @@
from tests.suite.case import BaseRegularTestCase
from tests.suite.scripts import load_script
-check_bundle = load_script("scripts/ci/checks/bundle.py")
+check_bundle = load_script("ci/checks/bundle.py")
NOTICES = ("LICENSE", "THIRD-PARTY-NOTICES.md", "THIRD-PARTY-LICENSES.txt")
@@ -25,17 +25,30 @@ def bundle(tmp_path: Path) -> Path:
def _install_launcher(bundle: Path) -> Path:
- launcher: Path = check_bundle.launcher_path(bundle, system=check_bundle.platform.system())
+ launcher: Path = check_bundle.launcher_path(
+ bundle,
+ system=check_bundle.platform.system(),
+ )
launcher.write_bytes(b"launcher")
return launcher
-def _stub_run(monkeypatch: pytest.MonkeyPatch, *, returncode: int) -> List[Sequence[str]]:
+def _stub_run(
+ monkeypatch: pytest.MonkeyPatch,
+ *,
+ returncode: int,
+) -> List[Sequence[str]]:
commands: List[Sequence[str]] = []
- def fake_run(command: Sequence[str], **_: Any) -> subprocess.CompletedProcess[str]:
+ def fake_run(
+ command: Sequence[str],
+ **_: Any,
+ ) -> subprocess.CompletedProcess[str]:
commands.append(command)
- return subprocess.CompletedProcess(args=list(command), returncode=returncode)
+ return subprocess.CompletedProcess(
+ args=list(command),
+ returncode=returncode,
+ )
monkeypatch.setattr(check_bundle.subprocess, "run", fake_run)
return commands
@@ -47,14 +60,34 @@ class TestCase(BaseRegularTestCase):
system: str
expected: str
- test_cases = [
- TestCase(label="windows_launcher_carries_an_extension", system="Windows", expected="sampletones.exe"),
- TestCase(label="linux_launcher", system="Linux", expected="sampletones"),
- TestCase(label="macos_launcher", system="Darwin", expected="sampletones"),
- ]
-
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
- def test_launcher_path(self, test_case: "TestLauncherPath.TestCase", tmp_path: Path) -> None:
+ test_cases = (
+ TestCase(
+ label="windows_launcher_carries_an_extension",
+ system="Windows",
+ expected="sampletones.exe",
+ ),
+ TestCase(
+ label="linux_launcher",
+ system="Linux",
+ expected="sampletones",
+ ),
+ TestCase(
+ label="macos_launcher",
+ system="Darwin",
+ expected="sampletones",
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_launcher_path(
+ self,
+ test_case: "TestLauncherPath.TestCase",
+ tmp_path: Path,
+ ) -> None:
assert check_bundle.launcher_path(tmp_path, system=test_case.system).name == test_case.expected
@@ -66,7 +99,10 @@ def test_every_absent_notice_is_reported(self, bundle: Path) -> None:
(bundle / "LICENSE").unlink()
(bundle / "THIRD-PARTY-LICENSES.txt").unlink()
- assert check_bundle.missing_notices(bundle) == ["LICENSE", "THIRD-PARTY-LICENSES.txt"]
+ assert check_bundle.missing_notices(bundle) == [
+ "LICENSE",
+ "THIRD-PARTY-LICENSES.txt",
+ ]
def test_a_notice_directory_counts_as_absent(self, bundle: Path) -> None:
(bundle / "LICENSE").unlink()
@@ -75,8 +111,27 @@ def test_a_notice_directory_counts_as_absent(self, bundle: Path) -> None:
assert check_bundle.missing_notices(bundle) == ["LICENSE"]
+class TestCarriedBuildTools:
+ def test_an_application_bundle_holds_to_its_notices(self, bundle: Path) -> None:
+ assert check_bundle.carried_build_tools(bundle) == []
+
+ def test_a_build_tool_beside_the_application_is_reported(self, bundle: Path) -> None:
+ (bundle / check_bundle.INTERNAL_DIRECTORY / "PIL").mkdir(parents=True)
+
+ assert check_bundle.carried_build_tools(bundle) == ["PIL"]
+
+ def test_a_build_tool_beside_the_launcher_is_reported(self, bundle: Path) -> None:
+ (bundle / "PIL").mkdir()
+
+ assert check_bundle.carried_build_tools(bundle) == ["PIL"]
+
+
class TestMain:
- def test_a_complete_bundle_passes(self, bundle: Path, monkeypatch: pytest.MonkeyPatch) -> None:
+ def test_a_complete_bundle_passes(
+ self,
+ bundle: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
launcher = _install_launcher(bundle)
commands = _stub_run(monkeypatch, returncode=0)
@@ -97,6 +152,20 @@ def test_a_missing_notice_is_annotated_as_an_error(
assert output.startswith("::error::")
assert "THIRD-PARTY-NOTICES.md" in output
+ def test_bundled_build_tooling_is_annotated_as_an_error(
+ self,
+ bundle: Path,
+ capsys: pytest.CaptureFixture[str],
+ ) -> None:
+ _install_launcher(bundle)
+ (bundle / check_bundle.INTERNAL_DIRECTORY / "PIL").mkdir(parents=True)
+
+ assert check_bundle.main([str(bundle)]) == 1
+
+ output = capsys.readouterr().out
+ assert output.startswith("::error::")
+ assert "PIL" in output
+
def test_a_missing_launcher_is_annotated_as_an_error(
self,
bundle: Path,
diff --git a/tests/unit/scripts/ci/checks/test_version_tag.py b/tests/unit/scripts/ci/checks/test_version_tag.py
index d3f1d204..77725fb1 100644
--- a/tests/unit/scripts/ci/checks/test_version_tag.py
+++ b/tests/unit/scripts/ci/checks/test_version_tag.py
@@ -6,7 +6,7 @@
from tests.suite.case import BaseRegularTestCase
from tests.suite.scripts import load_script
-check_version_tag = load_script("scripts/ci/checks/version_tag.py")
+check_version_tag = load_script("ci/checks/version_tag.py")
class TestVersionFromTag(BaseTestSuite):
@@ -15,16 +15,40 @@ class TestCase(BaseRegularTestCase):
tag: str
expected: str
- test_cases = [
- TestCase(label="release_tag", tag="v0.3.0", expected="0.3.0"),
- TestCase(label="prerelease_tag", tag="v0.3.0.dev1", expected="0.3.0.dev1"),
- TestCase(label="release_candidate", tag="v1.0.0rc2", expected="1.0.0rc2"),
- TestCase(label="bare_version", tag="0.3.0", expected="0.3.0"),
- TestCase(label="single_prefix_is_dropped", tag="vv0.3.0", expected="v0.3.0"),
- ]
-
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
- def test_version_from_tag(self, test_case: "TestVersionFromTag.TestCase") -> None:
+ test_cases = (
+ TestCase(
+ label="release_tag",
+ tag="v0.3.0",
+ expected="0.3.0",
+ ),
+ TestCase(
+ label="prerelease_tag",
+ tag="v0.3.0.dev1",
+ expected="0.3.0.dev1",
+ ),
+ TestCase(
+ label="release_candidate",
+ tag="v1.0.0rc2",
+ expected="1.0.0rc2",
+ ),
+ TestCase(
+ label="bare_version",
+ tag="0.3.0",
+ expected="0.3.0",
+ ),
+ TestCase(
+ label="single_prefix_is_dropped",
+ tag="vv0.3.0",
+ expected="v0.3.0",
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_version_from_tag(self, test_case: TestCase) -> None:
assert check_version_tag.version_from_tag(test_case.tag) == test_case.expected
@@ -35,16 +59,48 @@ class TestCase(BaseRegularTestCase):
project_version: str
expected: bool
- test_cases = [
- TestCase(label="tag_matches", tag="v0.3.0", project_version="0.3.0", expected=True),
- TestCase(label="prerelease_matches", tag="v0.3.0.dev1", project_version="0.3.0.dev1", expected=True),
- TestCase(label="patch_differs", tag="v0.3.1", project_version="0.3.0", expected=False),
- TestCase(label="project_ahead_of_tag", tag="v0.3.0", project_version="0.4.0", expected=False),
- TestCase(label="prerelease_against_release", tag="v0.3.0", project_version="0.3.0.dev1", expected=False),
- ]
-
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
- def test_tag_names_version(self, test_case: "TestTagNamesVersion.TestCase") -> None:
+ test_cases = (
+ TestCase(
+ label="tag_matches",
+ tag="v0.3.0",
+ project_version="0.3.0",
+ expected=True,
+ ),
+ TestCase(
+ label="prerelease_matches",
+ tag="v0.3.0.dev1",
+ project_version="0.3.0.dev1",
+ expected=True,
+ ),
+ TestCase(
+ label="patch_differs",
+ tag="v0.3.1",
+ project_version="0.3.0",
+ expected=False,
+ ),
+ TestCase(
+ label="project_ahead_of_tag",
+ tag="v0.3.0",
+ project_version="0.4.0",
+ expected=False,
+ ),
+ TestCase(
+ label="prerelease_against_release",
+ tag="v0.3.0",
+ project_version="0.3.0.dev1",
+ expected=False,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_tag_names_version(
+ self,
+ test_case: TestCase,
+ ) -> None:
result = check_version_tag.tag_names_version(
tag=test_case.tag,
project_version=test_case.project_version,
@@ -54,12 +110,28 @@ def test_tag_names_version(self, test_case: "TestTagNamesVersion.TestCase") -> N
class TestMain:
- def test_matching_version_succeeds(self, capsys: pytest.CaptureFixture[str]) -> None:
- assert check_version_tag.main(["--tag", "v0.3.0", "--project-version", "0.3.0"]) == 0
+ def test_matching_version_succeeds(
+ self,
+ capsys: pytest.CaptureFixture[str],
+ ) -> None:
+ assert (
+ check_version_tag.main(
+ ["--tag", "v0.3.0", "--project-version", "0.3.0"],
+ )
+ == 0
+ )
assert "matches" in capsys.readouterr().out
- def test_mismatched_version_is_annotated_as_an_error(self, capsys: pytest.CaptureFixture[str]) -> None:
- assert check_version_tag.main(["--tag", "v0.3.1", "--project-version", "0.3.0"]) == 1
+ def test_mismatched_version_is_annotated_as_an_error(
+ self,
+ capsys: pytest.CaptureFixture[str],
+ ) -> None:
+ assert (
+ check_version_tag.main(
+ ["--tag", "v0.3.1", "--project-version", "0.3.0"],
+ )
+ == 1
+ )
output = capsys.readouterr().out
assert output.startswith("::error::")
diff --git a/tests/unit/scripts/ci/test_zip_bundle.py b/tests/unit/scripts/ci/test_zip_bundle.py
index 899525ad..a5c40633 100644
--- a/tests/unit/scripts/ci/test_zip_bundle.py
+++ b/tests/unit/scripts/ci/test_zip_bundle.py
@@ -6,7 +6,7 @@
from tests.suite.scripts import load_script
-zip_bundle = load_script("scripts/ci/zip_bundle.py")
+zip_bundle = load_script("ci/zip_bundle.py")
ROOT = "sampletones-v0.3.0-windows-x86_64"
diff --git a/tests/unit/scripts/test_detect_cuda.py b/tests/unit/scripts/test_detect_cuda.py
index 73795bb5..60012eb6 100644
--- a/tests/unit/scripts/test_detect_cuda.py
+++ b/tests/unit/scripts/test_detect_cuda.py
@@ -3,7 +3,7 @@
import subprocess
from dataclasses import dataclass
from pathlib import Path
-from typing import Optional, Sequence, Tuple
+from typing import Any, Optional, Sequence, Tuple
import pytest
@@ -11,7 +11,7 @@
from tests.suite.case import BaseRegularTestCase
from tests.suite.scripts import load_script
-detect_cuda = load_script("scripts/detect_cuda.py")
+detect_cuda = load_script("detect_cuda.py")
def _completed(
@@ -38,19 +38,55 @@ class TestCase(BaseRegularTestCase):
cuda_version: Optional[Tuple[int, int]]
expected: Optional[str]
- test_cases = [
- TestCase(label="cuda_12_0_selects_gpu", cuda_version=(12, 0), expected="gpu"),
- TestCase(label="cuda_12_9_selects_gpu", cuda_version=(12, 9), expected="gpu"),
- TestCase(label="cuda_13_0_selects_gpu", cuda_version=(13, 0), expected="gpu"),
- TestCase(label="cuda_14_2_selects_gpu", cuda_version=(14, 2), expected="gpu"),
- TestCase(label="cuda_11_8_selects_legacy", cuda_version=(11, 8), expected="gpu-cuda11"),
- TestCase(label="cuda_11_0_selects_legacy", cuda_version=(11, 0), expected="gpu-cuda11"),
- TestCase(label="cuda_10_2_keeps_cpu", cuda_version=(10, 2), expected=None),
- TestCase(label="absent_version_keeps_cpu", cuda_version=None, expected=None),
- ]
-
- @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label)
- def test_select_extra(self, test_case: "TestSelectExtra.TestCase") -> None:
+ test_cases = (
+ TestCase(
+ label="cuda_12_0_selects_gpu",
+ cuda_version=(12, 0),
+ expected="gpu",
+ ),
+ TestCase(
+ label="cuda_12_9_selects_gpu",
+ cuda_version=(12, 9),
+ expected="gpu",
+ ),
+ TestCase(
+ label="cuda_13_0_selects_gpu",
+ cuda_version=(13, 0),
+ expected="gpu",
+ ),
+ TestCase(
+ label="cuda_14_2_selects_gpu",
+ cuda_version=(14, 2),
+ expected="gpu",
+ ),
+ TestCase(
+ label="cuda_11_8_selects_legacy",
+ cuda_version=(11, 8),
+ expected="gpu-cuda11",
+ ),
+ TestCase(
+ label="cuda_11_0_selects_legacy",
+ cuda_version=(11, 0),
+ expected="gpu-cuda11",
+ ),
+ TestCase(
+ label="cuda_10_2_keeps_cpu",
+ cuda_version=(10, 2),
+ expected=None,
+ ),
+ TestCase(
+ label="absent_version_keeps_cpu",
+ cuda_version=None,
+ expected=None,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_select_extra(self, test_case: TestCase) -> None:
assert detect_cuda.select_extra(test_case.cuda_version) == test_case.expected
@@ -60,38 +96,83 @@ class TestCase(BaseRegularTestCase):
output: str
expected: Optional[Tuple[int, int]]
- test_cases = [
- TestCase(label="table_header", output=TABLE_OUTPUT_CUDA12, expected=(12, 4)),
- TestCase(label="query_block", output=QUERY_OUTPUT_CUDA11, expected=(11, 8)),
- TestCase(label="cuda_13", output="CUDA Version: 13.0\n", expected=(13, 0)),
- TestCase(label="no_version_present", output=NO_VERSION_OUTPUT, expected=None),
- ]
-
- @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], **_: Any) -> subprocess.CompletedProcess[str]:
+ test_cases = (
+ TestCase(
+ label="table_header",
+ output=TABLE_OUTPUT_CUDA12,
+ expected=(12, 4),
+ ),
+ TestCase(
+ label="query_block",
+ output=QUERY_OUTPUT_CUDA11,
+ expected=(11, 8),
+ ),
+ TestCase(
+ label="cuda_13",
+ output="CUDA Version: 13.0\n",
+ expected=(13, 0),
+ ),
+ TestCase(
+ label="no_version_present",
+ output=NO_VERSION_OUTPUT,
+ expected=None,
+ ),
+ )
+
+ @pytest.mark.parametrize(
+ "test_case",
+ test_cases,
+ ids=lambda test_case: test_case.label,
+ )
+ def test_parse(
+ self,
+ test_case: TestCase,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ 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], **_: Any) -> subprocess.CompletedProcess[str]:
+ def test_falls_back_to_query_flag(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ 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)
monkeypatch.setattr(detect_cuda.subprocess, "run", fake_run)
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], **_: Any) -> subprocess.CompletedProcess[str]:
+ def test_missing_executable_keeps_cpu(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ 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], **_: Any) -> subprocess.CompletedProcess[str]:
+ def test_nonzero_return_code_keeps_cpu(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ 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)
@@ -100,7 +181,11 @@ def fake_run(command: Sequence[str], **_: Any) -> subprocess.CompletedProcess[st
class TestFindNvidiaSmi:
def test_uses_path_when_present(self, monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setattr(detect_cuda.shutil, "which", lambda name: "/usr/bin/nvidia-smi")
+ monkeypatch.setattr(
+ detect_cuda.shutil,
+ "which",
+ lambda name: "/usr/bin/nvidia-smi",
+ )
assert detect_cuda.find_nvidia_smi(system="Linux") == Path("/usr/bin/nvidia-smi")
def test_absent_on_linux(self, monkeypatch: pytest.MonkeyPatch) -> None:
@@ -120,9 +205,20 @@ def test_no_driver_keeps_cpu(self, monkeypatch: pytest.MonkeyPatch) -> None:
assert detection.extra is None
assert detection.nvidia_smi is None
- def test_selects_gpu_for_cuda12_driver(self, monkeypatch: pytest.MonkeyPatch) -> None:
- monkeypatch.setattr(detect_cuda.shutil, "which", lambda name: "/usr/bin/nvidia-smi")
- monkeypatch.setattr(detect_cuda.subprocess, "run", lambda command, **_: _completed(TABLE_OUTPUT_CUDA12))
+ def test_selects_gpu_for_cuda12_driver(
+ self,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ monkeypatch.setattr(
+ detect_cuda.shutil,
+ "which",
+ lambda name: "/usr/bin/nvidia-smi",
+ )
+ monkeypatch.setattr(
+ detect_cuda.subprocess,
+ "run",
+ lambda command, **_: _completed(TABLE_OUTPUT_CUDA12),
+ )
detection = detect_cuda.detect(system="Linux")
assert detection.cuda_version == (12, 4)
assert detection.extra == "gpu"
diff --git a/uv.lock b/uv.lock
index 0098df28..4699d98d 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1236,6 +1236,77 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" },
]
+[[package]]
+name = "pillow"
+version = "12.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
+ { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
+ { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
+ { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
+ { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
+ { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
+ { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
+ { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" },
+ { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" },
+ { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" },
+ { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" },
+ { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" },
+ { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" },
+ { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" },
+ { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" },
+ { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" },
+ { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" },
+ { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" },
+ { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" },
+ { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" },
+ { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" },
+ { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" },
+ { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" },
+ { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" },
+ { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" },
+ { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" },
+ { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" },
+ { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" },
+ { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" },
+ { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" },
+ { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" },
+ { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" },
+ { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" },
+ { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" },
+ { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" },
+ { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" },
+ { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" },
+ { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" },
+ { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" },
+ { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" },
+ { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" },
+ { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" },
+ { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" },
+ { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" },
+ { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" },
+ { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" },
+ { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" },
+ { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" },
+ { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" },
+ { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" },
+ { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" },
+]
+
[[package]]
name = "platformdirs"
version = "4.9.6"
@@ -1735,6 +1806,7 @@ dependencies = [
{ name = "rich" },
{ name = "scipy" },
{ name = "screeninfo" },
+ { name = "soundfile" },
{ name = "tqdm" },
]
@@ -1753,10 +1825,14 @@ gpu-cuda11 = [
]
[package.dev-dependencies]
+assets = [
+ { name = "pillow" },
+]
dev = [
{ name = "black" },
{ name = "isort" },
{ name = "mypy" },
+ { name = "pillow" },
{ name = "pre-commit" },
{ name = "pylint" },
{ name = "pylint-pydantic" },
@@ -1789,15 +1865,18 @@ requires-dist = [
{ name = "rich", specifier = ">=13.0,<16" },
{ name = "scipy", specifier = ">=1.13,<2" },
{ name = "screeninfo", specifier = ">=0.8,<0.9" },
+ { name = "soundfile", specifier = ">=0.13,<0.14" },
{ name = "tqdm", specifier = ">=4.66,<5" },
]
provides-extras = ["build", "gpu", "gpu-cuda11"]
[package.metadata.requires-dev]
+assets = [{ name = "pillow", specifier = ">=11,<13" }]
dev = [
{ name = "black", specifier = "==26.5.1" },
{ name = "isort", specifier = "==8.0.1" },
{ name = "mypy", specifier = "==2.1.0" },
+ { name = "pillow", specifier = ">=11,<13" },
{ name = "pre-commit", specifier = "==4.6.0" },
{ name = "pylint", specifier = "==4.0.6" },
{ name = "pylint-pydantic", specifier = "==0.4.1" },