diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff6e8556..1c8772a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,7 +47,7 @@ jobs: 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 +62,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..3cdb1383 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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 51492d91..570b4496 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 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..51593f8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ * 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`. ## v0.3.0 [2026-07-31] diff --git a/Makefile b/Makefile index 21d2e81e..0fec3476 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 + 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,7 +65,7 @@ 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) @@ -74,8 +76,8 @@ help: @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),) + $(SETUP_ENV) uv tool install --force $(if $(GPU_EXTRA),".[$(GPU_EXTRA)]",.) install: $(MAKE) setup @@ -118,6 +120,9 @@ 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 diff --git a/README.md b/README.md index 9997d6ac..c7751818 100644 --- a/README.md +++ b/README.md @@ -57,11 +57,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/docs/development/architecture.md b/docs/development/architecture.md index 6e531296..10dbf8bf 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.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/`. --- @@ -310,7 +337,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 +469,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/bugs-and-todos.md b/docs/development/bugs-and-todos.md index f181a0af..85b9863f 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -3,11 +3,8 @@ ### Navigation * Interface scale -* VSync/frame rate options * Tree navigation using keys * Waveform LOD for zooming -* Keybindings options -* Tracker cell shortcuts * Drag and drop * Multiple Reconstruction views * Playing a fragment by clicking on a waveform @@ -17,7 +14,6 @@ * Basic shapes as instruments * Selection operations on patterns and orders -* Replace/swap sample ### Workflow @@ -26,7 +22,6 @@ ### Features -* Theme selector and palette management * In-application guide/tutorial * Language selector @@ -38,6 +33,7 @@ * Respecting FamiTracker limitations * Carrying the project comment and tempo into a Bitphase document, once the format holds them * Per-tab undo routing +* Delete duplicated HistoryAction enumeration ## Bugs 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..a64068f6 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -18,6 +18,12 @@ 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. + ## 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. diff --git a/docs/development/playback.md b/docs/development/playback.md index b4513237..b8fb18a9 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -95,6 +95,15 @@ 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. +Marking the sounding row and the playing frame is independent of the choice: every mode paints both, +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 +136,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 +151,22 @@ 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. +## 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,11 +174,14 @@ 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`) | +| The reach the sequencer view follows the playhead at | `FollowMode` (`constants/playback.py`), held by `SongPlayerLogic` (`logic/sequencer/playback/song_player.py`) | +| 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.py`) | | The song's render-ahead buffer | `services/song_player/` | 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..af07b27e 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -22,13 +22,13 @@ built automatically the first time it is needed, so you can convert straight awa 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. +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 @@ -41,12 +41,11 @@ switch **Play audio source:** between **Reconstruction** and **Original audio** 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 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 @@ -74,17 +73,32 @@ 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 and +redo, **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**. + +Two of them 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` 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**. + +### 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..cc1491d9 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -41,8 +41,8 @@ to place a pattern, or right-click a frame to **Insert frame**, **Duplicate**, ## 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 +51,28 @@ 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 | + +All three mark the sounding row and the playing frame, so you can read the playhead +in any of them. **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 +90,8 @@ 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. `F1` to `F4` do the +same from the keyboard, one key per channel. 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 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..19f08acd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -92,12 +92,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 = [ @@ -155,3 +150,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/calibration.py b/scripts/calibration.py index 83a098a4..e092868e 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.constants.enums import ( + DEFAULT_GENERATORS, + GeneratorName, + SpectrumMethod, +) from sampletones_core.paths import USER_PATH_DOCUMENTS from sampletones_shared.logger import logger 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,9 +72,13 @@ 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()] diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py old mode 100644 new mode 100755 index 2628130d..f373fb8e --- 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.]+)") @@ -53,6 +55,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 +138,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 +159,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 +181,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 +198,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..89a5f9e6 --- 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 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..32f6f8c5 --- /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 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..14931ae4 --- 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 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..843e3efd 100644 --- a/scripts/ci/checks/bundle.py +++ b/scripts/ci/checks/bundle.py @@ -30,8 +30,14 @@ def missing_notices(bundle: Path) -> List[str]: 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") + 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 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/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/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..c54e35a9 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -11,7 +11,11 @@ ) 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.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 +23,9 @@ from sampletones_application.coordinators.reconstruction import ( ReconstructionCoordinator, ) -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, @@ -49,9 +55,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 ( @@ -85,6 +92,11 @@ 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, ) @@ -101,8 +113,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, ) @@ -130,6 +148,7 @@ SAMPLETONES_GROUP, SAMPLETONES_NAME_VERSION, ) +from sampletones_shared.exceptions import PlaybackError from sampletones_shared.logger import logger from sampletones_shared.types.application import Sender @@ -154,6 +173,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 +182,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 +196,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, @@ -215,22 +244,46 @@ 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.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) @@ -249,17 +302,39 @@ def __init__( 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, @@ -356,10 +431,12 @@ 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, @@ -435,10 +512,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 +530,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 @@ -494,11 +576,13 @@ 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_fullscreen=self._shell.toggle_fullscreen, about=self._open_about_dialog, @@ -540,8 +624,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_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: + def _on_tab_changed( + self, + _sender: Sender, + _app_data: Any, + _user_data: Any, + ) -> None: self._update_menu() def _build_initial_menu_state(self) -> MenuBarViewModel: @@ -562,16 +673,25 @@ 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, ) + 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( @@ -591,7 +711,7 @@ 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, @@ -614,36 +734,36 @@ 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() @@ -986,8 +1106,17 @@ def content(parent: str) -> None: ) 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, @@ -1138,7 +1267,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 +1277,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, @@ -1194,7 +1343,7 @@ def _is_project_open(self) -> bool: def _exit_application(self) -> None: stop_background_workers() - self.audio_device_manager.stop() + self._playback_router.shutdown() self._main_tab.cleanup() dpg.stop_dearpygui() @@ -1203,6 +1352,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: @@ -1249,6 +1399,7 @@ def run(self) -> None: return finally: 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/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 7071a083..d2c524a0 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -93,7 +93,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 +104,8 @@ class MenuElements(AbstractElement): GROUP_VIEW = "group_view" ITEM_VIEW_SHOW_ADVANCED_SETTINGS = "item_view_show_advanced_settings" ITEM_VIEW_FULLSCREEN = "item_view_fullscreen" + 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" diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 41939aa6..6660cec9 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" @@ -102,35 +102,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..a43bec06 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -22,3 +22,132 @@ class ProjectPropertiesElements(AbstractElement): COMMENT = "comment" 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" + 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_FULLSCREEN = "toggle_fullscreen" + ABOUT_DIALOG = "about_dialog" + NEXT_TAB = "next_tab" + PREVIOUS_TAB = "previous_tab" + + 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_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_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_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..50d32efc 100644 --- a/src/sampletones_application/categories/hierarchy.py +++ b/src/sampletones_application/categories/hierarchy.py @@ -80,7 +80,7 @@ class Panel(StrEnum): RECONSTRUCTION = auto() # Sequencer tab - GRID = auto() + TRACKER = auto() ORDER = auto() MODULE = auto() INSTRUMENTS = auto() @@ -92,4 +92,6 @@ class Panel(StrEnum): # Settings AUDIO = auto() + DISPLAY = auto() + KEYBINDINGS = auto() PROPERTIES = 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/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..206b541d 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,11 @@ def toggle_autoplay(self) -> bool: return self.config.playback.autoplay @property - def follow_playback(self) -> bool: - return self.config.playback.follow_playback + 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/session.py b/src/sampletones_application/config/managers/session.py index 0ce098a3..7725786b 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: @@ -54,8 +56,8 @@ def set_card_collapsed(self, card_tag: str, collapsed: bool) -> None: 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_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 +129,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 +194,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 +227,8 @@ def autoplay(self) -> bool: return self._config_manager.autoplay @property - def follow_playback(self) -> bool: - return self._config_manager.follow_playback + 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..9d12c5f2 100644 --- a/src/sampletones_application/config/managers/state.py +++ b/src/sampletones_application/config/managers/state.py @@ -3,7 +3,6 @@ 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( diff --git a/src/sampletones_application/config/profile.py b/src/sampletones_application/config/profile.py new file mode 100644 index 00000000..b6fcd9c7 --- /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_core.paths 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/config.py b/src/sampletones_application/config/session/application/config.py index 5b7d7314..35ca10ca 100644 --- a/src/sampletones_application/config/session/application/config.py +++ b/src/sampletones_application/config/session/application/config.py @@ -1,9 +1,11 @@ from pydantic import BaseModel, ConfigDict, Field from sampletones_application.config.session.application.audio import AudioConfig +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 +20,10 @@ class ApplicationConfig(BaseModel): default_factory=AudioConfig, description="The audio configuration settings.", ) + 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 +36,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/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..307d89fd --- /dev/null +++ b/src/sampletones_application/constants/playback.py @@ -0,0 +1,27 @@ +from enum import StrEnum +from typing import Final + + +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 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/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/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 7157f032..a84eac4f 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -61,6 +61,7 @@ ReconstructorPanelViewModel, ) from sampletones_core.audio import AudioDeviceManager +from sampletones_core.constants.enums import GeneratorName from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback @@ -490,6 +491,10 @@ def set_input_path(self, path: Path, convert: bool) -> None: 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..d8eb9897 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -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 @@ -613,6 +615,10 @@ def update_reconstruction(self) -> None: 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 +666,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..18e10326 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -3,26 +3,22 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.elements.sequencer import ( - SequencerHistoryActionElements, - SequencerHistoryElements, -) +from sampletones_application.categories.elements.sequencer import 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.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.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.history_detail import ( SequencerHistoryDetail, ) @@ -35,6 +31,7 @@ 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 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 +50,29 @@ 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.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, ) @@ -114,11 +112,13 @@ 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, @@ -172,7 +172,7 @@ def __init__( colors=layout.tree_colors, initial_collapsed=session_manager.is_card_collapsed(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._sequencer_samples_logic: SequencerSamplesLogic = SequencerSamplesLogic( project_controller, @@ -201,14 +201,16 @@ 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( 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 +223,16 @@ 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, 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 +242,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,7 +252,7 @@ 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_samples_callbacks() @@ -258,7 +264,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 +275,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, 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, 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, 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, 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, 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, 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._sequencer_module_panel.update_settings + 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 +343,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 +357,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 +374,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 @@ -453,7 +467,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 @@ -564,7 +578,7 @@ 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, @@ -615,7 +629,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 +667,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 +676,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,31 +711,49 @@ 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 _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_tracker_panel.set_playing_row(None) self._sequencer_order_panel.set_playing_position(None) def _on_player_position_changed( @@ -729,21 +761,27 @@ def _on_player_position_changed( order_position: int, row_index: int, ) -> None: + """Moves the marks the playhead carries, showing the frame it sounds when following. + + The frame is selected ahead of the row so the row's mark, and the scroll that reveals it, + land on the pattern the playhead has reached. + """ self._playing_order = order_position - self._sequencer_grid_panel.set_playing_row(row_index) + if self._song_player_logic.follow_mode.follows_pattern: + self._sequencer_tracker_logic.select_frame(order_position) + + self._sequencer_tracker_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) 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 +874,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 +912,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 +970,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) @@ -955,9 +993,9 @@ def _on_clear_row( generator: Optional[GeneratorName], ) -> None: if generator is None: - self._sequencer_grid_logic.clear_all_generators(row_index) + self._sequencer_tracker_logic.clear_all_generators(row_index) else: - self._sequencer_grid_logic.clear_row(generator, row_index) + self._sequencer_tracker_logic.clear_row(generator, row_index) def _on_clear_subcolumn( self, @@ -970,18 +1008,18 @@ def _on_clear_subcolumn( volume = subcolumn is SubColumn.VOLUME if generator is None: if instrument: - self._sequencer_grid_logic.clear_subcolumn_all_generators( + self._sequencer_tracker_logic.clear_subcolumn_all_generators( row_index, instrument=True, ) else: - self._sequencer_grid_logic.clear_sample_subcolumn( + self._sequencer_tracker_logic.clear_sample_subcolumn( row_index, transpose=transpose, volume=volume, ) else: - self._sequencer_grid_logic.clear_subcolumn( + self._sequencer_tracker_logic.clear_subcolumn( generator, row_index, instrument=instrument, @@ -999,12 +1037,12 @@ def _on_set_row( ) -> None: if generator is None: if sample_id is not None: - self._sequencer_grid_logic.set_sample_instrument( + self._sequencer_tracker_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( + self._sequencer_tracker_logic.set_sample_subcolumn( row_index, transpose=transpose, volume=volume, @@ -1018,7 +1056,7 @@ def _on_set_row( if sample_id is not None else None ) - self._sequencer_grid_logic.set_row( + self._sequencer_tracker_logic.set_row( generator, row_index, command=command, @@ -1033,14 +1071,14 @@ def _on_set_note_off( ) -> 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) + self._sequencer_tracker_logic.set_note_off_all_generators(row_index) else: - self._sequencer_grid_logic.set_note_off(generator, row_index) + self._sequencer_tracker_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, ) @@ -1052,9 +1090,9 @@ def _on_adjust_transpose( ) -> 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) + self._sequencer_tracker_logic.adjust_sample_transpose(row_index, delta) else: - self._sequencer_grid_logic.adjust_transpose( + self._sequencer_tracker_logic.adjust_transpose( generator, row_index, delta, @@ -1068,9 +1106,9 @@ def _on_adjust_volume( ) -> 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) + self._sequencer_tracker_logic.adjust_sample_volume(row_index, delta) else: - self._sequencer_grid_logic.adjust_volume( + self._sequencer_tracker_logic.adjust_volume( generator, row_index, delta, @@ -1081,10 +1119,10 @@ def _on_samples_changed( 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 +1175,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 +1190,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 +1206,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) @@ -1226,7 +1264,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.""" @@ -1255,14 +1293,10 @@ def _relocate_playhead(self, remap: Callable[[int], int]) -> None: 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 +1306,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 +1348,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.""" 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/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.py b/src/sampletones_application/layout/general/dialogs/dialogs.py similarity index 79% rename from src/sampletones_application/layout/general/dialogs.py rename to src/sampletones_application/layout/general/dialogs/dialogs.py index d1270e1b..700b5a76 100644 --- a/src/sampletones_application/layout/general/dialogs.py +++ b/src/sampletones_application/layout/general/dialogs/dialogs.py @@ -1,12 +1,9 @@ from pydantic import BaseModel +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 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/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..dcebe426 100644 --- a/src/sampletones_application/layout/settings/__init__.py +++ b/src/sampletones_application/layout/settings/__init__.py @@ -1,11 +1,19 @@ 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 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 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/tabs/sequencer/__init__.py b/src/sampletones_application/layout/tabs/sequencer/__init__.py index e4857e0e..104f2c78 100644 --- a/src/sampletones_application/layout/tabs/sequencer/__init__.py +++ b/src/sampletones_application/layout/tabs/sequencer/__init__.py @@ -1,13 +1,13 @@ 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.tables.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.tracker.tracker import TrackerLayout class SequencerLayout(BaseModel, extra="forbid", frozen=True): 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/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/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..44551c39 --- /dev/null +++ b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py @@ -0,0 +1,20 @@ +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, column widths and tint strengths. + + ``rows_per_beat`` and ``rows_per_bar`` say how the pattern is grouped: every row whose + index is a multiple of one of them opens that group and takes the emphasis its colour + carries. A count of zero leaves the rows evenly weighted. + """ + + rows: int + page_size: int + rows_per_beat: int + rows_per_bar: 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..ce0f6ce5 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" diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py index 236bff43..ac591141 100644 --- a/src/sampletones_application/logic/history/manager.py +++ b/src/sampletones_application/logic/history/manager.py @@ -1,5 +1,5 @@ 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 @@ -283,7 +283,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/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/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/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/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 8171b68e..674d5e78 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -2,7 +2,7 @@ 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 ( 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/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/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 68c5d30e..b6babae7 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -1,7 +1,7 @@ from typing import Dict, Final, List, Optional -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.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( HistoryDetail, @@ -10,7 +10,11 @@ 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 @@ -60,10 +64,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,7 +124,7 @@ 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)) @@ -132,7 +136,7 @@ def adjust_transpose( generator: Optional[GeneratorName], delta: int, ) -> Segments: - affected = self._grid_logic.relevant_generators(row_index) + affected = self._tracker_logic.relevant_generators(row_index) segments = list(self._location(row_index, generator, affected)) segments.append( self._segment(display_transpose(delta), HistoryDetailRole.TRANSPOSE), @@ -145,7 +149,7 @@ def adjust_volume( generator: Optional[GeneratorName], delta: int, ) -> Segments: - affected = self._grid_logic.relevant_generators(row_index) + affected = self._tracker_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) @@ -270,9 +274,9 @@ 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._grid_logic.relevant_generators(row_index) + return self._tracker_logic.relevant_generators(row_index) def _location( self, @@ -282,7 +286,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), ) diff --git a/src/sampletones_application/logic/sequencer/order.py b/src/sampletones_application/logic/sequencer/order.py index b1c8e4cd..2275ea49 100644 --- a/src/sampletones_application/logic/sequencer/order.py +++ b/src/sampletones_application/logic/sequencer/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, ) 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/samples.py b/src/sampletones_application/logic/sequencer/samples.py index a48edb44..4f4052b8 100644 --- a/src/sampletones_application/logic/sequencer/samples.py +++ b/src/sampletones_application/logic/sequencer/samples.py @@ -1,7 +1,7 @@ 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 diff --git a/src/sampletones_application/logic/sequencer/grid.py b/src/sampletones_application/logic/sequencer/tracker.py similarity index 96% rename from src/sampletones_application/logic/sequencer/grid.py rename to src/sampletones_application/logic/sequencer/tracker.py index fa59aa7a..ed421543 100644 --- a/src/sampletones_application/logic/sequencer/grid.py +++ b/src/sampletones_application/logic/sequencer/tracker.py @@ -1,12 +1,14 @@ 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.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,7 +31,7 @@ ) -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 @@ -43,7 +45,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 @@ -57,7 +59,7 @@ def settings(self) -> SequencerSettingsViewModel: rows_per_pattern=project.song.rows_per_pattern, ) - 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) @@ -72,7 +74,7 @@ def build_grid(self) -> SequencerGridViewModel: 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( + return SequencerTrackerViewModel( frame_index=frame_index, frame_count=frame_count, rows=rows, @@ -98,14 +100,14 @@ def _frame_row_count( 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) @@ -356,7 +358,7 @@ 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 used_generators(self, sample_id: str) -> List[GeneratorName]: """The channels a sample provides instructions for, empty when it is unknown.""" @@ -506,3 +508,5 @@ def _clamp_frame(self, frame_count: int) -> int: self._frame_index = max(0, min(self._frame_index, frame_count - 1)) return self._frame_index + self._frame_index = max(0, min(self._frame_index, frame_count - 1)) + return self._frame_index diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index 155c548e..00473536 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -2,7 +2,7 @@ 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 diff --git a/src/sampletones_application/parameters/instructions.py b/src/sampletones_application/parameters/instructions.py index 4ddb2aa5..6c90f217 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 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..04a3c820 100644 --- a/src/sampletones_application/parameters/sequencer.py +++ b/src/sampletones_application/parameters/sequencer.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 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 diff --git a/src/sampletones_application/paths.py b/src/sampletones_application/paths.py index 93872f20..4ecbdcfc 100644 --- a/src/sampletones_application/paths.py +++ b/src/sampletones_application/paths.py @@ -6,8 +6,9 @@ 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/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/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..d8ec39f0 100644 --- a/src/sampletones_application/services/song_player/player.py +++ b/src/sampletones_application/services/song_player/player.py @@ -9,6 +9,7 @@ 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 @@ -19,6 +20,7 @@ SongPositionUpdate, ) 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/shell.py b/src/sampletones_application/shell.py index b58edf61..9d156936 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, @@ -35,26 +38,14 @@ 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, 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 +61,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) @@ -110,11 +93,13 @@ 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_fullscreen: Callback about: Callback @@ -212,225 +197,108 @@ 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.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_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), + } + + @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, + } def _setup_handlers(self) -> None: self._key_router.bind() @@ -471,20 +339,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..6d6065d8 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -572,11 +572,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, 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..e487e273 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,182 @@ "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_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, 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/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/registry.py b/src/sampletones_application/ui/elements/fonts/registry.py index edfa5f1e..cea0c99f 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 @@ -27,8 +27,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), 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/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..0d29bbf4 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) 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/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/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/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/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index c84f6804..3c7a2ad7 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -6,7 +6,9 @@ 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, @@ -49,10 +51,12 @@ dpg_get_value, dpg_is_item_hovered, ) +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, @@ -73,7 +77,7 @@ Tree, TreeNode, ) -from sampletones_shared.types.application import ColorRGBA, Sender +from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import ( Callback, MessageCallback, @@ -297,7 +301,7 @@ def _create_hover_callback( 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 +351,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 @@ -444,7 +452,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"] @@ -460,7 +468,11 @@ def _create_status_bar_message_function_for_library_node( def _create_status_bar_message_function_for_directory_node( self, ) -> MessageCallback: - def message_function(*args: Any, user_data: Tuple[FileSystemNode, str], **kwargs: Any) -> str: + def message_function( + *_args: Any, + user_data: Tuple[FileSystemNode, str], + **_kwargs: Any, + ) -> str: _, node_tag = user_data expand_or_collapse = ( self._language_manager["global.dialog.template.collapse"] @@ -487,7 +499,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,10 +526,12 @@ 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]]: @@ -562,7 +576,8 @@ def _add_context_menu_details(self, node: TreeNode) -> None: dpg.add_separator() for label, value in detail_items: - detail_text = dpg.add_text(f"{label}: {value}", color=self._colors.muted) + detail_text = dpg.add_text(f"{label}: {value}") + dpg_set_palette_color(detail_text, self._colors.muted) FontRegistry.bind_to_item(detail_text, Font.MONO_SMALL) def _add_context_menu_play_item(self, node: FileSystemNode) -> None: @@ -622,7 +637,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,19 +660,29 @@ 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: + def _on_search_changed(self, _sender: Sender, query: str) -> None: if query: self.apply_filter(query, self._default_search_predicate) else: 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..c1e06fdb 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -1,15 +1,20 @@ -from typing import Dict, Final, Tuple +from functools import partial +from typing import Callable, Dict, Final, Tuple import dearpygui.dearpygui as dpg -from sampletones_application.categories.elements.global_ import ContextElements, MenuElements +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 ( @@ -24,7 +29,7 @@ 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, @@ -66,6 +71,7 @@ ) from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, + FOLLOW_MODE_SHORTCUT_IDS, PROJECT_EXPORT_SHORTCUT_IDS, SAMPLE_EXPORT_SHORTCUT_IDS, ShortcutId, @@ -88,6 +94,11 @@ TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_WAV, TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, ) +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, +} CHANNEL_LABELS: Final[Dict[GeneratorName, ContextElements]] = { GeneratorName.PULSE1: ContextElements.PULSE_1, GeneratorName.PULSE2: ContextElements.PULSE_2, @@ -110,6 +121,7 @@ def __init__( 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 @@ -121,6 +133,7 @@ def __init__( 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) @@ -360,12 +373,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 +387,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,6 +424,7 @@ 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]), check=True, @@ -420,6 +452,15 @@ def _create_view_menu(self) -> None: label=self._label(MenuElements.ITEM_VIEW_FULLSCREEN), check=True, ) + 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_help_menu(self) -> None: with dpg.menu(label=self._label(MenuElements.GROUP_HELP)): @@ -524,11 +565,8 @@ 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( @@ -536,6 +574,11 @@ def update(self, state: MenuBarViewModel) -> None: state.advanced_settings, ) + 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.""" for generator in CHANNEL_SHORTCUT_IDS: @@ -577,3 +620,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..57ba3b58 100644 --- a/src/sampletones_application/ui/panels/dialogs/project_properties.py +++ b/src/sampletones_application/ui/panels/dialogs/project_properties.py @@ -8,7 +8,6 @@ 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, @@ -18,17 +17,14 @@ 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.view_model.shared.project_properties import ( ProjectPropertiesViewModel, ) @@ -39,7 +35,7 @@ ) -class GUIProjectPropertiesWindow(GUIWindow): +class GUIProjectPropertiesWindow(GUIDialogWindow): """Modal form to view and edit the project's title, author, and comment. Each appearance renders the view model handed to :meth:`open`, and the edited @@ -54,12 +50,10 @@ 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 @@ -94,6 +88,8 @@ 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: @@ -109,15 +105,9 @@ 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, @@ -135,20 +125,14 @@ def create_window(self) -> None: 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() + ) - 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_COMMENT), @@ -156,14 +140,7 @@ def _install_navigation(self) -> None: 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): diff --git a/src/sampletones_application/ui/panels/instruction/choice.py b/src/sampletones_application/ui/panels/instruction/choice.py index f2e0805d..cd6da0ae 100644 --- a/src/sampletones_application/ui/panels/instruction/choice.py +++ b/src/sampletones_application/ui/panels/instruction/choice.py @@ -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, @@ -180,7 +183,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..af186d27 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -4,7 +4,9 @@ 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.general import ( TAG_GLOBAL_THEME_PRIMARY_BUTTON, TAG_GLOBAL_THEME_SECONDARY_BUTTON, @@ -146,20 +148,22 @@ def _setup_handlers(self) -> None: 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( + with ( + dpg.child_window( + tag=self.tag, + width=self.width, + height=self.height, + parent=parent, + border=False, + ), + 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_library_status() + self._create_library_controls() + self._create_library_tree() self._create_detail_tooltip(TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE) @@ -226,19 +230,21 @@ def _create_library_controls(self) -> None: 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.child_window( + tag=TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE, + width=-1, + height=-1, + horizontal_scrollbar=True, + ), + dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_TREE), + dpg.tree_node( + label=self._language_manager["instructions.library.label.available_libraries_text"], + tag=self.tree_tag, + default_open=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 + pass def _on_refresh_clicked(self) -> None: self.call(self.on_refresh_requested) @@ -350,9 +356,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 +381,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 +396,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 +464,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..7495e5ab 100644 --- a/src/sampletones_application/ui/panels/main/config.py +++ b/src/sampletones_application/ui/panels/main/config.py @@ -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..93853a45 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, @@ -166,7 +166,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: diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index b9eb0934..0f477526 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -4,7 +4,9 @@ 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.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON from sampletones_application.tags.main import ( TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, @@ -110,20 +112,22 @@ def __init__( 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( + with ( + dpg.child_window( + tag=self.tag, + width=self.width, + height=self.height, + parent=parent, + border=False, + ), + self._collapsible_section( self._lbl_section, glyph=self._glyphs.headers.filesystem, - ): - self._create_buttons() - dpg.add_separator() - self._create_tree_window() + ), + ): + self._create_buttons() + dpg.add_separator() + self._create_tree_window() self._create_detail_tooltip(TAG_MAIN_EXPLORER_WINDOW_TREE) self.rebuild_tree() @@ -175,23 +179,25 @@ def _create_buttons(self) -> None: def _create_tree_window(self) -> None: self.create_search(self._body_container) - with dpg.child_window( - tag=TAG_MAIN_EXPLORER_WINDOW_TREE, - horizontal_scrollbar=True, + with ( + dpg.child_window( + tag=TAG_MAIN_EXPLORER_WINDOW_TREE, + horizontal_scrollbar=True, + ), + dpg.group(tag=TAG_MAIN_EXPLORER_GROUP_TREE), + dpg.tree_node( + label=self._lbl_section, + tag=self.tree_tag, + default_open=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 + pass def collapse_all( self, - sender: Sender, - app_data: int, - user_data: Any, + _sender: Sender, + _app_data: int, + _user_data: Any, ) -> None: self._explorer_logic.collapse_all() children = dpg.get_item_children(self.tree_tag, 1) @@ -321,7 +327,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: @@ -342,7 +348,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: @@ -362,7 +368,7 @@ def _on_file_node_double_clicked( def _on_directory_node_clicked( self, - sender: Sender, + _sender: Sender, app_data: Tuple[int, int], user_data: Tuple[FileSystemNode, str], ) -> None: @@ -379,7 +385,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"] diff --git a/src/sampletones_application/ui/panels/main/reconstructor.py b/src/sampletones_application/ui/panels/main/reconstructor.py index 8c088728..df579fec 100644 --- a/src/sampletones_application/ui/panels/main/reconstructor.py +++ b/src/sampletones_application/ui/panels/main/reconstructor.py @@ -145,7 +145,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..3873d72f 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -4,7 +4,9 @@ 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.general import ( TAG_GLOBAL_THEME_SECONDARY_BUTTON, ) @@ -88,20 +90,22 @@ def __init__( 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( + with ( + dpg.child_window( + tag=self.tag, + width=self.width, + height=self.height, + parent=parent, + border=False, + ), + self._collapsible_section( self._lbl_reconstructions, glyph=self._glyphs.headers.reconstruction, - ): - self._create_buttons() - dpg.add_separator() - self._create_tree_window() + ), + ): + self._create_buttons() + dpg.add_separator() + self._create_tree_window() self._create_detail_tooltip(TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE) self.rebuild_tree() @@ -141,17 +145,19 @@ def _create_buttons(self) -> None: def _create_tree_window(self) -> None: self.create_search(self._body_container) - with dpg.child_window( - tag=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, - horizontal_scrollbar=True, + with ( + dpg.child_window( + tag=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, + horizontal_scrollbar=True, + ), + dpg.group(tag=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE), + dpg.tree_node( + label=self._lbl_reconstructions, + tag=self.tree_tag, + default_open=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 + pass def refresh(self) -> None: self.rebuild_tree() @@ -224,7 +230,7 @@ def _reconstruct_directory(self) -> None: def _on_directory_node_clicked( self, - sender: Sender, + _sender: Sender, app_data: Tuple[int, int], user_data: Tuple[FileSystemNode, str], ) -> None: @@ -237,7 +243,7 @@ def _on_directory_node_clicked( def _on_reconstruction_node_clicked( self, - sender: Sender, + _sender: Sender, app_data: Tuple[int, int], user_data: Tuple[FileSystemNode, str], ) -> None: @@ -251,7 +257,7 @@ def _on_reconstruction_node_clicked( def _on_reconstruction_node_double_clicked( self, - sender: Sender, + _sender: Sender, app_data: Tuple[int, int], user_data: Tuple[FileSystemNode, str], ) -> None: @@ -275,7 +281,7 @@ def _show_directory_context_menu(self, node: FileSystemNode) -> None: def _show_reconstruction_context_menu( self, node: FileSystemNode, - node_tag: str, + _node_tag: str, ) -> None: if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: return @@ -328,8 +334,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..b510122e 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -6,7 +6,7 @@ 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.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 ( @@ -39,7 +39,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, @@ -62,7 +65,9 @@ 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.formats.famitracker.specification.sequences import ( + MAX_SEQUENCE_ITEMS, +) from sampletones_core.utils.pitch_kind import ( PERIOD_VALUE_KIND, PITCH_VALUE_KIND, @@ -149,18 +154,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() @@ -480,7 +487,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 +678,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..617d96cf 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -200,7 +200,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 +229,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..d6fe796a 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -3,7 +3,9 @@ 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.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, @@ -77,20 +79,22 @@ def __init__( 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( + with ( + dpg.child_window( + tag=self.tag, + width=self.width, + height=self.height, + parent=parent, + border=False, + ), + self._collapsible_section( self._lbl_reconstructions, glyph=self._glyphs.headers.reconstruction, - ): - self._create_buttons() - dpg.add_separator() - self._create_tree_window() + ), + ): + self._create_buttons() + dpg.add_separator() + self._create_tree_window() self._create_detail_tooltip(TAG_SEQUENCER_BROWSER_WINDOW_TREE) self.rebuild_tree() @@ -130,17 +134,19 @@ def _create_buttons(self) -> None: def _create_tree_window(self) -> None: self.create_search(self._body_container) - with dpg.child_window( - tag=TAG_SEQUENCER_BROWSER_WINDOW_TREE, - horizontal_scrollbar=True, + with ( + dpg.child_window( + tag=TAG_SEQUENCER_BROWSER_WINDOW_TREE, + horizontal_scrollbar=True, + ), + dpg.group(tag=TAG_SEQUENCER_BROWSER_GROUP_TREE), + dpg.tree_node( + label=self._lbl_reconstructions, + tag=self.tree_tag, + default_open=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 + pass def refresh(self) -> None: self.rebuild_tree() @@ -204,7 +210,7 @@ def set_tree_enabled(self, enabled: bool) -> None: def _on_directory_node_clicked( self, - sender: Sender, + _sender: Sender, app_data: Tuple[int, int], user_data: Tuple[FileSystemNode, str], ) -> None: @@ -217,7 +223,7 @@ def _on_directory_node_clicked( def _on_reconstruction_node_clicked( self, - sender: Sender, + _sender: Sender, app_data: Tuple[int, int], user_data: Tuple[FileSystemNode, str], ) -> None: @@ -231,7 +237,7 @@ def _on_reconstruction_node_clicked( def _on_reconstruction_node_double_clicked( self, - sender: Sender, + _sender: Sender, app_data: Tuple[int, int], user_data: Tuple[FileSystemNode, str], ) -> None: @@ -254,7 +260,7 @@ def _show_directory_context_menu(self, node: FileSystemNode) -> None: def _show_reconstruction_context_menu( self, node: FileSystemNode, - node_tag: str, + _node_tag: str, ) -> None: if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: return diff --git a/src/sampletones_application/ui/panels/sequencer/columns.py b/src/sampletones_application/ui/panels/sequencer/columns.py index 0cd8dcf9..90c17a39 100644 --- a/src/sampletones_application/ui/panels/sequencer/columns.py +++ b/src/sampletones_application/ui/panels/sequencer/columns.py @@ -1,10 +1,10 @@ from typing import Final, Optional, Tuple -from sampletones_application.layout.tabs.sequencer.colors import ChannelColors +from sampletones_application.layout.tabs.sequencer.colors.channel import ChannelColors from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.view_model.sequencer.subcolumn import SubColumn 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) @@ -30,7 +30,7 @@ def from_flat(row: int, index: int) -> TrackerCursor: 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 diff --git a/src/sampletones_application/ui/panels/sequencer/display.py b/src/sampletones_application/ui/panels/sequencer/display.py index b53a99d4..a35151ca 100644 --- a/src/sampletones_application/ui/panels/sequencer/display.py +++ b/src/sampletones_application/ui/panels/sequencer/display.py @@ -2,8 +2,8 @@ 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.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 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/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/order_input.py b/src/sampletones_application/ui/panels/sequencer/input/order.py similarity index 96% rename from src/sampletones_application/ui/panels/sequencer/order_input.py rename to src/sampletones_application/ui/panels/sequencer/input/order.py index dcabcfc1..f6042e91 100644 --- a/src/sampletones_application/ui/panels/sequencer/order_input.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -8,7 +8,6 @@ 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) diff --git a/src/sampletones_application/ui/panels/sequencer/module.py b/src/sampletones_application/ui/panels/sequencer/module.py index 3364659d..5b22790d 100644 --- a/src/sampletones_application/ui/panels/sequencer/module.py +++ b/src/sampletones_application/ui/panels/sequencer/module.py @@ -80,7 +80,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 +93,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,7 +106,10 @@ 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, @@ -111,7 +120,10 @@ def _create_module_options(self) -> None: 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, @@ -192,25 +204,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..ecf4748a 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -5,10 +5,15 @@ 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.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_HEADER, + SUF_HANDLER_REGISTRY, +) from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_ORDER_BUTTON_PAIR, TAG_SEQUENCER_ORDER_PANEL, @@ -36,7 +41,7 @@ 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.input.order import ( INDEX_DIGITS, ORDER_ROWS, OrderCursor, @@ -50,26 +55,27 @@ 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 +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_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] @@ -82,6 +88,13 @@ OnChannelMuteToggledCallback = Callable[[GeneratorName], None] OnChannelSoloedCallback = Callable[[GeneratorName], None] +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,11 +119,15 @@ 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() @@ -151,8 +168,6 @@ def __init__( self._load_label_tooltips(language_manager) self._create_channel_switch(language_manager) - self._load_shortcut_hints() - super().__init__( tag=TAG_SEQUENCER_ORDER_PANEL, ) @@ -190,18 +205,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 +239,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 +262,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( @@ -308,7 +313,7 @@ def _register_handlers(self) -> None: 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: + 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: @@ -415,7 +420,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 +435,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. @@ -491,6 +496,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 +530,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 +546,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 +554,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 +575,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 +591,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: @@ -666,7 +696,7 @@ def _apply_cursor_highlight(self, cursor: OrderCursor) -> None: 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 @@ -765,17 +795,24 @@ 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() generator, position = user_data - self._apply_state(OrderInputState(cursor=OrderCursor(generator, position))) + self._apply_state( + OrderInputState( + cursor=OrderCursor( + generator, + position, + ) + ) + ) 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. @@ -797,14 +834,14 @@ def _on_cell_right_clicked( 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. @@ -837,167 +874,177 @@ def _show_context_menu(self, position: int) -> None: add_play_menu_item( self._lbl_context_play, lambda: self.call(self.on_play_from_requested, position), - shortcut=self._sc_play_from_frame, + shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() dpg.add_menu_item( label=self._lbl_context_duplicate, - shortcut=self._sc_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_insert, - shortcut=self._sc_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._sc_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._sc_remove, + shortcut=self._shortcuts.display(ShortcutId.ORDER_REMOVE_FRAME), callback=lambda: self.call(self.on_remove_requested, position), ) dpg.add_separator() self._add_move_item( self._lbl_context_move_left, - self._sc_move_left, + ShortcutId.ORDER_MOVE_FRAME_LEFT, position, - MoveDirection.PREVIOUS, ) self._add_move_item( self._lbl_context_move_right, - self._sc_move_right, + ShortcutId.ORDER_MOVE_FRAME_RIGHT, position, - MoveDirection.NEXT, ) self._add_move_item( self._lbl_context_move_start, - self._sc_move_start, + ShortcutId.ORDER_MOVE_FRAME_TO_START, position, - MoveDirection.FIRST, ) self._add_move_item( self._lbl_context_move_end, - self._sc_move_end, + ShortcutId.ORDER_MOVE_FRAME_TO_END, position, - MoveDirection.LAST, ) 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), ) 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._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 _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 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: + case ShortcutId.ORDER_CANCEL_ENTRY: if not self._input_state.pending: 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 - - target = direction.target(position, self._position_count) - if target is not None: - self.call(self.on_move_requested, position, target) + 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) - 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_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( @@ -1026,8 +1073,16 @@ def _committed_state(self) -> OrderInputState: return state - def _handle_printable_key(self, key: int) -> bool: - char = HEX_KEYS.get(key) + 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/rows.py b/src/sampletones_application/ui/panels/sequencer/rows.py new file mode 100644 index 00000000..be529c08 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/rows.py @@ -0,0 +1,77 @@ +from dataclasses import dataclass +from typing import Optional + +from sampletones_application.layout.tabs.sequencer.colors.colors import SequencerColors +from sampletones_application.layout.tabs.sequencer.tracker.tracker import TrackerLayout +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.palette.colors.layered import LayeredColor + + +@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, + tracker: TrackerLayout, + colors: SequencerColors, +) -> Optional[BaseColor]: + """The emphasis a row takes from the group it opens. + + A row opening a bar takes the stronger of the two shades, since a bar boundary is also a + beat boundary. A row inside a beat keeps the zebra stripe it already has. + """ + if tracker.rows_per_bar > 0 and row_index % tracker.rows_per_bar == 0: + return colors.rows.bar + + if tracker.rows_per_beat > 0 and row_index % tracker.rows_per_beat == 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, + tracker: TrackerLayout, + 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, tracker, 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..341b56c1 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -1,4 +1,4 @@ -from typing import Callable, Final, List, Optional, Tuple +from typing import Callable, Dict, Final, List, Optional, Tuple import dearpygui.dearpygui as dpg @@ -25,10 +25,12 @@ 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.view_model.sequencer.move import MoveDirection from sampletones_application.view_model.sequencer.samples import ( SampleEntryViewModel, @@ -41,6 +43,13 @@ FROZEN_HEADER_ROWS: Final[int] = 1 +MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = { + ShortcutId.SAMPLES_MOVE_SAMPLE_UP: MoveDirection.PREVIOUS, + ShortcutId.SAMPLES_MOVE_SAMPLE_DOWN: MoveDirection.NEXT, + ShortcutId.SAMPLES_MOVE_SAMPLE_TO_TOP: MoveDirection.FIRST, + ShortcutId.SAMPLES_MOVE_SAMPLE_TO_BOTTOM: MoveDirection.LAST, +} + class GUISequencerSamplesPanel(GUIPanel): def __init__( @@ -49,11 +58,15 @@ def __init__( layout: SequencerLayout, 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._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 @@ -105,13 +118,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 +139,23 @@ 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._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, + ) ThemeRegistry.get(TAG_SEQUENCER_INSTRUMENTS_THEME_ROW).bind_to_item(TAG_SEQUENCER_INSTRUMENTS_TABLE) def update_view(self, view_model: SequencerSamplesViewModel) -> None: @@ -170,11 +185,25 @@ def _build_sample_row(self, position: int, entry: SampleEntryViewModel) -> None: self._build_loop_cell(row_id, entry) if entry.sample_id == self._selected_sample_id: self._selected_row = position - dpg.highlight_table_row( - TAG_SEQUENCER_INSTRUMENTS_TABLE, - position, - color=self._layout.colors.cell_cursor, - ) + 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 +281,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 +294,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 +337,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. + + 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 - 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 +405,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 +436,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 +467,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 diff --git a/src/sampletones_application/ui/panels/sequencer/grid.py b/src/sampletones_application/ui/panels/sequencer/tracker.py similarity index 68% rename from src/sampletones_application/ui/panels/sequencer/grid.py rename to src/sampletones_application/ui/panels/sequencer/tracker.py index 82609334..a9299406 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -2,18 +2,23 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.elements.sequencer import SequencerGridElements +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_HEADER, SUF_HANDLER_REGISTRY +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, + 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, @@ -47,51 +52,53 @@ EditAction, ) from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background 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.frame import FrameCallbackManager from sampletones_application.utils.gui.keyboard import ( PRIORITY_PANEL, + ActivePredicate, 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.keyboard.keys import HEX_KEYS, SIGN_KEYS +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.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.grid import ( - SequencerGridViewModel, - SequencerRowViewModel, -) from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) 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.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] +OnCellSelectedCallback = VoidCallback OnPlayFromRowCallback = Callable[[int], None] -OnPlayFromFrameCallback = Callable[[], None] +OnPlayFromFrameCallback = VoidCallback OnAdjustCallback = Callable[[int, Optional[GeneratorName], int], None] OnChannelMuteToggledCallback = Callable[[GeneratorName], None] OnChannelSoloedCallback = Callable[[GeneratorName], None] @@ -99,20 +106,25 @@ VOLUME_FINE_STEP: Final[int] = 1 VOLUME_COARSE_STEP: Final[int] = (MAX_VOLUME + 1) // 4 +PLAYHEAD_PAINT_FRAMES: Final[int] = 1 -class GUISequencerGridPanel(GUIPanel): +class GUISequencerTrackerPanel(GUIPanel): def __init__( self, *, layout: SequencerLayout, language_manager: LanguageManager, key_router: KeyRouter, + tab_active: ActivePredicate, + shortcut_source: ShortcutSource, initial_collapsed: bool = False, ) -> None: self._layout = layout 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] = { @@ -121,9 +133,9 @@ def __init__( 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._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._rows: Dict[Optional[int], Sender] = {} self._header_columns: Dict[Sender, Optional[GeneratorName]] = {} @@ -131,6 +143,8 @@ def __init__( self._current_row_count: int = 0 self._highlighted_row: 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._subcolumn_themes: Dict[SubColumn, int] = {} self._muted_subcolumn_themes: Dict[SubColumn, int] = {} @@ -159,77 +173,71 @@ def __init__( self._lbl_tracker = self._label( language_manager, - SequencerGridElements.TRACKER_TEXT, + 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) - 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, + 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, SequencerGridElements.COLUMN_ROW) + self._lbl_col_row = self._label(language_manager, SequencerTrackerElements.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), + 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: SequencerGridElements, + element: SequencerTrackerElements, ) -> str: return language_manager[ Page.SEQUENCER, - Panel.GRID, + Panel.TRACKER, TextType.LABEL, element, ] def _load_context_labels(self, language_manager: LanguageManager) -> None: - def label(element: SequencerGridElements) -> str: + def label(element: SequencerTrackerElements) -> 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) + self._lbl_context_play = label(SequencerTrackerElements.CONTEXT_PLAY) + self._lbl_context_play_from_frame = label(SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME) + 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_context_transpose_up = label(SequencerTrackerElements.CONTEXT_TRANSPOSE_UP) + self._lbl_context_transpose_down = label(SequencerTrackerElements.CONTEXT_TRANSPOSE_DOWN) + self._lbl_context_transpose_octave_up = label(SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_UP) + self._lbl_context_transpose_octave_down = label(SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_DOWN) + self._lbl_context_volume_up = label(SequencerTrackerElements.CONTEXT_VOLUME_UP) + self._lbl_context_volume_down = label(SequencerTrackerElements.CONTEXT_VOLUME_DOWN) + self._lbl_context_volume_up_coarse = label(SequencerTrackerElements.CONTEXT_VOLUME_UP_COARSE) + self._lbl_context_volume_down_coarse = label(SequencerTrackerElements.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] + def tooltip(element: SequencerTrackerElements) -> str: + return language_manager[Page.SEQUENCER, Panel.TRACKER, TextType.TOOLTIP, element] - self._tooltip_header_channel = channel_tooltip(tooltip(SequencerGridElements.HEADER_CHANNEL)) - self._tooltip_header_sample = tooltip(SequencerGridElements.HEADER_SAMPLE) + 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. @@ -238,12 +246,12 @@ def _create_channel_switch(self, language_manager: LanguageManager) -> None: 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), + 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, @@ -299,7 +307,10 @@ def _create_subcolumn_themes(self) -> None: 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), + FadedColor( + color=color, + fraction=fraction, + ), ) def _create_header_themes(self) -> None: @@ -328,27 +339,27 @@ def _create_tracker_view(self, parent: str) -> None: 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. + 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_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, + 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, @@ -361,34 +372,35 @@ def _create_tracker_view(self, parent: str) -> None: 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, - ) + ), + ): + 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.sample, + init_width_or_weight=self._layout.table_cells.generator, 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) + dpg.add_table_column(width_stretch=True) - self.pattern_theme.bind_to_item(TAG_SEQUENCER_GRID_TABLE_TRACKER) + self.pattern_theme.bind_to_item(TAG_SEQUENCER_TRACKER_TABLE) - def update_grid(self, view_model: SequencerGridViewModel) -> None: + 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 @@ -404,17 +416,93 @@ def update_grid(self, view_model: SequencerGridViewModel) -> None: def _rebuild_table( self, - view_model: SequencerGridViewModel, + view_model: SequencerTrackerViewModel, cell_values: CellValues, ) -> None: - dpg_delete_children(TAG_SEQUENCER_GRID_TABLE_TRACKER, slot=1) + """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. + """ + dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) 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() - self._apply_playing_row_highlight() + + 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._layout.tracker, + 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 @@ -435,14 +523,14 @@ def _highlight_sample_column(self) -> None: once the rows are replaced. """ dpg.highlight_table_column( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + TAG_SEQUENCER_TRACKER_TABLE, SAMPLE_TABLE_COLUMN, - self._layout.colors.sample.column, + self._layout.colors.sample.column.rgba, ) dpg.highlight_table_column( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + TAG_SEQUENCER_TRACKER_TABLE, DIVIDER_TABLE_COLUMN, - self._layout.colors.sample.divider, + self._layout.colors.sample.divider.rgba, ) def _highlight_header_row(self) -> None: @@ -454,10 +542,10 @@ def _highlight_header_row(self) -> None: """ for column in range(TRACKER_TABLE_COLUMNS): dpg.highlight_table_cell( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + TAG_SEQUENCER_TRACKER_TABLE, HEADER_TABLE_ROW, column, - color=self._layout.colors.header.background, + color=self._layout.colors.header.background.rgba, ) def _tint_channel_columns(self) -> None: @@ -470,23 +558,24 @@ def _tint_channel_columns(self) -> None: """ for generator in GeneratorName.items(): dpg.highlight_table_column( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + 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 + 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 _compute_cell_values( self, - view_model: SequencerGridViewModel, + view_model: SequencerTrackerViewModel, ) -> CellValues: cell_values: CellValues = {} for row in view_model.rows: @@ -509,7 +598,7 @@ def _compute_cell_values( return cell_values - def _build_table(self, view_model: SequencerGridViewModel) -> None: + def _build_table(self, view_model: SequencerTrackerViewModel) -> None: self._rows = {} self._current_row_count = len(view_model.rows) self._build_header_row() @@ -524,7 +613,7 @@ def _build_header_row(self) -> None: 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) + 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) @@ -570,7 +659,7 @@ def _build_table_row(self, row: SequencerRowViewModel) -> None: keeps the channel cells aligned with their (shifted) table columns. """ row_id = dpg.add_table_row( - parent=TAG_SEQUENCER_GRID_TABLE_TRACKER, + parent=TAG_SEQUENCER_TRACKER_TABLE, user_data=row.index, ) self._add_empty_cell(row_id) @@ -654,8 +743,8 @@ def _update_cursor(self) -> None: 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._remove_cell_highlight(cursor.row, cursor.generator) self._update_caret() @@ -666,11 +755,11 @@ def _apply_state(self, new_state: TrackerInputState) -> None: 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) - self._input_state = new_state - if old_cursor is not None: self._update_cell_display(old_cursor.row, old_cursor.generator) @@ -680,8 +769,7 @@ def _apply_state(self, new_state: TrackerInputState) -> None: 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.call(self.on_cell_selected) self._update_caret() @@ -704,7 +792,7 @@ def _apply_channel_cues(self) -> None: 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): + if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): return self._tint_channel_columns() @@ -732,7 +820,7 @@ 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) + dpg.configure_item(TAG_SEQUENCER_TRACKER_GROUP, enabled=enabled) def _update_cell_display( self, @@ -749,17 +837,17 @@ 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) + 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_GRID_TABLE_TRACKER, + owner=TAG_SEQUENCER_TRACKER_TABLE, widget=self._editable_cells.widget(key), caret_index=len(self._input_state.pending), font=font, - clip_widget=TAG_SEQUENCER_GRID_WINDOW_TRACKER, + clip_widget=TAG_SEQUENCER_TRACKER_WINDOW, ) def _resolve_sample_id( @@ -844,18 +932,13 @@ def _apply_cell_highlight( 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) + """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_GRID_TABLE_TRACKER, - table_row, - column_index, - color=self._layout.colors.cell_cursor, + TAG_SEQUENCER_TRACKER_TABLE, + tracker_table_row(row_index), + tracker_table_column(generator), + color=self._layout.colors.cell_cursor.rgba, ) def _remove_cell_highlight( @@ -863,22 +946,22 @@ def _remove_cell_highlight( 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) + """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_GRID_TABLE_TRACKER, - table_row, - col_idx, + 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, + _app_data: bool, user_data: Tuple[int, Optional[GeneratorName], SubColumn], ) -> None: dpg.set_value(sender, False) @@ -893,14 +976,14 @@ def _on_cell_clicked( def _on_header_clicked( self, sender: Sender, - app_data: bool, + _app_data: bool, user_data: Optional[GeneratorName], ) -> None: self._channel_switch.click(sender, user_data) def _on_header_right_clicked( self, - sender: Sender, + _sender: Sender, app_data: Tuple[int, int], ) -> None: """Opens the channel menu for the right-clicked column header. @@ -930,7 +1013,7 @@ def _show_header_context_menu( def _on_cell_right_clicked( self, - sender: Sender, + _sender: Sender, app_data: Tuple[int, int], ) -> None: """Opens the cell-operations menu for the right-clicked subcolumn. @@ -964,12 +1047,12 @@ def _show_context_menu( add_play_menu_item( self._lbl_context_play, lambda: self.call(self.on_play_from_row, row_index), - shortcut=self._sc_play_from_here, + 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._sc_play_from_frame, + shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() self._add_instrument_submenu(row_index, generator) @@ -1041,8 +1124,8 @@ def _add_volume_items( def _on_set_instrument_menu( self, - sender: Sender, - app_data: None, + _sender: Sender, + _app_data: None, user_data: Tuple[int, Optional[GeneratorName], str], ) -> None: row_index, generator, sample_id = user_data @@ -1050,8 +1133,8 @@ def _on_set_instrument_menu( def _on_transpose_menu( self, - sender: Sender, - app_data: None, + _sender: Sender, + _app_data: None, user_data: Tuple[int, Optional[GeneratorName], int], ) -> None: row_index, generator, delta = user_data @@ -1059,8 +1142,8 @@ def _on_transpose_menu( def _on_volume_menu( self, - sender: Sender, - app_data: None, + _sender: Sender, + _app_data: None, user_data: Tuple[int, Optional[GeneratorName], int], ) -> None: row_index, generator, delta = user_data @@ -1101,66 +1184,89 @@ def _add_clear_items( ) def _keys_active(self) -> bool: - """Whether the grid owns the next key: its cursor is set and no field holds the keyboard. + """Whether the grid 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 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. + 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._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 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. + 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 - if event.modifiers == CTRL_SHIFT and event.key == dpg.mvKey_Spacebar: + 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 Modifier.CTRL in event.modifiers: - return False + if self._move_cursor(shortcut_id): + return True - match event.key: - case dpg.mvKey_Up: + 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 dpg.mvKey_Down: + case ShortcutId.TRACKER_NEXT_ROW: self._move_row(1) - case dpg.mvKey_Left: + case ShortcutId.TRACKER_PREVIOUS_SUBCOLUMN: self._move_subcolumn(-1) - case dpg.mvKey_Right: + case ShortcutId.TRACKER_NEXT_SUBCOLUMN: self._move_subcolumn(1) - case dpg.mvKey_Tab: - self._move_column(-1 if Modifier.SHIFT in event.modifiers else 1) - case dpg.mvKey_Home: + 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 dpg.mvKey_End: + case ShortcutId.TRACKER_LAST_ROW: self._jump_to_row(self._current_row_count - 1) - case _ if event.key == KEY_PAGE_UP: + case ShortcutId.TRACKER_PAGE_UP: self._page(-self._layout.tracker.page_size) - case _ if event.key == KEY_PAGE_DOWN: + case ShortcutId.TRACKER_PAGE_DOWN: self._page(self._layout.tracker.page_size) - case dpg.mvKey_Return: - self._move_row(1) - case dpg.mvKey_Delete: + case _: + return False + + return True + + 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 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 dpg.mvKey_Back: + case ShortcutId.TRACKER_CLEAR_PREVIOUS_ROW: self._clear_row() self._move_row(-1) - case dpg.mvKey_Escape: + case ShortcutId.TRACKER_CANCEL_ENTRY: if not self._input_state.pending: return False self._apply_state(self._input_state.cancel()) case _: - return self._handle_printable_key(event.key) + return False return True @@ -1194,25 +1300,78 @@ 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. + """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) - 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. + 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. """ - cursor = self._input_state.cursor - if cursor is None or self._current_row_count <= 1: + scroll_max = self._scroll_extent() + if scroll_max is None: return - if not dpg.does_item_exist(TAG_SEQUENCER_GRID_TABLE_TRACKER): - 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. - scroll_max = dpg.get_y_scroll_max(TAG_SEQUENCER_GRID_TABLE_TRACKER) - if scroll_max <= 0: + 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 - fraction = cursor.row / (self._current_row_count - 1) - dpg.set_y_scroll(TAG_SEQUENCER_GRID_TABLE_TRACKER, fraction * scroll_max) + 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() @@ -1226,8 +1385,17 @@ def _committed_state(self) -> TrackerInputState: return state - def _handle_printable_key(self, key: int) -> bool: - char = HEX_KEYS.get(key) or SIGN_KEYS.get(key) + 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 @@ -1242,7 +1410,7 @@ def _handle_printable_key(self, key: int) -> bool: def _on_row_number_clicked( self, sender: Sender, - app_data: bool, + _app_data: bool, user_data: int, ) -> None: dpg.set_value(sender, False) @@ -1260,7 +1428,7 @@ def _on_row_number_clicked( ) ) - def _on_row_hovered(self, sender: Sender, app_data: int) -> None: + def _on_row_hovered(self, _sender: Sender, app_data: int) -> None: if not dpg.does_item_exist(app_data): return @@ -1269,50 +1437,52 @@ def _on_row_hovered(self, sender: Sender, app_data: int) -> 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 - dpg.highlight_table_row( - TAG_SEQUENCER_GRID_TABLE_TRACKER, - tracker_table_row(row_index), - color=self._layout.colors.pattern_highlight, - ) + 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 - dpg.unhighlight_table_row( - TAG_SEQUENCER_GRID_TABLE_TRACKER, - tracker_table_row(row_index), - ) 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_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), - ) + """Moves the playhead to the row playback reached, mark and grid arriving together. + 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_row = row_index - self._apply_playing_row_highlight() + self._reveal_playing_row() + FrameCallbackManager.set_frame_callback(self._paint_playhead, PLAYHEAD_PAINT_FRAMES) - def _apply_playing_row_highlight(self) -> None: - """Highlights the playing row when its index lies within the live table. + 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._playing_row + if previous is not None and previous != self._painted_row: + self._paint_row(previous) - 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, - ) + 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.""" + if self._follows_playing_row and self._playing_row is not None: + self._scroll_row_to_band_top(self._playing_row) def _live_row_count(self) -> int: """The table's current pattern-row count, read live from DearPyGui. @@ -1322,8 +1492,8 @@ def _live_row_count(self) -> int: 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): + if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): return 0 - rows = dpg.get_item_children(TAG_SEQUENCER_GRID_TABLE_TRACKER, slot=1) + 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/themes/dpg_constants.py b/src/sampletones_application/ui/themes/dpg_constants.py index e7e4a0ea..701b0614 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]] = { @@ -92,7 +110,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..30ce8d06 100644 --- a/src/sampletones_application/ui/themes/inline.py +++ b/src/sampletones_application/ui/themes/inline.py @@ -2,18 +2,19 @@ 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 -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 +31,7 @@ def create_header_selectable_theme( ) -def _create_selectable_theme(colors: Dict[int, ColorRGBA]) -> int: +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 +47,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 +65,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..e24eb06e 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_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY +from sampletones_application.utils.palette.source import PaletteSource from sampletones_core.paths import EXT_FILE_YAML -from sampletones_shared.types.application import ColorRGBA 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/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..8bd9d58e 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 @@ -180,6 +188,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 +213,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 +254,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 +283,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 +359,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 +415,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 +425,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 +438,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 +460,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 +544,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 +557,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 +660,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 +681,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 +715,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..8e093677 100644 --- a/src/sampletones_application/utils/gui/dpg.py +++ b/src/sampletones_application/utils/gui/dpg.py @@ -55,7 +55,7 @@ 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: +def dpg_delete_children(tag: Sender, /, *_args: Any, **kwargs: Any) -> None: dpg_delete_item(tag, children_only=True, **kwargs) @@ -84,7 +84,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 +103,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 +114,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..6018b4f3 --- /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_core.paths import EXT_FILE_YAML +from sampletones_shared.logger import logger + + +@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..aa2adaf8 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -1,55 +1,152 @@ -from enum import Enum -from typing import Dict, Final +from enum import Enum, StrEnum +from typing import Dict, Final, Self, Tuple +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) + 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_FULLSCREEN = ("ToggleFullscreen", ShortcutCategory.APPLICATION) + ABOUT_DIALOG = ("AboutDialog", ShortcutCategory.APPLICATION) + NEXT_TAB = ("NextTab", ShortcutCategory.APPLICATION) + PREVIOUS_TAB = ("PreviousTab", 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_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_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_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, +} 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/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..ce04fbb4 --- /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_core.paths import EXT_FILE_YAML +from sampletones_shared.logger import logger + +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/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/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 93% rename from src/sampletones_application/view_model/sequencer/grid.py rename to src/sampletones_application/view_model/sequencer/tracker.py index d6b38872..addac95c 100644 --- a/src/sampletones_application/view_model/sequencer/grid.py +++ b/src/sampletones_application/view_model/sequencer/tracker.py @@ -4,7 +4,12 @@ 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 ( + NOTE_OFF, + display_id, + display_transpose, + display_volume, +) class SequencerCellViewModel(BaseModel, frozen=True): @@ -12,7 +17,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 @@ -85,7 +90,7 @@ def _aggregate( 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..74c7d74b --- /dev/null +++ b/src/sampletones_application/view_model/shared/display_settings.py @@ -0,0 +1,256 @@ +from __future__ import annotations + +from typing import Dict, Tuple + +from pydantic import BaseModel + +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. + + A preference outlives the list that was offered when it was written, so a stored value the + build has since dropped still selects an entry the combo shows. + + Raises: + ValueError: when no frame rate is offered. + """ + if not frame_rates: + raise ValueError("Selecting a frame rate requires at least one offered rate") + + if max_fps in frame_rates: + return max_fps + + return min(frame_rates, key=lambda frame_rate: (abs(frame_rate - max_fps), frame_rate)) + + +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/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..04bb3798 100644 --- a/src/sampletones_application/view_model/shared/menu.py +++ b/src/sampletones_application/view_model/shared/menu.py @@ -1,5 +1,6 @@ from pydantic import BaseModel +from sampletones_application.constants.playback import FollowMode from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel @@ -21,7 +22,7 @@ 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 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_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..140c84e3 --- /dev/null +++ b/src/sampletones_config/keybindings/default.yaml @@ -0,0 +1,110 @@ +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"} + 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: "F1"} + ToggleChannelPulse2: {combination: "F2"} + ToggleChannelTriangle: {combination: "F3"} + ToggleChannelNoise: {combination: "F4"} + UnmuteAllChannels: {combination: ~} + + # view + AudioSettings: {combination: "Ctrl+A"} + DisplaySettings: {combination: "Ctrl+D"} + KeyboardSettings: {combination: "Ctrl+K"} + ToggleAdvancedSettings: {combination: "Ctrl+Shift+A"} + ToggleFullscreen: {combination: "F11"} + AboutDialog: {combination: ~} + NextTab: {combination: "Ctrl+PgDn", field_transparent: true} + PreviousTab: {combination: "Ctrl+PgUp", 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"} + 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"} + 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"} + 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..175a1dfc --- /dev/null +++ b/src/sampletones_config/keybindings/macos.yaml @@ -0,0 +1,110 @@ +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"} + 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: "F1"} + ToggleChannelPulse2: {combination: "F2"} + ToggleChannelTriangle: {combination: "F3"} + ToggleChannelNoise: {combination: "F4"} + UnmuteAllChannels: {combination: ~} + + # view + AudioSettings: {combination: "Cmd+A"} + DisplaySettings: {combination: "Cmd+D"} + KeyboardSettings: {combination: "Cmd+K"} + ToggleAdvancedSettings: {combination: "Cmd+Shift+A"} + ToggleFullscreen: {combination: "Cmd+Ctrl+F"} + AboutDialog: {combination: ~} + NextTab: {combination: "Cmd+Alt+Right", aliases: ["Cmd+PgDn"], field_transparent: true} + PreviousTab: {combination: "Cmd+Alt+Left", aliases: ["Cmd+PgUp"], 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"]} + 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"]} + 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"]} + 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..5b357219 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -191,7 +191,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 +202,8 @@ 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.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" @@ -431,39 +436,39 @@ 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_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 @@ -636,6 +641,135 @@ 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.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.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_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.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_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_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_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" diff --git a/src/sampletones_config/layout/general/dialogs.yaml b/src/sampletones_config/layout/general/dialogs.yaml index b7b4b2c1..7c57efcd 100644 --- a/src/sampletones_config/layout/general/dialogs.yaml +++ b/src/sampletones_config/layout/general/dialogs.yaml @@ -8,7 +8,7 @@ recovery: width: 640 height: 120 confirmation: - height: 130 + height: 100 text_input: height: 104 traceback: 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/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/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/tracker.yaml b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml index 34c6001a..b9310835 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 +rows_per_beat: 4 +rows_per_bar: 16 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..1a83491e --- /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" + file_muted: "#a8a8ae" + + 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" + + 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..355c44f4 --- /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" + file_muted: "#6e7580" + + 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" + + 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..e97db657 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,58 @@ 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 +143,50 @@ 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" + 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/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..3e34fb31 100644 --- a/src/sampletones_config/theme/tables/order.yaml +++ b/src/sampletones_config/theme/tables/order.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/pattern.yaml b/src/sampletones_config/theme/tables/pattern.yaml index b5eecfa3..5f9ccc2a 100644 --- a/src/sampletones_config/theme/tables/pattern.yaml +++ b/src/sampletones_config/theme/tables/pattern.yaml @@ -10,13 +10,13 @@ components: y: 4 - 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 @@ -24,4 +24,4 @@ components: 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..fda067be 100644 --- a/src/sampletones_core/audio/__init__.py +++ b/src/sampletones_core/audio/__init__.py @@ -20,25 +20,25 @@ ) __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", + "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..bbcfab30 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 @@ -744,16 +754,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 +777,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 +785,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/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/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/constants/general.py b/src/sampletones_core/constants/general.py index a1396a88..dad4e91b 100644 --- a/src/sampletones_core/constants/general.py +++ b/src/sampletones_core/constants/general.py @@ -44,7 +44,7 @@ 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..798388bc 100644 --- a/src/sampletones_core/exporters/exporter.py +++ b/src/sampletones_core/exporters/exporter.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Dict, Final, Generic, List, Optional, Union, cast +from typing import ClassVar, Dict, Final, Generic, List, Optional, Union, cast import numpy as np @@ -32,7 +32,7 @@ class Exporter(ABC, Generic[InstructionT]): the reverse. """ - _ATTRIBUTE_MAP: Dict[FeatureKey, InstructionFields] + _ATTRIBUTE_MAP: ClassVar[Dict[FeatureKey, InstructionFields]] def to_features( self, 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/features/__init__.py b/src/sampletones_core/features/__init__.py index 1b593140..e2027c6f 100644 --- a/src/sampletones_core/features/__init__.py +++ b/src/sampletones_core/features/__init__.py @@ -9,11 +9,11 @@ ) __all__ = [ - "FeatureRange", "FEATURE_DIMENSION_ORDER", "GENERATOR_FEATURE_RANGES", "GENERATOR_KIND", - "supported_features", + "FeatureRange", "feature_range", + "supported_features", "supports", ] 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/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index 5cfa6259..39030a2b 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -3,6 +3,7 @@ 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 +23,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 +63,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 +114,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 +128,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 +137,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 +164,6 @@ def _row_cell( ) instrument = slot.index note, octave = _note_and_octave( - reference, row.transpose or 0, channel_generator, slot, @@ -200,6 +233,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 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/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/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/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/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/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/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/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index cd3d9185..addc2ce4 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -3,7 +3,7 @@ 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, List, Mapping, Optional, Self, Sequence from uuid import uuid4 import numpy as np @@ -11,7 +11,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName -from sampletones_core.data import DataModel, Metadata +from sampletones_core.data import DataModel, Metadata, MetadataContract from sampletones_core.exporters import ( GENERATOR_NAME_TO_EXPORTER_MAP, INSTRUCTION_TO_EXPORTER_MAP, @@ -21,14 +21,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 +39,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) @@ -356,20 +357,7 @@ 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, 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..1b5a2902 100644 --- a/src/sampletones_core/structures/tree/__init__.py +++ b/src/sampletones_core/structures/tree/__init__.py @@ -5,13 +5,13 @@ from .type import NodeType __all__ = [ + "Arguments", + "FileSystemNode", + "GeneratorNode", + "LibraryNode", "NodeType", "Tree", "TreeNode", - "FileSystemNode", - "LibraryNode", - "GeneratorNode", - "Arguments", "TreeTraversal", "traverse", ] 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/trackers/implementation/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py index cebbd102..ee84198e 100644 --- a/src/sampletones_core/trackers/implementation/famitracker.py +++ b/src/sampletones_core/trackers/implementation/famitracker.py @@ -2,12 +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.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.trackers.artifact import ExportArtifact from sampletones_core.trackers.format import TrackerFormat @@ -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_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/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..7dce2285 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -42,42 +42,42 @@ from .window import WindowError, WindowNotAvailableError __all__ = [ - "SampleToNESError", - "LibraryError", - "NoLibraryDataError", - "LoadLibraryError", - "InvalidLibraryDataError", + "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/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..772c40f5 --- /dev/null +++ b/src/sampletones_shared/meta/source/packages.py @@ -0,0 +1,26 @@ +from pathlib import Path + +from sampletones_shared.paths 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 index a6ad14b6..53464482 100644 --- a/src/sampletones_shared/paths.py +++ b/src/sampletones_shared/paths.py @@ -8,4 +8,5 @@ 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] +SOURCE_ROOT: Final[Path] = Path(__file__).resolve().parents[1] +REPOSITORY_ROOT: Final[Path] = SOURCE_ROOT.parent 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/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..eefcf7ac 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,10 @@ from pathlib import Path -from typing import Callable, TypeAlias +from typing import Callable, Iterator, TypeAlias import numpy as np import pytest +from sampletones_application.utils.gui.palette.palette import PaletteBindings from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName from sampletones_core.instructions import PulseInstruction @@ -12,14 +13,39 @@ 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)] + 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)}, + approximations={ + GeneratorName.PULSE1: np.zeros( + length, + dtype=np.float32, + ) + }, instructions={GeneratorName.PULSE1: instructions}, config=Config(), coefficient=1.0, diff --git a/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py index 0123aec8..60fba8ba 100644 --- a/tests/integration/assets/reconstruction.py +++ b/tests/integration/assets/reconstruction.py @@ -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/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py index 9829d27c..e28737a0 100644 --- a/tests/integration/bitphase/test_btp_pipeline.py +++ b/tests/integration/bitphase/test_btp_pipeline.py @@ -5,7 +5,11 @@ 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, 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..fee708dc --- /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 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/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/scripts.py b/tests/suite/scripts.py index 54ebc436..97a5263e 100644 --- a/tests/suite/scripts.py +++ b/tests/suite/scripts.py @@ -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/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/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/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..13c1aff0 100644 --- a/tests/unit/sampletones_application/config/managers/test_session.py +++ b/tests/unit/sampletones_application/config/managers/test_session.py @@ -1,34 +1,43 @@ 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 + + +@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 +45,107 @@ 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) diff --git a/tests/unit/sampletones_application/config/managers/test_state.py b/tests/unit/sampletones_application/config/managers/test_state.py index e450352a..21aae3e8 100644 --- a/tests/unit/sampletones_application/config/managers/test_state.py +++ b/tests/unit/sampletones_application/config/managers/test_state.py @@ -10,89 +10,81 @@ from sampletones_application.config.session.state.state import ApplicationState +@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) @@ -100,57 +92,75 @@ def test_toggle_show_advanced_settings_changes_value(self) -> None: 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 +172,61 @@ 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 @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..f1d3e5aa --- /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_core.paths 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/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..7a7ca1e7 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import UTC, datetime from pathlib import Path from typing import Dict, Final from unittest.mock import MagicMock @@ -7,21 +7,29 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.categories.manager import LanguageManager +from sampletones_application.constants.playback import FollowMode 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, + snapshot_project, +) 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.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.samples import SampleSelection +from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel from sampletones_application.view_model.shared.history import ( HistoryDetailRole, HistoryDetailSegment, @@ -57,8 +65,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 +143,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 +161,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 +172,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 +183,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 +193,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 +230,20 @@ 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() + + +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,58 +251,86 @@ 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() 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) + playback_coordinator._sequencer_tracker_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_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_row") - 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_player_view_changed(_player_view(follow_mode=mode)) - playback_coordinator._on_order_frame_selected(3) + panel = playback_coordinator._sequencer_tracker_panel + panel.set_row_following.assert_called_once_with(mode.follows_row) + + def test_a_stopped_view_drops_the_marks( + self, + playback_coordinator: SequencerTabCoordinator, + ) -> None: + playback_coordinator._on_player_view_changed(_player_view(follow_mode=FollowMode.ROWS)) - playback_coordinator._sequencer_grid_logic.select_frame.assert_called_once_with(3) - playback_coordinator._song_player_logic.seek.assert_called_once_with(3) + playback_coordinator._sequencer_tracker_panel.set_playing_row.assert_called_once_with(None) + playback_coordinator._sequencer_order_panel.set_playing_position.assert_called_once_with(None) - def test_order_selection_only_edits_when_not_following( + @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._song_player_logic.follow_playback = False + """Choosing a frame always picks what is edited, and moves the playhead when following.""" + playback_coordinator._song_player_logic.follow_mode = mode 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._sequencer_tracker_logic.select_frame.assert_called_once_with(3) + assert playback_coordinator._song_player_logic.seek.called is mode.follows_pattern + + @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.set_follow_mode(mode) + + playback_coordinator._song_player_logic.set_follow_mode.assert_called_once_with(mode) class TestNoteOffDispatch: @@ -291,8 +340,8 @@ def test_channel_cell_writes_note_off_to_that_channel( ) -> None: playback_coordinator._on_set_note_off(2, GeneratorName.PULSE1) - 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._sequencer_tracker_logic.set_note_off.assert_called_once_with(GeneratorName.PULSE1, 2) + playback_coordinator._sequencer_tracker_logic.set_note_off_all_generators.assert_not_called() def test_sample_column_cuts_every_channel( self, @@ -300,8 +349,8 @@ def test_sample_column_cuts_every_channel( ) -> None: playback_coordinator._on_set_note_off(2, None) - 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._sequencer_tracker_logic.set_note_off_all_generators.assert_called_once_with(2) + playback_coordinator._sequencer_tracker_logic.set_note_off.assert_not_called() @pytest.fixture @@ -309,7 +358,7 @@ 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._song_player_logic = MagicMock() instance._project_controller = MagicMock() @@ -392,7 +441,7 @@ def test_move_advances_cursor_and_highlight_immediately( 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_clear_leaves_the_playhead_in_place( @@ -481,13 +530,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 +544,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 +559,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 +592,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 +652,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 +672,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 +690,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 +712,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 +733,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: @@ -706,7 +761,9 @@ def history_coordinator() -> SequencerTabCoordinator: @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 +796,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 +908,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 +933,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 +944,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 +958,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 +968,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 +979,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 +991,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 +1003,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 +1052,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 +1063,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 +1137,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 +1180,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 +1200,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( @@ -1164,7 +1234,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 +1243,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 +1276,8 @@ 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) 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/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/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..d5f054fe 100644 --- a/tests/unit/sampletones_application/logic/history/test_fingerprint.py +++ b/tests/unit/sampletones_application/logic/history/test_fingerprint.py @@ -4,7 +4,10 @@ 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.fingerprint import ( + ReconstructionHashCache, + fingerprint_project, +) from sampletones_application.logic.history.snapshot import snapshot_project from sampletones_application.logic.project.controller import ProjectController from sampletones_core.reconstructions import Reconstruction @@ -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..0e18b1cc 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): @@ -78,10 +99,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 +111,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 +142,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 +169,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 +245,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 +258,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 +286,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 +307,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 +359,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 +377,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 +399,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 +438,19 @@ 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_untracked_mutation_self_heals_when_lenient( + self, + history_factory: HistoryFactory, + ) -> None: controller, history = history_factory(strict=False) controller.set_tempo(120) @@ -364,7 +460,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 +473,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/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/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index ed2e55dd..656cb2db 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -45,7 +45,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 +72,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 +107,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 +126,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 +146,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 +184,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 +242,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 +267,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 +290,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 +346,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 +371,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 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/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_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..b62e8edb 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() diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 5b0ce1b7..f8fd4aa8 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -1,16 +1,18 @@ -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.reconstructions import Reconstruction @@ -23,7 +25,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, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 242e85f6..ad0671d7 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, @@ -27,6 +27,7 @@ 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 +101,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 +116,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 +182,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 +194,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: @@ -469,7 +480,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 +495,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 +510,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 +648,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/sequencer/playback/test_apply_modifiers.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py index 8b542fb4..d3d8161f 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_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..ba289a08 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -8,7 +8,10 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_VOLUME -from sampletones_shared.constants.project import REFERENCE_NES_FREQUENCY, REFERENCE_TEMPO +from sampletones_shared.constants.project import ( + REFERENCE_NES_FREQUENCY, + REFERENCE_TEMPO, +) from tests.suite.scenario import BaseTestScenario, ScenarioStep from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( add_sample, @@ -58,7 +61,10 @@ def _controller(context: SynthesizerContext): return context.synthesizer._project_controller -def _state(context: SynthesizerContext, generator: GeneratorName = GeneratorName.PULSE1): +def _state( + context: SynthesizerContext, + generator: GeneratorName = GeneratorName.PULSE1, +): return context.synthesizer._channel_states[generator] @@ -73,7 +79,12 @@ 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 +95,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 +119,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 +131,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 +147,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 +160,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 +171,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 +212,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 +225,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 +259,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 +270,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 +291,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) @@ -280,7 +333,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 +353,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 +389,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 +409,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 +422,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 +444,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 +456,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 +480,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 +497,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 +520,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 +541,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 +562,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 +577,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,7 +653,10 @@ 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() @@ -559,7 +676,10 @@ def render_and_assert_chunk_length(context: SynthesizerContext) -> None: label="chunk length matches timing formula", 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() @@ -567,12 +687,15 @@ def render_and_assert_chunk_length(context: SynthesizerContext) -> None: 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: + def render_and_assert_chunk_uses_project_frequency( + context: SynthesizerContext, + ) -> None: settings = _controller(context).project.settings frame_length = round(settings.sample_rate / settings.nes_frequency) ticks_per_row = (settings.speed * settings.nes_frequency * REFERENCE_TEMPO) // ( @@ -595,7 +718,8 @@ 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() @@ -609,7 +733,8 @@ def pattern_duration_seconds(nes_frequency: int) -> float: 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) 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_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index 85876cd2..b236a09a 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -7,9 +7,11 @@ 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.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( HistoryDetailRole, @@ -47,21 +49,21 @@ def _reconstruction(generators: List[GeneratorName]) -> Reconstruction: 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") diff --git a/tests/unit/sampletones_application/logic/sequencer/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/test_samples.py index 189a486e..c6740773 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_samples.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_samples.py @@ -23,7 +23,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 +41,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 +56,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 +85,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 +110,57 @@ 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 TestPlaySample: @@ -186,7 +228,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/test_grid.py b/tests/unit/sampletones_application/logic/sequencer/test_tracker.py similarity index 82% rename from tests/unit/sampletones_application/logic/sequencer/test_grid.py rename to tests/unit/sampletones_application/logic/sequencer/test_tracker.py index 6a220eee..fe4d2967 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_grid.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_tracker.py @@ -5,7 +5,7 @@ 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.tracker import SequencerTrackerLogic from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME @@ -25,7 +25,15 @@ def _controller() -> ProjectController: def _reconstruction(generators: List[GeneratorName]) -> Reconstruction: instructions = { - generator: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)] for generator in generators + 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( @@ -38,13 +46,21 @@ def _reconstruction(generators: List[GeneratorName]) -> Reconstruction: ) -def _row(controller: ProjectController, generator: GeneratorName, row_index: int = 0) -> Row: +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: +def _place_instrument( + controller: ProjectController, + generator: GeneratorName, + sample_id: str, +) -> None: pattern_index = controller.project.song.order[0][generator] controller.set_row( generator, @@ -57,15 +73,18 @@ def _place_instrument(controller: ProjectController, generator: GeneratorName, s class TestSetNoteOff: def test_set_note_off_writes_note_off_command(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) logic.set_note_off(GeneratorName.PULSE1, 0) - assert isinstance(_row(controller, GeneratorName.PULSE1).command, NoteOff) + 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 = SequencerTrackerLogic(controller) logic.set_note_off_all_generators(0) @@ -76,7 +95,7 @@ def test_set_note_off_all_generators_cuts_every_channel(self) -> None: class TestSetSampleInstrument: def test_fills_only_used_generators(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) sample = controller.add_sample( _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", @@ -86,7 +105,7 @@ def test_fills_only_used_generators(self) -> None: for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): command = _row(controller, generator).command - assert command is not None + assert isinstance(command, Instrument) assert command.sample_id == sample.id assert command.generator_name == generator @@ -95,18 +114,27 @@ def test_fills_only_used_generators(self) -> 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") + logic = SequencerTrackerLogic(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), + command=Instrument( + sample_id=stale.id, + generator_name=GeneratorName.PULSE2, + ), volume=15, ) - lead = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead") + 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 @@ -116,8 +144,11 @@ def test_clears_channels_the_new_sample_does_not_use(self) -> 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 = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1]), + name="lead", + ) logic.set_sample_instrument(0, sample.id) logic.set_sample_instrument(0, None) @@ -127,9 +158,11 @@ def test_none_sample_clears_the_whole_row(self) -> None: class TestSampleSubcolumn: - def test_synchronises_across_relevant_channels_even_without_instrument(self) -> None: + def test_synchronises_across_relevant_channels_even_without_instrument( + self, + ) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) sample = controller.add_sample( _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", @@ -154,9 +187,11 @@ def test_synchronises_across_relevant_channels_even_without_instrument(self) -> assert row.transpose is None assert row.volume is None - def test_synchronises_across_all_channels_when_no_sample_is_referenced(self) -> None: + def test_synchronises_across_all_channels_when_no_sample_is_referenced( + self, + ) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) logic.set_sample_subcolumn(0, transpose=5, volume=10) @@ -168,7 +203,7 @@ def test_synchronises_across_all_channels_when_no_sample_is_referenced(self) -> def test_clear_removes_one_subcolumn_across_relevant_channels(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) sample = controller.add_sample( _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", @@ -189,7 +224,7 @@ def test_clear_removes_one_subcolumn_across_relevant_channels(self) -> None: class TestAdjustTranspose: def test_first_nudge_writes_the_delta_from_zero(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) logic.adjust_transpose(GeneratorName.PULSE1, 0, 1) @@ -197,7 +232,7 @@ def test_first_nudge_writes_the_delta_from_zero(self) -> None: def test_repeated_nudges_accumulate(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) logic.adjust_transpose(GeneratorName.PULSE1, 0, 1) logic.adjust_transpose(GeneratorName.PULSE1, 0, 12) @@ -206,7 +241,7 @@ def test_repeated_nudges_accumulate(self) -> None: def test_clamps_to_max_transpose(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) logic.set_row(GeneratorName.PULSE1, 0, transpose=MAX_TRANSPOSE) logic.adjust_transpose(GeneratorName.PULSE1, 0, 12) @@ -215,7 +250,7 @@ def test_clamps_to_max_transpose(self) -> None: def test_preserves_instrument_and_volume(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead") _place_instrument(controller, GeneratorName.PULSE1, sample.id) logic.adjust_volume(GeneratorName.PULSE1, 0, -1) @@ -231,7 +266,7 @@ def test_preserves_instrument_and_volume(self) -> None: class TestAdjustVolume: def test_unset_volume_steps_down_from_full(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) logic.adjust_volume(GeneratorName.PULSE1, 0, -1) @@ -239,7 +274,7 @@ def test_unset_volume_steps_down_from_full(self) -> None: def test_unset_volume_up_stays_full(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) logic.adjust_volume(GeneratorName.PULSE1, 0, 1) @@ -247,7 +282,7 @@ def test_unset_volume_up_stays_full(self) -> None: def test_clamps_to_zero(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) logic.set_row(GeneratorName.PULSE1, 0, volume=1) logic.adjust_volume(GeneratorName.PULSE1, 0, -4) @@ -258,7 +293,7 @@ def test_clamps_to_zero(self) -> None: class TestAdjustSampleColumn: def test_sample_transpose_shifts_only_relevant_channels(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) sample = controller.add_sample( _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", @@ -275,7 +310,7 @@ def test_sample_transpose_shifts_only_relevant_channels(self) -> None: def test_sample_volume_steps_relevant_channels_down_from_full(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) sample = controller.add_sample( _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", @@ -288,10 +323,10 @@ def test_sample_volume_steps_relevant_channels_down_from_full(self) -> None: assert _row(controller, generator).volume == MAX_VOLUME - 1 -class TestBuildGridAggregation: +class TestBuildTrackerAggregation: def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) sample = controller.add_sample( _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", @@ -304,7 +339,7 @@ def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None: def test_full_placement_reads_as_the_sample(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) sample = controller.add_sample( _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", @@ -318,7 +353,7 @@ def test_full_placement_reads_as_the_sample(self) -> None: def test_diverging_transpose_renders_as_mixed(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) sample = controller.add_sample( _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", @@ -332,7 +367,7 @@ def test_diverging_transpose_renders_as_mixed(self) -> None: def test_shared_transpose_is_reflected_in_the_sample_column(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) sample = controller.add_sample( _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", @@ -352,7 +387,7 @@ def _append_empty_frame(self, controller: ProjectController) -> None: def test_editing_an_empty_slot_creates_and_assigns_a_pattern(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) self._append_empty_frame(controller) logic.select_frame(1) @@ -366,10 +401,10 @@ def test_editing_an_empty_slot_creates_and_assigns_a_pattern(self) -> None: def test_empty_frame_still_shows_editable_rows(self) -> None: controller = _controller() - logic = SequencerGridLogic(controller) + logic = SequencerTrackerLogic(controller) self._append_empty_frame(controller) logic.select_frame(1) - grid = logic.build_grid() + tracker = logic.build_grid() - assert len(grid.rows) == controller.project.song.rows_per_pattern + assert len(tracker.rows) == controller.project.song.rows_per_pattern diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py index 7152921b..f12e2fce 100644 --- a/tests/unit/sampletones_application/logic/shared/test_tree.py +++ b/tests/unit/sampletones_application/logic/shared/test_tree.py @@ -4,12 +4,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.logic.shared.playback_priority import PlaybackPriority from sampletones_application.logic.shared.tree import TreeLogic from sampletones_core import paths @@ -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 @@ -299,7 +310,11 @@ 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() @@ -319,11 +334,13 @@ def test_unexpected_failure_propagates(self, tmp_path: Path) -> None: tree.on_autoplay_error = MagicMock() node = _file_node(tmp_path / f"sample{paths.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_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/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..33350b89 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -1,17 +1,26 @@ import threading from types import SimpleNamespace -from typing import Any, Dict, Final, List +from typing import Any, Callable, Dict, Final, Iterator, List, 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 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. @@ -37,7 +46,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 +72,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 +96,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 +106,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 +124,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 +162,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 +186,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 +199,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 +212,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 +242,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 +257,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 +265,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 +278,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 +286,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 +328,7 @@ def test_run_exception_emits_service_error(self, reconstruction) -> None: service._run( reconstruction, GeneratorName.PULSE1, - {}, + cast(Features, {}), FeatureKey.VOLUME, 1, ) @@ -275,7 +338,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 +353,7 @@ def test_run_exception_does_not_update_reconstruction(self, reconstruction) -> N service._run( reconstruction, GeneratorName.PULSE1, - {}, + cast(Features, {}), FeatureKey.VOLUME, 1, ) @@ -302,7 +368,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 +386,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 +399,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 +416,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 +428,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 +437,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_project_properties_history.py b/tests/unit/sampletones_application/test_project_properties_history.py index 405a97d2..8e9b9df2 100644 --- a/tests/unit/sampletones_application/test_project_properties_history.py +++ b/tests/unit/sampletones_application/test_project_properties_history.py @@ -11,7 +11,8 @@ 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,7 +26,8 @@ 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() @@ -41,7 +43,11 @@ def test_unchanged_confirmation_records_nothing(self) -> None: application = _application() info = application.project_controller.project.info - application._commit_project_properties(info.title, info.author, info.comment) + application._commit_project_properties( + info.title, + info.author, + info.comment, + ) assert len(application.history.entries) == 1 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..b6cc4525 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 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.keyboard.modifiers import NO_MODIFIERS +from sampletones_application.utils.gui.shortcuts.ids import ( + CHANNEL_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,19 @@ def _display_patches() -> List[Any]: return display_patches +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 +89,83 @@ 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 p in _display_patches(): - stack.enter_context(p) + for display_patch in _display_patches(): + stack.enter_context(display_patch) - 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 +302,51 @@ 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() + + +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, key: int, tab: Tab) -> None: + with patch.object(app._shell, "get_current_tab", return_value=tab): + app.key_router.route(KeyEvent(key=key, modifiers=NO_MODIFIERS)) + + def test_each_channel_reads_under_the_function_key_it_answers(self, app: Application) -> None: + displayed = [app._shortcut_source.display(shortcut_id) for shortcut_id in CHANNEL_SHORTCUT_IDS.values()] + + assert displayed == ["F1", "F2", "F3", "F4"] + + 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, dpg.mvKey_F3, 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, dpg.mvKey_F4, 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, dpg.mvKey_F1, Tab.SEQUENCER) + self._press(app, dpg.mvKey_F1, 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, dpg.mvKey_F2, 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, dpg.mvKey_F1, Tab.MAIN) + + assert not app._sequencer_tab.channels.any_muted 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..de1b57e0 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py @@ -6,6 +6,8 @@ expanded_side_width, stacked_graph_height, ) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase @dataclass(frozen=True) @@ -19,18 +21,6 @@ class StackedHeightCase: 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 @@ -42,21 +32,96 @@ class SideWidthCase: 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) + @dataclass(frozen=True, kw_only=True) + class StackedHeightCase(BaseRegularTestCase): + base_height: int + viewport_height: int + baseline_viewport_height: int + graph_count: int + max_stack_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_stack_height=1200, + expected=292, + ), + StackedHeightCase( + label="holds_base_below_baseline", + base_height=292, + viewport_height=640, + baseline_viewport_height=800, + graph_count=2, + max_stack_height=1200, + expected=292, + ), + StackedHeightCase( + label="shares_surplus_equally", + base_height=292, + viewport_height=1000, + baseline_viewport_height=800, + graph_count=2, + max_stack_height=1200, + expected=392, + ), + StackedHeightCase( + label="just_below_the_cap", + base_height=292, + viewport_height=1414, + baseline_viewport_height=800, + graph_count=2, + max_stack_height=1200, + expected=599, + ), + StackedHeightCase( + label="reaches_the_cap", + base_height=292, + viewport_height=1416, + baseline_viewport_height=800, + graph_count=2, + max_stack_height=1200, + expected=600, + ), + StackedHeightCase( + label="holds_the_cap_above_it", + base_height=292, + viewport_height=2200, + baseline_viewport_height=800, + graph_count=2, + max_stack_height=1200, + expected=600, + ), + StackedHeightCase( + label="three_graphs_share_surplus", + base_height=292, + viewport_height=1100, + baseline_viewport_height=800, + graph_count=3, + max_stack_height=1200, + expected=392, + ), + StackedHeightCase( + label="three_graphs_lower_cap", + base_height=292, + viewport_height=1124, + baseline_viewport_height=800, + graph_count=3, + max_stack_height=1200, + expected=400, + ), + ) + + @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( @@ -70,7 +135,10 @@ def test_height_follows_the_surplus_rule(self, case: StackedHeightCase) -> None: ) @pytest.mark.parametrize("viewport_height", range(600, 3000, 37)) - def test_stays_within_base_and_combined_cap(self, viewport_height: int) -> None: + 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 @@ -80,12 +148,69 @@ def test_stays_within_base_and_combined_cap(self, viewport_height: int) -> None: assert height * graph_count <= max_stack_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/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_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_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/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/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_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..7458f13a 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 @@ -10,7 +10,7 @@ BEHAVIOR_DIRECTORY, LANG_EN, LAYOUT_DIRECTORY, - PALETTE_PATH, + PALETTES_DIRECTORY, THEME_DIRECTORY, ) from sampletones_application.tags.general import ( @@ -24,22 +24,26 @@ ) 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_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS +from sampletones_core.formats.famitracker.specification.sequences import ( + MAX_SEQUENCE_ITEMS, +) SEQUENCE_STATUS_KEY: Final[str] = "reconstructions.instruments.message.status_sequence" @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, @@ -97,7 +101,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 +113,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: 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..7d3dac57 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py @@ -0,0 +1,152 @@ +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_core.constants.enums import GeneratorName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +ALL_GENERATORS = frozenset(GeneratorName) + + +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]] = [] + + 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_set_value", self.values.__setitem__) + + self.panel = GUIReconstructionPlotPanel.__new__(GUIReconstructionPlotPanel) + self.panel.on_generators_changed = self.reported.append + + @staticmethod + def _tag(generator: GeneratorName) -> str: + return GUIReconstructionPlotPanel._get_generator_checkbox_tag(generator) + + def selected(self) -> FrozenSet[GeneratorName]: + return frozenset(generator for generator in GeneratorName if self.values[self._tag(generator)]) + + +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/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py similarity index 97% rename from tests/unit/sampletones_application/ui/panels/sequencer/test_order_input.py rename to tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index 314f0f6b..ee068f1a 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -1,6 +1,6 @@ from typing import Optional -from sampletones_application.ui.panels.sequencer.order_input import ( +from sampletones_application.ui.panels.sequencer.input.order import ( ORDER_ROWS, OrderCursor, OrderInputState, 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_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_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py new file mode 100644 index 00000000..3f9d476c --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py @@ -0,0 +1,141 @@ +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) + 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_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] + + 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+A opens the audio settings, so cell entry keeps the plain key alone.""" + assert order.panel._on_key_pressed(_press("Ctrl+A")) 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..aa62eea8 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,37 @@ 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.order import ( + OrderCursor, + OrderInputState, +) from sampletones_application.ui.panels.sequencer.input.state import 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) @@ -41,12 +47,14 @@ class TestOrderEscapeYieldsToGlobalStop: 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) 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..1dcd8ac5 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py @@ -0,0 +1,106 @@ +from dataclasses import dataclass +from typing import Callable, Union + +import pytest + +from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.order import ( + OrderCursor, + OrderInputState, +) +from sampletones_application.ui.panels.sequencer.input.state import 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..aeb6a2ad --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py @@ -0,0 +1,160 @@ +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.layout.tabs.sequencer.tracker.tracker import TrackerLayout +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 + +NO_CUES = RowCues(cursor=None, playing=None) + + +@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 tracker(sequencer_layout: SequencerLayout) -> TrackerLayout: + return sequencer_layout.tracker + + +@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, + tracker: TrackerLayout, + colors: SequencerColors, + ) -> None: + assert group_color(0, tracker, colors) == colors.rows.bar + assert group_color(tracker.rows_per_bar, tracker, colors) == colors.rows.bar + + def test_the_row_opening_a_beat_takes_the_beat_shade( + self, + tracker: TrackerLayout, + colors: SequencerColors, + ) -> None: + beats = ( + tracker.rows_per_beat, + 2 * tracker.rows_per_beat, + tracker.rows_per_bar + tracker.rows_per_beat, + ) + + for row_index in beats: + assert group_color(row_index, tracker, colors) == colors.rows.beat + + def test_a_row_inside_a_beat_keeps_its_stripe( + self, + tracker: TrackerLayout, + colors: SequencerColors, + ) -> None: + for row_index in range(tracker.rows): + if row_index % tracker.rows_per_beat != 0: + assert group_color(row_index, tracker, colors) is None + + def test_the_bar_shade_outranks_the_beat_shade_where_they_meet( + self, + tracker: TrackerLayout, + colors: SequencerColors, + ) -> None: + """Every bar boundary opens a beat as well, and the row reads as the start of the bar.""" + assert tracker.rows_per_bar % tracker.rows_per_beat == 0 + assert group_color(tracker.rows_per_bar, tracker, colors) == colors.rows.bar + + def test_grouping_counts_of_zero_leave_every_row_even( + self, + tracker: TrackerLayout, + colors: SequencerColors, + ) -> None: + flat = tracker.model_copy(update={"rows_per_beat": 0, "rows_per_bar": 0}) + + for row_index in range(tracker.rows): + assert group_color(row_index, flat, colors) is None + + +class TestCues: + def test_the_playhead_outranks_the_cursor( + self, + tracker: TrackerLayout, + colors: SequencerColors, + ) -> None: + cues = RowCues(cursor=5, playing=5) + + assert row_background(5, tracker, colors, cues) == colors.playback_row + + def test_the_cursor_marks_the_row_it_rests_on( + self, + tracker: TrackerLayout, + colors: SequencerColors, + ) -> None: + cues = RowCues(cursor=5, playing=9) + + assert row_background(5, tracker, colors, cues) == colors.cursor_row + + def test_a_row_no_mark_stands_on_keeps_its_stripe( + self, + tracker: TrackerLayout, + colors: SequencerColors, + ) -> None: + cues = RowCues(cursor=5, playing=9) + + assert row_background(6, tracker, colors, cues) is None + + +class TestComposition: + def test_a_marked_group_row_carries_the_cue_over_the_group_shade( + self, + tracker: TrackerLayout, + colors: SequencerColors, + ) -> None: + row_index = tracker.rows_per_beat + cues = RowCues(cursor=row_index, playing=None) + + assert row_background(row_index, tracker, colors, cues) == LayeredColor( + base=colors.rows.beat, + overlay=colors.cursor_row, + ) + + def test_the_composed_shade_covers_more_than_either_alone( + self, + tracker: TrackerLayout, + colors: SequencerColors, + ) -> None: + row_index = tracker.rows_per_bar + cues = RowCues(cursor=None, playing=row_index) + composed = row_background(row_index, tracker, 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, + tracker: TrackerLayout, + colors: SequencerColors, + ) -> None: + assert row_background(0, tracker, colors, NO_CUES) == colors.rows.bar + assert row_background(tracker.rows_per_beat, tracker, colors, NO_CUES) == colors.rows.beat + + def test_a_plain_unmarked_row_leaves_the_layer_free( + self, + tracker: TrackerLayout, + colors: SequencerColors, + ) -> None: + assert row_background(1, tracker, 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_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_grid_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py similarity index 78% rename from tests/unit/sampletones_application/ui/panels/sequencer/test_grid_context_menu.py rename to tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 484f9d84..0eb8a719 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -3,19 +3,13 @@ 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.ui.panels.sequencer import tracker as tracker_module from sampletones_application.view_model.sequencer.samples import ( SampleEntryViewModel, SequencerSamplesViewModel, ) from sampletones_core.constants.enums import GeneratorName +from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP SENDER_WIDGET_ID = 6099 """A stand-in for the menu-item widget id DearPyGui passes as the callback's first @@ -36,16 +30,17 @@ ) -def _panel() -> GUISequencerGridPanel: +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, 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) + panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) for label in _CONTEXT_LABELS: setattr(panel, label, "") + return panel @@ -69,13 +64,13 @@ def dispatch_as_dpg(self) -> None: @pytest.fixture def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuItemRecorder: instance = _MenuItemRecorder() - monkeypatch.setattr(grid_module.dpg, "add_menu_item", instance.add_menu_item) + monkeypatch.setattr(tracker_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) + monkeypatch.setattr(tracker_module.dpg, "menu", _menu) return instance @@ -88,7 +83,12 @@ def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecor panel._add_transpose_items(2, GeneratorName.PULSE1) recorder.dispatch_as_dpg() - assert deltas == [SEMITONE_STEP, -SEMITONE_STEP, OCTAVE_SEMITONES, -OCTAVE_SEMITONES] + assert deltas == [ + SEMITONE_STEP, + -SEMITONE_STEP, + OCTAVE_SEMITONES, + -OCTAVE_SEMITONES, + ] def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None: panel = _panel() @@ -98,7 +98,12 @@ def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder 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] + 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_clicked_row_and_channel(self, recorder: _MenuItemRecorder) -> None: panel = _panel() @@ -113,7 +118,13 @@ def test_adjust_carries_the_clicked_row_and_channel(self, recorder: _MenuItemRec 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),), + 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) 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..88131ff4 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -0,0 +1,313 @@ +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.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.state import 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_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 + + +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._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 _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_row(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_row(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_row(12) + panel.set_playing_row(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_row(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_row(12) + paint() + panel.set_playing_row(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_row(12) + paint() + panel.set_playing_row(None) + paint() + + assert panel._painted_row is None + assert painted == [12, 12] + + +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+C carries no tracker action, so cell entry keeps the plain key alone.""" + panel = _panel() + states: List[TrackerInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + + assert panel._on_key_pressed(_press("Ctrl+C")) 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 83% 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..e5d43ea8 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,18 @@ 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.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..441b3eba --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -0,0 +1,386 @@ +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.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.state import 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.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from sampletones_shared.types.application import ColorRGBA + +PATTERN_ROWS = 4 +HEADER_AND_PATTERN_ROWS = PATTERN_ROWS + 1 + +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 _panel() -> GUISequencerTrackerPanel: + """Builds a panel around the state the row backgrounds read, with no DearPyGui context.""" + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = SimpleNamespace( + tracker=SimpleNamespace( + rows_per_beat=ROWS_PER_BEAT, + rows_per_bar=ROWS_PER_BAR, + ), + 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._playing_row = None + panel._painted_row = None + panel._follows_playing_row = False + panel._input_state = TrackerInputState() + return panel + + +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_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_row(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_row(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_row(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_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_returns_the_row_it_left(self, recorder: _TableRecorder) -> None: + panel = _panel() + + panel.set_playing_row(1) + panel.set_playing_row(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_row(0) + panel.set_playing_row(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_row(3) + + panel.set_playing_row(None) + + assert recorder.unhighlighted_rows == [tracker_table_row(3)] + + +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/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index d26dbe00..4dd228e4 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -4,6 +4,7 @@ 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_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, @@ -13,15 +14,23 @@ 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: @@ -71,6 +80,7 @@ def _state( muted: FrozenSet[GeneratorName], *, reconstruction_loaded: bool = False, + follow_mode: FollowMode = FollowMode.OFF, ) -> MenuBarViewModel: return MenuBarViewModel( project_open=True, @@ -89,7 +99,7 @@ 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, @@ -113,11 +123,21 @@ def shortcuts() -> _ShortcutManagerRecorder: @pytest.fixture -def menu_bar(shortcuts: _ShortcutManagerRecorder) -> MenuBar: +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 Channels submenu reads, 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 return instance @@ -161,6 +181,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 +267,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, 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..4c314e7e --- /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_core.paths 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..f40f0a70 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,11 +250,13 @@ 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: callback = Mock() - manager.register(ShortcutId.AUDIO_SETTINGS, Shortcut(dpg.mvKey_A, CTRL), callback) - manager.bind_all() + manager = _manager(source, ShortcutId.AUDIO_SETTINGS, callback) field_kind["kind"] = FieldKind.TEXT_ENTRY claimed = manager._dispatch(_event(dpg.mvKey_A, modifiers=CTRL)) @@ -106,12 +264,14 @@ def test_text_field_keeps_its_editing_chord(self, field_kind: Dict[str, FieldKin 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: + def test_text_field_yields_a_shifted_chord_it_has_no_use_for( + self, + source: ShortcutSource, + field_kind: Dict[str, FieldKind], + ) -> None: """Ctrl+Shift+A carries a chord letter without being a text chord, so the shortcut fires.""" - manager = _manager() callback = Mock() - manager.register(ShortcutId.TOGGLE_ADVANCED_SETTINGS, Shortcut(dpg.mvKey_A, CTRL_SHIFT), callback) - manager.bind_all() + manager = _manager(source, ShortcutId.TOGGLE_ADVANCED_SETTINGS, callback) field_kind["kind"] = FieldKind.TEXT_ENTRY claimed = manager._dispatch(_event(dpg.mvKey_A, modifiers=CTRL_SHIFT)) @@ -119,11 +279,13 @@ def test_text_field_yields_a_shifted_chord_it_has_no_use_for(self, field_kind: D 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 +293,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..f8b3578d --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -0,0 +1,314 @@ +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_core.paths 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 Shift+Left is not the plain Left move.""" + assert shipped.action(ShortcutCategory.ORDER, _press("Shift+Left")) is None + + +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_shipped.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py new file mode 100644 index 00000000..71493710 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py @@ -0,0 +1,241 @@ +import platform +from dataclasses import dataclass + +import pytest + +from sampletones_application.constants.keybindings import MACOS_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.shortcuts.catalog import ShortcutCatalog +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 + +DISPLAY_SETTINGS_COMBINATION = "Ctrl+D" +DUPLICATE_FRAME_COMBINATION = "Ctrl+Ins" + + +def _press(text: str) -> KeyEvent: + combination = KeyCombination.parse(text) + return KeyEvent(key=combination.key, modifiers=combination.modifiers) + + +@pytest.fixture(name="macos", scope="session") +def macos_fixture() -> ShortcutScheme: + """The scheme a Mac opens on, which a case reads on whichever platform the suite runs.""" + return ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).get(MACOS_SCHEME_NAME) + + +@pytest.fixture(name="mac_keyboard") +def mac_keyboard_fixture(monkeypatch: pytest.MonkeyPatch) -> None: + """Reads combinations the way a Mac is labelled, so Super shows as Command.""" + monkeypatch.setattr(platform, "system", lambda: "Darwin") + + +class TestDisplaySettingsKey: + """Ctrl+D opens the display settings, which the order table gave to duplicate-frame before.""" + + def test_the_display_settings_read_under_the_combination_they_answer(self, shipped: ShortcutScheme) -> None: + assert shipped.shortcut(ShortcutId.DISPLAY_SETTINGS).display() == DISPLAY_SETTINGS_COMBINATION + + def test_the_order_table_leaves_the_display_settings_key_alone(self, shipped: ShortcutScheme) -> None: + """The order table sees a press first, so it answering none is what lets the dialog open + while the cursor sits in the table.""" + assert shipped.action(ShortcutCategory.ORDER, _press(DISPLAY_SETTINGS_COMBINATION)) is None + + +class TestDuplicateFrameKey: + """Duplicate-frame reads as "insert a copy" beside the table's Insert and ``+``.""" + + def test_duplicate_frame_reads_under_the_combination_it_answers(self, shipped: ShortcutScheme) -> None: + assert shipped.shortcut(ShortcutId.ORDER_DUPLICATE_FRAME).display() == DUPLICATE_FRAME_COMBINATION + + def test_duplicate_frame_answers_its_press_in_the_order_table(self, shipped: ShortcutScheme) -> None: + action = shipped.action(ShortcutCategory.ORDER, _press(DUPLICATE_FRAME_COMBINATION)) + + assert action is ShortcutId.ORDER_DUPLICATE_FRAME + + def test_adding_a_frame_keeps_the_unmodified_insert(self, shipped: ShortcutScheme) -> None: + assert shipped.action(ShortcutCategory.ORDER, _press("Ins")) is ShortcutId.ORDER_ADD_FRAME + + +class TestChannelKeys(BaseTestSuite): + """The four channels sit on the four function keys, in the order the tracker shows them.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + shortcut_id: ShortcutId + expected: str + + test_cases = ( + TestCase(label="pulse 1", shortcut_id=ShortcutId.TOGGLE_CHANNEL_PULSE_1, expected="F1"), + TestCase(label="pulse 2", shortcut_id=ShortcutId.TOGGLE_CHANNEL_PULSE_2, expected="F2"), + TestCase(label="triangle", shortcut_id=ShortcutId.TOGGLE_CHANNEL_TRIANGLE, expected="F3"), + TestCase(label="noise", shortcut_id=ShortcutId.TOGGLE_CHANNEL_NOISE, expected="F4"), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_channel_reads_under_the_function_key_it_answers( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + assert shipped.shortcut(test_case.shortcut_id).display() == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_channel_key_reaches_it_from_every_tab( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + """The action is the application's, so the key answers wherever no panel claims it.""" + action = shipped.action(ShortcutCategory.APPLICATION, _press(test_case.expected)) + + assert action is test_case.shortcut_id + + def test_the_samples_panel_keeps_rename_on_its_function_key(self, shipped: ShortcutScheme) -> None: + """A panel is asked before the application is, so F2 renames while the samples list has + the keyboard and switches Pulse 2 everywhere else.""" + assert shipped.action(ShortcutCategory.SAMPLES, _press("F2")) is ShortcutId.SAMPLES_RENAME_SAMPLE + + +class TestMacosKeys(BaseTestSuite): + """What a Mac reads its keys as, spelled the way that keyboard is labelled.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + shortcut_id: ShortcutId + expected: str + + test_cases = ( + TestCase(label="save", shortcut_id=ShortcutId.SAVE_PROJECT, expected="Cmd+S"), + TestCase(label="undo", shortcut_id=ShortcutId.UNDO, expected="Cmd+Z"), + TestCase(label="redo", shortcut_id=ShortcutId.REDO, expected="Cmd+Shift+Z"), + TestCase(label="exit", shortcut_id=ShortcutId.EXIT, expected="Cmd+Q"), + TestCase(label="fullscreen", shortcut_id=ShortcutId.TOGGLE_FULLSCREEN, expected="Cmd+Ctrl+F"), + TestCase(label="playback stays on the space bar", shortcut_id=ShortcutId.PLAY, expected="Space"), + TestCase( + label="a channel keeps its function key", shortcut_id=ShortcutId.TOGGLE_CHANNEL_PULSE_1, expected="F1" + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_an_action_reads_under_the_combination_a_mac_gives_it( + self, + test_case: TestCase, + macos: ShortcutScheme, + mac_keyboard: None, + ) -> None: + assert macos.shortcut(test_case.shortcut_id).display() == test_case.expected + + +class TestMacosAlternatives(BaseTestSuite): + """The keys a Mac laptop keyboard omits, each reachable by a combination it carries.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + category: ShortcutCategory + combination: str + expected: ShortcutId + + test_cases = ( + TestCase( + label="the first row", + category=ShortcutCategory.TRACKER, + combination="Cmd+Up", + expected=ShortcutId.TRACKER_FIRST_ROW, + ), + TestCase( + label="the last row", + category=ShortcutCategory.TRACKER, + combination="Cmd+Down", + expected=ShortcutId.TRACKER_LAST_ROW, + ), + TestCase( + label="a page up", + category=ShortcutCategory.TRACKER, + combination="Alt+Up", + expected=ShortcutId.TRACKER_PAGE_UP, + ), + TestCase( + label="a page down", + category=ShortcutCategory.TRACKER, + combination="Alt+Down", + expected=ShortcutId.TRACKER_PAGE_DOWN, + ), + TestCase( + label="clearing a row", + category=ShortcutCategory.TRACKER, + combination="Cmd+Backspace", + expected=ShortcutId.TRACKER_CLEAR_ROW, + ), + TestCase( + label="the first frame", + category=ShortcutCategory.ORDER, + combination="Cmd+Left", + expected=ShortcutId.ORDER_FIRST_POSITION, + ), + TestCase( + label="the last frame", + category=ShortcutCategory.ORDER, + combination="Cmd+Right", + expected=ShortcutId.ORDER_LAST_POSITION, + ), + TestCase( + label="adding a frame", + category=ShortcutCategory.ORDER, + combination="Cmd+Enter", + expected=ShortcutId.ORDER_ADD_FRAME, + ), + TestCase( + label="a sample to the top", + category=ShortcutCategory.SAMPLES, + combination="Cmd+Alt+Up", + expected=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_TOP, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_alternative_reaches_the_action_its_missing_key_reaches( + self, + test_case: TestCase, + macos: ShortcutScheme, + ) -> None: + action = macos.action(test_case.category, _press(test_case.combination)) + + assert action is test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_key_it_stands_in_for_still_answers( + self, + test_case: TestCase, + macos: ShortcutScheme, + ) -> None: + """A Mac with a full keyboard finds the plain key where every other platform has it.""" + combinations = macos.shortcut(test_case.expected).combinations() + + assert all( + macos.claimant(test_case.category, combination) is test_case.expected for combination in combinations + ) 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..e44326b1 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -60,8 +60,15 @@ 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(), 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_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..462e7bae --- /dev/null +++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py @@ -0,0 +1,171 @@ +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="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", 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..6341a05c 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, @@ -74,7 +77,7 @@ 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, @@ -110,7 +113,7 @@ 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, 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/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/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..950135da 100644 --- a/tests/unit/sampletones_core/exporters/test_exporter.py +++ b/tests/unit/sampletones_core/exporters/test_exporter.py @@ -71,7 +71,7 @@ class TestCase(BaseRegularTestCase): arpeggio: np.ndarray edited_pitches: List[int] - test_cases = [ + test_cases = ( TestCase( label="pulse", exporter=PulseExporter, @@ -99,7 +99,7 @@ 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: @@ -197,7 +197,7 @@ class TestCase(BaseRegularTestCase): features: Features read_pitch: Callable[[Any], int] - test_cases = [ + test_cases = ( TestCase( label="pulse", exporter=PulseExporter, @@ -240,7 +240,7 @@ class TestCase(BaseRegularTestCase): read_pitch=_read_period, expected=REFERENCE_PERIOD, ), - ] + ) @pytest.mark.parametrize( "test_case", 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/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..10485b18 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_btp.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py @@ -9,7 +9,10 @@ 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.formats.bitphase.specification.chip import ( + CHIP_TYPE_NES, + TUNING_TABLE_LENGTH, +) from sampletones_core.paths 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..1d409e74 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py @@ -5,7 +5,10 @@ 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, 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..322f0e3f 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, 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..7e6139e2 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -8,7 +8,10 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.formats.bitphase.builder import project_to_bitphase 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.patterns import ( NO_INSTRUMENT_CHANGE, @@ -45,7 +48,9 @@ EMPTY_ROW: Final[int] = 6 -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 +64,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") @@ -125,7 +136,10 @@ def document_fixture(source: Project) -> BitphaseProject: 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 +147,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: 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..20d8e26b 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,9 @@ 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, 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..07bde575 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py @@ -2,11 +2,25 @@ 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 ( + 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, diff --git a/tests/unit/sampletones_core/formats/famitracker/test_fti.py b/tests/unit/sampletones_core/formats/famitracker/test_fti.py index dd59b6b0..ec16cfc9 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]) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_ftm.py b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py index 1abf9360..65a5d31b 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_ftm.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py @@ -15,7 +15,10 @@ 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, EXPANSION_NONE, 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..466cfe03 100644 --- a/tests/unit/sampletones_core/library/filename/test_fields.py +++ b/tests/unit/sampletones_core/library/filename/test_fields.py @@ -3,7 +3,10 @@ import pytest -from sampletones_core.library.filename.fields import FILENAME_SEPARATOR, InstructionsFilenameFields +from sampletones_core.library.filename.fields import ( + FILENAME_SEPARATOR, + InstructionsFilenameFields, +) from sampletones_core.paths import EXT_FILE_LIBRARY from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -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/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..af620de0 100644 --- a/tests/unit/sampletones_core/project/test_settings.py +++ b/tests/unit/sampletones_core/project/test_settings.py @@ -30,27 +30,75 @@ 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, + ), + ) @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) 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/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..ef79ec57 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -119,7 +119,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 +130,7 @@ class TestCase(BaseRegularTestCase): make_path=lambda root: root, expected=DIRECTORY_READ_ERRORS, ), - ] + ) @pytest.mark.parametrize( "test_case", @@ -188,7 +188,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 +199,7 @@ class TestCase(BaseRegularTestCase): side_effect=DeserializationError("missing getter"), expected=DeserializationError, ), - ] + ) @pytest.mark.parametrize( "test_case", @@ -236,7 +236,11 @@ 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, 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_tree.py b/tests/unit/sampletones_core/structures/tree/test_tree.py index c0544112..0fd62e7d 100644 --- a/tests/unit/sampletones_core/structures/tree/test_tree.py +++ b/tests/unit/sampletones_core/structures/tree/test_tree.py @@ -1,4 +1,5 @@ from dataclasses import dataclass +from typing import List import pytest @@ -13,7 +14,7 @@ def name_predicate(node: TreeNode, query: str) -> bool: @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 +25,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,15 +33,23 @@ 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: + def test_set_root_clears_existing_filter( + self, + all_nodes: List[TreeNode], + tree: Tree, + ) -> None: tree.apply_filter("child_a", name_predicate) assert tree.is_filtered() tree.set_root(all_nodes[0]) @@ -52,10 +61,10 @@ class TestTreeFilter: class TestCase(BaseTestCase): label: str query: str - expected_visible_names: frozenset - expected_hidden_names: frozenset + expected_visible_names: frozenset[str] + expected_hidden_names: frozenset[str] - FILTER_VISIBILITY_CASES = [ + test_cases = ( TestCase( label="match_leaf", query="leaf_ba", @@ -65,18 +74,38 @@ class TestCase(BaseTestCase): TestCase( label="match_internal", query="child_a", - expected_visible_names=frozenset({"root", "child_a", "leaf_aa", "leaf_ab"}), + 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"}), + 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: + def test_no_filter_all_nodes_visible( + self, + tree: Tree, + all_nodes: List[TreeNode], + ) -> None: for node in all_nodes: assert tree.is_node_visible(node) @@ -92,7 +121,11 @@ def test_filter_empty_query_clears_filter(self, tree: Tree) -> None: 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: + def test_clear_filter_makes_all_nodes_visible( + self, + tree: Tree, + all_nodes: List[TreeNode], + ) -> None: tree.apply_filter("leaf_ba", name_predicate) tree.clear_filter() for node in all_nodes: @@ -103,8 +136,13 @@ def test_filter_on_empty_tree_is_active(self) -> None: 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: + @pytest.mark.parametrize("case", test_cases, ids=lambda c: c.label) + def test_filter_visibility( + self, + tree: Tree, + all_nodes: List[TreeNode], + case: TestCase, + ) -> None: tree.apply_filter(case.query, name_predicate) for node in all_nodes: if node.name in case.expected_visible_names: 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/trackers/test_bitphase.py b/tests/unit/sampletones_core/trackers/test_bitphase.py index 9d3fca4c..1bbd52a3 100644 --- a/tests/unit/sampletones_core/trackers/test_bitphase.py +++ b/tests/unit/sampletones_core/trackers/test_bitphase.py @@ -13,8 +13,15 @@ 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 NES_FREQUENCY: Final[int] = 60 diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py index 9b889d10..5aedf2c4 100644 --- a/tests/unit/sampletones_core/trackers/test_famitracker.py +++ b/tests/unit/sampletones_core/trackers/test_famitracker.py @@ -7,7 +7,9 @@ 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.formats.famitracker.specification.sequences import ( + MAX_SEQUENCE_ITEMS, +) from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend @@ -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_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..bc2333ad --- /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 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/test_paths.py b/tests/unit/sampletones_shared/test_paths.py new file mode 100644 index 00000000..ae1c2fe2 --- /dev/null +++ b/tests/unit/sampletones_shared/test_paths.py @@ -0,0 +1,27 @@ +from sampletones_shared.paths import CONFIG_DIRECTORY, 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.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() + + +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/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..23b162f8 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 @@ -32,7 +30,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 +131,7 @@ class TestCase(BaseRegularTestCase): expected=TypeError, label="dict_raises_type_error", ), - ] + ) @pytest.mark.parametrize( "test_case", @@ -160,7 +158,7 @@ class TestCase(BaseRegularTestCase): extension: str expected: str - test_cases = [ + test_cases = ( TestCase( name="song", extension=".stp", @@ -185,7 +183,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 +201,7 @@ class TestCase(BaseRegularTestCase): suffix: str expected: str - test_cases = [ + test_cases = ( TestCase( input_path="song", suffix=".stp", @@ -252,7 +250,7 @@ class TestCase(BaseRegularTestCase): expected="/home/user/song.stp", label="full_path_keeps_matching_suffix", ), - ] + ) @pytest.mark.parametrize( "test_case", @@ -275,7 +273,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 +467,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 +632,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 +723,7 @@ class TestCase(BaseRegularTestCase): command_returncode=1, should_fallback=True, ), - ] + ) @pytest.mark.parametrize( "test_case", @@ -778,7 +776,7 @@ class TestCase(BaseRegularTestCase): system: System is_file: bool - test_cases = [ + test_cases = ( TestCase( label="windows_file", system=System.WINDOWS, @@ -803,7 +801,7 @@ class TestCase(BaseRegularTestCase): is_file=False, expected=["open", ""], ), - ] + ) @pytest.mark.parametrize( "test_case", 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_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..0d51b8a7 --- /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 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..a79a294f 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 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..4d8456f0 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 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..28793dfa 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() @@ -76,7 +112,11 @@ def test_a_notice_directory_counts_as_absent(self, bundle: Path) -> None: 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) 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"