From 9b13981fad138c51087e254b4ef6f8c8ce859a21 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 6 Aug 2026 18:36:58 +0200 Subject: [PATCH 001/152] Implemented: palette catalog with light/dark themes --- docs/development/config-organization.md | 35 ++- src/sampletones/self_check.py | 28 ++- src/sampletones_application/application.py | 8 +- .../layout/general/caret.py | 2 +- .../layout/general/colors.py | 2 +- .../layout/graphs/colors.py | 2 +- .../layout/graphs/spectrum.py | 2 +- src/sampletones_application/layout/loader.py | 3 +- .../layout/settings/master_gain.py | 2 +- .../layout/tabs/sequencer/colors.py | 2 +- .../parameters/reconstruction.py | 2 +- src/sampletones_application/paths.py | 2 +- .../ui/elements/pitch_stepper.py | 2 +- .../ui/themes/loader.py | 8 +- .../ui/themes/setup.py | 2 +- src/sampletones_application/ui/themes/spec.py | 2 +- src/sampletones_application/utils/palette.py | 135 ----------- .../utils/palette/__init__.py | 0 .../utils/palette/catalog.py | 80 +++++++ .../utils/palette/color.py | 44 ++++ .../utils/palette/palette.py | 62 +++++ .../utils/palette/reference.py | 57 +++++ src/sampletones_config/README.md | 5 +- .../layout/graphs/spectrum.yaml | 2 +- .../layout/tabs/sequencer/colors.yaml | 6 +- src/sampletones_config/palettes/dark.yaml | 219 ++++++++++++++++++ src/sampletones_config/palettes/light.yaml | 219 ++++++++++++++++++ .../palette.yaml => palettes/studio.yaml} | 3 +- src/sampletones_config/theme/converter.yaml | 2 +- .../theme/graphs/indicator.yaml | 2 +- .../theme/graphs/overlay.yaml | 2 +- .../theme/tables/instruments_row.yaml | 2 +- .../theme/tables/order.yaml | 2 +- .../theme/tables/pattern.yaml | 4 +- .../parameters/conftest.py | 6 +- .../reconstruction/test_instruments_panel.py | 8 +- .../ui/panels/sequencer/test_history_panel.py | 6 +- .../ui/themes/test_loader.py | 8 +- .../utils/palette/__init__.py | 0 .../utils/palette/test_catalog.py | 76 ++++++ .../utils/palette/test_color.py | 35 +++ .../utils/palette/test_palette.py | 47 ++++ .../utils/palette/test_reference.py | 33 +++ .../utils/test_palette.py | 92 -------- 44 files changed, 963 insertions(+), 298 deletions(-) delete mode 100644 src/sampletones_application/utils/palette.py create mode 100644 src/sampletones_application/utils/palette/__init__.py create mode 100644 src/sampletones_application/utils/palette/catalog.py create mode 100644 src/sampletones_application/utils/palette/color.py create mode 100644 src/sampletones_application/utils/palette/palette.py create mode 100644 src/sampletones_application/utils/palette/reference.py create mode 100644 src/sampletones_config/palettes/dark.yaml create mode 100644 src/sampletones_config/palettes/light.yaml rename src/sampletones_config/{layout/palette.yaml => palettes/studio.yaml} (99%) create mode 100644 tests/unit/sampletones_application/utils/palette/__init__.py create mode 100644 tests/unit/sampletones_application/utils/palette/test_catalog.py create mode 100644 tests/unit/sampletones_application/utils/palette/test_color.py create mode 100644 tests/unit/sampletones_application/utils/palette/test_palette.py create mode 100644 tests/unit/sampletones_application/utils/palette/test_reference.py delete mode 100644 tests/unit/sampletones_application/utils/test_palette.py diff --git a/docs/development/config-organization.md b/docs/development/config-organization.md index ce1ad1e3c..2d45b9caa 100644 --- a/docs/development/config-organization.md +++ b/docs/development/config-organization.md @@ -10,7 +10,7 @@ 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, 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. @@ -29,7 +29,7 @@ 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 +- `sampletones_application` owns the layout, theme, palettes, language, behavior, and deployment schemas. - `sampletones_core` owns the calibration schemas. - `sampletones_shared` owns the loader primitives (`load_yaml_model`, @@ -41,10 +41,16 @@ 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 +`application`, `behavior`, `calibration`, `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. + ### 3. The config tree mirrors the code The layout config is shaped like the code that reads it: its directory tree matches the @@ -54,9 +60,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 @@ -120,11 +126,13 @@ each value sits in the tree stays in the factory. | Calibration | `calibration/` | `CorpusConfig`, `RefereeConfig` (`sampletones_core/calibration/config/`) | each model's own `.load()` | | 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 active one is injected as validation **context**, so any +colour field in layout or theme resolves its tokens against it. `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. 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 +148,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 +162,10 @@ 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). `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. -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/src/sampletones/self_check.py b/src/sampletones/self_check.py index a2fd9d296..07dd64b83 100644 --- a/src/sampletones/self_check.py +++ b/src/sampletones/self_check.py @@ -5,7 +5,7 @@ from sampletones_shared.exceptions import SampleToNESError if TYPE_CHECKING: - from sampletones_application.utils.palette import Palette + from sampletones_application.utils.palette.catalog import PaletteCatalog CHECK_FAILURES: Final[Tuple[Type[Exception], ...]] = ( ImportError, @@ -36,11 +36,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 +63,29 @@ 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 _check_layout_config() -> str: + """Resolves the layout against every shipped palette, since each resolves 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 palette in _load_palette_catalog().palettes.values(): + load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, palette) + return f"{len(LayoutConfig.model_fields)} sections" def _check_themes() -> str: + """Resolves the theme set against every shipped palette, since each resolves 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, palette).load_all() for palette in _load_palette_catalog().palettes.values()] + return f"{len(themes[0])} themes" def _check_language() -> str: @@ -114,7 +118,7 @@ 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="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 b36732bda..ac24a92aa 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -51,7 +51,7 @@ DEPLOYMENT_CONFIG_PATH, LANG_EN, LAYOUT_DIRECTORY, - PALETTE_PATH, + PALETTES_DIRECTORY, THEME_DIRECTORY, ) from sampletones_application.services import ( @@ -102,7 +102,8 @@ from sampletones_application.utils.gui.dialogs import DialogsRenderer, get_dialog_tag from sampletones_application.utils.gui.keyboard import KeyRouter from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager -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.parallelization.background import ( stop_background_workers, ) @@ -162,7 +163,8 @@ def __init__( self.deployment: DeploymentConfig = DeploymentConfig.load(DEPLOYMENT_CONFIG_PATH) self._set_logging_level() - self._palette: Palette = Palette.load(PALETTE_PATH) + self._palette_catalog: PaletteCatalog = PaletteCatalog.load(PALETTES_DIRECTORY) + self._palette: Palette = self._palette_catalog.default self.layout: LayoutConfig = self._load_layout_config() self._setup_gui_elements() diff --git a/src/sampletones_application/layout/general/caret.py b/src/sampletones_application/layout/general/caret.py index 72571ea74..e7c0edc88 100644 --- a/src/sampletones_application/layout/general/caret.py +++ b/src/sampletones_application/layout/general/caret.py @@ -1,6 +1,6 @@ from pydantic import BaseModel -from sampletones_application.utils.palette import PaletteColor +from sampletones_application.utils.palette.color import PaletteColor class CaretLayout(BaseModel, extra="forbid", frozen=True): diff --git a/src/sampletones_application/layout/general/colors.py b/src/sampletones_application/layout/general/colors.py index c9ade8b49..b06f76bd5 100644 --- a/src/sampletones_application/layout/general/colors.py +++ b/src/sampletones_application/layout/general/colors.py @@ -1,6 +1,6 @@ from pydantic import BaseModel -from sampletones_application.utils.palette import PaletteColor +from sampletones_application.utils.palette.color import PaletteColor class TextColors(BaseModel, extra="forbid", frozen=True): diff --git a/src/sampletones_application/layout/graphs/colors.py b/src/sampletones_application/layout/graphs/colors.py index 03da2b2a0..08fab6dbd 100644 --- a/src/sampletones_application/layout/graphs/colors.py +++ b/src/sampletones_application/layout/graphs/colors.py @@ -1,6 +1,6 @@ from pydantic import BaseModel -from sampletones_application.utils.palette import PaletteColor +from sampletones_application.utils.palette.color import PaletteColor class GraphColors(BaseModel, extra="forbid", frozen=True): diff --git a/src/sampletones_application/layout/graphs/spectrum.py b/src/sampletones_application/layout/graphs/spectrum.py index 1d5760e09..d41971fd0 100644 --- a/src/sampletones_application/layout/graphs/spectrum.py +++ b/src/sampletones_application/layout/graphs/spectrum.py @@ -1,6 +1,6 @@ from pydantic import BaseModel -from sampletones_application.utils.palette import PaletteColor +from sampletones_application.utils.palette.color import PaletteColor class SpectrumLayout(BaseModel, extra="forbid", frozen=True): diff --git a/src/sampletones_application/layout/loader.py b/src/sampletones_application/layout/loader.py index 4779ae9b1..9f57c6895 100644 --- a/src/sampletones_application/layout/loader.py +++ b/src/sampletones_application/layout/loader.py @@ -14,7 +14,8 @@ 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.color import PALETTE_CONTEXT_KEY +from sampletones_application.utils.palette.palette import Palette from sampletones_shared.utils.serialization import load_yaml_model, load_yaml_model_dir diff --git a/src/sampletones_application/layout/settings/master_gain.py b/src/sampletones_application/layout/settings/master_gain.py index 8b5b86df0..c5d9f89b0 100644 --- a/src/sampletones_application/layout/settings/master_gain.py +++ b/src/sampletones_application/layout/settings/master_gain.py @@ -1,6 +1,6 @@ from pydantic import BaseModel -from sampletones_application.utils.palette import PaletteColor +from sampletones_application.utils.palette.color import PaletteColor class MasterGainLayout(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 index 22da79ab6..ea48c2efe 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors.py @@ -1,6 +1,6 @@ from pydantic import BaseModel -from sampletones_application.utils.palette import PaletteColor +from sampletones_application.utils.palette.color import PaletteColor class TrackerColors(BaseModel, extra="forbid", frozen=True): diff --git a/src/sampletones_application/parameters/reconstruction.py b/src/sampletones_application/parameters/reconstruction.py index 7cb9245dd..991a485d4 100644 --- a/src/sampletones_application/parameters/reconstruction.py +++ b/src/sampletones_application/parameters/reconstruction.py @@ -9,7 +9,7 @@ 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.color import PaletteColor @dataclass(frozen=True) diff --git a/src/sampletones_application/paths.py b/src/sampletones_application/paths.py index 93872f201..9bc55808a 100644 --- a/src/sampletones_application/paths.py +++ b/src/sampletones_application/paths.py @@ -7,7 +7,7 @@ APPLICATION_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "application" BEHAVIOR_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "behavior" 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/ui/elements/pitch_stepper.py b/src/sampletones_application/ui/elements/pitch_stepper.py index 6e504d8a2..dbf462415 100644 --- a/src/sampletones_application/ui/elements/pitch_stepper.py +++ b/src/sampletones_application/ui/elements/pitch_stepper.py @@ -25,7 +25,7 @@ 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.tooltip import show_tooltip -from sampletones_application.utils.palette import PaletteColor +from sampletones_application.utils.palette.color import PaletteColor from sampletones_core.utils.pitch_kind import PitchValueKind from sampletones_shared.types.application import Color from sampletones_shared.utils.callbacks import CallbackMixin diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py index 8aead166c..e71adaf30 100644 --- a/src/sampletones_application/ui/themes/loader.py +++ b/src/sampletones_application/ui/themes/loader.py @@ -27,11 +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.color import ColorSource +from sampletones_application.utils.palette.palette import Palette +from sampletones_application.utils.palette.reference import PaletteReference from sampletones_core.paths import EXT_FILE_YAML from sampletones_shared.types.application import ColorRGBA from sampletones_shared.utils.serialization import load_yaml diff --git a/src/sampletones_application/ui/themes/setup.py b/src/sampletones_application/ui/themes/setup.py index ed5031fb4..cc5597d63 100644 --- a/src/sampletones_application/ui/themes/setup.py +++ b/src/sampletones_application/ui/themes/setup.py @@ -2,7 +2,7 @@ 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.palette import Palette def setup_themes(theme_directory: Path, palette: Palette) -> None: diff --git a/src/sampletones_application/ui/themes/spec.py b/src/sampletones_application/ui/themes/spec.py index 5014a51d9..212f9fd08 100644 --- a/src/sampletones_application/ui/themes/spec.py +++ b/src/sampletones_application/ui/themes/spec.py @@ -4,7 +4,7 @@ from pydantic import BaseModel, Field -from sampletones_application.utils.palette import ColorSource +from sampletones_application.utils.palette.color import ColorSource class ThemeColorEntrySpec(BaseModel, frozen=True): diff --git a/src/sampletones_application/utils/palette.py b/src/sampletones_application/utils/palette.py deleted file mode 100644 index 51aad1545..000000000 --- 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 000000000..e69de29bb diff --git a/src/sampletones_application/utils/palette/catalog.py b/src/sampletones_application/utils/palette/catalog.py new file mode 100644 index 000000000..ce04fbb4d --- /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/color.py b/src/sampletones_application/utils/palette/color.py new file mode 100644 index 000000000..4cfd95aca --- /dev/null +++ b/src/sampletones_application/utils/palette/color.py @@ -0,0 +1,44 @@ +from typing import Annotated, Any, Final, Mapping, Union + +from pydantic import BeforeValidator, Field, ValidationInfo + +from sampletones_application.utils.palette.palette import Palette +from sampletones_application.utils.palette.reference import PaletteReference, is_reference +from sampletones_shared.types.application import ColorRGBA +from sampletones_shared.utils.color import RGBA, parse_hex_color + +PALETTE_CONTEXT_KEY: Final[str] = "palette" + +ColorSource = Annotated[Union[PaletteReference, RGBA], Field(union_mode="left_to_right")] + + +def palette_from_context(info: ValidationInfo) -> Palette: + """The palette a colour field resolves against, taken from the validation context. + + Raises: + ValueError: when the context omits the palette entry. + TypeError: when the context entry holds a value other than a 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 is_reference(text): + 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/palette.py b/src/sampletones_application/utils/palette/palette.py new file mode 100644 index 000000000..b523df969 --- /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 000000000..1e9438bdc --- /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_config/README.md b/src/sampletones_config/README.md index c82851f9e..8efc06916 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. @@ -21,7 +21,8 @@ The data package must not import a schema, and a schema package must not inline | `behavior/` | Non-visual runtime behavior | `BehaviorConfig` | | `calibration/` | DSP calibration tuning | `CorpusConfig`, `RefereeConfig` | | `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/layout/graphs/spectrum.yaml b/src/sampletones_config/layout/graphs/spectrum.yaml index 9dfc6d6f4..38a4ac1a6 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/tabs/sequencer/colors.yaml b/src/sampletones_config/layout/tabs/sequencer/colors.yaml index e60a452da..a01f1dcf8 100644 --- a/src/sampletones_config/layout/tabs/sequencer/colors.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/colors.yaml @@ -2,7 +2,7 @@ pattern_highlight: .pattern_highlight cell_cursor: .cell_cursor cursor_row: .cursor_row playback_row: .playback_row -label: .white +label: .contrast order: label: .order_label master: .order_master @@ -14,8 +14,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/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml new file mode 100644 index 000000000..e9d9b1726 --- /dev/null +++ b/src/sampletones_config/palettes/dark.yaml @@ -0,0 +1,219 @@ +name: dark + +colors: + # surfaces and neutrals + ground: "#17171a" + tab_strip: "#202024" + recess: "#2a2a2f" + surface: "#333338" + surface_alt: "#3d3d43" + surface_accent: "#3a3540" + menu: "#1f1f23" + status_bar: "#1f1f23" + popup: "#1e1e22" + frame: "#4a4a52" + frame_hovered: "#3e3e45" + frame_active: "#46464e" + border: "#45454c" + separator: "#55555e" + plot_background: "#1b1b1f" + well: "#202026" + + # tables + table_header: "#414149" + table_row: "#2e2e33" + table_row_alt: "#37373d" + table_border: "#52525b" + + # cool secondary (functional chrome: input focus, secondary emphasis) + cool: "#79a6e0" + cool_hover: "#92b8ea" + cool_active: "#628fc8" + cool_muted: "#3c4a5e" + + # accent + accent: "#b98af3" + accent_hover: "#a180ce" + accent_active: "#7b629e" + accent_muted: "#665780" + on_accent: "#17131f" + + # buttons + primary: "#8f6fc0" + primary_hover: "#a689d4" + primary_active: "#7d64ac" + primary_muted: "#63616a" + on_primary: "#f2f2f4" + + secondary: "#34343a" + secondary_hover: "#40404a" + secondary_active: "#4c4c58" + secondary_disabled: "#2b2b30" + + # danger (destructive actions: cancel, abort) + danger: "#a85555" + danger_hover: "#bd6a6a" + danger_active: "#8e4747" + on_danger: "#f2f2f4" + + # dialog windows (elevated surface + accent border/title) + dialog_surface: "#303036" + dialog_title: "#3a3a44" + + # channels: pulses orange, triangle blue, noise grey + channel_pulse1: "#f09256" + channel_pulse2: "#f2d15f" + channel_triangle: "#8cc1ed" + channel_noise: "#bbb8c2" + channel_pulse1_soft: "#e7c6aa" + channel_pulse2_soft: "#dfd6a8" + channel_triangle_soft: "#b9cedf" + channel_noise_soft: "#cbcace" + + # tabs + tab: "#232327" + tab_hovered: "#303038" + tab_active: "#3c3c48" + + # selection (tree, list, menu highlight) + selection: "#3c3c46" + selection_hovered: "#474753" + selection_active: "#52525f" + + # scrollbar + scrollbar_hovered: "#48484f" + + # buttons + button: "#4e4e58" + button_hovered: "#5d5d69" + button_active: "#6b6b79" + button_disabled: "#333338" + + # player + player_surface: "#26262c" + player_border: "#6a6a7a" + player_button: "#34343c" + player_button_hovered: "#3f3f49" + player_button_active: "#4c4c58" + player_button_disabled: "#2a2a30" + + # text + text: "#e8e8ea" + text_muted: "#9a9aa2" + text_disabled: "#78787f" + text_trace: "#c0c0c0" + + # emphasis and overlays + # contrast: the strongest foreground the surfaces carry + # overlay: tinted at a fraction to lift a row or region off its background + contrast: "#ffffff" + overlay: "#ffffff" + transparent: "#00000000" + border_strong: "#5a5a63" + input_invalid: "#c0504a64" + input_warning: "#c0884a64" + + # plot lines + plot_zero_line: "#c8c8c8" + + # 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: "#35353c" + button_flat_active: "#56565f" + button_flat_hovered: "#45454d" + button_flat_light: "#3e3e46" + + # layout: background fills + background_default: "#242424" + background_dark: "#1c1c1c" + background_light: "#2c2c2c" + background_menu: "#323232" + background_invalid: "#c0202064" + + # layout: properties table + properties_header: "#33333a" + properties_row: "#1f1f23" + properties_row_alt: "#26262c" + properties_border: "#43434c" + properties_label: "#a0a0aa" + properties_value: "#cbcbd2" + + # 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: "#1b1b1f" + + # layout: tracker cursor and playback + pattern_highlight: "#ffffff40" + cell_cursor: "#66bbffa0" + cursor_row: "#ffffff18" + playback_row: "#64dc6440" + + # layout: order table + order_label: "#33333a" + 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: "#0a0a0a10" + + # 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" + tracker_frame: "#22ccffff" + tracker_row: "#a0a0a0ff" + tracker_order: "#c8d0e0ff" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml new file mode 100644 index 000000000..e3c0f822a --- /dev/null +++ b/src/sampletones_config/palettes/light.yaml @@ -0,0 +1,219 @@ +name: light + +colors: + # surfaces and neutrals + ground: "#e6e6ea" + tab_strip: "#dcdce2" + recess: "#dedee4" + surface: "#f5f5f8" + surface_alt: "#fdfdff" + surface_accent: "#ede6fa" + menu: "#e0e5f0" + status_bar: "#e0e5f0" + popup: "#fdfdff" + frame: "#ffffff" + frame_hovered: "#eff0f5" + frame_active: "#e5e6f0" + border: "#c2c2cc" + separator: "#b4b4c2" + plot_background: "#f7f7fa" + well: "#e6ecf6" + + # tables + table_header: "#dcd6ec" + table_row: "#f3f3f7" + table_row_alt: "#e9e9f0" + table_border: "#c6c2d4" + + # cool secondary (functional chrome: input focus, secondary emphasis) + cool: "#2f6fd0" + cool_hover: "#1f5cb8" + cool_active: "#17488f" + cool_muted: "#b7cae9" + + # accent + accent: "#6b3fb0" + accent_hover: "#7f52c4" + accent_active: "#55308c" + accent_muted: "#d5c4ee" + on_accent: "#ffffff" + + # buttons + primary: "#7a55b8" + primary_hover: "#8a67c6" + primary_active: "#644497" + primary_muted: "#c1bad0" + on_primary: "#ffffff" + + secondary: "#dee2ea" + secondary_hover: "#ced5e1" + secondary_active: "#bcc5d4" + secondary_disabled: "#ebebf0" + + # danger (destructive actions: cancel, abort) + danger: "#bf4646" + danger_hover: "#d05a5a" + danger_active: "#9b3737" + on_danger: "#ffffff" + + # dialog windows (elevated surface + accent border/title) + dialog_surface: "#f2f2f7" + dialog_title: "#d7deee" + + # channels: pulses orange, triangle blue, noise grey + channel_pulse1: "#c25a12" + channel_pulse2: "#96790a" + channel_triangle: "#2a6ba4" + channel_noise: "#66646e" + channel_pulse1_soft: "#9c6f45" + channel_pulse2_soft: "#847540" + channel_triangle_soft: "#557c9a" + channel_noise_soft: "#8a8890" + + # tabs + tab: "#dde0e9" + tab_hovered: "#cad3e5" + tab_active: "#b6c4df" + + # selection (tree, list, menu highlight) + selection: "#dbd2ee" + selection_hovered: "#ccc0e5" + selection_active: "#bcaddc" + + # scrollbar + scrollbar_hovered: "#bebec9" + + # buttons + button: "#ccd4e5" + button_hovered: "#bcc7dd" + button_active: "#aabad5" + button_disabled: "#e2e2e8" + + # player + player_surface: "#e2e8f4" + player_border: "#3d7fc4" + player_button: "#d2daed" + player_button_hovered: "#c0cde5" + player_button_active: "#adbedc" + player_button_disabled: "#e6eaf3" + + # text + text: "#1e1e24" + text_muted: "#5c5c68" + text_disabled: "#92929c" + text_trace: "#4a4a4a" + + # emphasis and overlays + # contrast: the strongest foreground the surfaces carry + # overlay: tinted at a fraction to lift a row or region off its background + contrast: "#16161c" + overlay: "#000000" + transparent: "#00000000" + border_strong: "#a6a6b2" + input_invalid: "#c0504a48" + input_warning: "#c0884a48" + + # plot lines + plot_zero_line: "#909098" + + # file-tree nodes + file_wave: "#0d6ea8" + file_library: "#1c7a34" + file_reconstruction: "#4a4ab4" + file_muted: "#7c7c86" + + # favourites + favorite: "#b07d0a" + favorite_child: "#8d7a45" + + # instruction-library nodes + library_generator: "#2c6e3c" + library_group: "#1c6a70" + library_instruction: "#4c4c54" + library_root: "#33333a" + + # layout: content text + text_default: "#26262c" + text_inactive: "#84848e" + text_error: "#c03030" + text_highlight: "#9a6a00" + + # layout: flat control buttons + button_flat: "#dcdce4" + button_flat_active: "#b8b8c8" + button_flat_hovered: "#cbcbda" + button_flat_light: "#d2d2e0" + + # layout: background fills + background_default: "#f0f0f2" + background_dark: "#e2e2e6" + background_light: "#fafafc" + background_menu: "#e8e8ec" + background_invalid: "#c0202038" + + # layout: properties table + properties_header: "#dbe0ec" + properties_row: "#f4f5f8" + properties_row_alt: "#eaebf1" + properties_border: "#c3c8d6" + properties_label: "#3f5480" + properties_value: "#2c3140" + + # layout: path links + path_link: "#1f5fd0" + path_link_hover: "#0d3f9c" + + # layout: section headers + header_library: "#2c7a44" + header_reconstruction: "#6b3fb0" + + # layout: instruction features + feature_volume: "#1f8038" + feature_arpeggio: "#c05a18" + feature_pitch: "#0d6ea8" + feature_duty_cycle: "#96700a" + + # layout: caret overlay + caret_fill: "#3a3ac060" + caret_border: "#2a5fb0ff" + + # layout: graphs and waveforms + graph_bar: "#1c72ac" + waveform_sample: "#1c72ac" + waveform_reconstruction: "#b07d0a" + waveform_overlay: "#00000018" + spectrum_dim: "#f7f7fa" + + # layout: tracker cursor and playback + pattern_highlight: "#00000018" + cell_cursor: "#2a7fd090" + cursor_row: "#0000000f" + playback_row: "#1f903838" + + # layout: order table + order_label: "#dbe0ec" + order_master: "#1c8cc018" + order_master_divider: "#1c8cc028" + order_column_current: "#00000014" + order_column_playing: "#1f903828" + + # layout: sample column + sample_column: "#1c8cc022" + sample_divider: "#1c8cc028" + + # layout: muted channel column + channel_muted: "#9a9aa614" + + # layout: history detail + history_future: "#8c8c94ff" + history_channel: "#1f5fb0ff" + history_value: "#33333aff" + history_separator: "#9a9aa2ff" + + # layout: tracker text (instrument and sample share the reference yellow) + tracker_reference: "#8a6a00ff" + tracker_transpose: "#4c4c54ff" + tracker_volume: "#1f8038ff" + tracker_frame: "#0d6ea8ff" + tracker_row: "#70707aff" + tracker_order: "#3a4055ff" diff --git a/src/sampletones_config/layout/palette.yaml b/src/sampletones_config/palettes/studio.yaml similarity index 99% rename from src/sampletones_config/layout/palette.yaml rename to src/sampletones_config/palettes/studio.yaml index b29f36475..3c99bc1d0 100644 --- a/src/sampletones_config/layout/palette.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -104,7 +104,8 @@ colors: text_trace: "#c0c0c0" # emphasis and overlays - white: "#ffffff" + contrast: "#ffffff" + overlay: "#ffffff" transparent: "#00000000" border_strong: "#565f78" input_invalid: "#c0504a64" diff --git a/src/sampletones_config/theme/converter.yaml b/src/sampletones_config/theme/converter.yaml index 49868711f..f036c38bf 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/graphs/indicator.yaml b/src/sampletones_config/theme/graphs/indicator.yaml index 2e1220078..f51896d4e 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 31a967cf6..d50e180bf 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 d85edb47d..276e88836 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 6dbf4ee16..3e34fb310 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 b5eecfa3c..3953ce51c 100644 --- a/src/sampletones_config/theme/tables/pattern.yaml +++ b/src/sampletones_config/theme/tables/pattern.yaml @@ -10,7 +10,7 @@ components: y: 4 - type: color key: HeaderHovered - value: .white/0.25 + value: .overlay/0.25 - type: color key: HeaderActive value: .transparent @@ -24,4 +24,4 @@ components: entries: - type: color key: Text - value: .white + value: .contrast diff --git a/tests/unit/sampletones_application/parameters/conftest.py b/tests/unit/sampletones_application/parameters/conftest.py index d5b15f2dd..265e61022 100644 --- a/tests/unit/sampletones_application/parameters/conftest.py +++ b/tests/unit/sampletones_application/parameters/conftest.py @@ -2,10 +2,10 @@ 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 @pytest.fixture def layout_config() -> LayoutConfig: - return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, Palette.load(PALETTE_PATH)) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, PaletteCatalog.load(PALETTES_DIRECTORY).default) 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 4c13c0338..0837766e7 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,7 +24,7 @@ ) 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_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS @@ -33,13 +33,13 @@ @pytest.fixture def layout_config() -> LayoutConfig: - return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, Palette.load(PALETTE_PATH)) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, PaletteCatalog.load(PALETTES_DIRECTORY).default) @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, PaletteCatalog.load(PALETTES_DIRECTORY).default) GUIPanel.configure_section_header( layout_config.glyphs, layout_config.general.section_header, 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 08b1d801f..f99f8b3ec 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,10 @@ 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.view_model.sequencer.history import ( HistoryEntryViewModel, HistoryViewModel, @@ -18,7 +18,7 @@ @pytest.fixture def layout_config() -> LayoutConfig: - return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, Palette.load(PALETTE_PATH)) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, PaletteCatalog.load(PALETTES_DIRECTORY).default) @pytest.fixture diff --git a/tests/unit/sampletones_application/ui/themes/test_loader.py b/tests/unit/sampletones_application/ui/themes/test_loader.py index 945d3ee22..ee7abe8a9 100644 --- a/tests/unit/sampletones_application/ui/themes/test_loader.py +++ b/tests/unit/sampletones_application/ui/themes/test_loader.py @@ -4,12 +4,13 @@ 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 _BASE_NAME = "default" @@ -68,7 +69,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()} + palette = PaletteCatalog.load(PALETTES_DIRECTORY).default + return {theme.tag: theme for theme in ThemeLoader(THEME_DIRECTORY, palette).load_all()} def test_every_theme_carries_the_base_table_border(self, themes: Dict[str, Theme]) -> None: dpg.create_context() 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 000000000..e69de29bb 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 000000000..78e04414f --- /dev/null +++ b/tests/unit/sampletones_application/utils/palette/test_catalog.py @@ -0,0 +1,76 @@ +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_color.py b/tests/unit/sampletones_application/utils/palette/test_color.py new file mode 100644 index 000000000..6ab8d10c9 --- /dev/null +++ b/tests/unit/sampletones_application/utils/palette/test_color.py @@ -0,0 +1,35 @@ +import pytest +from pydantic import BaseModel, ValidationError + +from sampletones_application.utils.palette.color import PALETTE_CONTEXT_KEY, PaletteColor +from sampletones_application.utils.palette.palette import Palette + + +class _Swatch(BaseModel, frozen=True): + color: PaletteColor + + +@pytest.fixture +def palette() -> Palette: + return Palette.model_validate({"name": "test", "colors": {"accent": "#a97fe3", "overlay": "#ffffff40"}}) + + +class TestPaletteColor: + 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"}) + + def test_a_palette_context_of_the_wrong_type_raises(self) -> None: + with pytest.raises(TypeError): + _Swatch.model_validate({"color": ".accent"}, context={PALETTE_CONTEXT_KEY: "studio"}) 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 000000000..a45d5c592 --- /dev/null +++ b/tests/unit/sampletones_application/utils/palette/test_palette.py @@ -0,0 +1,47 @@ +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 000000000..97a69ad10 --- /dev/null +++ b/tests/unit/sampletones_application/utils/palette/test_reference.py @@ -0,0 +1,33 @@ +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/test_palette.py b/tests/unit/sampletones_application/utils/test_palette.py deleted file mode 100644 index f2cd4fce9..000000000 --- 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"}) From eb4cf17b4ddc87b7a68d99afcc979309d92120a6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 6 Aug 2026 20:30:14 +0200 Subject: [PATCH 002/152] Added: dynamic palette resolution --- .pre-commit-config.yaml | 8 + Makefile | 5 +- docs/development/config-organization.md | 5 +- scripts/checks/palette_colors.py | 159 ++++++++++++++++++ src/sampletones/self_check.py | 19 ++- src/sampletones_application/application.py | 8 +- src/sampletones_application/layout/loader.py | 8 +- .../ui/elements/graphs/bar.py | 15 +- .../ui/elements/graphs/layers/array.py | 4 +- .../ui/elements/graphs/layers/bar.py | 4 +- .../ui/elements/graphs/layers/instruction.py | 4 +- .../ui/elements/graphs/layers/spectrum.py | 6 +- .../ui/elements/graphs/spectrum.py | 21 +-- .../ui/elements/graphs/waveform.py | 8 +- .../ui/elements/path.py | 19 ++- .../ui/elements/pitch_stepper.py | 5 +- .../ui/elements/table/caret.py | 28 ++- .../ui/elements/table/table.py | 11 +- .../ui/elements/tree/colors.py | 12 +- .../ui/elements/tree/tree.py | 11 +- .../ui/panels/dialogs/audio_settings.py | 2 +- .../ui/panels/reconstruction/audio.py | 4 +- .../reconstruction/instruments/config.py | 12 +- .../ui/panels/sequencer/columns.py | 4 +- .../ui/panels/sequencer/grid.py | 36 ++-- .../ui/panels/sequencer/history.py | 8 +- .../ui/panels/sequencer/order.py | 30 ++-- .../ui/panels/sequencer/samples.py | 4 +- .../ui/themes/loader.py | 20 +-- .../ui/themes/registry.py | 7 +- .../ui/themes/setup.py | 6 +- src/sampletones_application/ui/themes/spec.py | 4 +- .../ui/themes/style.py | 4 +- .../ui/themes/theme.py | 50 ++++-- .../utils/gui/dialogs.py | 8 +- .../utils/palette/color.py | 104 +++++++++--- .../utils/palette/source.py | 36 ++++ .../parameters/conftest.py | 4 +- .../ui/elements/graphs/test_waveform.py | 9 +- .../ui/elements/table/test_caret.py | 11 ++ .../reconstruction/test_instruments_panel.py | 6 +- .../ui/panels/sequencer/test_grid_channels.py | 11 +- .../ui/panels/sequencer/test_grid_rows.py | 17 +- .../ui/panels/sequencer/test_history_panel.py | 4 +- .../panels/sequencer/test_order_channels.py | 11 +- .../ui/themes/test_loader.py | 7 +- .../ui/themes/test_registry.py | 45 +++++ .../ui/themes/test_theme.py | 123 ++++++++++++++ .../utils/palette/test_color.py | 72 ++++++-- .../utils/palette/test_source.py | 56 ++++++ .../scripts/checks/test_palette_colors.py | 79 +++++++++ 51 files changed, 905 insertions(+), 249 deletions(-) create mode 100755 scripts/checks/palette_colors.py create mode 100644 src/sampletones_application/utils/palette/source.py create mode 100644 tests/unit/sampletones_application/ui/themes/test_registry.py create mode 100644 tests/unit/sampletones_application/ui/themes/test_theme.py create mode 100644 tests/unit/sampletones_application/utils/palette/test_source.py create mode 100644 tests/unit/scripts/checks/test_palette_colors.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 51492d915..81c99f203 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -57,6 +57,14 @@ repos: files: ^src/sampletones_application/tags/ 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 name: language keys entry: uv run scripts/checks/language_keys.py diff --git a/Makefile b/Makefile index 21d2e81e3..72b13e45c 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),) @@ -118,6 +118,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/docs/development/config-organization.md b/docs/development/config-organization.md index 2d45b9caa..be92660b6 100644 --- a/docs/development/config-organization.md +++ b/docs/development/config-organization.md @@ -129,8 +129,9 @@ each value sits in the tree stays in the factory. | 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 palettes load first, and the active one is injected as validation **context**, so any -colour field in layout or theme resolves its tokens against it. `PaletteCatalog` names the +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. diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py new file mode 100755 index 000000000..31b66b9de --- /dev/null +++ b/scripts/checks/palette_colors.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 + +""" +Checks that a colour stays a palette token until the moment it is drawn with. + +`PaletteColor.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 two ways that contract is lost: an attribute assigned the +resolved value, 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 re +import sys +from pathlib import Path +from typing import Final, Iterator, List, NamedTuple, Sequence, Tuple + +from sampletones_shared.meta.source.modules import SourceModule, discover_modules +from sampletones_shared.paths import REPOSITORY_ROOT + +SOURCE_ROOT: Final[Path] = REPOSITORY_ROOT / "src" +APPLICATION_PACKAGE: Final[Path] = SOURCE_ROOT / "sampletones_application" +CONFIG_PACKAGE: Final[Path] = SOURCE_ROOT / "sampletones_config" +PALETTES_DIRECTORY: Final[Path] = CONFIG_PACKAGE / "palettes" + +COLOR_PROPERTY: Final[str] = "rgba" +SELF_NAMES: Final[Tuple[str, ...]] = ("self", "cls") + +CONFIG_PATTERN: Final[str] = "*.yaml" +HEX_COLOR: Final[re.Pattern[str]] = re.compile(r"[\"']#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?[\"']") + + +class ColorFinding(NamedTuple): + """One place a colour stops following the palette, and what to do about it.""" + + location: str + message: str + + +def _assigned_targets(statement: ast.stmt) -> Tuple[ast.expr, ...]: + if isinstance(statement, ast.Assign): + return tuple(statement.targets) + + if isinstance(statement, ast.AnnAssign): + return (statement.target,) + + return () + + +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 ast.walk(module.tree): + value = getattr(statement, "value", None) + if value is None or not _resolves_a_color(value): + continue + + if not isinstance(statement, ast.stmt): + 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 PaletteColor and read " + f".{COLOR_PROPERTY} where the colour reaches DearPyGui" + ), + ) + + +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_stored_colors(package: Path) -> List[ColorFinding]: + return [finding for module in discover_modules([package]) for finding in stored_colors(module)] + + +def find_literal_colors(package: Path, palettes: Path) -> List[ColorFinding]: + return [ + finding + for path in sorted(package.rglob(CONFIG_PATTERN)) + 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.""" + 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_PACKAGE, + 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_stored_colors(arguments.package) + 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/src/sampletones/self_check.py b/src/sampletones/self_check.py index 07dd64b83..922765a92 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.catalog import PaletteCatalog + from sampletones_application.utils.palette.source import PaletteSource CHECK_FAILURES: Final[Tuple[Type[Exception], ...]] = ( ImportError, @@ -68,23 +69,29 @@ def _check_palettes() -> str: 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_layout_config() -> str: - """Resolves the layout against every shipped palette, since each resolves the colour tokens itself.""" + """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 - for palette in _load_palette_catalog().palettes.values(): - load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, 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 resolves the colour tokens itself.""" + """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, palette).load_all() for palette in _load_palette_catalog().palettes.values()] + themes = [ThemeLoader(THEME_DIRECTORY, source).load_all() for source in _palette_sources()] return f"{len(themes[0])} themes" diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index ac24a92aa..6d36fb9fd 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -103,7 +103,7 @@ from sampletones_application.utils.gui.keyboard import KeyRouter from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager 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, ) @@ -164,7 +164,7 @@ def __init__( self._set_logging_level() self._palette_catalog: PaletteCatalog = PaletteCatalog.load(PALETTES_DIRECTORY) - self._palette: Palette = self._palette_catalog.default + self._palette_source: PaletteSource = PaletteSource(self._palette_catalog.default) self.layout: LayoutConfig = self._load_layout_config() self._setup_gui_elements() @@ -437,7 +437,7 @@ 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 @@ -450,7 +450,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 diff --git a/src/sampletones_application/layout/loader.py b/src/sampletones_application/layout/loader.py index 9f57c6895..5a7ae28ca 100644 --- a/src/sampletones_application/layout/loader.py +++ b/src/sampletones_application/layout/loader.py @@ -14,17 +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.color import PALETTE_CONTEXT_KEY -from sampletones_application.utils.palette.palette import Palette +from sampletones_application.utils.palette.color 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/ui/elements/graphs/bar.py b/src/sampletones_application/ui/elements/graphs/bar.py index a97180db9..3ffd607a8 100644 --- a/src/sampletones_application/ui/elements/graphs/bar.py +++ b/src/sampletones_application/ui/elements/graphs/bar.py @@ -24,7 +24,8 @@ dpg_delete_item, dpg_is_item_hovered, ) -from sampletones_shared.types.application import Color, Sender +from sampletones_application.utils.palette.color import PaletteColor +from sampletones_shared.types.application import Sender from sampletones_shared.utils.arrays import interpolate_segment OnBarPointClickedCallback = Callable[[np.ndarray], None] @@ -154,7 +155,7 @@ def _bind_theme( with dpg.theme_component(dpg.mvBarSeries): dpg.add_theme_color( dpg.mvPlotCol_Fill, - layer.color, + layer.color.rgba, category=dpg.mvThemeCat_Plots, ) @@ -168,12 +169,8 @@ 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, - ) + red, green, blue, _ = layer.color.rgba + hover_color = (red, green, blue, self._hover_alpha) with dpg.theme(tag=self.hover_theme_tag): with dpg.theme_component(dpg.mvBarSeries): dpg.add_theme_color( @@ -188,7 +185,7 @@ def load_data( self, data: np.ndarray, name: str, - color: Color, + color: PaletteColor, y_ticks: Optional[Tuple[int, ...]] = None, ) -> None: self._delete_hover_bar() diff --git a/src/sampletones_application/ui/elements/graphs/layers/array.py b/src/sampletones_application/ui/elements/graphs/layers/array.py index 41ad76727..6f6eb72fb 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.color import PaletteColor 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: PaletteColor 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 e1a21df94..d2eb2b8bb 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.color import PaletteColor @dataclass(frozen=True) class BarLayer(Layer): data: np.ndarray name: str - color: Color + color: PaletteColor 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 6dfebe4ca..747b7e8e6 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.color import PaletteColor 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: PaletteColor 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 01bcc5f07..1b1c8e3bc 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.color import PaletteColor 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: PaletteColor + color_bright: PaletteColor 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 a93d508c1..041ba9917 100644 --- a/src/sampletones_application/ui/elements/graphs/spectrum.py +++ b/src/sampletones_application/ui/elements/graphs/spectrum.py @@ -14,10 +14,12 @@ dpg_bind_item_theme, dpg_delete_children, ) +from sampletones_application.utils.palette.color import PaletteColor 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.utils.color import MAX_CHANNEL_VALUE, blend class GUISpectrumGraph(GUIGraph[SpectrumLayer]): @@ -101,8 +103,8 @@ 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, ) ) @@ -122,14 +124,13 @@ 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: PaletteColor, + color_bright: PaletteColor, + brightness: float, + ) -> str: + color = blend(color_dim.rgba, color_bright.rgba, brightness / MAX_CHANNEL_VALUE) if color in self.themes: return self.themes[color] diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index f795fadd0..2bb09d781 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -29,7 +29,7 @@ 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.types.application import Color, ColorRGBA, Sender from sampletones_shared.utils.color import to_grayscale, with_alpha_fraction @@ -350,7 +350,7 @@ def _update_display(self) -> None: self._upsert_series(series_tag, layer) self._bind_series_theme(series_tag, self._series_color(layer)) - def _series_color(self, layer: Union[ArrayLayer, InstructionLayer]) -> Color: + def _series_color(self, layer: Union[ArrayLayer, InstructionLayer]) -> ColorRGBA: """Resolves a layer's line colour, greying the reconstruction while a regeneration runs. The dimmed reconstruction is desaturated to gray and faded, so the drawn waveform — not just @@ -358,11 +358,11 @@ def _series_color(self, layer: Union[ArrayLayer, InstructionLayer]) -> Color: """ if self._reconstruction_dimmed and layer.name == self._lbl_waveform_reconstruction: return with_alpha_fraction( - to_grayscale(self._layout.colors.waveform_reconstruction), + to_grayscale(self._layout.colors.waveform_reconstruction.rgba), self._layout.waveform.reconstruction_dim_opacity, ) - return layer.color + return layer.color.rgba def _prune_stale_series(self) -> None: """Aligns the y-axis series with the current layers, keeping the position indicator diff --git a/src/sampletones_application/ui/elements/path.py b/src/sampletones_application/ui/elements/path.py index c12944de9..b4a6eb43f 100644 --- a/src/sampletones_application/ui/elements/path.py +++ b/src/sampletones_application/ui/elements/path.py @@ -15,7 +15,8 @@ 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.tooltip import show_tooltip -from sampletones_shared.types.application import Color, Sender +from sampletones_application.utils.palette.color import PaletteColor +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 +34,8 @@ def __init__( tag: str, path: Optional[Path], parent: str, - color: Color, - hover_color: Color, + color: PaletteColor, + hover_color: PaletteColor, status_message: str, prefix: Optional[str] = None, font: Optional[Font] = None, @@ -78,7 +79,7 @@ def _create_text(self) -> None: self.display_text, tag=self.tag, parent=parent, - color=self.color, + color=self.color.rgba, ) if self.font is not None: @@ -113,13 +114,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.configure_item(self.tag, color=self.hover_color.rgba) FrameCallbackManager.set_frame_callback( self._on_hover, 2, ) else: - dpg.configure_item(self.tag, color=self.color) + dpg.configure_item(self.tag, color=self.color.rgba) def _on_clicked(self) -> None: if not self.path.exists(): @@ -137,11 +138,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.configure_item(self.tag, color=self.color.rgba) 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: PaletteColor) -> 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 +153,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.configure_item(self.tag, color=color.rgba) 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 dbf462415..53f40b00b 100644 --- a/src/sampletones_application/ui/elements/pitch_stepper.py +++ b/src/sampletones_application/ui/elements/pitch_stepper.py @@ -27,7 +27,6 @@ from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.utils.palette.color import PaletteColor from sampletones_core.utils.pitch_kind import PitchValueKind -from sampletones_shared.types.application import Color from sampletones_shared.utils.callbacks import CallbackMixin @@ -78,7 +77,7 @@ def __init__( status_bar: GUIStatusBar, layout: PitchStepperLayout, plus_minus_layout: PlusMinusButtonsLayout, - value_color: Color, + value_color: PaletteColor, ) -> None: self.on_value_changed: Optional[Callable[[int], None]] = None self._status_bar = status_bar @@ -154,7 +153,7 @@ def _build(self) -> None: dpg.add_text( str(self._value), tag=self._value_tag, - color=self._value_color, + color=self._value_color.rgba, ) FontRegistry.bind_to_item(self._value_tag, Font.MONO) with dpg.table_cell(): diff --git a/src/sampletones_application/ui/elements/table/caret.py b/src/sampletones_application/ui/elements/table/caret.py index 87bd3c444..b5a5f9ed1 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] @@ -39,10 +39,7 @@ class CaretOverlay(metaclass=NonInstantiableMeta): 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 a5791b9cd..3173308de 100644 --- a/src/sampletones_application/ui/elements/table/table.py +++ b/src/sampletones_application/ui/elements/table/table.py @@ -10,8 +10,9 @@ 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.palette.color import PaletteColor 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 @@ -24,8 +25,8 @@ def __init__( rows: Tuple[TableCell, ...], *, label_column_width: int, - label_color: Color, - value_color: Color, + label_color: PaletteColor, + value_color: PaletteColor, parent: Optional[str] = None, before: Optional[str] = None, header_row: bool = False, @@ -116,12 +117,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.configure_item(label_text, color=self._label_color.rgba) 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.configure_item(value_text, color=self._value_color.rgba) self._values.append(value_text) @classmethod diff --git a/src/sampletones_application/ui/elements/tree/colors.py b/src/sampletones_application/ui/elements/tree/colors.py index 8c8fb19b0..710347b9a 100644 --- a/src/sampletones_application/ui/elements/tree/colors.py +++ b/src/sampletones_application/ui/elements/tree/colors.py @@ -2,7 +2,7 @@ from typing import Self from sampletones_application.layout.general.colors import GeneralColors -from sampletones_shared.types.application import ColorRGBA +from sampletones_application.utils.palette.color import PaletteColor @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: PaletteColor + node: PaletteColor + muted: PaletteColor + accent: PaletteColor @classmethod - def create(cls, colors: GeneralColors, *, accent: ColorRGBA) -> Self: + def create(cls, colors: GeneralColors, *, accent: PaletteColor) -> 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/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index c84f6804c..d4064510f 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -53,6 +53,7 @@ create_detail_tooltip, populate_detail_tooltip, ) +from sampletones_application.utils.palette.color import PaletteColor from sampletones_application.utils.parallelization.thread import ( BackgroundWorkCancelled, SingleThreadExecutor, @@ -73,7 +74,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, @@ -487,7 +488,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) -> PaletteColor: if self._logic.is_node_favorite(node): return self._colors.favorite @@ -514,10 +515,10 @@ 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, color=color.rgba) 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), color=color.rgba) FontRegistry.bind_to_item(text, Font.BOLD) def _node_detail_items(self, node: TreeNode) -> List[Tuple[str, str]]: @@ -562,7 +563,7 @@ 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}", color=self._colors.muted.rgba) FontRegistry.bind_to_item(detail_text, Font.MONO_SMALL) def _add_context_menu_play_item(self, node: FileSystemNode) -> None: diff --git a/src/sampletones_application/ui/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py index a2f52f907..037deb33c 100644 --- a/src/sampletones_application/ui/panels/dialogs/audio_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/audio_settings.py @@ -231,7 +231,7 @@ 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) + return blend(colors.label_color.rgba, colors.clip_color.rgba, clip_fraction) @table_wrapper(columns=2) def _create_action_buttons(self) -> None: diff --git a/src/sampletones_application/ui/panels/reconstruction/audio.py b/src/sampletones_application/ui/panels/reconstruction/audio.py index 49109b9a5..846f23475 100644 --- a/src/sampletones_application/ui/panels/reconstruction/audio.py +++ b/src/sampletones_application/ui/panels/reconstruction/audio.py @@ -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.color import PaletteColor 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: PaletteColor, initial_collapsed: bool = False, language_manager: LanguageManager, status_bar: GUIStatusBar, diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/config.py b/src/sampletones_application/ui/panels/reconstruction/instruments/config.py index 0c8f8e72b..2732bf8ce 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/config.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/config.py @@ -3,16 +3,16 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.general.colors import FeatureColors +from sampletones_application.utils.palette.color import PaletteColor 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: PaletteColor 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, PaletteColor]: 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, PaletteColor], ) -> Dict[LibraryGeneratorName, Dict[FeatureKey, FeaturePlotConfig]]: configs: Dict[LibraryGeneratorName, Dict[FeatureKey, FeaturePlotConfig]] = {} for kind in LibraryGeneratorName: @@ -77,7 +77,7 @@ def _build_plot_configs( def _build_kind_plot_configs( kind: LibraryGeneratorName, labels: Dict[FeatureKey, str], - colors: Dict[FeatureKey, Color], + colors: Dict[FeatureKey, PaletteColor], ) -> Dict[FeatureKey, FeaturePlotConfig]: kind_configs: Dict[FeatureKey, FeaturePlotConfig] = {} for feature_key in supported_features(kind): @@ -89,7 +89,7 @@ def _build_plot_config( kind: LibraryGeneratorName, feature_key: FeatureKey, labels: Dict[FeatureKey, str], - colors: Dict[FeatureKey, Color], + colors: Dict[FeatureKey, PaletteColor], ) -> FeaturePlotConfig: data = feature_range(kind, feature_key) return FeaturePlotConfig( diff --git a/src/sampletones_application/ui/panels/sequencer/columns.py b/src/sampletones_application/ui/panels/sequencer/columns.py index 0cd8dcf99..98885a542 100644 --- a/src/sampletones_application/ui/panels/sequencer/columns.py +++ b/src/sampletones_application/ui/panels/sequencer/columns.py @@ -2,9 +2,9 @@ from sampletones_application.layout.tabs.sequencer.colors import ChannelColors from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.utils.palette.color import PaletteColor 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) -> PaletteColor: match generator: case GeneratorName.PULSE1: return colors.pulse1 diff --git a/src/sampletones_application/ui/panels/sequencer/grid.py b/src/sampletones_application/ui/panels/sequencer/grid.py index 82609334f..0db105d84 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid.py +++ b/src/sampletones_application/ui/panels/sequencer/grid.py @@ -281,7 +281,7 @@ def _setup_handlers(self) -> None: def _create_themes(self) -> None: self._create_subcolumn_themes() self._create_header_themes() - self._row_number_theme = create_selectable_text_theme(self._layout.colors.text.row) + self._row_number_theme = create_selectable_text_theme(self._layout.colors.text.row.rgba) def _create_subcolumn_themes(self) -> None: """Builds each subcolumn's text theme in its full and its dimmed colour. @@ -297,9 +297,9 @@ def _create_subcolumn_themes(self) -> None: } fraction = self._layout.tracker.muted_text_fraction for subcolumn, color in theme_colors.items(): - self._subcolumn_themes[subcolumn] = create_selectable_text_theme(color) + self._subcolumn_themes[subcolumn] = create_selectable_text_theme(color.rgba) self._muted_subcolumn_themes[subcolumn] = create_selectable_text_theme( - with_alpha_fraction(color, fraction), + with_alpha_fraction(color.rgba, fraction), ) def _create_header_themes(self) -> None: @@ -310,14 +310,14 @@ def _create_header_themes(self) -> None: """ header = self._layout.colors.header self._header_theme = create_header_selectable_theme( - self._layout.colors.label, - header.hovered, - header.active, + self._layout.colors.label.rgba, + header.hovered.rgba, + header.active.rgba, ) self._muted_header_theme = create_header_selectable_theme( - self._layout.colors.muted.text, - header.hovered, - header.active, + self._layout.colors.muted.text.rgba, + header.hovered.rgba, + header.active.rgba, ) def _create_tracker_view(self, parent: str) -> None: @@ -437,12 +437,12 @@ def _highlight_sample_column(self) -> None: dpg.highlight_table_column( TAG_SEQUENCER_GRID_TABLE_TRACKER, SAMPLE_TABLE_COLUMN, - self._layout.colors.sample.column, + self._layout.colors.sample.column.rgba, ) dpg.highlight_table_column( TAG_SEQUENCER_GRID_TABLE_TRACKER, DIVIDER_TABLE_COLUMN, - self._layout.colors.sample.divider, + self._layout.colors.sample.divider.rgba, ) def _highlight_header_row(self) -> None: @@ -457,7 +457,7 @@ def _highlight_header_row(self) -> None: TAG_SEQUENCER_GRID_TABLE_TRACKER, HEADER_TABLE_ROW, column, - color=self._layout.colors.header.background, + color=self._layout.colors.header.background.rgba, ) def _tint_channel_columns(self) -> None: @@ -477,10 +477,10 @@ def _tint_channel_columns(self) -> None: 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), + channel_color(self._layout.colors.channels, generator).rgba, self._layout.tracker.channel_column_tint, ) @@ -848,14 +848,14 @@ def _apply_cell_highlight( dpg.highlight_table_row( TAG_SEQUENCER_GRID_TABLE_TRACKER, table_row, - color=self._layout.colors.cursor_row, + color=self._layout.colors.cursor_row.rgba, ) column_index = tracker_table_column(generator) dpg.highlight_table_cell( TAG_SEQUENCER_GRID_TABLE_TRACKER, table_row, column_index, - color=self._layout.colors.cell_cursor, + color=self._layout.colors.cell_cursor.rgba, ) def _remove_cell_highlight( @@ -1277,7 +1277,7 @@ def highlight_row(self, row_index: Optional[int] = None) -> None: dpg.highlight_table_row( TAG_SEQUENCER_GRID_TABLE_TRACKER, tracker_table_row(row_index), - color=self._layout.colors.pattern_highlight, + color=self._layout.colors.pattern_highlight.rgba, ) def unhighlight_row(self, row_index: Optional[int] = None) -> None: @@ -1311,7 +1311,7 @@ def _apply_playing_row_highlight(self) -> None: dpg.highlight_table_row( TAG_SEQUENCER_GRID_TABLE_TRACKER, tracker_table_row(self._playing_row), - color=self._layout.colors.playback_row, + color=self._layout.colors.playback_row.rgba, ) def _live_row_count(self) -> int: diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py index 8d64525df..176443dc1 100644 --- a/src/sampletones_application/ui/panels/sequencer/history.py +++ b/src/sampletones_application/ui/panels/sequencer/history.py @@ -21,6 +21,7 @@ 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.palette.color import PaletteColor from sampletones_application.view_model.sequencer.history import ( HistoryEntryViewModel, HistoryViewModel, @@ -31,7 +32,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, ...] @@ -308,19 +308,19 @@ 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: + def _add_text(self, value: str, *, parent: int, color: Optional[PaletteColor]) -> None: text = ( dpg.add_text(value, parent=parent) if color is None else dpg.add_text( value, parent=parent, - color=color, + color=color.rgba, ) ) FontRegistry.bind_to_item(text, Font.MONO_SMALL) - def _role_color(self, role: HistoryDetailRole) -> RGBA: + def _role_color(self, role: HistoryDetailRole) -> PaletteColor: colors = self._layout.colors roles = colors.history.roles text = colors.text diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index a70a272cd..465590a82 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -255,22 +255,22 @@ def _create_entry_themes(self) -> None: carries the header's hover and press washes instead, so it reads as the switch it is. """ colors = self._layout.colors - self._entry_theme = create_selectable_text_theme(colors.text.order) + self._entry_theme = create_selectable_text_theme(colors.text.order.rgba) self._muted_entry_theme = create_selectable_text_theme( with_alpha_fraction( - colors.text.order, + colors.text.order.rgba, self._layout.tracker.muted_text_fraction, ), ) self._label_theme = create_header_selectable_theme( - colors.label, - colors.header.hovered, - colors.header.active, + colors.label.rgba, + colors.header.hovered.rgba, + colors.header.active.rgba, ) self._muted_label_theme = create_header_selectable_theme( - colors.muted.text, - colors.header.hovered, - colors.header.active, + colors.muted.text.rgba, + colors.header.hovered.rgba, + colors.header.active.rgba, ) def _create_button_row(self) -> None: @@ -501,7 +501,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 +517,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 +525,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,10 +546,10 @@ 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), + channel_color(self._layout.colors.channels, generator).rgba, self._layout.tracker.channel_column_tint, ) @@ -561,7 +561,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 +666,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 diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index ab464a27b..62bf358ba 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -173,7 +173,7 @@ def _build_sample_row(self, position: int, entry: SampleEntryViewModel) -> None: dpg.highlight_table_row( TAG_SEQUENCER_INSTRUMENTS_TABLE, position, - color=self._layout.colors.cell_cursor, + color=self._layout.colors.cell_cursor.rgba, ) def _build_id_cell( @@ -268,7 +268,7 @@ def _on_sample_selected( dpg.highlight_table_row( TAG_SEQUENCER_INSTRUMENTS_TABLE, position, - color=self._layout.colors.cell_cursor, + color=self._layout.colors.cell_cursor.rgba, ) self.call(self.on_sample_selected, sample_id) diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py index e71adaf30..e54905bb5 100644 --- a/src/sampletones_application/ui/themes/loader.py +++ b/src/sampletones_application/ui/themes/loader.py @@ -27,11 +27,9 @@ ThemeValue, ) from sampletones_application.ui.themes.theme import Theme -from sampletones_application.utils.palette.color import ColorSource -from sampletones_application.utils.palette.palette import Palette -from sampletones_application.utils.palette.reference import PaletteReference +from sampletones_application.utils.palette.color 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" @@ -45,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() @@ -74,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 @@ -132,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, ) @@ -143,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/registry.py b/src/sampletones_application/ui/themes/registry.py index 70da4b7ca..dfbe77f74 100644 --- a/src/sampletones_application/ui/themes/registry.py +++ b/src/sampletones_application/ui/themes/registry.py @@ -1,4 +1,4 @@ -from typing import ClassVar, Dict, Optional +from typing import ClassVar, Dict, Optional, Tuple from sampletones_application.ui.themes.theme import Theme @@ -10,6 +10,11 @@ class ThemeRegistry: def register(cls, theme: Theme) -> None: cls._registry[theme.tag] = theme + @classmethod + def themes(cls) -> Tuple[Theme, ...]: + """Every registered theme, for an operation that addresses the whole set at once.""" + return tuple(cls._registry.values()) + @classmethod def get(cls, tag: str) -> Theme: if tag not in cls._registry: diff --git a/src/sampletones_application/ui/themes/setup.py b/src/sampletones_application/ui/themes/setup.py index cc5597d63..5cbc37490 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.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 212f9fd08..934975179 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.color import ColorSource +from sampletones_application.utils.palette.color import PaletteColor class ThemeColorEntrySpec(BaseModel, frozen=True): type: Literal["color"] key: str - value: ColorSource + value: PaletteColor category: str = "Core" diff --git a/src/sampletones_application/ui/themes/style.py b/src/sampletones_application/ui/themes/style.py index adec2c96b..c5d48b228 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.color import PaletteColor @dataclass(frozen=True, kw_only=True) @@ -14,7 +14,7 @@ class ThemeValue: @dataclass(frozen=True, kw_only=True) class ThemeColor(ThemeValue): - color: Color + color: PaletteColor @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 c6abddd30..0b5b7d7fa 100644 --- a/src/sampletones_application/ui/themes/theme.py +++ b/src/sampletones_application/ui/themes/theme.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Optional, Tuple +from typing import List, Optional, Tuple import dearpygui.dearpygui as dpg @@ -11,7 +11,10 @@ ThemeStyle, ThemeValue, ) -from sampletones_shared.types.application import Color +from sampletones_application.utils.palette.color import PaletteColor +from sampletones_shared.types.application import ColorRGBA, Sender + +ThemeColorItems = List[Tuple[Sender, PaletteColor]] class Theme: @@ -19,6 +22,7 @@ def __init__(self, *, tag: str, items: ThemeItems) -> None: self.tag = tag self._items = items self._dictionary: ThemeDictionary = self._index(items) + self._color_items: ThemeColorItems = [] @staticmethod def _index(items: ThemeItems) -> ThemeDictionary: @@ -39,12 +43,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, keeping hold of the colour items it fills. - if override and dpg.does_item_exist(self.tag): - dpg.delete_item(self.tag) + DearPyGui copies each colour into the item it creates, so the theme records the + item alongside the :class:`PaletteColor` it was filled from and :meth:`restyle` + writes the current value back into it. + """ + if dpg.does_item_exist(self.tag): + return with dpg.theme(tag=self.tag): for parameter, values in self._items.items.items(): @@ -54,11 +61,7 @@ def create(self, *, override: bool = False) -> None: ): for item in values: if isinstance(item, ThemeColor): - dpg.add_theme_color( - item.key, - item.color, - category=item.category, - ) + self._add_color(item) elif isinstance(item, ThemeStyle): dpg.add_theme_style( item.key, @@ -67,6 +70,24 @@ def create(self, *, override: bool = False) -> None: category=item.category, ) + def _add_color(self, item: ThemeColor) -> None: + color_item = dpg.add_theme_color( + item.key, + item.color.rgba, + category=item.category, + ) + self._color_items.append((color_item, item.color)) + + def restyle(self) -> None: + """Writes the current value of every colour this theme carries back into DearPyGui. + + Setting a live theme colour item repaints each item bound to the theme on the next + frame and leaves the bindings themselves in place, so a palette swap reaches every + themed widget through the theme it already has. + """ + for color_item, color in self._color_items: + dpg.set_value(color_item, color.rgba) + def bind_to_item(self, item: int | str) -> None: self.create() dpg.bind_item_theme(item, self.tag) @@ -97,7 +118,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 +128,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/gui/dialogs.py b/src/sampletones_application/utils/gui/dialogs.py index d08112385..9a3a5447d 100644 --- a/src/sampletones_application/utils/gui/dialogs.py +++ b/src/sampletones_application/utils/gui/dialogs.py @@ -248,7 +248,7 @@ def content(parent: str) -> None: f"- {property_name}", parent=parent, wrap=self._recovery_wrap, - color=self._col_text_highlight, + color=self._col_text_highlight.rgba, ) dpg.add_text( @@ -351,13 +351,13 @@ def close() -> None: dpg.add_text( f"{str(type(exception).__name__)}: ", parent=group_tag, - color=self._col_text_error, + color=self._col_text_error.rgba, ) dpg.add_text( str(exception), parent=group_tag, wrap=self._error_wrap, - color=self._col_text_error, + color=self._col_text_error.rgba, ) traceback = GUITraceback( @@ -416,7 +416,7 @@ def content(parent: str) -> None: dpg.add_text( str(filepath), parent=parent, - color=self._col_path, + color=self._col_path.rgba, wrap=self._error_wrap, ) diff --git a/src/sampletones_application/utils/palette/color.py b/src/sampletones_application/utils/palette/color.py index 4cfd95aca..92d3a5e1f 100644 --- a/src/sampletones_application/utils/palette/color.py +++ b/src/sampletones_application/utils/palette/color.py @@ -1,44 +1,98 @@ -from typing import Annotated, Any, Final, Mapping, Union +from typing import Any, Final, Mapping, Self, Union -from pydantic import BeforeValidator, Field, ValidationInfo +from pydantic import BaseModel, ConfigDict, ValidationInfo, model_validator -from sampletones_application.utils.palette.palette import Palette from sampletones_application.utils.palette.reference import PaletteReference, is_reference +from sampletones_application.utils.palette.source import PaletteSource from sampletones_shared.types.application import ColorRGBA -from sampletones_shared.utils.color import RGBA, parse_hex_color +from sampletones_shared.utils.color import parse_hex_color -PALETTE_CONTEXT_KEY: Final[str] = "palette" +PALETTE_SOURCE_CONTEXT_KEY: Final[str] = "palette_source" -ColorSource = Annotated[Union[PaletteReference, RGBA], Field(union_mode="left_to_right")] - -def palette_from_context(info: ValidationInfo) -> Palette: - """The palette a colour field resolves against, taken from the validation context. +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 entry. - TypeError: when the context entry holds a value other than a palette. + 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_CONTEXT_KEY not in context: - raise ValueError(f"Resolving a palette reference requires a {PALETTE_CONTEXT_KEY!r} validation 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 + + +class NamedColor(BaseModel): + """A palette reference together with the source that answers it.""" + + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + 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) + + +class PaletteColor(BaseModel): + """A colour read at the moment it is drawn with. + + Written as a palette reference (``.token``, optionally ``.token/alpha``) or as a + ``#rrggbb`` literal, and kept in the form it was written: a reference reads its + value from the palette active right now, so the same field answers with a new + colour once another palette is activated, while a literal stands on its own. + Consumers read :attr:`rgba` where they hand the colour to DearPyGui, keeping the + written form as the thing they hold on to. + """ + + model_config = ConfigDict(frozen=True) - 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)}") + value: Union[NamedColor, ColorRGBA] - return palette + @property + def rgba(self) -> ColorRGBA: + """The colour's value under the active palette.""" + if isinstance(self.value, NamedColor): + return self.value.rgba + return self.value -def _resolve_palette_color(value: Any, info: ValidationInfo) -> object: - if isinstance(value, str): - text = value.strip() - if is_reference(text): - return palette_from_context(info).resolve(PaletteReference.model_validate(text)) + @model_validator(mode="before") + @classmethod + def _from_written_color(cls, value: Any, info: ValidationInfo) -> object: + if isinstance(value, str): + text = value.strip() + if is_reference(text): + named = NamedColor( + reference=PaletteReference.model_validate(text), + source=palette_source_from_context(info), + ) + return {"value": named} - return parse_hex_color(text) + return {"value": parse_hex_color(text)} - return value + return value + @model_validator(mode="after") + def _resolve_once(self) -> Self: + """Reads the colour once, so the palette in place at load answers for its token. -PaletteColor = Annotated[ColorRGBA, BeforeValidator(_resolve_palette_color)] + Raises: + KeyError: when that palette holds no token of the referenced name. + """ + _ = self.rgba + return self diff --git a/src/sampletones_application/utils/palette/source.py b/src/sampletones_application/utils/palette/source.py new file mode 100644 index 000000000..940ecb075 --- /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:`PaletteColor` 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/tests/unit/sampletones_application/parameters/conftest.py b/tests/unit/sampletones_application/parameters/conftest.py index 265e61022..79dd22742 100644 --- a/tests/unit/sampletones_application/parameters/conftest.py +++ b/tests/unit/sampletones_application/parameters/conftest.py @@ -4,8 +4,10 @@ from sampletones_application.layout.loader import load_layout_config 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, PaletteCatalog.load(PALETTES_DIRECTORY).default) + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source) 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 69ba2ebcb..2ab5ffbea 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.color import PaletteColor class _FakeDPG: @@ -80,7 +81,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 = PaletteColor(value=(255, 255, 255, 255)) class _Array: @@ -105,7 +106,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=PaletteColor(value=(255, 200, 100, 255))), waveform=SimpleNamespace(reconstruction_dim_opacity=opacity), ) @@ -144,7 +145,7 @@ 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) == layer.color.rgba def test_series_color_greys_the_reconstruction_when_dimmed(self) -> None: graph = _graph() @@ -162,7 +163,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) == layer.color.rgba def test_set_dimmed_rebinds_the_reconstruction_series_once( self, 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 7bd21cbc2..7634ddaad 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.color import PaletteColor ROOT_WINDOW = "global.window.main" ROOT_ID = 5 @@ -17,14 +19,23 @@ _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} +CARET_LAYOUT = CaretLayout( + fill=PaletteColor(value=(102, 187, 255, 64)), + border=PaletteColor(value=(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 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 0837766e7..0e2e61da0 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 @@ -25,6 +25,7 @@ from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.ui.themes.theme import Theme 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 @@ -33,13 +34,14 @@ @pytest.fixture def layout_config() -> LayoutConfig: - return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, PaletteCatalog.load(PALETTES_DIRECTORY).default) + 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, PaletteCatalog.load(PALETTES_DIRECTORY).default) + setup_themes(THEME_DIRECTORY, PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default)) GUIPanel.configure_section_header( layout_config.glyphs, layout_config.general.section_header, diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_channels.py index f36aa9f0e..ad8f21bfe 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_channels.py @@ -12,6 +12,7 @@ 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.utils.palette.color import PaletteColor 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 @@ -26,10 +27,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=PaletteColor(value=(240, 146, 86, 255)), + pulse2=PaletteColor(value=(242, 209, 95, 255)), + triangle=PaletteColor(value=(140, 193, 237, 255)), + noise=PaletteColor(value=(187, 184, 194, 255)), ) HEADER_THEME = 1 @@ -90,7 +91,7 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerGridPanel: panel._layout = SimpleNamespace( colors=SimpleNamespace( channels=CHANNEL_COLORS, - muted=SimpleNamespace(background=MUTED_BACKGROUND), + muted=SimpleNamespace(background=PaletteColor(value=MUTED_BACKGROUND)), ), tracker=SimpleNamespace( channel_column_tint=TINT_FRACTION, 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 index 3ef3795c2..09a56b813 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_rows.py @@ -10,6 +10,7 @@ tracker_table_row, ) from sampletones_application.ui.panels.sequencer.grid import GUISequencerGridPanel +from sampletones_application.utils.palette.color import PaletteColor from sampletones_core.constants.enums import GeneratorName from sampletones_shared.types.application import ColorRGBA @@ -57,10 +58,10 @@ def _panel() -> GUISequencerGridPanel: 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, + cursor_row=PaletteColor(value=CURSOR_ROW), + cell_cursor=PaletteColor(value=CELL_CURSOR), + pattern_highlight=PaletteColor(value=PATTERN_HIGHLIGHT), + playback_row=PaletteColor(value=PLAYBACK_ROW), ), ) panel._current_row_count = PATTERN_ROWS @@ -213,7 +214,9 @@ def test_every_table_column_of_the_header_takes_the_header_shade( recorder: _TableRecorder, ) -> None: panel = _panel() - panel._layout = SimpleNamespace(colors=SimpleNamespace(header=SimpleNamespace(background=HEADER_SHADE))) + panel._layout = SimpleNamespace( + colors=SimpleNamespace(header=SimpleNamespace(background=PaletteColor(value=HEADER_SHADE))) + ) panel._highlight_header_row() @@ -229,7 +232,9 @@ def test_the_header_shade_covers_the_sample_and_channel_columns( """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._layout = SimpleNamespace( + colors=SimpleNamespace(header=SimpleNamespace(background=PaletteColor(value=HEADER_SHADE))) + ) panel._highlight_header_row() 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 f99f8b3ec..8934214ba 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 @@ -10,6 +10,7 @@ from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.panels.sequencer.history import GUISequencerHistoryPanel 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 +19,8 @@ @pytest.fixture def layout_config() -> LayoutConfig: - return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, PaletteCatalog.load(PALETTES_DIRECTORY).default) + 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 39def1fbd..f525de446 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 @@ -12,6 +12,7 @@ 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.utils.palette.color import PaletteColor from sampletones_application.view_model.sequencer.channels import SequencerChannelsViewModel from sampletones_core.constants.enums import GeneratorName from sampletones_shared.types.application import ColorRGBA, Sender @@ -29,10 +30,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=PaletteColor(value=(240, 146, 86, 255)), + pulse2=PaletteColor(value=(242, 209, 95, 255)), + triangle=PaletteColor(value=(140, 193, 237, 255)), + noise=PaletteColor(value=(187, 184, 194, 255)), ) LABEL_THEME = 1 @@ -136,7 +137,7 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerOrderPanel: panel._layout = SimpleNamespace( colors=SimpleNamespace( channels=CHANNEL_COLORS, - muted=SimpleNamespace(background=MUTED_BACKGROUND), + muted=SimpleNamespace(background=PaletteColor(value=MUTED_BACKGROUND)), ), tracker=SimpleNamespace( channel_column_tint=TINT_FRACTION, diff --git a/tests/unit/sampletones_application/ui/themes/test_loader.py b/tests/unit/sampletones_application/ui/themes/test_loader.py index ee7abe8a9..638842692 100644 --- a/tests/unit/sampletones_application/ui/themes/test_loader.py +++ b/tests/unit/sampletones_application/ui/themes/test_loader.py @@ -11,6 +11,7 @@ from sampletones_application.ui.themes.theme import Theme 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" @@ -69,8 +70,8 @@ class TestLoadedInheritance: @pytest.fixture def themes(self) -> Dict[str, Theme]: - palette = PaletteCatalog.load(PALETTES_DIRECTORY).default - return {theme.tag: theme for theme in ThemeLoader(THEME_DIRECTORY, palette).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() @@ -150,7 +151,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 000000000..e2d41946e --- /dev/null +++ b/tests/unit/sampletones_application/ui/themes/test_registry.py @@ -0,0 +1,45 @@ +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_the_whole_set_is_listed_for_an_operation_addressing_it_at_once(self) -> None: + default = _theme("global.theme.default") + table = _theme("global.theme.table") + ThemeRegistry.register(default) + ThemeRegistry.register(table) + + assert ThemeRegistry.themes() == (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.themes() == (replacement,) 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 000000000..96d50d116 --- /dev/null +++ b/tests/unit/sampletones_application/ui/themes/test_theme.py @@ -0,0 +1,123 @@ +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.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) + styled.theme.restyle() + + 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) + styled.theme.restyle() + + 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/palette/test_color.py b/tests/unit/sampletones_application/utils/palette/test_color.py index 6ab8d10c9..8b8728521 100644 --- a/tests/unit/sampletones_application/utils/palette/test_color.py +++ b/tests/unit/sampletones_application/utils/palette/test_color.py @@ -1,8 +1,9 @@ import pytest from pydantic import BaseModel, ValidationError -from sampletones_application.utils.palette.color import PALETTE_CONTEXT_KEY, PaletteColor +from sampletones_application.utils.palette.color import PALETTE_SOURCE_CONTEXT_KEY, PaletteColor from sampletones_application.utils.palette.palette import Palette +from sampletones_application.utils.palette.source import PaletteSource class _Swatch(BaseModel, frozen=True): @@ -10,26 +11,71 @@ class _Swatch(BaseModel, frozen=True): @pytest.fixture -def palette() -> Palette: - return Palette.model_validate({"name": "test", "colors": {"accent": "#a97fe3", "overlay": "#ffffff40"}}) +def studio() -> Palette: + return Palette.model_validate({"name": "studio", "colors": {"accent": "#a97fe3", "overlay": "#ffffff40"}}) + + +@pytest.fixture +def light() -> Palette: + return Palette.model_validate({"name": "light", "colors": {"accent": "#6b3fb0", "overlay": "#00000040"}}) + + +@pytest.fixture +def source(studio: Palette) -> PaletteSource: + return PaletteSource(studio) + + +def _swatch(written: str, source: PaletteSource) -> _Swatch: + return _Swatch.model_validate({"color": written}, context={PALETTE_SOURCE_CONTEXT_KEY: source}) class TestPaletteColor: def test_a_hex_literal_resolves_without_a_palette(self) -> None: - assert _Swatch.model_validate({"color": "#a97fe3"}).color == (169, 127, 227, 255) + assert _Swatch.model_validate({"color": "#a97fe3"}).color.rgba == (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_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, 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_alpha_override_is_applied(self, source: PaletteSource) -> None: + assert _swatch(".accent/0.5", source).color.rgba == (169, 127, 227, 128) - def test_a_reference_without_a_palette_context_raises(self) -> None: + 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_context_of_the_wrong_type_raises(self) -> None: + 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_CONTEXT_KEY: "studio"}) + _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/palette/test_source.py b/tests/unit/sampletones_application/utils/palette/test_source.py new file mode 100644 index 000000000..db88a3a9e --- /dev/null +++ b/tests/unit/sampletones_application/utils/palette/test_source.py @@ -0,0 +1,56 @@ +from typing import List + +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"}}) + + +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/scripts/checks/test_palette_colors.py b/tests/unit/scripts/checks/test_palette_colors.py new file mode 100644 index 000000000..44bcc1097 --- /dev/null +++ b/tests/unit/scripts/checks/test_palette_colors.py @@ -0,0 +1,79 @@ +from pathlib import Path +from typing import Final, List + +from sampletones_shared.meta.source.modules import SourceModule +from tests.suite.scripts import load_script +from tests.suite.source import parse_source + +check_palette_colors = load_script("scripts/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' + + +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)] + + +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 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") == [] From 450d7ef997ce9ec4bd81434eb5e3cf6cb291803f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 7 Aug 2026 21:09:13 +0200 Subject: [PATCH 003/152] Added: dynamic palette colors --- scripts/checks/palette_colors.py | 141 ++++++++++++---- src/sampletones_application/application.py | 15 ++ .../coordinators/tabs/sequencer.py | 11 ++ .../layout/behavior.py | 41 ----- .../layout/behavior/__init__.py | 0 .../layout/behavior/behavior.py | 11 ++ .../layout/behavior/main.py | 7 + .../layout/behavior/scheduling/__init__.py | 0 .../layout/behavior/scheduling/delays.py | 7 + .../layout/behavior/scheduling/emit.py | 6 + .../layout/behavior/scheduling/priorities.py | 7 + .../layout/behavior/scheduling/scheduling.py | 12 ++ .../layout/behavior/ui.py | 5 + src/sampletones_application/layout/config.py | 2 +- .../layout/general/__init__.py | 4 +- .../layout/general/caret.py | 6 +- .../layout/general/colors.py | 53 ------ .../layout/general/colors/__init__.py | 0 .../layout/general/colors/colors.py | 17 ++ .../layout/general/colors/favorite.py | 8 + .../layout/general/colors/feature.py | 17 ++ .../layout/general/colors/header.py | 8 + .../layout/general/colors/path.py | 8 + .../layout/general/colors/table.py | 8 + .../layout/general/colors/text.py | 10 ++ .../layout/general/dialogs/__init__.py | 0 .../layout/general/{ => dialogs}/dialogs.py | 5 +- .../layout/general/dialogs/height.py | 5 + .../layout/graphs/colors.py | 8 +- .../layout/graphs/spectrum.py | 6 +- src/sampletones_application/layout/loader.py | 4 +- .../layout/settings/master_gain.py | 6 +- .../layout/tabs/sequencer/__init__.py | 6 +- .../layout/tabs/sequencer/colors.py | 118 ------------- .../layout/tabs/sequencer/colors/__init__.py | 0 .../layout/tabs/sequencer/colors/channel.py | 17 ++ .../layout/tabs/sequencer/colors/colors.py | 25 +++ .../layout/tabs/sequencer/colors/header.py | 16 ++ .../layout/tabs/sequencer/colors/history.py | 13 ++ .../tabs/sequencer/colors/history_role.py | 16 ++ .../layout/tabs/sequencer/colors/muted.py | 15 ++ .../layout/tabs/sequencer/colors/order.py | 16 ++ .../layout/tabs/sequencer/colors/sample.py | 10 ++ .../layout/tabs/sequencer/colors/tracker.py | 22 +++ .../layout/tabs/sequencer/tables/__init__.py | 0 .../layout/tabs/sequencer/tables/cells.py | 11 ++ .../{table_cells.py => tables/instrument.py} | 8 - .../layout/tabs/sequencer/tracker/__init__.py | 0 .../tabs/sequencer/tracker/subcolumn.py | 7 + .../tabs/sequencer/{ => tracker}/tracker.py | 6 +- .../logic/main/converter.py | 2 +- .../logic/reconstruction/instruments.py | 2 +- .../logic/reconstruction/manager.py | 2 +- .../logic/sequencer/samples.py | 2 +- .../logic/shared/tree.py | 2 +- .../parameters/instructions.py | 4 +- .../parameters/main.py | 4 +- .../parameters/reconstruction.py | 9 +- .../parameters/sequencer.py | 4 +- .../ui/elements/graphs/bar.py | 15 +- .../ui/elements/graphs/layers/array.py | 4 +- .../ui/elements/graphs/layers/bar.py | 4 +- .../ui/elements/graphs/layers/instruction.py | 4 +- .../ui/elements/graphs/layers/spectrum.py | 6 +- .../ui/elements/graphs/spectrum.py | 29 ++-- .../ui/elements/graphs/waveform.py | 60 ++++--- .../ui/elements/path.py | 19 ++- .../ui/elements/pitch_stepper.py | 9 +- .../ui/elements/table/table.py | 11 +- .../ui/elements/tree/colors.py | 14 +- .../ui/elements/tree/emitter.py | 2 +- .../ui/elements/tree/tree.py | 16 +- .../ui/panels/instruction/library.py | 2 +- .../ui/panels/instruction/parameters.py | 2 +- .../ui/panels/main/advanced.py | 2 +- .../ui/panels/main/converter.py | 2 +- .../ui/panels/main/explorer.py | 2 +- .../ui/panels/reconstruction/audio.py | 6 +- .../ui/panels/reconstruction/browser.py | 2 +- .../reconstruction/instruments/config.py | 22 ++- .../reconstruction/instruments/instruments.py | 2 +- .../ui/panels/sequencer/browser.py | 2 +- .../ui/panels/sequencer/columns.py | 6 +- .../ui/panels/sequencer/grid.py | 27 ++- .../ui/panels/sequencer/history.py | 28 +-- .../ui/panels/sequencer/order.py | 26 ++- .../ui/themes/inline.py | 15 +- .../ui/themes/loader.py | 2 +- .../ui/themes/registry.py | 7 +- src/sampletones_application/ui/themes/spec.py | 4 +- .../ui/themes/style.py | 4 +- .../ui/themes/theme.py | 41 ++--- .../utils/file_dialogs/filter.py | 4 +- .../utils/gui/dialogs.py | 17 +- .../utils/gui/palette/__init__.py | 0 .../utils/gui/palette/binding.py | 37 ++++ .../utils/gui/palette/dpg.py | 51 ++++++ .../utils/gui/palette/palette.py | 82 +++++++++ .../utils/palette/color.py | 98 ----------- .../utils/palette/colors/__init__.py | 0 .../utils/palette/colors/base.py | 67 ++++++++ .../utils/palette/colors/literal.py | 16 ++ .../utils/palette/colors/named.py | 23 +++ .../utils/palette/colors/written.py | 66 ++++++++ .../utils/palette/source.py | 2 +- src/sampletones_application/viewport.py | 8 + src/sampletones_shared/array.py | 2 +- tests/conftest.py | 32 +++- tests/suite/application.py | 10 +- .../logic/reconstruction/conftest.py | 2 +- .../logic/reconstruction/test_instruments.py | 7 +- .../logic/shared/test_tree.py | 10 +- .../ui/elements/graphs/test_waveform.py | 15 +- .../ui/elements/table/test_caret.py | 6 +- .../ui/panels/sequencer/test_grid_channels.py | 14 +- .../ui/panels/sequencer/test_grid_rows.py | 14 +- .../panels/sequencer/test_order_channels.py | 14 +- .../ui/themes/test_inline.py | 21 ++- .../ui/themes/test_registry.py | 19 ++- .../ui/themes/test_theme.py | 5 +- .../utils/gui/test_palette.py | 159 ++++++++++++++++++ .../utils/palette/conftest.py | 19 +++ .../utils/palette/test_color.py | 99 +++++------ .../utils/palette/test_source.py | 12 -- .../utils/palette/test_written.py | 81 +++++++++ .../scripts/checks/test_palette_colors.py | 28 +++ 126 files changed, 1482 insertions(+), 730 deletions(-) delete mode 100644 src/sampletones_application/layout/behavior.py create mode 100644 src/sampletones_application/layout/behavior/__init__.py create mode 100644 src/sampletones_application/layout/behavior/behavior.py create mode 100644 src/sampletones_application/layout/behavior/main.py create mode 100644 src/sampletones_application/layout/behavior/scheduling/__init__.py create mode 100644 src/sampletones_application/layout/behavior/scheduling/delays.py create mode 100644 src/sampletones_application/layout/behavior/scheduling/emit.py create mode 100644 src/sampletones_application/layout/behavior/scheduling/priorities.py create mode 100644 src/sampletones_application/layout/behavior/scheduling/scheduling.py create mode 100644 src/sampletones_application/layout/behavior/ui.py delete mode 100644 src/sampletones_application/layout/general/colors.py create mode 100644 src/sampletones_application/layout/general/colors/__init__.py create mode 100644 src/sampletones_application/layout/general/colors/colors.py create mode 100644 src/sampletones_application/layout/general/colors/favorite.py create mode 100644 src/sampletones_application/layout/general/colors/feature.py create mode 100644 src/sampletones_application/layout/general/colors/header.py create mode 100644 src/sampletones_application/layout/general/colors/path.py create mode 100644 src/sampletones_application/layout/general/colors/table.py create mode 100644 src/sampletones_application/layout/general/colors/text.py create mode 100644 src/sampletones_application/layout/general/dialogs/__init__.py rename src/sampletones_application/layout/general/{ => dialogs}/dialogs.py (79%) create mode 100644 src/sampletones_application/layout/general/dialogs/height.py delete mode 100644 src/sampletones_application/layout/tabs/sequencer/colors.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/colors/__init__.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/colors/channel.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/colors/colors.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/colors/header.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/colors/history.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/colors/history_role.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/colors/muted.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/colors/order.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/colors/sample.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/colors/tracker.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/tables/__init__.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/tables/cells.py rename src/sampletones_application/layout/tabs/sequencer/{table_cells.py => tables/instrument.py} (65%) create mode 100644 src/sampletones_application/layout/tabs/sequencer/tracker/__init__.py create mode 100644 src/sampletones_application/layout/tabs/sequencer/tracker/subcolumn.py rename src/sampletones_application/layout/tabs/sequencer/{ => tracker}/tracker.py (65%) create mode 100644 src/sampletones_application/utils/gui/palette/__init__.py create mode 100644 src/sampletones_application/utils/gui/palette/binding.py create mode 100644 src/sampletones_application/utils/gui/palette/dpg.py create mode 100644 src/sampletones_application/utils/gui/palette/palette.py delete mode 100644 src/sampletones_application/utils/palette/color.py create mode 100644 src/sampletones_application/utils/palette/colors/__init__.py create mode 100644 src/sampletones_application/utils/palette/colors/base.py create mode 100644 src/sampletones_application/utils/palette/colors/literal.py create mode 100644 src/sampletones_application/utils/palette/colors/named.py create mode 100644 src/sampletones_application/utils/palette/colors/written.py create mode 100644 tests/unit/sampletones_application/utils/gui/test_palette.py create mode 100644 tests/unit/sampletones_application/utils/palette/conftest.py create mode 100644 tests/unit/sampletones_application/utils/palette/test_written.py diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py index 31b66b9de..e33d77d23 100755 --- a/scripts/checks/palette_colors.py +++ b/scripts/checks/palette_colors.py @@ -3,11 +3,11 @@ """ Checks that a colour stays a palette token until the moment it is drawn with. -`PaletteColor.rgba` answers with the palette active right now, so a consumer that holds the +`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 two ways that contract is lost: an attribute assigned the -resolved value, and a colour written into the shipped configuration as a literal instead of a -palette token. +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 @@ -15,24 +15,27 @@ import argparse import ast +import logging import re import sys +from itertools import chain from pathlib import Path -from typing import Final, Iterator, List, NamedTuple, Sequence, Tuple +from typing import Final, Iterator, List, NamedTuple, Sequence, Tuple, Union +from sampletones_shared.logger import logger from sampletones_shared.meta.source.modules import SourceModule, discover_modules -from sampletones_shared.paths import REPOSITORY_ROOT +from sampletones_shared.meta.source.nodes import terminal_name -SOURCE_ROOT: Final[Path] = REPOSITORY_ROOT / "src" -APPLICATION_PACKAGE: Final[Path] = SOURCE_ROOT / "sampletones_application" -CONFIG_PACKAGE: Final[Path] = SOURCE_ROOT / "sampletones_config" -PALETTES_DIRECTORY: Final[Path] = CONFIG_PACKAGE / "palettes" +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" -HEX_COLOR: Final[re.Pattern[str]] = re.compile(r"[\"']#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?[\"']") + + +Assignment = Union[ast.Assign, ast.AnnAssign] class ColorFinding(NamedTuple): @@ -42,14 +45,17 @@ class ColorFinding(NamedTuple): message: str -def _assigned_targets(statement: ast.stmt) -> Tuple[ast.expr, ...]: +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) - if isinstance(statement, ast.AnnAssign): - return (statement.target,) - - return () + return (statement.target,) def _is_own_attribute(target: ast.expr) -> bool: @@ -69,12 +75,8 @@ def stored_colors(module: SourceModule) -> Iterator[ColorFinding]: Yields: ColorFinding: One per assignment, naming the attribute that keeps the stale shade. """ - for statement in ast.walk(module.tree): - value = getattr(statement, "value", None) - if value is None or not _resolves_a_color(value): - continue - - if not isinstance(statement, ast.stmt): + 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): @@ -82,12 +84,38 @@ def stored_colors(module: SourceModule) -> Iterator[ColorFinding]: yield ColorFinding( location=module.location(statement), message=( - f"stores .{COLOR_PROPERTY}; hold the PaletteColor and read " + f"stores .{COLOR_PROPERTY}; hold the BaseColor and read " f".{COLOR_PROPERTY} where the colour reaches DearPyGui" ), ) +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. + + Yields: + ColorFinding: One per call, naming the theme colour that stays at the shade it was + built with. + """ + if module.path == 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. @@ -97,7 +125,10 @@ def literal_colors(path: Path) -> Iterator[ColorFinding]: 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 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}", @@ -105,8 +136,24 @@ def literal_colors(path: Path) -> Iterator[ColorFinding]: ) -def find_stored_colors(package: Path) -> List[ColorFinding]: - return [finding for module in discover_modules([package]) for finding in stored_colors(module)] +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]: @@ -120,38 +167,68 @@ def find_literal_colors(package: Path, palettes: Path) -> List[ColorFinding]: def main(argv: Sequence[str]) -> int: """Report every colour the application stores resolved or the configuration writes out.""" + + logger.set_level(level=logging.ERROR) + + import sampletones_application + import sampletones_config + from sampletones_application.utils.gui.palette import dpg + + config_package = Path(sampletones_config.__file__).resolve() + palettes_directory = config_package / "palettes" + + bindings_module = Path(dpg.__file__).resolve() + theme_color_helper = dpg.dpg_add_palette_theme_color.__name__ + 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, + default=Path(sampletones_application.__file__).resolve(), help="package whose colour reads to check", ) parser.add_argument( "--config", type=Path, - default=CONFIG_PACKAGE, + default=config_package, help="shipped configuration package whose colours must name palette tokens", ) parser.add_argument( "--palettes", type=Path, - default=PALETTES_DIRECTORY, + default=palettes_directory, help="directory holding the palettes, where colour values belong", ) arguments = parser.parse_args(list(argv)) - findings = find_stored_colors(arguments.package) + find_literal_colors(arguments.config, arguments.palettes) + 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) + 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) + print( + f"\nFound {len(findings)} colour(s) detached from the palette.", + file=sys.stderr, + ) return 1 diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 6d36fb9fd..30e22844c 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -101,8 +101,10 @@ 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.manager import ShortcutManager 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, @@ -542,6 +544,19 @@ 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 + + def _on_palette_changed(self, palette: Palette) -> None: + """Repaints what holds a colour DearPyGui has copied, once another palette is in place. + + Every layout and theme colour already answers with the new palette, so the work left is + handing those values to the copies DearPyGui keeps: the registered theme colours and item + arguments, the viewport clear colour, and the sequencer tables, whose tints belong to the + table rather than to an item. + """ + PaletteBindings.apply() + self._viewport_manager.refresh_clear_color() + self._sequencer_tab.repaint() def _on_tab_changed(self, sender: Sender, app_data: Any, user_data: Any) -> None: self._update_menu() diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 54e531a01..38a094c71 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -707,6 +707,17 @@ def refresh(self) -> None: self._sequencer_order_panel.set_enabled(is_open) self._sequencer_history_panel.set_enabled(is_open) + def repaint(self) -> None: + """Draws both tables again so their 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 — which is + what pushing the current view models through the panels does. + """ + self._sequencer_channels_logic.push_channels() + self._sequencer_grid_logic.refresh() + self._sequencer_order_logic.refresh() + def refresh_browser(self) -> None: self._sequencer_browser_panel.refresh() diff --git a/src/sampletones_application/layout/behavior.py b/src/sampletones_application/layout/behavior.py deleted file mode 100644 index f61cd1bca..000000000 --- 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 000000000..e69de29bb diff --git a/src/sampletones_application/layout/behavior/behavior.py b/src/sampletones_application/layout/behavior/behavior.py new file mode 100644 index 000000000..5dc3f816c --- /dev/null +++ b/src/sampletones_application/layout/behavior/behavior.py @@ -0,0 +1,11 @@ +from pydantic import BaseModel + +from sampletones_application.layout.behavior.main import MainBehavior +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 + main: MainBehavior diff --git a/src/sampletones_application/layout/behavior/main.py b/src/sampletones_application/layout/behavior/main.py new file mode 100644 index 000000000..d2c4b86b8 --- /dev/null +++ b/src/sampletones_application/layout/behavior/main.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel, Field + + +class MainBehavior(BaseModel, extra="forbid", frozen=True): + fps_update_interval: float + vsync: bool + max_fps: int = Field(ge=0) 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 000000000..e69de29bb 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 000000000..7a25eb70e --- /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 000000000..fb06eeff8 --- /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 000000000..915daeb57 --- /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 000000000..9f55455a4 --- /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 000000000..bc90d0ac2 --- /dev/null +++ b/src/sampletones_application/layout/behavior/ui.py @@ -0,0 +1,5 @@ +from pydantic import BaseModel + + +class UIBehavior(BaseModel, extra="forbid", frozen=True): + status_bar_display_time: float diff --git a/src/sampletones_application/layout/config.py b/src/sampletones_application/layout/config.py index d2cc0a99c..8d0ceb755 100644 --- a/src/sampletones_application/layout/config.py +++ b/src/sampletones_application/layout/config.py @@ -1,6 +1,6 @@ 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 diff --git a/src/sampletones_application/layout/general/__init__.py b/src/sampletones_application/layout/general/__init__.py index a0c943e37..4c793068d 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 e7c0edc88..4e9bb687f 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.color 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 b06f76bd5..000000000 --- a/src/sampletones_application/layout/general/colors.py +++ /dev/null @@ -1,53 +0,0 @@ -from pydantic import BaseModel - -from sampletones_application.utils.palette.color 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 000000000..e69de29bb 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 000000000..41208d3a7 --- /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 000000000..16ca858f0 --- /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 000000000..e8780ee55 --- /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 000000000..578ac3250 --- /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 000000000..d38e60512 --- /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 000000000..88727b1ac --- /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 000000000..f700365aa --- /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 000000000..e69de29bb 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 d1270e1b6..700b5a763 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 000000000..2a4bf449b --- /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/graphs/colors.py b/src/sampletones_application/layout/graphs/colors.py index 08fab6dbd..c5df19150 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.color 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 d41971fd0..6757fdf39 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.color 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 5a7ae28ca..dafbdeb12 100644 --- a/src/sampletones_application/layout/loader.py +++ b/src/sampletones_application/layout/loader.py @@ -1,6 +1,6 @@ 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 @@ -14,7 +14,7 @@ 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.color import PALETTE_SOURCE_CONTEXT_KEY +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 diff --git a/src/sampletones_application/layout/settings/master_gain.py b/src/sampletones_application/layout/settings/master_gain.py index c5d9f89b0..49a1639aa 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.color 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 e4857e0e8..104f2c786 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 ea48c2efe..000000000 --- a/src/sampletones_application/layout/tabs/sequencer/colors.py +++ /dev/null @@ -1,118 +0,0 @@ -from pydantic import BaseModel - -from sampletones_application.utils.palette.color 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 000000000..e69de29bb 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 000000000..c43e3655e --- /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 000000000..973894954 --- /dev/null +++ b/src/sampletones_application/layout/tabs/sequencer/colors/colors.py @@ -0,0 +1,25 @@ +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.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 + 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 000000000..21dce042d --- /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 000000000..837c60ac5 --- /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 000000000..ee02d51fd --- /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 000000000..7e430b1a5 --- /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 000000000..ffd0eed4b --- /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/sample.py b/src/sampletones_application/layout/tabs/sequencer/colors/sample.py new file mode 100644 index 000000000..02ccae710 --- /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 000000000..b6882e867 --- /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 000000000..e69de29bb 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 000000000..8cc234eb3 --- /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 d204f544b..83ac368b2 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/__init__.py b/src/sampletones_application/layout/tabs/sequencer/tracker/__init__.py new file mode 100644 index 000000000..e69de29bb 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 000000000..67ff0740f --- /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.py b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py similarity index 65% rename from src/sampletones_application/layout/tabs/sequencer/tracker.py rename to src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py index 2725899d2..db169cd30 100644 --- a/src/sampletones_application/layout/tabs/sequencer/tracker.py +++ b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py @@ -1,10 +1,6 @@ from pydantic import BaseModel - -class SubcolumnWidths(BaseModel, extra="forbid", frozen=True): - instrument: int - transpose: int - volume: int +from sampletones_application.layout.tabs.sequencer.tracker.subcolumn import SubcolumnWidths class TrackerLayout(BaseModel, extra="forbid", frozen=True): diff --git a/src/sampletones_application/logic/main/converter.py b/src/sampletones_application/logic/main/converter.py index b2b639fb7..edb7361e7 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/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 8171b68e6..674d5e788 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 c91db8ae2..d88078b7a 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/samples.py b/src/sampletones_application/logic/sequencer/samples.py index a48edb440..4f4052b8b 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/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index 155c548e8..004735363 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 4ddb2aa52..6c90f2175 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 2e0db49bc..ec602b45e 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 991a485d4..42adf0170 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.color 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 f80163d01..04a3c820a 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/ui/elements/graphs/bar.py b/src/sampletones_application/ui/elements/graphs/bar.py index 3ffd607a8..ff3ebd49b 100644 --- a/src/sampletones_application/ui/elements/graphs/bar.py +++ b/src/sampletones_application/ui/elements/graphs/bar.py @@ -24,9 +24,11 @@ dpg_delete_item, dpg_is_item_hovered, ) -from sampletones_application.utils.palette.color import PaletteColor +from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color +from sampletones_application.utils.palette.colors.base import BaseColor 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] @@ -153,9 +155,9 @@ def _bind_theme( with dpg.theme(tag=theme_tag): with dpg.theme_component(dpg.mvBarSeries): - dpg.add_theme_color( + dpg_add_palette_theme_color( dpg.mvPlotCol_Fill, - layer.color.rgba, + layer.color, category=dpg.mvThemeCat_Plots, ) @@ -169,11 +171,10 @@ def _bind_hover_theme(self) -> None: if layer is None: raise RuntimeError("No layers available to bind hover theme") - red, green, blue, _ = layer.color.rgba - hover_color = (red, green, blue, self._hover_alpha) + hover_color = layer.color.faded(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_add_palette_theme_color( dpg.mvPlotCol_Fill, hover_color, category=dpg.mvThemeCat_Plots, @@ -185,7 +186,7 @@ def load_data( self, data: np.ndarray, name: str, - color: PaletteColor, + color: BaseColor, y_ticks: Optional[Tuple[int, ...]] = None, ) -> None: self._delete_hover_bar() diff --git a/src/sampletones_application/ui/elements/graphs/layers/array.py b/src/sampletones_application/ui/elements/graphs/layers/array.py index 6f6eb72fb..87ec8abac 100644 --- a/src/sampletones_application/ui/elements/graphs/layers/array.py +++ b/src/sampletones_application/ui/elements/graphs/layers/array.py @@ -3,7 +3,7 @@ import numpy as np from sampletones_application.ui.elements.graphs.layers.layer import Layer -from sampletones_application.utils.palette.color import PaletteColor +from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_core.audio import minmax_decimate @@ -11,7 +11,7 @@ class ArrayLayer(Layer): data: np.ndarray name: str - color: PaletteColor + 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 d2eb2b8bb..8a4e177d2 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_application.utils.palette.color import PaletteColor +from sampletones_application.utils.palette.colors.base import BaseColor @dataclass(frozen=True) class BarLayer(Layer): data: np.ndarray name: str - color: PaletteColor + 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 747b7e8e6..4361cf3fd 100644 --- a/src/sampletones_application/ui/elements/graphs/layers/instruction.py +++ b/src/sampletones_application/ui/elements/graphs/layers/instruction.py @@ -4,7 +4,7 @@ import numpy as np from sampletones_application.ui.elements.graphs.layers.layer import Layer -from sampletones_application.utils.palette.color import PaletteColor +from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_core.generators import MIXER_LEVELS from sampletones_core.library import InstructionLibraryFragment @@ -13,7 +13,7 @@ class InstructionLayer(Layer): data: InstructionLibraryFragment[Any] name: str - color: PaletteColor + 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 1b1c8e3bc..9c07aef2a 100644 --- a/src/sampletones_application/ui/elements/graphs/layers/spectrum.py +++ b/src/sampletones_application/ui/elements/graphs/layers/spectrum.py @@ -4,7 +4,7 @@ import numpy as np from sampletones_application.ui.elements.graphs.layers.layer import Layer -from sampletones_application.utils.palette.color import PaletteColor +from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_core.library import InstructionLibraryFragment from sampletones_core.structures.histogram import Histogram @@ -13,8 +13,8 @@ class SpectrumLayer(Layer): data: InstructionLibraryFragment[Any] name: str - color_dim: PaletteColor - color_bright: PaletteColor + 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 041ba9917..d581eca98 100644 --- a/src/sampletones_application/ui/elements/graphs/spectrum.py +++ b/src/sampletones_application/ui/elements/graphs/spectrum.py @@ -14,12 +14,13 @@ dpg_bind_item_theme, dpg_delete_children, ) -from sampletones_application.utils.palette.color import PaletteColor +from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color +from sampletones_application.utils.palette.colors.base import BaseColor 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.utils.color import MAX_CHANNEL_VALUE, blend +from sampletones_shared.types.application import Sender +from sampletones_shared.utils.color import MAX_CHANNEL_VALUE class GUISpectrumGraph(GUIGraph[SpectrumLayer]): @@ -45,7 +46,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, @@ -120,24 +121,26 @@ 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: PaletteColor, - color_bright: PaletteColor, + color_dim: BaseColor, + color_bright: BaseColor, brightness: float, ) -> str: - color = blend(color_dim.rgba, color_bright.rgba, brightness / MAX_CHANNEL_VALUE) + """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 = color_dim.blended(color_bright, brightness / MAX_CHANNEL_VALUE) if color in self.themes: return self.themes[color] - theme_tag = self._get_color_theme_tag(color) + theme_tag = compose_tag(self.tag, SUF_GRAPH_THEME, str(len(self.themes))) with dpg.theme(tag=theme_tag): with dpg.theme_component(dpg.mvBarSeries): - dpg.add_theme_color( + dpg_add_palette_theme_color( dpg.mvPlotCol_Fill, color, category=dpg.mvThemeCat_Plots, diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index 2bb09d781..3528c455c 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,19 @@ 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.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, ColorRGBA, 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 +277,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,21 +357,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_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]) -> ColorRGBA: - """Resolves a layer's line colour, greying the reconstruction while a regeneration runs. + 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.rgba), - self._layout.waveform.reconstruction_dim_opacity, - ) + if shade is SeriesShade.DIMMED: + reconstruction = self._layout.colors.waveform_reconstruction + return reconstruction.grayscale().faded(self._layout.waveform.reconstruction_dim_opacity) - return layer.color.rgba + return layer.color def _prune_stale_series(self) -> None: """Aligns the y-axis series with the current layers, keeping the position indicator @@ -394,20 +409,25 @@ 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_add_palette_theme_color( dpg.mvPlotCol_Line, - color, + self._series_color(layer, shade), category=dpg.mvThemeCat_Plots, ) diff --git a/src/sampletones_application/ui/elements/path.py b/src/sampletones_application/ui/elements/path.py index b4a6eb43f..0d29bbf47 100644 --- a/src/sampletones_application/ui/elements/path.py +++ b/src/sampletones_application/ui/elements/path.py @@ -14,8 +14,9 @@ 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_application.utils.palette.color import PaletteColor +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 @@ -34,8 +35,8 @@ def __init__( tag: str, path: Optional[Path], parent: str, - color: PaletteColor, - hover_color: PaletteColor, + color: BaseColor, + hover_color: BaseColor, status_message: str, prefix: Optional[str] = None, font: Optional[Font] = None, @@ -79,8 +80,8 @@ def _create_text(self) -> None: self.display_text, tag=self.tag, parent=parent, - color=self.color.rgba, ) + dpg_set_palette_color(self.tag, self.color) if self.font is not None: FontRegistry.bind_to_item(self.label_tag, self.font) @@ -114,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.rgba) + 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.rgba) + dpg_set_palette_color(self.tag, self.color) def _on_clicked(self) -> None: if not self.path.exists(): @@ -138,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.rgba) + 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: PaletteColor) -> 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 @@ -153,7 +154,7 @@ def set_status(self, text: str, color: PaletteColor) -> None: self.color = color self.hover_color = color dpg_set_value(self.tag, text) - dpg.configure_item(self.tag, color=color.rgba) + 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 53f40b00b..a275fe303 100644 --- a/src/sampletones_application/ui/elements/pitch_stepper.py +++ b/src/sampletones_application/ui/elements/pitch_stepper.py @@ -24,8 +24,9 @@ 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.color import PaletteColor +from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_core.utils.pitch_kind import PitchValueKind from sampletones_shared.utils.callbacks import CallbackMixin @@ -42,7 +43,7 @@ class PitchStepperStyle: dimensions: PitchStepperLayout plus_minus: PlusMinusButtonsLayout - value_color: PaletteColor + value_color: BaseColor @classmethod def from_general(cls, general: GeneralLayout) -> Self: @@ -77,7 +78,7 @@ def __init__( status_bar: GUIStatusBar, layout: PitchStepperLayout, plus_minus_layout: PlusMinusButtonsLayout, - value_color: PaletteColor, + value_color: BaseColor, ) -> None: self.on_value_changed: Optional[Callable[[int], None]] = None self._status_bar = status_bar @@ -153,8 +154,8 @@ def _build(self) -> None: dpg.add_text( str(self._value), tag=self._value_tag, - color=self._value_color.rgba, ) + 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/table/table.py b/src/sampletones_application/ui/elements/table/table.py index 3173308de..ed2559e51 100644 --- a/src/sampletones_application/ui/elements/table/table.py +++ b/src/sampletones_application/ui/elements/table/table.py @@ -10,7 +10,8 @@ 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.palette.color import PaletteColor +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 Sender from sampletones_shared.types.data import SerializedData @@ -25,8 +26,8 @@ def __init__( rows: Tuple[TableCell, ...], *, label_column_width: int, - label_color: PaletteColor, - value_color: PaletteColor, + label_color: BaseColor, + value_color: BaseColor, parent: Optional[str] = None, before: Optional[str] = None, header_row: bool = False, @@ -117,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.rgba) + 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.rgba) + dpg_set_palette_color(value_text, self._value_color) self._values.append(value_text) @classmethod diff --git a/src/sampletones_application/ui/elements/tree/colors.py b/src/sampletones_application/ui/elements/tree/colors.py index 710347b9a..abff8d575 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_application.utils.palette.color import PaletteColor +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: PaletteColor - node: PaletteColor - muted: PaletteColor - accent: PaletteColor + favorite: BaseColor + node: BaseColor + muted: BaseColor + accent: BaseColor @classmethod - def create(cls, colors: GeneralColors, *, accent: PaletteColor) -> 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 4ef94a8de..3ca791193 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 d4064510f..54dd28388 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -6,7 +6,7 @@ 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,11 +49,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.color import PaletteColor +from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.utils.parallelization.thread import ( BackgroundWorkCancelled, SingleThreadExecutor, @@ -488,7 +489,7 @@ def _context_menu_header_name(self, node: TreeNode) -> str: return str(node.name) - def _node_header_color(self, node: TreeNode) -> PaletteColor: + def _node_header_color(self, node: TreeNode) -> BaseColor: if self._logic.is_node_favorite(node): return self._colors.favorite @@ -515,10 +516,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.rgba) + 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.rgba) + 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]]: @@ -563,7 +566,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.rgba) + 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: diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index c59e19a6f..2153df87d 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -4,7 +4,7 @@ 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, diff --git a/src/sampletones_application/ui/panels/instruction/parameters.py b/src/sampletones_application/ui/panels/instruction/parameters.py index e6eddc80b..313ba9971 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, diff --git a/src/sampletones_application/ui/panels/main/advanced.py b/src/sampletones_application/ui/panels/main/advanced.py index 4421f05ee..1054ed5e9 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 diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 54caa9de6..27c168b38 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, diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index b9eb09340..43a92f4f4 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -4,7 +4,7 @@ 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, diff --git a/src/sampletones_application/ui/panels/reconstruction/audio.py b/src/sampletones_application/ui/panels/reconstruction/audio.py index 846f23475..d0804998e 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,7 +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.color import PaletteColor +from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.view_model.reconstruction.reconstruction import ( ReconstructionPathState, ReconstructionPathViewModel, @@ -32,7 +32,7 @@ def __init__( self, *, path_colors: PathColors, - path_status_color: PaletteColor, + path_status_color: BaseColor, initial_collapsed: bool = False, language_manager: LanguageManager, status_bar: GUIStatusBar, diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index fdbefa9ac..9fccf60fa 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -4,7 +4,7 @@ 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, ) diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/config.py b/src/sampletones_application/ui/panels/reconstruction/instruments/config.py index 2732bf8ce..76c6e6528 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/config.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/config.py @@ -2,8 +2,8 @@ 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.utils.palette.color import PaletteColor +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 @@ -12,7 +12,7 @@ class FeaturePlotConfig: feature_key: FeatureKey label: str - color: PaletteColor + 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, PaletteColor]: +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, PaletteCo def _build_plot_configs( labels: Dict[FeatureKey, str], - colors: Dict[FeatureKey, PaletteColor], + 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, PaletteColor], + 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, PaletteColor], + 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 f5f4c6935..99fc0104b 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 ( diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index 7e25579ca..846cd2348 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -3,7 +3,7 @@ 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, diff --git a/src/sampletones_application/ui/panels/sequencer/columns.py b/src/sampletones_application/ui/panels/sequencer/columns.py index 98885a542..90c17a39a 100644 --- a/src/sampletones_application/ui/panels/sequencer/columns.py +++ b/src/sampletones_application/ui/panels/sequencer/columns.py @@ -1,8 +1,8 @@ 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.color import PaletteColor +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 @@ -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) -> PaletteColor: +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/grid.py b/src/sampletones_application/ui/panels/sequencer/grid.py index 0db105d84..2ba16f0ab 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid.py +++ b/src/sampletones_application/ui/panels/sequencer/grid.py @@ -83,7 +83,6 @@ 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] @@ -281,7 +280,7 @@ def _setup_handlers(self) -> None: def _create_themes(self) -> None: self._create_subcolumn_themes() self._create_header_themes() - self._row_number_theme = create_selectable_text_theme(self._layout.colors.text.row.rgba) + self._row_number_theme = create_selectable_text_theme(self._layout.colors.text.row) def _create_subcolumn_themes(self) -> None: """Builds each subcolumn's text theme in its full and its dimmed colour. @@ -297,10 +296,8 @@ def _create_subcolumn_themes(self) -> None: } fraction = self._layout.tracker.muted_text_fraction for subcolumn, color in theme_colors.items(): - self._subcolumn_themes[subcolumn] = create_selectable_text_theme(color.rgba) - self._muted_subcolumn_themes[subcolumn] = create_selectable_text_theme( - with_alpha_fraction(color.rgba, fraction), - ) + self._subcolumn_themes[subcolumn] = create_selectable_text_theme(color) + self._muted_subcolumn_themes[subcolumn] = create_selectable_text_theme(color.faded(fraction)) def _create_header_themes(self) -> None: """Builds the two shades a channel's header label takes: audible and silenced. @@ -310,14 +307,14 @@ def _create_header_themes(self) -> None: """ header = self._layout.colors.header self._header_theme = create_header_selectable_theme( - self._layout.colors.label.rgba, - header.hovered.rgba, - header.active.rgba, + self._layout.colors.label, + header.hovered, + header.active, ) self._muted_header_theme = create_header_selectable_theme( - self._layout.colors.muted.text.rgba, - header.hovered.rgba, - header.active.rgba, + self._layout.colors.muted.text, + header.hovered, + header.active, ) def _create_tracker_view(self, parent: str) -> None: @@ -479,10 +476,8 @@ def _channel_column_tint(self, generator: GeneratorName) -> ColorRGBA: if self._is_muted(generator): return self._layout.colors.muted.background.rgba - return with_alpha_fraction( - channel_color(self._layout.colors.channels, generator).rgba, - self._layout.tracker.channel_column_tint, - ) + channel = channel_color(self._layout.colors.channels, generator) + return channel.faded(self._layout.tracker.channel_column_tint).rgba def _compute_cell_values( self, diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py index 176443dc1..9a823fe47 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,7 +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.palette.color import PaletteColor +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, @@ -308,19 +309,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[PaletteColor]) -> None: - text = ( - dpg.add_text(value, parent=parent) - if color is None - else dpg.add_text( - value, - parent=parent, - color=color.rgba, - ) - ) + 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) -> PaletteColor: + def _role_color(self, role: HistoryDetailRole) -> BaseColor: colors = self._layout.colors roles = colors.history.roles text = colors.text diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 465590a82..9a49c094f 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -69,7 +69,6 @@ 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] @@ -255,22 +254,19 @@ def _create_entry_themes(self) -> None: carries the header's hover and press washes instead, so it reads as the switch it is. """ colors = self._layout.colors - self._entry_theme = create_selectable_text_theme(colors.text.order.rgba) + self._entry_theme = create_selectable_text_theme(colors.text.order) self._muted_entry_theme = create_selectable_text_theme( - with_alpha_fraction( - colors.text.order.rgba, - self._layout.tracker.muted_text_fraction, - ), + colors.text.order.faded(self._layout.tracker.muted_text_fraction), ) self._label_theme = create_header_selectable_theme( - colors.label.rgba, - colors.header.hovered.rgba, - colors.header.active.rgba, + colors.label, + colors.header.hovered, + colors.header.active, ) self._muted_label_theme = create_header_selectable_theme( - colors.muted.text.rgba, - colors.header.hovered.rgba, - colors.header.active.rgba, + colors.muted.text, + colors.header.hovered, + colors.header.active, ) def _create_button_row(self) -> None: @@ -548,10 +544,8 @@ def _channel_row_tint(self, generator: GeneratorName) -> ColorRGBA: if self._is_muted(generator): return self._layout.colors.muted.background.rgba - return with_alpha_fraction( - channel_color(self._layout.colors.channels, generator).rgba, - self._layout.tracker.channel_column_tint, - ) + channel = channel_color(self._layout.colors.channels, generator) + return channel.faded(self._layout.tracker.channel_column_tint).rgba def _apply_column_highlight(self, position: int, *, focused: bool) -> None: if focused: diff --git a/src/sampletones_application/ui/themes/inline.py b/src/sampletones_application/ui/themes/inline.py index 17db911dd..df600deb3 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, diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py index e54905bb5..e24eb06ef 100644 --- a/src/sampletones_application/ui/themes/loader.py +++ b/src/sampletones_application/ui/themes/loader.py @@ -27,7 +27,7 @@ ThemeValue, ) from sampletones_application.ui.themes.theme import Theme -from sampletones_application.utils.palette.color import PALETTE_SOURCE_CONTEXT_KEY +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.utils.serialization import load_yaml diff --git a/src/sampletones_application/ui/themes/registry.py b/src/sampletones_application/ui/themes/registry.py index dfbe77f74..70da4b7ca 100644 --- a/src/sampletones_application/ui/themes/registry.py +++ b/src/sampletones_application/ui/themes/registry.py @@ -1,4 +1,4 @@ -from typing import ClassVar, Dict, Optional, Tuple +from typing import ClassVar, Dict, Optional from sampletones_application.ui.themes.theme import Theme @@ -10,11 +10,6 @@ class ThemeRegistry: def register(cls, theme: Theme) -> None: cls._registry[theme.tag] = theme - @classmethod - def themes(cls) -> Tuple[Theme, ...]: - """Every registered theme, for an operation that addresses the whole set at once.""" - return tuple(cls._registry.values()) - @classmethod def get(cls, tag: str) -> Theme: if tag not in cls._registry: diff --git a/src/sampletones_application/ui/themes/spec.py b/src/sampletones_application/ui/themes/spec.py index 934975179..3eeb63711 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.color import PaletteColor +from sampletones_application.utils.palette.colors.written import WrittenColor class ThemeColorEntrySpec(BaseModel, frozen=True): type: Literal["color"] key: str - value: PaletteColor + value: WrittenColor category: str = "Core" diff --git a/src/sampletones_application/ui/themes/style.py b/src/sampletones_application/ui/themes/style.py index c5d48b228..4b65108fb 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_application.utils.palette.color import PaletteColor +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: PaletteColor + 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 0b5b7d7fa..05db24721 100644 --- a/src/sampletones_application/ui/themes/theme.py +++ b/src/sampletones_application/ui/themes/theme.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import List, Optional, Tuple +from typing import Optional, Tuple import dearpygui.dearpygui as dpg @@ -11,10 +11,8 @@ ThemeStyle, ThemeValue, ) -from sampletones_application.utils.palette.color import PaletteColor -from sampletones_shared.types.application import ColorRGBA, Sender - -ThemeColorItems = List[Tuple[Sender, PaletteColor]] +from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color +from sampletones_shared.types.application import ColorRGBA class Theme: @@ -22,7 +20,6 @@ def __init__(self, *, tag: str, items: ThemeItems) -> None: self.tag = tag self._items = items self._dictionary: ThemeDictionary = self._index(items) - self._color_items: ThemeColorItems = [] @staticmethod def _index(items: ThemeItems) -> ThemeDictionary: @@ -44,11 +41,11 @@ def _index(items: ThemeItems) -> ThemeDictionary: return dictionary def create(self) -> None: - """Builds the DearPyGui theme once, keeping hold of the colour items it fills. + """Builds the DearPyGui theme once, registering each colour item it fills. - DearPyGui copies each colour into the item it creates, so the theme records the - item alongside the :class:`PaletteColor` it was filled from and :meth:`restyle` - writes the current value back into it. + 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 @@ -61,7 +58,11 @@ def create(self) -> None: ): for item in values: if isinstance(item, ThemeColor): - self._add_color(item) + dpg_add_palette_theme_color( + item.key, + item.color, + category=item.category, + ) elif isinstance(item, ThemeStyle): dpg.add_theme_style( item.key, @@ -70,24 +71,6 @@ def create(self) -> None: category=item.category, ) - def _add_color(self, item: ThemeColor) -> None: - color_item = dpg.add_theme_color( - item.key, - item.color.rgba, - category=item.category, - ) - self._color_items.append((color_item, item.color)) - - def restyle(self) -> None: - """Writes the current value of every colour this theme carries back into DearPyGui. - - Setting a live theme colour item repaints each item bound to the theme on the next - frame and leaves the bindings themselves in place, so a palette swap reaches every - themed widget through the theme it already has. - """ - for color_item, color in self._color_items: - dpg.set_value(color_item, color.rgba) - def bind_to_item(self, item: int | str) -> None: self.create() dpg.bind_item_theme(item, self.tag) diff --git a/src/sampletones_application/utils/file_dialogs/filter.py b/src/sampletones_application/utils/file_dialogs/filter.py index beec2bdba..1b6679f27 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/gui/dialogs.py b/src/sampletones_application/utils/gui/dialogs.py index 9a3a5447d..fdf62fa9c 100644 --- a/src/sampletones_application/utils/gui/dialogs.py +++ b/src/sampletones_application/utils/gui/dialogs.py @@ -42,6 +42,7 @@ 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_shared.types.callback import Callback, StringCallback, VoidCallback _TEMPLATE_PLACEHOLDER: Pattern[str] = re.compile(r"\{(\w+)\}") @@ -244,12 +245,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.rgba, ) + dpg_set_palette_color(property_text, self._col_text_highlight) dpg.add_text( self._language_manager["global.dialog.message.configuration_recovery_path_prefix"], @@ -348,17 +349,17 @@ def close() -> None: group_tag = compose_tag(tag, SUF_GROUP) with dpg.group(tag=group_tag, parent=tag): - dpg.add_text( + name_text = dpg.add_text( f"{str(type(exception).__name__)}: ", parent=group_tag, - color=self._col_text_error.rgba, ) - 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.rgba, ) + dpg_set_palette_color(message_text, self._col_text_error) traceback = GUITraceback( parent=tag, @@ -413,12 +414,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.rgba, wrap=self._error_wrap, ) + dpg_set_palette_color(path_text, self._col_path) _show_modal_dialog( tag=tag, 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 000000000..e69de29bb 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 000000000..30c99e1ae --- /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 000000000..6adbcf422 --- /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 000000000..e060e4ac3 --- /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/palette/color.py b/src/sampletones_application/utils/palette/color.py deleted file mode 100644 index 92d3a5e1f..000000000 --- a/src/sampletones_application/utils/palette/color.py +++ /dev/null @@ -1,98 +0,0 @@ -from typing import Any, Final, Mapping, Self, Union - -from pydantic import BaseModel, ConfigDict, ValidationInfo, model_validator - -from sampletones_application.utils.palette.reference import PaletteReference, is_reference -from sampletones_application.utils.palette.source import PaletteSource -from sampletones_shared.types.application import ColorRGBA -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 - - -class NamedColor(BaseModel): - """A palette reference together with the source that answers it.""" - - model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) - - 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) - - -class PaletteColor(BaseModel): - """A colour read at the moment it is drawn with. - - Written as a palette reference (``.token``, optionally ``.token/alpha``) or as a - ``#rrggbb`` literal, and kept in the form it was written: a reference reads its - value from the palette active right now, so the same field answers with a new - colour once another palette is activated, while a literal stands on its own. - Consumers read :attr:`rgba` where they hand the colour to DearPyGui, keeping the - written form as the thing they hold on to. - """ - - model_config = ConfigDict(frozen=True) - - value: Union[NamedColor, ColorRGBA] - - @property - def rgba(self) -> ColorRGBA: - """The colour's value under the active palette.""" - if isinstance(self.value, NamedColor): - return self.value.rgba - - return self.value - - @model_validator(mode="before") - @classmethod - def _from_written_color(cls, value: Any, info: ValidationInfo) -> object: - if isinstance(value, str): - text = value.strip() - if is_reference(text): - named = NamedColor( - reference=PaletteReference.model_validate(text), - source=palette_source_from_context(info), - ) - return {"value": named} - - return {"value": parse_hex_color(text)} - - return value - - @model_validator(mode="after") - def _resolve_once(self) -> Self: - """Reads the colour once, so the palette in place at load answers for its token. - - Raises: - KeyError: when that palette holds no token of the referenced name. - """ - _ = self.rgba - return self 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 000000000..e69de29bb 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 000000000..b51b31958 --- /dev/null +++ b/src/sampletones_application/utils/palette/colors/base.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass + +from sampletones_shared.types.application import ColorRGBA +from sampletones_shared.utils.color import blend, to_grayscale, with_alpha_fraction + + +@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. + """ + + @property + @abstractmethod + def rgba(self) -> ColorRGBA: + """The colour's value under the active palette.""" + + def faded(self, fraction: float) -> BaseColor: + """This colour at ``fraction`` of full opacity, keeping its red, green and blue.""" + + @dataclass(frozen=True) + class FadedColor(BaseColor): + + base: BaseColor + fraction: float + + @property + def rgba(self) -> ColorRGBA: + return with_alpha_fraction(self.base.rgba, self.fraction) + + return FadedColor(self, fraction) + + def grayscale(self) -> BaseColor: + """This colour desaturated to the gray of the same luminance, keeping its alpha.""" + + @dataclass(frozen=True) + class GrayscaleColor(BaseColor): + + base: BaseColor + + @property + def rgba(self) -> ColorRGBA: + return to_grayscale(self.base.rgba) + + return GrayscaleColor(self) + + def blended(self, other: BaseColor, fraction: float) -> BaseColor: + """The colour ``fraction`` of the way from this one to ``other``, channel by channel.""" + + @dataclass(frozen=True) + class BlendedColor(BaseColor): + start: BaseColor + end: BaseColor + fraction: float + + @property + def rgba(self) -> ColorRGBA: + return blend(self.start.rgba, self.end.rgba, self.fraction) + + return BlendedColor(self, other, fraction) 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 000000000..76ae0636c --- /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 000000000..36b1c1fbf --- /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 000000000..be2851530 --- /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/source.py b/src/sampletones_application/utils/palette/source.py index 940ecb075..19a1fe7ec 100644 --- a/src/sampletones_application/utils/palette/source.py +++ b/src/sampletones_application/utils/palette/source.py @@ -8,7 +8,7 @@ class PaletteSource(CallbackMixin): """The palette every colour token resolves against, and the one place it changes. - A :class:`PaletteColor` keeps the token it was written as and reads its value from + 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``. diff --git a/src/sampletones_application/viewport.py b/src/sampletones_application/viewport.py index 3cc14810c..ae6063164 100644 --- a/src/sampletones_application/viewport.py +++ b/src/sampletones_application/viewport.py @@ -65,6 +65,14 @@ def create_viewport(self) -> None: vsync=self._vsync, ) + self.refresh_clear_color() + + 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)) diff --git a/src/sampletones_shared/array.py b/src/sampletones_shared/array.py index a4d8c8d3e..1e411e8c4 100644 --- a/src/sampletones_shared/array.py +++ b/src/sampletones_shared/array.py @@ -43,7 +43,7 @@ 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], diff --git a/tests/conftest.py b/tests/conftest.py index 726178bd5..eefcf7ac2 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/suite/application.py b/tests/suite/application.py index cebdb0aa1..e8df8a13a 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/unit/sampletones_application/logic/reconstruction/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/conftest.py index fd4849236..d2d8b13e9 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_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 5b0ce1b7a..c678f2fb4 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -1,12 +1,10 @@ -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.manager import ReconstructionManager @@ -23,7 +21,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/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py index 7152921ba..2d5fc672e 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 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 2ab5ffbea..d63e659f3 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -6,7 +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.color import PaletteColor +from sampletones_application.utils.palette.colors.written import LiteralColor class _FakeDPG: @@ -81,7 +81,7 @@ def __init__(self, name: str) -> None: self.name = name self.x_data = _Array() self.y_data = _Array() - self.color = PaletteColor(value=(255, 255, 255, 255)) + self.color = LiteralColor((255, 255, 255, 255)) class _Array: @@ -106,7 +106,7 @@ def _graph() -> GUIWaveformGraph: def _with_layout(graph: GUIWaveformGraph, opacity: float = 0.4) -> None: graph._layout = SimpleNamespace( # type: ignore[assignment] - colors=SimpleNamespace(waveform_reconstruction=PaletteColor(value=(255, 200, 100, 255))), + colors=SimpleNamespace(waveform_reconstruction=LiteralColor((255, 200, 100, 255))), waveform=SimpleNamespace(reconstruction_dim_opacity=opacity), ) @@ -145,17 +145,18 @@ def test_series_color_is_untouched_when_not_dimmed(self) -> None: graph = _graph() layer = _Layer("Reconstruction") - assert graph._series_color(layer) == layer.color.rgba + 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() @@ -163,7 +164,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.rgba + assert graph._series_color(layer, graph._series_shade(layer)) == layer.color def test_set_dimmed_rebinds_the_reconstruction_series_once( self, 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 7634ddaad..bdc0b02ff 100644 --- a/tests/unit/sampletones_application/ui/elements/table/test_caret.py +++ b/tests/unit/sampletones_application/ui/elements/table/test_caret.py @@ -5,7 +5,7 @@ from sampletones_application.layout.general.caret import CaretLayout from sampletones_application.ui.elements.table.caret import CaretOverlay -from sampletones_application.utils.palette.color import PaletteColor +from sampletones_application.utils.palette.colors.written import LiteralColor ROOT_WINDOW = "global.window.main" ROOT_ID = 5 @@ -20,8 +20,8 @@ _ALIAS_IDS: Dict[str, int] = {ROOT_WINDOW: ROOT_ID, PANEL_WINDOW: PANEL_ID, DIALOG_WINDOW: DIALOG_ID} CARET_LAYOUT = CaretLayout( - fill=PaletteColor(value=(102, 187, 255, 64)), - border=PaletteColor(value=(102, 187, 255, 255)), + fill=LiteralColor((102, 187, 255, 64)), + border=LiteralColor((102, 187, 255, 255)), offset=3, width_padding=2, ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_channels.py index ad8f21bfe..238727aef 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_channels.py @@ -4,7 +4,7 @@ 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 @@ -12,7 +12,7 @@ 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.utils.palette.color import PaletteColor +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 @@ -27,10 +27,10 @@ MUTED_BACKGROUND: ColorRGBA = (10, 8, 18, 96) CHANNEL_COLORS = ChannelColors( - pulse1=PaletteColor(value=(240, 146, 86, 255)), - pulse2=PaletteColor(value=(242, 209, 95, 255)), - triangle=PaletteColor(value=(140, 193, 237, 255)), - noise=PaletteColor(value=(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 @@ -91,7 +91,7 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerGridPanel: panel._layout = SimpleNamespace( colors=SimpleNamespace( channels=CHANNEL_COLORS, - muted=SimpleNamespace(background=PaletteColor(value=MUTED_BACKGROUND)), + muted=SimpleNamespace(background=LiteralColor(MUTED_BACKGROUND)), ), tracker=SimpleNamespace( channel_column_tint=TINT_FRACTION, 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 index 09a56b813..0f80b3036 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_rows.py @@ -10,7 +10,7 @@ tracker_table_row, ) from sampletones_application.ui.panels.sequencer.grid import GUISequencerGridPanel -from sampletones_application.utils.palette.color import PaletteColor +from sampletones_application.utils.palette.colors.written import LiteralColor from sampletones_core.constants.enums import GeneratorName from sampletones_shared.types.application import ColorRGBA @@ -58,10 +58,10 @@ def _panel() -> GUISequencerGridPanel: panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel) panel._layout = SimpleNamespace( colors=SimpleNamespace( - cursor_row=PaletteColor(value=CURSOR_ROW), - cell_cursor=PaletteColor(value=CELL_CURSOR), - pattern_highlight=PaletteColor(value=PATTERN_HIGHLIGHT), - playback_row=PaletteColor(value=PLAYBACK_ROW), + cursor_row=LiteralColor(CURSOR_ROW), + cell_cursor=LiteralColor(CELL_CURSOR), + pattern_highlight=LiteralColor(PATTERN_HIGHLIGHT), + playback_row=LiteralColor(PLAYBACK_ROW), ), ) panel._current_row_count = PATTERN_ROWS @@ -215,7 +215,7 @@ def test_every_table_column_of_the_header_takes_the_header_shade( ) -> None: panel = _panel() panel._layout = SimpleNamespace( - colors=SimpleNamespace(header=SimpleNamespace(background=PaletteColor(value=HEADER_SHADE))) + colors=SimpleNamespace(header=SimpleNamespace(background=LiteralColor(HEADER_SHADE))) ) panel._highlight_header_row() @@ -233,7 +233,7 @@ def test_the_header_shade_covers_the_sample_and_channel_columns( header is painted per cell to read as one band.""" panel = _panel() panel._layout = SimpleNamespace( - colors=SimpleNamespace(header=SimpleNamespace(background=PaletteColor(value=HEADER_SHADE))) + colors=SimpleNamespace(header=SimpleNamespace(background=LiteralColor(HEADER_SHADE))) ) panel._highlight_header_row() 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 f525de446..b37cd4aad 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,14 @@ 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.utils.palette.color import PaletteColor +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 @@ -30,10 +30,10 @@ MUTED_BACKGROUND: ColorRGBA = (10, 8, 18, 96) CHANNEL_COLORS = ChannelColors( - pulse1=PaletteColor(value=(240, 146, 86, 255)), - pulse2=PaletteColor(value=(242, 209, 95, 255)), - triangle=PaletteColor(value=(140, 193, 237, 255)), - noise=PaletteColor(value=(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 @@ -137,7 +137,7 @@ def _panel(muted: FrozenSet[GeneratorName]) -> GUISequencerOrderPanel: panel._layout = SimpleNamespace( colors=SimpleNamespace( channels=CHANNEL_COLORS, - muted=SimpleNamespace(background=PaletteColor(value=MUTED_BACKGROUND)), + muted=SimpleNamespace(background=LiteralColor(MUTED_BACKGROUND)), ), tracker=SimpleNamespace( channel_column_tint=TINT_FRACTION, diff --git a/tests/unit/sampletones_application/ui/themes/test_inline.py b/tests/unit/sampletones_application/ui/themes/test_inline.py index 1f08146b7..843cf25e6 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_registry.py b/tests/unit/sampletones_application/ui/themes/test_registry.py index e2d41946e..ec56ce30f 100644 --- a/tests/unit/sampletones_application/ui/themes/test_registry.py +++ b/tests/unit/sampletones_application/ui/themes/test_registry.py @@ -29,17 +29,30 @@ def test_an_unregistered_tag_raises(self) -> None: with pytest.raises(KeyError): ThemeRegistry.get("global.theme.default") - def test_the_whole_set_is_listed_for_an_operation_addressing_it_at_once(self) -> None: + 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.themes() == (default, 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.themes() == (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 index 96d50d116..396ebe551 100644 --- a/tests/unit/sampletones_application/ui/themes/test_theme.py +++ b/tests/unit/sampletones_application/ui/themes/test_theme.py @@ -6,6 +6,7 @@ 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 @@ -103,13 +104,13 @@ def test_a_referenced_colour_takes_the_newly_activated_palette( light: Palette, ) -> None: styled.source.activate(light) - styled.theme.restyle() + 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) - styled.theme.restyle() + PaletteBindings.apply() assert _live_colors(styled.theme)[dpg.mvThemeCol_WindowBg] == LITERAL_BACKGROUND 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 000000000..c11732f3b --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/test_palette.py @@ -0,0 +1,159 @@ +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.written import LiteralColor, 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, accent.faded(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/conftest.py b/tests/unit/sampletones_application/utils/palette/conftest.py new file mode 100644 index 000000000..14365f18e --- /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_color.py b/tests/unit/sampletones_application/utils/palette/test_color.py index 8b8728521..abe05265a 100644 --- a/tests/unit/sampletones_application/utils/palette/test_color.py +++ b/tests/unit/sampletones_application/utils/palette/test_color.py @@ -1,81 +1,74 @@ import pytest -from pydantic import BaseModel, ValidationError -from sampletones_application.utils.palette.color import PALETTE_SOURCE_CONTEXT_KEY, PaletteColor +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.palette.colors.written import LiteralColor, 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 - -class _Swatch(BaseModel, frozen=True): - color: PaletteColor - - -@pytest.fixture -def studio() -> Palette: - return Palette.model_validate({"name": "studio", "colors": {"accent": "#a97fe3", "overlay": "#ffffff40"}}) +BLACK = LiteralColor((0, 0, 0, 255)) +WHITE = LiteralColor((255, 255, 255, 255)) @pytest.fixture -def light() -> Palette: - return Palette.model_validate({"name": "light", "colors": {"accent": "#6b3fb0", "overlay": "#00000040"}}) - - -@pytest.fixture -def source(studio: Palette) -> PaletteSource: - return PaletteSource(studio) - - -def _swatch(written: str, source: PaletteSource) -> _Swatch: - return _Swatch.model_validate({"color": written}, context={PALETTE_SOURCE_CONTEXT_KEY: source}) - - -class TestPaletteColor: - def test_a_hex_literal_resolves_without_a_palette(self) -> None: - assert _Swatch.model_validate({"color": "#a97fe3"}).color.rgba == (169, 127, 227, 255) +def accent(source: PaletteSource) -> BaseColor: + return NamedColor( + reference=PaletteReference(token="accent"), + source=source, + ) - 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) +class TestDerivedColor: + """Fading, desaturating and mixing each answer with a colour that still reads the palette.""" - def test_a_reference_without_a_palette_source_context_raises(self) -> None: - with pytest.raises(ValidationError): - _Swatch.model_validate({"color": ".accent"}) + def test_fading_keeps_the_hue_and_sets_the_opacity(self, accent: BaseColor) -> None: + assert accent.faded(0.5).rgba == (169, 127, 227, 128) - 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_desaturating_collapses_the_channels_to_one_luminance(self, accent: BaseColor) -> None: + gray = round(0.299 * 169 + 0.587 * 127 + 0.114 * 227) - def test_a_token_the_palette_in_place_omits_raises_at_load(self, source: PaletteSource) -> None: - with pytest.raises(KeyError): - _swatch(".missing", source) + assert accent.grayscale().rgba == (gray, gray, gray, 255) + def test_mixing_lands_between_the_two_ends(self) -> None: + assert BLACK.blended(WHITE, 0.5).rgba == (128, 128, 128, 255) -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( + def test_a_derived_colour_answers_with_the_newly_activated_palette( self, source: PaletteSource, light: Palette, + accent: BaseColor, ) -> None: - swatch = _swatch(".accent", source) + faded = accent.faded(0.5) source.activate(light) - assert swatch.color.rgba == (107, 63, 176, 255) + assert faded.rgba == (107, 63, 176, 128) - def test_an_alpha_override_survives_the_swap(self, source: PaletteSource, light: Palette) -> None: - swatch = _swatch(".accent/0.5", source) + def test_derivations_compose( + self, + source: PaletteSource, + light: Palette, + accent: BaseColor, + ) -> None: + dimmed = accent.grayscale().faded(0.25) 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) + gray = round(0.299 * 107 + 0.587 * 63 + 0.114 * 176) + assert dimmed.rgba == (gray, gray, gray, 64) - source.activate(light) + def test_the_same_derivation_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 {accent.faded(0.5), accent.faded(0.5), accent.faded(0.25)} == { + accent.faded(0.5), + accent.faded(0.25), + } - assert swatch.color.rgba == (169, 127, 227, 255) + def test_the_same_derivation_of_two_colours_stays_two_values( + self, + accent: BaseColor, + ) -> None: + assert accent.faded(0.5) != WHITE.faded(0.5) diff --git a/tests/unit/sampletones_application/utils/palette/test_source.py b/tests/unit/sampletones_application/utils/palette/test_source.py index db88a3a9e..35d13c7bc 100644 --- a/tests/unit/sampletones_application/utils/palette/test_source.py +++ b/tests/unit/sampletones_application/utils/palette/test_source.py @@ -1,21 +1,9 @@ from typing import List -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"}}) - - class TestActivate: def test_the_source_reports_the_palette_it_was_built_with(self, studio: Palette) -> None: source = PaletteSource(studio) 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 000000000..b2e96e89d --- /dev/null +++ b/tests/unit/sampletones_application/utils/palette/test_written.py @@ -0,0 +1,81 @@ +import pytest +from pydantic import BaseModel, ValidationError + +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.palette.colors.written import ( + PALETTE_SOURCE_CONTEXT_KEY, + LiteralColor, + 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 = LiteralColor((240, 146, 86, 255)).faded(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/scripts/checks/test_palette_colors.py b/tests/unit/scripts/checks/test_palette_colors.py index 44bcc1097..9993fb072 100644 --- a/tests/unit/scripts/checks/test_palette_colors.py +++ b/tests/unit/scripts/checks/test_palette_colors.py @@ -36,6 +36,11 @@ def locations(source: str) -> List[str]: return [finding.location for finding in check_palette_colors.stored_colors(module)] +def theme_color_messages(source: str, path: Path = PANEL_MODULE) -> List[str]: + module = SourceModule(path=path, tree=parse_source(source)) + return [finding.message for finding in check_palette_colors.unregistered_theme_colors(module)] + + class TestStoredColors: def test_an_attribute_assigned_the_resolved_value_is_reported(self) -> None: assert len(messages(PANEL_SOURCE)) == 2 @@ -58,6 +63,29 @@ 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) -> None: + source = "def build() -> None:\n dpg.add_theme_color(dpg.mvThemeCol_Text, color.rgba)\n" + + assert len(theme_color_messages(source)) == 1 + + def test_the_helper_that_records_it_passes(self) -> None: + source = "def build() -> None:\n dpg_add_palette_theme_color(dpg.mvThemeCol_Text, color)\n" + + assert not theme_color_messages(source) + + def test_the_bindings_module_may_fill_it(self) -> None: + """The helper is where the call belongs, since it records the token in the same breath.""" + source = "def build() -> None:\n dpg.add_theme_color(dpg.mvThemeCol_Text, color.rgba)\n" + + assert not theme_color_messages(source, check_palette_colors.BINDINGS_MODULE) + + def test_a_theme_style_passes(self) -> None: + source = "def build() -> None:\n dpg.add_theme_style(dpg.mvStyleVar_ItemSpacing, 0, 0)\n" + + assert not theme_color_messages(source) + + 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) From 1e24aa28793cdaae2a5c3c201f33558371d0d432 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 11:27:00 +0200 Subject: [PATCH 004/152] Minor script check improvements --- scripts/checks/palette_colors.py | 12 +++- .../ui/elements/graphs/waveform.py | 6 +- tests/suite/scripts.py | 3 +- .../unit/scripts/checks/test_language_keys.py | 2 +- .../scripts/checks/test_palette_colors.py | 55 +++++++++++++------ tests/unit/scripts/checks/test_tag_names.py | 2 +- tests/unit/scripts/checks/test_unused_tags.py | 2 +- tests/unit/scripts/ci/checks/test_bundle.py | 2 +- .../scripts/ci/checks/test_version_tag.py | 2 +- tests/unit/scripts/ci/test_zip_bundle.py | 2 +- tests/unit/scripts/test_detect_cuda.py | 4 +- 11 files changed, 63 insertions(+), 29 deletions(-) diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py index e33d77d23..0e30daa70 100755 --- a/scripts/checks/palette_colors.py +++ b/scripts/checks/palette_colors.py @@ -90,6 +90,14 @@ def stored_colors(module: SourceModule) -> Iterator[ColorFinding]: ) +def dpg_module_helper() -> Tuple[Path, str]: + from sampletones_application.utils.gui.palette import dpg + + bindings_module = Path(dpg.__file__).resolve() + theme_color_helper = dpg.dpg_add_palette_theme_color.__name__ + return bindings_module, theme_color_helper + + def unregistered_theme_colors( module: SourceModule, *, @@ -172,13 +180,11 @@ def main(argv: Sequence[str]) -> int: import sampletones_application import sampletones_config - from sampletones_application.utils.gui.palette import dpg config_package = Path(sampletones_config.__file__).resolve() palettes_directory = config_package / "palettes" - bindings_module = Path(dpg.__file__).resolve() - theme_color_helper = dpg.dpg_add_palette_theme_color.__name__ + 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.", diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index 3528c455c..161bc7ef1 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -392,7 +392,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( diff --git a/tests/suite/scripts.py b/tests/suite/scripts.py index 54ebc436e..97a5263ec 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/unit/scripts/checks/test_language_keys.py b/tests/unit/scripts/checks/test_language_keys.py index 5c6c85740..9d5fe01fb 100644 --- a/tests/unit/scripts/checks/test_language_keys.py +++ b/tests/unit/scripts/checks/test_language_keys.py @@ -8,7 +8,7 @@ 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() diff --git a/tests/unit/scripts/checks/test_palette_colors.py b/tests/unit/scripts/checks/test_palette_colors.py index 9993fb072..e75b4c879 100644 --- a/tests/unit/scripts/checks/test_palette_colors.py +++ b/tests/unit/scripts/checks/test_palette_colors.py @@ -1,11 +1,14 @@ from pathlib import Path -from typing import Final, List +from typing import Final, List, Tuple + +from pytest import fixture from sampletones_shared.meta.source.modules import SourceModule +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("scripts/checks/palette_colors.py") +check_palette_colors = load_script("checks/palette_colors.py") PANEL_MODULE: Final[Path] = Path("ui/panel.py") @@ -26,6 +29,11 @@ def draw(self) -> None: 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)] @@ -36,9 +44,21 @@ def locations(source: str) -> List[str]: return [finding.location for finding in check_palette_colors.stored_colors(module)] -def theme_color_messages(source: str, path: Path = PANEL_MODULE) -> List[str]: +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)] + 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: @@ -64,26 +84,29 @@ def test_a_local_holding_the_resolved_value_passes(self) -> None: class TestUnregisteredThemeColors: - def test_a_theme_colour_filled_directly_is_reported(self) -> None: + 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)) == 1 + assert len(theme_color_messages(source, module_helpers)) == 1 - def test_the_helper_that_records_it_passes(self) -> None: + 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) - - def test_the_bindings_module_may_fill_it(self) -> None: - """The helper is where the call belongs, since it records the token in the same breath.""" - source = "def build() -> None:\n dpg.add_theme_color(dpg.mvThemeCol_Text, color.rgba)\n" - - assert not theme_color_messages(source, check_palette_colors.BINDINGS_MODULE) + assert not theme_color_messages(source, module_helpers) - def test_a_theme_style_passes(self) -> None: + 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) + assert not theme_color_messages(source, module_helpers) class TestLiteralColors: diff --git a/tests/unit/scripts/checks/test_tag_names.py b/tests/unit/scripts/checks/test_tag_names.py index 569118c6a..f7fbce233 100644 --- a/tests/unit/scripts/checks/test_tag_names.py +++ b/tests/unit/scripts/checks/test_tag_names.py @@ -12,7 +12,7 @@ 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") diff --git a/tests/unit/scripts/checks/test_unused_tags.py b/tests/unit/scripts/checks/test_unused_tags.py index 8a30e3a6f..7f27eb5b3 100644 --- a/tests/unit/scripts/checks/test_unused_tags.py +++ b/tests/unit/scripts/checks/test_unused_tags.py @@ -7,7 +7,7 @@ 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") diff --git a/tests/unit/scripts/ci/checks/test_bundle.py b/tests/unit/scripts/ci/checks/test_bundle.py index a2dd6b247..dceace1c6 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") diff --git a/tests/unit/scripts/ci/checks/test_version_tag.py b/tests/unit/scripts/ci/checks/test_version_tag.py index d3f1d204f..34b31d8f0 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): diff --git a/tests/unit/scripts/ci/test_zip_bundle.py b/tests/unit/scripts/ci/test_zip_bundle.py index 899525adc..a5c40633d 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 73795bb51..223e68247 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( From db3cf847d49901814e617bd6d2c65a7f62fb86a7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 12:28:13 +0200 Subject: [PATCH 005/152] Added: display option to application schemas --- scripts/checks/palette_colors.py | 33 +++-- scripts/checks/tag_names.py | 7 +- src/sampletones_application/application.py | 8 +- .../config/managers/application.py | 28 ++++ .../config/managers/session.py | 28 ++++ .../config/session/application/config.py | 5 + .../config/session/application/display.py | 36 +++++ .../layout/behavior/behavior.py | 4 +- .../layout/behavior/display.py | 40 +++++ .../layout/behavior/main.py | 7 - .../layout/behavior/ui.py | 1 + .../layout/general/window.py | 6 +- .../ui/elements/graphs/bar.py | 6 +- .../ui/elements/graphs/spectrum.py | 7 +- .../ui/elements/graphs/waveform.py | 7 +- .../ui/panels/sequencer/grid.py | 13 +- .../ui/panels/sequencer/order.py | 11 +- .../utils/frame_limiter.py | 11 +- src/sampletones_application/utils/monitors.py | 112 ++++++++++++++ .../utils/palette/colors/base.py | 58 +------- .../utils/palette/colors/blended.py | 19 +++ .../utils/palette/colors/faded.py | 18 +++ .../utils/palette/colors/grayscale.py | 17 +++ .../view_model/shared/display_settings.py | 94 ++++++++++++ src/sampletones_application/viewport.py | 137 ++++++------------ src/sampletones_config/behavior/general.yaml | 47 +++++- .../layout/general/window.yaml | 4 + src/sampletones_shared/display.py | 23 +++ .../session/application/test_display.py | 63 ++++++++ .../layout/__init__.py | 0 .../layout/behavior/__init__.py | 0 .../layout/behavior/test_display.py | 77 ++++++++++ .../sampletones_application/test_viewport.py | 41 ++++-- .../utils/gui/test_palette.py | 9 +- .../palette/{test_color.py => test_colors.py} | 41 ++++-- .../utils/palette/test_written.py | 8 +- .../utils/test_frame_limiter.py | 119 +++++++++++++++ .../utils/test_monitors.py | 118 +++++++++++++++ .../shared/test_display_settings.py | 121 ++++++++++++++++ .../scripts/checks/test_palette_colors.py | 27 +++- 40 files changed, 1193 insertions(+), 218 deletions(-) create mode 100644 src/sampletones_application/config/session/application/display.py create mode 100644 src/sampletones_application/layout/behavior/display.py delete mode 100644 src/sampletones_application/layout/behavior/main.py create mode 100644 src/sampletones_application/utils/monitors.py create mode 100644 src/sampletones_application/utils/palette/colors/blended.py create mode 100644 src/sampletones_application/utils/palette/colors/faded.py create mode 100644 src/sampletones_application/utils/palette/colors/grayscale.py create mode 100644 src/sampletones_application/view_model/shared/display_settings.py create mode 100644 src/sampletones_shared/display.py create mode 100644 tests/unit/sampletones_application/config/session/application/test_display.py create mode 100644 tests/unit/sampletones_application/layout/__init__.py create mode 100644 tests/unit/sampletones_application/layout/behavior/__init__.py create mode 100644 tests/unit/sampletones_application/layout/behavior/test_display.py rename tests/unit/sampletones_application/utils/palette/{test_color.py => test_colors.py} (52%) create mode 100644 tests/unit/sampletones_application/utils/test_frame_limiter.py create mode 100644 tests/unit/sampletones_application/utils/test_monitors.py create mode 100644 tests/unit/sampletones_application/view_model/shared/test_display_settings.py diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py index 0e30daa70..4baec1325 100755 --- a/scripts/checks/palette_colors.py +++ b/scripts/checks/palette_colors.py @@ -18,13 +18,18 @@ import logging import re import sys +from importlib.resources import files from itertools import chain from pathlib import Path from typing import Final, Iterator, List, NamedTuple, Sequence, 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.paths import CONFIG_DIRECTORY + +APPLICATION_PACKAGE: Final[Path] = Path(str(files("sampletones_application"))) HEX_COLOR: Final[re.Pattern[str]] = re.compile(r"[\"']#[0-9a-fA-F]{6}(?:[0-9a-fA-F]{2})?[\"']") @@ -91,11 +96,14 @@ def stored_colors(module: SourceModule) -> Iterator[ColorFinding]: def dpg_module_helper() -> Tuple[Path, str]: - from sampletones_application.utils.gui.palette import dpg + """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 - bindings_module = Path(dpg.__file__).resolve() - theme_color_helper = dpg.dpg_add_palette_theme_color.__name__ - return bindings_module, theme_color_helper + return Path(bindings.__file__).resolve(), bindings.dpg_add_palette_theme_color.__name__ def unregistered_theme_colors( @@ -108,12 +116,14 @@ def unregistered_theme_colors( 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 == bindings_module: + if module.path.resolve() == bindings_module: return for node in ast.walk(module.tree): @@ -177,13 +187,6 @@ def main(argv: Sequence[str]) -> int: """Report every colour the application stores resolved or the configuration writes out.""" logger.set_level(level=logging.ERROR) - - import sampletones_application - import sampletones_config - - config_package = Path(sampletones_config.__file__).resolve() - palettes_directory = config_package / "palettes" - bindings_module, theme_color_helper = dpg_module_helper() parser = argparse.ArgumentParser( @@ -192,19 +195,19 @@ def main(argv: Sequence[str]) -> int: parser.add_argument( "--package", type=Path, - default=Path(sampletones_application.__file__).resolve(), + default=APPLICATION_PACKAGE, help="package whose colour reads to check", ) parser.add_argument( "--config", type=Path, - default=config_package, + default=CONFIG_DIRECTORY, help="shipped configuration package whose colours must name palette tokens", ) parser.add_argument( "--palettes", type=Path, - default=palettes_directory, + default=PALETTES_DIRECTORY, help="directory holding the palettes, where colour values belong", ) arguments = parser.parse_args(list(argv)) diff --git a/scripts/checks/tag_names.py b/scripts/checks/tag_names.py index ebd1b60e9..682c653b2 100644 --- a/scripts/checks/tag_names.py +++ b/scripts/checks/tag_names.py @@ -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/src/sampletones_application/application.py b/src/sampletones_application/application.py index 30e22844c..d23b79edf 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -219,8 +219,8 @@ 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( @@ -258,9 +258,7 @@ def __init__( 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, ) diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py index 2bf8f91e1..016a5d85f 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -72,6 +72,34 @@ 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 favorites(self) -> Set[Path]: return self.config.favorites.paths diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index 0ce098a3e..ead3e5a59 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -127,6 +127,18 @@ 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 save_config(self) -> None: self._config_manager.save() self._state_manager.save() @@ -171,6 +183,22 @@ 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 advanced_settings(self) -> bool: return self._state_manager.advanced_settings diff --git a/src/sampletones_application/config/session/application/config.py b/src/sampletones_application/config/session/application/config.py index 5b7d73145..ffb360b0b 100644 --- a/src/sampletones_application/config/session/application/config.py +++ b/src/sampletones_application/config/session/application/config.py @@ -1,6 +1,7 @@ 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 @@ -18,6 +19,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.", 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 000000000..d59ba0d2b --- /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/layout/behavior/behavior.py b/src/sampletones_application/layout/behavior/behavior.py index 5dc3f816c..c374d2d8d 100644 --- a/src/sampletones_application/layout/behavior/behavior.py +++ b/src/sampletones_application/layout/behavior/behavior.py @@ -1,6 +1,6 @@ from pydantic import BaseModel -from sampletones_application.layout.behavior.main import MainBehavior +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 @@ -8,4 +8,4 @@ class BehaviorConfig(BaseModel, extra="forbid", frozen=True): scheduling: SchedulingBehavior ui: UIBehavior - main: MainBehavior + 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 000000000..b1e0134e0 --- /dev/null +++ b/src/sampletones_application/layout/behavior/display.py @@ -0,0 +1,40 @@ +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. + + 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) + + @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/main.py b/src/sampletones_application/layout/behavior/main.py deleted file mode 100644 index d2c4b86b8..000000000 --- a/src/sampletones_application/layout/behavior/main.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel, Field - - -class MainBehavior(BaseModel, extra="forbid", frozen=True): - fps_update_interval: float - vsync: bool - max_fps: int = Field(ge=0) diff --git a/src/sampletones_application/layout/behavior/ui.py b/src/sampletones_application/layout/behavior/ui.py index bc90d0ac2..d6754a974 100644 --- a/src/sampletones_application/layout/behavior/ui.py +++ b/src/sampletones_application/layout/behavior/ui.py @@ -3,3 +3,4 @@ class UIBehavior(BaseModel, extra="forbid", frozen=True): status_bar_display_time: float + fps_update_interval: float diff --git a/src/sampletones_application/layout/general/window.py b/src/sampletones_application/layout/general/window.py index 8f3d9f362..71f38219d 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/ui/elements/graphs/bar.py b/src/sampletones_application/ui/elements/graphs/bar.py index ff3ebd49b..3e5439e08 100644 --- a/src/sampletones_application/ui/elements/graphs/bar.py +++ b/src/sampletones_application/ui/elements/graphs/bar.py @@ -26,6 +26,7 @@ ) 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 @@ -171,7 +172,10 @@ def _bind_hover_theme(self) -> None: if layer is None: raise RuntimeError("No layers available to bind hover theme") - hover_color = layer.color.faded(fraction=self._hover_alpha / MAX_CHANNEL_VALUE) + 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_palette_theme_color( diff --git a/src/sampletones_application/ui/elements/graphs/spectrum.py b/src/sampletones_application/ui/elements/graphs/spectrum.py index d581eca98..efe814c17 100644 --- a/src/sampletones_application/ui/elements/graphs/spectrum.py +++ b/src/sampletones_application/ui/elements/graphs/spectrum.py @@ -16,6 +16,7 @@ ) 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 @@ -133,7 +134,11 @@ def _create_brightness_theme( 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 = color_dim.blended(color_bright, brightness / MAX_CHANNEL_VALUE) + color = BlendedColor( + start=color_dim, + end=color_bright, + fraction=brightness / MAX_CHANNEL_VALUE, + ) if color in self.themes: return self.themes[color] diff --git a/src/sampletones_application/ui/elements/graphs/waveform.py b/src/sampletones_application/ui/elements/graphs/waveform.py index 161bc7ef1..b5c7c7f43 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -29,6 +29,8 @@ ) 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 @@ -375,7 +377,10 @@ def _series_color( """ if shade is SeriesShade.DIMMED: reconstruction = self._layout.colors.waveform_reconstruction - return reconstruction.grayscale().faded(self._layout.waveform.reconstruction_dim_opacity) + return FadedColor( + color=GrayscaleColor(color=reconstruction), + fraction=self._layout.waveform.reconstruction_dim_opacity, + ) return layer.color diff --git a/src/sampletones_application/ui/panels/sequencer/grid.py b/src/sampletones_application/ui/panels/sequencer/grid.py index 2ba16f0ab..dd5df298b 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid.py +++ b/src/sampletones_application/ui/panels/sequencer/grid.py @@ -66,6 +66,7 @@ from sampletones_application.utils.gui.shortcuts.keys import HEX_KEYS, KEY_PAGE_DOWN, KEY_PAGE_UP, SIGN_KEYS from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut from sampletones_application.utils.gui.tooltip import show_tooltip +from sampletones_application.utils.palette.colors.faded import FadedColor from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) @@ -297,7 +298,12 @@ def _create_subcolumn_themes(self) -> None: fraction = self._layout.tracker.muted_text_fraction for subcolumn, color in theme_colors.items(): self._subcolumn_themes[subcolumn] = create_selectable_text_theme(color) - self._muted_subcolumn_themes[subcolumn] = create_selectable_text_theme(color.faded(fraction)) + self._muted_subcolumn_themes[subcolumn] = create_selectable_text_theme( + FadedColor( + color=color, + fraction=fraction, + ), + ) def _create_header_themes(self) -> None: """Builds the two shades a channel's header label takes: audible and silenced. @@ -477,7 +483,10 @@ def _channel_column_tint(self, generator: GeneratorName) -> ColorRGBA: return self._layout.colors.muted.background.rgba channel = channel_color(self._layout.colors.channels, generator) - return channel.faded(self._layout.tracker.channel_column_tint).rgba + return FadedColor( + color=channel, + fraction=self._layout.tracker.channel_column_tint, + ).rgba def _compute_cell_values( self, diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 9a49c094f..ffa004571 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -57,6 +57,7 @@ from sampletones_application.utils.gui.shortcuts.keys import HEX_KEYS from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut 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, ) @@ -256,7 +257,10 @@ def _create_entry_themes(self) -> None: colors = self._layout.colors self._entry_theme = create_selectable_text_theme(colors.text.order) self._muted_entry_theme = create_selectable_text_theme( - colors.text.order.faded(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( colors.label, @@ -545,7 +549,10 @@ def _channel_row_tint(self, generator: GeneratorName) -> ColorRGBA: return self._layout.colors.muted.background.rgba channel = channel_color(self._layout.colors.channels, generator) - return channel.faded(self._layout.tracker.channel_column_tint).rgba + return FadedColor( + color=channel, + fraction=self._layout.tracker.channel_column_tint, + ).rgba def _apply_column_highlight(self, position: int, *, focused: bool) -> None: if focused: diff --git a/src/sampletones_application/utils/frame_limiter.py b/src/sampletones_application/utils/frame_limiter.py index f8971964a..9e841a2b3 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/monitors.py b/src/sampletones_application/utils/monitors.py new file mode 100644 index 000000000..686a8be21 --- /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/colors/base.py b/src/sampletones_application/utils/palette/colors/base.py index b51b31958..c8db56333 100644 --- a/src/sampletones_application/utils/palette/colors/base.py +++ b/src/sampletones_application/utils/palette/colors/base.py @@ -1,10 +1,7 @@ -from __future__ import annotations - from abc import ABC, abstractmethod from dataclasses import dataclass from sampletones_shared.types.application import ColorRGBA -from sampletones_shared.utils.color import blend, to_grayscale, with_alpha_fraction @dataclass(frozen=True) @@ -12,56 +9,17 @@ 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. + 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.""" - - def faded(self, fraction: float) -> BaseColor: - """This colour at ``fraction`` of full opacity, keeping its red, green and blue.""" - - @dataclass(frozen=True) - class FadedColor(BaseColor): - - base: BaseColor - fraction: float - - @property - def rgba(self) -> ColorRGBA: - return with_alpha_fraction(self.base.rgba, self.fraction) - - return FadedColor(self, fraction) - - def grayscale(self) -> BaseColor: - """This colour desaturated to the gray of the same luminance, keeping its alpha.""" - - @dataclass(frozen=True) - class GrayscaleColor(BaseColor): - - base: BaseColor - - @property - def rgba(self) -> ColorRGBA: - return to_grayscale(self.base.rgba) - - return GrayscaleColor(self) - - def blended(self, other: BaseColor, fraction: float) -> BaseColor: - """The colour ``fraction`` of the way from this one to ``other``, channel by channel.""" - - @dataclass(frozen=True) - class BlendedColor(BaseColor): - start: BaseColor - end: BaseColor - fraction: float - - @property - def rgba(self) -> ColorRGBA: - return blend(self.start.rgba, self.end.rgba, self.fraction) - - return BlendedColor(self, other, fraction) 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 000000000..cc77ad782 --- /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 000000000..a25f77976 --- /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 000000000..b803eef08 --- /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/view_model/shared/display_settings.py b/src/sampletones_application/view_model/shared/display_settings.py new file mode 100644 index 000000000..9fbc903dd --- /dev/null +++ b/src/sampletones_application/view_model/shared/display_settings.py @@ -0,0 +1,94 @@ +from typing import Tuple + +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, + ), + ) diff --git a/src/sampletones_application/viewport.py b/src/sampletones_application/viewport.py index ae6063164..03a768341 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 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,19 +46,37 @@ 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() + def refresh_clear_color(self) -> None: """Paints the area around the windows in the main theme's background colour. @@ -117,25 +127,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, @@ -145,65 +136,31 @@ 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() - - 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)) + area = monitor_area_for_window( + x, + y, + width, + height, + usable_ratio=self._window.max_monitor_ratio, + fallback_monitor=self._window.fallback_monitor, + ) + fitted_width = max(self._window.min_width, min(width, area.usable_width)) + fitted_height = max(self._window.min_height, min(height, area.usable_height)) - 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( - 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 diff --git a/src/sampletones_config/behavior/general.yaml b/src/sampletones_config/behavior/general.yaml index 4fb8cce42..bfb81b52f 100644 --- a/src/sampletones_config/behavior/general.yaml +++ b/src/sampletones_config/behavior/general.yaml @@ -14,8 +14,47 @@ 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 diff --git a/src/sampletones_config/layout/general/window.yaml b/src/sampletones_config/layout/general/window.yaml index f081b59ca..f6e07302b 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_shared/display.py b/src/sampletones_shared/display.py new file mode 100644 index 000000000..8df5d055a --- /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/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 000000000..0d6ce7b5d --- /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/layout/__init__.py b/tests/unit/sampletones_application/layout/__init__.py new file mode 100644 index 000000000..e69de29bb 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 000000000..e69de29bb 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 000000000..78e9bf53a --- /dev/null +++ b/tests/unit/sampletones_application/layout/behavior/test_display.py @@ -0,0 +1,77 @@ +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] + + +def behavior(**overrides: Any) -> DisplayBehavior: + return DisplayBehavior.model_validate({"resolutions": RESOLUTIONS, "frame_rates": FRAME_RATES, **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.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 diff --git a/tests/unit/sampletones_application/test_viewport.py b/tests/unit/sampletones_application/test_viewport.py index e1690e174..00b340307 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 @@ -58,7 +71,7 @@ def test_result_stays_within_usable_area( monkeypatch: pytest.MonkeyPatch, ) -> None: monkeypatch.setattr( - "sampletones_application.viewport.get_monitors", + "sampletones_application.utils.monitors.get_monitors", lambda: list(case.monitors), ) manager = _manager() @@ -75,7 +88,7 @@ def test_result_stays_within_usable_area( 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() @@ -84,7 +97,7 @@ def test_window_that_already_fits_is_unchanged(self, monkeypatch: pytest.MonkeyP 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() @@ -96,7 +109,7 @@ def test_monitor_sized_window_is_shrunk_below_monitor(self, monkeypatch: pytest. 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,13 +119,12 @@ 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) @@ -120,18 +132,17 @@ def test_falls_back_to_screen_dimensions_without_monitors(self, monkeypatch: pyt assert x + width <= 1920 assert y + height <= 1080 - def test_falls_back_to_screen_dimensions_when_enumeration_fails(self, monkeypatch: pytest.MonkeyPatch) -> None: + 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) diff --git a/tests/unit/sampletones_application/utils/gui/test_palette.py b/tests/unit/sampletones_application/utils/gui/test_palette.py index c11732f3b..a34344754 100644 --- a/tests/unit/sampletones_application/utils/gui/test_palette.py +++ b/tests/unit/sampletones_application/utils/gui/test_palette.py @@ -6,7 +6,9 @@ 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.written import LiteralColor, NamedColor +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 @@ -150,7 +152,10 @@ def test_a_derived_colour_follows_the_colour_it_came_from( ) -> None: with dpg.theme(): with dpg.theme_component(dpg.mvAll): - item = dpg_add_palette_theme_color(dpg.mvThemeCol_Text, accent.faded(0.5)) + item = dpg_add_palette_theme_color( + dpg.mvThemeCol_Text, + FadedColor(color=accent, fraction=0.5), + ) source.activate(light) PaletteBindings.apply() diff --git a/tests/unit/sampletones_application/utils/palette/test_color.py b/tests/unit/sampletones_application/utils/palette/test_colors.py similarity index 52% rename from tests/unit/sampletones_application/utils/palette/test_color.py rename to tests/unit/sampletones_application/utils/palette/test_colors.py index abe05265a..9a5302e95 100644 --- a/tests/unit/sampletones_application/utils/palette/test_color.py +++ b/tests/unit/sampletones_application/utils/palette/test_colors.py @@ -1,7 +1,11 @@ import pytest from sampletones_application.utils.palette.colors.base import BaseColor -from sampletones_application.utils.palette.colors.written import LiteralColor, NamedColor +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 @@ -18,57 +22,64 @@ def accent(source: PaletteSource) -> BaseColor: ) -class TestDerivedColor: +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 accent.faded(0.5).rgba == (169, 127, 227, 128) + 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 accent.grayscale().rgba == (gray, gray, gray, 255) + assert GrayscaleColor(color=accent).rgba == (gray, gray, gray, 255) def test_mixing_lands_between_the_two_ends(self) -> None: - assert BLACK.blended(WHITE, 0.5).rgba == (128, 128, 128, 255) + assert BlendedColor(start=BLACK, end=WHITE, fraction=0.5).rgba == (128, 128, 128, 255) - def test_a_derived_colour_answers_with_the_newly_activated_palette( + def test_a_composed_colour_answers_with_the_newly_activated_palette( self, source: PaletteSource, light: Palette, accent: BaseColor, ) -> None: - faded = accent.faded(0.5) + faded = FadedColor(color=accent, fraction=0.5) source.activate(light) assert faded.rgba == (107, 63, 176, 128) - def test_derivations_compose( + def test_compositions_nest( self, source: PaletteSource, light: Palette, accent: BaseColor, ) -> None: - dimmed = accent.grayscale().faded(0.25) + 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_derivation_of_the_same_colour_is_one_value( + 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 {accent.faded(0.5), accent.faded(0.5), accent.faded(0.25)} == { - accent.faded(0.5), - accent.faded(0.25), + 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_derivation_of_two_colours_stays_two_values( + def test_the_same_composition_of_two_colours_stays_two_values( self, accent: BaseColor, ) -> None: - assert accent.faded(0.5) != WHITE.faded(0.5) + assert FadedColor(color=accent, fraction=0.5) != FadedColor(color=WHITE, fraction=0.5) diff --git a/tests/unit/sampletones_application/utils/palette/test_written.py b/tests/unit/sampletones_application/utils/palette/test_written.py index b2e96e89d..03388d55b 100644 --- a/tests/unit/sampletones_application/utils/palette/test_written.py +++ b/tests/unit/sampletones_application/utils/palette/test_written.py @@ -2,9 +2,10 @@ 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, - LiteralColor, WrittenColor, ) from sampletones_application.utils.palette.palette import Palette @@ -31,7 +32,10 @@ def test_a_reference_alpha_override_is_applied(self, source: PaletteSource) -> N 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 = LiteralColor((240, 146, 86, 255)).faded(0.5) + color: BaseColor = FadedColor( + color=LiteralColor((240, 146, 86, 255)), + fraction=0.5, + ) assert _swatch(color, source).color is color 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 000000000..286afae5e --- /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 000000000..db16bfdb0 --- /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/view_model/shared/test_display_settings.py b/tests/unit/sampletones_application/view_model/shared/test_display_settings.py new file mode 100644 index 000000000..081ddb616 --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/test_display_settings.py @@ -0,0 +1,121 @@ +from typing import Tuple + +import pytest + +from sampletones_application.view_model.shared.display_settings import ( + 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, ()) diff --git a/tests/unit/scripts/checks/test_palette_colors.py b/tests/unit/scripts/checks/test_palette_colors.py index e75b4c879..0d51b8a72 100644 --- a/tests/unit/scripts/checks/test_palette_colors.py +++ b/tests/unit/scripts/checks/test_palette_colors.py @@ -3,7 +3,9 @@ from pytest import fixture -from sampletones_shared.meta.source.modules import SourceModule +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 @@ -100,6 +102,16 @@ def test_the_helper_that_records_it_passes( 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], @@ -109,6 +121,19 @@ def test_a_theme_style_passes( 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) From 0cc8094b40748f4443438ebc706a9f66185e613d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 13:17:24 +0200 Subject: [PATCH 006/152] Added: display settings dialog box --- .pylintrc | 13 - pyproject.toml | 32 +- scripts/calibration.py | 18 +- scripts/checks/palette_colors.py | 3 +- src/sampletones_application/application.py | 41 +- .../categories/elements/global_.py | 1 + .../categories/hierarchy.py | 1 + .../coordinators/display.py | 260 ++++++++++ .../layout/behavior/display.py | 4 +- .../layout/settings/__init__.py | 14 +- .../layout/settings/audio.py | 9 + .../layout/settings/display.py | 8 + src/sampletones_application/shell.py | 6 + src/sampletones_application/tags/settings.py | 86 +++ src/sampletones_application/ui/menu.py | 5 + .../ui/panels/dialogs/audio_settings.py | 8 +- .../ui/panels/dialogs/countdown.py | 141 +++++ .../ui/panels/dialogs/display_settings.py | 300 +++++++++++ .../utils/gui/shortcuts/ids.py | 1 + .../view_model/shared/display_settings.py | 164 +++++- src/sampletones_application/viewport.py | 35 +- src/sampletones_config/behavior/general.yaml | 1 + src/sampletones_config/lang/en.yaml | 21 + .../layout/settings/audio.yaml | 7 + .../layout/settings/display.yaml | 6 + .../layout/settings/master_gain.yaml | 3 - .../layout/settings/window.yaml | 2 - tests/suite/base.py | 4 +- .../coordinators/test_display.py | 488 ++++++++++++++++++ .../layout/behavior/test_display.py | 20 +- .../panels/dialogs/test_display_settings.py | 244 +++++++++ .../backends/portal/test_client.py | 4 +- .../shared/test_display_settings.py | 115 +++++ tests/unit/scripts/test_detect_cuda.py | 12 +- 34 files changed, 2016 insertions(+), 61 deletions(-) delete mode 100644 .pylintrc create mode 100644 src/sampletones_application/coordinators/display.py create mode 100644 src/sampletones_application/layout/settings/audio.py create mode 100644 src/sampletones_application/layout/settings/display.py create mode 100644 src/sampletones_application/ui/panels/dialogs/countdown.py create mode 100644 src/sampletones_application/ui/panels/dialogs/display_settings.py create mode 100644 src/sampletones_config/layout/settings/audio.yaml create mode 100644 src/sampletones_config/layout/settings/display.yaml delete mode 100644 src/sampletones_config/layout/settings/master_gain.yaml delete mode 100644 src/sampletones_config/layout/settings/window.yaml create mode 100644 tests/unit/sampletones_application/coordinators/test_display.py create mode 100644 tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 1a9882384..000000000 --- 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/pyproject.toml b/pyproject.toml index d1ccdc224..66b9a2684 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,28 @@ 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-arguments", + "too-many-instance-attributes", + "too-many-lines", + "too-many-return-statements", + "too-many-statements", +] + +[tool.pylint.format] +max-line-length = 120 + +[tool.pylint.typecheck] +ignored-modules = ["pydantic"] diff --git a/scripts/calibration.py b/scripts/calibration.py index 83a098a4d..e092868ed 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/palette_colors.py b/scripts/checks/palette_colors.py index 4baec1325..246bdcd17 100755 --- a/scripts/checks/palette_colors.py +++ b/scripts/checks/palette_colors.py @@ -18,10 +18,11 @@ import logging import re import sys +from collections.abc import Iterator, Sequence from importlib.resources import files from itertools import chain from pathlib import Path -from typing import Final, Iterator, List, NamedTuple, Sequence, Tuple, Union +from typing import Final, List, NamedTuple, Tuple, Union from sampletones_application.paths import PALETTES_DIRECTORY from sampletones_shared.logger import logger diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index d23b79edf..55d8a9aed 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -12,6 +12,7 @@ from sampletones_application.config.managers.config import ConfigManager from sampletones_application.config.managers.session import SessionManager from sampletones_application.coordinators.config import ConfigCoordinator +from sampletones_application.coordinators.display import DisplayCoordinator from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol from sampletones_application.coordinators.playback.router import PlaybackRouter @@ -85,6 +86,10 @@ 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.project_properties import ( GUIProjectPropertiesWindow, ) @@ -165,8 +170,11 @@ def __init__( self.deployment: DeploymentConfig = DeploymentConfig.load(DEPLOYMENT_CONFIG_PATH) self._set_logging_level() + self.session_manager = SessionManager() self._palette_catalog: PaletteCatalog = PaletteCatalog.load(PALETTES_DIRECTORY) - self._palette_source: PaletteSource = PaletteSource(self._palette_catalog.default) + 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() @@ -185,7 +193,6 @@ def __init__( ) self.audio_device_manager: AudioDeviceManager = AudioDeviceManager() self.config_manager = ConfigManager(config_path) - self.session_manager = SessionManager() self.library_manager = InstructionsLibraryManager( self.config_manager, @@ -231,6 +238,20 @@ def __init__( 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, + ) + 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, + ) self.project_properties_window: GUIProjectPropertiesWindow = GUIProjectPropertiesWindow( layout=self.layout.project_properties, language_manager=self.language_manager, @@ -262,6 +283,20 @@ def __init__( 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._project_coordinator = ProjectCoordinator( self.project_controller, self.project_manager, @@ -501,6 +536,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: toggle_channel=self._sequencer_tab.toggle_channel, unmute_all_channels=self._sequencer_tab.unmute_all_channels, audio_settings=self._open_audio_settings, + display_settings=self._display_coordinator.open, toggle_advanced_settings=self._toggle_advanced_settings, toggle_fullscreen=self._shell.toggle_fullscreen, about=self._open_about_dialog, @@ -1218,6 +1254,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: diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 7071a0831..1e1d351df 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -101,6 +101,7 @@ 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" GROUP_HELP = "group_help" ITEM_HELP_ABOUT = "item_help_about" TAB_MAIN = "tab_main" diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py index 56e0d40da..0b21d0aa6 100644 --- a/src/sampletones_application/categories/hierarchy.py +++ b/src/sampletones_application/categories/hierarchy.py @@ -92,4 +92,5 @@ class Panel(StrEnum): # Settings AUDIO = auto() + DISPLAY = auto() PROPERTIES = auto() diff --git a/src/sampletones_application/coordinators/display.py b/src/sampletones_application/coordinators/display.py new file mode 100644 index 000000000..ad8d89171 --- /dev/null +++ b/src/sampletones_application/coordinators/display.py @@ -0,0 +1,260 @@ +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, so a run of unconfirmed changes still returns to the mode last seen as readable. + """ + if self._armed is None: + self._armed = restorable + + self._remaining = self._behavior.revert_countdown_seconds + self._countdown.open(self._displayed_seconds()) + + def _disarm(self) -> None: + self._armed = None + self._remaining = 0.0 + self._countdown.hide() + + 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.""" + if self._require_settings() == self._snapshot: + self._discard() + return + + self._window.reveal() + 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, + 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/layout/behavior/display.py b/src/sampletones_application/layout/behavior/display.py index b1e0134e0..7cf821a5d 100644 --- a/src/sampletones_application/layout/behavior/display.py +++ b/src/sampletones_application/layout/behavior/display.py @@ -6,7 +6,8 @@ class DisplayBehavior(BaseModel, extra="forbid", frozen=True): - """What the display settings offer: the window sizes and the frame rates a user picks from. + """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 @@ -15,6 +16,7 @@ class DisplayBehavior(BaseModel, extra="forbid", frozen=True): 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 diff --git a/src/sampletones_application/layout/settings/__init__.py b/src/sampletones_application/layout/settings/__init__.py index 35eb09aae..aba706933 100644 --- a/src/sampletones_application/layout/settings/__init__.py +++ b/src/sampletones_application/layout/settings/__init__.py @@ -1,11 +1,17 @@ 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 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 diff --git a/src/sampletones_application/layout/settings/audio.py b/src/sampletones_application/layout/settings/audio.py new file mode 100644 index 000000000..ca8eaad9d --- /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 000000000..c5ef12341 --- /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/shell.py b/src/sampletones_application/shell.py index b58edf611..f65436894 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -115,6 +115,7 @@ class ShortcutBindings: toggle_channel: Callable[[GeneratorName], None] unmute_all_channels: Callback audio_settings: Callback + display_settings: Callback toggle_advanced_settings: Callback toggle_fullscreen: Callback about: Callback @@ -318,6 +319,11 @@ def _register_shortcuts(self, bindings: ShortcutBindings) -> None: Shortcut(dpg.mvKey_F11), bindings.toggle_fullscreen, ) + self._shortcut_manager.register( + ShortcutId.DISPLAY_SETTINGS, + Shortcut(), + bindings.display_settings, + ) self._shortcut_manager.register( ShortcutId.TOGGLE_ADVANCED_SETTINGS, Shortcut(dpg.mvKey_A, CTRL_SHIFT), diff --git a/src/sampletones_application/tags/settings.py b/src/sampletones_application/tags/settings.py index 35acc794e..adb190333 100644 --- a/src/sampletones_application/tags/settings.py +++ b/src/sampletones_application/tags/settings.py @@ -50,6 +50,92 @@ "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_PROPERTIES_WINDOW = TagName( Page.SETTINGS, Panel.PROPERTIES, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 34edc06d2..6286e52b0 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -420,6 +420,11 @@ 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), + ) def _create_help_menu(self) -> None: with dpg.menu(label=self._label(MenuElements.GROUP_HELP)): diff --git a/src/sampletones_application/ui/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py index 037deb33c..96415fac8 100644 --- a/src/sampletones_application/ui/panels/dialogs/audio_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/audio_settings.py @@ -74,8 +74,8 @@ 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, ) def open(self, view_model: AudioSettingsViewModel) -> None: @@ -192,7 +192,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,7 +230,7 @@ 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 + colors = self._layout.audio.master_gain return blend(colors.label_color.rgba, colors.clip_color.rgba, clip_fraction) @table_wrapper(columns=2) 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 000000000..9798a83e1 --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/countdown.py @@ -0,0 +1,141 @@ +from typing import Any, 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.window import GUIWindow +from sampletones_application.utils.gui.align import table_wrapper +from sampletones_application.utils.gui.dialog_navigation import ( + DialogKeyboardNavigator, + FocusStop, +) +from sampletones_application.utils.gui.dpg import dpg_set_value +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_shared.types.callback import VoidCallback + + +class GUICountdownWindow(GUIWindow): + """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. + + Stacking over the dialog that armed it keeps that dialog on screen, so the change is judged + against the window it was made in. + """ + + def __init__( + self, + *, + layout: Dimensions, + title: str, + message: str, + remaining_format: str, + keep_label: str, + revert_label: str, + key_router: KeyRouter, + ) -> None: + self._title = title + self._message = message + self._remaining_format = remaining_format + self._keep_label = keep_label + self._revert_label = revert_label + self._router = key_router + self._navigator: Optional[DialogKeyboardNavigator] = None + 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, + ) + + 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 dpg.window( + tag=self.tag, + label=self._title, + width=self.width, + height=self.height, + no_resize=True, + no_collapse=True, + no_close=True, + autosize=True, + modal=True, + ): + 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() + + 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 _install_navigation(self) -> None: + """Wires Tab/Enter/Escape over the two answers, with Escape reading as reverting.""" + self._navigator = DialogKeyboardNavigator( + window_tag=self.tag, + stops=[ + FocusStop.button(TAG_SETTINGS_DISPLAY_BUTTON_REVERT, self._revert), + FocusStop.button(TAG_SETTINGS_DISPLAY_BUTTON_KEEP, self._keep), + ], + on_escape=self._revert, + key_router=self._router, + initial_index=1, + ) + self._navigator.install() + + def _teardown(self) -> None: + if self._navigator is not None: + self._navigator.dispose() + self._navigator = None + + 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 000000000..8ed53f8a6 --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/display_settings.py @@ -0,0 +1,300 @@ +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.general import TAG_GLOBAL_THEME_DIALOG +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.field import labeled_field, subheader +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.dpg import dpg_configure_item, dpg_set_value +from sampletones_application.utils.gui.keyboard import KeyRouter +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(GUIWindow): + """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, + ) -> 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._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, + ) + + 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 reveal(self) -> None: + """Brings the window back after the title bar's close button hid it.""" + dpg_configure_item(self.tag, show=True) + + def create_window(self) -> None: + with dpg.window( + tag=self.tag, + label=self._language_manager["settings.display.title.window_title"], + width=self.width, + height=self.height, + no_resize=True, + no_collapse=True, + autosize=True, + on_close=self._request_cancel, + modal=True, + ): + 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() + + for combo_tag in ( + TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, + TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE, + TAG_SETTINGS_DISPLAY_COMBO_PALETTE, + ): + self._dialog_theme.bind_to_item(combo_tag) + + self._render() + self._install_navigation() + + 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 _install_navigation(self) -> None: + """Wires Tab/Enter/Escape navigation over the controls and buttons.""" + self._navigator = DialogKeyboardNavigator( + window_tag=self.tag, + stops=[ + 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, + key_router=self._router, + ) + self._navigator.install() + + def _teardown(self) -> None: + if self._navigator is not None: + self._navigator.dispose() + self._navigator = None + + 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/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 966fd475e..60b16f60c 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -44,6 +44,7 @@ class ShortcutId(Enum): TOGGLE_CHANNEL_NOISE = "ToggleChannelNoise" UNMUTE_ALL_CHANNELS = "UnmuteAllChannels" AUDIO_SETTINGS = "AudioSettings" + DISPLAY_SETTINGS = "DisplaySettings" TOGGLE_ADVANCED_SETTINGS = "ToggleAdvancedSettings" TOGGLE_FULLSCREEN = "ToggleFullscreen" ABOUT_DIALOG = "AboutDialog" diff --git a/src/sampletones_application/view_model/shared/display_settings.py b/src/sampletones_application/view_model/shared/display_settings.py index 9fbc903dd..74c7d74b1 100644 --- a/src/sampletones_application/view_model/shared/display_settings.py +++ b/src/sampletones_application/view_model/shared/display_settings.py @@ -1,4 +1,8 @@ -from typing import Tuple +from __future__ import annotations + +from typing import Dict, Tuple + +from pydantic import BaseModel from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution @@ -92,3 +96,161 @@ def nearest_resolution( 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/viewport.py b/src/sampletones_application/viewport.py index 03a768341..6c740dd59 100644 --- a/src/sampletones_application/viewport.py +++ b/src/sampletones_application/viewport.py @@ -8,7 +8,7 @@ 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 monitor_area_for_window +from sampletones_application.utils.monitors import MonitorArea, monitor_area_for_window from sampletones_shared.application import SAMPLETONES_NAME from sampletones_shared.types.callback import VoidCallback @@ -77,6 +77,13 @@ 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. @@ -141,14 +148,7 @@ def _fit_window_to_monitor( even a small requested size opens usably wide. The position is nudged inside the resulting margins so every edge lands within the monitor. """ - area = monitor_area_for_window( - x, - y, - width, - height, - usable_ratio=self._window.max_monitor_ratio, - fallback_monitor=self._window.fallback_monitor, - ) + 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)) @@ -164,3 +164,20 @@ def _fit_window_to_monitor( ) return fitted_x, fitted_y, fitted_width, fitted_height + + def _monitor_area( + self, + x: int, + y: int, + width: int, + height: int, + ) -> 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/behavior/general.yaml b/src/sampletones_config/behavior/general.yaml index bfb81b52f..1d4e14a92 100644 --- a/src/sampletones_config/behavior/general.yaml +++ b/src/sampletones_config/behavior/general.yaml @@ -58,3 +58,4 @@ display: - 144 - 165 - 240 + revert_countdown_seconds: 10.0 diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 84d6ead29..72e12892e 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -199,6 +199,7 @@ 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.group_help: "Help" global.menu.label.item_help_about: "About" global.menu.label.tab_main: "Main" @@ -636,6 +637,26 @@ 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.properties.title.window_title: "Project properties" settings.properties.label.title: "Title" settings.properties.label.author: "Author" diff --git a/src/sampletones_config/layout/settings/audio.yaml b/src/sampletones_config/layout/settings/audio.yaml new file mode 100644 index 000000000..3bed24006 --- /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 000000000..3b53ce894 --- /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/master_gain.yaml b/src/sampletones_config/layout/settings/master_gain.yaml deleted file mode 100644 index 1f61d9389..000000000 --- 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 e81fbbbe2..000000000 --- a/src/sampletones_config/layout/settings/window.yaml +++ /dev/null @@ -1,2 +0,0 @@ -width: 600 -height: 0 diff --git a/tests/suite/base.py b/tests/suite/base.py index 64d31603d..11d798862 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/unit/sampletones_application/coordinators/test_display.py b/tests/unit/sampletones_application/coordinators/test_display.py new file mode 100644 index 000000000..8dd75a318 --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/test_display.py @@ -0,0 +1,488 @@ +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 + +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: + def __init__(self) -> None: + self.view_models: List[DisplaySettingsViewModel] = [] + self.visible = False + self.reveals = 0 + 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 reveal(self) -> None: + self.reveals += 1 + + 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"]() + + +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_and_stays_open(self, harness: Harness) -> None: + harness.change(harness.settings.with_palette(DARK)) + harness.cancel() + + assert len(harness.dialogs.confirmations) == 1 + assert harness.window.visible + assert harness.window.reveals == 1 + + 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 == 2 + assert harness.countdown.hides == 0 + + 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_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/layout/behavior/test_display.py b/tests/unit/sampletones_application/layout/behavior/test_display.py index 78e9bf53a..46688029a 100644 --- a/tests/unit/sampletones_application/layout/behavior/test_display.py +++ b/tests/unit/sampletones_application/layout/behavior/test_display.py @@ -18,9 +18,18 @@ 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, **overrides}) + return DisplayBehavior.model_validate( + { + "resolutions": RESOLUTIONS, + "frame_rates": FRAME_RATES, + "revert_countdown_seconds": COUNTDOWN_SECONDS, + **overrides, + } + ) class TestDisplayBehavior: @@ -60,6 +69,12 @@ 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: @@ -75,3 +90,6 @@ def test_the_unlimited_setting_is_offered(self, display: DisplayBehavior) -> Non 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/ui/panels/dialogs/test_display_settings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py new file mode 100644 index 000000000..f8833ecbb --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py @@ -0,0 +1,244 @@ +from typing import Iterator, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.settings import SettingsLayout +from sampletones_application.paths import LANG_EN, LAYOUT_DIRECTORY +from sampletones_application.tags.general import TAG_GLOBAL_THEME_DIALOG +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.elements.fonts.registry import FontRegistry +from sampletones_application.ui.panels.dialogs.display_settings import ( + GUIDisplaySettingsWindow, +) +from sampletones_application.ui.themes.items import ThemeItems +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.ui.themes.theme import Theme +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.view_model.shared.display_settings import ( + DisplaySettings, + DisplaySettingsViewModel, + WindowMode, +) +from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution +from sampletones_shared.utils.serialization import load_yaml_model_dir +from tests.unit.sampletones_application.utils.palette.helpers import build_palette + +UNLIMITED_LABEL = "Unlimited" + +RESOLUTIONS: Tuple[Resolution, ...] = ( + Resolution(width=1024, height=768), + Resolution(width=1280, height=800), + Resolution(width=1600, height=900), +) +FRAME_RATES: Tuple[int, ...] = (UNLIMITED_FRAME_RATE, 30, 60, 120) +PALETTES: 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="dpg_context") +def dpg_context_fixture() -> Iterator[None]: + dpg.create_context() + FontRegistry.register_fonts() + ThemeRegistry.register(Theme(tag=TAG_GLOBAL_THEME_DIALOG, items=ThemeItems())) + try: + yield + finally: + ThemeRegistry.clear() + dpg.destroy_context() + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None) -> GUIDisplaySettingsWindow: + layout = load_yaml_model_dir( + LAYOUT_DIRECTORY / "settings", + SettingsLayout, + context={"palette_source": PaletteSource(build_palette())}, + ) + return GUIDisplaySettingsWindow( + layout=layout, + language_manager=LanguageManager(LANG_EN), + key_router=KeyRouter(), + ) + + +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/utils/file_dialogs/backends/portal/test_client.py b/tests/unit/sampletones_application/utils/file_dialogs/backends/portal/test_client.py index 7342d00e9..1d8060a60 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 @@ -82,7 +84,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: 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 index 081ddb616..10dfd9fdd 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_display_settings.py +++ b/tests/unit/sampletones_application/view_model/shared/test_display_settings.py @@ -3,6 +3,9 @@ import pytest from sampletones_application.view_model.shared.display_settings import ( + DisplaySettings, + DisplaySettingsViewModel, + WindowMode, available_resolutions, frame_rate_label, frame_rate_labels, @@ -119,3 +122,115 @@ def test_a_window_between_two_sizes_selects_the_closer(self) -> None: 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/scripts/test_detect_cuda.py b/tests/unit/scripts/test_detect_cuda.py index 223e68247..00699ebe1 100644 --- a/tests/unit/scripts/test_detect_cuda.py +++ b/tests/unit/scripts/test_detect_cuda.py @@ -38,7 +38,7 @@ class TestCase(BaseRegularTestCase): cuda_version: Optional[Tuple[int, int]] expected: Optional[str] - test_cases = [ + 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"), @@ -47,10 +47,10 @@ class TestCase(BaseRegularTestCase): 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: + def test_select_extra(self, test_case: TestCase) -> None: assert detect_cuda.select_extra(test_case.cuda_version) == test_case.expected @@ -60,15 +60,15 @@ class TestCase(BaseRegularTestCase): output: str expected: Optional[Tuple[int, int]] - test_cases = [ + 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 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) From 72dd9b40d2958c252f6b09d2a01329049e60b45c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 14:55:27 +0200 Subject: [PATCH 007/152] General improvements --- pyproject.toml | 8 ++ scripts/checks/import_boundary.py | 0 scripts/checks/language_keys.py | 0 scripts/checks/tag_names.py | 0 scripts/checks/unused_tags.py | 0 scripts/ci/checks/bundle.py | 10 +- src/sampletones/__init__.py | 16 +-- src/sampletones_application/application.py | 28 ++--- .../config/deployment/deployment.py | 2 +- .../coordinators/tabs/reconstruction.py | 11 +- .../coordinators/tabs/sequencer.py | 6 +- src/sampletones_application/layout/config.py | 2 +- .../layout/general/section_header.py | 2 +- src/sampletones_application/layout/glyphs.py | 48 -------- .../layout/glyphs/__init__.py | 0 .../layout/glyphs/common.py | 10 ++ .../layout/glyphs/glyph.py | 7 ++ .../layout/glyphs/glyphs.py | 11 ++ .../layout/glyphs/header.py | 20 ++++ .../layout/glyphs/player.py | 8 ++ src/sampletones_application/layout/loader.py | 2 +- .../logic/history/manager.py | 4 +- .../logic/instruction/library.py | 6 +- .../services/conversion.py | 6 +- src/sampletones_application/shell.py | 26 ++-- .../ui/elements/graphs/bar.py | 2 +- .../ui/elements/graphs/graph.py | 7 +- .../ui/elements/graphs/spectrum.py | 11 +- .../ui/elements/layout/collapse.py | 2 +- .../ui/elements/panel.py | 113 ++++++++++-------- .../ui/elements/plus_minus_buttons.py | 14 ++- .../ui/elements/status.py | 2 +- .../ui/elements/tree/tree.py | 43 +++++-- src/sampletones_application/ui/menu.py | 7 +- .../ui/panels/dialogs/audio_settings.py | 2 +- .../ui/panels/instruction/choice.py | 7 +- .../ui/panels/instruction/library.py | 68 ++++++----- .../ui/panels/main/advanced.py | 2 +- .../ui/panels/main/config.py | 2 +- .../ui/panels/main/converter.py | 2 +- .../ui/panels/main/explorer.py | 66 +++++----- .../ui/panels/main/reconstructor.py | 2 +- .../ui/panels/player/controls.py | 2 +- .../ui/panels/reconstruction/audio.py | 2 +- .../ui/panels/reconstruction/browser.py | 64 +++++----- .../reconstruction/instruments/instruments.py | 15 ++- .../ui/panels/reconstruction/plot.py | 4 +- .../ui/panels/sequencer/browser.py | 60 +++++----- .../ui/panels/sequencer/grid.py | 99 ++++++++------- .../ui/panels/sequencer/history.py | 8 +- .../ui/panels/sequencer/module.py | 8 +- .../ui/panels/sequencer/order.py | 33 +++-- .../ui/panels/sequencer/samples.py | 66 +++++----- src/sampletones_application/utils/gui/dpg.py | 2 +- .../utils/gui/keyboard/router.py | 2 +- src/sampletones_core/data/model.py | 51 ++++++-- .../formats/famitracker/builder.py | 12 +- src/sampletones_core/scripts/library.py | 7 +- .../scripts/reconstruction.py | 24 ++-- src/sampletones_shared/array.py | 6 +- .../oscillators/exponential_glide.py | 2 +- .../oscillators/geometric_sweep.py | 2 +- .../oscillators/pulse.py | 2 +- src/sampletones_synthesis/oscillators/sine.py | 2 +- src/sampletones_synthesis/voice/layer.py | 10 +- .../ui/elements/layout/test_collapse.py | 26 ++-- 66 files changed, 648 insertions(+), 446 deletions(-) mode change 100644 => 100755 scripts/checks/import_boundary.py mode change 100644 => 100755 scripts/checks/language_keys.py mode change 100644 => 100755 scripts/checks/tag_names.py mode change 100644 => 100755 scripts/checks/unused_tags.py delete mode 100644 src/sampletones_application/layout/glyphs.py create mode 100644 src/sampletones_application/layout/glyphs/__init__.py create mode 100644 src/sampletones_application/layout/glyphs/common.py create mode 100644 src/sampletones_application/layout/glyphs/glyph.py create mode 100644 src/sampletones_application/layout/glyphs/glyphs.py create mode 100644 src/sampletones_application/layout/glyphs/header.py create mode 100644 src/sampletones_application/layout/glyphs/player.py diff --git a/pyproject.toml b/pyproject.toml index 66b9a2684..ab22dfaef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -163,9 +163,14 @@ disable = [ "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", ] @@ -173,5 +178,8 @@ disable = [ [tool.pylint.format] max-line-length = 120 +[tool.pylint.similarities] +min-similarity-lines = 5 + [tool.pylint.typecheck] ignored-modules = ["pydantic"] diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py old mode 100644 new mode 100755 diff --git a/scripts/checks/language_keys.py b/scripts/checks/language_keys.py old mode 100644 new mode 100755 diff --git a/scripts/checks/tag_names.py b/scripts/checks/tag_names.py old mode 100644 new mode 100755 diff --git a/scripts/checks/unused_tags.py b/scripts/checks/unused_tags.py old mode 100644 new mode 100755 diff --git a/scripts/ci/checks/bundle.py b/scripts/ci/checks/bundle.py index d804e773a..843e3efd9 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/src/sampletones/__init__.py b/src/sampletones/__init__.py index 3ce71ef46..936911a5e 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_application/application.py b/src/sampletones_application/application.py index 55d8a9aed..4f0159b91 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -580,7 +580,7 @@ def _set_callbacks(self) -> None: self._reconstructions_tab.set_can_add_to_sequencer(self._is_project_open) self._palette_source.on_palette_changed = self._on_palette_changed - def _on_palette_changed(self, palette: Palette) -> None: + def _on_palette_changed(self, _palette: Palette) -> None: """Repaints what holds a colour DearPyGui has copied, once another palette is in place. Every layout and theme colour already answers with the new palette, so the work left is @@ -592,7 +592,7 @@ def _on_palette_changed(self, palette: Palette) -> None: 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: @@ -665,36 +665,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, + _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) 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() diff --git a/src/sampletones_application/config/deployment/deployment.py b/src/sampletones_application/config/deployment/deployment.py index 43f2c9ecd..c86e23821 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/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index e9a5876fa..1bcd6ff87 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 @@ -660,12 +662,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 38a094c71..eaf3a0517 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1268,11 +1268,7 @@ def _select_frame_when_idle(self, frame_index: int) -> None: if not self._song_player_logic.is_playing(): self._sequencer_grid_logic.select_frame(frame_index) - def _on_tracker_cell_focused( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: + def _on_tracker_cell_focused(self) -> None: """Drops the order cursor and sample selection when the tracker grid takes focus. The tracker, order, and samples panels each register a key-router scope active only while diff --git a/src/sampletones_application/layout/config.py b/src/sampletones_application/layout/config.py index 8d0ceb755..4d586e721 100644 --- a/src/sampletones_application/layout/config.py +++ b/src/sampletones_application/layout/config.py @@ -3,7 +3,7 @@ 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/section_header.py b/src/sampletones_application/layout/general/section_header.py index 5cd74612d..0ed4731d7 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/glyphs.py b/src/sampletones_application/layout/glyphs.py deleted file mode 100644 index 969996aa1..000000000 --- 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 000000000..e69de29bb diff --git a/src/sampletones_application/layout/glyphs/common.py b/src/sampletones_application/layout/glyphs/common.py new file mode 100644 index 000000000..10fddf7b9 --- /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 000000000..bdbb26235 --- /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 000000000..f54ee443f --- /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 000000000..660fbbbd9 --- /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 000000000..5afa25fcc --- /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/loader.py b/src/sampletones_application/layout/loader.py index dafbdeb12..35b5ebacf 100644 --- a/src/sampletones_application/layout/loader.py +++ b/src/sampletones_application/layout/loader.py @@ -4,7 +4,7 @@ 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 diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py index 236bff43a..ac591141c 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 5636a9e5c..ba9984da5 100644 --- a/src/sampletones_application/logic/instruction/library.py +++ b/src/sampletones_application/logic/instruction/library.py @@ -367,7 +367,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/services/conversion.py b/src/sampletones_application/services/conversion.py index d47a73225..5b07c9ed6 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/shell.py b/src/sampletones_application/shell.py index f65436894..dd66d3e42 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -477,20 +477,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/ui/elements/graphs/bar.py b/src/sampletones_application/ui/elements/graphs/bar.py index 3e5439e08..2608be25f 100644 --- a/src/sampletones_application/ui/elements/graphs/bar.py +++ b/src/sampletones_application/ui/elements/graphs/bar.py @@ -302,7 +302,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 19bdd7c1a..19e9c21ce 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/spectrum.py b/src/sampletones_application/ui/elements/graphs/spectrum.py index efe814c17..fc3c85e14 100644 --- a/src/sampletones_application/ui/elements/graphs/spectrum.py +++ b/src/sampletones_application/ui/elements/graphs/spectrum.py @@ -95,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() @@ -112,7 +112,12 @@ def load_library_fragment( 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: diff --git a/src/sampletones_application/ui/elements/layout/collapse.py b/src/sampletones_application/ui/elements/layout/collapse.py index a3a44cb53..616f4312d 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 7ab9c78d1..7a510d4e2 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/plus_minus_buttons.py b/src/sampletones_application/ui/elements/plus_minus_buttons.py index eef9289f4..73dc9edc5 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 ffa5686f4..d1204682b 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/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 54dd28388..3c7a2ad74 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.scheduling.scheduling 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, @@ -299,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) @@ -349,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 @@ -446,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"] @@ -462,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"] @@ -627,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 @@ -645,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/menu.py b/src/sampletones_application/ui/menu.py index 6286e52b0..82eefceb3 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -2,14 +2,17 @@ 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.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 ( diff --git a/src/sampletones_application/ui/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py index 96415fac8..79fa90bff 100644 --- a/src/sampletones_application/ui/panels/dialogs/audio_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/audio_settings.py @@ -248,7 +248,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/instruction/choice.py b/src/sampletones_application/ui/panels/instruction/choice.py index f2e0805d1..cd6da0ae4 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 2153df87d..af186d27b 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.scheduling.scheduling 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/main/advanced.py b/src/sampletones_application/ui/panels/main/advanced.py index 1054ed5e9..97becf51e 100644 --- a/src/sampletones_application/ui/panels/main/advanced.py +++ b/src/sampletones_application/ui/panels/main/advanced.py @@ -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 f3e427cc8..7495e5aba 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 27c168b38..93853a45c 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -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 43a92f4f4..0f477526d 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.scheduling.scheduling 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 8c0887285..287421eea 100644 --- a/src/sampletones_application/ui/panels/main/reconstructor.py +++ b/src/sampletones_application/ui/panels/main/reconstructor.py @@ -145,7 +145,7 @@ 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 _on_parameter_change(self, _sender: Sender, _app_data: Any) -> 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 99a8967d8..bc6487c0d 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 d0804998e..59a65183d 100644 --- a/src/sampletones_application/ui/panels/reconstruction/audio.py +++ b/src/sampletones_application/ui/panels/reconstruction/audio.py @@ -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 9fccf60fa..3873d72f5 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.scheduling.scheduling 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/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 99fc0104b..53502ff13 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -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, @@ -480,7 +485,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 +676,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 f65a3f9cb..b73913a06 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" @@ -233,5 +233,5 @@ 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 846cd2348..d6fe796a6 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.scheduling.scheduling 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/grid.py b/src/sampletones_application/ui/panels/sequencer/grid.py index dd5df298b..1164dab7b 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid.py +++ b/src/sampletones_application/ui/panels/sequencer/grid.py @@ -7,7 +7,10 @@ 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, @@ -63,7 +66,12 @@ 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.keys import ( + HEX_KEYS, + KEY_PAGE_DOWN, + KEY_PAGE_UP, + SIGN_KEYS, +) from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.utils.palette.colors.faded import FadedColor @@ -89,9 +97,9 @@ 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] @@ -343,14 +351,15 @@ def _create_tracker_view(self, parent: str) -> None: 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( + with ( + dpg.child_window( + tag=TAG_SEQUENCER_GRID_WINDOW_TRACKER, + parent=TAG_SEQUENCER_GRID_GROUP_TRACKER, + border=False, + width=0, + height=-1, + ), + dpg.table( tag=TAG_SEQUENCER_GRID_TABLE_TRACKER, width=0, header_row=False, @@ -364,30 +373,31 @@ 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) @@ -684,8 +694,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() @@ -882,7 +891,7 @@ def _remove_cell_highlight( 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) @@ -897,14 +906,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. @@ -1045,8 +1054,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 @@ -1054,8 +1063,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 @@ -1063,8 +1072,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 @@ -1246,7 +1255,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) @@ -1264,7 +1273,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 diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py index 9a823fe47..4f0a87c6c 100644 --- a/src/sampletones_application/ui/panels/sequencer/history.py +++ b/src/sampletones_application/ui/panels/sequencer/history.py @@ -357,16 +357,16 @@ def _role_color(self, role: HistoryDetailRole) -> BaseColor: 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/module.py b/src/sampletones_application/ui/panels/sequencer/module.py index 3364659d7..661163379 100644 --- a/src/sampletones_application/ui/panels/sequencer/module.py +++ b/src/sampletones_application/ui/panels/sequencer/module.py @@ -192,25 +192,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 ffa004571..b5138fdd8 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, @@ -53,7 +58,12 @@ KeyEvent, KeyRouter, ) -from sampletones_application.utils.gui.keyboard.modifiers import ALT, CTRL, SHIFT, Modifier +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.tooltip import show_tooltip @@ -766,17 +776,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. @@ -798,14 +815,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. diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index 62bf358ba..a619ec461 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -105,13 +105,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 +126,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: @@ -252,7 +254,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 @@ -409,23 +411,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 +442,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/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py index a8ff48204..98cd7dc37 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) diff --git a/src/sampletones_application/utils/gui/keyboard/router.py b/src/sampletones_application/utils/gui/keyboard/router.py index f9837e738..fe80f5565 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_core/data/model.py b/src/sampletones_core/data/model.py index 50112c9c1..82ca27112 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/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index 5cfa62594..d9fe39e87 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -22,13 +22,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, @@ -94,7 +98,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 +107,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 +134,6 @@ def _row_cell( ) instrument = slot.index note, octave = _note_and_octave( - reference, row.transpose or 0, channel_generator, slot, @@ -200,6 +203,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/scripts/library.py b/src/sampletones_core/scripts/library.py index 3384ca253..9c2c44859 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 80b49d756..8037aa256 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_shared/array.py b/src/sampletones_shared/array.py index 1e411e8c4..c3d68ae2c 100644 --- a/src/sampletones_shared/array.py +++ b/src/sampletones_shared/array.py @@ -48,9 +48,9 @@ def _preload_cuda_libraries() -> None: 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" diff --git a/src/sampletones_synthesis/oscillators/exponential_glide.py b/src/sampletones_synthesis/oscillators/exponential_glide.py index 19fb89bab..31254045a 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 05d6b803c..db207f3c8 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 84f027114..8bb5e90d4 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 c93c6b95c..3e2e96ed3 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 7bd2603a2..31b8429d7 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/unit/sampletones_application/ui/elements/layout/test_collapse.py b/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py index 09b4cff4e..6f994b712 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,18 @@ 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) From accd937e780eeab0ecb92c19d25218bc3eff5240 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 15:06:36 +0200 Subject: [PATCH 008/152] Added: display panel tests --- .../ui/panels/dialogs/__init__.py | 0 .../ui/panels/dialogs/conftest.py | 38 +++++++++ .../ui/panels/dialogs/test_countdown.py | 83 +++++++++++++++++++ .../panels/dialogs/test_display_settings.py | 46 +++------- 4 files changed, 132 insertions(+), 35 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/panels/dialogs/__init__.py create mode 100644 tests/unit/sampletones_application/ui/panels/dialogs/conftest.py create mode 100644 tests/unit/sampletones_application/ui/panels/dialogs/test_countdown.py 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 000000000..e69de29bb 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 000000000..514f516d7 --- /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 000000000..fa01990f1 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_countdown.py @@ -0,0 +1,83 @@ +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 + +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(), + ) + + +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 index f8833ecbb..47bfeef27 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py @@ -1,12 +1,11 @@ -from typing import Iterator, List, Tuple +from typing import Final, List, Tuple import dearpygui.dearpygui as dpg import pytest from sampletones_application.categories.manager import LanguageManager -from sampletones_application.layout.settings import SettingsLayout -from sampletones_application.paths import LANG_EN, LAYOUT_DIRECTORY -from sampletones_application.tags.general import TAG_GLOBAL_THEME_DIALOG +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, @@ -17,33 +16,27 @@ TAG_SETTINGS_DISPLAY_COMBO_PALETTE, TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, ) -from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.panels.dialogs.display_settings import ( GUIDisplaySettingsWindow, ) -from sampletones_application.ui.themes.items import ThemeItems -from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.gui.keyboard import KeyRouter -from sampletones_application.utils.palette.source import PaletteSource from sampletones_application.view_model.shared.display_settings import ( DisplaySettings, DisplaySettingsViewModel, WindowMode, ) from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution -from sampletones_shared.utils.serialization import load_yaml_model_dir -from tests.unit.sampletones_application.utils.palette.helpers import build_palette -UNLIMITED_LABEL = "Unlimited" +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +UNLIMITED_LABEL: Final[str] = LANGUAGE_MANAGER["settings.display.label.unlimited_frame_rate"] -RESOLUTIONS: Tuple[Resolution, ...] = ( +RESOLUTIONS: Final[Tuple[Resolution, ...]] = ( Resolution(width=1024, height=768), Resolution(width=1280, height=800), Resolution(width=1600, height=900), ) -FRAME_RATES: Tuple[int, ...] = (UNLIMITED_FRAME_RATE, 30, 60, 120) -PALETTES: Tuple[str, ...] = ("dark", "light", "studio") +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: @@ -64,28 +57,11 @@ def view_model(*, fullscreen: bool = False) -> DisplaySettingsViewModel: ) -@pytest.fixture(name="dpg_context") -def dpg_context_fixture() -> Iterator[None]: - dpg.create_context() - FontRegistry.register_fonts() - ThemeRegistry.register(Theme(tag=TAG_GLOBAL_THEME_DIALOG, items=ThemeItems())) - try: - yield - finally: - ThemeRegistry.clear() - dpg.destroy_context() - - @pytest.fixture(name="window") -def window_fixture(dpg_context: None) -> GUIDisplaySettingsWindow: - layout = load_yaml_model_dir( - LAYOUT_DIRECTORY / "settings", - SettingsLayout, - context={"palette_source": PaletteSource(build_palette())}, - ) +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIDisplaySettingsWindow: return GUIDisplaySettingsWindow( - layout=layout, - language_manager=LanguageManager(LANG_EN), + layout=layout_config.settings, + language_manager=LANGUAGE_MANAGER, key_router=KeyRouter(), ) From f5328e52a273f736a14825b10a85b727fbcc21c0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 15:37:00 +0200 Subject: [PATCH 009/152] General improvements --- .../categories/key/tag.py | 6 +- .../logic/instruction/library.py | 7 +-- .../logic/instruction/table.py | 6 +- .../logic/reconstruction/data.py | 8 +-- .../services/retune/__init__.py | 2 +- .../ui/elements/button.py | 4 +- .../ui/elements/fonts/registry.py | 6 +- .../ui/elements/graphs/bar.py | 26 ++++---- .../ui/elements/graphs/spectrum.py | 13 ++-- .../ui/elements/graphs/waveform.py | 13 ++-- .../ui/elements/table/table.py | 4 +- .../ui/elements/trace.py | 4 +- .../ui/panels/instruction/parameters.py | 4 +- .../reconstruction/instruments/instruments.py | 22 +++---- .../ui/panels/sequencer/history.py | 38 ++++++------ .../ui/panels/sequencer/order.py | 18 +++--- .../ui/themes/inline.py | 15 +++-- .../utils/callbacks/queue.py | 12 ++-- .../file_dialogs/backends/portal/client.py | 31 ++++++---- .../utils/gui/dialogs.py | 2 +- .../utils/gui/frame.py | 6 +- .../utils/gui/keyboard/__init__.py | 6 +- .../utils/parallelization/thread.py | 6 +- src/sampletones_core/audio/__init__.py | 18 +++--- src/sampletones_core/audio/manager.py | 2 +- src/sampletones_core/calibration/__init__.py | 22 ++++--- src/sampletones_core/configs/__init__.py | 6 +- src/sampletones_core/constants/general.py | 2 +- src/sampletones_core/exporters/__init__.py | 14 ++--- src/sampletones_core/exporters/exporter.py | 4 +- .../exporters/implementation/noise.py | 4 +- .../exporters/implementation/pulse.py | 4 +- .../exporters/implementation/triangle.py | 4 +- src/sampletones_core/features/__init__.py | 4 +- src/sampletones_core/fft/__init__.py | 16 ++--- src/sampletones_core/fft/features/__init__.py | 4 +- src/sampletones_core/fft/fragment/fragment.py | 8 +-- src/sampletones_core/fft/window/cyclic.py | 6 +- src/sampletones_core/generators/__init__.py | 26 ++++---- src/sampletones_core/instructions/__init__.py | 14 ++--- src/sampletones_core/library/__init__.py | 4 +- .../library/creator/__init__.py | 2 +- .../parallelization/__init__.py | 6 +- .../parallelization/processor.py | 2 +- .../parallelization/progress.py | 6 +- src/sampletones_core/project/__init__.py | 8 +-- src/sampletones_core/project/info.py | 4 +- .../project/instruments/__init__.py | 2 +- .../project/instruments/sample.py | 4 +- .../reconstructions/__init__.py | 18 +++--- .../reconstructions/converter/__init__.py | 10 ++-- .../reconstructions/converter/conversion.py | 4 +- .../converter/paths/__init__.py | 6 +- .../reconstructor/selector/__init__.py | 6 +- .../reconstructor/selector/viterbi.py | 3 +- src/sampletones_core/structures/__init__.py | 8 +-- .../structures/collection/__init__.py | 4 +- .../structures/collection/bidirectional.py | 4 +- .../structures/collection/indexed.py | 7 +-- .../structures/histogram/__init__.py | 2 +- .../structures/histogram/histogram.py | 8 +-- .../structures/tree/__init__.py | 8 +-- src/sampletones_core/timers/__init__.py | 4 +- .../timers/implementation/lfsr.py | 16 ++--- .../timers/implementation/phase.py | 7 ++- src/sampletones_shared/array.py | 4 +- src/sampletones_shared/exceptions/__init__.py | 60 +++++++++---------- src/sampletones_shared/logger/__init__.py | 6 +- .../utils/transformations/__init__.py | 2 +- src/sampletones_synthesis/frequency.py | 2 +- 70 files changed, 325 insertions(+), 319 deletions(-) diff --git a/src/sampletones_application/categories/key/tag.py b/src/sampletones_application/categories/key/tag.py index a48d85ec0..0d53608fd 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/logic/instruction/library.py b/src/sampletones_application/logic/instruction/library.py index ba9984da5..23439f211 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}", diff --git a/src/sampletones_application/logic/instruction/table.py b/src/sampletones_application/logic/instruction/table.py index e2eab0bda..18808eea0 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/reconstruction/data.py b/src/sampletones_application/logic/reconstruction/data.py index b7e37e0c5..e74ddf556 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/services/retune/__init__.py b/src/sampletones_application/services/retune/__init__.py index 0af32c70e..963b47d82 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/ui/elements/button.py b/src/sampletones_application/ui/elements/button.py index 243269017..9e77c027e 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/fonts/registry.py b/src/sampletones_application/ui/elements/fonts/registry.py index edfa5f1eb..cea0c99f2 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 2608be25f..3b83a6ed4 100644 --- a/src/sampletones_application/ui/elements/graphs/bar.py +++ b/src/sampletones_application/ui/elements/graphs/bar.py @@ -154,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_palette_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) @@ -176,13 +175,12 @@ def _bind_hover_theme(self) -> None: 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_palette_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) diff --git a/src/sampletones_application/ui/elements/graphs/spectrum.py b/src/sampletones_application/ui/elements/graphs/spectrum.py index fc3c85e14..d36cc0a22 100644 --- a/src/sampletones_application/ui/elements/graphs/spectrum.py +++ b/src/sampletones_application/ui/elements/graphs/spectrum.py @@ -148,13 +148,12 @@ def _create_brightness_theme( return self.themes[color] theme_tag = compose_tag(self.tag, SUF_GRAPH_THEME, str(len(self.themes))) - with dpg.theme(tag=theme_tag): - with dpg.theme_component(dpg.mvBarSeries): - dpg_add_palette_theme_color( - dpg.mvPlotCol_Fill, - color, - category=dpg.mvThemeCat_Plots, - ) + 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 b5c7c7f43..a782ab12c 100644 --- a/src/sampletones_application/ui/elements/graphs/waveform.py +++ b/src/sampletones_application/ui/elements/graphs/waveform.py @@ -432,13 +432,12 @@ def _bind_series_theme( 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_palette_theme_color( - dpg.mvPlotCol_Line, - self._series_color(layer, shade), - 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/table/table.py b/src/sampletones_application/ui/elements/table/table.py index ed2559e51..b6a6eb65a 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 @@ -18,7 +18,7 @@ class GUITable: - _REGISTRY: Dict[str, GUITable] = {} + _REGISTRY: ClassVar[Dict[str, GUITable]] = {} def __init__( self, diff --git a/src/sampletones_application/ui/elements/trace.py b/src/sampletones_application/ui/elements/trace.py index 6390ea38b..40d08639a 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/panels/instruction/parameters.py b/src/sampletones_application/ui/panels/instruction/parameters.py index 313ba9971..5b73e32ad 100644 --- a/src/sampletones_application/ui/panels/instruction/parameters.py +++ b/src/sampletones_application/ui/panels/instruction/parameters.py @@ -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/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 53502ff13..b510122ef 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -154,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() diff --git a/src/sampletones_application/ui/panels/sequencer/history.py b/src/sampletones_application/ui/panels/sequencer/history.py index 4f0a87c6c..9a340473c 100644 --- a/src/sampletones_application/ui/panels/sequencer/history.py +++ b/src/sampletones_application/ui/panels/sequencer/history.py @@ -115,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"], diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index b5138fdd8..c1918b37b 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -246,15 +246,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. diff --git a/src/sampletones_application/ui/themes/inline.py b/src/sampletones_application/ui/themes/inline.py index df600deb3..30ce8d06d 100644 --- a/src/sampletones_application/ui/themes/inline.py +++ b/src/sampletones_application/ui/themes/inline.py @@ -65,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/utils/callbacks/queue.py b/src/sampletones_application/utils/callbacks/queue.py index b80e72cd9..d8c2d6e4c 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 2a7beea74..ba7ea4b9a 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/gui/dialogs.py b/src/sampletones_application/utils/gui/dialogs.py index fdf62fa9c..697271643 100644 --- a/src/sampletones_application/utils/gui/dialogs.py +++ b/src/sampletones_application/utils/gui/dialogs.py @@ -350,7 +350,7 @@ def close() -> None: group_tag = compose_tag(tag, SUF_GROUP) with dpg.group(tag=group_tag, parent=tag): name_text = dpg.add_text( - f"{str(type(exception).__name__)}: ", + f"{type(exception).__name__!s}: ", parent=group_tag, ) dpg_set_palette_color(name_text, self._col_text_error) diff --git a/src/sampletones_application/utils/gui/frame.py b/src/sampletones_application/utils/gui/frame.py index af4285e10..9542c3962 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 1708d3434..172de099e 100644 --- a/src/sampletones_application/utils/gui/keyboard/__init__.py +++ b/src/sampletones_application/utils/gui/keyboard/__init__.py @@ -8,10 +8,10 @@ ) __all__ = [ - "KeyEvent", - "KeyRouter", - "ModalKeyHandler", "PRIORITY_MODAL", "PRIORITY_PANEL", "PRIORITY_SHORTCUT", + "KeyEvent", + "KeyRouter", + "ModalKeyHandler", ] diff --git a/src/sampletones_application/utils/parallelization/thread.py b/src/sampletones_application/utils/parallelization/thread.py index 1f4fe32b7..5acbaa3bf 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: diff --git a/src/sampletones_core/audio/__init__.py b/src/sampletones_core/audio/__init__.py index b0e51bb77..fda067bed 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/manager.py b/src/sampletones_core/audio/manager.py index 71966535a..5a3b4f774 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -227,7 +227,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 diff --git a/src/sampletones_core/calibration/__init__.py b/src/sampletones_core/calibration/__init__.py index e77b25377..e63aed347 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 ae48f9e36..275c68c7e 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 a1396a885..dad4e91b9 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/exporters/__init__.py b/src/sampletones_core/exporters/__init__.py index 4f6297471..f8a841f07 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 3999e6680..798388bc0 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 5fe7867a1..6beec1535 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 00a4c99c3..987810263 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 4f69ab58b..1c7eb7d4e 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 1b593140e..e2027c6fa 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 4a4ec7410..bf912910a 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 c131bda37..78a94c37f 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 02c34fa73..1fb29eb0e 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 0ff8ef78b..e836591d9 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/generators/__init__.py b/src/sampletones_core/generators/__init__.py index 0c67b560e..95edc7d2b 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 3a0d8fc1b..7360d224f 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 7f3c0f847..735c2497a 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 c4b776555..50ca67c77 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/parallelization/__init__.py b/src/sampletones_core/parallelization/__init__.py index c3114c3de..748e3b496 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 4ce8fbd6f..a377bf3ce 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 0fb6d7c4d..f95759630 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 463c03354..5cf443901 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 e8f130e1e..aa8ccc53e 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 992ff2eff..576c12dee 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 1243d65c3..672abb09b 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 9a93e75e6..4608b5499 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 5b845e21c..c85133963 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 c653bcb17..1d3dfe158 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 436e11cf0..32e362fc2 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/reconstructor/selector/__init__.py b/src/sampletones_core/reconstructions/reconstructor/selector/__init__.py index be47397c6..e28386b83 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 d487b6969..8f4d76d94 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/structures/__init__.py b/src/sampletones_core/structures/__init__.py index 765d20ee5..7673c8756 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 d7507cc1f..a894dacf8 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 11eb4cee4..50f1d1457 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[ @@ -129,7 +129,7 @@ def __delitem__(self, key_or_value: Union[str, ValueT]) -> None: string = self._backward.pop(key_or_value) del self._forward[string] - def __eq__(self, other: 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 ae29387df..b767cb4c9 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 2b5eb43d4..2ad873dbd 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 bfc0b7cf8..0b13c75ca 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 4eae3325c..1b5a29021 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 850c1641c..f5e56c4c9 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 c81233059..da4c88d56 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 262ac22a1..8d6e2f422 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_shared/array.py b/src/sampletones_shared/array.py index c3d68ae2c..006271a4b 100644 --- a/src/sampletones_shared/array.py +++ b/src/sampletones_shared/array.py @@ -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/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index 8d886f318..7dce22852 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 074be694d..12600795c 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/utils/transformations/__init__.py b/src/sampletones_shared/utils/transformations/__init__.py index 25194c65a..4393e787e 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 b8a08cbfd..36f46d085 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. From 325a515f6bf54a6ad467997978ffbd0116b8707f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 15:46:22 +0200 Subject: [PATCH 010/152] Fixed: display settings dialog shrinking --- .../ui/elements/window.py | 35 +++++++++- .../ui/panels/dialogs/audio_settings.py | 8 +-- .../ui/panels/dialogs/countdown.py | 11 +-- .../ui/panels/dialogs/display_settings.py | 9 +-- .../ui/panels/dialogs/project_properties.py | 8 +-- .../structures/collection/bidirectional.py | 5 +- .../ui/elements/test_window.py | 67 +++++++++++++++++++ 7 files changed, 110 insertions(+), 33 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/test_window.py diff --git a/src/sampletones_application/ui/elements/window.py b/src/sampletones_application/ui/elements/window.py index e31e1ad37..09c1fc7c2 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 @@ -8,6 +9,7 @@ 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_shared.types.callback import VoidCallback class GUIWindow(GUIPanel, ABC): @@ -24,6 +26,37 @@ class GUIWindow(GUIPanel, ABC): def center(self) -> None: center_item(self.tag) + @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/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py index 79fa90bff..4878ddc1e 100644 --- a/src/sampletones_application/ui/panels/dialogs/audio_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/audio_settings.py @@ -102,15 +102,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() diff --git a/src/sampletones_application/ui/panels/dialogs/countdown.py b/src/sampletones_application/ui/panels/dialogs/countdown.py index 9798a83e1..0542bf22e 100644 --- a/src/sampletones_application/ui/panels/dialogs/countdown.py +++ b/src/sampletones_application/ui/panels/dialogs/countdown.py @@ -79,16 +79,9 @@ 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 dpg.window( - tag=self.tag, + with self.dialog_window( label=self._title, - width=self.width, - height=self.height, - no_resize=True, - no_collapse=True, - no_close=True, - autosize=True, - modal=True, + on_close=None, ): dpg.add_text(self._message, wrap=self.width) dpg.add_text(self._remaining_text(), tag=TAG_SETTINGS_DISPLAY_TEXT_COUNTDOWN) diff --git a/src/sampletones_application/ui/panels/dialogs/display_settings.py b/src/sampletones_application/ui/panels/dialogs/display_settings.py index 8ed53f8a6..a1c826b1d 100644 --- a/src/sampletones_application/ui/panels/dialogs/display_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/display_settings.py @@ -91,16 +91,9 @@ def reveal(self) -> None: dpg_configure_item(self.tag, show=True) def create_window(self) -> None: - with dpg.window( - tag=self.tag, + with self.dialog_window( label=self._language_manager["settings.display.title.window_title"], - width=self.width, - height=self.height, - no_resize=True, - no_collapse=True, - autosize=True, on_close=self._request_cancel, - modal=True, ): self._create_window_section() dpg.add_separator() diff --git a/src/sampletones_application/ui/panels/dialogs/project_properties.py b/src/sampletones_application/ui/panels/dialogs/project_properties.py index 5b4ca11c6..ad78f0832 100644 --- a/src/sampletones_application/ui/panels/dialogs/project_properties.py +++ b/src/sampletones_application/ui/panels/dialogs/project_properties.py @@ -109,15 +109,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, diff --git a/src/sampletones_core/structures/collection/bidirectional.py b/src/sampletones_core/structures/collection/bidirectional.py index 50f1d1457..4936337e1 100644 --- a/src/sampletones_core/structures/collection/bidirectional.py +++ b/src/sampletones_core/structures/collection/bidirectional.py @@ -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. 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 000000000..1a1979c4b --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/test_window.py @@ -0,0 +1,67 @@ +from typing import Any, Final, Iterator, Optional + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.ui.elements.window import GUIWindow +from sampletones_shared.types.callback import VoidCallback + +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 From b76e4d53693acfebf472844c421bac5b38b5ea48 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 15:50:40 +0200 Subject: [PATCH 011/152] Minor improvements --- docs/development/bugs-and-todos.md | 3 +-- src/sampletones_application/utils/gui/dpg.py | 6 +++--- src/sampletones_shared/meta/singleton.pyi | 6 +++--- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index f181a0af1..ab77d8ba9 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -3,7 +3,6 @@ ### Navigation * Interface scale -* VSync/frame rate options * Tree navigation using keys * Waveform LOD for zooming * Keybindings options @@ -17,7 +16,6 @@ * Basic shapes as instruments * Selection operations on patterns and orders -* Replace/swap sample ### Workflow @@ -38,6 +36,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/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py index 98cd7dc37..8e0936774 100644 --- a/src/sampletones_application/utils/gui/dpg.py +++ b/src/sampletones_application/utils/gui/dpg.py @@ -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_shared/meta/singleton.pyi b/src/sampletones_shared/meta/singleton.pyi index 2de24fdea..bb3a3e725 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: ... From 8d9be21864fec765ae8444304cff915522313a74 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 16:07:38 +0200 Subject: [PATCH 012/152] Fixed: check scripts --- .pre-commit-config.yaml | 4 +- scripts/checks/import_boundary.py | 175 ++++++++++++------ scripts/checks/language_keys.py | 4 +- scripts/checks/palette_colors.py | 27 ++- scripts/checks/tag_names.py | 4 +- scripts/checks/unused_tags.py | 6 +- src/sampletones_shared/meta/source/modules.py | 22 ++- .../meta/source/packages.py | 26 +++ src/sampletones_shared/paths.py | 3 +- tests/integration/tooling/__init__.py | 0 .../tooling/test_check_commands.py | 69 +++++++ .../meta/source/test_modules.py | 31 ++++ .../meta/source/test_packages.py | 31 ++++ tests/unit/sampletones_shared/test_paths.py | 27 +++ .../scripts/checks/test_import_boundary.py | 157 ++++++++++++++++ .../unit/scripts/checks/test_language_keys.py | 12 ++ tests/unit/scripts/checks/test_tag_names.py | 13 +- tests/unit/scripts/checks/test_unused_tags.py | 16 +- 18 files changed, 552 insertions(+), 75 deletions(-) create mode 100644 src/sampletones_shared/meta/source/packages.py create mode 100644 tests/integration/tooling/__init__.py create mode 100644 tests/integration/tooling/test_check_commands.py create mode 100644 tests/unit/sampletones_shared/meta/source/test_packages.py create mode 100644 tests/unit/sampletones_shared/test_paths.py create mode 100644 tests/unit/scripts/checks/test_import_boundary.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 81c99f203..570b44966 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,10 +51,10 @@ 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 diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py index 2628130d0..47b8cf3d3 100755 --- 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", @@ -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 index e58cbe62d..15db4f8cd 100755 --- a/scripts/checks/language_keys.py +++ b/scripts/checks/language_keys.py @@ -37,9 +37,7 @@ 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 - -SOURCE_ROOT: Final[Path] = REPOSITORY_ROOT / "src" +from sampletones_shared.paths import SOURCE_ROOT RECEIVER_TYPE: Final[str] = "LanguageManager" ELEMENT_BASE: Final[str] = AbstractElement.__name__ diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py index 246bdcd17..32f6f8c59 100755 --- a/scripts/checks/palette_colors.py +++ b/scripts/checks/palette_colors.py @@ -19,7 +19,6 @@ import re import sys from collections.abc import Iterator, Sequence -from importlib.resources import files from itertools import chain from pathlib import Path from typing import Final, List, NamedTuple, Tuple, Union @@ -28,9 +27,10 @@ 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] = Path(str(files("sampletones_application"))) +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})?[\"']") @@ -176,12 +176,23 @@ def find_detached_colors( def find_literal_colors(package: Path, palettes: Path) -> List[ColorFinding]: - return [ - finding - for path in sorted(package.rglob(CONFIG_PATTERN)) - if palettes not in path.parents - for finding in literal_colors(path) - ] + """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: diff --git a/scripts/checks/tag_names.py b/scripts/checks/tag_names.py index 682c653b2..e2757858d 100755 --- 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" diff --git a/scripts/checks/unused_tags.py b/scripts/checks/unused_tags.py index 27d2f7db1..14931ae4d 100755 --- 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/src/sampletones_shared/meta/source/modules.py b/src/sampletones_shared/meta/source/modules.py index 47905e261..205f3a9ee 100644 --- a/src/sampletones_shared/meta/source/modules.py +++ b/src/sampletones_shared/meta/source/modules.py @@ -55,15 +55,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 +93,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 000000000..772c40f53 --- /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 a6ad14b62..534644823 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/tests/integration/tooling/__init__.py b/tests/integration/tooling/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/tooling/test_check_commands.py b/tests/integration/tooling/test_check_commands.py new file mode 100644 index 000000000..fee708dc0 --- /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/unit/sampletones_shared/meta/source/test_modules.py b/tests/unit/sampletones_shared/meta/source/test_modules.py index b3ce9a821..31e158cbe 100644 --- a/tests/unit/sampletones_shared/meta/source/test_modules.py +++ b/tests/unit/sampletones_shared/meta/source/test_modules.py @@ -80,9 +80,40 @@ 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: + with pytest.raises(FileNotFoundError, match=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_packages.py b/tests/unit/sampletones_shared/meta/source/test_packages.py new file mode 100644 index 000000000..bc2333ad7 --- /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/test_paths.py b/tests/unit/sampletones_shared/test_paths.py new file mode 100644 index 000000000..ae1c2fe2c --- /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/scripts/checks/test_import_boundary.py b/tests/unit/scripts/checks/test_import_boundary.py new file mode 100644 index 000000000..e2aa92fc7 --- /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 9d5fe01fb..e88bfaa45 100644 --- a/tests/unit/scripts/checks/test_language_keys.py +++ b/tests/unit/scripts/checks/test_language_keys.py @@ -4,7 +4,9 @@ import pytest from sampletones_application.categories.elements.global_ import DialogElements +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 @@ -186,6 +188,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_tag_names.py b/tests/unit/scripts/checks/test_tag_names.py index f7fbce233..83575b0fc 100644 --- a/tests/unit/scripts/checks/test_tag_names.py +++ b/tests/unit/scripts/checks/test_tag_names.py @@ -6,7 +6,8 @@ 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 @@ -140,6 +141,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 7f27eb5b3..4d8456f06 100644 --- a/tests/unit/scripts/checks/test_unused_tags.py +++ b/tests/unit/scripts/checks/test_unused_tags.py @@ -3,7 +3,8 @@ 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 @@ -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 From f7b7c433c05d6fe73e0ec74b78378ebeb5c3682d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 17:20:38 +0200 Subject: [PATCH 013/152] Added: keyboard combination --- src/sampletones_application/shell.py | 105 +++++++++--------- .../ui/panels/sequencer/grid.py | 20 ++-- .../ui/panels/sequencer/order.py | 18 +-- .../utils/gui/keyboard/__init__.py | 2 + .../utils/gui/keyboard/combination.py | 73 ++++++++++++ .../utils/gui/keyboard/focus/consumption.py | 18 +-- .../utils/gui/keyboard/keys.py | 95 ++++++++++++++++ .../utils/gui/keyboard/modifiers.py | 2 + .../utils/gui/shortcuts/keys.py | 87 --------------- .../utils/gui/shortcuts/manager.py | 47 +++----- .../utils/gui/shortcuts/shortcut.py | 54 +++++---- .../panels/sequencer/test_grid_navigation.py | 2 +- .../utils/gui/shortcuts/test_manager.py | 35 ++++-- 13 files changed, 321 insertions(+), 237 deletions(-) create mode 100644 src/sampletones_application/utils/gui/keyboard/combination.py create mode 100644 src/sampletones_application/utils/gui/keyboard/keys.py delete mode 100644 src/sampletones_application/utils/gui/shortcuts/keys.py diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index dd66d3e42..2597b626c 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -34,7 +34,11 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.fps import FPSTimer -from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.keyboard import KeyCombination, KeyRouter +from sampletones_application.utils.gui.keyboard.keys import ( + KEY_PAGE_DOWN, + KEY_PAGE_UP, +) from sampletones_application.utils.gui.keyboard.modifiers import ( ALT, CTRL, @@ -49,10 +53,6 @@ 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 @@ -71,12 +71,12 @@ } _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), + TrackerFormat.FAMITRACKER: Shortcut(combination=KeyCombination(dpg.mvKey_M, CTRL)), + TrackerFormat.BITPHASE: Shortcut(combination=KeyCombination(dpg.mvKey_B, CTRL)), } _SAMPLE_EXPORT_SHORTCUTS: Final[Dict[TrackerFormat, Shortcut]] = { - TrackerFormat.FAMITRACKER: Shortcut(dpg.mvKey_I, CTRL), - TrackerFormat.BITPHASE_PRESET: Shortcut(), + TrackerFormat.FAMITRACKER: Shortcut(combination=KeyCombination(dpg.mvKey_I, CTRL)), + TrackerFormat.BITPHASE_PRESET: Shortcut(combination=None), } @@ -215,183 +215,188 @@ def _set_default_theme(self) -> None: def _register_shortcuts(self, bindings: ShortcutBindings) -> None: self._shortcut_manager.register( ShortcutId.NEW_PROJECT, - Shortcut(dpg.mvKey_N, CTRL), + Shortcut(combination=KeyCombination(dpg.mvKey_N, CTRL)), bindings.new_project, ) self._shortcut_manager.register( ShortcutId.OPEN_PROJECT, - Shortcut(dpg.mvKey_O, CTRL), + Shortcut(combination=KeyCombination(dpg.mvKey_O, CTRL)), bindings.open_project, ) self._shortcut_manager.register( ShortcutId.SAVE_PROJECT, - Shortcut(dpg.mvKey_S, CTRL), + Shortcut(combination=KeyCombination(dpg.mvKey_S, CTRL)), bindings.save_project, ) self._shortcut_manager.register( ShortcutId.SAVE_PROJECT_AS, - Shortcut(dpg.mvKey_S, CTRL_SHIFT), + Shortcut(combination=KeyCombination(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), + Shortcut(combination=KeyCombination(dpg.mvKey_P, ALT)), bindings.project_properties, ) self._shortcut_manager.register( ShortcutId.CLOSE_PROJECT, - Shortcut(dpg.mvKey_W, CTRL), + Shortcut(combination=KeyCombination(dpg.mvKey_W, CTRL)), bindings.close_project, ) self._shortcut_manager.register( ShortcutId.SAVE_RECONSTRUCTION, - Shortcut(dpg.mvKey_S, CTRL_ALT), + Shortcut(combination=KeyCombination(dpg.mvKey_S, CTRL_ALT)), bindings.save_reconstruction, ) self._shortcut_manager.register( ShortcutId.SAVE_RECONSTRUCTION_AS, - Shortcut(dpg.mvKey_S, CTRL_ALT_SHIFT), + Shortcut(combination=KeyCombination(dpg.mvKey_S, CTRL_ALT_SHIFT)), bindings.save_reconstruction_as, ) self._shortcut_manager.register( ShortcutId.OPEN_RECONSTRUCTION, - Shortcut(dpg.mvKey_O, CTRL_ALT), + Shortcut(combination=KeyCombination(dpg.mvKey_O, CTRL_ALT)), bindings.open_reconstruction, ) self._shortcut_manager.register( ShortcutId.CLOSE_RECONSTRUCTION, - Shortcut(dpg.mvKey_W, CTRL_ALT), + Shortcut(combination=KeyCombination(dpg.mvKey_W, CTRL_ALT)), bindings.close_reconstruction, ) self._shortcut_manager.register( ShortcutId.SAVE_GENERATION_SETTINGS, - Shortcut(), + Shortcut(combination=None), bindings.save_generation_settings, ) self._shortcut_manager.register( ShortcutId.LOAD_GENERATION_SETTINGS, - Shortcut(), + Shortcut(combination=None), bindings.load_generation_settings, ) self._shortcut_manager.register( ShortcutId.AUDIO_SETTINGS, - Shortcut(dpg.mvKey_A, CTRL), + Shortcut(combination=KeyCombination(dpg.mvKey_A, CTRL)), bindings.audio_settings, ) self._shortcut_manager.register( ShortcutId.EXIT, - Shortcut(dpg.mvKey_F4, ALT), + Shortcut(combination=KeyCombination(dpg.mvKey_F4, ALT)), bindings.exit, ) self._shortcut_manager.register( ShortcutId.RECONSTRUCT_FILE, - Shortcut(dpg.mvKey_R, CTRL), + Shortcut(combination=KeyCombination(dpg.mvKey_R, CTRL)), bindings.reconstruct_file, ) self._shortcut_manager.register( ShortcutId.RECONSTRUCT_DIRECTORY, - Shortcut(dpg.mvKey_R, CTRL_SHIFT), + Shortcut(combination=KeyCombination(dpg.mvKey_R, CTRL_SHIFT)), bindings.reconstruct_directory, ) self._shortcut_manager.register( ShortcutId.EXPORT_RECONSTRUCTION_WAV, - Shortcut(dpg.mvKey_E, CTRL), + Shortcut(combination=KeyCombination(dpg.mvKey_E, CTRL)), bindings.export_wav, ) self._shortcut_manager.register( ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER, - Shortcut(), + Shortcut(combination=None), bindings.add_reconstruction_to_sequencer, ) self._shortcut_manager.register( ShortcutId.OPEN_RECONSTRUCTION_IN_EXPLORER, - Shortcut(), + Shortcut(combination=None), bindings.open_reconstruction_in_explorer, ) self._shortcut_manager.register( ShortcutId.LOCATE_ORIGINAL_AUDIO, - Shortcut(), + Shortcut(combination=None), bindings.locate_original_audio, ) self._shortcut_manager.register( ShortcutId.TOGGLE_FULLSCREEN, - Shortcut(dpg.mvKey_F11), + Shortcut(combination=KeyCombination(dpg.mvKey_F11)), bindings.toggle_fullscreen, ) self._shortcut_manager.register( ShortcutId.DISPLAY_SETTINGS, - Shortcut(), + Shortcut(combination=None), bindings.display_settings, ) self._shortcut_manager.register( ShortcutId.TOGGLE_ADVANCED_SETTINGS, - Shortcut(dpg.mvKey_A, CTRL_SHIFT), + Shortcut(combination=KeyCombination(dpg.mvKey_A, CTRL_SHIFT)), bindings.toggle_advanced_settings, ) self._shortcut_manager.register( ShortcutId.PLAY, - Shortcut(dpg.mvKey_Spacebar), + Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar)), bindings.play, ) self._shortcut_manager.register( ShortcutId.PLAY_FROM_START, - Shortcut(dpg.mvKey_Spacebar, SHIFT), + Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar, SHIFT)), bindings.play_from_start, ) self._shortcut_manager.register( ShortcutId.PLAY_FROM_FRAME, - Shortcut(dpg.mvKey_Spacebar, CTRL), + Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar, CTRL)), bindings.play_from_frame, ) self._shortcut_manager.register( ShortcutId.STOP, - Shortcut(dpg.mvKey_Escape), + Shortcut(combination=KeyCombination(dpg.mvKey_Escape)), bindings.stop, ) self._shortcut_manager.register( ShortcutId.TOGGLE_AUTOPLAY, - Shortcut(dpg.mvKey_P, CTRL), + Shortcut(combination=KeyCombination(dpg.mvKey_P, CTRL)), bindings.toggle_autoplay, ) self._shortcut_manager.register( ShortcutId.TOGGLE_FOLLOW_PLAYBACK, - Shortcut(), + Shortcut(combination=None), bindings.toggle_follow_playback, ) self._shortcut_manager.register( ShortcutId.TOGGLE_LOOP_SONG, - Shortcut(), + Shortcut(combination=None), bindings.toggle_loop_song, ) self._register_channel_shortcuts(bindings) self._shortcut_manager.register( ShortcutId.UNDO, - Shortcut(dpg.mvKey_Z, CTRL), + Shortcut(combination=KeyCombination(dpg.mvKey_Z, CTRL)), bindings.undo, ) self._shortcut_manager.register( ShortcutId.REDO, - Shortcut(dpg.mvKey_Y, CTRL), + Shortcut( + combination=KeyCombination(dpg.mvKey_Y, CTRL), + aliases=(KeyCombination(dpg.mvKey_Z, CTRL_SHIFT),), + ), bindings.redo, ) - self._shortcut_manager.register_alias( - ShortcutId.REDO, - Shortcut(dpg.mvKey_Z, CTRL_SHIFT), - ) self._shortcut_manager.register( ShortcutId.ABOUT_DIALOG, - Shortcut(), + Shortcut(combination=None), bindings.about, ) self._shortcut_manager.register( ShortcutId.NEXT_TAB, - Shortcut(KEY_PAGE_DOWN, CTRL, field_transparent=True), + Shortcut( + combination=KeyCombination(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), + Shortcut( + combination=KeyCombination(KEY_PAGE_UP, CTRL), + field_transparent=True, + ), bindings.previous_tab, ) @@ -428,13 +433,13 @@ def _register_channel_shortcuts(self, bindings: ShortcutBindings) -> None: for generator, shortcut_id in CHANNEL_SHORTCUT_IDS.items(): self._shortcut_manager.register( shortcut_id, - Shortcut(), + Shortcut(combination=None), partial(bindings.toggle_channel, generator), ) self._shortcut_manager.register( ShortcutId.UNMUTE_ALL_CHANNELS, - Shortcut(), + Shortcut(combination=None), bindings.unmute_all_channels, ) diff --git a/src/sampletones_application/ui/panels/sequencer/grid.py b/src/sampletones_application/ui/panels/sequencer/grid.py index 1164dab7b..ac9b8cba5 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid.py +++ b/src/sampletones_application/ui/panels/sequencer/grid.py @@ -58,21 +58,21 @@ from sampletones_application.utils.gui.dpg import dpg_delete_children from sampletones_application.utils.gui.keyboard import ( PRIORITY_PANEL, + KeyCombination, KeyEvent, KeyRouter, ) -from sampletones_application.utils.gui.keyboard.modifiers import ( - CTRL, - CTRL_SHIFT, - Modifier, -) -from sampletones_application.utils.gui.shortcuts.keys import ( +from sampletones_application.utils.gui.keyboard.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.modifiers import ( + CTRL, + CTRL_SHIFT, + Modifier, +) 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 ( @@ -174,11 +174,11 @@ def __init__( self._load_header_tooltips(language_manager) self._create_channel_switch(language_manager) - self._sc_play_from_here = Shortcut( + self._sc_play_from_here = KeyCombination( dpg.mvKey_Spacebar, CTRL_SHIFT, - ).get_display_string() - self._sc_play_from_frame = Shortcut(dpg.mvKey_Spacebar, CTRL).get_display_string() + ).display() + self._sc_play_from_frame = KeyCombination(dpg.mvKey_Spacebar, CTRL).display() super().__init__( tag=TAG_SEQUENCER_GRID_PANEL, diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index c1918b37b..cacfb5ead 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -55,17 +55,17 @@ from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item from sampletones_application.utils.gui.keyboard import ( PRIORITY_PANEL, + KeyCombination, KeyEvent, KeyRouter, ) +from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS 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.tooltip import show_tooltip from sampletones_application.utils.palette.colors.faded import FadedColor from sampletones_application.view_model.sequencer.channels import ( @@ -202,15 +202,15 @@ def label(element: SequencerOrderElements) -> str: 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_play_from_frame = KeyCombination(dpg.mvKey_Spacebar, CTRL).display() + self._sc_move_left = KeyCombination(dpg.mvKey_Left, ALT).display() + self._sc_move_right = KeyCombination(dpg.mvKey_Right, ALT).display() + self._sc_move_start = KeyCombination(dpg.mvKey_Home, ALT).display() + self._sc_move_end = KeyCombination(dpg.mvKey_End, ALT).display() + self._sc_duplicate = KeyCombination(dpg.mvKey_D, CTRL).display() self._sc_insert = PLUS self._sc_remove = MINUS - self._sc_clear = Shortcut(dpg.mvKey_Delete, SHIFT).get_display_string() + self._sc_clear = KeyCombination(dpg.mvKey_Delete, SHIFT).display() def _load_label_tooltips(self, language_manager: LanguageManager) -> None: """Reads the row-label tooltips, which name the click gestures the labels carry.""" diff --git a/src/sampletones_application/utils/gui/keyboard/__init__.py b/src/sampletones_application/utils/gui/keyboard/__init__.py index 172de099e..a4fc92e25 100644 --- a/src/sampletones_application/utils/gui/keyboard/__init__.py +++ b/src/sampletones_application/utils/gui/keyboard/__init__.py @@ -1,3 +1,4 @@ +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, @@ -11,6 +12,7 @@ "PRIORITY_MODAL", "PRIORITY_PANEL", "PRIORITY_SHORTCUT", + "KeyCombination", "KeyEvent", "KeyRouter", "ModalKeyHandler", 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 000000000..3f2954b17 --- /dev/null +++ b/src/sampletones_application/utils/gui/keyboard/combination.py @@ -0,0 +1,73 @@ +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 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 + + 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 c684429cd..4634ab14d 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 000000000..92c2abcce --- /dev/null +++ b/src/sampletones_application/utils/gui/keyboard/keys.py @@ -0,0 +1,95 @@ +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 + +UNKNOWN_KEY: Final[str] = "?" + +LETTER_COUNT: Final[int] = 26 +DIGIT_COUNT: Final[int] = 10 +FUNCTION_KEY_COUNT: Final[int] = 12 + +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) +} + +FUNCTION_KEYS: Final[FrozenSet[int]] = frozenset(FUNCTION_KEY_NAMES) + +KEY_DISPLAY_NAMES: Final[Dict[int, str]] = { + **LETTER_NAMES, + **DIGIT_NAMES, + **FUNCTION_KEY_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_Plus: PLUS, + dpg.mvKey_Minus: MINUS, + dpg.mvKey_Add: f"Num{PLUS}", + dpg.mvKey_Subtract: f"Num{MINUS}", +} + +KEY_CODES: Final[Dict[str, int]] = {name.casefold(): key for key, name in KEY_DISPLAY_NAMES.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, + dpg.mvKey_Plus: PLUS, + dpg.mvKey_Add: PLUS, +} + + +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. + + Args: + name: A key name as :func:`key_display` writes it. + + 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 61a75b8e9..2e65085dd 100644 --- a/src/sampletones_application/utils/gui/keyboard/modifiers.py +++ b/src/sampletones_application/utils/gui/keyboard/modifiers.py @@ -22,6 +22,8 @@ class Modifier(Enum): 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]] = {modifier.value.casefold(): modifier for modifier in Modifier} + MODIFIER_KEYS: Final[Dict[Modifier, Tuple[int, int]]] = { Modifier.CTRL: (dpg.mvKey_LControl, dpg.mvKey_RControl), Modifier.ALT: (dpg.mvKey_LAlt, dpg.mvKey_RAlt), 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 c5d207796..000000000 --- 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 c5cb4a346..4a2d881be 100644 --- a/src/sampletones_application/utils/gui/shortcuts/manager.py +++ b/src/sampletones_application/utils/gui/shortcuts/manager.py @@ -17,7 +17,6 @@ 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]]] = {} def register( @@ -28,24 +27,11 @@ def register( ) -> None: self._shortcuts[shortcut_id] = (shortcut, callback) - def register_alias( - self, - shortcut_id: ShortcutId, - shortcut: Shortcut, - ) -> None: - """Binds an additional key combination to an already registered action. - - The primary shortcut keeps the action's display string in menus and - tooltips; an alias extends only the key handling, so one action honours - several conventional combinations. - """ - self._aliases.setdefault(shortcut_id, []).append(shortcut) - 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(), + shortcut=shortcut.display(), **kwargs, ) @@ -56,10 +42,8 @@ def bind_all(self) -> None: at a higher priority, so this scope handles a press whenever no dialog holds the keyboard. """ self._bindings_by_key = {} - for shortcut_id, (shortcut, callback) in self._shortcuts.items(): + for shortcut, callback in self._shortcuts.values(): self._add_binding(shortcut, callback) - for alias in self._aliases.get(shortcut_id, []): - self._add_binding(alias, callback) self._router.register( self._dispatch, @@ -68,15 +52,14 @@ def bind_all(self) -> None: ) def _add_binding(self, shortcut: Shortcut, callback: Callback) -> None: - if shortcut.key is None: - return - - self._bindings_by_key.setdefault(shortcut.key, []).append( - ( - shortcut, - callback, + """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 +69,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/shortcut.py b/src/sampletones_application/utils/gui/shortcuts/shortcut.py index 5d20f1cde..c990e3e9c 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/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_navigation.py index 9dd78279c..cf8a8a398 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_navigation.py @@ -7,8 +7,8 @@ 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.keys import KEY_PAGE_DOWN, KEY_PAGE_UP 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 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 fa253b3d5..a75b0d778 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py @@ -4,7 +4,7 @@ import dearpygui.dearpygui as dpg import pytest -from sampletones_application.utils.gui.keyboard import KeyEvent, KeyRouter +from sampletones_application.utils.gui.keyboard import KeyCombination, 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.modifiers import ( @@ -39,7 +39,7 @@ class TestShortcutDispatch: def test_matching_shortcut_fires_and_is_claimed(self) -> None: manager = _manager() callback = Mock() - manager.register(ShortcutId.SAVE_PROJECT, Shortcut(KEY, CTRL), callback) + manager.register(ShortcutId.SAVE_PROJECT, Shortcut(combination=KeyCombination(KEY, CTRL)), callback) manager.bind_all() claimed = manager._dispatch(_event(modifiers=CTRL)) @@ -50,7 +50,7 @@ def test_matching_shortcut_fires_and_is_claimed(self) -> None: def test_modifier_mismatch_does_not_fire(self) -> None: manager = _manager() callback = Mock() - manager.register(ShortcutId.SAVE_PROJECT, Shortcut(KEY, CTRL), callback) + manager.register(ShortcutId.SAVE_PROJECT, Shortcut(combination=KeyCombination(KEY, CTRL)), callback) manager.bind_all() claimed = manager._dispatch(_event()) @@ -61,8 +61,14 @@ def test_modifier_mismatch_does_not_fire(self) -> None: def test_alias_reaches_the_same_callback(self) -> None: manager = _manager() callback = Mock() - manager.register(ShortcutId.REDO, Shortcut(KEY, CTRL), callback) - manager.register_alias(ShortcutId.REDO, Shortcut(KEY, CTRL_SHIFT)) + manager.register( + ShortcutId.REDO, + Shortcut( + combination=KeyCombination(KEY, CTRL), + aliases=(KeyCombination(KEY, CTRL_SHIFT),), + ), + callback, + ) manager.bind_all() assert manager._dispatch(_event(modifiers=CTRL_SHIFT)) @@ -73,7 +79,7 @@ class TestFieldFocusGate: def test_text_field_keeps_a_plain_space(self, field_kind: Dict[str, FieldKind]) -> None: manager = _manager() callback = Mock() - manager.register(ShortcutId.PLAY, Shortcut(dpg.mvKey_Spacebar), callback) + manager.register(ShortcutId.PLAY, Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar)), callback) manager.bind_all() field_kind["kind"] = FieldKind.TEXT_ENTRY @@ -85,7 +91,9 @@ def test_text_field_keeps_a_plain_space(self, field_kind: Dict[str, FieldKind]) def test_text_field_yields_ctrl_space(self, field_kind: Dict[str, FieldKind]) -> None: manager = _manager() callback = Mock() - manager.register(ShortcutId.PLAY_FROM_FRAME, Shortcut(dpg.mvKey_Spacebar, CTRL), callback) + manager.register( + ShortcutId.PLAY_FROM_FRAME, Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar, CTRL)), callback + ) manager.bind_all() field_kind["kind"] = FieldKind.TEXT_ENTRY @@ -97,7 +105,7 @@ def test_text_field_yields_ctrl_space(self, field_kind: Dict[str, FieldKind]) -> def test_text_field_keeps_its_editing_chord(self, field_kind: Dict[str, FieldKind]) -> None: manager = _manager() callback = Mock() - manager.register(ShortcutId.AUDIO_SETTINGS, Shortcut(dpg.mvKey_A, CTRL), callback) + manager.register(ShortcutId.AUDIO_SETTINGS, Shortcut(combination=KeyCombination(dpg.mvKey_A, CTRL)), callback) manager.bind_all() field_kind["kind"] = FieldKind.TEXT_ENTRY @@ -110,7 +118,9 @@ def test_text_field_yields_a_shifted_chord_it_has_no_use_for(self, field_kind: D """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.register( + ShortcutId.TOGGLE_ADVANCED_SETTINGS, Shortcut(combination=KeyCombination(dpg.mvKey_A, CTRL_SHIFT)), callback + ) manager.bind_all() field_kind["kind"] = FieldKind.TEXT_ENTRY @@ -122,7 +132,7 @@ def test_text_field_yields_a_shifted_chord_it_has_no_use_for(self, field_kind: D def test_focused_field_keeps_escape(self, field_kind: Dict[str, FieldKind]) -> None: manager = _manager() callback = Mock() - manager.register(ShortcutId.STOP, Shortcut(dpg.mvKey_Escape), callback) + manager.register(ShortcutId.STOP, Shortcut(combination=KeyCombination(dpg.mvKey_Escape)), callback) manager.bind_all() field_kind["kind"] = FieldKind.TEXT_ENTRY @@ -136,7 +146,10 @@ def test_field_transparent_shortcut_fires_while_focused(self, field_kind: Dict[s callback = Mock() manager.register( ShortcutId.NEXT_TAB, - Shortcut(KEY, CTRL, field_transparent=True), + Shortcut( + combination=KeyCombination(KEY, CTRL), + field_transparent=True, + ), callback, ) manager.bind_all() From 05a009fc8852e3c859ad9fd3c5e049ae2aa3cad6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 18:05:57 +0200 Subject: [PATCH 014/152] Renamed: grid to tracker --- docs/development/architecture.md | 10 +- scripts/checks/import_boundary.py | 4 +- src/sampletones_application/application.py | 11 +- .../categories/elements/sequencer.py | 2 +- .../categories/hierarchy.py | 2 +- .../config/session/application/playback.py | 2 +- .../coordinators/tabs/sequencer.py | 146 +++++++-------- .../logic/sequencer/channels.py | 6 +- .../logic/sequencer/history_detail.py | 24 +-- .../logic/sequencer/order.py | 10 +- .../logic/sequencer/{grid.py => tracker.py} | 26 +-- src/sampletones_application/tags/sequencer.py | 18 +- .../ui/elements/table/caret.py | 2 +- .../ui/panels/sequencer/display.py | 2 +- .../ui/panels/sequencer/input/edit.py | 1 - .../{order_input.py => input/order.py} | 1 - .../ui/panels/sequencer/module.py | 20 ++- .../ui/panels/sequencer/order.py | 10 +- .../panels/sequencer/{grid.py => tracker.py} | 170 +++++++++--------- .../view_model/sequencer/order.py | 2 +- .../sequencer/{grid.py => tracker.py} | 11 +- src/sampletones_config/lang/en.yaml | 66 +++---- .../categories/key/test_grammar.py | 6 +- .../coordinators/tabs/test_sequencer.py | 120 +++++++------ .../logic/sequencer/test_history_detail.py | 12 +- .../{test_grid.py => test_tracker.py} | 56 +++--- .../sequencer/{ => input}/test_order_input.py | 2 +- .../ui/panels/sequencer/test_order_remove.py | 2 +- .../ui/panels/sequencer/test_panel_escape.py | 18 +- ...d_channels.py => test_tracker_channels.py} | 32 ++-- ...t_menu.py => test_tracker_context_menu.py} | 34 ++-- ...er_menu.py => test_tracker_header_menu.py} | 34 ++-- ...vigation.py => test_tracker_navigation.py} | 6 +- ...rtcut.py => test_tracker_play_shortcut.py} | 6 +- ...test_grid_rows.py => test_tracker_rows.py} | 22 +-- .../view_model/sequencer/test_order.py | 16 +- .../{test_grid.py => test_tracker.py} | 2 +- .../meta/source/bindings/test_scopes.py | 4 +- .../unit/scripts/checks/test_language_keys.py | 2 +- 39 files changed, 493 insertions(+), 427 deletions(-) rename src/sampletones_application/logic/sequencer/{grid.py => tracker.py} (96%) rename src/sampletones_application/ui/panels/sequencer/{order_input.py => input/order.py} (96%) rename src/sampletones_application/ui/panels/sequencer/{grid.py => tracker.py} (90%) rename src/sampletones_application/view_model/sequencer/{grid.py => tracker.py} (93%) rename tests/unit/sampletones_application/logic/sequencer/{test_grid.py => test_tracker.py} (89%) rename tests/unit/sampletones_application/ui/panels/sequencer/{ => input}/test_order_input.py (97%) rename tests/unit/sampletones_application/ui/panels/sequencer/{test_grid_channels.py => test_tracker_channels.py} (92%) rename tests/unit/sampletones_application/ui/panels/sequencer/{test_grid_context_menu.py => test_tracker_context_menu.py} (81%) rename tests/unit/sampletones_application/ui/panels/sequencer/{test_grid_header_menu.py => test_tracker_header_menu.py} (92%) rename tests/unit/sampletones_application/ui/panels/sequencer/{test_grid_navigation.py => test_tracker_navigation.py} (90%) rename tests/unit/sampletones_application/ui/panels/sequencer/{test_grid_play_shortcut.py => test_tracker_play_shortcut.py} (87%) rename tests/unit/sampletones_application/ui/panels/sequencer/{test_grid_rows.py => test_tracker_rows.py} (89%) rename tests/unit/sampletones_application/view_model/sequencer/{test_grid.py => test_tracker.py} (98%) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 6e531296a..24fb9b2db 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -88,7 +88,9 @@ Text resolves where it is displayed. A class that reads text holds the manager a 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 +102,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. @@ -211,7 +215,7 @@ 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. **Must not import:** `ui/`, `coordinators/`, `logic/`, `services/`, `config/`. diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py index 47b8cf3d3..f373fb8ed 100755 --- a/scripts/checks/import_boundary.py +++ b/scripts/checks/import_boundary.py @@ -138,8 +138,8 @@ class Violation(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( diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 4f0159b91..c9282448f 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -20,7 +20,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, @@ -592,7 +594,12 @@ def _on_palette_changed(self, _palette: Palette) -> None: 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: diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 41939aa6c..3871f0834 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" diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py index 0b21d0aa6..172ecb90f 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() diff --git a/src/sampletones_application/config/session/application/playback.py b/src/sampletones_application/config/session/application/playback.py index 5a1f7cd1b..628cefe0a 100644 --- a/src/sampletones_application/config/session/application/playback.py +++ b/src/sampletones_application/config/session/application/playback.py @@ -8,7 +8,7 @@ class PlaybackConfig(BaseModel): ) follow_playback: bool = Field( default=True, - description="If the sequencer grid follows the playhead during playback.", + description="If the sequencer tracker follows the playhead during playback.", ) loop_song: bool = Field( default=False, diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index eaf3a0517..d808f046c 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -22,7 +22,6 @@ 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 +34,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,23 +53,23 @@ 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 @@ -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,14 @@ 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, ) 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), @@ -236,7 +236,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 +246,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 +258,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 +269,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 +337,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 +351,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() @@ -453,7 +453,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 +564,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 +615,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() @@ -697,13 +697,13 @@ 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) @@ -715,15 +715,15 @@ def repaint(self) -> None: what pushing the current view models through the panels does. """ self._sequencer_channels_logic.push_channels() - self._sequencer_grid_logic.refresh() + self._sequencer_tracker_logic.refresh() self._sequencer_order_logic.refresh() 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: @@ -732,7 +732,7 @@ def _on_player_error(self, error: Exception) -> None: def _on_player_view_changed(self, view_model: SongPlayerViewModel) -> None: 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( @@ -741,19 +741,19 @@ def _on_player_position_changed( row_index: int, ) -> None: self._playing_order = order_position - self._sequencer_grid_panel.set_playing_row(row_index) + 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) + self._sequencer_tracker_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. """ - self._sequencer_grid_logic.select_frame(frame_index) + self._sequencer_tracker_logic.select_frame(frame_index) if self._song_player_logic.follow_playback: self._song_player_logic.seek(frame_index) @@ -847,7 +847,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) @@ -885,7 +885,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) @@ -943,7 +943,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) @@ -966,9 +966,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, @@ -981,18 +981,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, @@ -1010,12 +1010,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, @@ -1029,7 +1029,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, @@ -1044,14 +1044,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, ) @@ -1063,9 +1063,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, @@ -1079,9 +1079,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, @@ -1092,10 +1092,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}") @@ -1148,7 +1148,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: @@ -1163,7 +1163,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: @@ -1179,7 +1179,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) @@ -1237,7 +1237,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.""" @@ -1266,10 +1266,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) -> None: - """Drops the order cursor and sample selection when the tracker grid takes focus. + """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 @@ -1279,8 +1279,8 @@ def _on_tracker_cell_focused(self) -> None: 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: @@ -1321,10 +1321,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/logic/sequencer/channels.py b/src/sampletones_application/logic/sequencer/channels.py index f5d080f7a..aff9faef8 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 68c5d30e0..b6babae7d 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 b1c8e4cd9..2275ea49e 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/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 fa59aa7af..ed421543c 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/tags/sequencer.py b/src/sampletones_application/tags/sequencer.py index 6e0c2e1d8..0fddc3982 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/ui/elements/table/caret.py b/src/sampletones_application/ui/elements/table/caret.py index b5a5f9ed1..40f0a0166 100644 --- a/src/sampletones_application/ui/elements/table/caret.py +++ b/src/sampletones_application/ui/elements/table/caret.py @@ -35,7 +35,7 @@ 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. """ diff --git a/src/sampletones_application/ui/panels/sequencer/display.py b/src/sampletones_application/ui/panels/sequencer/display.py index b53a99d43..a35151cae 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/input/edit.py b/src/sampletones_application/ui/panels/sequencer/input/edit.py index 8a2b03d55..3a8a2704e 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 dcabcfc1e..f6042e91b 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 661163379..5b22790dc 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, diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index cacfb5ead..558ac9460 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -41,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, @@ -73,7 +73,7 @@ ) 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 @@ -320,7 +320,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: @@ -427,7 +427,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): @@ -442,7 +442,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. diff --git a/src/sampletones_application/ui/panels/sequencer/grid.py b/src/sampletones_application/ui/panels/sequencer/tracker.py similarity index 90% rename from src/sampletones_application/ui/panels/sequencer/grid.py rename to src/sampletones_application/ui/panels/sequencer/tracker.py index ac9b8cba5..b6ca2d2f0 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -2,7 +2,9 @@ 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 @@ -12,11 +14,11 @@ 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, @@ -78,14 +80,14 @@ 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 @@ -109,7 +111,7 @@ VOLUME_COARSE_STEP: Final[int] = (MAX_VOLUME + 1) // 4 -class GUISequencerGridPanel(GUIPanel): +class GUISequencerTrackerPanel(GUIPanel): def __init__( self, *, @@ -129,9 +131,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]] = {} @@ -167,7 +169,7 @@ 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) @@ -181,63 +183,63 @@ def __init__( self._sc_play_from_frame = KeyCombination(dpg.mvKey_Spacebar, CTRL).display() 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. @@ -246,12 +248,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, @@ -350,17 +352,17 @@ def _create_tracker_view(self, parent: str) -> None: self._lbl_tracker, glyph=self._glyphs.headers.tracker, ): - dpg.add_group(tag=TAG_SEQUENCER_GRID_GROUP_TRACKER) + dpg.add_group(tag=TAG_SEQUENCER_TRACKER_GROUP) with ( dpg.child_window( - tag=TAG_SEQUENCER_GRID_WINDOW_TRACKER, - parent=TAG_SEQUENCER_GRID_GROUP_TRACKER, + tag=TAG_SEQUENCER_TRACKER_WINDOW, + parent=TAG_SEQUENCER_TRACKER_GROUP, border=False, width=0, height=-1, ), dpg.table( - tag=TAG_SEQUENCER_GRID_TABLE_TRACKER, + tag=TAG_SEQUENCER_TRACKER_TABLE, width=0, header_row=False, resizable=False, @@ -399,9 +401,9 @@ def _create_tracker_view(self, parent: str) -> None: ) 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 @@ -417,10 +419,10 @@ 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) + dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) self._editable_cells.reset(cell_values) self._build_table(view_model) self._highlight_sample_column() @@ -448,12 +450,12 @@ 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.rgba, ) dpg.highlight_table_column( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + TAG_SEQUENCER_TRACKER_TABLE, DIVIDER_TABLE_COLUMN, self._layout.colors.sample.divider.rgba, ) @@ -467,7 +469,7 @@ 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.rgba, @@ -483,7 +485,7 @@ 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), ) @@ -500,7 +502,7 @@ def _channel_column_tint(self, generator: GeneratorName) -> ColorRGBA: def _compute_cell_values( self, - view_model: SequencerGridViewModel, + view_model: SequencerTrackerViewModel, ) -> CellValues: cell_values: CellValues = {} for row in view_model.rows: @@ -523,7 +525,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() @@ -538,7 +540,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) @@ -584,7 +586,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) @@ -717,7 +719,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() @@ -745,7 +747,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, @@ -762,17 +764,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( @@ -859,13 +861,13 @@ def _apply_cell_highlight( ) -> None: table_row = tracker_table_row(row_index) dpg.highlight_table_row( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + TAG_SEQUENCER_TRACKER_TABLE, table_row, color=self._layout.colors.cursor_row.rgba, ) column_index = tracker_table_column(generator) dpg.highlight_table_cell( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + TAG_SEQUENCER_TRACKER_TABLE, table_row, column_index, color=self._layout.colors.cell_cursor.rgba, @@ -878,12 +880,12 @@ def _remove_cell_highlight( ) -> None: table_row = tracker_table_row(row_index) dpg.unhighlight_table_row( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + TAG_SEQUENCER_TRACKER_TABLE, table_row, ) col_idx = tracker_table_column(generator) dpg.unhighlight_table_cell( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + TAG_SEQUENCER_TRACKER_TABLE, table_row, col_idx, ) @@ -1217,15 +1219,15 @@ def _scroll_cursor_into_view(self) -> None: if cursor is None or self._current_row_count <= 1: return - if not dpg.does_item_exist(TAG_SEQUENCER_GRID_TABLE_TRACKER): + if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): return - scroll_max = dpg.get_y_scroll_max(TAG_SEQUENCER_GRID_TABLE_TRACKER) + scroll_max = dpg.get_y_scroll_max(TAG_SEQUENCER_TRACKER_TABLE) if scroll_max <= 0: return fraction = cursor.row / (self._current_row_count - 1) - dpg.set_y_scroll(TAG_SEQUENCER_GRID_TABLE_TRACKER, fraction * scroll_max) + dpg.set_y_scroll(TAG_SEQUENCER_TRACKER_TABLE, fraction * scroll_max) def _clear_row(self) -> None: state, clear_action = self._input_state.clear() @@ -1288,7 +1290,7 @@ def highlight_row(self, row_index: Optional[int] = None) -> None: return dpg.highlight_table_row( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + TAG_SEQUENCER_TRACKER_TABLE, tracker_table_row(row_index), color=self._layout.colors.pattern_highlight.rgba, ) @@ -1298,7 +1300,7 @@ def unhighlight_row(self, row_index: Optional[int] = None) -> None: return dpg.unhighlight_table_row( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + TAG_SEQUENCER_TRACKER_TABLE, tracker_table_row(row_index), ) self._highlighted_row = None @@ -1306,7 +1308,7 @@ def unhighlight_row(self, row_index: Optional[int] = None) -> None: def set_playing_row(self, row_index: Optional[int]) -> None: if self._playing_row is not None and self._playing_row < self._live_row_count(): dpg.unhighlight_table_row( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + TAG_SEQUENCER_TRACKER_TABLE, tracker_table_row(self._playing_row), ) @@ -1322,7 +1324,7 @@ def _apply_playing_row_highlight(self) -> None: """ if self._playing_row is not None and self._playing_row < self._live_row_count(): dpg.highlight_table_row( - TAG_SEQUENCER_GRID_TABLE_TRACKER, + TAG_SEQUENCER_TRACKER_TABLE, tracker_table_row(self._playing_row), color=self._layout.colors.playback_row.rgba, ) @@ -1335,8 +1337,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/view_model/sequencer/order.py b/src/sampletones_application/view_model/sequencer/order.py index 1f9b1aafe..8a2246705 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/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 d6b388726..addac95c9 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_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 72e12892e..effb2ff03 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -432,39 +432,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 diff --git a/tests/unit/sampletones_application/categories/key/test_grammar.py b/tests/unit/sampletones_application/categories/key/test_grammar.py index 594ba98b9..295b4ec29 100644 --- a/tests/unit/sampletones_application/categories/key/test_grammar.py +++ b/tests/unit/sampletones_application/categories/key/test_grammar.py @@ -24,11 +24,11 @@ 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="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), @@ -49,7 +49,7 @@ class TestCase(BaseRegularTestCase): 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: diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 3f67fa646..737c779c7 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -11,15 +11,21 @@ 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.shared.history import ( @@ -57,8 +63,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 +141,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 +159,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 +170,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 +181,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 +191,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 +228,7 @@ 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() @pytest.fixture @@ -230,8 +236,8 @@ 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 @@ -245,9 +251,9 @@ def test_position_change_follows_playhead_when_enabled( 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) + playback_coordinator._sequencer_tracker_logic.select_frame.assert_called_once_with(2) def test_position_change_does_not_move_edited_frame_when_disabled( self, @@ -257,9 +263,9 @@ def test_position_change_does_not_move_edited_frame_when_disabled( 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_not_called() + playback_coordinator._sequencer_tracker_logic.select_frame.assert_not_called() def test_order_selection_seeks_playhead_when_following( self, @@ -269,7 +275,7 @@ def test_order_selection_seeks_playhead_when_following( playback_coordinator._on_order_frame_selected(3) - playback_coordinator._sequencer_grid_logic.select_frame.assert_called_once_with(3) + playback_coordinator._sequencer_tracker_logic.select_frame.assert_called_once_with(3) playback_coordinator._song_player_logic.seek.assert_called_once_with(3) def test_order_selection_only_edits_when_not_following( @@ -280,7 +286,7 @@ def test_order_selection_only_edits_when_not_following( playback_coordinator._on_order_frame_selected(3) - playback_coordinator._sequencer_grid_logic.select_frame.assert_called_once_with(3) + playback_coordinator._sequencer_tracker_logic.select_frame.assert_called_once_with(3) playback_coordinator._song_player_logic.seek.assert_not_called() @@ -291,8 +297,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 +306,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 +315,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 +398,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 +487,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 +501,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 +516,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 +549,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 +609,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, @@ -657,7 +663,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 +684,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: @@ -848,17 +854,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 +879,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 +890,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 +904,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 +914,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 +925,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 +937,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 +949,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 +998,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 +1009,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 +1083,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() 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 85876cd29..b236a09ad 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_grid.py b/tests/unit/sampletones_application/logic/sequencer/test_tracker.py similarity index 89% rename from tests/unit/sampletones_application/logic/sequencer/test_grid.py rename to tests/unit/sampletones_application/logic/sequencer/test_tracker.py index 6a220eee5..6381d9eae 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 @@ -57,7 +57,7 @@ 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) @@ -65,7 +65,7 @@ def test_set_note_off_writes_note_off_command(self) -> None: 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 +76,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 +86,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,7 +95,7 @@ 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) + logic = SequencerTrackerLogic(controller) stale = controller.add_sample(_reconstruction([GeneratorName.PULSE2]), name="bass") pattern_index = controller.project.song.order[0][GeneratorName.PULSE2] controller.set_row( @@ -116,7 +116,7 @@ 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) + logic = SequencerTrackerLogic(controller) sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead") logic.set_sample_instrument(0, sample.id) @@ -129,7 +129,7 @@ def test_none_sample_clears_the_whole_row(self) -> None: class TestSampleSubcolumn: 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", @@ -156,7 +156,7 @@ def test_synchronises_across_relevant_channels_even_without_instrument(self) -> 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 +168,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 +189,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 +197,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 +206,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 +215,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 +231,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 +239,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 +247,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 +258,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 +275,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 +288,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 +304,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 +318,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 +332,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 +352,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 +366,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/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 314f0f6b2..ee068f1a9 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_order_remove.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py index b35ed2c70..c829213ef 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,8 @@ 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 ad600693b..84835206e 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,11 +3,11 @@ 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 @@ -17,17 +17,17 @@ 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._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._input_state = TrackerInputState(cursor=TrackerCursor(0, None, SubColumn.INSTRUMENT), pending="3") applied: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", applied.append) @@ -53,3 +53,9 @@ def test_escape_cancels_a_pending_edit_and_consumes(self, monkeypatch: pytest.Mo assert panel._on_key_pressed(_escape()) is True assert applied and applied[0].pending == "" + + assert panel._on_key_pressed(_escape()) is True + assert applied and applied[0].pending == "" + + assert panel._on_key_pressed(_escape()) is True + assert applied and applied[0].pending == "" 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 92% 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 238727aef..bd11226a5 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 @@ -8,12 +8,18 @@ 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.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.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 @@ -81,13 +87,13 @@ 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, @@ -121,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 @@ -325,9 +331,9 @@ 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 81% 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 484f9d84b..846645149 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 @@ -98,7 +93,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 +113,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 ad43e6797..457758d15 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_grid_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py similarity index 90% rename from tests/unit/sampletones_application/ui/panels/sequencer/test_grid_navigation.py rename to tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py index cf8a8a398..d8511e119 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -3,9 +3,9 @@ import pytest -from sampletones_application.ui.panels.sequencer.grid import GUISequencerGridPanel from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard import KeyEvent from sampletones_application.utils.gui.keyboard.keys import KEY_PAGE_DOWN, KEY_PAGE_UP from sampletones_application.utils.gui.keyboard.modifiers import NO_MODIFIERS @@ -14,8 +14,8 @@ PAGE_SIZE = 16 -def _panel() -> GUISequencerGridPanel: - panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel) +def _panel() -> GUISequencerTrackerPanel: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._input_state = TrackerInputState(cursor=TrackerCursor(5, None, SubColumn.INSTRUMENT), pending="") panel._layout = SimpleNamespace(tracker=SimpleNamespace(page_size=PAGE_SIZE)) return panel 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 87% 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 74b4b4ad0..2942997bc 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,16 @@ 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 -def _panel(cursor: Optional[TrackerCursor]) -> GUISequencerGridPanel: - panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel) +def _panel(cursor: Optional[TrackerCursor]) -> GUISequencerTrackerPanel: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._input_state = TrackerInputState(cursor=cursor, pending="") return panel diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py similarity index 89% rename from tests/unit/sampletones_application/ui/panels/sequencer/test_grid_rows.py rename to tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py index 0f80b3036..1adba4b5d 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_grid_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -3,13 +3,13 @@ import pytest -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 ( HEADER_TABLE_ROW, tracker_table_column, tracker_table_row, ) -from sampletones_application.ui.panels.sequencer.grid import GUISequencerGridPanel +from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.palette.colors.written import LiteralColor from sampletones_core.constants.enums import GeneratorName from sampletones_shared.types.application import ColorRGBA @@ -53,9 +53,9 @@ def unhighlight_table_cell(self, table: str, row: int, column: int) -> None: self.unhighlighted_cells.append((row, column)) -def _panel() -> GUISequencerGridPanel: +def _panel() -> GUISequencerTrackerPanel: """Builds a panel around the state the row highlights read, with no DearPyGui context.""" - panel = GUISequencerGridPanel.__new__(GUISequencerGridPanel) + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._layout = SimpleNamespace( colors=SimpleNamespace( cursor_row=LiteralColor(CURSOR_ROW), @@ -73,12 +73,12 @@ def _panel() -> GUISequencerGridPanel: @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) + 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 @@ -223,7 +223,7 @@ def test_every_table_column_of_the_header_takes_the_header_shade( 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)) + assert columns == set(range(tracker_module.TRACKER_TABLE_COLUMNS)) def test_the_header_shade_covers_the_sample_and_channel_columns( self, 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 9a5c0adfa..ca2b0250b 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_order.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_order.py @@ -5,7 +5,7 @@ from sampletones_application.view_model.sequencer.order import ( OrderEntryViewModel, - SequencerOrderGridViewModel, + SequencerOrderTrackerViewModel, SequencerOrderViewModel, ) from sampletones_core.constants.enums import GeneratorName @@ -15,7 +15,7 @@ _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, @@ -26,7 +26,7 @@ def _grid(channels: Dict[GeneratorName, List[Optional[int]]]) -> SequencerOrderG 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) + return SequencerOrderTrackerViewModel(position_count=position_count, channels=views) def _uniform(*indices: Optional[int]) -> Dict[GeneratorName, List[Optional[int]]]: @@ -34,10 +34,10 @@ 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)) + tracker = _tracker(_uniform(5, None)) - assert grid.entry_label(GeneratorName.PULSE1, 0) == display_id(5) - assert grid.entry_label(GeneratorName.PULSE1, 1) == _EMPTY + assert tracker.entry_label(GeneratorName.PULSE1, 0) == display_id(5) + assert tracker.entry_label(GeneratorName.PULSE1, 1) == _EMPTY @dataclass(frozen=True) @@ -75,6 +75,6 @@ class MasterCase: @pytest.mark.parametrize("case", _CASES, ids=lambda case: case.name) def test_master_label_aggregates_across_channels(case: MasterCase) -> None: - grid = _grid(case.channels) + tracker = _tracker(case.channels) - assert grid.master_label(0) == case.expected + assert tracker.master_label(0) == case.expected diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_grid.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py similarity index 98% rename from tests/unit/sampletones_application/view_model/sequencer/test_grid.py rename to tests/unit/sampletones_application/view_model/sequencer/test_tracker.py index fbd35bc02..97b459de0 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_grid.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py @@ -3,7 +3,7 @@ import pytest -from sampletones_application.view_model.sequencer.grid import ( +from sampletones_application.view_model.sequencer.tracker import ( SequencerCellViewModel, SequencerRowViewModel, ) 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 97b4dc351..5adbdf036 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()] @@ -90,7 +90,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" diff --git a/tests/unit/scripts/checks/test_language_keys.py b/tests/unit/scripts/checks/test_language_keys.py index e88bfaa45..0d1da8d49 100644 --- a/tests/unit/scripts/checks/test_language_keys.py +++ b/tests/unit/scripts/checks/test_language_keys.py @@ -67,7 +67,7 @@ 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] == {} From 60ba68aaeb268ae7d830ee1135cbefd1fc40e6d2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 19:14:28 +0200 Subject: [PATCH 015/152] Reformat --- .../utils/parallelization/thread.py | 6 +- tests/integration/assets/reconstruction.py | 5 +- tests/integration/assets/song_loader.py | 4 +- .../integration/bitphase/test_btp_pipeline.py | 6 +- .../services/test_regeneration.py | 15 +- tests/suite/errors.py | 6 +- tests/suite/famitracker.py | 46 +++- .../categories/key/test_grammar.py | 146 +++++++++-- .../config/deployment/test_deployment.py | 6 +- .../coordinators/tabs/test_instructions.py | 20 +- .../coordinators/tabs/test_reconstruction.py | 36 ++- .../coordinators/tabs/test_sequencer.py | 45 +++- .../coordinators/test_config.py | 51 +++- .../coordinators/test_display.py | 10 +- .../coordinators/test_project.py | 72 +++-- .../coordinators/test_reconstruction.py | 88 +++++-- .../logic/history/test_fingerprint.py | 19 +- .../logic/history/test_manager.py | 166 +++++++++--- .../logic/instruction/test_library_logic.py | 8 +- .../logic/main/test_converter.py | 82 ++++-- .../logic/project/test_controller.py | 81 ++++-- .../logic/project/title/test_compose.py | 5 +- .../logic/reconstruction/test_data.py | 82 ++++-- .../logic/reconstruction/test_feature.py | 4 +- .../logic/reconstruction/test_instruments.py | 8 +- .../reconstruction/test_reconstruction.py | 58 ++++- .../playback/test_apply_modifiers.py | 245 +++++++++++++++--- .../logic/sequencer/playback/test_playhead.py | 7 +- .../sequencer/playback/test_song_player.py | 111 ++++++-- .../sequencer/playback/test_synthesizer.py | 217 ++++++++++++---- .../logic/sequencer/test_channels.py | 44 +++- .../logic/sequencer/test_samples.py | 73 +++++- .../logic/sequencer/test_tracker.py | 55 +++- .../logic/shared/test_tree.py | 37 ++- .../parameters/conftest.py | 6 +- .../parameters/test_geometry.py | 8 +- .../parameters/test_main.py | 13 +- .../parameters/test_reconstruction.py | 15 +- .../services/export/test_result.py | 84 +++++- .../services/export/test_service.py | 197 +++++++++++--- .../services/song_player/test_song_player.py | 5 +- .../services/test_conversion.py | 151 ++++++++--- .../services/test_regeneration.py | 140 +++++++--- .../tags/test_compose.py | 18 +- .../test_application_retune.py | 14 +- .../test_project_properties_history.py | 12 +- .../sampletones_application/test_viewport.py | 127 ++++++--- .../ui/elements/graphs/test_waveform.py | 10 +- .../ui/elements/layout/test_collapse.py | 29 ++- .../ui/elements/table/test_caret.py | 11 +- .../ui/elements/test_button.py | 10 +- .../ui/elements/test_pitch_stepper.py | 6 +- .../ui/elements/test_plus_minus_buttons.py | 4 +- .../test_details_instruction_changed.py | 14 +- .../instruction/test_library_actions_lock.py | 3 +- .../reconstruction/test_instruments_panel.py | 14 +- .../panels/sequencer/test_channels_switch.py | 7 +- .../ui/panels/sequencer/test_history_panel.py | 7 +- .../panels/sequencer/test_order_channels.py | 24 +- .../ui/panels/sequencer/test_order_remove.py | 5 +- .../ui/panels/sequencer/test_panel_escape.py | 5 +- .../panels/sequencer/test_tracker_channels.py | 6 +- .../sequencer/test_tracker_context_menu.py | 7 +- .../sampletones_application/ui/test_menu.py | 4 +- .../ui/themes/test_registry.py | 5 +- .../backends/portal/test_backend.py | 9 +- .../backends/portal/test_client.py | 10 +- .../file_dialogs/backends/test_command.py | 10 +- .../utils/file_dialogs/test_api.py | 10 +- .../utils/file_dialogs/test_filter.py | 5 +- .../utils/file_dialogs/test_selection.py | 37 ++- .../utils/gui/keyboard/focus/item_tree.py | 5 +- .../gui/keyboard/focus/test_consumption.py | 39 ++- .../utils/gui/keyboard/focus/test_items.py | 30 ++- .../utils/gui/keyboard/focus/test_query.py | 7 +- .../utils/gui/keyboard/focus/test_search.py | 66 ++++- .../utils/gui/keyboard/test_modifiers.py | 21 +- .../utils/gui/keyboard/test_router.py | 30 ++- .../utils/gui/shortcuts/test_manager.py | 44 +++- .../utils/gui/test_palette.py | 12 +- .../utils/palette/test_catalog.py | 5 +- .../utils/palette/test_colors.py | 7 +- .../utils/palette/test_palette.py | 14 +- .../utils/palette/test_reference.py | 5 +- .../utils/palette/test_written.py | 7 +- .../view_model/instruction/test_library.py | 9 +- .../view_model/main/test_converter.py | 12 +- .../view_model/sequencer/test_channels.py | 4 +- .../view_model/sequencer/test_order.py | 4 +- .../view_model/sequencer/test_tracker.py | 17 +- .../view_model/shared/test_audio_settings.py | 19 +- .../shared/test_display_settings.py | 8 +- .../view_model/shared/test_menu.py | 4 +- .../sampletones_core/audio/test_processing.py | 38 ++- .../calibration/config/test_corpus.py | 6 +- .../exporters/implementation/test_noise.py | 10 +- .../sampletones_core/features/test_spec.py | 11 +- .../sampletones_core/fft/cqt/test_geometry.py | 7 +- .../fft/test_spectrum_scaling.py | 18 +- .../formats/bitphase/test_btp.py | 5 +- .../formats/bitphase/test_builder.py | 40 ++- .../formats/bitphase/test_envelopes.py | 5 +- .../formats/bitphase/test_preset.py | 6 +- .../formats/bitphase/test_project_builder.py | 29 ++- .../formats/famitracker/conftest.py | 23 +- .../famitracker/sequences/test_features.py | 4 +- .../formats/famitracker/test_binary.py | 5 +- .../formats/famitracker/test_builder.py | 24 +- .../formats/famitracker/test_fti.py | 4 +- .../formats/famitracker/test_ftm.py | 5 +- .../formats/famitracker/test_notes.py | 38 ++- .../sampletones_core/generators/test_utils.py | 6 +- .../library/filename/test_fields.py | 5 +- .../project/test_container.py | 6 +- .../project/test_serialization.py | 5 +- .../sampletones_core/project/test_settings.py | 78 ++++-- .../criterion/test_criterion.py | 8 +- .../reconstruction/test_reconstruction.py | 6 +- .../reconstructor/selector/test_viterbi.py | 4 +- .../reconstructor/test_reconstructor.py | 18 +- .../reconstructor/test_state.py | 4 +- .../structures/tree/test_arguments.py | 116 +++++++-- .../timers/implementation/test_phase.py | 15 +- .../trackers/test_bitphase.py | 11 +- .../trackers/test_famitracker.py | 10 +- .../sampletones_core/utils/test_pitch_kind.py | 6 +- .../meta/source/bindings/test_containers.py | 41 ++- .../meta/source/bindings/test_environment.py | 12 +- .../meta/source/bindings/test_scopes.py | 31 ++- .../meta/source/bindings/test_statements.py | 45 +++- .../meta/source/test_annotations.py | 60 ++++- .../meta/source/test_constants.py | 8 +- .../meta/source/test_index.py | 5 +- .../meta/source/test_lookups.py | 39 ++- .../meta/source/test_nodes.py | 10 +- .../meta/source/test_subscripts.py | 5 +- .../meta/source/test_values.py | 89 +++++-- .../utils/system/test_filesystem.py | 10 +- .../utils/test_validation.py | 61 ++++- .../oscillators/test_sweeps.py | 6 +- .../unit/sampletones_synthesis/test_unions.py | 30 ++- .../sampletones_synthesis/voice/test_layer.py | 48 +++- .../sampletones_synthesis/voice/test_voice.py | 66 ++++- .../unit/scripts/checks/test_language_keys.py | 66 ++++- tests/unit/scripts/ci/checks/test_bundle.py | 68 ++++- .../scripts/ci/checks/test_version_tag.py | 120 +++++++-- tests/unit/scripts/test_detect_cuda.py | 148 +++++++++-- 147 files changed, 3881 insertions(+), 938 deletions(-) diff --git a/src/sampletones_application/utils/parallelization/thread.py b/src/sampletones_application/utils/parallelization/thread.py index 5acbaa3bf..d13f101c6 100644 --- a/src/sampletones_application/utils/parallelization/thread.py +++ b/src/sampletones_application/utils/parallelization/thread.py @@ -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/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py index 0123aec81..60fba8bac 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 ce2eda378..03079cdc5 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 9829d27c4..e28737a0e 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 acb2d48a5..eaa09995e 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/suite/errors.py b/tests/suite/errors.py index 9cc5f65bd..a79fcedd3 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 66de2bc7a..d1245c514 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/unit/sampletones_application/categories/key/test_grammar.py b/tests/unit/sampletones_application/categories/key/test_grammar.py index 295b4ec29..36030733a 100644 --- a/tests/unit/sampletones_application/categories/key/test_grammar.py +++ b/tests/unit/sampletones_application/categories/key/test_grammar.py @@ -26,29 +26,113 @@ class TestCase(BaseRegularTestCase): 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.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="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) @@ -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/config/deployment/test_deployment.py b/tests/unit/sampletones_application/config/deployment/test_deployment.py index ec76af5d8..5d44b39d9 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/coordinators/tabs/test_instructions.py b/tests/unit/sampletones_application/coordinators/tabs/test_instructions.py index f15913f12..df7e31a30 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 c0f660a16..b23318406 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 737c779c7..7c171e066 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -629,7 +629,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")) @@ -644,7 +647,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")) @@ -712,7 +718,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 — @@ -745,7 +753,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 @@ -1126,11 +1137,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( @@ -1139,7 +1157,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( @@ -1179,7 +1200,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)) @@ -1209,5 +1233,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 ff7032f16..ace3c6c59 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 index 8dd75a318..e2dafdc18 100644 --- a/tests/unit/sampletones_application/coordinators/test_display.py +++ b/tests/unit/sampletones_application/coordinators/test_display.py @@ -97,7 +97,10 @@ def set_borderless(self, borderless: bool) -> None: class _ViewportRecorder: def __init__(self) -> None: - self.resolution: Tuple[int, int] = (DEFAULT_RESOLUTION.width, DEFAULT_RESOLUTION.height) + self.resolution: Tuple[int, int] = ( + DEFAULT_RESOLUTION.width, + DEFAULT_RESOLUTION.height, + ) self.fullscreen_toggles = 0 self.calls: List[Tuple[str, Any]] = [] @@ -281,7 +284,10 @@ def test_vsync_reaches_the_viewport_the_moment_it_is_switched(self, harness: Har 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 + 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)) diff --git a/tests/unit/sampletones_application/coordinators/test_project.py b/tests/unit/sampletones_application/coordinators/test_project.py index c821a1f57..e811e956b 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 80167c75f..4b86af48b 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -5,7 +5,9 @@ import pytest -from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator +from sampletones_application.coordinators.reconstruction import ( + ReconstructionCoordinator, +) from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.services.regeneration import RegeneratedInstrument from sampletones_application.services.result import ServiceSuccess @@ -36,7 +38,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 +61,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 +78,7 @@ class TestReconstructionRestoreAbsorbsFailures(BaseTestSuite): class TestCase(BaseRegularTestCase): failure: Exception - test_cases = [ + test_cases = ( TestCase( label="invalid_values", failure=InvalidReconstructionValuesError("bad", ValueError("inner")), @@ -85,9 +94,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 +158,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): @@ -162,21 +178,49 @@ class TestCase(BaseRegularTestCase): expects_prompt: bool test_cases = [ - TestCase(label="standalone_unsaved_prompts", unsaved=True, embedded=False, expects_prompt=True, expected=True), 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 +231,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/logic/history/test_fingerprint.py b/tests/unit/sampletones_application/logic/history/test_fingerprint.py index 79845dd65..d5f054fec 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 2878c2462..0e18b1cc5 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 4a7b0e1d0..e1ac1669a 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 468c4a8ad..4fc8ddac3 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 ed2e55dd2..656cb2dbf 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 32fce3bc7..350f22fe6 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/reconstruction/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 9aeeda5af..22f2e8b58 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_data.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_data.py @@ -18,7 +18,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 +31,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 +54,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 +68,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 +118,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 +132,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 +149,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 +164,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 +180,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 +203,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 +223,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 +237,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 +251,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 115bb3493..b62e8edb1 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 c678f2fb4..f8fd4aa8b 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -6,9 +6,13 @@ 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 diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 242e85f63..860d94195 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, @@ -100,8 +100,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,7 +115,11 @@ 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") @@ -202,7 +211,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,12 +223,19 @@ 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) + @pytest.mark.parametrize( + "case", + audio_path_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 @@ -469,7 +486,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 +501,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 +516,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 +654,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 8b542fb43..d3d8161fa 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 a75edad0f..89832f510 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 2f5db9479..aad8f56af 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 @@ -9,7 +9,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: @@ -179,7 +181,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 +197,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 +220,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 +240,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 +340,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 +364,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,10 +404,22 @@ 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)] 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 724185909..ba289a089 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 1c317991f..965284fac 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_samples.py b/tests/unit/sampletones_application/logic/sequencer/test_samples.py index 189a486e4..c67407732 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_tracker.py b/tests/unit/sampletones_application/logic/sequencer/test_tracker.py index 6381d9eae..fe4d29676 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_tracker.py @@ -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, @@ -61,7 +77,10 @@ def test_set_note_off_writes_note_off_command(self) -> None: 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() @@ -96,17 +115,26 @@ def test_fills_only_used_generators(self) -> None: def test_clears_channels_the_new_sample_does_not_use(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - stale = controller.add_sample(_reconstruction([GeneratorName.PULSE2]), name="bass") + 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 @@ -117,7 +145,10 @@ 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 = SequencerTrackerLogic(controller) - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead") + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1]), + name="lead", + ) logic.set_sample_instrument(0, sample.id) logic.set_sample_instrument(0, None) @@ -127,7 +158,9 @@ 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 = SequencerTrackerLogic(controller) sample = controller.add_sample( @@ -154,7 +187,9 @@ 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 = SequencerTrackerLogic(controller) diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py index 2d5fc672e..f12e2fcec 100644 --- a/tests/unit/sampletones_application/logic/shared/test_tree.py +++ b/tests/unit/sampletones_application/logic/shared/test_tree.py @@ -24,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( @@ -41,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: @@ -118,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 @@ -155,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 @@ -297,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() @@ -317,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 79dd22742..42e8a24a6 100644 --- a/tests/unit/sampletones_application/parameters/conftest.py +++ b/tests/unit/sampletones_application/parameters/conftest.py @@ -2,7 +2,11 @@ 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 +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 diff --git a/tests/unit/sampletones_application/parameters/test_geometry.py b/tests/unit/sampletones_application/parameters/test_geometry.py index a6efa21d6..288d0314d 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 537cb4dd7..64d4ec015 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 bc6c43b61..d19d1ef45 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 a17436016..715763e14 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 ebde20c20..664aaba72 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 e01cfcbc4..e63e166ef 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 @@ -2,7 +2,10 @@ 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 ( SongPlaybackStopped, SongPositionUpdate, diff --git a/tests/unit/sampletones_application/services/test_conversion.py b/tests/unit/sampletones_application/services/test_conversion.py index d9f0ee028..d7580c142 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 57be1d740..33350b890 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 cc4fa6b17..2a1a6fb69 100644 --- a/tests/unit/sampletones_application/tags/test_compose.py +++ b/tests/unit/sampletones_application/tags/test_compose.py @@ -26,18 +26,30 @@ class TestCase(BaseRegularTestCase): 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="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="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="whitespace_only_part_raises", + parts=("base", " "), + expected=ValueError, + ), ] @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) diff --git a/tests/unit/sampletones_application/test_application_retune.py b/tests/unit/sampletones_application/test_application_retune.py index f58f6e177..66684f8e6 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 405a97d21..8e9b9df20 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_viewport.py b/tests/unit/sampletones_application/test_viewport.py index 00b340307..c6428dfc5 100644 --- a/tests/unit/sampletones_application/test_viewport.py +++ b/tests/unit/sampletones_application/test_viewport.py @@ -54,17 +54,24 @@ 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, @@ -76,7 +83,12 @@ def test_result_stays_within_usable_area( ) 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 @@ -86,28 +98,47 @@ 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.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.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.utils.monitors.get_monitors", lambda: [_PRIMARY], @@ -119,20 +150,31 @@ 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_assumed_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.utils.monitors.get_monitors", list, ) manager = _manager() - 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_assumed_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]: @@ -144,7 +186,12 @@ def raise_screen_info_error() -> List[Monitor]: ) manager = _manager() - 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 @@ -160,15 +207,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 @@ -179,14 +237,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, @@ -207,7 +264,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) @@ -219,7 +279,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 d63e659f3..ce2de5541 100644 --- a/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py +++ b/tests/unit/sampletones_application/ui/elements/graphs/test_waveform.py @@ -58,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) @@ -177,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 6f994b712..02af2b2b7 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_collapse.py @@ -90,7 +90,10 @@ def _controller( def _build_card(controller: CollapseController) -> None: """Mirrors the item subtree ``_collapsible_section`` builds, without fonts or the header theme.""" - with dpg.window(), dpg.child_window(tag=controller.card_tag, height=_EXPANDED_HEIGHT): + 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) @@ -188,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 @@ -219,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) @@ -256,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 @@ -297,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) @@ -307,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/table/test_caret.py b/tests/unit/sampletones_application/ui/elements/table/test_caret.py index bdc0b02ff..3d493f240 100644 --- a/tests/unit/sampletones_application/ui/elements/table/test_caret.py +++ b/tests/unit/sampletones_application/ui/elements/table/test_caret.py @@ -17,7 +17,11 @@ # 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)), @@ -72,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 db7556cee..69833bc79 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 23d8cf797..db9b27f84 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 b8983a99b..4eb1a879e 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/panels/instruction/test_details_instruction_changed.py b/tests/unit/sampletones_application/ui/panels/instruction/test_details_instruction_changed.py index 98eb46684..5babe2a99 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 e11ff0626..70465e230 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/reconstruction/test_instruments_panel.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py index 0e2e61da0..7458f13a7 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 @@ -27,7 +27,9 @@ 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" @@ -99,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, @@ -108,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/sequencer/test_channels_switch.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_channels_switch.py index aa67653b1..15503553a 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_history_panel.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_history_panel.py index 8934214ba..369387031 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,7 +6,12 @@ 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, PALETTES_DIRECTORY +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.catalog import PaletteCatalog 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 b37cd4aad..47dbb8ee1 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 @@ -11,9 +11,15 @@ 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.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.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 @@ -270,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})) @@ -439,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_remove.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_remove.py index c829213ef..8308e931f 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,7 +1,10 @@ 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.input.order import ( + OrderCursor, + OrderInputState, +) from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_core.constants.enums import GeneratorName 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 84835206e..1f8583e8d 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 @@ -4,7 +4,10 @@ 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.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.tracker import GUISequencerTrackerPanel diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py index bd11226a5..535a10796 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_channels.py @@ -332,7 +332,11 @@ def test_the_model_is_kept_while_the_table_is_absent(self, monkeypatch: pytest.M """A mute set pushed before the table exists is reapplied by the next rebuild.""" instance = _DearPyGuiRecorder(table_exists=False) 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, + "highlight_table_column", + instance.highlight_table_column, + ) monkeypatch.setattr(tracker_module.dpg, "bind_item_theme", instance.bind_item_theme) panel = _panel(frozenset()) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 846645149..0eb8a719e 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -83,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() diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index d26dbe00d..3df3d7d75 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -16,7 +16,9 @@ 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 diff --git a/tests/unit/sampletones_application/ui/themes/test_registry.py b/tests/unit/sampletones_application/ui/themes/test_registry.py index ec56ce30f..6fa322fed 100644 --- a/tests/unit/sampletones_application/ui/themes/test_registry.py +++ b/tests/unit/sampletones_application/ui/themes/test_registry.py @@ -35,7 +35,10 @@ def test_each_tag_finds_its_own_theme(self) -> None: ThemeRegistry.register(default) ThemeRegistry.register(table) - assert (ThemeRegistry.get(default.tag), ThemeRegistry.get(table.tag)) == (default, 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") 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 9112fe248..b2f461141 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 1d8060a60..544ee4641 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 @@ -16,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" @@ -190,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 1673117c5..794a70a28 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 cf58b61ff..c0c4e81f7 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 c93d0f922..c53ec7990 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 dcbf94247..a16487f21 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/keyboard/focus/item_tree.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/item_tree.py index a38b37055..1dd821e3b 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 90cca2242..e808eb3cb 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, @@ -32,7 +34,12 @@ class TestCase(BaseRegularTestCase): 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, 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 68cfe3864..9a2206b4d 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 @@ -34,13 +34,29 @@ class TestCase(BaseRegularTestCase): 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="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), + 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) @@ -67,7 +83,11 @@ class TestCase(BaseRegularTestCase): 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="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), 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 2aa141206..68942df6c 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 @@ -35,7 +35,12 @@ class TestCase(BaseRegularTestCase): expected: FieldKind test_cases = [ - TestCase(label="nothing focused", items={}, focused_item=NO_ITEM, expected=FieldKind.NONE), + TestCase( + label="nothing focused", + items={}, + focused_item=NO_ITEM, + expected=FieldKind.NONE, + ), TestCase( label="stale item destroyed by a table rebuild", items={}, 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 1b37e2477..670651623 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 @@ -39,33 +39,75 @@ class TestCase(BaseRegularTestCase): 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) 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 34e911f32..99e8fd70f 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py @@ -49,7 +49,11 @@ class TestCase(BaseRegularTestCase): TestCase(label="right alt", held=[R_ALT], expected=ALT), 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="every modifier", + held=[L_CONTROL, L_SHIFT, L_ALT], + expected=CTRL_ALT_SHIFT, + ), ] @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) @@ -77,13 +81,22 @@ class TestCase(BaseRegularTestCase): 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="every modifier", + modifiers=CTRL_ALT_SHIFT, + expected=("Ctrl", "Alt", "Shift"), + ), ] @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", + ) 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 8ea759318..74e0ab336 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/test_manager.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py index a75b0d778..ff37e8508 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py @@ -4,7 +4,11 @@ import dearpygui.dearpygui as dpg import pytest -from sampletones_application.utils.gui.keyboard import KeyCombination, KeyEvent, KeyRouter +from sampletones_application.utils.gui.keyboard import ( + KeyCombination, + 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.modifiers import ( @@ -39,7 +43,11 @@ class TestShortcutDispatch: def test_matching_shortcut_fires_and_is_claimed(self) -> None: manager = _manager() callback = Mock() - manager.register(ShortcutId.SAVE_PROJECT, Shortcut(combination=KeyCombination(KEY, CTRL)), callback) + manager.register( + ShortcutId.SAVE_PROJECT, + Shortcut(combination=KeyCombination(KEY, CTRL)), + callback, + ) manager.bind_all() claimed = manager._dispatch(_event(modifiers=CTRL)) @@ -50,7 +58,11 @@ def test_matching_shortcut_fires_and_is_claimed(self) -> None: def test_modifier_mismatch_does_not_fire(self) -> None: manager = _manager() callback = Mock() - manager.register(ShortcutId.SAVE_PROJECT, Shortcut(combination=KeyCombination(KEY, CTRL)), callback) + manager.register( + ShortcutId.SAVE_PROJECT, + Shortcut(combination=KeyCombination(KEY, CTRL)), + callback, + ) manager.bind_all() claimed = manager._dispatch(_event()) @@ -79,7 +91,11 @@ class TestFieldFocusGate: def test_text_field_keeps_a_plain_space(self, field_kind: Dict[str, FieldKind]) -> None: manager = _manager() callback = Mock() - manager.register(ShortcutId.PLAY, Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar)), callback) + manager.register( + ShortcutId.PLAY, + Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar)), + callback, + ) manager.bind_all() field_kind["kind"] = FieldKind.TEXT_ENTRY @@ -92,7 +108,9 @@ def test_text_field_yields_ctrl_space(self, field_kind: Dict[str, FieldKind]) -> manager = _manager() callback = Mock() manager.register( - ShortcutId.PLAY_FROM_FRAME, Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar, CTRL)), callback + ShortcutId.PLAY_FROM_FRAME, + Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar, CTRL)), + callback, ) manager.bind_all() field_kind["kind"] = FieldKind.TEXT_ENTRY @@ -105,7 +123,11 @@ def test_text_field_yields_ctrl_space(self, field_kind: Dict[str, FieldKind]) -> def test_text_field_keeps_its_editing_chord(self, field_kind: Dict[str, FieldKind]) -> None: manager = _manager() callback = Mock() - manager.register(ShortcutId.AUDIO_SETTINGS, Shortcut(combination=KeyCombination(dpg.mvKey_A, CTRL)), callback) + manager.register( + ShortcutId.AUDIO_SETTINGS, + Shortcut(combination=KeyCombination(dpg.mvKey_A, CTRL)), + callback, + ) manager.bind_all() field_kind["kind"] = FieldKind.TEXT_ENTRY @@ -119,7 +141,9 @@ def test_text_field_yields_a_shifted_chord_it_has_no_use_for(self, field_kind: D manager = _manager() callback = Mock() manager.register( - ShortcutId.TOGGLE_ADVANCED_SETTINGS, Shortcut(combination=KeyCombination(dpg.mvKey_A, CTRL_SHIFT)), callback + ShortcutId.TOGGLE_ADVANCED_SETTINGS, + Shortcut(combination=KeyCombination(dpg.mvKey_A, CTRL_SHIFT)), + callback, ) manager.bind_all() field_kind["kind"] = FieldKind.TEXT_ENTRY @@ -132,7 +156,11 @@ def test_text_field_yields_a_shifted_chord_it_has_no_use_for(self, field_kind: D def test_focused_field_keeps_escape(self, field_kind: Dict[str, FieldKind]) -> None: manager = _manager() callback = Mock() - manager.register(ShortcutId.STOP, Shortcut(combination=KeyCombination(dpg.mvKey_Escape)), callback) + manager.register( + ShortcutId.STOP, + Shortcut(combination=KeyCombination(dpg.mvKey_Escape)), + callback, + ) manager.bind_all() field_kind["kind"] = FieldKind.TEXT_ENTRY diff --git a/tests/unit/sampletones_application/utils/gui/test_palette.py b/tests/unit/sampletones_application/utils/gui/test_palette.py index a34344754..d011a4305 100644 --- a/tests/unit/sampletones_application/utils/gui/test_palette.py +++ b/tests/unit/sampletones_application/utils/gui/test_palette.py @@ -3,7 +3,10 @@ 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.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 @@ -161,4 +164,9 @@ def test_a_derived_colour_follows_the_colour_it_came_from( PaletteBindings.apply() red, green, blue, _ = LIGHT_ACCENT - assert tuple(int(channel) for channel in dpg.get_value(item)) == (red, green, blue, 128) + assert tuple(int(channel) for channel in dpg.get_value(item)) == ( + red, + green, + blue, + 128, + ) diff --git a/tests/unit/sampletones_application/utils/palette/test_catalog.py b/tests/unit/sampletones_application/utils/palette/test_catalog.py index 78e04414f..4e58edbc0 100644 --- a/tests/unit/sampletones_application/utils/palette/test_catalog.py +++ b/tests/unit/sampletones_application/utils/palette/test_catalog.py @@ -3,7 +3,10 @@ import pytest from sampletones_application.paths import PALETTES_DIRECTORY -from sampletones_application.utils.palette.catalog import DEFAULT_PALETTE_NAME, PaletteCatalog +from sampletones_application.utils.palette.catalog import ( + DEFAULT_PALETTE_NAME, + PaletteCatalog, +) _STUDIO = """ name: studio diff --git a/tests/unit/sampletones_application/utils/palette/test_colors.py b/tests/unit/sampletones_application/utils/palette/test_colors.py index 9a5302e95..f90f20cf1 100644 --- a/tests/unit/sampletones_application/utils/palette/test_colors.py +++ b/tests/unit/sampletones_application/utils/palette/test_colors.py @@ -34,7 +34,12 @@ def test_desaturating_collapses_the_channels_to_one_luminance(self, accent: Base 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) + 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, diff --git a/tests/unit/sampletones_application/utils/palette/test_palette.py b/tests/unit/sampletones_application/utils/palette/test_palette.py index a45d5c592..eb06d836a 100644 --- a/tests/unit/sampletones_application/utils/palette/test_palette.py +++ b/tests/unit/sampletones_application/utils/palette/test_palette.py @@ -23,7 +23,12 @@ def test_a_reference_resolves_to_its_token_colour(self, palette: Palette) -> Non 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) + 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): @@ -34,7 +39,12 @@ 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) + 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): diff --git a/tests/unit/sampletones_application/utils/palette/test_reference.py b/tests/unit/sampletones_application/utils/palette/test_reference.py index 97a69ad10..7b0785ced 100644 --- a/tests/unit/sampletones_application/utils/palette/test_reference.py +++ b/tests/unit/sampletones_application/utils/palette/test_reference.py @@ -1,6 +1,9 @@ import pytest -from sampletones_application.utils.palette.reference import PaletteReference, is_reference +from sampletones_application.utils.palette.reference import ( + PaletteReference, + is_reference, +) class TestPaletteReference: diff --git a/tests/unit/sampletones_application/utils/palette/test_written.py b/tests/unit/sampletones_application/utils/palette/test_written.py index 03388d55b..4bd1981df 100644 --- a/tests/unit/sampletones_application/utils/palette/test_written.py +++ b/tests/unit/sampletones_application/utils/palette/test_written.py @@ -22,7 +22,12 @@ def _swatch(written: object, source: PaletteSource) -> _Swatch: 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) + 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) 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 7e8e6926d..7de6cd614 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 c9c022239..7a1f61aca 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/sequencer/test_channels.py b/tests/unit/sampletones_application/view_model/sequencer/test_channels.py index f2130392f..2cabb18c2 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_channels.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_channels.py @@ -3,7 +3,9 @@ import pytest -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 BaseRegularTestCase 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 ca2b0250b..da8d23389 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_order.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_order.py @@ -15,7 +15,9 @@ _EMPTY = display_id(None) -def _tracker(channels: Dict[GeneratorName, List[Optional[int]]]) -> SequencerOrderTrackerViewModel: +def _tracker( + channels: Dict[GeneratorName, List[Optional[int]]], +) -> SequencerOrderTrackerViewModel: views = { generator: SequencerOrderViewModel( generator=generator, diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py index 97b459de0..b951b4b98 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py @@ -8,7 +8,12 @@ SequencerRowViewModel, ) 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, +) from sampletones_shared.constants.symbols import MIXED _EMPTY_INSTRUMENT = display_id(None) @@ -42,7 +47,9 @@ class AggregateCase: _OCCUPIED = _cell(instrument=display_id(0), transpose=display_transpose(5), volume=display_volume(8)) -def _row_cells(**overrides: SequencerCellViewModel) -> Dict[GeneratorName, SequencerCellViewModel]: +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 @@ -95,7 +102,11 @@ def _row_cells(**overrides: SequencerCellViewModel) -> Dict[GeneratorName, Seque 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)), + 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), 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 03346f659..f412e6b9d 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 index 10dfd9fdd..1eb195f73 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_display_settings.py +++ b/tests/unit/sampletones_application/view_model/shared/test_display_settings.py @@ -170,7 +170,9 @@ def test_changing_one_entry_leaves_the_rest_standing(self) -> None: assert changed.palette == "dark" assert changed.window == settings().window - def test_changing_one_part_of_the_window_mode_leaves_the_rest_standing(self) -> None: + 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 @@ -188,7 +190,9 @@ 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: + 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) 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 52b16c53c..b90fc188b 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_menu.py +++ b/tests/unit/sampletones_application/view_model/shared/test_menu.py @@ -2,7 +2,9 @@ import pytest -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 EVERY_CHANNEL_AUDIBLE = SequencerChannelsViewModel(muted=frozenset()) diff --git a/tests/unit/sampletones_core/audio/test_processing.py b/tests/unit/sampletones_core/audio/test_processing.py index aa773d763..325ca5d09 100644 --- a/tests/unit/sampletones_core/audio/test_processing.py +++ b/tests/unit/sampletones_core/audio/test_processing.py @@ -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( diff --git a/tests/unit/sampletones_core/calibration/config/test_corpus.py b/tests/unit/sampletones_core/calibration/config/test_corpus.py index 53fa85e94..1a2814ea8 100644 --- a/tests/unit/sampletones_core/calibration/config/test_corpus.py +++ b/tests/unit/sampletones_core/calibration/config/test_corpus.py @@ -41,7 +41,11 @@ class InvalidFieldCase: 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="nonpositive_tone_frequency", + field="tone", + value={"frequencies": (440.0, 0.0)}, + ), InvalidFieldCase( name="empty_duty_cycles", field="timbre", diff --git a/tests/unit/sampletones_core/exporters/implementation/test_noise.py b/tests/unit/sampletones_core/exporters/implementation/test_noise.py index a2852ea54..eb1c426ec 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/features/test_spec.py b/tests/unit/sampletones_core/features/test_spec.py index a1fb20613..012d27341 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 4b75ca2e5..3887bfd37 100644 --- a/tests/unit/sampletones_core/fft/cqt/test_geometry.py +++ b/tests/unit/sampletones_core/fft/cqt/test_geometry.py @@ -96,7 +96,12 @@ def label(self) -> str: 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 a7bcafb4c..438ae02e3 100644 --- a/tests/unit/sampletones_core/fft/test_spectrum_scaling.py +++ b/tests/unit/sampletones_core/fft/test_spectrum_scaling.py @@ -149,7 +149,11 @@ 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) @@ -179,7 +183,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 +198,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 diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py index 21b5ba4d3..10485b189 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 1ca422721..7494578f5 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 28e4da5d2..1d409e745 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_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py index 6643d59b9..322f0e3ff 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 cfd42ecb9..7e6139e2b 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/famitracker/conftest.py b/tests/unit/sampletones_core/formats/famitracker/conftest.py index 1cb7e559d..aaf858000 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 46d6dabf1..20d8e26b3 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 bc1c8f218..b07743490 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 cc95bf023..07bde575b 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 dd59b6b02..ec16cfc93 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 1abf93605..65a5d31bf 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 8abfb3210..fbbc0d637 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 c7f5610d3..e30432696 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 956742586..418a86fe7 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 diff --git a/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py index 1ede65587..81e1b62db 100644 --- a/tests/unit/sampletones_core/project/test_container.py +++ b/tests/unit/sampletones_core/project/test_container.py @@ -258,7 +258,11 @@ 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) diff --git a/tests/unit/sampletones_core/project/test_serialization.py b/tests/unit/sampletones_core/project/test_serialization.py index 0f5e2a6e3..3883e2a88 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 9a2bccc87..af620de0a 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/reconstructions/criterion/test_criterion.py b/tests/unit/sampletones_core/reconstructions/criterion/test_criterion.py index b56ba5df8..5cf9168e1 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 df570d850..5dd0b4f92 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -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 8f0d90e5c..b6b968386 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 363b2a9c5..50ec2fd16 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 c9af2cfec..acb462853 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/tree/test_arguments.py b/tests/unit/sampletones_core/structures/tree/test_arguments.py index b0425e4e8..0673c41ad 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/timers/implementation/test_phase.py b/tests/unit/sampletones_core/timers/implementation/test_phase.py index d957102cc..b0512259c 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 9d3fca4ce..1bbd52a3f 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 9b889d10d..5aedf2c43 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_pitch_kind.py b/tests/unit/sampletones_core/utils/test_pitch_kind.py index 52963d67d..3b309b1b6 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 86dda1e3b..2145ce5b8 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 6a67af536..79bc0bb9c 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 5adbdf036..22ee5cc57 100644 --- a/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py +++ b/tests/unit/sampletones_shared/meta/source/bindings/test_scopes.py @@ -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 @@ -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 09576ead8..8aa1043fe 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 a5ab04a93..51722285c 100644 --- a/tests/unit/sampletones_shared/meta/source/test_annotations.py +++ b/tests/unit/sampletones_shared/meta/source/test_annotations.py @@ -25,17 +25,41 @@ class TestCase(BaseRegularTestCase): test_cases = [ TestCase(label="plain_name", annotation="LanguageManager", expected="LanguageManager"), - TestCase(label="optional", annotation="Optional[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="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="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="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), @@ -66,12 +90,28 @@ class TestCase(BaseRegularTestCase): annotation="Final[Dict[TrackerFormat, FileFilterElements]]", expected=("TrackerFormat", "FileFilterElements"), ), - TestCase(label="homogeneous_tuple", annotation="Tuple[MenuElements, ...]", expected=("MenuElements",)), + 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="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="unwrapped_scalar_holds_nothing", + annotation="Optional[MenuElements]", + expected=(), + ), ] @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) diff --git a/tests/unit/sampletones_shared/meta/source/test_constants.py b/tests/unit/sampletones_shared/meta/source/test_constants.py index eaee591cd..1c76fadd1 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 4961cbd6e..a2da5abb0 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 37278e9ac..1db807dbc 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_nodes.py b/tests/unit/sampletones_shared/meta/source/test_nodes.py index c01ea1521..1d68f1b39 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_subscripts.py b/tests/unit/sampletones_shared/meta/source/test_subscripts.py index 432ae68d3..5285a216a 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 d829f2227..733ad0e94 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/utils/system/test_filesystem.py b/tests/unit/sampletones_shared/utils/system/test_filesystem.py index 99dbeec55..d682af546 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/test_validation.py b/tests/unit/sampletones_shared/utils/test_validation.py index 41ab2483d..fa867de34 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_synthesis/oscillators/test_sweeps.py b/tests/unit/sampletones_synthesis/oscillators/test_sweeps.py index bd7028d04..49c1a351a 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 fcb55ccd0..1b70adaa3 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 68aecde92..92ee232e1 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 fc7a8a734..6323253a1 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_language_keys.py b/tests/unit/scripts/checks/test_language_keys.py index 0d1da8d49..ab9762b2c 100644 --- a/tests/unit/scripts/checks/test_language_keys.py +++ b/tests/unit/scripts/checks/test_language_keys.py @@ -67,7 +67,11 @@ 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", "SequencerTrackerElements", "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] == {} @@ -75,11 +79,21 @@ def test_the_element_base_states_no_members(self) -> None: 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: @@ -142,21 +156,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') @@ -165,7 +197,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" @@ -173,11 +208,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" diff --git a/tests/unit/scripts/ci/checks/test_bundle.py b/tests/unit/scripts/ci/checks/test_bundle.py index dceace1c6..28793dfae 100644 --- a/tests/unit/scripts/ci/checks/test_bundle.py +++ b/tests/unit/scripts/ci/checks/test_bundle.py @@ -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 34b31d8f0..77725fb10 100644 --- a/tests/unit/scripts/ci/checks/test_version_tag.py +++ b/tests/unit/scripts/ci/checks/test_version_tag.py @@ -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/test_detect_cuda.py b/tests/unit/scripts/test_detect_cuda.py index 00699ebe1..60012eb6d 100644 --- a/tests/unit/scripts/test_detect_cuda.py +++ b/tests/unit/scripts/test_detect_cuda.py @@ -39,17 +39,53 @@ class TestCase(BaseRegularTestCase): 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), + 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) + @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 @@ -61,37 +97,82 @@ class TestCase(BaseRegularTestCase): 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), + 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]: + @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" From aa53471156413cfaf92f0b4a08a4cc3316c63696 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 20:34:51 +0200 Subject: [PATCH 016/152] Reworked: unit tests --- tests/suite/case.py | 2 +- tests/suite/dummy.py | 4 +- .../categories/key/test_text.py | 38 +- .../config/managers/test_application.py | 12 +- .../config/managers/test_state.py | 12 +- .../coordinators/playback/test_router.py | 225 ++++++----- .../coordinators/tabs/test_sequencer.py | 4 +- .../coordinators/test_reconstruction.py | 8 +- .../logic/project/title/test_document.py | 10 +- .../logic/reconstruction/test_data.py | 3 +- .../reconstruction/test_reconstruction.py | 64 ++- .../sequencer/playback/test_song_player.py | 2 +- .../tags/test_compose.py | 64 ++- .../sampletones_application/test_viewport.py | 40 +- .../ui/elements/layout/test_responsive.py | 177 +++++++-- .../gui/keyboard/focus/test_consumption.py | 16 +- .../utils/gui/keyboard/focus/test_items.py | 62 ++- .../utils/gui/keyboard/focus/test_query.py | 28 +- .../utils/gui/keyboard/focus/test_search.py | 26 +- .../utils/gui/keyboard/test_modifiers.py | 33 +- .../reconstruction/test_reconstruction.py | 11 +- .../view_model/sequencer/test_channels.py | 117 ++++-- .../view_model/sequencer/test_order.py | 104 +++-- .../view_model/sequencer/test_tracker.py | 214 +++++----- .../view_model/shared/test_menu.py | 100 ++--- .../sampletones_core/audio/test_processing.py | 35 +- .../sampletones_core/audio/test_validation.py | 29 +- .../calibration/config/test_corpus.py | 130 +++--- .../calibration/config/test_referee.py | 64 ++- .../exporters/test_exporter.py | 8 +- .../sampletones_core/exporters/test_naming.py | 55 ++- .../sampletones_core/fft/cqt/test_geometry.py | 12 +- .../fft/test_spectrum_scaling.py | 199 ++++++---- .../sampletones_core/fft/test_transformer.py | 121 ++++-- tests/unit/sampletones_core/fft/test_utils.py | 8 +- .../formats/bitphase/test_identifiers.py | 41 +- .../formats/bitphase/test_notes.py | 62 ++- .../formats/bitphase/test_tuning.py | 118 ++++-- .../library/filename/test_fields.py | 16 +- .../sampletones_core/library/test_data.py | 8 +- .../sampletones_core/project/test_models.py | 4 +- .../project/test_song_position.py | 8 +- .../reconstruction/test_reconstruction.py | 8 +- .../structures/histogram/test_histogram.py | 246 ++++++------ .../structures/histogram/test_interval.py | 374 ++++++++++++++---- .../structures/tree/test_tree.py | 68 +++- .../utils/test_frequencies.py | 66 ++-- .../meta/source/test_annotations.py | 80 +++- .../utils/system/test_locales.py | 6 +- .../utils/system/test_paths.py | 26 +- .../sampletones_shared/utils/test_arrays.py | 42 +- .../utils/test_callbacks.py | 29 +- .../sampletones_shared/utils/test_color.py | 4 +- .../sampletones_shared/utils/test_common.py | 14 +- .../utils/transformations/test_functions.py | 39 +- .../utils/transformations/test_morpher.py | 17 +- .../transformations/test_transformation.py | 80 ++-- tests/unit/scripts/checks/test_tag_names.py | 10 +- 58 files changed, 2208 insertions(+), 1195 deletions(-) diff --git a/tests/suite/case.py b/tests/suite/case.py index 834e31bf6..af1b2d838 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 4f2d72a1c..776297772 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/unit/sampletones_application/categories/key/test_text.py b/tests/unit/sampletones_application/categories/key/test_text.py index 212039fe7..d4dd7618c 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/config/managers/test_application.py b/tests/unit/sampletones_application/config/managers/test_application.py index 3959705f7..e9a7ec246 100644 --- a/tests/unit/sampletones_application/config/managers/test_application.py +++ b/tests/unit/sampletones_application/config/managers/test_application.py @@ -98,9 +98,11 @@ def test_save_propagates_unexpected_error(self, tmp_path: Path) -> None: config_path, ): manager = ApplicationConfigManager() - with patch( - "sampletones_application.config.managers.application.save_yaml_atomic", - side_effect=RuntimeError("unexpected"), + with ( + patch( + "sampletones_application.config.managers.application.save_yaml_atomic", + side_effect=RuntimeError("unexpected"), + ), + pytest.raises(RuntimeError), ): - with pytest.raises(RuntimeError): - manager.save() + manager.save() diff --git a/tests/unit/sampletones_application/config/managers/test_state.py b/tests/unit/sampletones_application/config/managers/test_state.py index e450352a4..c02fe60b6 100644 --- a/tests/unit/sampletones_application/config/managers/test_state.py +++ b/tests/unit/sampletones_application/config/managers/test_state.py @@ -222,9 +222,11 @@ def test_save_propagates_unexpected_error(self, tmp_path: Path) -> None: state_path, ): manager = ApplicationStateManager() - with patch( - "sampletones_application.config.managers.state.save_yaml_atomic", - side_effect=RuntimeError("unexpected"), + with ( + patch( + "sampletones_application.config.managers.state.save_yaml_atomic", + side_effect=RuntimeError("unexpected"), + ), + pytest.raises(RuntimeError), ): - with pytest.raises(RuntimeError): - manager.save() + manager.save() diff --git a/tests/unit/sampletones_application/coordinators/playback/test_router.py b/tests/unit/sampletones_application/coordinators/playback/test_router.py index 87368ce89..df3b9d550 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,115 @@ 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 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_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 7c171e066..9d8af5d02 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 @@ -1191,7 +1191,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), diff --git a/tests/unit/sampletones_application/coordinators/test_reconstruction.py b/tests/unit/sampletones_application/coordinators/test_reconstruction.py index 4b86af48b..efe251588 100644 --- a/tests/unit/sampletones_application/coordinators/test_reconstruction.py +++ b/tests/unit/sampletones_application/coordinators/test_reconstruction.py @@ -5,9 +5,7 @@ import pytest -from sampletones_application.coordinators.reconstruction import ( - ReconstructionCoordinator, -) +from sampletones_application.coordinators.reconstruction import ReconstructionCoordinator from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.services.regeneration import RegeneratedInstrument from sampletones_application.services.result import ServiceSuccess @@ -177,7 +175,7 @@ class TestCase(BaseRegularTestCase): embedded: bool expects_prompt: bool - test_cases = [ + test_cases = ( TestCase( label="standalone_unsaved_prompts", unsaved=True, @@ -206,7 +204,7 @@ class TestCase(BaseRegularTestCase): expects_prompt=False, expected=False, ), - ] + ) @pytest.mark.parametrize( "test_case", 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 ca6fbd9f9..884808681 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/test_data.py b/tests/unit/sampletones_application/logic/reconstruction/test_data.py index 22f2e8b58..a8f734e8d 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 diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 860d94195..ad0671d7e 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -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] = "" @@ -124,36 +125,6 @@ def data_with_original_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, @@ -223,11 +194,34 @@ 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 @@ -237,7 +231,7 @@ def test_audio_path_state_follows_loaded_content(self, case: AudioPathCase) -> N original_audio, ) - assert view_model.state is case.expected_state + assert view_model.state is case.expected class TestReconstructionPanelLogicUpdate: 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 aad8f56af..c0d6a32dc 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,4 +1,4 @@ -from typing import List +from typing import List, Tuple from unittest.mock import MagicMock from sampletones_application.logic.sequencer.playback.song_player import SongPlayerLogic diff --git a/tests/unit/sampletones_application/tags/test_compose.py b/tests/unit/sampletones_application/tags/test_compose.py index 2a1a6fb69..75d3eda0f 100644 --- a/tests/unit/sampletones_application/tags/test_compose.py +++ b/tests/unit/sampletones_application/tags/test_compose.py @@ -19,13 +19,37 @@ 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"), + 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 ",), @@ -42,17 +66,33 @@ class TestCase(BaseRegularTestCase): 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="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_viewport.py b/tests/unit/sampletones_application/test_viewport.py index c6428dfc5..15da57058 100644 --- a/tests/unit/sampletones_application/test_viewport.py +++ b/tests/unit/sampletones_application/test_viewport.py @@ -56,10 +56,42 @@ class FitCase: class TestFitWindowToMonitor: 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( + "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), 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 156e60334..de1b57e00 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/utils/gui/keyboard/focus/test_consumption.py b/tests/unit/sampletones_application/utils/gui/keyboard/focus/test_consumption.py index e808eb3cb..6d5b1f04e 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 @@ -27,7 +27,7 @@ class TestCase(BaseRegularTestCase): modifiers: ModifierSet = NO_MODIFIERS expected: bool - test_cases = [ + test_cases = ( TestCase( label="no field lets every key through", kind=FieldKind.NONE, @@ -140,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 9a2206b4d..dfe4ba574 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,7 +33,7 @@ class TestCase(BaseRegularTestCase): item_type: str expected: FieldKind - test_cases = [ + test_cases = ( TestCase( label="text input types characters", item_type=INPUT_TEXT, @@ -49,17 +49,33 @@ class TestCase(BaseRegularTestCase): 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="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 @@ -81,18 +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), + 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), - ] + 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 68942df6c..56a9a81b2 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,7 +34,7 @@ class TestCase(BaseRegularTestCase): focused_item: int expected: FieldKind - test_cases = [ + test_cases = ( TestCase( label="nothing focused", items={}, @@ -83,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 670651623..4aecd3b3b 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,7 +37,7 @@ class TestCase(BaseRegularTestCase): items: Dict[int, FakeItem] expected: FieldKind - test_cases = [ + test_cases = ( TestCase( label="actively edited text input", items={FOCUSED: editing(INPUT_TEXT)}, @@ -108,17 +108,28 @@ class TestCase(BaseRegularTestCase): 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 @@ -131,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_modifiers.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py index 99e8fd70f..644edd472 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py @@ -39,7 +39,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), @@ -54,15 +54,26 @@ class TestCase(BaseRegularTestCase): held=[L_CONTROL, L_SHIFT, L_ALT], expected=CTRL_ALT_SHIFT, ), - ] - - @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 @@ -74,7 +85,7 @@ 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",)), @@ -86,9 +97,13 @@ class TestCase(BaseRegularTestCase): 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 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 379f91995..e44326b1a 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 2cabb18c2..b76b16938 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_channels.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_channels.py @@ -3,10 +3,9 @@ import pytest -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.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase ALL_CHANNELS = frozenset(GeneratorName.items()) @@ -17,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 @@ -60,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)} @@ -86,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_order.py b/tests/unit/sampletones_application/view_model/sequencer/test_order.py index da8d23389..678d2c8d7 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_order.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_order.py @@ -11,6 +11,8 @@ 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) @@ -22,13 +24,23 @@ def _tracker( 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 SequencerOrderTrackerViewModel(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]]]: @@ -42,41 +54,51 @@ def test_entry_label_renders_index_and_empty_slot() -> None: assert tracker.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: - tracker = _tracker(case.channels) - - assert tracker.master_label(0) == case.expected +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_tracker.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py index b951b4b98..462e7baec 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py @@ -15,6 +15,8 @@ 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) @@ -27,24 +29,22 @@ def _cell( transpose: str = _EMPTY_TRANSPOSE, volume: str = _EMPTY_VOLUME, ) -> SequencerCellViewModel: - return SequencerCellViewModel(instrument=instrument, transpose=transpose, volume=volume) + 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)) +_OCCUPIED = _cell( + instrument=display_id(0), + transpose=display_transpose(5), + volume=display_volume(8), +) def _row_cells( @@ -57,89 +57,115 @@ def _row_cells( 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), +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, ), - 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 + @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_menu.py b/tests/unit/sampletones_application/view_model/shared/test_menu.py index b90fc188b..3d971dd63 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_menu.py +++ b/tests/unit/sampletones_application/view_model/shared/test_menu.py @@ -2,63 +2,63 @@ import pytest -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 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, diff --git a/tests/unit/sampletones_core/audio/test_processing.py b/tests/unit/sampletones_core/audio/test_processing.py index 325ca5d09..e83b77995 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]), @@ -577,7 +577,7 @@ class TestCase(BaseRegularTestCase): num_buckets=5, expected=ValueError, ), - ] + ) @pytest.mark.parametrize( "test_case", @@ -605,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]), @@ -686,7 +686,7 @@ class TestCase(BaseRegularTestCase): audio=np.array([[1, 2], [3, 4]]), expected=ValueError, ), - ] + ) @pytest.mark.parametrize( "test_case", @@ -709,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]), @@ -818,7 +818,7 @@ class TestCase(BaseRegularTestCase): levels=3, expected=ValueError, ), - ] + ) @pytest.mark.parametrize( "test_case", @@ -826,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 14feb1875..0cc1e4392 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 1a2814ea8..b09e60af6 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,61 +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 a9a8d441f..21120e5ad 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/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py index 9b818b23d..950135da1 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 001e71516..a3ffb93ac 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/fft/cqt/test_geometry.py b/tests/unit/sampletones_core/fft/cqt/test_geometry.py index 3887bfd37..96ad8c86b 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,10 +87,10 @@ 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: diff --git a/tests/unit/sampletones_core/fft/test_spectrum_scaling.py b/tests/unit/sampletones_core/fft/test_spectrum_scaling.py index 438ae02e3..5a8a5e6cf 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 @@ -156,7 +177,11 @@ def test_weight_shares_match_across_spectrum_methods(self) -> None: ): 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()) @@ -168,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()) @@ -227,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: """ @@ -235,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 05a389c24..8958abe41 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 dbd72ad15..5b42e5c15 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_identifiers.py b/tests/unit/sampletones_core/formats/bitphase/test_identifiers.py index ec1522956..31accbc6d 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 2649fecea..0f69224fd 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_tuning.py b/tests/unit/sampletones_core/formats/bitphase/test_tuning.py index 7c187ec77..7763628aa 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/library/filename/test_fields.py b/tests/unit/sampletones_core/library/filename/test_fields.py index 418a86fe7..466cfe036 100644 --- a/tests/unit/sampletones_core/library/filename/test_fields.py +++ b/tests/unit/sampletones_core/library/filename/test_fields.py @@ -45,7 +45,7 @@ class TestCase(BaseRegularTestCase): fields: InstructionsFilenameFields expected: str - test_cases = [ + test_cases = ( TestCase( label="standard_values", fields=_fields(), @@ -76,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: @@ -89,7 +89,7 @@ class TestCase(BaseRegularTestCase): fields: InstructionsFilenameFields expected: str - test_cases = [ + test_cases = ( TestCase( label="appends_extension", fields=_fields(), @@ -100,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: @@ -114,7 +114,7 @@ class TestCase(BaseRegularTestCase): expected: Union[InstructionsFilenameFields, Type[Exception]] match: str = "" - test_cases = [ + test_cases = ( TestCase( label="valid_stem", pathlike=_stem(), @@ -167,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: @@ -193,7 +193,7 @@ class TestCase(BaseRegularTestCase): fields: InstructionsFilenameFields expected: str - test_cases = [ + test_cases = ( TestCase( label="standard", fields=_fields(), @@ -209,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 aa518334a..4f3f70d69 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_models.py b/tests/unit/sampletones_core/project/test_models.py index 2d595842b..b48949bc4 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_song_position.py b/tests/unit/sampletones_core/project/test_song_position.py index d0343ddc7..cc8da2164 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/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 5dd0b4f92..ef79ec57e 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", diff --git a/tests/unit/sampletones_core/structures/histogram/test_histogram.py b/tests/unit/sampletones_core/structures/histogram/test_histogram.py index c8c00b8f3..89be89463 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 4188cc060..6949def3a 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_tree.py b/tests/unit/sampletones_core/structures/tree/test_tree.py index c0544112d..0fd62e7df 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/utils/test_frequencies.py b/tests/unit/sampletones_core/utils/test_frequencies.py index 2369660d0..8f41a2cda 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_shared/meta/source/test_annotations.py b/tests/unit/sampletones_shared/meta/source/test_annotations.py index 51722285c..e08917df6 100644 --- a/tests/unit/sampletones_shared/meta/source/test_annotations.py +++ b/tests/unit/sampletones_shared/meta/source/test_annotations.py @@ -23,16 +23,28 @@ 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="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]", @@ -43,7 +55,11 @@ class TestCase(BaseRegularTestCase): annotation="categories.LanguageManager", expected="LanguageManager", ), - TestCase(label="generic_states_itself", annotation="Dict[str, int]", expected="Dict"), + TestCase( + label="generic_states_itself", + annotation="Dict[str, int]", + expected="Dict", + ), TestCase( label="wrapped_generic", annotation="Final[Dict[Page, Panel]]", @@ -54,18 +70,38 @@ class TestCase(BaseRegularTestCase): annotation="Final[Optional[LanguageManager]]", expected="LanguageManager", ), - TestCase(label="quoted", annotation="'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), - ] + 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) + @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 @@ -79,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]", @@ -95,7 +131,11 @@ class TestCase(BaseRegularTestCase): annotation="Tuple[MenuElements, ...]", expected=("MenuElements",), ), - TestCase(label="list", annotation="List[MenuElements]", expected=("MenuElements",)), + TestCase( + label="list", + annotation="List[MenuElements]", + expected=("MenuElements",), + ), TestCase( label="optional_item", annotation="List[Optional[MenuElements]]", @@ -106,15 +146,23 @@ class TestCase(BaseRegularTestCase): annotation="Dict[str, Dict[str, MenuElements]]", expected=("str", "Dict"), ), - TestCase(label="plain_name_holds_nothing", annotation="str", expected=()), + 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/utils/system/test_locales.py b/tests/unit/sampletones_shared/utils/system/test_locales.py index c8801cd1e..e10cfe69c 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 2ae5874db..23b162f8c 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 4183aff71..70ff1d029 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 da21bbdf9..a00ef0e59 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 f47739542..95f11d611 100644 --- a/tests/unit/sampletones_shared/utils/test_color.py +++ b/tests/unit/sampletones_shared/utils/test_color.py @@ -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", diff --git a/tests/unit/sampletones_shared/utils/test_common.py b/tests/unit/sampletones_shared/utils/test_common.py index e9d1c89c5..0b1bb1037 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/transformations/test_functions.py b/tests/unit/sampletones_shared/utils/transformations/test_functions.py index 2046d498c..19e2891c1 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 aa5144b09..a371f444c 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 5ba66c939..d46af2f37 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/scripts/checks/test_tag_names.py b/tests/unit/scripts/checks/test_tag_names.py index 83575b0fc..a79a294fa 100644 --- a/tests/unit/scripts/checks/test_tag_names.py +++ b/tests/unit/scripts/checks/test_tag_names.py @@ -45,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}", @@ -130,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) From 814a239cf269346d0f2b18601afa0a5ef89195d7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 20:39:58 +0200 Subject: [PATCH 017/152] Added: remaining tests --- .../utils/gui/keyboard/test_combination.py | 178 ++++++++++++++++++ .../utils/gui/keyboard/test_keys.py | 129 +++++++++++++ .../utils/gui/shortcuts/test_shortcut.py | 127 +++++++++++++ 3 files changed, 434 insertions(+) create mode 100644 tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py create mode 100644 tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py create mode 100644 tests/unit/sampletones_application/utils/gui/shortcuts/test_shortcut.py 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 000000000..318f1c22b --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py @@ -0,0 +1,178 @@ +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_PAGE_DOWN +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, + f"Ctrl{PLUS}{PLUS}", + f"Num{PLUS}", + "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="every modifier", + 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 separator as the key", key=dpg.mvKey_Plus, modifiers=CTRL, expected="Ctrl++"), + 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 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(dpg.mvKey_Plus)), + TestCase( + label="the separator as the key", + text="Ctrl++", + expected=KeyCombination(dpg.mvKey_Plus, CTRL), + ), + TestCase(label="a keypad key", text=f"Num{PLUS}", 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", ["Meta+D", "Ctrl+Meta", "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 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 000000000..b685801c7 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py @@ -0,0 +1,129 @@ +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_PAGE_DOWN, + KEY_PAGE_UP, + LETTER_COUNT, + SIGN_KEYS, + UNKNOWN_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 + + +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="F12", + ), + 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=dpg.mvKey_Plus, expected=PLUS), + TestCase(label="keypad plus", key=dpg.mvKey_Add, expected=f"Num{PLUS}"), + TestCase(label="keypad minus", key=dpg.mvKey_Subtract, expected=f"Num{MINUS}"), + ) + + @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 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=dpg.mvKey_Plus), + TestCase(label="keypad plus", name=f"Num{PLUS}", expected=dpg.mvKey_Add), + ) + + @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("Meta") + + +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(KEY_CODES) == len(KEY_DISPLAY_NAMES) + + def test_the_page_keys_sit_among_the_keys_they_are_named_beside(self) -> None: + """DearPyGui's page constants carry stale codes, so the live ones are named directly.""" + assert KEY_PAGE_DOWN == KEY_PAGE_UP + 1 + assert dpg.mvKey_Home == KEY_PAGE_DOWN + 1 + + def test_the_function_keys_are_the_keys_the_function_names_carry(self) -> None: + assert FUNCTION_KEYS == frozenset(FUNCTION_KEY_NAMES) + + +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[dpg.mvKey_Plus] == PLUS + assert SIGN_KEYS[dpg.mvKey_Subtract] == SIGN_KEYS[dpg.mvKey_Minus] == MINUS 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 000000000..4612daf6e --- /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 From 4fb635f6ed763937b2a4ed81fa74094d1399a1b0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 21:30:37 +0200 Subject: [PATCH 018/152] Added: keybinding scheme loaded from configuration --- src/sampletones/self_check.py | 11 + src/sampletones_application/application.py | 11 +- src/sampletones_application/paths.py | 1 + src/sampletones_application/shell.py | 321 +++++------------- .../utils/gui/shortcuts/catalog.py | 80 +++++ .../utils/gui/shortcuts/ids.py | 172 +++++++--- .../utils/gui/shortcuts/manager.py | 43 ++- .../utils/gui/shortcuts/scheme.py | 85 +++++ .../utils/gui/shortcuts/source.py | 45 +++ .../utils/gui/shortcuts/written.py | 34 ++ src/sampletones_config/README.md | 1 + .../keybindings/default.yaml | 107 ++++++ .../sampletones_application/test_shell.py | 34 ++ .../utils/gui/shortcuts/conftest.py | 38 +++ .../utils/gui/shortcuts/test_catalog.py | 70 ++++ .../utils/gui/shortcuts/test_manager.py | 174 +++++----- .../utils/gui/shortcuts/test_scheme.py | 134 ++++++++ .../utils/gui/shortcuts/test_source.py | 54 +++ .../utils/gui/shortcuts/test_written.py | 36 ++ 19 files changed, 1052 insertions(+), 399 deletions(-) create mode 100644 src/sampletones_application/utils/gui/shortcuts/catalog.py create mode 100644 src/sampletones_application/utils/gui/shortcuts/scheme.py create mode 100644 src/sampletones_application/utils/gui/shortcuts/source.py create mode 100644 src/sampletones_application/utils/gui/shortcuts/written.py create mode 100644 src/sampletones_config/keybindings/default.yaml create mode 100644 tests/unit/sampletones_application/test_shell.py create mode 100644 tests/unit/sampletones_application/utils/gui/shortcuts/conftest.py create mode 100644 tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py create mode 100644 tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py create mode 100644 tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py create mode 100644 tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py diff --git a/src/sampletones/self_check.py b/src/sampletones/self_check.py index 922765a92..ff5bb192d 100644 --- a/src/sampletones/self_check.py +++ b/src/sampletones/self_check.py @@ -14,6 +14,7 @@ KeyError, TypeError, ValueError, + SystemError, SampleToNESError, ) @@ -75,6 +76,15 @@ def _palette_sources() -> "List[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 @@ -126,6 +136,7 @@ def _check_file_dialog_backend() -> str: SelfCheck(name="application import", run=_check_application_import), SelfCheck(name="deployment config", run=_check_deployment_config), 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 c9282448f..b9ef8c5e2 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -52,6 +52,7 @@ from sampletones_application.paths import ( BEHAVIOR_DIRECTORY, DEPLOYMENT_CONFIG_PATH, + KEYBINDINGS_DIRECTORY, LANG_EN, LAYOUT_DIRECTORY, PALETTES_DIRECTORY, @@ -109,7 +110,9 @@ 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.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 @@ -186,7 +189,13 @@ 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_source: ShortcutSource = ShortcutSource( + ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).default, + ) + 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, diff --git a/src/sampletones_application/paths.py b/src/sampletones_application/paths.py index 9bc55808a..4ecbdcfca 100644 --- a/src/sampletones_application/paths.py +++ b/src/sampletones_application/paths.py @@ -6,6 +6,7 @@ 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" PALETTES_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "palettes" LANG_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "lang" diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 2597b626c..4a9aad07a 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -1,7 +1,7 @@ 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 @@ -34,19 +34,7 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.fps import FPSTimer -from sampletones_application.utils.gui.keyboard import KeyCombination, KeyRouter -from sampletones_application.utils.gui.keyboard.keys import ( - KEY_PAGE_DOWN, - KEY_PAGE_UP, -) -from sampletones_application.utils.gui.keyboard.modifiers import ( - ALT, - CTRL, - CTRL_ALT, - CTRL_ALT_SHIFT, - CTRL_SHIFT, - SHIFT, -) +from sampletones_application.utils.gui.keyboard import KeyRouter from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, PROJECT_EXPORT_SHORTCUT_IDS, @@ -54,7 +42,6 @@ ShortcutId, ) 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 +57,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(combination=KeyCombination(dpg.mvKey_M, CTRL)), - TrackerFormat.BITPHASE: Shortcut(combination=KeyCombination(dpg.mvKey_B, CTRL)), -} -_SAMPLE_EXPORT_SHORTCUTS: Final[Dict[TrackerFormat, Shortcut]] = { - TrackerFormat.FAMITRACKER: Shortcut(combination=KeyCombination(dpg.mvKey_I, CTRL)), - TrackerFormat.BITPHASE_PRESET: Shortcut(combination=None), -} @dataclass(frozen=True) @@ -213,235 +192,91 @@ def _set_default_theme(self) -> None: self._theme.bind() def _register_shortcuts(self, bindings: ShortcutBindings) -> None: - self._shortcut_manager.register( - ShortcutId.NEW_PROJECT, - Shortcut(combination=KeyCombination(dpg.mvKey_N, CTRL)), - bindings.new_project, - ) - self._shortcut_manager.register( - ShortcutId.OPEN_PROJECT, - Shortcut(combination=KeyCombination(dpg.mvKey_O, CTRL)), - bindings.open_project, - ) - self._shortcut_manager.register( - ShortcutId.SAVE_PROJECT, - Shortcut(combination=KeyCombination(dpg.mvKey_S, CTRL)), - bindings.save_project, - ) - self._shortcut_manager.register( - ShortcutId.SAVE_PROJECT_AS, - Shortcut(combination=KeyCombination(dpg.mvKey_S, CTRL_SHIFT)), - bindings.save_project_as, - ) - self._register_export_shortcuts(bindings) - self._shortcut_manager.register( - ShortcutId.PROJECT_PROPERTIES, - Shortcut(combination=KeyCombination(dpg.mvKey_P, ALT)), - bindings.project_properties, - ) - self._shortcut_manager.register( - ShortcutId.CLOSE_PROJECT, - Shortcut(combination=KeyCombination(dpg.mvKey_W, CTRL)), - bindings.close_project, - ) - self._shortcut_manager.register( - ShortcutId.SAVE_RECONSTRUCTION, - Shortcut(combination=KeyCombination(dpg.mvKey_S, CTRL_ALT)), - bindings.save_reconstruction, - ) - self._shortcut_manager.register( - ShortcutId.SAVE_RECONSTRUCTION_AS, - Shortcut(combination=KeyCombination(dpg.mvKey_S, CTRL_ALT_SHIFT)), - bindings.save_reconstruction_as, - ) - self._shortcut_manager.register( - ShortcutId.OPEN_RECONSTRUCTION, - Shortcut(combination=KeyCombination(dpg.mvKey_O, CTRL_ALT)), - bindings.open_reconstruction, - ) - self._shortcut_manager.register( - ShortcutId.CLOSE_RECONSTRUCTION, - Shortcut(combination=KeyCombination(dpg.mvKey_W, CTRL_ALT)), - bindings.close_reconstruction, - ) - self._shortcut_manager.register( - ShortcutId.SAVE_GENERATION_SETTINGS, - Shortcut(combination=None), - bindings.save_generation_settings, - ) - self._shortcut_manager.register( - ShortcutId.LOAD_GENERATION_SETTINGS, - Shortcut(combination=None), - bindings.load_generation_settings, - ) - self._shortcut_manager.register( - ShortcutId.AUDIO_SETTINGS, - Shortcut(combination=KeyCombination(dpg.mvKey_A, CTRL)), - bindings.audio_settings, - ) - self._shortcut_manager.register( - ShortcutId.EXIT, - Shortcut(combination=KeyCombination(dpg.mvKey_F4, ALT)), - bindings.exit, - ) - self._shortcut_manager.register( - ShortcutId.RECONSTRUCT_FILE, - Shortcut(combination=KeyCombination(dpg.mvKey_R, CTRL)), - bindings.reconstruct_file, - ) - self._shortcut_manager.register( - ShortcutId.RECONSTRUCT_DIRECTORY, - Shortcut(combination=KeyCombination(dpg.mvKey_R, CTRL_SHIFT)), - bindings.reconstruct_directory, - ) - self._shortcut_manager.register( - ShortcutId.EXPORT_RECONSTRUCTION_WAV, - Shortcut(combination=KeyCombination(dpg.mvKey_E, CTRL)), - bindings.export_wav, - ) - self._shortcut_manager.register( - ShortcutId.ADD_RECONSTRUCTION_TO_SEQUENCER, - Shortcut(combination=None), - bindings.add_reconstruction_to_sequencer, - ) - self._shortcut_manager.register( - ShortcutId.OPEN_RECONSTRUCTION_IN_EXPLORER, - Shortcut(combination=None), - bindings.open_reconstruction_in_explorer, - ) - self._shortcut_manager.register( - ShortcutId.LOCATE_ORIGINAL_AUDIO, - Shortcut(combination=None), - bindings.locate_original_audio, - ) - self._shortcut_manager.register( - ShortcutId.TOGGLE_FULLSCREEN, - Shortcut(combination=KeyCombination(dpg.mvKey_F11)), - bindings.toggle_fullscreen, - ) - self._shortcut_manager.register( - ShortcutId.DISPLAY_SETTINGS, - Shortcut(combination=None), - bindings.display_settings, - ) - self._shortcut_manager.register( - ShortcutId.TOGGLE_ADVANCED_SETTINGS, - Shortcut(combination=KeyCombination(dpg.mvKey_A, CTRL_SHIFT)), - bindings.toggle_advanced_settings, - ) - self._shortcut_manager.register( - ShortcutId.PLAY, - Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar)), - bindings.play, - ) - self._shortcut_manager.register( - ShortcutId.PLAY_FROM_START, - Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar, SHIFT)), - bindings.play_from_start, - ) - self._shortcut_manager.register( - ShortcutId.PLAY_FROM_FRAME, - Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar, CTRL)), - bindings.play_from_frame, - ) - self._shortcut_manager.register( - ShortcutId.STOP, - Shortcut(combination=KeyCombination(dpg.mvKey_Escape)), - bindings.stop, - ) - self._shortcut_manager.register( - ShortcutId.TOGGLE_AUTOPLAY, - Shortcut(combination=KeyCombination(dpg.mvKey_P, CTRL)), - bindings.toggle_autoplay, - ) - self._shortcut_manager.register( - ShortcutId.TOGGLE_FOLLOW_PLAYBACK, - Shortcut(combination=None), - bindings.toggle_follow_playback, - ) - self._shortcut_manager.register( - ShortcutId.TOGGLE_LOOP_SONG, - Shortcut(combination=None), - bindings.toggle_loop_song, - ) - self._register_channel_shortcuts(bindings) - self._shortcut_manager.register( - ShortcutId.UNDO, - Shortcut(combination=KeyCombination(dpg.mvKey_Z, CTRL)), - bindings.undo, - ) - self._shortcut_manager.register( - ShortcutId.REDO, - Shortcut( - combination=KeyCombination(dpg.mvKey_Y, CTRL), - aliases=(KeyCombination(dpg.mvKey_Z, CTRL_SHIFT),), - ), - bindings.redo, - ) - self._shortcut_manager.register( - ShortcutId.ABOUT_DIALOG, - Shortcut(combination=None), - bindings.about, - ) - self._shortcut_manager.register( - ShortcutId.NEXT_TAB, - Shortcut( - combination=KeyCombination(KEY_PAGE_DOWN, CTRL), - field_transparent=True, - ), - bindings.next_tab, - ) - self._shortcut_manager.register( - ShortcutId.PREVIOUS_TAB, - Shortcut( - combination=KeyCombination(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_FOLLOW_PLAYBACK: bindings.toggle_follow_playback, + ShortcutId.TOGGLE_LOOP_SONG: bindings.toggle_loop_song, + ShortcutId.AUDIO_SETTINGS: bindings.audio_settings, + ShortcutId.DISPLAY_SETTINGS: bindings.display_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._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 _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(combination=None), - partial(bindings.toggle_channel, generator), - ) - - self._shortcut_manager.register( - ShortcutId.UNMUTE_ALL_CHANNELS, - Shortcut(combination=None), - 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() 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 000000000..430a47771 --- /dev/null +++ b/src/sampletones_application/utils/gui/shortcuts/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.gui.shortcuts.scheme import ShortcutScheme +from sampletones_core.paths import EXT_FILE_YAML +from sampletones_shared.logger import logger + +DEFAULT_SCHEME_NAME: Final[str] = "default" + + +@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/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 60b16f60c..749032afe 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -1,55 +1,135 @@ -from enum import Enum -from typing import Dict, Final +from enum import Enum, StrEnum +from typing import Dict, Final, Self 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" - DISPLAY_SETTINGS = "DisplaySettings" - 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) + TOGGLE_FOLLOW_PLAYBACK = ("ToggleFollowPlayback", 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) + 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) CHANNEL_SHORTCUT_IDS: Final[Dict[GeneratorName, ShortcutId]] = { diff --git a/src/sampletones_application/utils/gui/shortcuts/manager.py b/src/sampletones_application/utils/gui/shortcuts/manager.py index 4a2d881be..5b7ceed64 100644 --- a/src/sampletones_application/utils/gui/shortcuts/manager.py +++ b/src/sampletones_application/utils/gui/shortcuts/manager.py @@ -10,28 +10,38 @@ ) 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.callback import Callback class ShortcutManager: - def __init__(self, *, key_router: KeyRouter) -> None: - self._router = key_router - self._shortcuts: Dict[ShortcutId, Tuple[Shortcut, Callback]] = {} - 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]]] = {} + + 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 add_menu_item(self, shortcut_id: ShortcutId, **kwargs: Any) -> None: - shortcut, callback = self._shortcuts[shortcut_id] + callback = self._callbacks[shortcut_id] dpg.add_menu_item( callback=lambda s, a, u: callback(), - shortcut=shortcut.display(), + shortcut=self._source.display(shortcut_id), **kwargs, ) @@ -41,16 +51,19 @@ def bind_all(self) -> None: 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. """ - self._bindings_by_key = {} - for shortcut, callback in self._shortcuts.values(): - self._add_binding(shortcut, callback) - + self._index_bindings() self._router.register( self._dispatch, priority=PRIORITY_SHORTCUT, active=lambda: True, ) + 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()}): 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 000000000..cc7c1648c --- /dev/null +++ b/src/sampletones_application/utils/gui/shortcuts/scheme.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from functools import cached_property +from pathlib import Path +from typing import Dict, List, Self, Tuple + +from pydantic import BaseModel, model_validator + +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId +from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut +from sampletones_application.utils.gui.shortcuts.written import WrittenShortcut +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()} + + @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] + + @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 _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: + claimed: Dict[Tuple[ShortcutCategory, KeyCombination], ShortcutId] = {} + for shortcut_id, shortcut in self.shortcuts.items(): + for combination in shortcut.combinations(): + claim = (shortcut_id.category, combination) + if claim in claimed: + raise SystemError( + f"Keybinding scheme {self.name!r} gives {combination.display()} to both " + f"{claimed[claim].value!r} and {shortcut_id.value!r}, " + f"which share the {shortcut_id.category} category" + ) + + claimed[claim] = shortcut_id 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 000000000..75d119e7c --- /dev/null +++ b/src/sampletones_application/utils/gui/shortcuts/source.py @@ -0,0 +1,45 @@ +from typing import Optional + +from sampletones_application.utils.gui.shortcuts.ids import 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 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 000000000..4c57d4264 --- /dev/null +++ b/src/sampletones_application/utils/gui/shortcuts/written.py @@ -0,0 +1,34 @@ +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 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_config/README.md b/src/sampletones_config/README.md index 8efc06916..e54fd9655 100644 --- a/src/sampletones_config/README.md +++ b/src/sampletones_config/README.md @@ -20,6 +20,7 @@ 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 | `LayoutConfig` | | `palettes/` | The colour sets layout and theme resolve against | `Palette` | diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml new file mode 100644 index 000000000..1dc50dbe5 --- /dev/null +++ b/src/sampletones_config/keybindings/default.yaml @@ -0,0 +1,107 @@ +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"} + ToggleFollowPlayback: {combination: ~} + ToggleLoopSong: {combination: ~} + ToggleChannelPulse1: {combination: ~} + ToggleChannelPulse2: {combination: ~} + ToggleChannelTriangle: {combination: ~} + ToggleChannelNoise: {combination: ~} + UnmuteAllChannels: {combination: ~} + + # view + AudioSettings: {combination: "Ctrl+A"} + DisplaySettings: {combination: ~} + 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: "+", aliases: ["Num+"]} + OrderRemoveFrame: {combination: "-", aliases: ["Num-"]} + OrderDuplicateFrame: {combination: "Ctrl+D"} + 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/tests/unit/sampletones_application/test_shell.py b/tests/unit/sampletones_application/test_shell.py new file mode 100644 index 000000000..55741bf66 --- /dev/null +++ b/tests/unit/sampletones_application/test_shell.py @@ -0,0 +1,34 @@ +from dataclasses import fields +from unittest.mock import Mock + +from sampletones_application.shell import ApplicationShell, ShortcutBindings +from sampletones_application.utils.gui.shortcuts.ids import 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() 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 000000000..b8f50a7a0 --- /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 000000000..9bee72739 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py @@ -0,0 +1,70 @@ +from pathlib import Path + +import pytest + +from sampletones_application.paths import KEYBINDINGS_DIRECTORY +from sampletones_application.utils.gui.shortcuts.catalog import ( + DEFAULT_SCHEME_NAME, + 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}" +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: + def test_the_build_ships_a_default_scheme(self) -> None: + """Loading is what proves every action is answered and no category holds a clash.""" + assert DEFAULT_SCHEME_NAME in ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).names + + def test_the_shipped_scheme_answers_every_action(self) -> None: + catalog = ShortcutCatalog.load(KEYBINDINGS_DIRECTORY) + + assert set(catalog.default.bindings) == set(ShortcutId) 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 ff37e8508..a93abb9c6 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py @@ -4,24 +4,20 @@ import dearpygui.dearpygui as dpg import pytest -from sampletones_application.utils.gui.keyboard import ( - KeyCombination, - KeyEvent, - KeyRouter, -) +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_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 @pytest.fixture(autouse=True) @@ -31,72 +27,60 @@ 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 _event(key: int = KEY, *, modifiers: ModifierSet = NO_MODIFIERS) -> KeyEvent: +def _event(key: int, *, modifiers: ModifierSet = NO_MODIFIERS) -> KeyEvent: return KeyEvent(key=key, modifiers=modifiers) 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(combination=KeyCombination(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(combination=KeyCombination(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( - combination=KeyCombination(KEY, CTRL), - aliases=(KeyCombination(KEY, CTRL_SHIFT),), - ), - callback, - ) - manager.bind_all() - - assert manager._dispatch(_event(modifiers=CTRL_SHIFT)) + manager = _manager(source, ShortcutId.REDO, callback) + + 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() + def test_text_field_keeps_a_plain_space( + self, + source: ShortcutSource, + field_kind: Dict[str, FieldKind], + ) -> None: callback = Mock() - manager.register( - ShortcutId.PLAY, - Shortcut(combination=KeyCombination(dpg.mvKey_Spacebar)), - callback, - ) - manager.bind_all() + manager = _manager(source, ShortcutId.PLAY, callback) field_kind["kind"] = FieldKind.TEXT_ENTRY claimed = manager._dispatch(_event(dpg.mvKey_Spacebar)) @@ -104,15 +88,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(combination=KeyCombination(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)) @@ -120,15 +102,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(combination=KeyCombination(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)) @@ -136,16 +116,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(combination=KeyCombination(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)) @@ -153,15 +131,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(combination=KeyCombination(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)) @@ -169,21 +145,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( - combination=KeyCombination(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 000000000..ebac9217e --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -0,0 +1,134 @@ +from pathlib import Path + +import dearpygui.dearpygui as dpg +import pytest +from pydantic import ValidationError + +from sampletones_application.paths import KEYBINDINGS_DIRECTORY +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.catalog import DEFAULT_SCHEME_NAME +from sampletones_application.utils.gui.shortcuts.ids import 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}" + +_PARTIAL_SCHEME_FILE = """ +name: minimal +bindings: + Play: {combination: "Space"} +""" + + +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 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_source.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py new file mode 100644 index 000000000..d7c056b54 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py @@ -0,0 +1,54 @@ +from typing import List + +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 +from tests.unit.sampletones_application.utils.gui.shortcuts.conftest import RebindScheme + + +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" + + +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_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 000000000..18f17c160 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py @@ -0,0 +1,36 @@ +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() From ca817ed0178079a06df0829698dee7179c8e5bee Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 8 Aug 2026 21:58:04 +0200 Subject: [PATCH 019/152] Refactored: sequencer panels onto the keybinding registry --- src/sampletones_application/application.py | 6 + .../coordinators/tabs/sequencer.py | 5 + .../ui/panels/dialogs/audio_settings.py | 4 + .../ui/panels/dialogs/countdown.py | 4 + .../ui/panels/dialogs/display_settings.py | 4 + .../ui/panels/dialogs/project_properties.py | 4 + .../ui/panels/sequencer/order.py | 200 +++++++++--------- .../ui/panels/sequencer/samples.py | 80 +++---- .../ui/panels/sequencer/tracker.py | 108 ++++++---- .../utils/gui/dialog_navigation/navigator.py | 19 +- .../utils/gui/dialogs.py | 16 ++ .../utils/gui/shortcuts/scheme.py | 46 +++- .../utils/gui/shortcuts/source.py | 11 +- .../keybindings/default.yaml | 4 +- tests/suite/shortcuts.py | 21 ++ .../ui/panels/dialogs/test_countdown.py | 2 + .../panels/dialogs/test_display_settings.py | 2 + .../ui/panels/sequencer/test_order_keys.py | 141 ++++++++++++ .../ui/panels/sequencer/test_panel_escape.py | 11 +- .../ui/panels/sequencer/test_samples_keys.py | 105 +++++++++ .../sequencer/test_tracker_navigation.py | 54 ++++- .../sequencer/test_tracker_play_shortcut.py | 2 + .../gui/dialog_navigation/test_navigator.py | 49 +++-- .../utils/gui/shortcuts/test_scheme.py | 33 ++- .../utils/gui/shortcuts/test_shipped.py | 39 ++++ .../utils/gui/shortcuts/test_source.py | 24 ++- 26 files changed, 771 insertions(+), 223 deletions(-) create mode 100644 tests/suite/shortcuts.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_samples_keys.py create mode 100644 tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index b9ef8c5e2..7026cbd2f 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -201,6 +201,7 @@ def __init__( 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) @@ -245,6 +246,7 @@ def __init__( 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 @@ -253,6 +255,7 @@ def __init__( 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, @@ -262,11 +265,13 @@ def __init__( 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) @@ -404,6 +409,7 @@ 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, diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index d808f046c..fb5bb6110 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -75,6 +75,7 @@ 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.shortcuts.source import ShortcutSource from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) @@ -114,6 +115,7 @@ def __init__( session_manager: SessionManager, audio_device_manager: AudioDeviceManager, key_router: KeyRouter, + shortcut_source: ShortcutSource, browser_manager: BrowserManager, project_controller: ProjectController, history: HistoryManager, @@ -206,6 +208,7 @@ def __init__( initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_TRACKER_PANEL), language_manager=language_manager, key_router=key_router, + shortcut_source=shortcut_source, ) self._sequencer_module_panel: GUISequencerModulePanel = GUISequencerModulePanel( self._sequencer_tracker_logic.settings, @@ -221,12 +224,14 @@ def __init__( initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_ORDER_WINDOW_ORDER_CARD), language_manager=language_manager, key_router=key_router, + 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, + shortcut_source=shortcut_source, ) self._sequencer_history_panel: GUISequencerHistoryPanel = GUISequencerHistoryPanel( layout=layout.sequencer, diff --git a/src/sampletones_application/ui/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py index 4878ddc1e..a2fa73f9d 100644 --- a/src/sampletones_application/ui/panels/dialogs/audio_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/audio_settings.py @@ -26,6 +26,7 @@ ) 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, @@ -50,10 +51,12 @@ def __init__( 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._shortcuts = shortcut_source self._dialog_theme = ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG) self._navigator: Optional[DialogKeyboardNavigator] = None @@ -137,6 +140,7 @@ def _install_navigation(self) -> None: ], on_escape=self.hide, key_router=self._router, + shortcut_source=self._shortcuts, ) self._navigator.install() diff --git a/src/sampletones_application/ui/panels/dialogs/countdown.py b/src/sampletones_application/ui/panels/dialogs/countdown.py index 0542bf22e..726b1288c 100644 --- a/src/sampletones_application/ui/panels/dialogs/countdown.py +++ b/src/sampletones_application/ui/panels/dialogs/countdown.py @@ -18,6 +18,7 @@ ) 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 @@ -43,6 +44,7 @@ def __init__( keep_label: str, revert_label: str, key_router: KeyRouter, + shortcut_source: ShortcutSource, ) -> None: self._title = title self._message = message @@ -50,6 +52,7 @@ def __init__( self._keep_label = keep_label self._revert_label = revert_label self._router = key_router + self._shortcuts = shortcut_source self._navigator: Optional[DialogKeyboardNavigator] = None self._remaining = 0 @@ -118,6 +121,7 @@ def _install_navigation(self) -> None: ], on_escape=self._revert, key_router=self._router, + shortcut_source=self._shortcuts, initial_index=1, ) self._navigator.install() diff --git a/src/sampletones_application/ui/panels/dialogs/display_settings.py b/src/sampletones_application/ui/panels/dialogs/display_settings.py index a1c826b1d..169858f4a 100644 --- a/src/sampletones_application/ui/panels/dialogs/display_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/display_settings.py @@ -27,6 +27,7 @@ ) 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, @@ -53,10 +54,12 @@ def __init__( 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._shortcuts = shortcut_source self._dialog_theme = ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG) self._navigator: Optional[DialogKeyboardNavigator] = None self._view_model: Optional[DisplaySettingsViewModel] = None @@ -208,6 +211,7 @@ def _install_navigation(self) -> None: ], on_escape=self._request_cancel, key_router=self._router, + shortcut_source=self._shortcuts, ) self._navigator.install() diff --git a/src/sampletones_application/ui/panels/dialogs/project_properties.py b/src/sampletones_application/ui/panels/dialogs/project_properties.py index ad78f0832..b384aaf34 100644 --- a/src/sampletones_application/ui/panels/dialogs/project_properties.py +++ b/src/sampletones_application/ui/panels/dialogs/project_properties.py @@ -29,6 +29,7 @@ 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, ) @@ -54,10 +55,12 @@ 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._shortcuts = shortcut_source self._dialog_theme = ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG) self._navigator: Optional[DialogKeyboardNavigator] = None @@ -151,6 +154,7 @@ def _install_navigation(self) -> None: ], on_escape=self.hide, key_router=self._router, + shortcut_source=self._shortcuts, ) self._navigator.install() diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 558ac9460..55a96bb6b 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -55,17 +55,13 @@ from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_delete_item from sampletones_application.utils.gui.keyboard import ( PRIORITY_PANEL, - KeyCombination, KeyEvent, KeyRouter, ) from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS -from sampletones_application.utils.gui.keyboard.modifiers import ( - ALT, - CTRL, - SHIFT, - Modifier, -) +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 ( @@ -77,7 +73,6 @@ ) 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 @@ -92,6 +87,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 @@ -116,11 +118,13 @@ def __init__( plus_minus_layout: PlusMinusButtonsLayout, language_manager: LanguageManager, key_router: KeyRouter, + shortcut_source: ShortcutSource, initial_collapsed: bool = False, ) -> None: self._layout = layout self._plus_minus_layout = plus_minus_layout self._router = key_router + self._shortcuts = shortcut_source self._buttons: Optional[GUIPlusMinusButtons] = None self._position_count: int = 0 self._order: EditableCells[OrderKey] = EditableCells() @@ -161,8 +165,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, ) @@ -200,18 +202,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 = KeyCombination(dpg.mvKey_Spacebar, CTRL).display() - self._sc_move_left = KeyCombination(dpg.mvKey_Left, ALT).display() - self._sc_move_right = KeyCombination(dpg.mvKey_Right, ALT).display() - self._sc_move_start = KeyCombination(dpg.mvKey_Home, ALT).display() - self._sc_move_end = KeyCombination(dpg.mvKey_End, ALT).display() - self._sc_duplicate = KeyCombination(dpg.mvKey_D, CTRL).display() - self._sc_insert = PLUS - self._sc_remove = MINUS - self._sc_clear = KeyCombination(dpg.mvKey_Delete, SHIFT).display() - def _load_label_tooltips(self, language_manager: LanguageManager) -> None: """Reads the row-label tooltips, which name the click gestures the labels carry.""" @@ -857,67 +847,66 @@ 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), ) @@ -934,90 +923,99 @@ def _keys_active(self) -> bool: 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 + direction = MOVE_DIRECTIONS.get(shortcut_id) + if direction is not None: + target = direction.target(position, self._position_count) + if target is not None: + self.call(self.on_move_requested, position, target) - target = direction.target(position, self._position_count) - if target is not None: - self.call(self.on_move_requested, position, target) + return True - return True - - def _alt_move_direction(self, key: int) -> Optional[MoveDirection]: - match key: - case dpg.mvKey_Left: - return MoveDirection.PREVIOUS - case dpg.mvKey_Right: - return MoveDirection.NEXT - case dpg.mvKey_Home: - return MoveDirection.FIRST - case dpg.mvKey_End: - return MoveDirection.LAST + match shortcut_id: + case ShortcutId.ORDER_ADD_FRAME: + self._on_add_clicked() + case ShortcutId.ORDER_INSERT_FRAME: + self.call(self.on_insert_requested, position) + case ShortcutId.ORDER_REMOVE_FRAME: + self._on_remove_clicked() + case ShortcutId.ORDER_DUPLICATE_FRAME: + self.call(self.on_duplicate_requested, position) + case ShortcutId.ORDER_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( @@ -1046,8 +1044,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/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index a619ec461..80bf134fe 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 @@ -28,7 +28,8 @@ 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 +42,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 +57,13 @@ def __init__( layout: SequencerLayout, language_manager: LanguageManager, key_router: KeyRouter, + shortcut_source: ShortcutSource, initial_collapsed: bool = False, ) -> None: self._language_manager = language_manager self._layout = layout self._router = key_router + 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 @@ -324,40 +334,51 @@ def _keys_active(self) -> bool: 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 @@ -367,19 +388,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: diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index b6ca2d2f0..81b28d82d 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -60,21 +60,13 @@ from sampletones_application.utils.gui.dpg import dpg_delete_children from sampletones_application.utils.gui.keyboard import ( PRIORITY_PANEL, - KeyCombination, KeyEvent, KeyRouter, ) -from sampletones_application.utils.gui.keyboard.keys import ( - HEX_KEYS, - KEY_PAGE_DOWN, - KEY_PAGE_UP, - SIGN_KEYS, -) -from sampletones_application.utils.gui.keyboard.modifiers import ( - CTRL, - CTRL_SHIFT, - Modifier, -) +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.faded import FadedColor from sampletones_application.view_model.sequencer.channels import ( @@ -118,11 +110,13 @@ def __init__( layout: SequencerLayout, language_manager: LanguageManager, key_router: KeyRouter, + shortcut_source: ShortcutSource, initial_collapsed: bool = False, ) -> None: self._layout = layout self._language_manager = language_manager self._router = key_router + self._shortcuts = shortcut_source widths = layout.tracker.subcolumn_widths self._subcolumn_widths: Dict[SubColumn, int] = { @@ -176,12 +170,6 @@ def __init__( self._load_header_tooltips(language_manager) self._create_channel_switch(language_manager) - self._sc_play_from_here = KeyCombination( - dpg.mvKey_Spacebar, - CTRL_SHIFT, - ).display() - self._sc_play_from_frame = KeyCombination(dpg.mvKey_Spacebar, CTRL).display() - super().__init__( tag=TAG_SEQUENCER_TRACKER_PANEL, height=-1, @@ -979,12 +967,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) @@ -1127,55 +1115,76 @@ def _keys_active(self) -> bool: 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 + + return self._edit_row(shortcut_id) - match event.key: - case dpg.mvKey_Up: + 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 @@ -1241,8 +1250,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 diff --git a/src/sampletones_application/utils/gui/dialog_navigation/navigator.py b/src/sampletones_application/utils/gui/dialog_navigation/navigator.py index 3f957ae91..ba4bb99a2 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 697271643..38be0149e 100644 --- a/src/sampletones_application/utils/gui/dialogs.py +++ b/src/sampletones_application/utils/gui/dialogs.py @@ -43,6 +43,7 @@ ) 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+)\}") @@ -63,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``.""" @@ -71,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() @@ -86,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) @@ -125,6 +129,7 @@ def close() -> None: stops=[FocusStop.button(ok_button_tag, close)], on_escape=close, key_router=key_router, + shortcut_source=shortcut_source, ) @@ -136,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 @@ -181,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, @@ -205,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, @@ -274,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, @@ -405,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) @@ -427,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, ) @@ -542,6 +554,7 @@ def buttons(_: None) -> None: ], on_escape=_on_cancel, key_router=self._router, + shortcut_source=self._shortcuts, initial_index=1, ) center_when_settled(tag) @@ -644,6 +657,7 @@ def buttons(_: None) -> None: ], on_escape=_on_cancel, key_router=self._router, + shortcut_source=self._shortcuts, initial_index=2, ) center_when_settled(tag) @@ -664,6 +678,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, @@ -697,6 +712,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/shortcuts/scheme.py b/src/sampletones_application/utils/gui/shortcuts/scheme.py index cc7c1648c..588dcfb20 100644 --- a/src/sampletones_application/utils/gui/shortcuts/scheme.py +++ b/src/sampletones_application/utils/gui/shortcuts/scheme.py @@ -2,11 +2,12 @@ from functools import cached_property from pathlib import Path -from typing import Dict, List, Self, Tuple +from typing import Dict, List, 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 ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.shortcut import Shortcut from sampletones_application.utils.gui.shortcuts.written import WrittenShortcut @@ -29,6 +30,22 @@ 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 @@ -47,6 +64,19 @@ 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 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.claims[category].get(KeyCombination(event.key, event.modifiers)) + @classmethod def load(cls, path: Path) -> ShortcutScheme: """Load the scheme a keybinding file holds. @@ -71,15 +101,17 @@ def _require_every_action_answered(self) -> None: raise SystemError(f"Keybinding scheme {self.name!r} leaves actions unanswered: {unanswered}") def _require_one_action_per_combination(self) -> None: - claimed: Dict[Tuple[ShortcutCategory, KeyCombination], ShortcutId] = {} + """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(): - claim = (shortcut_id.category, combination) - if claim in claimed: + 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"{claimed[claim].value!r} and {shortcut_id.value!r}, " + f"{claimant.value!r} and {shortcut_id.value!r}, " f"which share the {shortcut_id.category} category" ) - - claimed[claim] = shortcut_id diff --git a/src/sampletones_application/utils/gui/shortcuts/source.py b/src/sampletones_application/utils/gui/shortcuts/source.py index 75d119e7c..b52d1ce7c 100644 --- a/src/sampletones_application/utils/gui/shortcuts/source.py +++ b/src/sampletones_application/utils/gui/shortcuts/source.py @@ -1,6 +1,7 @@ from typing import Optional -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +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 @@ -31,6 +32,14 @@ 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. diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 1dc50dbe5..c768c9a60 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -48,7 +48,7 @@ bindings: # view AudioSettings: {combination: "Ctrl+A"} - DisplaySettings: {combination: ~} + DisplaySettings: {combination: "Ctrl+D"} ToggleAdvancedSettings: {combination: "Ctrl+Shift+A"} ToggleFullscreen: {combination: "F11"} AboutDialog: {combination: ~} @@ -69,7 +69,7 @@ bindings: OrderAddFrame: {combination: "Ins"} OrderInsertFrame: {combination: "+", aliases: ["Num+"]} OrderRemoveFrame: {combination: "-", aliases: ["Num-"]} - OrderDuplicateFrame: {combination: "Ctrl+D"} + OrderDuplicateFrame: {combination: "Ctrl+Ins"} OrderClearFrame: {combination: "Shift+Del"} OrderClearCell: {combination: "Del"} OrderClearPreviousCell: {combination: "Backspace"} diff --git a/tests/suite/shortcuts.py b/tests/suite/shortcuts.py new file mode 100644 index 000000000..0dcd14af6 --- /dev/null +++ b/tests/suite/shortcuts.py @@ -0,0 +1,21 @@ +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_scheme() -> ShortcutScheme: + """The keybinding scheme the build ships, read once for the whole run.""" + return ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).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/ui/panels/dialogs/test_countdown.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_countdown.py index fa01990f1..3238e462a 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_countdown.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_countdown.py @@ -15,6 +15,7 @@ ) 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"] @@ -30,6 +31,7 @@ def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUICountdo 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(), ) 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 index 47bfeef27..ccc1306a0 100644 --- a/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_display_settings.py @@ -26,6 +26,7 @@ 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"] @@ -63,6 +64,7 @@ def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIDisplay layout=layout_config.settings, language_manager=LANGUAGE_MANAGER, key_router=KeyRouter(), + shortcut_source=shipped_source(), ) 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 000000000..3f9d476c4 --- /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_panel_escape.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py index 1f8583e8d..aa62eea82 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 @@ -14,6 +14,7 @@ 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: @@ -25,12 +26,14 @@ class TestTrackerEscapeYieldsToGlobalStop: def test_escape_yields_when_no_pending_edit(self) -> None: 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 = 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) @@ -44,21 +47,17 @@ 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) assert panel._on_key_pressed(_escape()) is True assert applied and applied[0].pending == "" - - assert panel._on_key_pressed(_escape()) is True - assert applied and applied[0].pending == "" - - assert panel._on_key_pressed(_escape()) is True - assert applied and applied[0].pending == "" 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 000000000..2c24e0c12 --- /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_tracker_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py index d8511e119..15634dfbb 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -7,20 +7,33 @@ 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 tests.suite.shortcuts import shipped_source PAGE_SIZE = 16 +CURSOR_ROW = 5 def _panel() -> GUISequencerTrackerPanel: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) - panel._input_state = TrackerInputState(cursor=TrackerCursor(5, None, SubColumn.INSTRUMENT), pending="") + 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)) 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.""" @@ -46,3 +59,42 @@ def test_page_down_moves_down_one_page_and_scrolls(self, monkeypatch: pytest.Mon assert panel._on_key_pressed(KeyEvent(key=KEY_PAGE_DOWN, modifiers=NO_MODIFIERS)) is True assert moves == [PAGE_SIZE] assert scrolls == [None] + + +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_tracker_play_shortcut.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py index 2942997bc..e5d43ea89 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py @@ -8,10 +8,12 @@ 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]) -> 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/utils/gui/dialog_navigation/test_navigator.py b/tests/unit/sampletones_application/utils/gui/dialog_navigation/test_navigator.py index f43e176c7..a936841cb 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/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py index ebac9217e..2be4637cc 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -6,9 +6,10 @@ 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.catalog import DEFAULT_SCHEME_NAME -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +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 @@ -26,6 +27,12 @@ """ +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")}) @@ -108,6 +115,30 @@ def test_a_combination_naming_no_key_raises(self, rebound: RebindScheme) -> None 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 TestLoad: def test_a_file_is_read_as_the_scheme_it_holds(self, tmp_path: Path) -> None: path = tmp_path / "copy.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 000000000..e6a8bf386 --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py @@ -0,0 +1,39 @@ +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 + +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) + + +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 diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py index d7c056b54..939a3532c 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_source.py @@ -1,12 +1,19 @@ from typing import List -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +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 @@ -17,6 +24,11 @@ def test_an_action_resolves_its_keys_against_the_scheme_in_place(self, source: 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( @@ -28,6 +40,16 @@ def test_activating_another_scheme_replaces_the_one_in_place( 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, From 33fc9ba7ab4d98c55f99a33a4cd792b51bcc5a91 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 09:03:15 +0200 Subject: [PATCH 020/152] Refactored: sequencer panels onto the keybinding registry --- .../coordinators/tabs/sequencer.py | 12 +- .../ui/panels/sequencer/order.py | 24 +++ .../ui/panels/sequencer/samples.py | 30 ++-- .../ui/panels/sequencer/tracker.py | 12 ++ .../ui/themes/dpg_constants.py | 27 ++++ src/sampletones_config/palettes/dark.yaml | 16 +- src/sampletones_config/palettes/light.yaml | 148 ++++++++++-------- src/sampletones_config/palettes/studio.yaml | 16 +- src/sampletones_config/theme/default.yaml | 136 ++++++++++++++++ 9 files changed, 335 insertions(+), 86 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index fb5bb6110..97c02d19c 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -713,15 +713,15 @@ def refresh(self) -> None: self._sequencer_history_panel.set_enabled(is_open) def repaint(self) -> None: - """Draws both tables again so their tints take the palette now in place. + """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 — which is - what pushing the current view models through the panels does. + 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_channels_logic.push_channels() - self._sequencer_tracker_logic.refresh() - self._sequencer_order_logic.refresh() + 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() diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 55a96bb6b..34e9aa9dd 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -493,6 +493,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. diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index 80bf134fe..5ce1b7ac4 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -182,11 +182,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.rgba, - ) + 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, @@ -277,11 +291,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.rgba, - ) + self._highlight_selected_row(position) self.call(self.on_sample_selected, sample_id) @property diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 81b28d82d..f8cdedc58 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -413,6 +413,18 @@ def _rebuild_table( dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) self._editable_cells.reset(cell_values) self._build_table(view_model) + self.repaint() + + 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() diff --git a/src/sampletones_application/ui/themes/dpg_constants.py b/src/sampletones_application/ui/themes/dpg_constants.py index e7e4a0ea9..75261a4ff 100644 --- a/src/sampletones_application/ui/themes/dpg_constants.py +++ b/src/sampletones_application/ui/themes/dpg_constants.py @@ -63,10 +63,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 +109,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_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index e9d9b1726..c43f12ac7 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -113,8 +113,18 @@ colors: input_invalid: "#c0504a64" input_warning: "#c0884a64" - # plot lines + # controls + control: "#4a4a52" + control_hovered: "#55555e" + control_active: "#60606a" + control_border: "#6c6c77" + + # plots plot_zero_line: "#c8c8c8" + plot_grid: "#34343c" + plot_axis_text: "#9a9aa2" + plot_border: "#45454c" + plot_legend_bg: "#26262c" # file-tree nodes file_wave: "#64c8ff" @@ -184,6 +194,10 @@ colors: waveform_overlay: "#ffffff20" spectrum_dim: "#1b1b1f" + # layout: tracker row grouping + tracker_beat_row: "#ffffff0e" + tracker_bar_row: "#ffffff1e" + # layout: tracker cursor and playback pattern_highlight: "#ffffff40" cell_cursor: "#66bbffa0" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index e3c0f822a..d9789fd72 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -2,30 +2,30 @@ name: light colors: # surfaces and neutrals - ground: "#e6e6ea" - tab_strip: "#dcdce2" - recess: "#dedee4" - surface: "#f5f5f8" - surface_alt: "#fdfdff" - surface_accent: "#ede6fa" - menu: "#e0e5f0" - status_bar: "#e0e5f0" - popup: "#fdfdff" + ground: "#dcdfe6" + tab_strip: "#d0d4dd" + recess: "#e6e9f0" + surface: "#f8f9fb" + surface_alt: "#ffffff" + surface_accent: "#eae4f7" + menu: "#d5dae5" + status_bar: "#d5dae5" + popup: "#ffffff" frame: "#ffffff" - frame_hovered: "#eff0f5" - frame_active: "#e5e6f0" - border: "#c2c2cc" - separator: "#b4b4c2" - plot_background: "#f7f7fa" - well: "#e6ecf6" + frame_hovered: "#eef1f6" + frame_active: "#e2e7f0" + border: "#b3bac7" + separator: "#a3abba" + plot_background: "#ffffff" + well: "#e3e8f2" # tables - table_header: "#dcd6ec" - table_row: "#f3f3f7" - table_row_alt: "#e9e9f0" - table_border: "#c6c2d4" + table_header: "#d3d7e5" + table_row: "#fbfbfd" + table_row_alt: "#edeff5" + table_border: "#bac0cd" - # cool secondary (functional chrome: input focus, secondary emphasis) + # cool secondary cool: "#2f6fd0" cool_hover: "#1f5cb8" cool_active: "#17488f" @@ -50,13 +50,13 @@ colors: secondary_active: "#bcc5d4" secondary_disabled: "#ebebf0" - # danger (destructive actions: cancel, abort) + # danger danger: "#bf4646" danger_hover: "#d05a5a" danger_active: "#9b3737" on_danger: "#ffffff" - # dialog windows (elevated surface + accent border/title) + # dialog windows dialog_surface: "#f2f2f7" dialog_title: "#d7deee" @@ -71,50 +71,58 @@ colors: channel_noise_soft: "#8a8890" # tabs - tab: "#dde0e9" - tab_hovered: "#cad3e5" - tab_active: "#b6c4df" + tab: "#cdd2dd" + tab_hovered: "#c0c8d8" + tab_active: "#e6e9f0" # selection (tree, list, menu highlight) - selection: "#dbd2ee" - selection_hovered: "#ccc0e5" - selection_active: "#bcaddc" + selection: "#d5cbec" + selection_hovered: "#c5b8e3" + selection_active: "#b3a4d8" # scrollbar - scrollbar_hovered: "#bebec9" + scrollbar_hovered: "#9aa2b0" # buttons - button: "#ccd4e5" - button_hovered: "#bcc7dd" - button_active: "#aabad5" - button_disabled: "#e2e2e8" + button: "#c5cddd" + button_hovered: "#b4bfd4" + button_active: "#a2b0cb" + button_disabled: "#dfe2e9" # player - player_surface: "#e2e8f4" + player_surface: "#dbe2f0" player_border: "#3d7fc4" - player_button: "#d2daed" - player_button_hovered: "#c0cde5" - player_button_active: "#adbedc" - player_button_disabled: "#e6eaf3" + player_button: "#cbd4e8" + player_button_hovered: "#b8c5df" + player_button_active: "#a5b6d6" + player_button_disabled: "#e2e6ee" # text - text: "#1e1e24" - text_muted: "#5c5c68" - text_disabled: "#92929c" - text_trace: "#4a4a4a" - - # emphasis and overlays - # contrast: the strongest foreground the surfaces carry - # overlay: tinted at a fraction to lift a row or region off its background - contrast: "#16161c" + text: "#1a1d23" + text_muted: "#535b68" + text_disabled: "#8b93a1" + text_trace: "#41464e" + + # overlay + contrast: "#14171c" overlay: "#000000" transparent: "#00000000" - border_strong: "#a6a6b2" + border_strong: "#8f97a6" input_invalid: "#c0504a48" input_warning: "#c0884a48" - # plot lines - plot_zero_line: "#909098" + # controls + control: "#ffffff" + control_hovered: "#eaeef6" + control_active: "#dbe2ee" + control_border: "#8f97a6" + + # plots + plot_zero_line: "#8a92a0" + plot_grid: "#d7dce5" + plot_axis_text: "#535b68" + plot_border: "#b3bac7" + plot_legend_bg: "#f4f6fa" # file-tree nodes file_wave: "#0d6ea8" @@ -133,31 +141,31 @@ colors: library_root: "#33333a" # layout: content text - text_default: "#26262c" - text_inactive: "#84848e" + text_default: "#22262d" + text_inactive: "#7b8391" text_error: "#c03030" - text_highlight: "#9a6a00" + text_highlight: "#8a5f00" # layout: flat control buttons - button_flat: "#dcdce4" - button_flat_active: "#b8b8c8" - button_flat_hovered: "#cbcbda" - button_flat_light: "#d2d2e0" + button_flat: "#d7dbe4" + button_flat_active: "#aeb6c4" + button_flat_hovered: "#c4cad7" + button_flat_light: "#cdd3de" # layout: background fills - background_default: "#f0f0f2" - background_dark: "#e2e2e6" - background_light: "#fafafc" - background_menu: "#e8e8ec" + background_default: "#eceff4" + background_dark: "#dcdfe6" + background_light: "#ffffff" + background_menu: "#e3e7ee" background_invalid: "#c0202038" # layout: properties table - properties_header: "#dbe0ec" - properties_row: "#f4f5f8" - properties_row_alt: "#eaebf1" - properties_border: "#c3c8d6" - properties_label: "#3f5480" - properties_value: "#2c3140" + properties_header: "#d3d7e5" + properties_row: "#fbfbfd" + properties_row_alt: "#edeff5" + properties_border: "#bac0cd" + properties_label: "#3a4f7c" + properties_value: "#272c39" # layout: path links path_link: "#1f5fd0" @@ -184,6 +192,10 @@ colors: waveform_overlay: "#00000018" spectrum_dim: "#f7f7fa" + # layout: tracker row grouping + tracker_beat_row: "#0000000c" + tracker_bar_row: "#0000001c" + # layout: tracker cursor and playback pattern_highlight: "#00000018" cell_cursor: "#2a7fd090" @@ -191,7 +203,7 @@ colors: playback_row: "#1f903838" # layout: order table - order_label: "#dbe0ec" + order_label: "#d3d7e5" order_master: "#1c8cc018" order_master_divider: "#1c8cc028" order_column_current: "#00000014" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index 3c99bc1d0..68b521b58 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -111,8 +111,18 @@ colors: input_invalid: "#c0504a64" input_warning: "#c0884a64" - # plot lines + # controls (the box a checkbox or radio button draws its mark inside) + control: "#514c68" + control_hovered: "#5c5676" + control_active: "#665f83" + control_border: "#6f6a8a" + + # plots plot_zero_line: "#c8c8c8" + plot_grid: "#39344a" + plot_axis_text: "#9497ab" + plot_border: "#464a5e" + plot_legend_bg: "#2a2636" # file-tree nodes file_wave: "#64c8ff" @@ -182,6 +192,10 @@ colors: waveform_overlay: "#ffffff20" spectrum_dim: "#1c1c1c" + # layout: tracker row grouping + tracker_beat_row: "#ffffff0e" + tracker_bar_row: "#ffffff1e" + # layout: tracker cursor and playback pattern_highlight: "#ffffff40" cell_cursor: "#66bbffa0" diff --git a/src/sampletones_config/theme/default.yaml b/src/sampletones_config/theme/default.yaml index 9a503ff88..5c2477ac5 100644 --- a/src/sampletones_config/theme/default.yaml +++ b/src/sampletones_config/theme/default.yaml @@ -105,6 +105,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 +281,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 From ab2d93e36c435df56017d10c0ba8e81244ed75fc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 09:46:44 +0200 Subject: [PATCH 021/152] Added: tracker beat and bar row emphasis --- pyproject.toml | 3 + .../layout/tabs/sequencer/colors/colors.py | 2 + .../layout/tabs/sequencer/colors/row.py | 14 ++ .../layout/tabs/sequencer/tracker/tracker.py | 9 + .../ui/panels/sequencer/rows.py | 77 ++++++++ .../ui/panels/sequencer/tracker.py | 148 ++++++++++------ .../utils/palette/colors/layered.py | 22 +++ .../layout/tabs/sequencer/colors.yaml | 3 + .../layout/tabs/sequencer/tracker.yaml | 2 + src/sampletones_config/palettes/dark.yaml | 44 +---- src/sampletones_config/palettes/light.yaml | 42 +---- src/sampletones_config/palettes/studio.yaml | 40 +---- .../theme/tables/pattern.yaml | 2 +- src/sampletones_shared/utils/color.py | 19 ++ .../ui/panels/sequencer/test_rows.py | 160 +++++++++++++++++ .../ui/panels/sequencer/test_tracker_rows.py | 166 ++++++++++++++++-- .../ui/themes/test_loader.py | 18 +- .../sampletones_shared/utils/test_color.py | 26 ++- 18 files changed, 596 insertions(+), 201 deletions(-) create mode 100644 src/sampletones_application/layout/tabs/sequencer/colors/row.py create mode 100644 src/sampletones_application/ui/panels/sequencer/rows.py create mode 100644 src/sampletones_application/utils/palette/colors/layered.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py diff --git a/pyproject.toml b/pyproject.toml index ab22dfaef..19f08acd7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -175,6 +175,9 @@ disable = [ "too-many-statements", ] +[tool.pylint.basic] +bad-names = ["foo", "baz"] + [tool.pylint.format] max-line-length = 120 diff --git a/src/sampletones_application/layout/tabs/sequencer/colors/colors.py b/src/sampletones_application/layout/tabs/sequencer/colors/colors.py index 973894954..ba05e75e8 100644 --- a/src/sampletones_application/layout/tabs/sequencer/colors/colors.py +++ b/src/sampletones_application/layout/tabs/sequencer/colors/colors.py @@ -5,6 +5,7 @@ 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 @@ -16,6 +17,7 @@ class SequencerColors(BaseModel, extra="forbid", frozen=True): cursor_row: WrittenColor playback_row: WrittenColor label: WrittenColor + rows: RowColors order: OrderColors sample: SampleColors header: HeaderColors 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 000000000..559bc8001 --- /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/tracker/tracker.py b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py index db169cd30..44551c39a 100644 --- a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py +++ b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py @@ -4,8 +4,17 @@ 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/ui/panels/sequencer/rows.py b/src/sampletones_application/ui/panels/sequencer/rows.py new file mode 100644 index 000000000..be529c089 --- /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/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index f8cdedc58..97a4ac712 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -52,6 +52,7 @@ 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, @@ -68,7 +69,9 @@ 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, ) @@ -329,11 +332,10 @@ 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, @@ -428,8 +430,66 @@ def repaint(self) -> None: 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._playing_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 @@ -670,8 +730,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() @@ -682,11 +742,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) @@ -859,17 +919,12 @@ def _apply_cell_highlight( row_index: int, generator: Optional[GeneratorName], ) -> None: - table_row = tracker_table_row(row_index) - dpg.highlight_table_row( - TAG_SEQUENCER_TRACKER_TABLE, - table_row, - color=self._layout.colors.cursor_row.rgba, - ) - 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_TRACKER_TABLE, - table_row, - column_index, + tracker_table_row(row_index), + tracker_table_column(generator), color=self._layout.colors.cell_cursor.rgba, ) @@ -878,17 +933,17 @@ def _remove_cell_highlight( row_index: int, generator: Optional[GeneratorName], ) -> None: - table_row = tracker_table_row(row_index) - dpg.unhighlight_table_row( - TAG_SEQUENCER_TRACKER_TABLE, - 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_TRACKER_TABLE, - table_row, - col_idx, + tracker_table_row(row_index), + tracker_table_column(generator), ) + self._paint_row(row_index) def _on_cell_clicked( self, @@ -945,7 +1000,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. @@ -1314,50 +1369,31 @@ 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_TRACKER_TABLE, - tracker_table_row(row_index), - color=self._layout.colors.pattern_highlight.rgba, - ) + 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_TRACKER_TABLE, - tracker_table_row(row_index), - ) self._highlighted_row = None + self._paint_row(row_index) 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_TRACKER_TABLE, - tracker_table_row(self._playing_row), - ) - + """Moves the playhead mark, drawing both the row it left and the row it reached.""" + previous = self._playing_row self._playing_row = row_index - self._apply_playing_row_highlight() - - def _apply_playing_row_highlight(self) -> None: - """Highlights the playing row when its index lies within the live table. + if previous is not None and previous != row_index: + 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_TRACKER_TABLE, - tracker_table_row(self._playing_row), - color=self._layout.colors.playback_row.rgba, - ) + if row_index is not None: + self._paint_row(row_index) def _live_row_count(self) -> int: """The table's current pattern-row count, read live from DearPyGui. 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 000000000..84af55932 --- /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_config/layout/tabs/sequencer/colors.yaml b/src/sampletones_config/layout/tabs/sequencer/colors.yaml index a01f1dcf8..88b613131 100644 --- a/src/sampletones_config/layout/tabs/sequencer/colors.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/colors.yaml @@ -3,6 +3,9 @@ cell_cursor: .cell_cursor cursor_row: .cursor_row playback_row: .playback_row label: .contrast +rows: + beat: .tracker_beat_row + bar: .tracker_bar_row order: label: .order_label master: .order_master diff --git a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml index 34c6001a1..b9310835d 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 index c43f12ac7..c7041e014 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -1,7 +1,6 @@ name: dark colors: - # surfaces and neutrals ground: "#17171a" tab_strip: "#202024" recess: "#2a2a2f" @@ -19,26 +18,22 @@ colors: plot_background: "#1b1b1f" well: "#202026" - # tables table_header: "#414149" table_row: "#2e2e33" table_row_alt: "#37373d" table_border: "#52525b" - # cool secondary (functional chrome: input focus, secondary emphasis) cool: "#79a6e0" cool_hover: "#92b8ea" cool_active: "#628fc8" cool_muted: "#3c4a5e" - # accent accent: "#b98af3" accent_hover: "#a180ce" accent_active: "#7b629e" accent_muted: "#665780" on_accent: "#17131f" - # buttons primary: "#8f6fc0" primary_hover: "#a689d4" primary_active: "#7d64ac" @@ -50,17 +45,14 @@ colors: secondary_active: "#4c4c58" secondary_disabled: "#2b2b30" - # danger (destructive actions: cancel, abort) danger: "#a85555" danger_hover: "#bd6a6a" danger_active: "#8e4747" on_danger: "#f2f2f4" - # dialog windows (elevated surface + accent border/title) dialog_surface: "#303036" dialog_title: "#3a3a44" - # 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: "#232327" tab_hovered: "#303038" tab_active: "#3c3c48" - # selection (tree, list, menu highlight) selection: "#3c3c46" selection_hovered: "#474753" selection_active: "#52525f" - # scrollbar scrollbar_hovered: "#48484f" - # buttons button: "#4e4e58" button_hovered: "#5d5d69" button_active: "#6b6b79" button_disabled: "#333338" - # player player_surface: "#26262c" player_border: "#6a6a7a" player_button: "#34343c" @@ -97,15 +84,11 @@ colors: player_button_active: "#4c4c58" player_button_disabled: "#2a2a30" - # text text: "#e8e8ea" text_muted: "#9a9aa2" text_disabled: "#78787f" text_trace: "#c0c0c0" - # emphasis and overlays - # contrast: the strongest foreground the surfaces carry - # overlay: tinted at a fraction to lift a row or region off its background contrast: "#ffffff" overlay: "#ffffff" transparent: "#00000000" @@ -113,55 +96,46 @@ colors: input_invalid: "#c0504a64" input_warning: "#c0884a64" - # controls control: "#4a4a52" control_hovered: "#55555e" control_active: "#60606a" control_border: "#6c6c77" - # plots plot_zero_line: "#c8c8c8" plot_grid: "#34343c" plot_axis_text: "#9a9aa2" plot_border: "#45454c" plot_legend_bg: "#26262c" - # 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: "#35353c" button_flat_active: "#56565f" button_flat_hovered: "#45454d" button_flat_light: "#3e3e46" - # layout: background fills background_default: "#242424" background_dark: "#1c1c1c" background_light: "#2c2c2c" background_menu: "#323232" background_invalid: "#c0202064" - # layout: properties table properties_header: "#33333a" properties_row: "#1f1f23" properties_row_alt: "#26262c" @@ -169,62 +143,50 @@ colors: properties_label: "#a0a0aa" properties_value: "#cbcbd2" - # 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: "#1b1b1f" - # layout: tracker row grouping - tracker_beat_row: "#ffffff0e" - tracker_bar_row: "#ffffff1e" + tracker_beat_row: "#ffffff14" + tracker_bar_row: "#ffffff26" - # layout: tracker cursor and playback pattern_highlight: "#ffffff40" cell_cursor: "#66bbffa0" - cursor_row: "#ffffff18" + cursor_row: "#ffffff2c" playback_row: "#64dc6440" - # layout: order table order_label: "#33333a" 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: "#0a0a0a10" - # 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/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index d9789fd72..061f3fa54 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -1,7 +1,6 @@ name: light colors: - # surfaces and neutrals ground: "#dcdfe6" tab_strip: "#d0d4dd" recess: "#e6e9f0" @@ -19,26 +18,22 @@ colors: plot_background: "#ffffff" well: "#e3e8f2" - # tables table_header: "#d3d7e5" table_row: "#fbfbfd" table_row_alt: "#edeff5" table_border: "#bac0cd" - # cool secondary cool: "#2f6fd0" cool_hover: "#1f5cb8" cool_active: "#17488f" cool_muted: "#b7cae9" - # accent accent: "#6b3fb0" accent_hover: "#7f52c4" accent_active: "#55308c" accent_muted: "#d5c4ee" on_accent: "#ffffff" - # buttons primary: "#7a55b8" primary_hover: "#8a67c6" primary_active: "#644497" @@ -50,17 +45,14 @@ colors: secondary_active: "#bcc5d4" secondary_disabled: "#ebebf0" - # danger danger: "#bf4646" danger_hover: "#d05a5a" danger_active: "#9b3737" on_danger: "#ffffff" - # dialog windows dialog_surface: "#f2f2f7" dialog_title: "#d7deee" - # channels: pulses orange, triangle blue, noise grey channel_pulse1: "#c25a12" channel_pulse2: "#96790a" channel_triangle: "#2a6ba4" @@ -70,26 +62,21 @@ colors: channel_triangle_soft: "#557c9a" channel_noise_soft: "#8a8890" - # tabs tab: "#cdd2dd" tab_hovered: "#c0c8d8" tab_active: "#e6e9f0" - # selection (tree, list, menu highlight) selection: "#d5cbec" selection_hovered: "#c5b8e3" selection_active: "#b3a4d8" - # scrollbar scrollbar_hovered: "#9aa2b0" - # buttons button: "#c5cddd" button_hovered: "#b4bfd4" button_active: "#a2b0cb" button_disabled: "#dfe2e9" - # player player_surface: "#dbe2f0" player_border: "#3d7fc4" player_button: "#cbd4e8" @@ -97,13 +84,11 @@ colors: player_button_active: "#a5b6d6" player_button_disabled: "#e2e6ee" - # text text: "#1a1d23" text_muted: "#535b68" text_disabled: "#8b93a1" text_trace: "#41464e" - # overlay contrast: "#14171c" overlay: "#000000" transparent: "#00000000" @@ -111,55 +96,46 @@ colors: input_invalid: "#c0504a48" input_warning: "#c0884a48" - # controls control: "#ffffff" control_hovered: "#eaeef6" control_active: "#dbe2ee" control_border: "#8f97a6" - # plots plot_zero_line: "#8a92a0" plot_grid: "#d7dce5" plot_axis_text: "#535b68" plot_border: "#b3bac7" plot_legend_bg: "#f4f6fa" - # file-tree nodes file_wave: "#0d6ea8" file_library: "#1c7a34" file_reconstruction: "#4a4ab4" file_muted: "#7c7c86" - # favourites favorite: "#b07d0a" favorite_child: "#8d7a45" - # instruction-library nodes library_generator: "#2c6e3c" library_group: "#1c6a70" library_instruction: "#4c4c54" library_root: "#33333a" - # layout: content text text_default: "#22262d" text_inactive: "#7b8391" text_error: "#c03030" text_highlight: "#8a5f00" - # layout: flat control buttons button_flat: "#d7dbe4" button_flat_active: "#aeb6c4" button_flat_hovered: "#c4cad7" button_flat_light: "#cdd3de" - # layout: background fills background_default: "#eceff4" background_dark: "#dcdfe6" background_light: "#ffffff" background_menu: "#e3e7ee" background_invalid: "#c0202038" - # layout: properties table properties_header: "#d3d7e5" properties_row: "#fbfbfd" properties_row_alt: "#edeff5" @@ -167,62 +143,50 @@ colors: properties_label: "#3a4f7c" properties_value: "#272c39" - # layout: path links path_link: "#1f5fd0" path_link_hover: "#0d3f9c" - # layout: section headers header_library: "#2c7a44" header_reconstruction: "#6b3fb0" - # layout: instruction features feature_volume: "#1f8038" feature_arpeggio: "#c05a18" feature_pitch: "#0d6ea8" feature_duty_cycle: "#96700a" - # layout: caret overlay caret_fill: "#3a3ac060" caret_border: "#2a5fb0ff" - # layout: graphs and waveforms graph_bar: "#1c72ac" waveform_sample: "#1c72ac" waveform_reconstruction: "#b07d0a" waveform_overlay: "#00000018" spectrum_dim: "#f7f7fa" - # layout: tracker row grouping - tracker_beat_row: "#0000000c" - tracker_bar_row: "#0000001c" + tracker_beat_row: "#00000012" + tracker_bar_row: "#00000022" - # layout: tracker cursor and playback pattern_highlight: "#00000018" cell_cursor: "#2a7fd090" - cursor_row: "#0000000f" + cursor_row: "#00000020" playback_row: "#1f903838" - # layout: order table order_label: "#d3d7e5" order_master: "#1c8cc018" order_master_divider: "#1c8cc028" order_column_current: "#00000014" order_column_playing: "#1f903828" - # layout: sample column sample_column: "#1c8cc022" sample_divider: "#1c8cc028" - # layout: muted channel column channel_muted: "#9a9aa614" - # layout: history detail history_future: "#8c8c94ff" history_channel: "#1f5fb0ff" history_value: "#33333aff" history_separator: "#9a9aa2ff" - # layout: tracker text (instrument and sample share the reference yellow) tracker_reference: "#8a6a00ff" tracker_transpose: "#4c4c54ff" tracker_volume: "#1f8038ff" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index 68b521b58..e97db6577 100644 --- a/src/sampletones_config/palettes/studio.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,13 +84,11 @@ colors: player_button_active: "#4a5896" player_button_disabled: "#252e4d" - # text text: "#e7e7f0" text_muted: "#9497ab" text_disabled: "#767a8e" text_trace: "#c0c0c0" - # emphasis and overlays contrast: "#ffffff" overlay: "#ffffff" transparent: "#00000000" @@ -111,55 +96,46 @@ colors: input_invalid: "#c0504a64" input_warning: "#c0884a64" - # controls (the box a checkbox or radio button draws its mark inside) control: "#514c68" control_hovered: "#5c5676" control_active: "#665f83" control_border: "#6f6a8a" - # plots 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" @@ -167,62 +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 row grouping - tracker_beat_row: "#ffffff0e" - tracker_bar_row: "#ffffff1e" + tracker_beat_row: "#ffffff14" + tracker_bar_row: "#ffffff26" - # layout: tracker cursor and playback 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/tables/pattern.yaml b/src/sampletones_config/theme/tables/pattern.yaml index 3953ce51c..5f9ccc2a4 100644 --- a/src/sampletones_config/theme/tables/pattern.yaml +++ b/src/sampletones_config/theme/tables/pattern.yaml @@ -16,7 +16,7 @@ components: value: .transparent - type: color key: TableRowBg - value: .table_row_alt + value: .table_row - type: color key: TableRowBgAlt value: .table_row diff --git a/src/sampletones_shared/utils/color.py b/src/sampletones_shared/utils/color.py index 35ba9f781..63f2a2507 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/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 000000000..aeb6a2ad3 --- /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_tracker_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py index 1adba4b5d..3a1c3f3d7 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -9,18 +9,29 @@ 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) @@ -42,9 +53,12 @@ def get_item_children(self, item: str, slot: int) -> List[int]: 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 @@ -54,22 +68,43 @@ def unhighlight_table_cell(self, table: str, row: int, column: int) -> None: def _panel() -> GUISequencerTrackerPanel: - """Builds a panel around the state the row highlights read, with no DearPyGui context.""" + """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._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)) @@ -101,21 +136,72 @@ def test_an_unbuilt_table_reports_no_pattern_rows(self, recorder: _TableRecorder 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", range(PATTERN_ROWS)) + @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) @@ -126,11 +212,12 @@ def test_no_cursor_ever_paints_the_header_row(self, recorder: _TableRecorder) -> 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_mapped_row_and_cell(self, recorder: _TableRecorder) -> None: + def test_removing_the_cursor_clears_the_cell_and_the_plain_row(self, recorder: _TableRecorder) -> None: panel = _panel() panel._remove_cell_highlight(1, None) @@ -138,6 +225,13 @@ def test_removing_the_cursor_clears_the_mapped_row_and_cell(self, recorder: _Tab 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: @@ -147,25 +241,42 @@ def test_hover_lands_on_the_mapped_table_row(self, recorder: _TableRecorder) -> assert recorder.highlighted_rows == {tracker_table_row(3): PATTERN_HIGHLIGHT} - def test_moving_the_hover_clears_the_row_it_left(self, recorder: _TableRecorder) -> None: + 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(2) + panel.highlight_row(3) - assert recorder.unhighlighted_rows == [tracker_table_row(0)] + 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(2) + panel.highlight_row(3) panel.highlight_row(None) - assert recorder.unhighlighted_rows == [tracker_table_row(2)] + assert recorder.unhighlighted_rows == [tracker_table_row(3)] class TestPlayingRowHighlight: - @pytest.mark.parametrize("row_index", range(PATTERN_ROWS)) + @pytest.mark.parametrize("row_index", PLAIN_ROWS) def test_the_playhead_lands_on_the_mapped_table_row( self, recorder: _TableRecorder, @@ -177,6 +288,27 @@ def test_the_playhead_lands_on_the_mapped_table_row( 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() @@ -191,21 +323,29 @@ def test_a_row_beyond_the_pattern_is_left_to_the_next_rebuild(self, recorder: _T assert not recorder.highlighted_rows - def test_advancing_the_playhead_clears_the_row_it_left(self, recorder: _TableRecorder) -> None: + 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(2) + 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(2) + panel.set_playing_row(3) panel.set_playing_row(None) - assert recorder.unhighlighted_rows == [tracker_table_row(2)] + assert recorder.unhighlighted_rows == [tracker_table_row(3)] class TestHeaderRowBackground: diff --git a/tests/unit/sampletones_application/ui/themes/test_loader.py b/tests/unit/sampletones_application/ui/themes/test_loader.py index 638842692..2dbdcf407 100644 --- a/tests/unit/sampletones_application/ui/themes/test_loader.py +++ b/tests/unit/sampletones_application/ui/themes/test_loader.py @@ -108,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: @@ -120,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() diff --git a/tests/unit/sampletones_shared/utils/test_color.py b/tests/unit/sampletones_shared/utils/test_color.py index 95f11d611..a4de4b939 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 @@ -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) From bd82a088ba349a43c13d3046e93e9ca6ae320b19 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 14:23:37 +0200 Subject: [PATCH 022/152] Updated: themes --- docs/development/bugs-and-todos.md | 1 - .../layout/general/dialogs.yaml | 2 +- src/sampletones_config/palettes/dark.yaml | 316 ++++++++--------- src/sampletones_config/palettes/light.yaml | 328 +++++++++--------- src/sampletones_config/theme/default.yaml | 3 + src/sampletones_core/library/data.py | 1 - 6 files changed, 326 insertions(+), 325 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index ab77d8ba9..94a52cb78 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -24,7 +24,6 @@ ### Features -* Theme selector and palette management * In-application guide/tutorial * Language selector diff --git a/src/sampletones_config/layout/general/dialogs.yaml b/src/sampletones_config/layout/general/dialogs.yaml index b7b4b2c14..7c57efcde 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/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index c7041e014..1a83491e4 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -1,195 +1,195 @@ name: dark colors: - ground: "#17171a" - tab_strip: "#202024" - recess: "#2a2a2f" - surface: "#333338" - surface_alt: "#3d3d43" - surface_accent: "#3a3540" - menu: "#1f1f23" - status_bar: "#1f1f23" - popup: "#1e1e22" - frame: "#4a4a52" - frame_hovered: "#3e3e45" - frame_active: "#46464e" - border: "#45454c" - separator: "#55555e" - plot_background: "#1b1b1f" - well: "#202026" - - table_header: "#414149" - table_row: "#2e2e33" - table_row_alt: "#37373d" - table_border: "#52525b" - - cool: "#79a6e0" - cool_hover: "#92b8ea" - cool_active: "#628fc8" - cool_muted: "#3c4a5e" - - accent: "#b98af3" - accent_hover: "#a180ce" - accent_active: "#7b629e" - accent_muted: "#665780" - on_accent: "#17131f" - - primary: "#8f6fc0" - primary_hover: "#a689d4" - primary_active: "#7d64ac" - primary_muted: "#63616a" - on_primary: "#f2f2f4" - - secondary: "#34343a" - secondary_hover: "#40404a" - secondary_active: "#4c4c58" - secondary_disabled: "#2b2b30" - - danger: "#a85555" - danger_hover: "#bd6a6a" - danger_active: "#8e4747" + 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: "#303036" - dialog_title: "#3a3a44" + dialog_surface: "#2c2c30" + dialog_title: "#333a44" channel_pulse1: "#f09256" channel_pulse2: "#f2d15f" - channel_triangle: "#8cc1ed" - channel_noise: "#bbb8c2" - channel_pulse1_soft: "#e7c6aa" - channel_pulse2_soft: "#dfd6a8" - channel_triangle_soft: "#b9cedf" - channel_noise_soft: "#cbcace" - - tab: "#232327" - tab_hovered: "#303038" - tab_active: "#3c3c48" - - selection: "#3c3c46" - selection_hovered: "#474753" - selection_active: "#52525f" - - scrollbar_hovered: "#48484f" - - button: "#4e4e58" - button_hovered: "#5d5d69" - button_active: "#6b6b79" - button_disabled: "#333338" - - player_surface: "#26262c" - player_border: "#6a6a7a" - player_button: "#34343c" - player_button_hovered: "#3f3f49" - player_button_active: "#4c4c58" - player_button_disabled: "#2a2a30" - - text: "#e8e8ea" - text_muted: "#9a9aa2" - text_disabled: "#78787f" - text_trace: "#c0c0c0" + 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: "#5a5a63" + border_strong: "#56565e" input_invalid: "#c0504a64" input_warning: "#c0884a64" - control: "#4a4a52" - control_hovered: "#55555e" - control_active: "#60606a" - control_border: "#6c6c77" + control: "#3c3c42" + control_hovered: "#48484f" + control_active: "#54545c" + control_border: "#6c6c76" - plot_zero_line: "#c8c8c8" - plot_grid: "#34343c" - plot_axis_text: "#9a9aa2" - plot_border: "#45454c" - plot_legend_bg: "#26262c" + plot_zero_line: "#c8c8cc" + plot_grid: "#313136" + plot_axis_text: "#9a9aa0" + plot_border: "#3f3f43" + plot_legend_bg: "#26262a" - file_wave: "#64c8ff" - file_library: "#96ff96" - file_reconstruction: "#b4b4ff" - file_muted: "#b4b4b4" + file_wave: "#4fa6ff" + file_library: "#89d185" + file_reconstruction: "#dcdcaa" + file_muted: "#a8a8ae" favorite: "#ffd76e" - favorite_child: "#e7dbb7" - - library_generator: "#d2e8d2" - library_group: "#d2e8e8" - library_instruction: "#d2d2d2" - library_root: "#dcdcdc" - - text_default: "#dcdcdc" - text_inactive: "#828282" - text_error: "#ff6464" - text_highlight: "#ffcf6e" - - button_flat: "#35353c" - button_flat_active: "#56565f" - button_flat_hovered: "#45454d" - button_flat_light: "#3e3e46" - - background_default: "#242424" - background_dark: "#1c1c1c" - background_light: "#2c2c2c" - background_menu: "#323232" + 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: "#33333a" - properties_row: "#1f1f23" - properties_row_alt: "#26262c" - properties_border: "#43434c" - properties_label: "#a0a0aa" - properties_value: "#cbcbd2" + properties_header: "#2f3a46" + properties_row: "#1f1f22" + properties_row_alt: "#26262a" + properties_border: "#3f3f45" + properties_label: "#9cdcfe" + properties_value: "#cbcbd0" - path_link: "#6496ff" - path_link_hover: "#96c8ff" + path_link: "#4fa6ff" + path_link_hover: "#7cbcff" - header_library: "#96d2a0" - header_reconstruction: "#c8a0ff" + header_library: "#89d185" + header_reconstruction: "#4fa6ff" - feature_volume: "#64ff64" - feature_arpeggio: "#ff9664" - feature_pitch: "#64c8ff" - feature_duty_cycle: "#ffc864" + feature_volume: "#7ee787" + feature_arpeggio: "#f0a35e" + feature_pitch: "#6fb8ff" + feature_duty_cycle: "#e6c26a" - caret_fill: "#8888ff80" - caret_border: "#88bbffff" + caret_fill: "#4fa6ff55" + caret_border: "#7cc4ffff" - graph_bar: "#64c8ff" - waveform_sample: "#64c8ff" - waveform_reconstruction: "#ffc864" - waveform_overlay: "#ffffff20" - spectrum_dim: "#1b1b1f" + graph_bar: "#4fa6ff" + waveform_sample: "#4fa6ff" + waveform_reconstruction: "#e6a34d" + waveform_overlay: "#ffffff22" + spectrum_dim: "#2a2a30" tracker_beat_row: "#ffffff14" - tracker_bar_row: "#ffffff26" + tracker_bar_row: "#ffffff28" pattern_highlight: "#ffffff40" - cell_cursor: "#66bbffa0" - cursor_row: "#ffffff2c" - playback_row: "#64dc6440" + cell_cursor: "#4fa6ffb0" + cursor_row: "#4fa6ff3a" + playback_row: "#5ec46e42" - order_label: "#33333a" - order_master: "#22ccff18" - order_master_divider: "#22ccff24" + order_label: "#2f3a46" + order_master: "#4fa6ff18" + order_master_divider: "#4fa6ff2e" order_column_current: "#ffffff20" - order_column_playing: "#64dc6430" + order_column_playing: "#5ec46e34" - sample_column: "#22ccff2c" - sample_divider: "#22ccff24" + sample_column: "#4fa6ff2c" + sample_divider: "#4fa6ff2e" - channel_muted: "#0a0a0a10" + channel_muted: "#0a0a0a18" - history_future: "#808080ff" - history_channel: "#88bbffff" - history_value: "#d0d0d0ff" - history_separator: "#707070ff" + history_future: "#82828aff" + history_channel: "#6fb8ffff" + history_value: "#d0d0d4ff" + history_separator: "#6c6c74ff" tracker_reference: "#e0c860ff" - tracker_transpose: "#c0c0c0ff" - tracker_volume: "#64dc64ff" - tracker_frame: "#22ccffff" - tracker_row: "#a0a0a0ff" - tracker_order: "#c8d0e0ff" + 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 index 061f3fa54..e74bf639f 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -1,195 +1,195 @@ name: light colors: - ground: "#dcdfe6" - tab_strip: "#d0d4dd" - recess: "#e6e9f0" - surface: "#f8f9fb" + ground: "#c2c7d0" + tab_strip: "#b3b9c4" + recess: "#d6dae2" + surface: "#e7e8ea" surface_alt: "#ffffff" - surface_accent: "#eae4f7" - menu: "#d5dae5" - status_bar: "#d5dae5" + surface_accent: "#d8e4f2" + menu: "#c8ccd6" + status_bar: "#c8ccd6" popup: "#ffffff" frame: "#ffffff" - frame_hovered: "#eef1f6" - frame_active: "#e2e7f0" - border: "#b3bac7" - separator: "#a3abba" + frame_hovered: "#eaeef4" + frame_active: "#dce2ec" + border: "#7f8794" + separator: "#69717e" plot_background: "#ffffff" - well: "#e3e8f2" - - table_header: "#d3d7e5" - table_row: "#fbfbfd" - table_row_alt: "#edeff5" - table_border: "#bac0cd" - - cool: "#2f6fd0" - cool_hover: "#1f5cb8" - cool_active: "#17488f" - cool_muted: "#b7cae9" - - accent: "#6b3fb0" - accent_hover: "#7f52c4" - accent_active: "#55308c" - accent_muted: "#d5c4ee" + well: "#c7ccd4" + + 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: "#7a55b8" - primary_hover: "#8a67c6" - primary_active: "#644497" - primary_muted: "#c1bad0" + primary: "#0b4c8c" + primary_hover: "#1163ac" + primary_active: "#073a6c" + primary_muted: "#a3aab6" on_primary: "#ffffff" - secondary: "#dee2ea" - secondary_hover: "#ced5e1" - secondary_active: "#bcc5d4" - secondary_disabled: "#ebebf0" + secondary: "#d2d7e0" + secondary_hover: "#bfc6d2" + secondary_active: "#aab3c2" + secondary_disabled: "#e3e6ec" - danger: "#bf4646" - danger_hover: "#d05a5a" - danger_active: "#9b3737" + danger: "#a82820" + danger_hover: "#c33a30" + danger_active: "#851f18" on_danger: "#ffffff" - dialog_surface: "#f2f2f7" - dialog_title: "#d7deee" - - channel_pulse1: "#c25a12" - channel_pulse2: "#96790a" - channel_triangle: "#2a6ba4" - channel_noise: "#66646e" - channel_pulse1_soft: "#9c6f45" - channel_pulse2_soft: "#847540" - channel_triangle_soft: "#557c9a" - channel_noise_soft: "#8a8890" - - tab: "#cdd2dd" - tab_hovered: "#c0c8d8" - tab_active: "#e6e9f0" - - selection: "#d5cbec" - selection_hovered: "#c5b8e3" - selection_active: "#b3a4d8" - - scrollbar_hovered: "#9aa2b0" - - button: "#c5cddd" - button_hovered: "#b4bfd4" - button_active: "#a2b0cb" - button_disabled: "#dfe2e9" - - player_surface: "#dbe2f0" - player_border: "#3d7fc4" - player_button: "#cbd4e8" - player_button_hovered: "#b8c5df" - player_button_active: "#a5b6d6" - player_button_disabled: "#e2e6ee" - - text: "#1a1d23" - text_muted: "#535b68" - text_disabled: "#8b93a1" - text_trace: "#41464e" - - contrast: "#14171c" + 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: "#8f97a6" + border_strong: "#5a6270" input_invalid: "#c0504a48" input_warning: "#c0884a48" control: "#ffffff" - control_hovered: "#eaeef6" - control_active: "#dbe2ee" - control_border: "#8f97a6" - - plot_zero_line: "#8a92a0" - plot_grid: "#d7dce5" - plot_axis_text: "#535b68" - plot_border: "#b3bac7" - plot_legend_bg: "#f4f6fa" - - file_wave: "#0d6ea8" - file_library: "#1c7a34" - file_reconstruction: "#4a4ab4" - file_muted: "#7c7c86" - - favorite: "#b07d0a" - favorite_child: "#8d7a45" - - library_generator: "#2c6e3c" - library_group: "#1c6a70" - library_instruction: "#4c4c54" - library_root: "#33333a" - - text_default: "#22262d" - text_inactive: "#7b8391" - text_error: "#c03030" - text_highlight: "#8a5f00" - - button_flat: "#d7dbe4" - button_flat_active: "#aeb6c4" - button_flat_hovered: "#c4cad7" - button_flat_light: "#cdd3de" - - background_default: "#eceff4" - background_dark: "#dcdfe6" + 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: "#e3e7ee" + background_menu: "#d9dde5" background_invalid: "#c0202038" - properties_header: "#d3d7e5" - properties_row: "#fbfbfd" - properties_row_alt: "#edeff5" - properties_border: "#bac0cd" - properties_label: "#3a4f7c" - properties_value: "#272c39" + properties_header: "#c6cbd5" + properties_row: "#ffffff" + properties_row_alt: "#eef1f5" + properties_border: "#8b93a0" + properties_label: "#073a6c" + properties_value: "#161b22" - path_link: "#1f5fd0" - path_link_hover: "#0d3f9c" + path_link: "#0a5aa8" + path_link_hover: "#073a6c" - header_library: "#2c7a44" - header_reconstruction: "#6b3fb0" + header_library: "#1f6e34" + header_reconstruction: "#3a3a9c" - feature_volume: "#1f8038" - feature_arpeggio: "#c05a18" - feature_pitch: "#0d6ea8" - feature_duty_cycle: "#96700a" + feature_volume: "#16702e" + feature_arpeggio: "#a8410a" + feature_pitch: "#0a5aa8" + feature_duty_cycle: "#7a5c00" - caret_fill: "#3a3ac060" - caret_border: "#2a5fb0ff" + caret_fill: "#0b4c8c58" + caret_border: "#073a6cff" - graph_bar: "#1c72ac" - waveform_sample: "#1c72ac" - waveform_reconstruction: "#b07d0a" - waveform_overlay: "#00000018" - spectrum_dim: "#f7f7fa" + graph_bar: "#0a5aa8" + waveform_sample: "#0a5aa8" + waveform_reconstruction: "#c04a10" + waveform_overlay: "#00000020" + spectrum_dim: "#dfe4ec" - tracker_beat_row: "#00000012" - tracker_bar_row: "#00000022" + tracker_beat_row: "#00000016" + tracker_bar_row: "#0000002c" pattern_highlight: "#00000018" - cell_cursor: "#2a7fd090" - cursor_row: "#00000020" - playback_row: "#1f903838" - - order_label: "#d3d7e5" - order_master: "#1c8cc018" - order_master_divider: "#1c8cc028" - order_column_current: "#00000014" - order_column_playing: "#1f903828" - - sample_column: "#1c8cc022" - sample_divider: "#1c8cc028" - - channel_muted: "#9a9aa614" - - history_future: "#8c8c94ff" - history_channel: "#1f5fb0ff" - history_value: "#33333aff" - history_separator: "#9a9aa2ff" - - tracker_reference: "#8a6a00ff" - tracker_transpose: "#4c4c54ff" - tracker_volume: "#1f8038ff" - tracker_frame: "#0d6ea8ff" - tracker_row: "#70707aff" - tracker_order: "#3a4055ff" + 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/theme/default.yaml b/src/sampletones_config/theme/default.yaml index 5c2477ac5..fc61e3668 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 diff --git a/src/sampletones_core/library/data.py b/src/sampletones_core/library/data.py index 73dd6cba6..13f621a00 100644 --- a/src/sampletones_core/library/data.py +++ b/src/sampletones_core/library/data.py @@ -144,7 +144,6 @@ def validate_metadata(metadata: Metadata) -> None: ) 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 " From af7c9b11b82369b01e527db63d241782fd4392f2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 14:24:15 +0200 Subject: [PATCH 023/152] Fixed: unclosable display settings dialog --- .../coordinators/display.py | 30 ++++++--- .../ui/elements/window.py | 27 +++++++- .../ui/panels/dialogs/countdown.py | 5 +- .../ui/panels/dialogs/display_settings.py | 4 -- .../ui/themes/dpg_constants.py | 1 + .../utils/gui/dialogs.py | 5 +- .../coordinators/test_display.py | 61 +++++++++++++++++-- .../ui/elements/test_window.py | 51 ++++++++++++++++ 8 files changed, 163 insertions(+), 21 deletions(-) diff --git a/src/sampletones_application/coordinators/display.py b/src/sampletones_application/coordinators/display.py index ad8d89171..1bc022c1f 100644 --- a/src/sampletones_application/coordinators/display.py +++ b/src/sampletones_application/coordinators/display.py @@ -112,18 +112,27 @@ 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, so a run of unconfirmed changes still returns to the mode last seen as readable. + 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. """ - if self._armed is None: - self._armed = restorable - self._remaining = self._behavior.revert_countdown_seconds - self._countdown.open(self._displayed_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.""" @@ -156,17 +165,24 @@ def _commit(self) -> None: self._close() def _request_close(self) -> None: - """Answers Cancel, Escape and the title bar's close button, asking before losing an edit.""" + """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.reveal() + 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"], ) diff --git a/src/sampletones_application/ui/elements/window.py b/src/sampletones_application/ui/elements/window.py index 09c1fc7c2..4a832b97e 100644 --- a/src/sampletones_application/ui/elements/window.py +++ b/src/sampletones_application/ui/elements/window.py @@ -8,7 +8,8 @@ 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 @@ -21,11 +22,35 @@ 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, diff --git a/src/sampletones_application/ui/panels/dialogs/countdown.py b/src/sampletones_application/ui/panels/dialogs/countdown.py index 726b1288c..5faf0c22b 100644 --- a/src/sampletones_application/ui/panels/dialogs/countdown.py +++ b/src/sampletones_application/ui/panels/dialogs/countdown.py @@ -30,8 +30,9 @@ class GUICountdownWindow(GUIWindow): seconds its owner reports and reports both answers back; the owner runs the clock and decides what each answer means. - Stacking over the dialog that armed it keeps that dialog on screen, so the change is judged - against the window it was made in. + 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__( diff --git a/src/sampletones_application/ui/panels/dialogs/display_settings.py b/src/sampletones_application/ui/panels/dialogs/display_settings.py index 169858f4a..ae802581c 100644 --- a/src/sampletones_application/ui/panels/dialogs/display_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/display_settings.py @@ -89,10 +89,6 @@ def update_view(self, view_model: DisplaySettingsViewModel) -> None: self._view_model = view_model self._render() - def reveal(self) -> None: - """Brings the window back after the title bar's close button hid it.""" - dpg_configure_item(self.tag, show=True) - def create_window(self) -> None: with self.dialog_window( label=self._language_manager["settings.display.title.window_title"], diff --git a/src/sampletones_application/ui/themes/dpg_constants.py b/src/sampletones_application/ui/themes/dpg_constants.py index 75261a4ff..701b0614e 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, diff --git a/src/sampletones_application/utils/gui/dialogs.py b/src/sampletones_application/utils/gui/dialogs.py index 38be0149e..8bd9d58e2 100644 --- a/src/sampletones_application/utils/gui/dialogs.py +++ b/src/sampletones_application/utils/gui/dialogs.py @@ -460,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. @@ -541,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) diff --git a/tests/unit/sampletones_application/coordinators/test_display.py b/tests/unit/sampletones_application/coordinators/test_display.py index e2dafdc18..3776d0c2f 100644 --- a/tests/unit/sampletones_application/coordinators/test_display.py +++ b/tests/unit/sampletones_application/coordinators/test_display.py @@ -14,6 +14,7 @@ WindowMode, ) from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution +from sampletones_shared.types.callback import VoidCallback STUDIO = "studio" DARK = "dark" @@ -132,10 +133,15 @@ def set_max_fps(self, max_fps: int) -> None: 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.reveals = 0 self.on_settings_changed: Any = None self.on_commit: Any = None self.on_cancel: Any = None @@ -147,8 +153,12 @@ def open(self, view_model: DisplaySettingsViewModel) -> None: def update_view(self, view_model: DisplaySettingsViewModel) -> None: self.view_models.append(view_model) - def reveal(self) -> None: - self.reveals += 1 + 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 @@ -190,6 +200,9 @@ def show_confirmation(self, **kwargs: Any) -> None: 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.""" @@ -344,13 +357,26 @@ def test_cancelling_an_untouched_dialog_closes_it_without_asking(self, harness: assert harness.dialogs.confirmations == [] assert not harness.window.visible - def test_cancelling_a_changed_dialog_asks_first_and_stays_open(self, harness: Harness) -> None: + 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.window.reveals == 1 + 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)) @@ -433,8 +459,9 @@ def test_a_second_change_restarts_one_clock_rather_than_starting_another( harness.elapse(4.0) harness.change(harness.settings.with_window(harness.settings.window.with_borderless(True))) - assert harness.countdown.opens == 2 + 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))) @@ -447,6 +474,28 @@ def test_a_run_of_changes_returns_to_the_mode_last_seen_as_readable(self, harnes 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() diff --git a/tests/unit/sampletones_application/ui/elements/test_window.py b/tests/unit/sampletones_application/ui/elements/test_window.py index 1a1979c4b..127570864 100644 --- a/tests/unit/sampletones_application/ui/elements/test_window.py +++ b/tests/unit/sampletones_application/ui/elements/test_window.py @@ -1,4 +1,5 @@ from typing import Any, Final, Iterator, Optional +from unittest.mock import MagicMock, patch import dearpygui.dearpygui as dpg import pytest @@ -6,6 +7,7 @@ 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 @@ -65,3 +67,52 @@ def test_a_dialog_answering_for_no_close_omits_the_button(self, dpg_context: Non 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) From a89a72885e0abdc643bbbf4a73619cc1338eedbf Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 15:03:35 +0200 Subject: [PATCH 024/152] Added: keybinding preferences and live rebinding --- src/sampletones_application/application.py | 20 +++- .../config/managers/application.py | 16 ++- .../config/managers/session.py | 16 ++- .../config/session/application/config.py | 5 + .../config/session/application/shortcuts.py | 24 +++++ .../constants/__init__.py | 0 .../constants/keybindings.py | 3 + .../utils/gui/shortcuts/catalog.py | 5 +- .../utils/gui/shortcuts/ids.py | 2 + .../utils/gui/shortcuts/manager.py | 26 ++++- .../utils/gui/shortcuts/scheme.py | 64 ++++++++++- .../utils/gui/shortcuts/written.py | 11 ++ .../config/managers/test_application.py | 44 ++++++++ .../config/managers/test_session.py | 12 +++ .../session/application/test_shortcuts.py | 44 ++++++++ .../sampletones_application/test_startup.py | 50 ++++++++- .../utils/gui/shortcuts/test_catalog.py | 6 +- .../utils/gui/shortcuts/test_manager.py | 101 +++++++++++++++++- .../utils/gui/shortcuts/test_scheme.py | 60 ++++++++++- .../utils/gui/shortcuts/test_written.py | 17 +++ 20 files changed, 505 insertions(+), 21 deletions(-) create mode 100644 src/sampletones_application/config/session/application/shortcuts.py create mode 100644 src/sampletones_application/constants/__init__.py create mode 100644 src/sampletones_application/constants/keybindings.py create mode 100644 tests/unit/sampletones_application/config/session/application/test_shortcuts.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 7026cbd2f..6ddedbf15 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -112,6 +112,7 @@ 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.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 @@ -189,9 +190,8 @@ def __init__( display_time=self.layout.behavior.ui.status_bar_display_time, ) self.key_router: KeyRouter = KeyRouter() - self._shortcut_source: ShortcutSource = ShortcutSource( - ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).default, - ) + 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, @@ -493,6 +493,11 @@ def _load_layout_config(self) -> LayoutConfig: 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( @@ -596,6 +601,15 @@ def _set_callbacks(self) -> None: 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. diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py index 016a5d85f..fd8f321e5 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Set +from typing import Dict, Set from sampletones_application.config.session.application.config import ApplicationConfig from sampletones_core.audio import AudioDeviceManager, CurrentDevice @@ -100,6 +100,20 @@ def borderless(self) -> bool: 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, str]: + return self.config.shortcuts.overrides + + def set_shortcut_overrides(self, overrides: Dict[str, str]) -> None: + self.config.shortcuts.overrides = overrides + @property def favorites(self) -> Set[Path]: return self.config.favorites.paths diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index ead3e5a59..6788111e7 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -1,5 +1,5 @@ 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 @@ -139,6 +139,12 @@ def set_max_fps(self, max_fps: int) -> None: 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, str]) -> None: + self._config_manager.set_shortcut_overrides(overrides) + def save_config(self) -> None: self._config_manager.save() self._state_manager.save() @@ -199,6 +205,14 @@ def max_fps(self) -> int: 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, str]: + return self._config_manager.shortcut_overrides + @property def advanced_settings(self) -> bool: return self._state_manager.advanced_settings diff --git a/src/sampletones_application/config/session/application/config.py b/src/sampletones_application/config/session/application/config.py index ffb360b0b..35ca10caf 100644 --- a/src/sampletones_application/config/session/application/config.py +++ b/src/sampletones_application/config/session/application/config.py @@ -5,6 +5,7 @@ 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 @@ -35,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/shortcuts.py b/src/sampletones_application/config/session/application/shortcuts.py new file mode 100644 index 000000000..3ff5878a5 --- /dev/null +++ b/src/sampletones_application/config/session/application/shortcuts.py @@ -0,0 +1,24 @@ +from typing import Dict + +from pydantic import BaseModel, Field + +from sampletones_application.constants.keybindings import DEFAULT_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. + """ + + scheme: str = Field( + default=DEFAULT_SCHEME_NAME, + description="The name of the keybinding scheme the application resolves its keys against.", + ) + overrides: Dict[str, str] = Field( + default_factory=dict, + description="The combination each rebound action answers to, keyed by the action's name.", + ) diff --git a/src/sampletones_application/constants/__init__.py b/src/sampletones_application/constants/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/constants/keybindings.py b/src/sampletones_application/constants/keybindings.py new file mode 100644 index 000000000..bc8397c68 --- /dev/null +++ b/src/sampletones_application/constants/keybindings.py @@ -0,0 +1,3 @@ +from typing import Final + +DEFAULT_SCHEME_NAME: Final[str] = "default" diff --git a/src/sampletones_application/utils/gui/shortcuts/catalog.py b/src/sampletones_application/utils/gui/shortcuts/catalog.py index 430a47771..6018b4f3b 100644 --- a/src/sampletones_application/utils/gui/shortcuts/catalog.py +++ b/src/sampletones_application/utils/gui/shortcuts/catalog.py @@ -2,14 +2,13 @@ from dataclasses import dataclass from pathlib import Path -from typing import Dict, Final, Tuple +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 -DEFAULT_SCHEME_NAME: Final[str] = "default" - @dataclass(frozen=True) class ShortcutCatalog: diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 749032afe..9990bb8ab 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -132,6 +132,8 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: DIALOG_CANCEL = ("DialogCancel", ShortcutCategory.DIALOG) +SHORTCUT_IDS_BY_NAME: Final[Dict[str, ShortcutId]] = {shortcut_id.value: shortcut_id for shortcut_id in ShortcutId} + CHANNEL_SHORTCUT_IDS: Final[Dict[GeneratorName, ShortcutId]] = { GeneratorName.PULSE1: ShortcutId.TOGGLE_CHANNEL_PULSE_1, GeneratorName.PULSE2: ShortcutId.TOGGLE_CHANNEL_PULSE_2, diff --git a/src/sampletones_application/utils/gui/shortcuts/manager.py b/src/sampletones_application/utils/gui/shortcuts/manager.py index 5b7ceed64..e3c4c904f 100644 --- a/src/sampletones_application/utils/gui/shortcuts/manager.py +++ b/src/sampletones_application/utils/gui/shortcuts/manager.py @@ -2,6 +2,7 @@ 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, @@ -11,6 +12,7 @@ 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 @@ -32,24 +34,32 @@ def __init__( 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 add_menu_item(self, shortcut_id: ShortcutId, **kwargs: Any) -> None: + """Adds the menu item an action is chosen from, printing the combination that also fires it. + + The item is kept under the action it stands for, so a later rebind reaches the accelerator + already on screen. + """ callback = self._callbacks[shortcut_id] - dpg.add_menu_item( + item: Sender = dpg.add_menu_item( callback=lambda s, a, u: callback(), 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._index_bindings() self._router.register( @@ -58,6 +68,20 @@ def bind_all(self) -> None: active=lambda: True, ) + 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)) + def _index_bindings(self) -> None: """Reads each registered action's combinations from the scheme and indexes them by key.""" self._bindings_by_key = {} diff --git a/src/sampletones_application/utils/gui/shortcuts/scheme.py b/src/sampletones_application/utils/gui/shortcuts/scheme.py index 588dcfb20..3773e3135 100644 --- a/src/sampletones_application/utils/gui/shortcuts/scheme.py +++ b/src/sampletones_application/utils/gui/shortcuts/scheme.py @@ -8,9 +8,14 @@ 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.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 @@ -42,7 +47,10 @@ def claims(self) -> Dict[ShortcutCategory, Dict[KeyCombination, ShortcutId]]: } for shortcut_id, shortcut in self.shortcuts.items(): for combination in shortcut.combinations(): - claims[shortcut_id.category].setdefault(combination, shortcut_id) + claims[shortcut_id.category].setdefault( + combination, + shortcut_id, + ) return claims @@ -64,7 +72,11 @@ 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 action(self, category: ShortcutCategory, event: KeyEvent) -> Optional[ShortcutId]: + def action( + self, + category: ShortcutCategory, + event: KeyEvent, + ) -> Optional[ShortcutId]: """The action of a category a press reaches. Args: @@ -75,7 +87,51 @@ def action(self, category: ShortcutCategory, event: KeyEvent) -> Optional[Shortc Optional[ShortcutId]: The action the category binds the press to, ``None`` while the category leaves it unnamed. """ - return self.claims[category].get(KeyCombination(event.key, event.modifiers)) + return self.claims[category].get( + KeyCombination( + event.key, + event.modifiers, + ) + ) + + def with_overrides(self, overrides: Dict[str, 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: an override stands where this build carries the action, + the key and a category with room for the combination, and the rest are reported while their + actions keep the keys the scheme gives them. + + 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. + """ + scheme = self + for name, combination in overrides.items(): + scheme = scheme.rebound(name, combination) + + return scheme + + def rebound(self, name: str, combination: str) -> ShortcutScheme: + """The scheme with one action answering ``combination``, as it stands for every other entry. + + 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. + """ + 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 + + bindings = {**self.bindings, shortcut_id: self.bindings[shortcut_id].rebound(combination)} + try: + return ShortcutScheme(name=self.name, bindings=bindings) + 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: diff --git a/src/sampletones_application/utils/gui/shortcuts/written.py b/src/sampletones_application/utils/gui/shortcuts/written.py index 4c57d4264..c692c5d74 100644 --- a/src/sampletones_application/utils/gui/shortcuts/written.py +++ b/src/sampletones_application/utils/gui/shortcuts/written.py @@ -21,6 +21,17 @@ class WrittenShortcut(BaseModel, frozen=True): aliases: Tuple[str, ...] = NO_WRITTEN_ALIASES field_transparent: bool = False + def rebound(self, combination: 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. + """ + 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. diff --git a/tests/unit/sampletones_application/config/managers/test_application.py b/tests/unit/sampletones_application/config/managers/test_application.py index e9a7ec246..37641100a 100644 --- a/tests/unit/sampletones_application/config/managers/test_application.py +++ b/tests/unit/sampletones_application/config/managers/test_application.py @@ -73,6 +73,50 @@ def test_set_master_gain_round_trips(self, tmp_path: Path) -> None: assert manager.master_gain == 0.0 +class TestApplicationConfigManagerShortcuts: + 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_a_fresh_configuration_names_the_shipped_scheme(self, tmp_path: Path) -> None: + manager = self._manager(tmp_path) + + 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 = self._manager(tmp_path) + 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 = self._manager(tmp_path) + 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" + with patch( + "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH", + path, + ): + manager = ApplicationConfigManager() + manager.set_shortcut_scheme_name("compact") + manager.set_shortcut_overrides({"Undo": "Ctrl+Alt+U"}) + manager.save() + reloaded = ApplicationConfigManager() + + assert reloaded.shortcut_scheme_name == "compact" + assert reloaded.shortcut_overrides == {"Undo": "Ctrl+Alt+U"} + + 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: diff --git a/tests/unit/sampletones_application/config/managers/test_session.py b/tests/unit/sampletones_application/config/managers/test_session.py index eed411b5e..b2edd4cf1 100644 --- a/tests/unit/sampletones_application/config/managers/test_session.py +++ b/tests/unit/sampletones_application/config/managers/test_session.py @@ -36,6 +36,18 @@ 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) -> None: + session = SessionManager() + session.set_shortcut_scheme_name("compact") + assert session.shortcut_scheme_name == "compact" + + def test_shortcut_overrides_reflect_what_was_set(self) -> None: + session = SessionManager() + 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() 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 000000000..4e2255cff --- /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 DEFAULT_SCHEME_NAME + +REBOUND_UNDO = {"Undo": "Ctrl+Alt+U"} + + +class TestDefaults: + def test_a_fresh_configuration_runs_the_default_scheme(self) -> None: + assert ShortcutsConfig().scheme == DEFAULT_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/test_startup.py b/tests/unit/sampletones_application/test_startup.py index c5cf23309..838f03e72 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -1,19 +1,23 @@ 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.config.managers.session import SessionManager from sampletones_application.logic.history.action import HistoryAction +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.parallelization.background import ( stop_background_workers, ) from sampletones_application.utils.parallelization.thread import SingleThreadExecutor from sampletones_core.reconstructions import Reconstruction +REBOUND_UNDO: Final[Dict[str, str]] = {"Undo": "Ctrl+Alt+U"} + _DPG_DISPLAY_FUNCTIONS = [ "create_context", "create_viewport", @@ -85,6 +89,48 @@ def app() -> Generator[Any, Application, Any]: dpg.destroy_context() +class TestKeybindingPreferences: + """The application runs on the keys the session stores, which is what makes a rebind stick.""" + + @pytest.fixture + def application(self) -> 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_overrides", + new_callable=PropertyMock, + return_value=REBOUND_UNDO, + ) + ) + yield Application() + 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 diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py index 9bee72739..a20390eb9 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py @@ -2,11 +2,9 @@ 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 ( - DEFAULT_SCHEME_NAME, - ShortcutCatalog, -) +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 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 a93abb9c6..8aa68636b 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 @@ -10,6 +10,7 @@ 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, @@ -18,6 +19,8 @@ from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager 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) @@ -35,10 +38,29 @@ def _manager(source: ShortcutSource, shortcut_id: ShortcutId, callback: Mock) -> 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, *, 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_the_combination_the_scheme_gives_an_action_fires_it(self, source: ShortcutSource) -> None: callback = Mock() @@ -73,6 +95,83 @@ def test_an_action_the_scheme_leaves_unassigned_answers_no_press(self, source: S callback.assert_not_called() +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 = _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" + + class TestFieldFocusGate: def test_text_field_keeps_a_plain_space( self, diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py index 2be4637cc..2567e78fb 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -4,11 +4,11 @@ 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.catalog import DEFAULT_SCHEME_NAME 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 @@ -139,6 +139,64 @@ def test_a_modifier_the_combination_omits_leaves_the_press_unnamed(self, shipped assert shipped.action(ShortcutCategory.ORDER, _press("Shift+Left")) is None +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": "F2"}) + + assert scheme.action(ShortcutCategory.APPLICATION, _press("F2")) is ShortcutId.ABOUT_DIALOG + assert scheme.action(ShortcutCategory.SAMPLES, _press("F2")) is ShortcutId.SAMPLES_RENAME_SAMPLE + + 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" diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py index 18f17c160..8017d188b 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py @@ -34,3 +34,20 @@ def test_a_transparent_entry_carries_its_declaration(self) -> None: 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_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 From 7942cba97dd399a16c4e3b8a486aad772324948b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 15:15:24 +0200 Subject: [PATCH 025/152] Documented: keybinding registry and colour ownership --- docs/development/architecture.md | 21 +++++++++++-- docs/development/bugs-and-todos.md | 3 +- docs/development/config-organization.md | 39 ++++++++++++++++++------- docs/development/playback.md | 3 +- 4 files changed, 50 insertions(+), 16 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 24fb9b2db..ac3962cf1 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -148,8 +148,22 @@ The query resolves the focused item to the field behind it. A `dpg.group` report **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. + 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 @@ -158,15 +172,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. @@ -315,6 +330,7 @@ There are two coordinator kinds: |---------|---------| | `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/` | +| `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. 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 | @@ -445,6 +461,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 94a52cb78..1f100e6b1 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -5,8 +5,7 @@ * Interface scale * Tree navigation using keys * Waveform LOD for zooming -* Keybindings options -* Tracker cell shortcuts +* Keybindings editor dialog * Drag and drop * Multiple Reconstruction views * Playing a fragment by clicking on a waveform diff --git a/docs/development/config-organization.md b/docs/development/config-organization.md index be92660b6..0a552dbb5 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, - palettes, 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, palettes, 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,9 @@ 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`, `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. +`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 @@ -51,6 +51,13 @@ is what turns that name into a value. A directory holds one file per palette, na 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 The layout config is shaped like the code that reads it: its directory tree matches the @@ -124,6 +131,7 @@ 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 | @@ -135,6 +143,13 @@ reads its value from the palette in place when it is drawn with. `PaletteCatalog 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. + 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`), so a mismatch between YAML and schema surfaces loudly at startup. @@ -163,9 +178,11 @@ Three load mechanisms serve the three 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). `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. +- **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`. Deployment and calibration each load through a bespoke `.load()` classmethod over the same low-level primitives in `sampletones_shared/utils/serialization.py` — the one diff --git a/docs/development/playback.md b/docs/development/playback.md index b45132377..3397548d9 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -127,7 +127,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 From ed78aa3a30b617b1d4192bb14bcbd4de7be84e6f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 18:36:59 +0200 Subject: [PATCH 026/152] Extended: key vocabulary --- .../utils/gui/keyboard/keys.py | 87 ++++++++++++-- .../utils/gui/keyboard/modifiers.py | 53 ++++++++- .../keybindings/default.yaml | 4 +- .../utils/gui/keyboard/test_combination.py | 37 ++++-- .../utils/gui/keyboard/test_keys.py | 106 +++++++++++++++--- .../utils/gui/keyboard/test_modifiers.py | 105 ++++++++++++++++- 6 files changed, 351 insertions(+), 41 deletions(-) diff --git a/src/sampletones_application/utils/gui/keyboard/keys.py b/src/sampletones_application/utils/gui/keyboard/keys.py index 92c2abcce..f5f7f8ce7 100644 --- a/src/sampletones_application/utils/gui/keyboard/keys.py +++ b/src/sampletones_application/utils/gui/keyboard/keys.py @@ -6,18 +6,27 @@ 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 UNKNOWN_KEY: Final[str] = "?" LETTER_COUNT: Final[int] = 26 DIGIT_COUNT: Final[int] = 10 -FUNCTION_KEY_COUNT: Final[int] = 12 +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) @@ -25,6 +34,7 @@ **LETTER_NAMES, **DIGIT_NAMES, **FUNCTION_KEY_NAMES, + **KEYPAD_DIGIT_NAMES, dpg.mvKey_Escape: "Esc", dpg.mvKey_Return: "Enter", dpg.mvKey_Tab: "Tab", @@ -40,13 +50,70 @@ dpg.mvKey_Down: "Down", dpg.mvKey_Left: "Left", dpg.mvKey_Right: "Right", - dpg.mvKey_Plus: PLUS, - dpg.mvKey_Minus: MINUS, - dpg.mvKey_Add: f"Num{PLUS}", - dpg.mvKey_Subtract: f"Num{MINUS}", + 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", } -KEY_CODES: Final[Dict[str, int]] = {name.casefold(): key for key, name in KEY_DISPLAY_NAMES.items()} +_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)} | { @@ -56,7 +123,7 @@ SIGN_KEYS: Final[Dict[int, str]] = { dpg.mvKey_Minus: MINUS, dpg.mvKey_Subtract: MINUS, - dpg.mvKey_Plus: PLUS, + KEY_PLUS: PLUS, dpg.mvKey_Add: PLUS, } @@ -77,10 +144,12 @@ 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. + 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. + 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. diff --git a/src/sampletones_application/utils/gui/keyboard/modifiers.py b/src/sampletones_application/utils/gui/keyboard/modifiers.py index 2e65085dd..889a8393b 100644 --- a/src/sampletones_application/utils/gui/keyboard/modifiers.py +++ b/src/sampletones_application/utils/gui/keyboard/modifiers.py @@ -1,12 +1,19 @@ -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_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,18 +25,40 @@ 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]] = {modifier.value.casefold(): modifier for modifier in Modifier} +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), } +MODIFIER_KEY_CODES: Final[FrozenSet[int]] = frozenset(key for keys in MODIFIER_KEYS.values() for key in keys) + def capture_modifiers() -> ModifierSet: """The modifiers held at the moment of the call, as DearPyGui reports their keys. @@ -39,10 +68,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_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index c768c9a60..53c4b812c 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -67,8 +67,8 @@ bindings: OrderMoveFrameToStart: {combination: "Alt+Home"} OrderMoveFrameToEnd: {combination: "Alt+End"} OrderAddFrame: {combination: "Ins"} - OrderInsertFrame: {combination: "+", aliases: ["Num+"]} - OrderRemoveFrame: {combination: "-", aliases: ["Num-"]} + OrderInsertFrame: {combination: "Plus", aliases: ["NumPlus", "Shift+Plus"]} + OrderRemoveFrame: {combination: "Minus", aliases: ["NumMinus"]} OrderDuplicateFrame: {combination: "Ctrl+Ins"} OrderClearFrame: {combination: "Shift+Del"} OrderClearCell: {combination: "Del"} diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py index 318f1c22b..0d0d3eb7e 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py @@ -5,7 +5,7 @@ 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_PAGE_DOWN +from sampletones_application.utils.gui.keyboard.keys import KEY_PAGE_DOWN, KEY_PLUS from sampletones_application.utils.gui.keyboard.modifiers import ( ALT, CTRL, @@ -27,9 +27,9 @@ "Alt+Home", "Shift+Del", "Ctrl+Ins", - PLUS, - f"Ctrl{PLUS}{PLUS}", - f"Num{PLUS}", + "Plus", + "Ctrl+Plus", + "NumPlus", "Ctrl+Alt+Shift+Space", ) @@ -106,13 +106,13 @@ class TestCase(BaseRegularTestCase): expected="Ctrl+Shift+Z", ), TestCase( - label="every modifier", + 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 separator as the key", key=dpg.mvKey_Plus, modifiers=CTRL, expected="Ctrl++"), + 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"), ) @@ -150,13 +150,15 @@ class TestCase(BaseRegularTestCase): 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(dpg.mvKey_Plus)), + TestCase(label="the separator alone", text=PLUS, expected=KeyCombination(KEY_PLUS)), TestCase( label="the separator as the key", text="Ctrl++", - expected=KeyCombination(dpg.mvKey_Plus, 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( @@ -167,7 +169,7 @@ class TestCase(BaseRegularTestCase): def test_parse(self, test_case: TestCase) -> None: assert KeyCombination.parse(test_case.text) == test_case.expected - @pytest.mark.parametrize("text", ["Meta+D", "Ctrl+Meta", "Ctrl", ""]) + @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) @@ -176,3 +178,20 @@ def test_a_text_naming_no_key_raises(self, text: str) -> None: 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 index b685801c7..6423ca541 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py @@ -11,8 +11,15 @@ HEX_KEYS, KEY_CODES, KEY_DISPLAY_NAMES, + KEY_LEFT_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, @@ -25,6 +32,8 @@ UNNAMED_KEY = -1 +IMGUI_KEY_BLOCK_START = 512 + class TestKeyDisplay(BaseTestSuite): @dataclass(frozen=True, kw_only=True) @@ -41,14 +50,20 @@ class TestCase(BaseRegularTestCase): TestCase( label="last function key", key=dpg.mvKey_F1 + FUNCTION_KEY_COUNT - 1, - expected="F12", + 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=dpg.mvKey_Plus, expected=PLUS), - TestCase(label="keypad plus", key=dpg.mvKey_Add, expected=f"Num{PLUS}"), - TestCase(label="keypad minus", key=dpg.mvKey_Subtract, expected=f"Num{MINUS}"), + 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( @@ -78,8 +93,16 @@ class TestCase(BaseRegularTestCase): 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=dpg.mvKey_Plus), - TestCase(label="keypad plus", name=f"Num{PLUS}", expected=dpg.mvKey_Add), + 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( @@ -92,7 +115,7 @@ def test_key_code(self, test_case: TestCase) -> None: def test_a_name_the_table_holds_no_key_under_raises(self) -> None: with pytest.raises(KeyError): - key_code("Meta") + key_code("Nonesuch") class TestKeyTable: @@ -102,17 +125,74 @@ def test_every_named_key_reads_back_as_itself(self) -> None: 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(KEY_CODES) == len(KEY_DISPLAY_NAMES) + 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_the_page_keys_sit_among_the_keys_they_are_named_beside(self) -> None: - """DearPyGui's page constants carry stale codes, so the live ones are named directly.""" - assert KEY_PAGE_DOWN == KEY_PAGE_UP + 1 - assert dpg.mvKey_Home == KEY_PAGE_DOWN + 1 + 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) @@ -125,5 +205,5 @@ def test_a_letter_key_enters_the_digit_it_stands_for(self) -> None: 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[dpg.mvKey_Plus] == PLUS + 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 644edd472..c1e7966b5 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,28 @@ +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_RIGHT_SUPER, +) from sampletones_application.utils.gui.keyboard.modifiers import ( ALT, CTRL, CTRL_ALT, CTRL_ALT_SHIFT, CTRL_SHIFT, + MODIFIER_NAMES, NO_MODIFIERS, SHIFT, + SUPER, Modifier, ModifierSet, capture_modifiers, + modifier_display, modifiers_display, ) from tests.suite.base import BaseTestSuite @@ -26,10 +34,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) @@ -47,13 +57,20 @@ 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", + 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( @@ -93,7 +110,7 @@ class TestCase(BaseRegularTestCase): 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", + label="control, alt and shift", modifiers=CTRL_ALT_SHIFT, expected=("Ctrl", "Alt", "Shift"), ), @@ -115,3 +132,85 @@ def test_the_order_a_caller_names_its_modifiers_leaves_the_display_unchanged( "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) From 39b0777ed9fa627a740d030ad74abaaf0de21070 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 18:53:14 +0200 Subject: [PATCH 027/152] Added: shortcut draft over an edited scheme --- .../config/managers/application.py | 9 +- .../config/managers/session.py | 7 +- .../config/session/application/shortcuts.py | 9 +- .../utils/gui/shortcuts/draft.py | 149 +++++++++ .../utils/gui/shortcuts/scheme.py | 139 ++++++++- .../utils/gui/shortcuts/written.py | 7 +- .../utils/gui/shortcuts/test_draft.py | 286 ++++++++++++++++++ .../utils/gui/shortcuts/test_scheme.py | 88 ++++++ .../utils/gui/shortcuts/test_written.py | 6 + 9 files changed, 677 insertions(+), 23 deletions(-) create mode 100644 src/sampletones_application/utils/gui/shortcuts/draft.py create mode 100644 tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py index fd8f321e5..2aad08946 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, Set +from typing import Dict, Optional, Set from sampletones_application.config.session.application.config import ApplicationConfig from sampletones_core.audio import AudioDeviceManager, CurrentDevice @@ -108,10 +108,13 @@ def set_shortcut_scheme_name(self, name: str) -> None: self.config.shortcuts.scheme = name @property - def shortcut_overrides(self) -> Dict[str, str]: + def shortcut_overrides(self) -> Dict[str, Optional[str]]: return self.config.shortcuts.overrides - def set_shortcut_overrides(self, overrides: Dict[str, str]) -> None: + def set_shortcut_overrides( + self, + overrides: Dict[str, Optional[str]], + ) -> None: self.config.shortcuts.overrides = overrides @property diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index 6788111e7..7fb18c2f2 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -142,7 +142,10 @@ def set_borderless(self, borderless: bool) -> None: 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, str]) -> None: + def set_shortcut_overrides( + self, + overrides: Dict[str, Optional[str]], + ) -> None: self._config_manager.set_shortcut_overrides(overrides) def save_config(self) -> None: @@ -210,7 +213,7 @@ def shortcut_scheme_name(self) -> str: return self._config_manager.shortcut_scheme_name @property - def shortcut_overrides(self) -> Dict[str, str]: + def shortcut_overrides(self) -> Dict[str, Optional[str]]: return self._config_manager.shortcut_overrides @property diff --git a/src/sampletones_application/config/session/application/shortcuts.py b/src/sampletones_application/config/session/application/shortcuts.py index 3ff5878a5..42db262ce 100644 --- a/src/sampletones_application/config/session/application/shortcuts.py +++ b/src/sampletones_application/config/session/application/shortcuts.py @@ -1,4 +1,4 @@ -from typing import Dict +from typing import Dict, Optional from pydantic import BaseModel, Field @@ -18,7 +18,10 @@ class ShortcutsConfig(BaseModel): default=DEFAULT_SCHEME_NAME, description="The name of the keybinding scheme the application resolves its keys against.", ) - overrides: Dict[str, str] = Field( + overrides: Dict[str, Optional[str]] = Field( default_factory=dict, - description="The combination each rebound action answers to, keyed by the action's name.", + 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/utils/gui/shortcuts/draft.py b/src/sampletones_application/utils/gui/shortcuts/draft.py new file mode 100644 index 000000000..84527e0c8 --- /dev/null +++ b/src/sampletones_application/utils/gui/shortcuts/draft.py @@ -0,0 +1,149 @@ +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. + """ + 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/scheme.py b/src/sampletones_application/utils/gui/shortcuts/scheme.py index 3773e3135..38f0d439b 100644 --- a/src/sampletones_application/utils/gui/shortcuts/scheme.py +++ b/src/sampletones_application/utils/gui/shortcuts/scheme.py @@ -2,7 +2,7 @@ from functools import cached_property from pathlib import Path -from typing import Dict, List, Optional, Self +from typing import Dict, List, Mapping, Optional, Self from pydantic import BaseModel, model_validator @@ -72,6 +72,26 @@ 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, @@ -87,20 +107,76 @@ def action( Optional[ShortcutId]: The action the category binds the press to, ``None`` while the category leaves it unnamed. """ - return self.claims[category].get( + 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: Dict[str, str]) -> ShortcutScheme: + 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: an override stands where this build carries the action, - the key and a category with room for the combination, and the rest are reported while their - actions keep the keys the scheme gives them. + 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. @@ -108,27 +184,39 @@ def with_overrides(self, overrides: Dict[str, str]) -> ShortcutScheme: Returns: ShortcutScheme: The scheme every action resolves against once the overrides are read. """ - scheme = self - for name, combination in overrides.items(): - scheme = scheme.rebound(name, combination) + if not overrides: + return self - return scheme + 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: str) -> ShortcutScheme: - """The scheme with one action answering ``combination``, as it stands for every other entry. + 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 - bindings = {**self.bindings, shortcut_id: self.bindings[shortcut_id].rebound(combination)} try: - return ShortcutScheme(name=self.name, bindings=bindings) + 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 @@ -151,6 +239,29 @@ def load(cls, path: Path) -> ShortcutScheme: 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: diff --git a/src/sampletones_application/utils/gui/shortcuts/written.py b/src/sampletones_application/utils/gui/shortcuts/written.py index c692c5d74..035b2a8ff 100644 --- a/src/sampletones_application/utils/gui/shortcuts/written.py +++ b/src/sampletones_application/utils/gui/shortcuts/written.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Final, Optional, Tuple from pydantic import BaseModel @@ -21,11 +23,14 @@ class WrittenShortcut(BaseModel, frozen=True): aliases: Tuple[str, ...] = NO_WRITTEN_ALIASES field_transparent: bool = False - def rebound(self, combination: str) -> "WrittenShortcut": + 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, 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 000000000..340fc612c --- /dev/null +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py @@ -0,0 +1,286 @@ +from dataclasses import dataclass +from typing import Dict, Optional + +import pytest + +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +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" + + +@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("F2")) 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 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 + + +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_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py index 2567e78fb..8e716a570 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -20,6 +20,8 @@ SHIPPED_FILE = KEYBINDINGS_DIRECTORY / f"{DEFAULT_SCHEME_NAME}{EXT_FILE_YAML}" +UNNAMED_KEY = -1 + _PARTIAL_SCHEME_FILE = """ name: minimal bindings: @@ -139,6 +141,77 @@ def test_a_modifier_the_combination_omits_leaves_the_press_unnamed(self, shipped 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("F2")) + + assert scheme.claimant(ShortcutCategory.APPLICATION, KeyCombination.parse("F2")) 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"}) @@ -193,6 +266,21 @@ def test_an_override_taking_a_combination_another_category_holds_stands(self, sh assert scheme.action(ShortcutCategory.APPLICATION, _press("F2")) is ShortcutId.ABOUT_DIALOG assert scheme.action(ShortcutCategory.SAMPLES, _press("F2")) is ShortcutId.SAMPLES_RENAME_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 diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py index 8017d188b..96e715835 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_written.py @@ -47,6 +47,12 @@ def test_a_rebound_entry_answers_that_combination_alone(self) -> None: 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") From 1b6634f8f154539e38df85d974e5e2c453149e1e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 19:53:06 +0200 Subject: [PATCH 028/152] Added: keyboard shortcuts dialog box --- src/sampletones_application/application.py | 18 + .../categories/elements/global_.py | 1 + .../categories/elements/settings.py | 127 +++++ .../categories/hierarchy.py | 1 + .../coordinators/keybindings.py | 341 ++++++++++++ .../layout/settings/__init__.py | 2 + .../layout/settings/keybindings.py | 15 + src/sampletones_application/shell.py | 2 + src/sampletones_application/tags/settings.py | 91 ++++ src/sampletones_application/ui/menu.py | 4 + .../ui/panels/dialogs/keybindings.py | 439 +++++++++++++++ .../utils/gui/keyboard/capture.py | 75 +++ .../utils/gui/keyboard/combination.py | 15 +- .../utils/gui/keyboard/keys.py | 19 + .../utils/gui/keyboard/modifiers.py | 31 +- .../utils/gui/shortcuts/draft.py | 9 +- .../utils/gui/shortcuts/ids.py | 7 +- .../view_model/shared/keybindings.py | 58 ++ .../keybindings/default.yaml | 1 + src/sampletones_config/lang/en.yaml | 108 ++++ .../layout/settings/keybindings.yaml | 5 + tests/suite/shortcuts.py | 9 +- .../categories/test_elements.py | 54 ++ .../coordinators/test_keybindings.py | 501 ++++++++++++++++++ .../ui/panels/dialogs/test_keybindings.py | 383 +++++++++++++ .../utils/gui/keyboard/test_capture.py | 249 +++++++++ .../utils/gui/keyboard/test_combination.py | 64 ++- .../utils/gui/keyboard/test_keys.py | 53 ++ .../utils/gui/keyboard/test_modifiers.py | 44 ++ .../utils/gui/shortcuts/test_draft.py | 80 +++ 30 files changed, 2799 insertions(+), 7 deletions(-) create mode 100644 src/sampletones_application/coordinators/keybindings.py create mode 100644 src/sampletones_application/layout/settings/keybindings.py create mode 100644 src/sampletones_application/ui/panels/dialogs/keybindings.py create mode 100644 src/sampletones_application/utils/gui/keyboard/capture.py create mode 100644 src/sampletones_application/view_model/shared/keybindings.py create mode 100644 src/sampletones_config/layout/settings/keybindings.yaml create mode 100644 tests/unit/sampletones_application/categories/test_elements.py create mode 100644 tests/unit/sampletones_application/coordinators/test_keybindings.py create mode 100644 tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py create mode 100644 tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 6ddedbf15..ba1b08f6c 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -13,6 +13,7 @@ from sampletones_application.config.managers.session import SessionManager 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 @@ -93,6 +94,7 @@ 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, ) @@ -257,6 +259,12 @@ def __init__( 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"], @@ -313,6 +321,15 @@ def __init__( 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, @@ -559,6 +576,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: 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, diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 1e1d351df..f73c9eced 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -102,6 +102,7 @@ class MenuElements(AbstractElement): 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/settings.py b/src/sampletones_application/categories/elements/settings.py index a511cbe7c..693191e40 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -22,3 +22,130 @@ 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" + TOGGLE_FOLLOW_PLAYBACK = "toggle_follow_playback" + 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 172ecb90f..50d32efcc 100644 --- a/src/sampletones_application/categories/hierarchy.py +++ b/src/sampletones_application/categories/hierarchy.py @@ -93,4 +93,5 @@ class Panel(StrEnum): # Settings AUDIO = auto() DISPLAY = auto() + KEYBINDINGS = auto() PROPERTIES = auto() diff --git a/src/sampletones_application/coordinators/keybindings.py b/src/sampletones_application/coordinators/keybindings.py new file mode 100644 index 000000000..41894d9d9 --- /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/layout/settings/__init__.py b/src/sampletones_application/layout/settings/__init__.py index aba706933..dcebe4263 100644 --- a/src/sampletones_application/layout/settings/__init__.py +++ b/src/sampletones_application/layout/settings/__init__.py @@ -2,6 +2,7 @@ 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): @@ -15,3 +16,4 @@ class SettingsLayout(BaseModel, extra="forbid", frozen=True): label_width: int audio: AudioSettingsLayout display: DisplaySettingsLayout + keybindings: KeybindingsSettingsLayout diff --git a/src/sampletones_application/layout/settings/keybindings.py b/src/sampletones_application/layout/settings/keybindings.py new file mode 100644 index 000000000..863f0adfc --- /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/shell.py b/src/sampletones_application/shell.py index 4a9aad07a..260fdcc3b 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -95,6 +95,7 @@ class ShortcutBindings: unmute_all_channels: Callback audio_settings: Callback display_settings: Callback + keyboard_settings: Callback toggle_advanced_settings: Callback toggle_fullscreen: Callback about: Callback @@ -236,6 +237,7 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback 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, diff --git a/src/sampletones_application/tags/settings.py b/src/sampletones_application/tags/settings.py index adb190333..e487e2733 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, @@ -136,6 +137,96 @@ "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/menu.py b/src/sampletones_application/ui/menu.py index 82eefceb3..c85b30f55 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -428,6 +428,10 @@ def _create_view_menu(self) -> None: 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)): 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 000000000..bdfc42a1b --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/keybindings.py @@ -0,0 +1,439 @@ +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.general import TAG_GLOBAL_THEME_DIALOG +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.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.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(GUIWindow): + """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._router = key_router + self._shortcuts = shortcut_source + self._dialog_theme = ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG) + self._navigator: Optional[DialogKeyboardNavigator] = None + 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, + ) + + 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() + + for field_tag in ( + TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME, + TAG_SETTINGS_KEYBINDINGS_INPUT_FILTER, + TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT, + ): + self._dialog_theme.bind_to_item(field_tag) + + self._install_capture() + self._render() + self._install_navigation() + + 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 _install_navigation(self) -> None: + """Wires Tab/Enter/Escape navigation over the controls and buttons.""" + self._navigator = DialogKeyboardNavigator( + window_tag=self.tag, + stops=[ + 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, + key_router=self._router, + shortcut_source=self._shortcuts, + ) + self._navigator.install() + + def _teardown(self) -> None: + if self._capture is not None: + self._capture.stop() + self._capture = None + + if self._navigator is not None: + self._navigator.dispose() + self._navigator = None + + 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/utils/gui/keyboard/capture.py b/src/sampletones_application/utils/gui/keyboard/capture.py new file mode 100644 index 000000000..1bbb001ef --- /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 index 3f2954b17..828e9ef8b 100644 --- a/src/sampletones_application/utils/gui/keyboard/combination.py +++ b/src/sampletones_application/utils/gui/keyboard/combination.py @@ -4,7 +4,11 @@ from typing import Final, Set from sampletones_application.utils.gui.keyboard.event import KeyEvent -from sampletones_application.utils.gui.keyboard.keys import key_code, key_display +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, @@ -28,6 +32,15 @@ class KeyCombination: 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. diff --git a/src/sampletones_application/utils/gui/keyboard/keys.py b/src/sampletones_application/utils/gui/keyboard/keys.py index f5f7f8ce7..5042625af 100644 --- a/src/sampletones_application/utils/gui/keyboard/keys.py +++ b/src/sampletones_application/utils/gui/keyboard/keys.py @@ -12,6 +12,10 @@ 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] = "?" @@ -128,6 +132,21 @@ } +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. diff --git a/src/sampletones_application/utils/gui/keyboard/modifiers.py b/src/sampletones_application/utils/gui/keyboard/modifiers.py index 889a8393b..0cab9693b 100644 --- a/src/sampletones_application/utils/gui/keyboard/modifiers.py +++ b/src/sampletones_application/utils/gui/keyboard/modifiers.py @@ -5,6 +5,10 @@ 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 @@ -57,7 +61,32 @@ class Modifier(StrEnum): Modifier.SHIFT: (dpg.mvKey_LShift, dpg.mvKey_RShift), } -MODIFIER_KEY_CODES: Final[FrozenSet[int]] = frozenset(key for keys in MODIFIER_KEYS.values() for key in keys) +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: diff --git a/src/sampletones_application/utils/gui/shortcuts/draft.py b/src/sampletones_application/utils/gui/shortcuts/draft.py index 84527e0c8..94a4f7253 100644 --- a/src/sampletones_application/utils/gui/shortcuts/draft.py +++ b/src/sampletones_application/utils/gui/shortcuts/draft.py @@ -101,8 +101,15 @@ def assign( """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. + 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, diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 9990bb8ab..4250ae293 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -1,5 +1,5 @@ from enum import Enum, StrEnum -from typing import Dict, Final, Self +from typing import Dict, Final, Self, Tuple from sampletones_core.constants.enums import GeneratorName from sampletones_core.trackers.format import TrackerFormat @@ -78,6 +78,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: 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) @@ -134,6 +135,10 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: 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 +) + CHANNEL_SHORTCUT_IDS: Final[Dict[GeneratorName, ShortcutId]] = { GeneratorName.PULSE1: ShortcutId.TOGGLE_CHANNEL_PULSE_1, GeneratorName.PULSE2: ShortcutId.TOGGLE_CHANNEL_PULSE_2, 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 000000000..eb272731b --- /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_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 53c4b812c..2e3dbff25 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -49,6 +49,7 @@ bindings: # view AudioSettings: {combination: "Ctrl+A"} DisplaySettings: {combination: "Ctrl+D"} + KeyboardSettings: {combination: "Ctrl+K"} ToggleAdvancedSettings: {combination: "Ctrl+Shift+A"} ToggleFullscreen: {combination: "F11"} AboutDialog: {combination: ~} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index effb2ff03..add30379b 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -200,6 +200,7 @@ 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" @@ -657,6 +658,113 @@ 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.toggle_follow_playback: "Follow playback" +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/settings/keybindings.yaml b/src/sampletones_config/layout/settings/keybindings.yaml new file mode 100644 index 000000000..165bfc38d --- /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/tests/suite/shortcuts.py b/tests/suite/shortcuts.py index 0dcd14af6..c7cf794cd 100644 --- a/tests/suite/shortcuts.py +++ b/tests/suite/shortcuts.py @@ -7,9 +7,14 @@ @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, read once for the whole run.""" - return ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).default + """The keybinding scheme the build ships as its default.""" + return shipped_catalog().default def shipped_source() -> ShortcutSource: 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 000000000..bcab23595 --- /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/coordinators/test_keybindings.py b/tests/unit/sampletones_application/coordinators/test_keybindings.py new file mode 100644 index 000000000..56ee13d14 --- /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/ui/panels/dialogs/test_keybindings.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_keybindings.py new file mode 100644 index 000000000..1dfe4852b --- /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/utils/gui/keyboard/test_capture.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_capture.py new file mode 100644 index 000000000..ca2ae9fca --- /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 index 0d0d3eb7e..f9fe80ae8 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_combination.py @@ -5,7 +5,11 @@ 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_PAGE_DOWN, KEY_PLUS +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, @@ -125,6 +129,64 @@ 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): diff --git a/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py b/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py index 6423ca541..c1ebf3e64 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_keys.py @@ -12,6 +12,10 @@ 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, @@ -23,6 +27,7 @@ LETTER_COUNT, SIGN_KEYS, UNKNOWN_KEY, + is_named_key, key_code, key_display, ) @@ -79,6 +84,54 @@ def test_a_key_the_table_omits_reads_as_a_placeholder(self) -> None: 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): 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 c1e7966b5..96a617850 100644 --- a/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py +++ b/tests/unit/sampletones_application/utils/gui/keyboard/test_modifiers.py @@ -7,6 +7,10 @@ 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 ( @@ -17,11 +21,13 @@ CTRL_SHIFT, MODIFIER_NAMES, NO_MODIFIERS, + RESERVED_MODIFIER_KEYS, SHIFT, SUPER, Modifier, ModifierSet, capture_modifiers, + is_modifier_key, modifier_display, modifiers_display, ) @@ -96,6 +102,44 @@ def test_both_keys_of_one_modifier_report_it_once( 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): diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py index 340fc612c..835245f74 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py @@ -1,9 +1,16 @@ 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 @@ -12,6 +19,8 @@ FREE_COMBINATION = "Ctrl+Alt+B" +UNNAMED_KEY = -1 + @pytest.fixture def draft(shipped: ShortcutScheme) -> ShortcutDraft: @@ -186,6 +195,68 @@ def test_an_action_given_the_keys_it_already_answers_keeps_them(self, draft: Sho 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) @@ -253,6 +324,15 @@ def test_two_actions_trade_the_combinations_they_held(self, draft: ShortcutDraft 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: From 1f0dfa78a02ae35a774a2d0a4ee48013e1366357 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 20:30:27 +0200 Subject: [PATCH 029/152] Bound: macOS keys + generator keys --- .github/workflows/ci.yml | 2 +- src/sampletones_application/application.py | 23 +- .../config/session/application/shortcuts.py | 7 +- .../constants/keybindings.py | 24 ++- .../coordinators/tabs/main.py | 5 + .../coordinators/tabs/reconstruction.py | 4 + src/sampletones_application/ui/menu.py | 9 +- .../ui/panels/main/reconstructor.py | 13 ++ .../ui/panels/reconstruction/plot.py | 14 ++ .../utils/gui/shortcuts/manager.py | 23 +- .../keybindings/default.yaml | 8 +- src/sampletones_config/keybindings/macos.yaml | 108 ++++++++++ .../session/application/test_shortcuts.py | 6 +- .../constants/__init__.py | 0 .../constants/test_keybindings.py | 96 +++++++++ .../test_application_channels.py | 109 ++++++++++ .../sampletones_application/test_startup.py | 57 ++++- .../ui/panels/main/__init__.py | 0 .../ui/panels/main/test_reconstructor.py | 157 ++++++++++++++ .../ui/panels/reconstruction/test_plot.py | 152 +++++++++++++ .../sampletones_application/ui/test_menu.py | 28 ++- .../utils/gui/shortcuts/test_catalog.py | 51 ++++- .../utils/gui/shortcuts/test_draft.py | 3 +- .../utils/gui/shortcuts/test_manager.py | 49 +++++ .../utils/gui/shortcuts/test_scheme.py | 13 +- .../utils/gui/shortcuts/test_shipped.py | 202 ++++++++++++++++++ 26 files changed, 1126 insertions(+), 37 deletions(-) create mode 100644 src/sampletones_config/keybindings/macos.yaml create mode 100644 tests/unit/sampletones_application/constants/__init__.py create mode 100644 tests/unit/sampletones_application/constants/test_keybindings.py create mode 100644 tests/unit/sampletones_application/test_application_channels.py create mode 100644 tests/unit/sampletones_application/ui/panels/main/__init__.py create mode 100644 tests/unit/sampletones_application/ui/panels/main/test_reconstructor.py create mode 100644 tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff6e85563..506ead4f6 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 diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index ba1b08f6c..38d84c5f4 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -298,6 +298,7 @@ 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( @@ -572,7 +573,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: toggle_autoplay=self._toggle_autoplay, toggle_follow_playback=self._toggle_follow_playback, 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, @@ -1253,6 +1254,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, diff --git a/src/sampletones_application/config/session/application/shortcuts.py b/src/sampletones_application/config/session/application/shortcuts.py index 42db262ce..b70a0391b 100644 --- a/src/sampletones_application/config/session/application/shortcuts.py +++ b/src/sampletones_application/config/session/application/shortcuts.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field -from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME +from sampletones_application.constants.keybindings import platform_scheme_name class ShortcutsConfig(BaseModel): @@ -12,10 +12,13 @@ class ShortcutsConfig(BaseModel): 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=DEFAULT_SCHEME_NAME, + default_factory=platform_scheme_name, description="The name of the keybinding scheme the application resolves its keys against.", ) overrides: Dict[str, Optional[str]] = Field( diff --git a/src/sampletones_application/constants/keybindings.py b/src/sampletones_application/constants/keybindings.py index bc8397c68..656725306 100644 --- a/src/sampletones_application/constants/keybindings.py +++ b/src/sampletones_application/constants/keybindings.py @@ -1,3 +1,25 @@ -from typing import Final +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/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 7157f0327..a84eac4fc 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 1bcd6ff87..d8eb98970 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -615,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 diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index c85b30f55..dcb77d9b0 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -1,4 +1,5 @@ -from typing import Dict, Final, Tuple +from functools import partial +from typing import Callable, Dict, Final, Tuple import dearpygui.dearpygui as dpg @@ -113,6 +114,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 @@ -124,6 +126,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) @@ -388,6 +391,9 @@ def _create_channels_menu(self, state: MenuBarViewModel) -> None: 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, @@ -396,6 +402,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, diff --git a/src/sampletones_application/ui/panels/main/reconstructor.py b/src/sampletones_application/ui/panels/main/reconstructor.py index 287421eea..df579fecb 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 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/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py index b73913a06..617d96cfa 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -229,6 +229,20 @@ 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) diff --git a/src/sampletones_application/utils/gui/shortcuts/manager.py b/src/sampletones_application/utils/gui/shortcuts/manager.py index e3c4c904f..9cb137288 100644 --- a/src/sampletones_application/utils/gui/shortcuts/manager.py +++ b/src/sampletones_application/utils/gui/shortcuts/manager.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Tuple +from typing import Any, Dict, List, Optional, Tuple import dearpygui.dearpygui as dpg @@ -40,15 +40,28 @@ 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 add_menu_item(self, shortcut_id: ShortcutId, **kwargs: Any) -> None: + def add_menu_item( + self, + shortcut_id: ShortcutId, + *, + callback: Optional[Callback] = None, + **kwargs: Any, + ) -> None: """Adds the menu item an action is chosen from, printing the combination that also fires it. The item is kept under the action it stands for, so a later rebind reaches the accelerator - already on screen. + 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. + + 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. """ - callback = self._callbacks[shortcut_id] + chosen = self._callbacks[shortcut_id] if callback is None else callback item: Sender = dpg.add_menu_item( - callback=lambda s, a, u: callback(), + callback=lambda s, a, u: chosen(), shortcut=self._source.display(shortcut_id), **kwargs, ) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 2e3dbff25..7980ebdd3 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -40,10 +40,10 @@ bindings: ToggleAutoplay: {combination: "Ctrl+P"} ToggleFollowPlayback: {combination: ~} ToggleLoopSong: {combination: ~} - ToggleChannelPulse1: {combination: ~} - ToggleChannelPulse2: {combination: ~} - ToggleChannelTriangle: {combination: ~} - ToggleChannelNoise: {combination: ~} + ToggleChannelPulse1: {combination: "F1"} + ToggleChannelPulse2: {combination: "F2"} + ToggleChannelTriangle: {combination: "F3"} + ToggleChannelNoise: {combination: "F4"} UnmuteAllChannels: {combination: ~} # view diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml new file mode 100644 index 000000000..dec177569 --- /dev/null +++ b/src/sampletones_config/keybindings/macos.yaml @@ -0,0 +1,108 @@ +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"} + ToggleFollowPlayback: {combination: ~} + ToggleLoopSong: {combination: ~} + 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/tests/unit/sampletones_application/config/session/application/test_shortcuts.py b/tests/unit/sampletones_application/config/session/application/test_shortcuts.py index 4e2255cff..2ee2f4051 100644 --- a/tests/unit/sampletones_application/config/session/application/test_shortcuts.py +++ b/tests/unit/sampletones_application/config/session/application/test_shortcuts.py @@ -1,13 +1,13 @@ from sampletones_application.config.session.application.config import ApplicationConfig from sampletones_application.config.session.application.shortcuts import ShortcutsConfig -from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME +from sampletones_application.constants.keybindings import platform_scheme_name REBOUND_UNDO = {"Undo": "Ctrl+Alt+U"} class TestDefaults: - def test_a_fresh_configuration_runs_the_default_scheme(self) -> None: - assert ShortcutsConfig().scheme == DEFAULT_SCHEME_NAME + 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 == {} diff --git a/tests/unit/sampletones_application/constants/__init__.py b/tests/unit/sampletones_application/constants/__init__.py new file mode 100644 index 000000000..e69de29bb 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 000000000..563dbb9d9 --- /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/test_application_channels.py b/tests/unit/sampletones_application/test_application_channels.py new file mode 100644 index 000000000..39a46fc0f --- /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_startup.py b/tests/unit/sampletones_application/test_startup.py index 838f03e72..06d1fcd50 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -7,13 +7,20 @@ 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.logic.history.action import HistoryAction -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +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"} @@ -267,3 +274,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/ui/panels/main/__init__.py b/tests/unit/sampletones_application/ui/panels/main/__init__.py new file mode 100644 index 000000000..e69de29bb 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 000000000..ae07e0514 --- /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_plot.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py new file mode 100644 index 000000000..7d3dac572 --- /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/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index 3df3d7d75..d392af366 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -115,11 +115,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 @@ -185,6 +195,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/utils/gui/shortcuts/test_catalog.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py index a20390eb9..4c314e7e5 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py @@ -9,6 +9,7 @@ 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" @@ -25,20 +26,32 @@ def directory(tmp_path: Path) -> Path: class TestLoadCatalog: - def test_every_scheme_in_the_directory_is_indexed_by_name(self, directory: Path) -> None: + 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: + 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: + 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: + 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): @@ -46,23 +59,39 @@ def test_a_scheme_named_apart_from_its_file_raises(self, directory: Path) -> Non class TestSelectScheme: - def test_a_known_name_selects_that_scheme(self, directory: Path) -> None: + 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: + 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: + 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: - """Loading is what proves every action is answered and no category holds a clash.""" - assert DEFAULT_SCHEME_NAME in ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).names + assert DEFAULT_SCHEME_NAME in SHIPPED_SCHEME_NAMES - def test_the_shipped_scheme_answers_every_action(self) -> None: + @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.default.bindings) == set(ShortcutId) + 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 index 835245f74..183e31beb 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_draft.py @@ -18,6 +18,7 @@ from tests.suite.case import BaseRegularTestCase FREE_COMBINATION = "Ctrl+Alt+B" +TABLE_COMBINATION = "Del" UNNAMED_KEY = -1 @@ -103,7 +104,7 @@ def test_a_combination_no_action_of_the_category_holds_is_free(self, draft: Shor 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("F2")) is 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.""" 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 8aa68636b..f40f0a709 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py @@ -171,6 +171,55 @@ def test_a_rebind_prints_the_keys_now_in_place( 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( diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py index 8e716a570..f8b3578dd 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -20,6 +20,8 @@ SHIPPED_FILE = KEYBINDINGS_DIRECTORY / f"{DEFAULT_SCHEME_NAME}{EXT_FILE_YAML}" +TABLE_COMBINATION = "Del" + UNNAMED_KEY = -1 _PARTIAL_SCHEME_FILE = """ @@ -184,9 +186,10 @@ def test_a_combination_the_category_already_answers_raises(self, shipped: Shortc 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("F2")) + scheme = shipped.with_binding(ShortcutId.ABOUT_DIALOG, KeyCombination.parse(TABLE_COMBINATION)) + combination = KeyCombination.parse(TABLE_COMBINATION) - assert scheme.claimant(ShortcutCategory.APPLICATION, KeyCombination.parse("F2")) is ShortcutId.ABOUT_DIALOG + 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): @@ -261,10 +264,10 @@ def test_an_override_its_category_already_answers_is_left_out(self, shipped: Sho 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": "F2"}) + scheme = shipped.with_overrides({"AboutDialog": TABLE_COMBINATION}) - assert scheme.action(ShortcutCategory.APPLICATION, _press("F2")) is ShortcutId.ABOUT_DIALOG - assert scheme.action(ShortcutCategory.SAMPLES, _press("F2")) is ShortcutId.SAMPLES_RENAME_SAMPLE + 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}) diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py index e6a8bf386..71493710a 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py @@ -1,7 +1,17 @@ +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" @@ -12,6 +22,18 @@ def _press(text: str) -> KeyEvent: 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.""" @@ -37,3 +59,183 @@ def test_duplicate_frame_answers_its_press_in_the_order_table(self, shipped: Sho 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 + ) From d0746c090e68ea8e8882fe5b16a9d948cad2cabe Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 21:29:42 +0200 Subject: [PATCH 030/152] Documented: keyboard shortcuts dialog --- docs/development/architecture.md | 4 + docs/development/bugs-and-todos.md | 1 - docs/development/config-organization.md | 5 ++ docs/guide/interface.md | 62 +++++++++------ docs/guide/sequencer.md | 3 +- src/sampletones_config/palettes/light.yaml | 2 +- .../config/managers/test_application.py | 75 +++++++++++++++++++ 7 files changed, 125 insertions(+), 27 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index ac3962cf1..8dea5ab97 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -154,6 +154,10 @@ The split is that **the combination is data and the category is code**: which ke 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 diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 1f100e6b1..85b9863f2 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -5,7 +5,6 @@ * Interface scale * Tree navigation using keys * Waveform LOD for zooming -* Keybindings editor dialog * Drag and drop * Multiple Reconstruction views * Playing a fragment by clicking on a waveform diff --git a/docs/development/config-organization.md b/docs/development/config-organization.md index 0a552dbb5..fb4df14cb 100644 --- a/docs/development/config-organization.md +++ b/docs/development/config-organization.md @@ -150,6 +150,11 @@ action within a category, so a scheme in use resolves any press its category own 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`), so a mismatch between YAML and schema surfaces loudly at startup. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index d9fb86d3b..af07b27e7 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 087066fa0..5f497f3ad 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -72,7 +72,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/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index e74bf639f..355c44f4d 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -16,7 +16,7 @@ colors: border: "#7f8794" separator: "#69717e" plot_background: "#ffffff" - well: "#c7ccd4" + well: "#eff5ff" table_header: "#c6cbd5" table_row: "#ffffff" diff --git a/tests/unit/sampletones_application/config/managers/test_application.py b/tests/unit/sampletones_application/config/managers/test_application.py index 37641100a..4340d643f 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,6 +8,10 @@ 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, +) class TestApplicationConfigManagerRecovery: @@ -117,6 +122,76 @@ def test_the_preferences_reach_the_file_the_session_is_saved_to(self, tmp_path: assert reloaded.shortcut_overrides == {"Undo": "Ctrl+Alt+U"} +class TestApplicationConfigManagerPlatformScheme: + """The keyboard a Mac opens on, decided when the configuration is created.""" + + @staticmethod + def _manager(path: Path) -> ApplicationConfigManager: + with patch( + "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH", + path, + ): + return ApplicationConfigManager() + + 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 = self._manager(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 = self._manager(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 self._manager(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") + with patch( + "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH", + path, + ): + ApplicationConfigManager().save() + monkeypatch.setattr(platform, "system", lambda: "Linux") + reloaded = ApplicationConfigManager() + + assert yaml.safe_load(path.read_text())["shortcuts"]["scheme"] == MACOS_SCHEME_NAME + assert reloaded.shortcut_scheme_name == MACOS_SCHEME_NAME + + 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: From 9c23cc22ed88415c0d978dedcdc605779aef8ead Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 22:09:07 +0200 Subject: [PATCH 031/152] Fixed: sequencer keys reaching from other tabs and stale application metadata --- CHANGELOG.md | 4 + docs/development/architecture.md | 4 +- src/sampletones_application/application.py | 14 ++- .../config/managers/application.py | 7 ++ .../coordinators/tabs/sequencer.py | 6 +- .../ui/panels/sequencer/order.py | 15 ++- .../ui/panels/sequencer/samples.py | 11 +- .../ui/panels/sequencer/tracker.py | 15 ++- .../utils/gui/keyboard/__init__.py | 2 + .../config/managers/test_application.py | 32 ++++++ .../sampletones_application/test_startup.py | 33 ++++-- .../panels/sequencer/test_panel_tab_gate.py | 106 ++++++++++++++++++ 12 files changed, 226 insertions(+), 23 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_panel_tab_gate.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ef6992210..51593f8dd 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/docs/development/architecture.md b/docs/development/architecture.md index 8dea5ab97..005bdeb5f 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -137,11 +137,13 @@ 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. diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 38d84c5f4..95d5bb746 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -432,6 +432,7 @@ def __init__( 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, @@ -675,9 +676,18 @@ def _build_initial_menu_state(self) -> MenuBarViewModel: 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( @@ -1244,7 +1254,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() diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py index 2aad08946..a58a86280 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -4,6 +4,7 @@ from sampletones_application.config.session.application.config import ApplicationConfig from sampletones_core.audio import AudioDeviceManager, CurrentDevice from sampletones_core.constants.audio import BufferSize +from sampletones_core.data.metadata import Metadata from sampletones_core.paths import APPLICATION_CONFIG_PATH from sampletones_shared.logger import logger from sampletones_shared.utils.serialization import load_yaml, save_yaml_atomic @@ -42,6 +43,12 @@ def _load(self) -> ApplicationConfig: 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()) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 97c02d19c..84bffabe5 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -74,7 +74,7 @@ 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, @@ -121,6 +121,7 @@ def __init__( history: HistoryManager, original_audio_locator: OriginalAudioLocator, *, + tab_active: ActivePredicate, layout: SequencerTabParameters, language_manager: LanguageManager, dialogs: DialogsRenderer, @@ -208,6 +209,7 @@ def __init__( 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( @@ -224,6 +226,7 @@ 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( @@ -231,6 +234,7 @@ def __init__( 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( diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 34e9aa9dd..ecf4748ae 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -55,6 +55,7 @@ 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, ) @@ -118,12 +119,14 @@ 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 @@ -936,13 +939,15 @@ def _add_move_item( ) 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. diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index 5ce1b7ac4..341b56c13 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -25,6 +25,7 @@ from sampletones_application.utils.gui.frame import FrameCallbackManager from sampletones_application.utils.gui.keyboard import ( PRIORITY_PANEL, + ActivePredicate, KeyEvent, KeyRouter, ) @@ -57,12 +58,14 @@ 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) @@ -334,10 +337,14 @@ 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 diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 97a4ac712..59646f5b3 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -61,6 +61,7 @@ from sampletones_application.utils.gui.dpg import dpg_delete_children from sampletones_application.utils.gui.keyboard import ( PRIORITY_PANEL, + ActivePredicate, KeyEvent, KeyRouter, ) @@ -113,12 +114,14 @@ def __init__( 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 @@ -1171,13 +1174,15 @@ 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. diff --git a/src/sampletones_application/utils/gui/keyboard/__init__.py b/src/sampletones_application/utils/gui/keyboard/__init__.py index a4fc92e25..1d6e00ca7 100644 --- a/src/sampletones_application/utils/gui/keyboard/__init__.py +++ b/src/sampletones_application/utils/gui/keyboard/__init__.py @@ -4,6 +4,7 @@ PRIORITY_MODAL, PRIORITY_PANEL, PRIORITY_SHORTCUT, + ActivePredicate, KeyRouter, ModalKeyHandler, ) @@ -12,6 +13,7 @@ "PRIORITY_MODAL", "PRIORITY_PANEL", "PRIORITY_SHORTCUT", + "ActivePredicate", "KeyCombination", "KeyEvent", "KeyRouter", diff --git a/tests/unit/sampletones_application/config/managers/test_application.py b/tests/unit/sampletones_application/config/managers/test_application.py index 4340d643f..495810cdc 100644 --- a/tests/unit/sampletones_application/config/managers/test_application.py +++ b/tests/unit/sampletones_application/config/managers/test_application.py @@ -12,6 +12,7 @@ DEFAULT_SCHEME_NAME, MACOS_SCHEME_NAME, ) +from sampletones_core.data.metadata import Metadata class TestApplicationConfigManagerRecovery: @@ -192,6 +193,37 @@ def test_the_platform_decides_once_and_the_file_decides_after( 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"]}})) + with patch( + "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH", + path, + ): + manager = ApplicationConfigManager() + 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"}})) + with patch( + "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH", + path, + ): + ApplicationConfigManager().save() + reloaded = ApplicationConfigManager() + + 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: diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 06d1fcd50..5b3f4a6a4 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -65,6 +65,25 @@ def _display_patches() -> List[Any]: return display_patches +def _profile_patches(directory: Path) -> List[Any]: + """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 the user's configuration, + so a suite that reads the machine's own profile answers for whatever that machine prefers. + Pointing both files at a directory per test is what holds a run to the shipped defaults. + """ + return [ + patch( + "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH", + directory / "config.yaml", + ), + patch( + "sampletones_application.config.managers.state.APPLICATION_STATE_PATH", + directory / "state.yaml", + ), + ] + + class TestGUIStartup: @pytest.fixture(autouse=True) def dpg_context(self) -> Generator[Any, Application, Any]: @@ -74,20 +93,20 @@ 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(): + for p in (*_display_patches(), *_profile_patches(tmp_path)): stack.enter_context(p) Application() @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(): + for p in (*_display_patches(), *_profile_patches(tmp_path)): stack.enter_context(p) yield Application() finally: @@ -100,12 +119,12 @@ class TestKeybindingPreferences: """The application runs on the keys the session stores, which is what makes a rebind stick.""" @pytest.fixture - def application(self) -> Generator[Any, Application, Any]: + 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) + for patched in (*_display_patches(), *_profile_patches(tmp_path)): + stack.enter_context(patched) stack.enter_context( patch.object( 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 000000000..1dcd8ac5a --- /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 From 7559c503a356cdfd4f074d5d437a2c86ee9e8c05 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 22:47:03 +0200 Subject: [PATCH 032/152] Refactored: user profile --- docs/development/architecture.md | 2 + src/sampletones/run.py | 2 + src/sampletones_application/application.py | 4 +- .../config/managers/application.py | 25 +-- .../config/managers/session.py | 7 +- .../config/managers/state.py | 20 +- src/sampletones_application/config/profile.py | 27 +++ .../config/managers/test_application.py | 141 ++++-------- .../config/managers/test_session.py | 128 +++++------ .../config/managers/test_state.py | 202 +++++++++--------- .../config/test_profile.py | 25 +++ .../sampletones_application/test_startup.py | 42 ++-- 12 files changed, 304 insertions(+), 321 deletions(-) create mode 100644 src/sampletones_application/config/profile.py create mode 100644 tests/unit/sampletones_application/config/test_profile.py diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 005bdeb5f..772aef4ee 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: diff --git a/src/sampletones/run.py b/src/sampletones/run.py index 758d65d7a..a99e233be 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_application/application.py b/src/sampletones_application/application.py index 95d5bb746..b46542721 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -11,6 +11,7 @@ ) 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.coordinators.config import ConfigCoordinator from sampletones_application.coordinators.display import DisplayCoordinator from sampletones_application.coordinators.keybindings import KeybindingsCoordinator @@ -170,6 +171,7 @@ class Application: def __init__( self, + profile: UserProfile, config_path: Optional[Path] = None, library_path: Optional[Path] = None, reconstruction_path: Optional[Path] = None, @@ -178,7 +180,7 @@ def __init__( self.deployment: DeploymentConfig = DeploymentConfig.load(DEPLOYMENT_CONFIG_PATH) self._set_logging_level() - self.session_manager = SessionManager() + 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), diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py index a58a86280..3ab0a5f6a 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -5,7 +5,6 @@ from sampletones_core.audio import AudioDeviceManager, CurrentDevice from sampletones_core.constants.audio import BufferSize from sampletones_core.data.metadata import Metadata -from sampletones_core.paths import APPLICATION_CONFIG_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 to_path @@ -13,22 +12,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) @@ -36,7 +31,7 @@ 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}" ) @@ -50,12 +45,12 @@ def save(self) -> None: """ 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: diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index 7fb18c2f2..25d43a8f2 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -4,6 +4,7 @@ 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_core.audio import AudioDeviceManager, CurrentDevice @@ -11,9 +12,9 @@ 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: diff --git a/src/sampletones_application/config/managers/state.py b/src/sampletones_application/config/managers/state.py index 8bcab291d..9d12c5f2e 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 000000000..b6fcd9c7f --- /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/tests/unit/sampletones_application/config/managers/test_application.py b/tests/unit/sampletones_application/config/managers/test_application.py index 495810cdc..1c6403c21 100644 --- a/tests/unit/sampletones_application/config/managers/test_application.py +++ b/tests/unit/sampletones_application/config/managers/test_application.py @@ -19,11 +19,8 @@ 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 @@ -31,48 +28,37 @@ 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 = ApplicationConfigManager(tmp_path / "config.yaml") manager.set_follow_playback(False) assert manager.follow_playback is False manager.set_follow_playback(True) assert manager.follow_playback is True 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) @@ -80,28 +66,20 @@ def test_set_master_gain_round_trips(self, tmp_path: Path) -> None: class TestApplicationConfigManagerShortcuts: - 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_a_fresh_configuration_names_the_shipped_scheme(self, tmp_path: Path) -> None: - manager = self._manager(tmp_path) + 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 = self._manager(tmp_path) + 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 = self._manager(tmp_path) + manager = ApplicationConfigManager(tmp_path / "config.yaml") manager.set_shortcut_overrides({"Undo": "Ctrl+Alt+U"}) assert manager.shortcut_overrides == {"Undo": "Ctrl+Alt+U"} @@ -109,15 +87,12 @@ def test_set_shortcut_overrides_round_trips(self, tmp_path: Path) -> None: 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" - with patch( - "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH", - path, - ): - manager = ApplicationConfigManager() - manager.set_shortcut_scheme_name("compact") - manager.set_shortcut_overrides({"Undo": "Ctrl+Alt+U"}) - manager.save() - reloaded = ApplicationConfigManager() + 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"} @@ -126,14 +101,6 @@ def test_the_preferences_reach_the_file_the_session_is_saved_to(self, tmp_path: class TestApplicationConfigManagerPlatformScheme: """The keyboard a Mac opens on, decided when the configuration is created.""" - @staticmethod - def _manager(path: Path) -> ApplicationConfigManager: - with patch( - "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH", - path, - ): - return ApplicationConfigManager() - def test_a_fresh_configuration_on_a_mac_names_the_mac_scheme( self, tmp_path: Path, @@ -141,7 +108,7 @@ def test_a_fresh_configuration_on_a_mac_names_the_mac_scheme( ) -> None: monkeypatch.setattr(platform, "system", lambda: "Darwin") - manager = self._manager(tmp_path / "config.yaml") + manager = ApplicationConfigManager(tmp_path / "config.yaml") assert manager.shortcut_scheme_name == MACOS_SCHEME_NAME @@ -155,7 +122,7 @@ def test_a_configuration_carrying_no_scheme_yet_takes_the_platform_one( path = tmp_path / "config.yaml" path.write_text(yaml.safe_dump({"favorites": {"paths": ["/x/y"]}})) - manager = self._manager(path) + manager = ApplicationConfigManager(path) assert manager.shortcut_scheme_name == MACOS_SCHEME_NAME assert Path("/x/y") in manager.favorites @@ -170,7 +137,7 @@ def test_a_stored_scheme_stands_on_a_mac( path = tmp_path / "config.yaml" path.write_text(yaml.safe_dump({"shortcuts": {"scheme": DEFAULT_SCHEME_NAME}})) - assert self._manager(path).shortcut_scheme_name == DEFAULT_SCHEME_NAME + assert ApplicationConfigManager(path).shortcut_scheme_name == DEFAULT_SCHEME_NAME def test_the_platform_decides_once_and_the_file_decides_after( self, @@ -181,13 +148,10 @@ def test_the_platform_decides_once_and_the_file_decides_after( is what every run after reads, so the platform is asked one time.""" path = tmp_path / "config.yaml" monkeypatch.setattr(platform, "system", lambda: "Darwin") - with patch( - "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH", - path, - ): - ApplicationConfigManager().save() - monkeypatch.setattr(platform, "system", lambda: "Linux") - reloaded = ApplicationConfigManager() + 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 @@ -199,13 +163,10 @@ class TestApplicationConfigManagerMetadata: 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"]}})) - with patch( - "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH", - path, - ): - manager = ApplicationConfigManager() - assert manager.config.metadata.version == "0.0.1" - manager.save() + 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() @@ -213,12 +174,9 @@ 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"}})) - with patch( - "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH", - path, - ): - ApplicationConfigManager().save() - reloaded = ApplicationConfigManager() + ApplicationConfigManager(path).save() + + reloaded = ApplicationConfigManager(path) assert reloaded.palette_name == "ink" assert reloaded.config.metadata == Metadata.default() @@ -228,32 +186,23 @@ 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(tmp_path / "config.yaml") + with ( + patch( + "sampletones_application.config.managers.application.save_yaml_atomic", + side_effect=RuntimeError("unexpected"), + ), + pytest.raises(RuntimeError), ): - manager = ApplicationConfigManager() - with ( - patch( - "sampletones_application.config.managers.application.save_yaml_atomic", - side_effect=RuntimeError("unexpected"), - ), - pytest.raises(RuntimeError), - ): - manager.save() + 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 b2edd4cf1..13c1aff01 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 @@ -37,129 +46,106 @@ def test_set_window_state_non_fullscreen_updates_dimensions(self) -> None: class TestSessionManagerKeybindings: - def test_shortcut_scheme_name_reflects_what_was_set(self) -> None: - session = SessionManager() + 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) -> None: - session = SessionManager() + 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 c02fe60b6..21aae3e8a 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,71 +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(tmp_path / "state.yaml") + with ( + patch( + "sampletones_application.config.managers.state.save_yaml_atomic", + side_effect=RuntimeError("unexpected"), + ), + pytest.raises(RuntimeError), ): - manager = ApplicationStateManager() - with ( - patch( - "sampletones_application.config.managers.state.save_yaml_atomic", - side_effect=RuntimeError("unexpected"), - ), - pytest.raises(RuntimeError), - ): - manager.save() + manager.save() 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 000000000..f1d3e5aad --- /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/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 5b3f4a6a4..9b753f56f 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -9,6 +9,7 @@ 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.logic.history.action import HistoryAction from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.utils.gui.keyboard.modifiers import NO_MODIFIERS @@ -65,23 +66,17 @@ def _display_patches() -> List[Any]: return display_patches -def _profile_patches(directory: Path) -> List[Any]: +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 the user's configuration, - so a suite that reads the machine's own profile answers for whatever that machine prefers. - Pointing both files at a directory per test is what holds a run to the shipped defaults. + 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 [ - patch( - "sampletones_application.config.managers.application.APPLICATION_CONFIG_PATH", - directory / "config.yaml", - ), - patch( - "sampletones_application.config.managers.state.APPLICATION_STATE_PATH", - directory / "state.yaml", - ), - ] + return UserProfile( + config=directory / "config.yaml", + state=directory / "state.yaml", + ) class TestGUIStartup: @@ -95,10 +90,10 @@ def dpg_context(self) -> Generator[Any, Application, Any]: def test_initialises_without_error(self, tmp_path: Path) -> None: with ExitStack() as stack: - for p in (*_display_patches(), *_profile_patches(tmp_path)): - stack.enter_context(p) + for display_patch in _display_patches(): + stack.enter_context(display_patch) - Application() + Application(profile=_profile(tmp_path)) @pytest.fixture @@ -106,9 +101,10 @@ def app(tmp_path: Path) -> Generator[Any, Application, Any]: dpg.create_context() try: with ExitStack() as stack: - for p in (*_display_patches(), *_profile_patches(tmp_path)): - 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() @@ -123,8 +119,8 @@ def application(self, tmp_path: Path) -> Generator[Any, Application, Any]: dpg.create_context() try: with ExitStack() as stack: - for patched in (*_display_patches(), *_profile_patches(tmp_path)): - stack.enter_context(patched) + for display_patch in _display_patches(): + stack.enter_context(display_patch) stack.enter_context( patch.object( @@ -134,7 +130,7 @@ def application(self, tmp_path: Path) -> Generator[Any, Application, Any]: return_value=REBOUND_UNDO, ) ) - yield Application() + yield Application(profile=_profile(tmp_path)) finally: stop_background_workers() SingleThreadExecutor.reset_shutdown() From ae303bcbb48e12a4b556dfc00310bc15bec93099 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 23:11:50 +0200 Subject: [PATCH 033/152] Refactored: dialog windows onto a shared keyboard base --- .../ui/elements/dialog.py | 83 +++++++++++++++++++ .../ui/panels/dialogs/audio_settings.py | 39 ++------- .../ui/panels/dialogs/countdown.py | 47 ++++------- .../ui/panels/dialogs/display_settings.py | 61 +++++--------- .../ui/panels/dialogs/keybindings.py | 59 +++++-------- .../ui/panels/dialogs/project_properties.py | 39 ++------- 6 files changed, 158 insertions(+), 170 deletions(-) create mode 100644 src/sampletones_application/ui/elements/dialog.py diff --git a/src/sampletones_application/ui/elements/dialog.py b/src/sampletones_application/ui/elements/dialog.py new file mode 100644 index 000000000..4b29db792 --- /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/panels/dialogs/audio_settings.py b/src/sampletones_application/ui/panels/dialogs/audio_settings.py index a2fa73f9d..c45b76d67 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,14 +15,10 @@ 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 @@ -44,7 +39,7 @@ from sampletones_shared.utils.color import blend -class GUIAudioSettingsWindow(GUIWindow): +class GUIAudioSettingsWindow(GUIDialogWindow): def __init__( self, *, @@ -55,10 +50,6 @@ def __init__( ) -> None: self._language_manager = language_manager self._layout = layout - self._router = key_router - self._shortcuts = shortcut_source - 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 @@ -79,6 +70,8 @@ def __init__( tag=TAG_SETTINGS_AUDIO_WINDOW, 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: @@ -116,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), @@ -139,15 +126,7 @@ def _install_navigation(self) -> None: FocusStop.button(TAG_SETTINGS_AUDIO_BUTTON_APPLY, self._commit), ], on_escape=self.hide, - key_router=self._router, - shortcut_source=self._shortcuts, ) - 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): diff --git a/src/sampletones_application/ui/panels/dialogs/countdown.py b/src/sampletones_application/ui/panels/dialogs/countdown.py index 5faf0c22b..593c25e6d 100644 --- a/src/sampletones_application/ui/panels/dialogs/countdown.py +++ b/src/sampletones_application/ui/panels/dialogs/countdown.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Any, Final, Optional import dearpygui.dearpygui as dpg @@ -10,19 +10,18 @@ TAG_SETTINGS_DISPLAY_WINDOW_COUNTDOWN, ) from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.window import GUIWindow +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 ( - DialogKeyboardNavigator, - FocusStop, -) +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(GUIWindow): +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 @@ -52,9 +51,6 @@ def __init__( self._remaining_format = remaining_format self._keep_label = keep_label self._revert_label = revert_label - self._router = key_router - self._shortcuts = shortcut_source - self._navigator: Optional[DialogKeyboardNavigator] = None self._remaining = 0 self.on_keep: Optional[VoidCallback] = None @@ -64,6 +60,8 @@ def __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: @@ -92,7 +90,14 @@ def create_window(self) -> None: dpg.add_separator() self._create_action_buttons() - self._install_navigation() + 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) @@ -112,26 +117,6 @@ def _create_action_buttons(self) -> None: width=-1, ) - def _install_navigation(self) -> None: - """Wires Tab/Enter/Escape over the two answers, with Escape reading as reverting.""" - self._navigator = DialogKeyboardNavigator( - window_tag=self.tag, - stops=[ - FocusStop.button(TAG_SETTINGS_DISPLAY_BUTTON_REVERT, self._revert), - FocusStop.button(TAG_SETTINGS_DISPLAY_BUTTON_KEEP, self._keep), - ], - on_escape=self._revert, - key_router=self._router, - shortcut_source=self._shortcuts, - initial_index=1, - ) - self._navigator.install() - - def _teardown(self) -> None: - if self._navigator is not None: - self._navigator.dispose() - self._navigator = None - def _keep(self) -> None: self.call(self.on_keep) diff --git a/src/sampletones_application/ui/panels/dialogs/display_settings.py b/src/sampletones_application/ui/panels/dialogs/display_settings.py index ae802581c..e6aefb219 100644 --- a/src/sampletones_application/ui/panels/dialogs/display_settings.py +++ b/src/sampletones_application/ui/panels/dialogs/display_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_DISPLAY_BUTTON_CANCEL, TAG_SETTINGS_DISPLAY_BUTTON_OK, @@ -17,14 +16,10 @@ 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.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 @@ -39,7 +34,7 @@ SettingsCallback = Callable[[DisplaySettings], None] -class GUIDisplaySettingsWindow(GUIWindow): +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 @@ -58,10 +53,6 @@ def __init__( ) -> None: self._language_manager = language_manager self._layout = layout - self._router = key_router - self._shortcuts = shortcut_source - self._dialog_theme = ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG) - self._navigator: Optional[DialogKeyboardNavigator] = None self._view_model: Optional[DisplaySettingsViewModel] = None self.on_settings_changed: Optional[SettingsCallback] = None @@ -74,6 +65,8 @@ def __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: @@ -102,15 +95,26 @@ def create_window(self) -> None: dpg.add_separator() self._create_action_buttons() - for combo_tag in ( + self._bind_dialog_theme( TAG_SETTINGS_DISPLAY_COMBO_RESOLUTION, TAG_SETTINGS_DISPLAY_COMBO_FRAME_RATE, TAG_SETTINGS_DISPLAY_COMBO_PALETTE, - ): - self._dialog_theme.bind_to_item(combo_tag) + ) self._render() - self._install_navigation() + 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() @@ -191,31 +195,6 @@ def _create_action_buttons(self) -> None: width=-1, ) - def _install_navigation(self) -> None: - """Wires Tab/Enter/Escape navigation over the controls and buttons.""" - self._navigator = DialogKeyboardNavigator( - window_tag=self.tag, - stops=[ - 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, - key_router=self._router, - shortcut_source=self._shortcuts, - ) - self._navigator.install() - - def _teardown(self) -> None: - if self._navigator is not None: - self._navigator.dispose() - self._navigator = None - def _render(self) -> None: """Shows the standing selection, offering the size and frame controls while they apply.""" view_model = self._require_view_model() diff --git a/src/sampletones_application/ui/panels/dialogs/keybindings.py b/src/sampletones_application/ui/panels/dialogs/keybindings.py index bdfc42a1b..c5db0248f 100644 --- a/src/sampletones_application/ui/panels/dialogs/keybindings.py +++ b/src/sampletones_application/ui/panels/dialogs/keybindings.py @@ -7,7 +7,6 @@ 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.general import TAG_GLOBAL_THEME_DIALOG from sampletones_application.tags.settings import ( PRE_SETTINGS_KEYBINDINGS_GROUP, PRE_SETTINGS_KEYBINDINGS_ROW, @@ -26,16 +25,12 @@ 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.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 KeyCombination, KeyRouter from sampletones_application.utils.gui.keyboard.capture import KeyCapture @@ -52,7 +47,7 @@ CombinationCallback = Callable[[KeyCombination], None] -class GUIKeybindingsWindow(GUIWindow): +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 @@ -75,10 +70,6 @@ def __init__( ) -> None: self._language_manager = language_manager self._layout = layout - self._router = key_router - self._shortcuts = shortcut_source - self._dialog_theme = ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG) - self._navigator: Optional[DialogKeyboardNavigator] = None self._capture: Optional[KeyCapture] = None self._view_model: Optional[KeybindingsViewModel] = None self._filter = "" @@ -99,6 +90,8 @@ def __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: @@ -129,16 +122,26 @@ def create_window(self) -> None: dpg.add_separator() self._create_action_buttons() - for field_tag in ( + self._bind_dialog_theme( TAG_SETTINGS_KEYBINDINGS_COMBO_SCHEME, TAG_SETTINGS_KEYBINDINGS_INPUT_FILTER, TAG_SETTINGS_KEYBINDINGS_INPUT_SHORTCUT, - ): - self._dialog_theme.bind_to_item(field_tag) + ) self._install_capture() self._render() - self._install_navigation() + 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() @@ -261,33 +264,13 @@ def _install_capture(self) -> None: self._capture.on_captured = self._report_captured self._capture.on_cancelled = self._render - def _install_navigation(self) -> None: - """Wires Tab/Enter/Escape navigation over the controls and buttons.""" - self._navigator = DialogKeyboardNavigator( - window_tag=self.tag, - stops=[ - 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, - key_router=self._router, - shortcut_source=self._shortcuts, - ) - self._navigator.install() - 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 - if self._navigator is not None: - self._navigator.dispose() - self._navigator = None + super()._teardown() def _render(self) -> None: """Shows each action's keys, the standing selection, and what the filter leaves listed.""" diff --git a/src/sampletones_application/ui/panels/dialogs/project_properties.py b/src/sampletones_application/ui/panels/dialogs/project_properties.py index b384aaf34..57ba3b580 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,16 +17,12 @@ 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 ( @@ -40,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 @@ -59,10 +54,6 @@ def __init__( ) -> None: self._language_manager = language_manager self._layout = layout - self._router = key_router - self._shortcuts = shortcut_source - self._dialog_theme = ThemeRegistry.get(TAG_GLOBAL_THEME_DIALOG) - self._navigator: Optional[DialogKeyboardNavigator] = None self.on_commit: Optional[Callable[[str, str, str], None]] = None @@ -97,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: @@ -132,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), @@ -153,15 +140,7 @@ def _install_navigation(self) -> None: FocusStop.button(TAG_SETTINGS_PROPERTIES_BUTTON_OK, self._commit), ], on_escape=self.hide, - key_router=self._router, - shortcut_source=self._shortcuts, ) - 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): From 9a1db30bcdab176f34c31dd5a8fb0b12a5a4b727 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 23:29:10 +0200 Subject: [PATCH 034/152] Refactored: shared candidate alignment, metadata contract and instrument builder --- src/sampletones_core/data/__init__.py | 3 +- src/sampletones_core/data/metadata.py | 44 ++++++++++++++- .../formats/famitracker/builder.py | 56 ++++++++++++++----- src/sampletones_core/library/data.py | 32 ++++------- .../reconstructions/criterion/alignment.py | 38 +++++++++++++ .../reconstructions/criterion/spectral.py | 39 ++++++++----- .../reconstructions/criterion/temporal.py | 13 +---- .../reconstruction/reconstruction.py | 32 ++++------- .../trackers/implementation/famitracker.py | 36 ++++++------ .../project/test_container.py | 49 +++++++++++----- 10 files changed, 226 insertions(+), 116 deletions(-) create mode 100644 src/sampletones_core/reconstructions/criterion/alignment.py diff --git a/src/sampletones_core/data/__init__.py b/src/sampletones_core/data/__init__.py index 76234b97f..ccdf63d14 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 1208f2090..aa8da0c06 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/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index d9fe39e87..39030a2bc 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, @@ -62,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. @@ -76,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 diff --git a/src/sampletones_core/library/data.py b/src/sampletones_core/library/data.py index 13f621a00..c62d07e63 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,17 +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 - 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/reconstructions/criterion/alignment.py b/src/sampletones_core/reconstructions/criterion/alignment.py new file mode 100644 index 000000000..bc1bf35e3 --- /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 e355ae82f..8324d3ba5 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 82c4d05b3..5d7819fb3 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 cd3d9185e..addc2ce45 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/trackers/implementation/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py index cebbd102e..ee84198eb 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/tests/unit/sampletones_core/project/test_container.py b/tests/unit/sampletones_core/project/test_container.py index 81e1b62db..22051afab 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,7 +269,10 @@ 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) @@ -290,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) From ff0fac38ebb55592011ef612f3ddb09c0462ef2f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sun, 9 Aug 2026 23:53:49 +0200 Subject: [PATCH 035/152] Refactored: element enums found by type --- docs/development/architecture.md | 4 +- scripts/checks/language_keys.py | 82 +++++++++++++++---- .../categories/elements/sequencer.py | 32 -------- .../coordinators/tabs/sequencer.py | 12 +-- .../logic/history/action.py | 9 +- src/sampletones_shared/meta/source/classes.py | 26 ++++++ src/sampletones_shared/meta/source/modules.py | 23 ++++++ .../logic/history/test_action_labels.py | 14 ++-- .../meta/source/test_classes.py | 73 +++++++++++++++++ .../meta/source/test_modules.py | 16 ++++ .../unit/scripts/checks/test_language_keys.py | 11 ++- 11 files changed, 228 insertions(+), 74 deletions(-) create mode 100644 src/sampletones_shared/meta/source/classes.py create mode 100644 tests/unit/sampletones_shared/meta/source/test_classes.py diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 772aef4ee..dc3fb305b 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -83,7 +83,7 @@ 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. @@ -337,7 +337,7 @@ 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. 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` | diff --git a/scripts/checks/language_keys.py b/scripts/checks/language_keys.py index 15db4f8cd..89a5f9e65 100755 --- 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,25 +21,30 @@ 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.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 +EnumPredicate = Callable[[object], bool] + +APPLICATION_PACKAGE: Final[Path] = package_directory("sampletones_application") + RECEIVER_TYPE: Final[str] = "LanguageManager" ELEMENT_BASE: Final[str] = AbstractElement.__name__ @@ -65,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/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 3871f0834..6660cec9a 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -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/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 84bffabe5..c941fe27d 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -3,10 +3,7 @@ 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 @@ -16,7 +13,6 @@ 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 @@ -662,7 +658,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, @@ -671,12 +667,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( diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py index 595655c15..ce0f6ce5a 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_shared/meta/source/classes.py b/src/sampletones_shared/meta/source/classes.py new file mode 100644 index 000000000..02bac0a34 --- /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 205f3a9ee..d2b5efd7e 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) 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 a8496f577..ec94cc159 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_shared/meta/source/test_classes.py b/tests/unit/sampletones_shared/meta/source/test_classes.py new file mode 100644 index 000000000..00603297f --- /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_modules.py b/tests/unit/sampletones_shared/meta/source/test_modules.py index 31e158cbe..a010c5f62 100644 --- a/tests/unit/sampletones_shared/meta/source/test_modules.py +++ b/tests/unit/sampletones_shared/meta/source/test_modules.py @@ -7,6 +7,7 @@ from sampletones_shared.meta.source.modules import ( discover_modules, is_visible, + module_name, parse_module, source_paths, ) @@ -59,6 +60,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) diff --git a/tests/unit/scripts/checks/test_language_keys.py b/tests/unit/scripts/checks/test_language_keys.py index ab9762b2c..3e74ffab2 100644 --- a/tests/unit/scripts/checks/test_language_keys.py +++ b/tests/unit/scripts/checks/test_language_keys.py @@ -4,6 +4,7 @@ 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 @@ -73,8 +74,14 @@ def test_every_element_enum_of_the_package_is_read(self) -> None: "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: From 02d4d929bbb74ef6e345cbd507847ae5890a0394 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 00:04:07 +0200 Subject: [PATCH 036/152] Added: tracker scrolling to the playing row --- .../coordinators/tabs/sequencer.py | 17 +++- .../ui/panels/sequencer/tracker.py | 43 +++++++-- .../coordinators/tabs/test_sequencer.py | 47 ++++++++++ .../sequencer/test_tracker_navigation.py | 87 +++++++++++++++++++ .../ui/panels/sequencer/test_tracker_rows.py | 1 + 5 files changed, 185 insertions(+), 10 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index c941fe27d..d6185cdca 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -735,6 +735,13 @@ 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_playback) if not view_model.is_playing and not view_model.is_paused: self._playing_order = None self._sequencer_tracker_panel.set_playing_row(None) @@ -745,12 +752,18 @@ 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_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_tracker_logic.select_frame(order_position) + self._sequencer_tracker_panel.set_playing_row(row_index) + self._sequencer_order_panel.set_playing_position(order_position) + def _on_order_frame_selected(self, frame_index: int) -> None: """Selects an order frame in the tracker, and moves the playhead too when following. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 59646f5b3..66b2a8415 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -59,6 +59,7 @@ ) 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, @@ -141,6 +142,7 @@ def __init__( self._current_row_count: int = 0 self._highlighted_row: Optional[int] = None self._playing_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] = {} @@ -415,10 +417,16 @@ def _rebuild_table( view_model: SequencerTrackerViewModel, cell_values: CellValues, ) -> None: + """Replaces the table body, and re-reveals the sounding row once the new body has laid out. + + A table repopulated this frame reports the scroll extent of the body it replaced, so the + reveal is repeated a frame later, when DearPyGui has measured the rows now in it. + """ 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. @@ -1290,14 +1298,20 @@ 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) + + 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 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. + 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. Both the + edit cursor and the playhead are placed by this one rule. """ - cursor = self._input_state.cursor - if cursor is None or self._current_row_count <= 1: + if self._current_row_count <= 1: return if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): @@ -1307,7 +1321,7 @@ def _scroll_cursor_into_view(self) -> None: if scroll_max <= 0: return - fraction = cursor.row / (self._current_row_count - 1) + fraction = row_index / (self._current_row_count - 1) dpg.set_y_scroll(TAG_SEQUENCER_TRACKER_TABLE, fraction * scroll_max) def _clear_row(self) -> None: @@ -1390,8 +1404,15 @@ def unhighlight_row(self, row_index: Optional[int] = None) -> None: 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: - """Moves the playhead mark, drawing both the row it left and the row it reached.""" + """Moves the playhead mark, drawing both the row it left and the row it reached. + + While the grid follows the playhead, the row it reached is also scrolled into view. + """ previous = self._playing_row self._playing_row = row_index if previous is not None and previous != row_index: @@ -1399,6 +1420,12 @@ def set_playing_row(self, row_index: Optional[int]) -> None: if row_index is not None: self._paint_row(row_index) + self._reveal_playing_row() + + def _reveal_playing_row(self) -> None: + """Scrolls the sounding row into view while the grid follows the playhead.""" + if self._follows_playing_row and self._playing_row is not None: + self._scroll_row_into_view(self._playing_row) def _live_row_count(self) -> int: """The table's current pattern-row count, read live from DearPyGui. diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 9d8af5d02..89255449c 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -28,6 +28,7 @@ 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, @@ -231,6 +232,19 @@ def test_cancel_restores_the_field( nes_frequency_coordinator._sequencer_tracker_logic.push_settings.assert_called_once() +def _player_view(*, follow_playback: bool) -> 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_playback=follow_playback, + order_position=0, + row_index=0, + error=None, + ) + + @pytest.fixture def playback_coordinator() -> SequencerTabCoordinator: """A coordinator with only the collaborators the follow-playback handlers touch.""" @@ -267,6 +281,39 @@ def test_position_change_does_not_move_edited_frame_when_disabled( playback_coordinator._sequencer_order_panel.set_playing_position.assert_called_once_with(2) playback_coordinator._sequencer_tracker_logic.select_frame.assert_not_called() + def test_the_frame_is_selected_before_the_row_is_marked( + self, + playback_coordinator: SequencerTabCoordinator, + ) -> None: + """The mark and the scroll that reveals it land on the pattern the playhead has reached.""" + playback_coordinator._song_player_logic.follow_playback = True + 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) + + names = [name for name, _, _ in recorder.mock_calls] + assert names.index("logic.select_frame") < names.index("panel.set_playing_row") + + def test_the_view_states_whether_the_grid_follows_the_row( + self, + playback_coordinator: SequencerTabCoordinator, + ) -> None: + playback_coordinator._on_player_view_changed(_player_view(follow_playback=True)) + + playback_coordinator._sequencer_tracker_panel.set_row_following.assert_called_once_with(True) + + def test_a_stopped_view_drops_the_marks( + self, + playback_coordinator: SequencerTabCoordinator, + ) -> None: + playback_coordinator._on_player_view_changed(_player_view(follow_playback=False)) + + playback_coordinator._sequencer_tracker_panel.set_row_following.assert_called_once_with(False) + 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_seeks_playhead_when_following( self, playback_coordinator: SequencerTabCoordinator, 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 index 15634dfbb..0b2b00d9e 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -1,8 +1,10 @@ +from dataclasses import dataclass from types import SimpleNamespace from typing import List 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 @@ -15,6 +17,8 @@ PAGE_SIZE = 16 CURSOR_ROW = 5 +ROW_COUNT = 65 +SCROLL_MAX = 640.0 def _panel() -> GUISequencerTrackerPanel: @@ -25,6 +29,9 @@ def _panel() -> GUISequencerTrackerPanel: pending="", ) panel._layout = SimpleNamespace(tracker=SimpleNamespace(page_size=PAGE_SIZE)) + panel._playing_row = None + panel._follows_playing_row = False + panel._current_row_count = ROW_COUNT return panel @@ -61,6 +68,86 @@ def test_page_down_moves_down_one_page_and_scrolls(self, monkeypatch: pytest.Mon assert scrolls == [None] +@dataclass(frozen=True) +class RowPlacementCase: + """A row of the frame, and the share of the scroll extent that reveals 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), +] + + +class TestPlayheadFollowing: + """The grid reveals the sounding row 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_into_view", 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_into_view", 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_into_view", revealed.append) + + panel.set_row_following(True) + panel.set_playing_row(12) + panel.set_playing_row(None) + + assert revealed == [12] + + @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: + """The first row rests at the top of the band, the last at the bottom, the rest between.""" + panel = _panel() + 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, "set_y_scroll", lambda tag, value: scrolls.append(value)) + + panel._scroll_row_into_view(case.row_index) + + assert scrolls == [pytest.approx(case.scroll)] + + def test_the_cursor_is_placed_by_the_same_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.""" 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 index 3a1c3f3d7..96af0709e 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -89,6 +89,7 @@ def _panel() -> GUISequencerTrackerPanel: panel._current_row_count = PATTERN_ROWS panel._highlighted_row = None panel._playing_row = None + panel._follows_playing_row = False panel._input_state = TrackerInputState() return panel From 273b5e18b308587ed250916b1e9ada1ee6b5314b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 02:14:52 +0200 Subject: [PATCH 037/152] Fixed: playhead mark --- .../ui/panels/sequencer/tracker.py | 93 +++++++++++++++---- .../ui/panels/sequencer/conftest.py | 22 +++++ .../sequencer/test_tracker_navigation.py | 85 ++++++++++++++--- .../ui/panels/sequencer/test_tracker_rows.py | 1 + 4 files changed, 172 insertions(+), 29 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/conftest.py diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 66b2a8415..a9299406b 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -106,6 +106,7 @@ VOLUME_FINE_STEP: Final[int] = 1 VOLUME_COARSE_STEP: Final[int] = (MAX_VOLUME + 1) // 4 +PLAYHEAD_PAINT_FRAMES: Final[int] = 1 class GUISequencerTrackerPanel(GUIPanel): @@ -142,6 +143,7 @@ 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] = {} @@ -453,7 +455,7 @@ def _row_background(self, row_index: int) -> Optional[BaseColor]: self._layout.colors, RowCues( cursor=cursor.row if cursor is not None else None, - playing=self._playing_row, + playing=self._painted_row, ), ) @@ -1308,21 +1310,68 @@ def _scroll_row_into_view(self, row_index: int) -> None: 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. Both the - edit cursor and the playhead are placed by this one rule. + 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. """ - if self._current_row_count <= 1: + scroll_max = self._scroll_extent() + if scroll_max is None: return - if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): + fraction = row_index / (self._current_row_count - 1) + dpg.set_y_scroll(TAG_SEQUENCER_TRACKER_TABLE, fraction * scroll_max) + + def _scroll_row_to_band_top(self, row_index: int) -> None: + """Scrolls the tracker so the given row heads the visible band. + + A playhead read from one place is a playhead that stays easy to read, so the sounding row + is carried to the top of the band by the height of the rows above it, and the rows it is + about to reach fill the band beneath it. The rows closing a frame have nothing behind them + left to scroll into place: there the grid rests at its end and the playhead walks down the + band to meet it. + """ + scroll_max = self._scroll_extent() + offset = self._row_offset(row_index) + if scroll_max is None or offset is None: return + dpg.set_y_scroll(TAG_SEQUENCER_TRACKER_TABLE, min(offset, scroll_max)) + + def _scroll_extent(self) -> Optional[float]: + """How far the grid scrolls, once there is a built table with a frame too tall to fit it.""" + if self._current_row_count <= 1: + return None + + if not dpg.does_item_exist(TAG_SEQUENCER_TRACKER_TABLE): + return None + scroll_max = dpg.get_y_scroll_max(TAG_SEQUENCER_TRACKER_TABLE) - if scroll_max <= 0: - return + return scroll_max if scroll_max > 0 else None - fraction = row_index / (self._current_row_count - 1) - dpg.set_y_scroll(TAG_SEQUENCER_TRACKER_TABLE, fraction * scroll_max) + 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() @@ -1409,23 +1458,31 @@ def set_row_following(self, following: bool) -> None: self._follows_playing_row = following def set_playing_row(self, row_index: Optional[int]) -> None: - """Moves the playhead mark, drawing both the row it left and the row it reached. + """Moves the playhead to the row playback reached, mark and grid arriving together. - While the grid follows the playhead, the row it reached is also scrolled into view. + 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. """ - previous = self._playing_row self._playing_row = row_index - if previous is not None and previous != row_index: + self._reveal_playing_row() + FrameCallbackManager.set_frame_callback(self._paint_playhead, PLAYHEAD_PAINT_FRAMES) + + 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) - if row_index is not None: - self._paint_row(row_index) - self._reveal_playing_row() + if self._painted_row is not None: + self._paint_row(self._painted_row) def _reveal_playing_row(self) -> None: - """Scrolls the sounding row into view while the grid follows the playhead.""" + """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_into_view(self._playing_row) + 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. 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 000000000..574a1e837 --- /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_tracker_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py index 0b2b00d9e..f513728f4 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -19,6 +19,9 @@ 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: @@ -30,8 +33,10 @@ def _panel() -> GUISequencerTrackerPanel: ) 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 @@ -70,7 +75,7 @@ def test_page_down_moves_down_one_page_and_scrolls(self, monkeypatch: pytest.Mon @dataclass(frozen=True) class RowPlacementCase: - """A row of the frame, and the share of the scroll extent that reveals it.""" + """A row of the frame, and the scroll that places it.""" row_index: int scroll: float @@ -82,15 +87,39 @@ class RowPlacementCase: 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 reveals the sounding row for as long as it follows the playhead.""" + """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_into_view", revealed.append) + monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append) panel.set_row_following(True) panel.set_playing_row(12) @@ -101,7 +130,7 @@ def test_an_unfollowed_row_stays_where_the_reader_left_it(self, monkeypatch: pyt panel = _panel() revealed: List[int] = [] monkeypatch.setattr(panel, "_paint_row", lambda row_index: None) - monkeypatch.setattr(panel, "_scroll_row_into_view", revealed.append) + monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append) panel.set_row_following(False) panel.set_playing_row(12) @@ -113,7 +142,7 @@ def test_a_cleared_playhead_leaves_the_scroll_alone(self, monkeypatch: pytest.Mo panel = _panel() revealed: List[int] = [] monkeypatch.setattr(panel, "_paint_row", lambda row_index: None) - monkeypatch.setattr(panel, "_scroll_row_into_view", revealed.append) + monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append) panel.set_row_following(True) panel.set_playing_row(12) @@ -121,24 +150,58 @@ def test_a_cleared_playhead_leaves_the_scroll_alone(self, monkeypatch: pytest.Mo 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 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: - """The first row rests at the top of the band, the last at the bottom, the rest between.""" panel = _panel() - 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, "set_y_scroll", lambda tag, value: scrolls.append(value)) + 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_the_same_rule(self, monkeypatch: pytest.MonkeyPatch) -> None: + 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) 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 index 96af0709e..441b3eba7 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -89,6 +89,7 @@ def _panel() -> GUISequencerTrackerPanel: 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 From f93aa8d58f5dc974fbe4edca9eeedf23dd9e3e55 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 10:08:01 +0200 Subject: [PATCH 038/152] Added: follow mode --- src/sampletones_application/application.py | 19 ++++-- .../config/managers/application.py | 9 +-- .../config/managers/session.py | 9 +-- .../config/session/application/playback.py | 15 +++-- .../constants/playback.py | 27 ++++++++ .../coordinators/tabs/sequencer.py | 21 ++++-- .../logic/sequencer/playback/song_player.py | 11 ++-- src/sampletones_application/ui/menu.py | 2 +- .../view_model/sequencer/song_player.py | 4 +- .../view_model/shared/menu.py | 3 +- .../config/managers/test_application.py | 10 +-- .../coordinators/tabs/test_sequencer.py | 60 ++++++++--------- .../sequencer/playback/test_song_player.py | 32 +++++---- .../sequencer/test_tracker_navigation.py | 65 ++++++++++++++++++- .../sampletones_application/ui/test_menu.py | 3 +- .../view_model/sequencer/test_song_player.py | 5 +- .../view_model/shared/test_menu.py | 5 +- 17 files changed, 214 insertions(+), 86 deletions(-) create mode 100644 src/sampletones_application/constants/playback.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index b46542721..6dd001c03 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -12,6 +12,7 @@ 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 DEFAULT_FOLLOW_MODE, FollowMode from sampletones_application.coordinators.config import ConfigCoordinator from sampletones_application.coordinators.display import DisplayCoordinator from sampletones_application.coordinators.keybindings import KeybindingsCoordinator @@ -671,7 +672,7 @@ 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, @@ -709,7 +710,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, @@ -739,14 +740,24 @@ def _toggle_autoplay( self.session_manager.toggle_autoplay() self._update_menu() + 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_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) - self._update_menu() + """Turns following on at its fullest reach, or off, the one gesture the menu carries.""" + following = self.session_manager.follow_mode.follows_pattern + self._set_follow_mode(FollowMode.OFF if following else DEFAULT_FOLLOW_MODE) def _toggle_loop_song( self, diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py index 3ab0a5f6a..206b541de 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -2,6 +2,7 @@ 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.data.metadata import Metadata @@ -132,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 25d43a8f2..7725786bd 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -7,6 +7,7 @@ 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 @@ -55,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) @@ -226,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/session/application/playback.py b/src/sampletones_application/config/session/application/playback.py index 628cefe0a..bd6c8b653 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 tracker 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/constants/playback.py b/src/sampletones_application/constants/playback.py new file mode 100644 index 000000000..307d89fda --- /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/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index d6185cdca..18e10326a 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -8,6 +8,7 @@ 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 @@ -373,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 @@ -741,7 +750,7 @@ def _on_player_view_changed(self, view_model: SongPlayerViewModel) -> None: 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_playback) + 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_tracker_panel.set_playing_row(None) @@ -758,7 +767,7 @@ def _on_player_position_changed( land on the pattern the playhead has reached. """ self._playing_order = order_position - if self._song_player_logic.follow_playback: + 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) @@ -767,12 +776,12 @@ def _on_player_position_changed( def _on_order_frame_selected(self, frame_index: int) -> None: """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_tracker_logic.select_frame(frame_index) - if self._song_player_logic.follow_playback: + if self._song_player_logic.follow_mode.follows_pattern: self._song_player_logic.seek(frame_index) def _on_preview_error(self, exception: Exception) -> None: diff --git a/src/sampletones_application/logic/sequencer/playback/song_player.py b/src/sampletones_application/logic/sequencer/playback/song_player.py index 5a2e36631..a30e3b573 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/ui/menu.py b/src/sampletones_application/ui/menu.py index dcb77d9b0..c766eea1a 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -545,7 +545,7 @@ def update(self, state: MenuBarViewModel) -> None: dpg_set_value(TAG_GLOBAL_MENU_ITEM_PLAYBACK_AUTOPLAY, state.autoplay) dpg_set_value( TAG_GLOBAL_MENU_ITEM_PLAYBACK_FOLLOW_PLAYBACK, - state.follow_playback, + state.follow_mode.follows_pattern, ) dpg_set_value(TAG_GLOBAL_MENU_ITEM_PLAYBACK_LOOP_SONG, state.loop_song) self._update_channels(state) diff --git a/src/sampletones_application/view_model/sequencer/song_player.py b/src/sampletones_application/view_model/sequencer/song_player.py index 238a97a34..6901de178 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/shared/menu.py b/src/sampletones_application/view_model/shared/menu.py index 92a09c74e..04bb3798f 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/tests/unit/sampletones_application/config/managers/test_application.py b/tests/unit/sampletones_application/config/managers/test_application.py index 1c6403c21..865ae32ef 100644 --- a/tests/unit/sampletones_application/config/managers/test_application.py +++ b/tests/unit/sampletones_application/config/managers/test_application.py @@ -12,6 +12,7 @@ DEFAULT_SCHEME_NAME, MACOS_SCHEME_NAME, ) +from sampletones_application.constants.playback import FollowMode from sampletones_core.data.metadata import Metadata @@ -43,12 +44,11 @@ def test_toggle_autoplay_changes_value(self, tmp_path: Path) -> None: assert result == (not initial) assert manager.autoplay == (not initial) - def test_set_follow_playback_round_trips(self, tmp_path: Path) -> None: + @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_playback(False) - assert manager.follow_playback is False - manager.set_follow_playback(True) - assert manager.follow_playback is True + manager.set_follow_mode(mode) + assert manager.follow_mode is mode def test_set_loop_song_round_trips(self, tmp_path: Path) -> None: manager = ApplicationConfigManager(tmp_path / "config.yaml") diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 89255449c..7a7ca1e76 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -7,6 +7,7 @@ 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 @@ -232,13 +233,13 @@ def test_cancel_restores_the_field( nes_frequency_coordinator._sequencer_tracker_logic.push_settings.assert_called_once() -def _player_view(*, follow_playback: bool) -> SongPlayerViewModel: +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_playback=follow_playback, + follow_mode=follow_mode, order_position=0, row_index=0, error=None, @@ -256,37 +257,28 @@ def playback_coordinator() -> SequencerTabCoordinator: 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_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_tracker_logic.select_frame.assert_called_once_with(2) - - def test_position_change_does_not_move_edited_frame_when_disabled( - self, - playback_coordinator: SequencerTabCoordinator, - ) -> None: - playback_coordinator._song_player_logic.follow_playback = False - - playback_coordinator._on_player_position_changed(2, 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_tracker_logic.select_frame.assert_not_called() + assert playback_coordinator._sequencer_tracker_logic.select_frame.called is mode.follows_pattern def test_the_frame_is_selected_before_the_row_is_marked( self, playback_coordinator: SequencerTabCoordinator, ) -> None: """The mark and the scroll that reveals it land on the pattern the playhead has reached.""" - playback_coordinator._song_player_logic.follow_playback = True + 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") @@ -296,45 +288,49 @@ def test_the_frame_is_selected_before_the_row_is_marked( names = [name for name, _, _ in recorder.mock_calls] assert names.index("logic.select_frame") < names.index("panel.set_playing_row") + @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._on_player_view_changed(_player_view(follow_playback=True)) + playback_coordinator._on_player_view_changed(_player_view(follow_mode=mode)) - playback_coordinator._sequencer_tracker_panel.set_row_following.assert_called_once_with(True) + 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_playback=False)) + playback_coordinator._on_player_view_changed(_player_view(follow_mode=FollowMode.ROWS)) - playback_coordinator._sequencer_tracker_panel.set_row_following.assert_called_once_with(False) 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_seeks_playhead_when_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 = True + """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_tracker_logic.select_frame.assert_called_once_with(3) - playback_coordinator._song_player_logic.seek.assert_called_once_with(3) + assert playback_coordinator._song_player_logic.seek.called is mode.follows_pattern - def test_order_selection_only_edits_when_not_following( + @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._song_player_logic.follow_playback = False + playback_coordinator.set_follow_mode(mode) - playback_coordinator._on_order_frame_selected(3) - - playback_coordinator._sequencer_tracker_logic.select_frame.assert_called_once_with(3) - playback_coordinator._song_player_logic.seek.assert_not_called() + playback_coordinator._song_player_logic.set_follow_mode.assert_called_once_with(mode) class TestNoteOffDispatch: 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 c0d6a32dc..c6cfc27a5 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, 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, @@ -22,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), ) @@ -424,34 +427,37 @@ def test_seek_while_stopped_does_not_suppress_updates(self) -> None: 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/ui/panels/sequencer/test_tracker_navigation.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py index f513728f4..88131ff40 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -1,6 +1,6 @@ from dataclasses import dataclass from types import SimpleNamespace -from typing import List +from typing import List, Tuple import pytest @@ -13,6 +13,7 @@ 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 @@ -184,6 +185,68 @@ def test_a_grid_awaiting_its_layout_is_left_alone(self, monkeypatch: pytest.Monk 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.""" diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index d392af366..e5a3fa9aa 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, @@ -91,7 +92,7 @@ def _state( player_paused=False, stop_enabled=False, autoplay=False, - follow_playback=False, + follow_mode=FollowMode.OFF, loop_song=False, channels=SequencerChannelsViewModel(muted=muted), fullscreen=False, 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 96f2dbc8e..8e3966d1f 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/shared/test_menu.py b/tests/unit/sampletones_application/view_model/shared/test_menu.py index 3d971dd63..6341a05c4 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_menu.py +++ b/tests/unit/sampletones_application/view_model/shared/test_menu.py @@ -2,6 +2,7 @@ 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 @@ -76,7 +77,7 @@ def test_enablement_follows_project_and_history_state( player_paused=False, stop_enabled=False, autoplay=False, - follow_playback=False, + follow_mode=FollowMode.OFF, loop_song=False, channels=EVERY_CHANNEL_AUDIBLE, fullscreen=False, @@ -112,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, From 47c1fa86695277119562a24c829b2c791c3a6c9b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 10:43:08 +0200 Subject: [PATCH 039/152] Added: follow-mode menu and keyboard shortcuts --- src/sampletones_application/application.py | 14 +--- .../categories/elements/global_.py | 5 +- .../categories/elements/settings.py | 4 +- src/sampletones_application/shell.py | 28 ++++++- src/sampletones_application/tags/general.py | 4 +- src/sampletones_application/ui/menu.py | 51 ++++++++++--- .../utils/gui/shortcuts/ids.py | 11 ++- .../keybindings/default.yaml | 6 +- src/sampletones_config/keybindings/macos.yaml | 6 +- src/sampletones_config/lang/en.yaml | 9 ++- .../sampletones_application/test_shell.py | 17 ++++- .../sampletones_application/ui/test_menu.py | 73 ++++++++++++++++++- 12 files changed, 188 insertions(+), 40 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 6dd001c03..26f496a5f 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -12,7 +12,7 @@ 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 DEFAULT_FOLLOW_MODE, FollowMode +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 @@ -575,7 +575,7 @@ 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._toggle_channel, unmute_all_channels=self._sequencer_tab.unmute_all_channels, @@ -749,16 +749,6 @@ def _set_follow_mode(self, mode: FollowMode) -> None: self._sequencer_tab.set_follow_mode(mode) self._update_menu() - def _toggle_follow_playback( - self, - _sender: Optional[Sender] = None, - _app_data: Optional[Any] = None, - _user_data: Optional[Any] = None, - ) -> None: - """Turns following on at its fullest reach, or off, the one gesture the menu carries.""" - following = self.session_manager.follow_mode.follows_pattern - self._set_follow_mode(FollowMode.OFF if following else DEFAULT_FOLLOW_MODE) - def _toggle_loop_song( self, _sender: Optional[Sender] = None, diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index f73c9eced..d2c524a0b 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" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 693191e40..a43bec06f 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -62,7 +62,9 @@ class KeybindingActionElements(AbstractElement): PLAY_FROM_FRAME = "play_from_frame" STOP = "stop" TOGGLE_AUTOPLAY = "toggle_autoplay" - TOGGLE_FOLLOW_PLAYBACK = "toggle_follow_playback" + 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" diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 260fdcc3b..9d1569360 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -8,8 +8,11 @@ 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, @@ -37,6 +40,7 @@ from sampletones_application.utils.gui.keyboard import KeyRouter from sampletones_application.utils.gui.shortcuts.ids import ( CHANNEL_SHORTCUT_IDS, + FOLLOW_MODE_SHORTCUT_IDS, PROJECT_EXPORT_SHORTCUT_IDS, SAMPLE_EXPORT_SHORTCUT_IDS, ShortcutId, @@ -89,7 +93,7 @@ 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 @@ -233,7 +237,6 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback ShortcutId.PLAY_FROM_FRAME: bindings.play_from_frame, ShortcutId.STOP: bindings.stop, ShortcutId.TOGGLE_AUTOPLAY: bindings.toggle_autoplay, - ShortcutId.TOGGLE_FOLLOW_PLAYBACK: bindings.toggle_follow_playback, ShortcutId.TOGGLE_LOOP_SONG: bindings.toggle_loop_song, ShortcutId.AUDIO_SETTINGS: bindings.audio_settings, ShortcutId.DISPLAY_SETTINGS: bindings.display_settings, @@ -244,11 +247,14 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback 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]: + 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 @@ -264,6 +270,20 @@ def _export_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback]: } 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. diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 99b50d7b5..6d6065d87 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/ui/menu.py b/src/sampletones_application/ui/menu.py index c766eea1a..c1e06fdbf 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -13,6 +13,7 @@ TRACKER_PROJECT_MENU_LABELS, TRACKER_SAMPLE_MENU_LABELS, ) +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 @@ -28,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, @@ -70,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, @@ -92,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, @@ -366,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, @@ -385,6 +387,26 @@ 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. @@ -543,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_mode.follows_pattern, - ) 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( @@ -555,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: @@ -596,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/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 4250ae293..aa2adaf80 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -1,6 +1,7 @@ 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 @@ -69,7 +70,9 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: PLAY_FROM_FRAME = ("PlayFromFrame", ShortcutCategory.APPLICATION) STOP = ("Stop", ShortcutCategory.APPLICATION) TOGGLE_AUTOPLAY = ("ToggleAutoplay", ShortcutCategory.APPLICATION) - TOGGLE_FOLLOW_PLAYBACK = ("ToggleFollowPlayback", 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) @@ -139,6 +142,12 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: 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, GeneratorName.PULSE2: ShortcutId.TOGGLE_CHANNEL_PULSE_2, diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 7980ebdd3..140c84e3b 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -38,8 +38,10 @@ bindings: PlayFromFrame: {combination: "Ctrl+Space"} Stop: {combination: "Esc"} ToggleAutoplay: {combination: "Ctrl+P"} - ToggleFollowPlayback: {combination: ~} - ToggleLoopSong: {combination: ~} + 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"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index dec177569..175a1dfc8 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -38,8 +38,10 @@ bindings: PlayFromFrame: {combination: "Ctrl+Space"} Stop: {combination: "Esc"} ToggleAutoplay: {combination: "Cmd+P"} - ToggleFollowPlayback: {combination: ~} - ToggleLoopSong: {combination: ~} + 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"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index add30379b..5b3572196 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" @@ -711,7 +714,9 @@ 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.toggle_follow_playback: "Follow playback" +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" diff --git a/tests/unit/sampletones_application/test_shell.py b/tests/unit/sampletones_application/test_shell.py index 55741bf66..eb5f5a374 100644 --- a/tests/unit/sampletones_application/test_shell.py +++ b/tests/unit/sampletones_application/test_shell.py @@ -1,8 +1,15 @@ 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 ShortcutCategory, ShortcutId +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 @@ -32,3 +39,11 @@ def test_a_channel_action_carries_the_channel_it_switches(self) -> None: 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/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index e5a3fa9aa..4dd228e46 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -14,6 +14,7 @@ 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, ) @@ -25,6 +26,11 @@ 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: @@ -74,6 +80,7 @@ def _state( muted: FrozenSet[GeneratorName], *, reconstruction_loaded: bool = False, + follow_mode: FollowMode = FollowMode.OFF, ) -> MenuBarViewModel: return MenuBarViewModel( project_open=True, @@ -92,7 +99,7 @@ def _state( player_paused=False, stop_enabled=False, autoplay=False, - follow_mode=FollowMode.OFF, + follow_mode=follow_mode, loop_song=False, channels=SequencerChannelsViewModel(muted=muted), fullscreen=False, @@ -174,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, From 3c0a8850e99cfd7a68ee17f69d02805cfe948e0d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 10:56:29 +0200 Subject: [PATCH 040/152] Documented: follow modes --- docs/development/architecture.md | 6 +++--- docs/development/playback.md | 11 +++++++++++ docs/guide/sequencer.md | 22 ++++++++++++++++++++-- 3 files changed, 34 insertions(+), 5 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index dc3fb305b..10dbf8bf5 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -222,7 +222,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m | `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). --- @@ -240,7 +240,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m **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/`. --- @@ -338,7 +338,7 @@ There are two coordinator kinds: |---------|---------| | `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, 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. A fact shared beyond the application belongs to `sampletones_shared/constants/` | +| `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 | diff --git a/docs/development/playback.md b/docs/development/playback.md index 3397548d9..272efd9b8 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 @@ -154,6 +163,8 @@ audible. | 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/sequencer.md b/docs/guide/sequencer.md index 5f497f3ad..cc1491d99 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 From 34950d6183bfc2b75b751060ecb923afabef614c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 11:29:45 +0200 Subject: [PATCH 041/152] Fixed: audio teardown ordering on exit --- src/sampletones_application/application.py | 3 ++- .../coordinators/playback/router.py | 13 ++++++++++++ .../coordinators/playback/test_router.py | 20 +++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 26f496a5f..a46b6c27c 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1333,7 +1333,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() @@ -1389,6 +1389,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/coordinators/playback/router.py b/src/sampletones_application/coordinators/playback/router.py index b67f0a5ba..194460f3d 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/tests/unit/sampletones_application/coordinators/playback/test_router.py b/tests/unit/sampletones_application/coordinators/playback/test_router.py index df3b9d550..b4b72a0ef 100644 --- a/tests/unit/sampletones_application/coordinators/playback/test_router.py +++ b/tests/unit/sampletones_application/coordinators/playback/test_router.py @@ -161,6 +161,26 @@ def test_stop_silences_a_preview_when_no_source_is_engaged(self) -> None: assert device.stop_calls == 1 +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): From 223ecc3a4c5994e0bc046819b9931ab08ba47306 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 11:51:07 +0200 Subject: [PATCH 042/152] Fixed: song player stop --- .../services/song_player/constants.py | 1 + .../services/song_player/player.py | 62 ++++- .../services/song_player/test_song_player.py | 221 +++++++++++++++++- 3 files changed, 275 insertions(+), 9 deletions(-) diff --git a/src/sampletones_application/services/song_player/constants.py b/src/sampletones_application/services/song_player/constants.py index ef4a66382..00938292a 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 27303176b..e4cf3c801 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,6 +202,10 @@ 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() @@ -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/tests/unit/sampletones_application/services/song_player/test_song_player.py b/tests/unit/sampletones_application/services/song_player/test_song_player.py index e63e166ef..5ccf818c2 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,4 +1,6 @@ -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 @@ -7,11 +9,20 @@ _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( *, @@ -30,6 +41,103 @@ 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 _make_device_manager(stream: Optional[_FakeStream] = None) -> MagicMock: + 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 + 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() @@ -334,3 +442,114 @@ 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 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() From c222536a6840a5c4990569962e2f32dd12098d03 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 12:23:36 +0200 Subject: [PATCH 043/152] Fixed: audio device manager release --- src/sampletones_application/application.py | 14 ++- .../services/song_player/player.py | 4 +- src/sampletones_core/audio/manager.py | 83 ++++++++++++-- .../services/song_player/test_song_player.py | 33 ++++++ .../sampletones_core/audio/test_manager.py | 102 +++++++++++++++++- 5 files changed, 220 insertions(+), 16 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index a46b6c27c..c54e35a9a 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -148,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 @@ -1105,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, diff --git a/src/sampletones_application/services/song_player/player.py b/src/sampletones_application/services/song_player/player.py index e4cf3c801..d8ec39f05 100644 --- a/src/sampletones_application/services/song_player/player.py +++ b/src/sampletones_application/services/song_player/player.py @@ -207,8 +207,7 @@ def _write_loop(self) -> None: 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: @@ -216,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 diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index 5a3b4f774..bbcfab300 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") @@ -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/tests/unit/sampletones_application/services/song_player/test_song_player.py b/tests/unit/sampletones_application/services/song_player/test_song_player.py index 5ccf818c2..40db1bcbd 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 @@ -105,11 +105,18 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: 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 @@ -529,6 +536,32 @@ def test_start_is_refused_while_a_worker_still_holds_the_output(self) -> None: 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") diff --git a/tests/unit/sampletones_core/audio/test_manager.py b/tests/unit/sampletones_core/audio/test_manager.py index bd12cec83..27902d376 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 From 709acbd5471438fa8ad2b85386316184579f5fff Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 12:39:08 +0200 Subject: [PATCH 044/152] Documented: audio backend teardown ordering --- docs/development/playback.md | 17 +++++++++++++++++ src/sampletones_core/audio/device.py | 2 +- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/docs/development/playback.md b/docs/development/playback.md index 272efd9b8..b8fb18a9a 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -151,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 | @@ -158,6 +174,7 @@ 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) | diff --git a/src/sampletones_core/audio/device.py b/src/sampletones_core/audio/device.py index 8397dc12d..4c8db5c82 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) From 68c1f47d8573062bac52ed1d306a6388c1be8e94 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 14:24:42 +0200 Subject: [PATCH 045/152] Fixed: macOS CI PortAudio dependency for pyaudio --- .github/workflows/ci.yml | 6 ++++++ .github/workflows/workflow.yml | 10 ++++++---- Makefile | 16 +++++++++------- README.md | 5 +++-- docs/development/dependencies.md | 6 ++++++ docs/guide/installation.md | 14 +++++++++----- install.sh | 2 +- scripts/macos/build/build_env.sh | 15 +++++++++++++++ scripts/macos/build/dependencies.sh | 17 +++++++++++++++++ .../{source_only.sh => build/no_bundle.sh} | 1 + 10 files changed, 73 insertions(+), 19 deletions(-) create mode 100755 scripts/macos/build/build_env.sh create mode 100755 scripts/macos/build/dependencies.sh rename scripts/macos/{source_only.sh => build/no_bundle.sh} (90%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 506ead4f6..1c8772a42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 2d66a283f..3cdb13833 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/Makefile b/Makefile index 72b13e45c..0fec3476d 100644 --- a/Makefile +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 9997d6acf..c7751818d 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/dependencies.md b/docs/development/dependencies.md index 5c09edac9..a64068f6e 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/guide/installation.md b/docs/guide/installation.md index 5f1daec9f..74efb5882 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/install.sh b/install.sh index ec1b277d0..73eea2e26 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/scripts/macos/build/build_env.sh b/scripts/macos/build/build_env.sh new file mode 100755 index 000000000..f6d195827 --- /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 000000000..ca5049082 --- /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 93b7543eb..b6cfb4ec7 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 From ab8ab03deb908de0f9b51fa8518bcc63cd045a11 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 14:53:50 +0200 Subject: [PATCH 046/152] Fixed: unit tests --- .../unit/sampletones_application/test_startup.py | 15 ++++++++++++++- .../meta/source/test_modules.py | 5 ++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index 9b753f56f..b6cc4525b 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -10,6 +10,7 @@ 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 @@ -112,7 +113,11 @@ def app(tmp_path: Path) -> Generator[Any, Application, Any]: class TestKeybindingPreferences: - """The application runs on the keys the session stores, which is what makes a rebind stick.""" + """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]: @@ -122,6 +127,14 @@ def application(self, tmp_path: Path) -> Generator[Any, Application, Any]: 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, diff --git a/tests/unit/sampletones_shared/meta/source/test_modules.py b/tests/unit/sampletones_shared/meta/source/test_modules.py index a010c5f62..777586ccb 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 @@ -119,7 +120,9 @@ def test_roots_holding_no_source_raise(self, tmp_path: Path) -> None: source_paths([tmp_path]) def test_the_report_names_the_root_it_read_nothing_under(self, tmp_path: Path) -> None: - with pytest.raises(FileNotFoundError, match=str(tmp_path)): + """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]) From 3937b268f437bc0a1e37a6ae9c3868579538e02c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 16:44:35 +0200 Subject: [PATCH 047/152] Fixed: playing-row mark scoped to the sounding frame --- docs/development/playback.md | 11 ++- docs/guide/sequencer.md | 10 +-- .../coordinators/tabs/sequencer.py | 50 ++++++++----- .../ui/panels/sequencer/tracker.py | 49 ++++++++++--- .../coordinators/tabs/test_sequencer.py | 46 +++++++++--- .../sequencer/test_tracker_navigation.py | 55 +++++++++++--- .../ui/panels/sequencer/test_tracker_rows.py | 72 ++++++++++++++++--- 7 files changed, 230 insertions(+), 63 deletions(-) diff --git a/docs/development/playback.md b/docs/development/playback.md index b8fb18a9a..025e4aad9 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -101,8 +101,12 @@ answers — whether the tracker shows the frame being played, and whether it scr 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. + +A mark belongs to what it names. The playhead's position is a frame and a row within it, so the +order grid marks the frame under every mode, while the row's mark reads as the sounding row of the +pattern on screen: the tracker carries it while the frame it shows is the frame that sounds, and the +mark travels with the frame across a structural order edit. Every mode paints on this rule, and the +mode governs where the view sits. ## Keyboard delivery under field focus @@ -181,7 +185,8 @@ terminating would reclaim. | 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`) | +| Where the playhead stands, and both grids' marks for it | `SequencerTabCoordinator` (`coordinators/tabs/sequencer.py`) | +| Marking and revealing the sounding row in the tracker | `GUISequencerTrackerPanel` (`ui/panels/sequencer/tracker.py`) | | Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer.py`) | | The song's render-ahead buffer | `services/song_player/` | diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index cc1491d99..b4b31c1f0 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -68,10 +68,12 @@ plays, and the choice is remembered for the next time you launch: | **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. +The **Order** grid marks the frame being played under every mode, and the tracker +marks the sounding row of the frame it shows — so a held view still shows the +playhead each time the song passes through the frame you are editing. +**Follow rows** is the one that moves the grid while you play, which is what makes +the other two the modes to type in: they hold the view still under your cursor while +the song runs. ## Listening to one channel at a time diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 18e10326a..635573f50 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -93,6 +93,7 @@ from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.song_position import SongPosition from sampletones_core.reconstructions import Reconstruction from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger @@ -144,7 +145,7 @@ def __init__( self._msg_no_project = language_manager["global.dialog.message.no_project_open"] self._ttl_no_project = language_manager["global.dialog.title.no_project_open"] self._nes_frequency_change_acknowledged: bool = False - self._playing_order: Optional[int] = None + self._playing_position: Optional[SongPosition] = None self._geometry = layout.geometry self._side_panel_count: int self._instruments_width = layout.right_column_width @@ -752,9 +753,8 @@ def _on_player_view_changed(self, view_model: SongPlayerViewModel) -> None: """ 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_tracker_panel.set_playing_row(None) - self._sequencer_order_panel.set_playing_position(None) + self._playing_position = None + self._mark_playhead() def _on_player_position_changed( self, @@ -763,15 +763,29 @@ def _on_player_position_changed( ) -> 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, + The frame is selected ahead of the marks so the row's mark, and the scroll that reveals it, land on the pattern the playhead has reached. """ - self._playing_order = order_position + self._playing_position = SongPosition( + order_position=order_position, + row_index=row_index, + ) if self._song_player_logic.follow_mode.follows_pattern: self._sequencer_tracker_logic.select_frame(order_position) - self._sequencer_tracker_panel.set_playing_row(row_index) - self._sequencer_order_panel.set_playing_position(order_position) + self._mark_playhead() + + def _mark_playhead(self) -> None: + """Puts the playhead's marks where it stands, on both grids. + + The order grid marks the frame the playhead sounds; the tracker takes the whole position, + since the row it marks belongs to the pattern of that frame. + """ + position = self._playing_position + self._sequencer_tracker_panel.set_playing_position(position) + self._sequencer_order_panel.set_playing_position( + position.order_position if position is not None else None, + ) def _on_order_frame_selected(self, frame_index: int) -> None: """Selects an order frame in the tracker, and moves the playhead too when following. @@ -1276,19 +1290,23 @@ def _on_order_play_from(self, position: int) -> None: def _relocate_playhead(self, remap: Callable[[int], int]) -> None: """Keeps the live playhead on the frame it was sounding after a structural order edit. - The new position is reflected in the playing highlight straight away, ahead of the worker's - next row update, so rapid edits (e.g. a held Alt+arrow) stay in step. + Both grids take the new position straight away, ahead of the worker's next row update, so + rapid edits (e.g. a held Alt+arrow) stay in step, and a paused playhead — which reports no + further rows — is marked on the frame the edit moved it to. """ - if self._playing_order is None: + if self._playing_position is None: return - new_order = remap(self._playing_order) - if new_order == self._playing_order: + order_position = remap(self._playing_position.order_position) + if order_position == self._playing_position.order_position: return - self._playing_order = new_order - self._song_player_logic.relocate(new_order) - self._sequencer_order_panel.set_playing_position(new_order) + self._playing_position = SongPosition( + order_position=order_position, + row_index=self._playing_position.row_index, + ) + self._song_player_logic.relocate(order_position) + self._mark_playhead() def _select_frame_when_idle(self, frame_index: int) -> None: """Moves the editor selection to a frame, unless playback is actively driving it.""" diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index a9299406b..642cd0bdc 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -87,6 +87,7 @@ ) from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.project.song_position import SongPosition from sampletones_core.utils.display import NOTE_OFF, display_id from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP from sampletones_shared.types.application import ColorRGBA, Sender @@ -142,6 +143,8 @@ def __init__( self._editable_cells: EditableCells[CellKey] = EditableCells() self._current_row_count: int = 0 self._highlighted_row: Optional[int] = None + self._displayed_frame: Optional[int] = None + self._playing_frame: Optional[int] = None self._playing_row: Optional[int] = None self._painted_row: Optional[int] = None self._follows_playing_row: bool = False @@ -409,11 +412,25 @@ def update_tracker(self, view_model: SequencerTrackerViewModel) -> None: the edit cursor that a full rebuild would otherwise discard. """ cell_values = self._compute_cell_values(view_model) + self._show_frame(view_model.frame_index) if len(view_model.rows) != self._current_row_count: self._rebuild_table(view_model, cell_values) else: self._editable_cells.reconcile(cell_values, self._render_cell) + def _show_frame(self, frame_index: int) -> None: + """Records the order frame the grid stands on, and settles the playhead's mark for it. + + The mark reads as the sounding row of the pattern on screen, so it belongs to the frame the + playhead sounds: a frame arriving at the grid takes the mark while playback stands on it, + and hands it back as the reader moves on to another frame. + """ + if frame_index == self._displayed_frame: + return + + self._displayed_frame = frame_index + self._paint_playhead() + def _rebuild_table( self, view_model: SequencerTrackerViewModel, @@ -1457,22 +1474,33 @@ 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: - """Moves the playhead to the row playback reached, mark and grid arriving together. + def set_playing_position(self, position: Optional[SongPosition]) -> None: + """Moves the playhead to the position 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. + The position carries the order frame with the row, which is what tells the grid whether the + row it would mark belongs to the pattern it shows. A row's mark is drawn on the very next + frame while the grid answers a scroll on the frame after that, so a mark drawn as the row is + reported stands a row clear of the band's head until the grid catches up — a step down and + back on every row. Holding the mark until the frame its scroll lands on carries the two as + one. """ - self._playing_row = row_index + self._playing_frame = position.order_position if position is not None else None + self._playing_row = position.row_index if position is not None else None self._reveal_playing_row() FrameCallbackManager.set_frame_callback(self._paint_playhead, PLAYHEAD_PAINT_FRAMES) + @property + def _playhead_row(self) -> Optional[int]: + """The row the mark stands on: the sounding row, while the grid shows the frame it sounds.""" + if self._playing_frame == self._displayed_frame: + return self._playing_row + + return None + def _paint_playhead(self) -> None: """Draws the mark on the row the playhead has reached, clearing the row it came from.""" previous = self._painted_row - self._painted_row = self._playing_row + self._painted_row = self._playhead_row if previous is not None and previous != self._painted_row: self._paint_row(previous) @@ -1481,8 +1509,9 @@ def _paint_playhead(self) -> None: 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) + row_index = self._playhead_row + if self._follows_playing_row and row_index is not None: + self._scroll_row_to_band_top(row_index) def _live_row_count(self) -> int: """The table's current pattern-row count, read live from DearPyGui. diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 7a7ca1e76..464f0aafe 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -37,6 +37,7 @@ HistoryDetailWordSegment, ) from sampletones_core.constants.enums import GeneratorName +from sampletones_core.project.song_position import SongPosition from sampletones_shared.exceptions import InvalidReconstructionValuesError from tests.suite.language import FakeLanguageManager @@ -233,6 +234,14 @@ def test_cancel_restores_the_field( nes_frequency_coordinator._sequencer_tracker_logic.push_settings.assert_called_once() +SOUNDING_ROW: Final[int] = 7 + + +def _playhead(frame_index: int, row_index: int) -> SongPosition: + """The playhead standing on a row of an order frame.""" + return SongPosition(order_position=frame_index, row_index=row_index) + + def _player_view(*, follow_mode: FollowMode) -> SongPlayerViewModel: """A stopped transport view, which is what the coordinator reads the follow behaviour from.""" return SongPlayerViewModel( @@ -254,6 +263,7 @@ def playback_coordinator() -> SequencerTabCoordinator: instance._sequencer_tracker_logic = MagicMock() instance._sequencer_tracker_panel = MagicMock() instance._sequencer_order_panel = MagicMock() + instance._playing_position = None return instance @@ -269,7 +279,8 @@ def test_the_sounding_frame_is_shown_while_the_mode_follows_patterns( playback_coordinator._on_player_position_changed(2, 5) - playback_coordinator._sequencer_tracker_panel.set_playing_row.assert_called_once_with(5) + panel = playback_coordinator._sequencer_tracker_panel + panel.set_playing_position.assert_called_once_with(_playhead(2, 5)) playback_coordinator._sequencer_order_panel.set_playing_position.assert_called_once_with(2) assert playback_coordinator._sequencer_tracker_logic.select_frame.called is mode.follows_pattern @@ -286,7 +297,7 @@ def test_the_frame_is_selected_before_the_row_is_marked( playback_coordinator._on_player_position_changed(2, 5) names = [name for name, _, _ in recorder.mock_calls] - assert names.index("logic.select_frame") < names.index("panel.set_playing_row") + assert names.index("logic.select_frame") < names.index("panel.set_playing_position") @pytest.mark.parametrize("mode", list(FollowMode), ids=str) def test_the_view_states_whether_the_grid_follows_the_row( @@ -305,7 +316,7 @@ def test_a_stopped_view_drops_the_marks( ) -> None: playback_coordinator._on_player_view_changed(_player_view(follow_mode=FollowMode.ROWS)) - playback_coordinator._sequencer_tracker_panel.set_playing_row.assert_called_once_with(None) + playback_coordinator._sequencer_tracker_panel.set_playing_position.assert_called_once_with(None) playback_coordinator._sequencer_order_panel.set_playing_position.assert_called_once_with(None) @pytest.mark.parametrize("mode", list(FollowMode), ids=str) @@ -360,9 +371,10 @@ def order_ops_coordinator() -> SequencerTabCoordinator: instance._sequencer_order_logic = MagicMock() instance._sequencer_tracker_logic = MagicMock() instance._sequencer_order_panel = MagicMock() + instance._sequencer_tracker_panel = MagicMock() instance._song_player_logic = MagicMock() instance._project_controller = MagicMock() - instance._playing_order = None + instance._playing_position = None return instance @@ -383,7 +395,7 @@ def test_remove_pulls_playhead_earlier_when_playing( order_ops_coordinator: SequencerTabCoordinator, ) -> None: coordinator = order_ops_coordinator - coordinator._playing_order = 3 + coordinator._playing_position = _playhead(3, SOUNDING_ROW) coordinator._project_controller.order_length = 5 coordinator._on_order_remove(1) @@ -396,7 +408,7 @@ def test_remove_does_not_relocate_when_not_playing( order_ops_coordinator: SequencerTabCoordinator, ) -> None: coordinator = order_ops_coordinator - coordinator._playing_order = None + coordinator._playing_position = None coordinator._project_controller.order_length = 5 coordinator._on_order_remove(1) @@ -408,7 +420,7 @@ def test_duplicate_before_playhead_shifts_it_later( order_ops_coordinator: SequencerTabCoordinator, ) -> None: coordinator = order_ops_coordinator - coordinator._playing_order = 2 + coordinator._playing_position = _playhead(2, SOUNDING_ROW) coordinator._song_player_logic.is_playing.return_value = True coordinator._on_order_duplicate(0) @@ -421,7 +433,7 @@ def test_move_makes_the_playing_frame_follow_itself( order_ops_coordinator: SequencerTabCoordinator, ) -> None: coordinator = order_ops_coordinator - coordinator._playing_order = 2 + coordinator._playing_position = _playhead(2, SOUNDING_ROW) coordinator._song_player_logic.is_playing.return_value = True coordinator._on_order_move(2, 5) @@ -436,7 +448,7 @@ def test_move_advances_cursor_and_highlight_immediately( # The cursor and playing highlight must advance on the keypress, not on the next row # update, so a rapid second Alt+arrow acts on the moved frame rather than snapping back. coordinator = order_ops_coordinator - coordinator._playing_order = 2 + coordinator._playing_position = _playhead(2, SOUNDING_ROW) coordinator._song_player_logic.is_playing.return_value = True coordinator._on_order_move(2, 3) @@ -444,12 +456,26 @@ def test_move_advances_cursor_and_highlight_immediately( coordinator._sequencer_tracker_logic.select_frame.assert_called_once_with(3) coordinator._sequencer_order_panel.set_playing_position.assert_called_once_with(3) + def test_move_carries_the_sounding_row_to_the_frame_it_lands_on( + self, + order_ops_coordinator: SequencerTabCoordinator, + ) -> None: + """The tracker's mark belongs to a frame, so an edit that moves the frame moves the mark.""" + coordinator = order_ops_coordinator + coordinator._playing_position = _playhead(2, SOUNDING_ROW) + coordinator._song_player_logic.is_playing.return_value = True + + coordinator._on_order_move(2, 5) + + panel = coordinator._sequencer_tracker_panel + panel.set_playing_position.assert_called_once_with(_playhead(5, SOUNDING_ROW)) + def test_clear_leaves_the_playhead_in_place( self, order_ops_coordinator: SequencerTabCoordinator, ) -> None: coordinator = order_ops_coordinator - coordinator._playing_order = 2 + coordinator._playing_position = _playhead(2, SOUNDING_ROW) coordinator._on_order_clear(2) 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 index 88131ff40..e755baf85 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -13,6 +13,7 @@ from sampletones_application.utils.gui.keyboard.keys import KEY_PAGE_DOWN, KEY_PAGE_UP from sampletones_application.utils.gui.keyboard.modifiers import NO_MODIFIERS from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.project.song_position import SongPosition from sampletones_shared.types.callback import VoidCallback from tests.suite.shortcuts import shipped_source @@ -24,6 +25,9 @@ BAND_TOP = 100.0 LAST_HEADING_ROW = 32 +SHOWN_FRAME = 3 +OTHER_FRAME = 4 + def _panel() -> GUISequencerTrackerPanel: panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) @@ -33,6 +37,8 @@ def _panel() -> GUISequencerTrackerPanel: pending="", ) panel._layout = SimpleNamespace(tracker=SimpleNamespace(page_size=PAGE_SIZE)) + panel._displayed_frame = SHOWN_FRAME + panel._playing_frame = None panel._playing_row = None panel._painted_row = None panel._follows_playing_row = False @@ -41,6 +47,11 @@ def _panel() -> GUISequencerTrackerPanel: return panel +def _playhead(frame_index: int, row_index: int) -> SongPosition: + """The playhead standing on a row of an order frame.""" + return SongPosition(order_position=frame_index, row_index=row_index) + + def _press(text: str) -> KeyEvent: """The press a written combination names, as the router delivers it.""" combination = KeyCombination.parse(text) @@ -123,7 +134,7 @@ def test_a_followed_row_is_revealed(self, monkeypatch: pytest.MonkeyPatch) -> No monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append) panel.set_row_following(True) - panel.set_playing_row(12) + panel.set_playing_position(_playhead(SHOWN_FRAME, 12)) assert revealed == [12] @@ -134,7 +145,19 @@ def test_an_unfollowed_row_stays_where_the_reader_left_it(self, monkeypatch: pyt monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append) panel.set_row_following(False) - panel.set_playing_row(12) + panel.set_playing_position(_playhead(SHOWN_FRAME, 12)) + + assert revealed == [] + + def test_a_row_of_another_frame_holds_the_grid_where_it_is(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A row belongs to its own pattern, so the grid travels to it once that frame is shown.""" + panel = _panel() + revealed: List[int] = [] + monkeypatch.setattr(panel, "_paint_row", lambda row_index: None) + monkeypatch.setattr(panel, "_scroll_row_to_band_top", revealed.append) + + panel.set_row_following(True) + panel.set_playing_position(_playhead(OTHER_FRAME, 12)) assert revealed == [] @@ -146,8 +169,8 @@ def test_a_cleared_playhead_leaves_the_scroll_alone(self, monkeypatch: pytest.Mo 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) + panel.set_playing_position(_playhead(SHOWN_FRAME, 12)) + panel.set_playing_position(None) assert revealed == [12] @@ -192,7 +215,7 @@ def test_the_mark_waits_for_the_frame_its_scroll_lands_on(self, monkeypatch: pyt panel = _panel() painted, paint = _deferred_painting(monkeypatch, panel) - panel.set_playing_row(12) + panel.set_playing_position(_playhead(SHOWN_FRAME, 12)) assert panel._painted_row is None assert painted == [] @@ -206,9 +229,9 @@ def test_the_row_the_playhead_left_is_cleared(self, monkeypatch: pytest.MonkeyPa panel = _panel() painted, paint = _deferred_painting(monkeypatch, panel) - panel.set_playing_row(12) + panel.set_playing_position(_playhead(SHOWN_FRAME, 12)) paint() - panel.set_playing_row(13) + panel.set_playing_position(_playhead(SHOWN_FRAME, 13)) paint() assert painted == [12, 12, 13] @@ -217,14 +240,28 @@ def test_a_stopped_playhead_clears_the_row_it_stood_on(self, monkeypatch: pytest panel = _panel() painted, paint = _deferred_painting(monkeypatch, panel) - panel.set_playing_row(12) + panel.set_playing_position(_playhead(SHOWN_FRAME, 12)) paint() - panel.set_playing_row(None) + panel.set_playing_position(None) paint() assert panel._painted_row is None assert painted == [12, 12] + def test_the_mark_arrives_with_the_frame_the_playhead_moved_to(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A followed playhead crossing a frame boundary shows the next frame, then marks its row.""" + panel = _panel() + painted, paint = _deferred_painting(monkeypatch, panel) + + panel.set_playing_position(_playhead(SHOWN_FRAME, 12)) + paint() + panel._show_frame(OTHER_FRAME) + panel.set_playing_position(_playhead(OTHER_FRAME, 0)) + paint() + + assert panel._painted_row == 0 + assert painted == [12, 12, 0] + def _deferred_painting( monkeypatch: pytest.MonkeyPatch, 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 index 441b3eba7..49c889e35 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -15,11 +15,15 @@ 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_core.project.song_position import SongPosition from sampletones_shared.types.application import ColorRGBA PATTERN_ROWS = 4 HEADER_AND_PATTERN_ROWS = PATTERN_ROWS + 1 +SHOWN_FRAME = 3 +OTHER_FRAME = 4 + ROWS_PER_BEAT = 2 ROWS_PER_BAR = 4 BAR_ROWS = (0,) @@ -88,6 +92,8 @@ def _panel() -> GUISequencerTrackerPanel: ) panel._current_row_count = PATTERN_ROWS panel._highlighted_row = None + panel._displayed_frame = SHOWN_FRAME + panel._playing_frame = None panel._playing_row = None panel._painted_row = None panel._follows_playing_row = False @@ -95,6 +101,11 @@ def _panel() -> GUISequencerTrackerPanel: return panel +def _playhead(frame_index: int, row_index: int) -> SongPosition: + """The playhead standing on a row of an order frame.""" + return SongPosition(order_position=frame_index, row_index=row_index) + + def _place_cursor( panel: GUISequencerTrackerPanel, row_index: int, @@ -286,7 +297,7 @@ def test_the_playhead_lands_on_the_mapped_table_row( ) -> None: panel = _panel() - panel.set_playing_row(row_index) + panel.set_playing_position(_playhead(SHOWN_FRAME, row_index)) assert recorder.highlighted_rows == {tracker_table_row(row_index): PLAYBACK_ROW} @@ -298,7 +309,7 @@ def test_the_playhead_over_a_group_row_carries_both_shades( ) -> None: panel = _panel() - panel.set_playing_row(row_index) + panel.set_playing_position(_playhead(SHOWN_FRAME, row_index)) painted = recorder.highlighted_rows[tracker_table_row(row_index)] assert painted[3] > PLAYBACK_ROW[3] @@ -307,49 +318,88 @@ def test_the_playhead_outranks_the_cursor_on_the_same_row(self, recorder: _Table panel = _panel() _place_cursor(panel, 1, GeneratorName.PULSE1) - panel.set_playing_row(1) + panel.set_playing_position(_playhead(SHOWN_FRAME, 1)) assert recorder.highlighted_rows == {tracker_table_row(1): PLAYBACK_ROW} def test_the_last_pattern_row_is_still_within_the_table(self, recorder: _TableRecorder) -> None: panel = _panel() - panel.set_playing_row(PATTERN_ROWS - 1) + panel.set_playing_position(_playhead(SHOWN_FRAME, PATTERN_ROWS - 1)) assert recorder.highlighted_rows def test_a_row_beyond_the_pattern_is_left_to_the_next_rebuild(self, recorder: _TableRecorder) -> None: panel = _panel() - panel.set_playing_row(PATTERN_ROWS) + panel.set_playing_position(_playhead(SHOWN_FRAME, PATTERN_ROWS)) assert not recorder.highlighted_rows def test_advancing_the_playhead_returns_the_row_it_left(self, recorder: _TableRecorder) -> None: panel = _panel() - panel.set_playing_row(1) - panel.set_playing_row(3) + panel.set_playing_position(_playhead(SHOWN_FRAME, 1)) + panel.set_playing_position(_playhead(SHOWN_FRAME, 3)) assert recorder.unhighlighted_rows == [tracker_table_row(1)] def test_advancing_past_a_group_row_gives_it_its_shade_back(self, recorder: _TableRecorder) -> None: panel = _panel() - panel.set_playing_row(0) - panel.set_playing_row(1) + panel.set_playing_position(_playhead(SHOWN_FRAME, 0)) + panel.set_playing_position(_playhead(SHOWN_FRAME, 1)) assert recorder.highlighted_rows[tracker_table_row(0)] == BAR_ROW def test_stopping_clears_the_mapped_row(self, recorder: _TableRecorder) -> None: panel = _panel() - panel.set_playing_row(3) + panel.set_playing_position(_playhead(SHOWN_FRAME, 3)) - panel.set_playing_row(None) + panel.set_playing_position(None) assert recorder.unhighlighted_rows == [tracker_table_row(3)] +class TestPlayheadFrame: + """The mark reads as the sounding row of the pattern on screen, so it stands on the grid while + the frame it shows is the frame the playhead sounds.""" + + def test_a_row_of_another_frame_leaves_the_grid_alone(self, recorder: _TableRecorder) -> None: + panel = _panel() + + panel.set_playing_position(_playhead(OTHER_FRAME, 1)) + + assert not recorder.highlighted_rows + + def test_showing_another_frame_returns_the_marked_row(self, recorder: _TableRecorder) -> None: + panel = _panel() + panel.set_playing_position(_playhead(SHOWN_FRAME, 3)) + + panel._show_frame(OTHER_FRAME) + + assert recorder.unhighlighted_rows == [tracker_table_row(3)] + + def test_returning_to_the_sounding_frame_marks_its_row_again(self, recorder: _TableRecorder) -> None: + panel = _panel() + panel.set_playing_position(_playhead(SHOWN_FRAME, 3)) + panel._show_frame(OTHER_FRAME) + + panel._show_frame(SHOWN_FRAME) + + assert recorder.highlighted_rows == {tracker_table_row(3): PLAYBACK_ROW} + + def test_the_cursor_keeps_its_row_on_a_frame_the_playhead_left(self, recorder: _TableRecorder) -> None: + """A frame the playhead is away from shows the reader's own cursor on the row it sits on.""" + panel = _panel() + _place_cursor(panel, 3, GeneratorName.PULSE1) + panel.set_playing_position(_playhead(SHOWN_FRAME, 3)) + + panel._show_frame(OTHER_FRAME) + + assert recorder.highlighted_rows[tracker_table_row(3)] == CURSOR_ROW + + class TestHeaderRowBackground: def test_every_table_column_of_the_header_takes_the_header_shade( self, From 57bc6f7a3de353419b5ef87dcb6d59f37247f48f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 19:31:15 +0200 Subject: [PATCH 048/152] Added: groove calculator for tempo-driven row timing --- src/sampletones_core/timing/__init__.py | 13 + src/sampletones_core/timing/distribution.py | 77 ++ src/sampletones_core/timing/groove.py | 125 +++ src/sampletones_core/timing/metre.py | 60 ++ src/sampletones_core/timing/rate.py | 77 ++ .../unit/sampletones_core/timing/__init__.py | 0 .../timing/test_distribution.py | 181 ++++ .../sampletones_core/timing/test_groove.py | 883 ++++++++++++++++++ .../sampletones_core/timing/test_metre.py | 146 +++ .../unit/sampletones_core/timing/test_rate.py | 126 +++ 10 files changed, 1688 insertions(+) create mode 100644 src/sampletones_core/timing/__init__.py create mode 100644 src/sampletones_core/timing/distribution.py create mode 100644 src/sampletones_core/timing/groove.py create mode 100644 src/sampletones_core/timing/metre.py create mode 100644 src/sampletones_core/timing/rate.py create mode 100644 tests/unit/sampletones_core/timing/__init__.py create mode 100644 tests/unit/sampletones_core/timing/test_distribution.py create mode 100644 tests/unit/sampletones_core/timing/test_groove.py create mode 100644 tests/unit/sampletones_core/timing/test_metre.py create mode 100644 tests/unit/sampletones_core/timing/test_rate.py diff --git a/src/sampletones_core/timing/__init__.py b/src/sampletones_core/timing/__init__.py new file mode 100644 index 000000000..83f5fddcd --- /dev/null +++ b/src/sampletones_core/timing/__init__.py @@ -0,0 +1,13 @@ +from .distribution import distribute_by_halving, distribute_proportionally +from .groove import Groove, calculate_groove +from .metre import Metre +from .rate import RowRate + +__all__ = [ + "Groove", + "Metre", + "RowRate", + "calculate_groove", + "distribute_by_halving", + "distribute_proportionally", +] diff --git a/src/sampletones_core/timing/distribution.py b/src/sampletones_core/timing/distribution.py new file mode 100644 index 000000000..53d511ab7 --- /dev/null +++ b/src/sampletones_core/timing/distribution.py @@ -0,0 +1,77 @@ +from typing import List, Sequence, Tuple + + +def _divide_rounding_up(dividend: int, divisor: int) -> int: + """Divides two integers, carrying a fractional result up to the next integer.""" + return -(-dividend // divisor) + + +def distribute_proportionally( + total: int, + lengths: Sequence[int], +) -> Tuple[int, ...]: + """Shares a tick total among consecutive spans in proportion to their row counts. + + Each span ends at a boundary rounded up from its exact share, so where a share falls + between two integers the surplus tick goes to the earlier span. Over a pattern this + puts the longer rows on the earlier, metrically stronger positions. + + Only the floor and the ceiling of the average per row ever appear, which is what lets + a caller hold every row within an engine's speed range by bounding ``total`` alone. + + Args: + total: The tick count the spans share. + lengths: The row count of each span, in order, each at least 1. + + Returns: + Tuple[int, ...]: One tick total per span, together summing to ``total``. + + Raises: + ValueError: If no span is given, or a span holds fewer than one row. + """ + if not lengths: + raise ValueError("At least one span is required to share a tick total") + + if any(length < 1 for length in lengths): + raise ValueError(f"Every span must hold at least 1 row, got {tuple(lengths)}") + + rows = sum(lengths) + shares: List[int] = [] + cumulative = 0 + boundary = 0 + for length in lengths: + cumulative += length + previous, boundary = boundary, _divide_rounding_up(total * cumulative, rows) + shares.append(boundary - previous) + + return tuple(shares) + + +def distribute_by_halving(total: int, rows: int) -> Tuple[int, ...]: + """Shares a tick total among rows by halving the span down to single rows. + + The earlier half takes the extra row where the count is odd and the surplus tick + where the share is fractional, so within a beat the longer rows fall on the positions + a listener hears as strong: the first row, then the halfway row, then the quarters. + + Args: + total: The tick count the rows share. + rows: How many rows share it, at least 1. + + Returns: + Tuple[int, ...]: One tick count per row, together summing to ``total``. + + Raises: + ValueError: If fewer than one row is given. + """ + if rows < 1: + raise ValueError(f"rows must be at least 1, got {rows}") + + if rows == 1: + return (total,) + + left = _divide_rounding_up(rows, 2) + right = rows - left + halves = distribute_proportionally(total, (left, right)) + + return distribute_by_halving(halves[0], left) + distribute_by_halving(halves[1], right) diff --git a/src/sampletones_core/timing/groove.py b/src/sampletones_core/timing/groove.py new file mode 100644 index 000000000..368ff36f7 --- /dev/null +++ b/src/sampletones_core/timing/groove.py @@ -0,0 +1,125 @@ +from dataclasses import dataclass +from fractions import Fraction +from math import floor +from typing import Final, List, Tuple + +from sampletones_core.timing.distribution import ( + distribute_by_halving, + distribute_proportionally, +) +from sampletones_core.timing.metre import Metre +from sampletones_core.timing.rate import RowRate + +HALF: Final[Fraction] = Fraction(1, 2) + + +@dataclass(frozen=True) +class Groove: + """The engine ticks each row of a pattern lasts. + + An engine that takes one speed value per row reaches a fractional row rate by varying + that value from row to row, which is how a tempo its speed column alone cannot state + still comes out right on average. The variation is placed by metre, so the longer rows + land on the bar, then the beat, then the subdivisions inside a beat. + + Attributes: + ticks: One tick count per pattern row, in order. + """ + + ticks: Tuple[int, ...] + + @property + def total_ticks(self) -> int: + """How many engine ticks the whole pattern lasts.""" + return sum(self.ticks) + + @property + def mean_ticks_per_row(self) -> Fraction: + """The row rate the groove realizes, which states what a bounded groove reached.""" + return Fraction(self.total_ticks, len(self.ticks)) + + @property + def is_uniform(self) -> bool: + """Whether every row lasts alike, so a single speed value carries the tempo.""" + return len(set(self.ticks)) == 1 + + +def _pattern_ticks( + rate: RowRate, + rows: int, + *, + minimum_ticks: int, + maximum_ticks: int, +) -> int: + """Rounds a pattern's exact tick count to the nearest integer within the engine's speed range. + + Rounding once, on the pattern, is what makes the pattern's duration the closest the + engine reaches; the metre then decides which rows carry the difference. Bounding the + pattern total rather than each row keeps every row inside the range as a consequence, + since a proportional split yields only the floor and the ceiling of the average. + + Args: + rate: The exact ticks one row lasts. + rows: The pattern's row count. + minimum_ticks: The fewest ticks the engine holds a row for. + maximum_ticks: The most ticks the engine holds a row for. + + Returns: + int: The tick count the pattern's rows share. + """ + exact = rate.ticks_per_row * rows + return min( + max(floor(exact + HALF), rows * minimum_ticks), + rows * maximum_ticks, + ) + + +def calculate_groove( + rate: RowRate, + metre: Metre, + *, + minimum_ticks: int, + maximum_ticks: int, +) -> Groove: + """Builds the per-row tick counts that carry a row rate across one pattern. + + The pattern's tick total is shared among its bars, each bar's among its beats, and + each beat's among its rows by halving — one rule applied at three levels, so the + surplus ticks settle on the strongest position each level offers. + + Args: + rate: The exact ticks one row lasts. + metre: The pattern's length and its beat and bar grouping. + minimum_ticks: The fewest ticks the engine holds a row for. + maximum_ticks: The most ticks the engine holds a row for. + + Returns: + Groove: One tick count per row of the pattern. + """ + total = _pattern_ticks( + rate, + metre.rows, + minimum_ticks=minimum_ticks, + maximum_ticks=maximum_ticks, + ) + bars = metre.spans + bar_lengths = tuple(sum(beats) for beats in bars) + + ticks: List[int] = [] + for beats, bar_ticks in zip( + bars, + distribute_proportionally( + total, + bar_lengths, + ), + ): + for beat_rows, beat_ticks in zip( + beats, + distribute_proportionally( + bar_ticks, + beats, + ), + ): + ticks.extend(distribute_by_halving(beat_ticks, beat_rows)) + + return Groove(ticks=tuple(ticks)) diff --git a/src/sampletones_core/timing/metre.py b/src/sampletones_core/timing/metre.py new file mode 100644 index 000000000..705c132aa --- /dev/null +++ b/src/sampletones_core/timing/metre.py @@ -0,0 +1,60 @@ +from dataclasses import dataclass +from typing import Tuple + + +@dataclass(frozen=True) +class Metre: + """The row grouping a pattern is felt in: its length, its beat, and the bar above it. + + ``first_highlight`` is the beat, the unit an actual tempo is read from, and + ``second_highlight`` gathers beats into a bar. The bar is what organizes emphases + where the beat divides the pattern unevenly; where both divide it cleanly the bar + grouping agrees with the beats on their own. + + The pattern length is the hard limit, so a span reaching past the last row ends + there and counts as the shorter span it is. This holds at both levels: a pattern of + 60 rows against a 16-row bar carries three whole bars and a 12-row one, and a bar + shorter than its beat carries a single beat of the rows that remain. + + Attributes: + rows: The pattern's row count. + first_highlight: The rows one beat spans. + second_highlight: The rows one bar spans. + """ + + rows: int + first_highlight: int + second_highlight: int + + def __post_init__(self) -> None: + if self.rows < 1: + raise ValueError(f"rows must be at least 1, got {self.rows}") + + if self.first_highlight < 1: + raise ValueError(f"first_highlight must be at least 1, got {self.first_highlight}") + + if self.second_highlight < 1: + raise ValueError(f"second_highlight must be at least 1, got {self.second_highlight}") + + @property + def spans(self) -> Tuple[Tuple[int, ...], ...]: + """The whole grouping, as the beat row counts of each consecutive bar. + + Returns: + Tuple[Tuple[int, ...], ...]: One entry per bar, each holding that bar's beat + row counts in order, so the entries flattened come to ``rows``. + """ + return tuple( + self._divide(bar_rows, self.first_highlight) + for bar_rows in self._divide( + self.rows, + self.second_highlight, + ) + ) + + @staticmethod + def _divide(rows: int, unit: int) -> Tuple[int, ...]: + """Cuts a row span into consecutive units, the final one holding what remains.""" + whole, remainder = divmod(rows, unit) + spans = (unit,) * whole + return spans + (remainder,) if remainder else spans diff --git a/src/sampletones_core/timing/rate.py b/src/sampletones_core/timing/rate.py new file mode 100644 index 000000000..f02103858 --- /dev/null +++ b/src/sampletones_core/timing/rate.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction + +from sampletones_core.project.settings import ProjectSettings +from sampletones_shared.constants.project import ( + REFERENCE_NES_FREQUENCY, + REFERENCE_TEMPO, +) + + +@dataclass(frozen=True) +class RowRate: + """How long one tracker row lasts, in engine ticks, as the exact ratio a tempo asks for. + + The engine advances a row once every ``ticks_per_row`` ticks of its ``nes_frequency`` + interrupt, and ``speed`` states that count directly at ``REFERENCE_TEMPO`` and + ``REFERENCE_NES_FREQUENCY``, scaling from there with the tempo and the tick rate. + + The ratio is held exact, since a row rate is fractional for most tempi and the + fraction is what a groove distributes across a pattern's rows. + + A row rate reads as a tempo in beats per minute once a metre says how many rows one + beat spans:: + + beats_per_minute = 60 * nes_frequency / (ticks_per_row * first_highlight) + + which at the four-row beat of common time comes to ``6 * tempo / speed``, the figure + a tracker prints. The beat is therefore what turns a row rate into an actual tempo. + + Attributes: + ticks_per_row: The exact number of engine ticks one row lasts. + """ + + ticks_per_row: Fraction + + @classmethod + def from_parameters( + cls, + *, + tempo: int, + speed: int, + nes_frequency: int, + ) -> RowRate: + """Derives the row rate from the three settings that govern it. + + Args: + tempo: The project tempo. + speed: Engine ticks per row at the reference tempo and tick rate. + nes_frequency: The engine tick rate in Hz. + + Returns: + RowRate: The exact ticks one row lasts under those settings. + """ + return cls( + ticks_per_row=Fraction( + speed * nes_frequency * REFERENCE_TEMPO, + tempo * REFERENCE_NES_FREQUENCY, + ), + ) + + @classmethod + def from_settings(cls, settings: ProjectSettings) -> RowRate: + """Derives the row rate a project plays at. + + Args: + settings: The project settings holding the tempo, the speed and the tick rate. + + Returns: + RowRate: The exact ticks one row of this project lasts. + """ + return cls.from_parameters( + tempo=settings.tempo, + speed=settings.speed, + nes_frequency=settings.nes_frequency, + ) diff --git a/tests/unit/sampletones_core/timing/__init__.py b/tests/unit/sampletones_core/timing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/timing/test_distribution.py b/tests/unit/sampletones_core/timing/test_distribution.py new file mode 100644 index 000000000..f659f4b85 --- /dev/null +++ b/tests/unit/sampletones_core/timing/test_distribution.py @@ -0,0 +1,181 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_core.timing.distribution import distribute_by_halving, distribute_proportionally +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + + +class TestDistributeProportionally(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[int, ...] + total: int + lengths: Tuple[int, ...] + + @property + def label(self) -> str: + spans = "_".join(str(length) for length in self.lengths) + return f"total_{self.total}_over_{spans}" + + test_cases = ( + TestCase( + total=69, + lengths=(4, 4, 4, 4), + expected=(18, 17, 17, 17), + ), + TestCase( + total=69, + lengths=(8, 8), + expected=(35, 34), + ), + TestCase( + total=274, + lengths=(16, 16, 16, 16), + expected=(69, 68, 69, 68), + ), + TestCase( + total=17, + lengths=(2, 2), + expected=(9, 8), + ), + TestCase( + total=18, + lengths=(2, 2), + expected=(9, 9), + ), + TestCase( + total=100, + lengths=(1,), + expected=(100,), + ), + TestCase( + total=0, + lengths=(4, 4), + expected=(0, 0), + ), + TestCase( + total=69, + lengths=(12, 4), + expected=(52, 17), + ), + TestCase( + total=10, + lengths=(1, 1, 1), + expected=(4, 3, 3), + ), + TestCase( + total=11, + lengths=(1, 1, 1), + expected=(4, 4, 3), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_shares_match(self, test_case: TestCase) -> None: + shares = distribute_proportionally(test_case.total, test_case.lengths) + assert shares == test_case.expected + assert sum(shares) == test_case.total + + def test_no_span_is_rejected(self) -> None: + with pytest.raises(ValueError, match="At least one span"): + distribute_proportionally(10, ()) + + def test_empty_span_is_rejected(self) -> None: + with pytest.raises(ValueError, match="at least 1 row"): + distribute_proportionally(10, (4, 0)) + + +class TestDistributeByHalving(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[int, ...] + total: int + rows: int + + @property + def label(self) -> str: + return f"total_{self.total}_over_{self.rows}_rows" + + test_cases = ( + TestCase( + total=22, + rows=4, + expected=(6, 5, 6, 5), + ), + TestCase( + total=69, + rows=16, + expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4), + ), + TestCase( + total=18, + rows=4, + expected=(5, 4, 5, 4), + ), + TestCase( + total=17, + rows=4, + expected=(5, 4, 4, 4), + ), + TestCase( + total=9, + rows=2, + expected=(5, 4), + ), + TestCase( + total=100, + rows=1, + expected=(100,), + ), + TestCase( + total=0, + rows=5, + expected=(0, 0, 0, 0, 0), + ), + TestCase( + total=13, + rows=3, + expected=(5, 4, 4), + ), + TestCase( + total=22, + rows=5, + expected=(5, 5, 4, 4, 4), + ), + TestCase( + total=30, + rows=7, + expected=(5, 4, 5, 4, 4, 4, 4), + ), + TestCase( + total=52, + rows=12, + expected=(5, 4, 4, 5, 4, 4, 5, 4, 4, 5, 4, 4), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_ticks_match(self, test_case: TestCase) -> None: + ticks = distribute_by_halving(test_case.total, test_case.rows) + assert ticks == test_case.expected + assert sum(ticks) == test_case.total + + def test_absent_rows_are_rejected(self) -> None: + with pytest.raises(ValueError, match="rows must be at least 1"): + distribute_by_halving(10, 0) + + @pytest.mark.parametrize("rows", (1, 2, 3, 4, 5, 7, 8, 12, 16, 31, 64)) + def test_earlier_rows_run_at_least_as_long(self, rows: int) -> None: + ticks = distribute_by_halving(rows * 4 + 1, rows) + assert ticks[0] == max(ticks) diff --git a/tests/unit/sampletones_core/timing/test_groove.py b/tests/unit/sampletones_core/timing/test_groove.py new file mode 100644 index 000000000..f728dd2b3 --- /dev/null +++ b/tests/unit/sampletones_core/timing/test_groove.py @@ -0,0 +1,883 @@ +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction +from typing import Final, Tuple + +import pytest + +from sampletones_core.timing.groove import Groove, calculate_groove +from sampletones_core.timing.metre import Metre +from sampletones_core.timing.rate import RowRate +from sampletones_shared.constants.project import REFERENCE_NES_FREQUENCY, REFERENCE_TEMPO +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + +MINIMUM_TICKS: Final[int] = 1 +MAXIMUM_TICKS: Final[int] = 255 + +COMMON_TIME_BEAT: Final[int] = 4 +COMMON_TIME_BAR: Final[int] = 16 + +REFERENCE_SPEED: Final[int] = 6 + + +class TestGroove(BaseTestSuite): + """One case table, read both for the ticks it produces and for the rules they obey.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[int, ...] + tempo: int + speed: int + nes_frequency: int + rows: int + first_highlight: int + second_highlight: int + + @property + def label(self) -> str: + return ( + f"tempo_{self.tempo}_speed_{self.speed}_{self.nes_frequency}hz" + f"_{self.rows}r_{self.first_highlight}_{self.second_highlight}" + ) + + @property + def metre(self) -> Metre: + return Metre( + rows=self.rows, + first_highlight=self.first_highlight, + second_highlight=self.second_highlight, + ) + + @property + def groove(self) -> Groove: + return calculate_groove( + RowRate.from_parameters( + tempo=self.tempo, + speed=self.speed, + nes_frequency=self.nes_frequency, + ), + self.metre, + minimum_ticks=MINIMUM_TICKS, + maximum_ticks=MAXIMUM_TICKS, + ) + + test_cases = ( + TestCase( + tempo=150, + speed=6, + nes_frequency=60, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(6,) * 16, + ), + TestCase( + tempo=150, + speed=6, + nes_frequency=30, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(3,) * 16, + ), + TestCase( + tempo=75, + speed=6, + nes_frequency=60, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(12,) * 16, + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=15, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=24, + rows=12, + first_highlight=3, + second_highlight=12, + expected=(2, 2, 2, 2, 2, 1, 2, 2, 1, 2, 2, 1), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=25, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(2, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 1, 2, 2, 2, 1), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=30, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(3, 2, 2, 2, 2, 2, 2, 2, 3, 2, 2, 2, 2, 2, 2, 2), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=50, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(4, 4, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3, 4, 3), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=100, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(8, 7, 7, 7, 7, 7, 7, 7, 8, 7, 7, 7, 7, 7, 7, 7), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=120, + rows=8, + first_highlight=4, + second_highlight=16, + expected=(9, 9, 9, 8, 9, 8, 9, 8), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=300, + rows=8, + first_highlight=4, + second_highlight=16, + expected=(22, 21, 22, 21, 22, 21, 21, 21), + ), + TestCase( + tempo=37, + speed=13, + nes_frequency=25, + rows=11, + first_highlight=4, + second_highlight=16, + expected=(22,) * 11, + ), + TestCase( + tempo=33, + speed=7, + nes_frequency=17, + rows=13, + first_highlight=3, + second_highlight=12, + expected=(9,) * 13, + ), + TestCase( + tempo=137, + speed=11, + nes_frequency=23, + rows=7, + first_highlight=2, + second_highlight=3, + expected=(5, 5, 4, 5, 5, 4, 4), + ), + TestCase( + tempo=251, + speed=13, + nes_frequency=199, + rows=17, + first_highlight=5, + second_highlight=7, + expected=(26, 26, 26, 26, 26, 26, 25, 26, 26, 26, 26, 25, 26, 25, 26, 26, 25), + ), + TestCase( + tempo=97, + speed=3, + nes_frequency=41, + rows=9, + first_highlight=4, + second_highlight=6, + expected=(4, 3, 4, 3, 3, 3, 3, 3, 3), + ), + TestCase( + tempo=128, + speed=5, + nes_frequency=96, + rows=15, + first_highlight=4, + second_highlight=16, + expected=(10, 9, 10, 9, 10, 9, 10, 9, 10, 9, 9, 9, 10, 9, 9), + ), + TestCase( + tempo=100, + speed=7, + nes_frequency=45, + rows=13, + first_highlight=7, + second_highlight=7, + expected=(8, 8, 8, 8, 8, 8, 7, 8, 8, 8, 8, 8, 7), + ), + TestCase( + tempo=43, + speed=29, + nes_frequency=31, + rows=11, + first_highlight=3, + second_highlight=8, + expected=(53, 53, 52, 53, 52, 52, 52, 52, 52, 52, 52), + ), + TestCase( + tempo=199, + speed=17, + nes_frequency=47, + rows=19, + first_highlight=4, + second_highlight=16, + expected=(11, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=1, + first_highlight=4, + second_highlight=16, + expected=(4,), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=2, + first_highlight=4, + second_highlight=16, + expected=(5, 4), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=3, + first_highlight=4, + second_highlight=16, + expected=(5, 4, 4), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=5, + first_highlight=4, + second_highlight=16, + expected=(5, 4, 4, 4, 4), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=7, + first_highlight=4, + second_highlight=16, + expected=(5, 4, 5, 4, 4, 4, 4), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=12, + first_highlight=3, + second_highlight=12, + expected=(5, 4, 4, 5, 4, 4, 5, 4, 4, 4, 4, 4), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=12, + first_highlight=6, + second_highlight=12, + expected=(5, 4, 4, 5, 4, 4, 5, 4, 4, 4, 4, 4), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=13, + first_highlight=4, + second_highlight=16, + expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 4), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=17, + first_highlight=4, + second_highlight=16, + expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 4), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=23, + first_highlight=4, + second_highlight=16, + expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 5, 4, 4, 4, 4), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=32, + first_highlight=4, + second_highlight=16, + expected=(5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=60, + first_highlight=4, + second_highlight=16, + expected=( + 5, + 4, + 5, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 5, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + ), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + rows=64, + first_highlight=4, + second_highlight=16, + expected=( + 5, + 4, + 5, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 5, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + 5, + 4, + 4, + 4, + ), + ), + TestCase( + tempo=105, + speed=6, + nes_frequency=60, + rows=16, + first_highlight=1, + second_highlight=1, + expected=(9, 9, 8, 9, 8, 9, 8, 9, 9, 8, 9, 8, 9, 8, 9, 8), + ), + TestCase( + tempo=105, + speed=6, + nes_frequency=60, + rows=16, + first_highlight=2, + second_highlight=4, + expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), + ), + TestCase( + tempo=105, + speed=6, + nes_frequency=60, + rows=16, + first_highlight=3, + second_highlight=4, + expected=(9, 9, 9, 8, 9, 9, 8, 8, 9, 9, 8, 8, 9, 9, 8, 8), + ), + TestCase( + tempo=105, + speed=6, + nes_frequency=60, + rows=16, + first_highlight=5, + second_highlight=3, + expected=(9, 9, 8, 9, 9, 8, 9, 9, 8, 9, 8, 8, 9, 9, 8, 8), + ), + TestCase( + tempo=105, + speed=6, + nes_frequency=60, + rows=16, + first_highlight=16, + second_highlight=4, + expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), + ), + TestCase( + tempo=105, + speed=6, + nes_frequency=60, + rows=16, + first_highlight=64, + second_highlight=64, + expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), + ), + TestCase( + tempo=105, + speed=6, + nes_frequency=60, + rows=12, + first_highlight=4, + second_highlight=6, + expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), + ), + TestCase( + tempo=105, + speed=6, + nes_frequency=60, + rows=20, + first_highlight=4, + second_highlight=8, + expected=(9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8), + ), + TestCase( + tempo=105, + speed=6, + nes_frequency=60, + rows=9, + first_highlight=2, + second_highlight=6, + expected=(9, 9, 9, 8, 9, 8, 9, 8, 8), + ), + TestCase( + tempo=105, + speed=6, + nes_frequency=60, + rows=15, + first_highlight=5, + second_highlight=15, + expected=(9, 9, 8, 9, 8, 9, 9, 8, 9, 8, 9, 9, 8, 9, 8), + ), + TestCase( + tempo=150, + speed=1, + nes_frequency=60, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(1,) * 16, + ), + TestCase( + tempo=151, + speed=1, + nes_frequency=60, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(1,) * 16, + ), + TestCase( + tempo=140, + speed=1, + nes_frequency=60, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1), + ), + TestCase( + tempo=300, + speed=1, + nes_frequency=60, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(1,) * 16, + ), + TestCase( + tempo=255, + speed=1, + nes_frequency=60, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(1,) * 16, + ), + TestCase( + tempo=255, + speed=1, + nes_frequency=15, + rows=16, + first_highlight=4, + second_highlight=16, + expected=(1,) * 16, + ), + TestCase( + tempo=300, + speed=1, + nes_frequency=15, + rows=7, + first_highlight=4, + second_highlight=16, + expected=(1,) * 7, + ), + TestCase( + tempo=50, + speed=17, + nes_frequency=300, + rows=8, + first_highlight=4, + second_highlight=16, + expected=(255,) * 8, + ), + TestCase( + tempo=19, + speed=31, + nes_frequency=60, + rows=8, + first_highlight=4, + second_highlight=16, + expected=(245, 245, 245, 244, 245, 245, 245, 244), + ), + TestCase( + tempo=32, + speed=31, + nes_frequency=300, + rows=8, + first_highlight=4, + second_highlight=16, + expected=(255,) * 8, + ), + TestCase( + tempo=1, + speed=31, + nes_frequency=300, + rows=4, + first_highlight=4, + second_highlight=16, + expected=(255,) * 4, + ), + TestCase( + tempo=1, + speed=1, + nes_frequency=300, + rows=5, + first_highlight=4, + second_highlight=16, + expected=(255,) * 5, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_ticks_match(self, test_case: TestCase) -> None: + assert test_case.groove.ticks == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_groove_fills_the_pattern(self, test_case: TestCase) -> None: + assert len(test_case.groove.ticks) == test_case.rows + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_total_and_the_mean_describe_the_rows(self, test_case: TestCase) -> None: + groove = test_case.groove + assert groove.total_ticks == sum(groove.ticks) + assert groove.mean_ticks_per_row == Fraction(groove.total_ticks, test_case.rows) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_rate_is_reached_as_closely_as_the_range_allows(self, test_case: TestCase) -> None: + rate = RowRate.from_parameters( + tempo=test_case.tempo, + speed=test_case.speed, + nes_frequency=test_case.nes_frequency, + ) + reachable = min(max(rate.ticks_per_row, MINIMUM_TICKS), MAXIMUM_TICKS) + assert abs(test_case.groove.mean_ticks_per_row - reachable) <= Fraction(1, 2 * test_case.rows) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_every_row_lies_within_the_engine_range(self, test_case: TestCase) -> None: + assert all(MINIMUM_TICKS <= ticks <= MAXIMUM_TICKS for ticks in test_case.groove.ticks) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_every_row_neighbours_the_average(self, test_case: TestCase) -> None: + groove = test_case.groove + shorter, remainder = divmod(groove.total_ticks, test_case.rows) + longer = shorter + 1 if remainder else shorter + assert set(groove.ticks) <= {shorter, longer} + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_longer_rows_come_first(self, test_case: TestCase) -> None: + groove = test_case.groove + elapsed = 0 + for index, ticks in enumerate(groove.ticks, start=1): + elapsed += ticks + assert elapsed >= groove.total_ticks * index // test_case.rows + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_each_beat_opens_on_its_longest_row(self, test_case: TestCase) -> None: + ticks = test_case.groove.ticks + start = 0 + for beats in test_case.metre.spans: + for beat_rows in beats: + beat = ticks[start : start + beat_rows] + assert beat[0] == max(beat) + start += beat_rows + + +class TestReferenceCalibration(BaseTestSuite): + """At the reference tempo and tick rate the speed alone states every row's length.""" + + @pytest.mark.parametrize("speed", tuple(range(1, 32))) + @pytest.mark.parametrize("rows", (1, 2, 7, 16, 60, 64, 256)) + def test_every_row_lasts_speed_ticks(self, speed: int, rows: int) -> None: + groove = calculate_groove( + RowRate.from_parameters( + tempo=REFERENCE_TEMPO, + speed=speed, + nes_frequency=REFERENCE_NES_FREQUENCY, + ), + Metre( + rows=rows, + first_highlight=COMMON_TIME_BEAT, + second_highlight=COMMON_TIME_BAR, + ), + minimum_ticks=MINIMUM_TICKS, + maximum_ticks=MAXIMUM_TICKS, + ) + assert groove.ticks == (speed,) * rows + assert groove.is_uniform + + +class TestSecondHighlight(BaseTestSuite): + """The bar organizes where the surplus ticks fall, leaving the tempo to the beat.""" + + @staticmethod + def _groove(rows: int, first_highlight: int, second_highlight: int, tempo: int) -> Groove: + return calculate_groove( + RowRate.from_parameters( + tempo=tempo, + speed=REFERENCE_SPEED, + nes_frequency=60, + ), + Metre( + rows=rows, + first_highlight=first_highlight, + second_highlight=second_highlight, + ), + minimum_ticks=MINIMUM_TICKS, + maximum_ticks=MAXIMUM_TICKS, + ) + + @pytest.mark.parametrize("second_highlight", (1, 2, 3, 4, 7, 8, 12, 16, 20, 32, 64)) + @pytest.mark.parametrize("rows", (5, 12, 16, 17, 20, 23, 64)) + @pytest.mark.parametrize("tempo", (32, 105, 210, 255)) + def test_the_bar_leaves_the_tempo_alone(self, tempo: int, rows: int, second_highlight: int) -> None: + grouped = self._groove(rows, COMMON_TIME_BEAT, second_highlight, tempo) + pattern_wide = self._groove(rows, COMMON_TIME_BEAT, rows, tempo) + assert grouped.total_ticks == pattern_wide.total_ticks + + def test_a_bar_cutting_across_the_beat_reorganizes_the_groove(self) -> None: + across = self._groove(4, 2, 3, 105) + pattern_wide = self._groove(4, 2, 4, 105) + assert across.ticks == (9, 9, 8, 8) + assert pattern_wide.ticks == (9, 8, 9, 8) + assert across.total_ticks == pattern_wide.total_ticks + + def test_a_bar_shorter_than_the_beat_reorganizes_the_groove(self) -> None: + across = self._groove(5, 5, 4, 105) + aligned = self._groove(5, 5, 8, 105) + assert across.ticks == (9, 9, 9, 8, 8) + assert aligned.ticks == (9, 9, 8, 9, 8) + assert across.total_ticks == aligned.total_ticks + + def test_a_bar_of_whole_beats_reorganizes_the_groove_too(self) -> None: + barred = self._groove(64, COMMON_TIME_BEAT, COMMON_TIME_BAR, 105) + pattern_wide = self._groove(64, COMMON_TIME_BEAT, 64, 105) + assert barred.ticks == ( + 9, 9, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, + 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, + 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, + 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, + ) # fmt: skip + assert pattern_wide.ticks == ( + 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, + 9, 8, 9, 8, 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, + 9, 8, 9, 8, 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, + 9, 9, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, 9, 8, + ) # fmt: skip + assert barred.total_ticks == pattern_wide.total_ticks + + +class TestGrooveProperties(BaseTestSuite): + def test_total_ticks_sums_the_rows(self) -> None: + assert Groove(ticks=(5, 4, 4, 4)).total_ticks == 17 + + def test_mean_ticks_per_row_is_exact(self) -> None: + assert Groove(ticks=(5, 4, 4, 4)).mean_ticks_per_row == Fraction(17, 4) + + def test_a_varying_groove_is_not_uniform(self) -> None: + assert not Groove(ticks=(5, 4, 4, 4)).is_uniform + + def test_a_constant_groove_is_uniform(self) -> None: + assert Groove(ticks=(4, 4, 4, 4)).is_uniform + + def test_a_single_row_groove_is_uniform(self) -> None: + assert Groove(ticks=(4,)).is_uniform diff --git a/tests/unit/sampletones_core/timing/test_metre.py b/tests/unit/sampletones_core/timing/test_metre.py new file mode 100644 index 000000000..7737277ae --- /dev/null +++ b/tests/unit/sampletones_core/timing/test_metre.py @@ -0,0 +1,146 @@ +from dataclasses import dataclass +from typing import Tuple + +import pytest + +from sampletones_core.timing.metre import Metre +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + + +class TestSpans(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[Tuple[int, ...], ...] + rows: int + first_highlight: int + second_highlight: int + + @property + def label(self) -> str: + return f"{self.rows}_rows_at_{self.first_highlight}_{self.second_highlight}" + + test_cases = ( + TestCase( + rows=16, + first_highlight=4, + second_highlight=16, + expected=((4, 4, 4, 4),), + ), + TestCase( + rows=64, + first_highlight=4, + second_highlight=16, + expected=((4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4, 4)), + ), + TestCase( + rows=60, + first_highlight=4, + second_highlight=16, + expected=((4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4, 4), (4, 4, 4)), + ), + TestCase( + rows=17, + first_highlight=4, + second_highlight=16, + expected=((4, 4, 4, 4), (1,)), + ), + TestCase( + rows=12, + first_highlight=3, + second_highlight=12, + expected=((3, 3, 3, 3),), + ), + TestCase( + rows=16, + first_highlight=6, + second_highlight=12, + expected=((6, 6), (4,)), + ), + TestCase( + rows=1, + first_highlight=4, + second_highlight=16, + expected=((1,),), + ), + TestCase( + rows=8, + first_highlight=1, + second_highlight=1, + expected=((1,), (1,), (1,), (1,), (1,), (1,), (1,), (1,)), + ), + TestCase( + rows=8, + first_highlight=16, + second_highlight=4, + expected=((4,), (4,)), + ), + TestCase( + rows=8, + first_highlight=64, + second_highlight=64, + expected=((8,),), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_spans_match(self, test_case: TestCase) -> None: + metre = Metre( + rows=test_case.rows, + first_highlight=test_case.first_highlight, + second_highlight=test_case.second_highlight, + ) + assert metre.spans == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_spans_cover_the_pattern(self, test_case: TestCase) -> None: + metre = Metre( + rows=test_case.rows, + first_highlight=test_case.first_highlight, + second_highlight=test_case.second_highlight, + ) + assert sum(sum(beats) for beats in metre.spans) == test_case.rows + + +class TestBounds(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: str + field: str + + @property + def label(self) -> str: + return f"{self.field}_below_one" + + test_cases = ( + TestCase( + field="rows", + expected="rows must be at least 1", + ), + TestCase( + field="first_highlight", + expected="first_highlight must be at least 1", + ), + TestCase( + field="second_highlight", + expected="second_highlight must be at least 1", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_field_below_one_is_rejected(self, test_case: TestCase) -> None: + fields = {"rows": 16, "first_highlight": 4, "second_highlight": 16, test_case.field: 0} + with pytest.raises(ValueError, match=test_case.expected): + Metre(**fields) diff --git a/tests/unit/sampletones_core/timing/test_rate.py b/tests/unit/sampletones_core/timing/test_rate.py new file mode 100644 index 000000000..2b2cc410f --- /dev/null +++ b/tests/unit/sampletones_core/timing/test_rate.py @@ -0,0 +1,126 @@ +from dataclasses import dataclass +from fractions import Fraction + +import pytest + +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.timing.rate import RowRate +from sampletones_shared.constants.project import REFERENCE_NES_FREQUENCY, REFERENCE_TEMPO +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + + +class TestRowRate(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Fraction + tempo: int + speed: int + nes_frequency: int + + @property + def label(self) -> str: + return f"tempo_{self.tempo}_speed_{self.speed}_at_{self.nes_frequency}hz" + + test_cases = ( + TestCase( + tempo=REFERENCE_TEMPO, + speed=6, + nes_frequency=REFERENCE_NES_FREQUENCY, + expected=Fraction(6), + ), + TestCase( + tempo=REFERENCE_TEMPO, + speed=1, + nes_frequency=REFERENCE_NES_FREQUENCY, + expected=Fraction(1), + ), + TestCase( + tempo=REFERENCE_TEMPO, + speed=31, + nes_frequency=REFERENCE_NES_FREQUENCY, + expected=Fraction(31), + ), + TestCase( + tempo=75, + speed=6, + nes_frequency=60, + expected=Fraction(12), + ), + TestCase( + tempo=210, + speed=6, + nes_frequency=60, + expected=Fraction(30, 7), + ), + TestCase( + tempo=150, + speed=6, + nes_frequency=50, + expected=Fraction(5), + ), + TestCase( + tempo=150, + speed=6, + nes_frequency=30, + expected=Fraction(3), + ), + TestCase( + tempo=32, + speed=1, + nes_frequency=60, + expected=Fraction(75, 16), + ), + TestCase( + tempo=255, + speed=31, + nes_frequency=300, + expected=Fraction(1550, 17), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_ticks_per_row(self, test_case: TestCase) -> None: + rate = RowRate.from_parameters( + tempo=test_case.tempo, + speed=test_case.speed, + nes_frequency=test_case.nes_frequency, + ) + assert rate.ticks_per_row == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_ticks_per_row_follows_the_reference_formula(self, test_case: TestCase) -> None: + rate = RowRate.from_parameters( + tempo=test_case.tempo, + speed=test_case.speed, + nes_frequency=test_case.nes_frequency, + ) + assert rate.ticks_per_row == Fraction( + test_case.speed * test_case.nes_frequency * REFERENCE_TEMPO, + test_case.tempo * REFERENCE_NES_FREQUENCY, + ) + + def test_speed_states_the_tick_count_at_the_reference(self) -> None: + for speed in range(1, 32): + rate = RowRate.from_parameters( + tempo=REFERENCE_TEMPO, + speed=speed, + nes_frequency=REFERENCE_NES_FREQUENCY, + ) + assert rate.ticks_per_row == speed + + def test_settings_and_parameters_agree(self) -> None: + settings = ProjectSettings(tempo=210, speed=6, nes_frequency=60) + assert RowRate.from_settings(settings) == RowRate.from_parameters( + tempo=settings.tempo, + speed=settings.speed, + nes_frequency=settings.nes_frequency, + ) From e8f5d0b82449bfdb36b3a8049f4fa51c59461721 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 19:41:16 +0200 Subject: [PATCH 049/152] Added: metric highlight fields to project settings --- docs/formats/projects.md | 2 +- .../logic/history/action.py | 2 + .../logic/project/controller.py | 10 +++ .../logic/sequencer/tracker.py | 10 ++- .../view_model/sequencer/settings.py | 2 + src/sampletones_config/lang/en.yaml | 2 + src/sampletones_core/project/settings.py | 16 +++++ src/sampletones_core/timing/metre.py | 17 +++++ src/sampletones_shared/constants/project.py | 6 ++ .../logic/project/test_controller.py | 21 +++++++ .../sampletones_core/project/test_settings.py | 62 +++++++++++++++++++ .../sampletones_core/timing/test_metre.py | 15 +++++ 12 files changed, 162 insertions(+), 3 deletions(-) diff --git a/docs/formats/projects.md b/docs/formats/projects.md index cd3b42cf3..570d3275f 100644 --- a/docs/formats/projects.md +++ b/docs/formats/projects.md @@ -25,7 +25,7 @@ while the larger audio data travels alongside it in the same archive. | `format_version` | the project format version, checked for compatibility on load (see [Versioning](#versioning)) | | `metadata` | the application name and version (managed automatically) | | `info` | `title`, `author`, and `comment`, plus `created` and `modified` timestamps | -| `settings` | the engine settings: `nes_frequency`, `sample_rate`, `tempo`, and `speed` | +| `settings` | the engine settings: `nes_frequency`, `sample_rate`, `tempo`, `speed`, and the metric highlights `first_highlight` and `second_highlight` | | `samples` | the song's samples — each an `id`, a `name`, and the `reconstruction_id` of its audio member | | `song` | the arrangement (below) | diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py index ce0f6ce5a..6b711babb 100644 --- a/src/sampletones_application/logic/history/action.py +++ b/src/sampletones_application/logic/history/action.py @@ -30,6 +30,8 @@ class HistoryAction(AbstractElement): SET_SAMPLE_LOOP = "set_sample_loop" SET_TEMPO = "set_tempo" SET_SPEED = "set_speed" + SET_FIRST_HIGHLIGHT = "set_first_highlight" + SET_SECOND_HIGHLIGHT = "set_second_highlight" SET_NES_FREQUENCY = "set_nes_frequency" SET_ROWS_PER_PATTERN = "set_rows_per_pattern" EDIT_RECONSTRUCTION = "edit_reconstruction" diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index e0656a648..fc5d9c95b 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -128,6 +128,16 @@ def set_speed(self, speed: int) -> None: self._touch() self.call(self.on_settings_changed) + def set_first_highlight(self, first_highlight: int) -> None: + self.project.settings.first_highlight = first_highlight + self._touch() + self.call(self.on_settings_changed) + + def set_second_highlight(self, second_highlight: int) -> None: + self.project.settings.second_highlight = second_highlight + self._touch() + self.call(self.on_settings_changed) + def set_nes_frequency(self, nes_frequency: int) -> None: self.project.settings.nes_frequency = nes_frequency self._touch() diff --git a/src/sampletones_application/logic/sequencer/tracker.py b/src/sampletones_application/logic/sequencer/tracker.py index ed421543c..5879f5dce 100644 --- a/src/sampletones_application/logic/sequencer/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker.py @@ -57,6 +57,8 @@ def settings(self) -> SequencerSettingsViewModel: tempo=project_settings.tempo, speed=project_settings.speed, rows_per_pattern=project.song.rows_per_pattern, + first_highlight=project_settings.first_highlight, + second_highlight=project_settings.second_highlight, ) def build_grid(self) -> SequencerTrackerViewModel: @@ -121,6 +123,12 @@ def set_tempo(self, tempo: int) -> None: def set_speed(self, speed: int) -> None: self._controller.set_speed(speed) + def set_first_highlight(self, first_highlight: int) -> None: + self._controller.set_first_highlight(first_highlight) + + def set_second_highlight(self, second_highlight: int) -> None: + self._controller.set_second_highlight(second_highlight) + def set_row( self, generator: GeneratorName, @@ -508,5 +516,3 @@ 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/view_model/sequencer/settings.py b/src/sampletones_application/view_model/sequencer/settings.py index 18f41d1b0..ae414e450 100644 --- a/src/sampletones_application/view_model/sequencer/settings.py +++ b/src/sampletones_application/view_model/sequencer/settings.py @@ -6,3 +6,5 @@ class SequencerSettingsViewModel(BaseModel, frozen=True): tempo: int speed: int rows_per_pattern: int + first_highlight: int + second_highlight: int diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 5b3572196..4377c9049 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -543,6 +543,8 @@ sequencer.history.label.loop_on: "on" sequencer.history.label.loop_off: "off" sequencer.history.label.set_tempo: "Set tempo" sequencer.history.label.set_speed: "Set speed" +sequencer.history.label.set_first_highlight: "Set first highlight" +sequencer.history.label.set_second_highlight: "Set second highlight" sequencer.history.label.set_nes_frequency: "Set NES frequency" sequencer.history.label.set_rows_per_pattern: "Set rows per pattern" sequencer.history.label.edit_reconstruction: "Edit reconstruction" diff --git a/src/sampletones_core/project/settings.py b/src/sampletones_core/project/settings.py index f7f79d6ac..7d9942331 100644 --- a/src/sampletones_core/project/settings.py +++ b/src/sampletones_core/project/settings.py @@ -11,10 +11,14 @@ MIN_NES_FREQUENCY, ) from sampletones_shared.constants.project import ( + DEFAULT_FIRST_HIGHLIGHT, + DEFAULT_SECOND_HIGHLIGHT, DEFAULT_SPEED, DEFAULT_TEMPO, + MAX_HIGHLIGHT, MAX_SPEED, MAX_TEMPO, + MIN_HIGHLIGHT, MIN_SPEED, MIN_TEMPO, ) @@ -47,3 +51,15 @@ class ProjectSettings(BaseModel): le=MAX_SPEED, description="Engine ticks per row.", ) + first_highlight: int = Field( + default=DEFAULT_FIRST_HIGHLIGHT, + ge=MIN_HIGHLIGHT, + le=MAX_HIGHLIGHT, + description="Rows per beat, the unit the tempo is counted in.", + ) + second_highlight: int = Field( + default=DEFAULT_SECOND_HIGHLIGHT, + ge=MIN_HIGHLIGHT, + le=MAX_HIGHLIGHT, + description="Rows per bar, the unit that groups beats.", + ) diff --git a/src/sampletones_core/timing/metre.py b/src/sampletones_core/timing/metre.py index 705c132aa..8b80eee91 100644 --- a/src/sampletones_core/timing/metre.py +++ b/src/sampletones_core/timing/metre.py @@ -1,6 +1,10 @@ +from __future__ import annotations + from dataclasses import dataclass from typing import Tuple +from sampletones_core.project.settings import ProjectSettings + @dataclass(frozen=True) class Metre: @@ -36,6 +40,19 @@ def __post_init__(self) -> None: if self.second_highlight < 1: raise ValueError(f"second_highlight must be at least 1, got {self.second_highlight}") + @classmethod + def from_settings(cls, settings: ProjectSettings, *, rows: int) -> Metre: + """Reads the metre a project states, over a pattern of ``rows`` rows. + + The project holds the two highlights while the song holds the pattern length, so + the row count arrives beside the settings. + """ + return cls( + rows=rows, + first_highlight=settings.first_highlight, + second_highlight=settings.second_highlight, + ) + @property def spans(self) -> Tuple[Tuple[int, ...], ...]: """The whole grouping, as the beat row counts of each consecutive bar. diff --git a/src/sampletones_shared/constants/project.py b/src/sampletones_shared/constants/project.py index db32c5b59..77ff11515 100644 --- a/src/sampletones_shared/constants/project.py +++ b/src/sampletones_shared/constants/project.py @@ -34,3 +34,9 @@ DEFAULT_ROWS_PER_PATTERN: Final[int] = 64 MIN_ROWS_PER_PATTERN: Final[int] = 1 MAX_ROWS_PER_PATTERN: Final[int] = 256 + +# Metric highlights +DEFAULT_FIRST_HIGHLIGHT: Final[int] = 4 +DEFAULT_SECOND_HIGHLIGHT: Final[int] = 16 +MIN_HIGHLIGHT: Final[int] = 1 +MAX_HIGHLIGHT: Final[int] = MAX_ROWS_PER_PATTERN diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 656cb2dbf..86efdd502 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -30,6 +30,13 @@ def test_settings_edits_apply(self) -> None: assert controller.project.settings.tempo == 128 assert controller.project.settings.speed == 4 + def test_highlight_edits_apply(self) -> None: + controller = _controller() + controller.set_first_highlight(3) + controller.set_second_highlight(12) + assert controller.project.settings.first_highlight == 3 + assert controller.project.settings.second_highlight == 12 + def test_rows_per_pattern_resizes_all_patterns(self) -> None: controller = _controller() song = controller.project.song @@ -464,6 +471,20 @@ def test_set_speed_fires_settings_callback(self) -> None: controller.set_speed(6) assert fired == ["settings"] + def test_set_first_highlight_fires_settings_callback(self) -> None: + controller = _controller() + fired: List[str] = [] + controller.on_settings_changed = lambda: fired.append("settings") + controller.set_first_highlight(3) + assert fired == ["settings"] + + def test_set_second_highlight_fires_settings_callback(self) -> None: + controller = _controller() + fired: List[str] = [] + controller.on_settings_changed = lambda: fired.append("settings") + controller.set_second_highlight(12) + assert fired == ["settings"] + def test_set_nes_frequency_updates_settings(self) -> None: controller = _controller() fired: List[str] = [] diff --git a/tests/unit/sampletones_core/project/test_settings.py b/tests/unit/sampletones_core/project/test_settings.py index af620de0a..23054392a 100644 --- a/tests/unit/sampletones_core/project/test_settings.py +++ b/tests/unit/sampletones_core/project/test_settings.py @@ -9,8 +9,12 @@ ) from sampletones_core.project.settings import ProjectSettings from sampletones_shared.constants.project import ( + DEFAULT_FIRST_HIGHLIGHT, + DEFAULT_SECOND_HIGHLIGHT, + MAX_HIGHLIGHT, MAX_SPEED, MAX_TEMPO, + MIN_HIGHLIGHT, MIN_SPEED, MIN_TEMPO, ) @@ -91,6 +95,46 @@ def label(self) -> str: value=MAX_SPEED + 1, expected=False, ), + TestCase( + field="first_highlight", + value=MIN_HIGHLIGHT, + expected=True, + ), + TestCase( + field="first_highlight", + value=MAX_HIGHLIGHT, + expected=True, + ), + TestCase( + field="first_highlight", + value=MIN_HIGHLIGHT - 1, + expected=False, + ), + TestCase( + field="first_highlight", + value=MAX_HIGHLIGHT + 1, + expected=False, + ), + TestCase( + field="second_highlight", + value=MIN_HIGHLIGHT, + expected=True, + ), + TestCase( + field="second_highlight", + value=MAX_HIGHLIGHT, + expected=True, + ), + TestCase( + field="second_highlight", + value=MIN_HIGHLIGHT - 1, + expected=False, + ), + TestCase( + field="second_highlight", + value=MAX_HIGHLIGHT + 1, + expected=False, + ), ) @pytest.mark.parametrize( @@ -124,6 +168,24 @@ def test_round_trip(self) -> None: restored = ProjectSettings.model_validate(settings.model_dump()) assert restored == settings + def test_highlights_round_trip(self) -> None: + settings = ProjectSettings(first_highlight=3, second_highlight=12) + restored = ProjectSettings.model_validate(settings.model_dump()) + assert (restored.first_highlight, restored.second_highlight) == (3, 12) + + def test_settings_without_highlights_load_on_common_time(self) -> None: + """A project saved before the highlights existed reads as the 4/16 grouping it was played in.""" + document = ProjectSettings(tempo=120).model_dump() + del document["first_highlight"] + del document["second_highlight"] + + restored = ProjectSettings.model_validate(document) + + assert (restored.first_highlight, restored.second_highlight) == ( + DEFAULT_FIRST_HIGHLIGHT, + DEFAULT_SECOND_HIGHLIGHT, + ) + def test_json_round_trip(self) -> None: settings = ProjectSettings(nes_frequency=50) restored = ProjectSettings.model_validate_json(settings.model_dump_json()) diff --git a/tests/unit/sampletones_core/timing/test_metre.py b/tests/unit/sampletones_core/timing/test_metre.py index 7737277ae..a9d7711ee 100644 --- a/tests/unit/sampletones_core/timing/test_metre.py +++ b/tests/unit/sampletones_core/timing/test_metre.py @@ -3,6 +3,7 @@ import pytest +from sampletones_core.project.settings import ProjectSettings from sampletones_core.timing.metre import Metre from tests.suite.base import BaseTestSuite from tests.suite.case import BaseAutolabelTestCase @@ -144,3 +145,17 @@ def test_field_below_one_is_rejected(self, test_case: TestCase) -> None: fields = {"rows": 16, "first_highlight": 4, "second_highlight": 16, test_case.field: 0} with pytest.raises(ValueError, match=test_case.expected): Metre(**fields) + + +class TestProjectSettings: + def test_settings_state_the_highlights(self) -> None: + settings = ProjectSettings(first_highlight=3, second_highlight=12) + assert Metre.from_settings(settings, rows=24) == Metre( + rows=24, + first_highlight=3, + second_highlight=12, + ) + + def test_the_default_settings_state_common_time(self) -> None: + metre = Metre.from_settings(ProjectSettings(), rows=16) + assert metre.spans == ((4, 4, 4, 4),) From 0eb5fefa219eea29dc48351fa029b440fc1ffe24 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 20:05:25 +0200 Subject: [PATCH 050/152] Added: metric highlight inputs and project-driven row tinting --- docs/glossary.md | 7 + docs/guide/sequencer.md | 8 ++ .../coordinators/tabs/sequencer.py | 34 ++++- .../layout/tabs/sequencer/tracker/tracker.py | 7 +- src/sampletones_application/tags/sequencer.py | 12 ++ .../ui/panels/sequencer/module.py | 72 ++++++++++ .../ui/panels/sequencer/rows.py | 21 +-- .../ui/panels/sequencer/tracker.py | 12 +- src/sampletones_config/lang/en.yaml | 4 + .../layout/tabs/sequencer/tracker.yaml | 2 - .../ui/panels/sequencer/test_rows.py | 125 ++++++++++++------ .../ui/panels/sequencer/test_tracker_rows.py | 38 +++++- 12 files changed, 279 insertions(+), 63 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index d8dddcfa8..466ade550 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -182,6 +182,13 @@ arpeggio, pitch, hi-pitch, or duty/noise mode. A block of tracker rows spanning the channels. A song plays its patterns in an order. +### Metric highlight + +The row grouping a song is counted in. The **first highlight** is the beat — the +rows one beat spans — and the **second highlight** is the bar that gathers beats. +The tracker tints the row that opens each, and the beat is what a tempo counts: +`beats_per_minute = 60 × nes_frequency / (ticks_per_row × first_highlight)`. + ### Order The list that arranges patterns into the song's timeline. diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index b4b31c1f0..7080d21c3 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -108,6 +108,14 @@ Set the song's timing in **Module options** on the right: **Rows** per pattern, after samples exist re-times how they all play back, so it asks **Change NES frequency** first (with a **Don't ask again** option). +**First highlight** and **Second highlight** state the metre the song is counted +in: how many rows make a beat, and how many make a bar. The tracker tints the row +that opens each one. The bar divided by the beat is how many beats you hear in a +bar, so the default 4 and 16 give four beats of four rows — common time. Waltz time +keeps the four-row beat and shortens the bar to 12, for three beats. The beat is +what the tempo counts, so the two together say how fast the song is felt as well as +how it looks. + The project's title, author, and comment — which carry into the exported module — are set in **Project properties**, from the button or **File ▸ Project properties...**. diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 635573f50..1e33709d7 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -3,7 +3,9 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.elements.sequencer import 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 @@ -83,6 +85,9 @@ from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) +from sampletones_application.view_model.sequencer.settings import ( + SequencerSettingsViewModel, +) from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( @@ -203,6 +208,7 @@ def __init__( error_message=language_manager["global.player.message.audio_playback_error"], ) self._sequencer_tracker_panel: GUISequencerTrackerPanel = GUISequencerTrackerPanel( + self._sequencer_tracker_logic.settings, layout=layout.sequencer, initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_TRACKER_PANEL), language_manager=language_manager, @@ -292,6 +298,18 @@ def _wire_module_callbacks(self) -> None: detail=self._history_detail.value, coalesce=self._module_setting_key, ) + self._sequencer_module_panel.on_first_highlight = self._undoable( + HistoryAction.SET_FIRST_HIGHLIGHT, + self._sequencer_tracker_logic.set_first_highlight, + detail=self._history_detail.value, + coalesce=self._module_setting_key, + ) + self._sequencer_module_panel.on_second_highlight = self._undoable( + HistoryAction.SET_SECOND_HIGHLIGHT, + self._sequencer_tracker_logic.set_second_highlight, + detail=self._history_detail.value, + coalesce=self._module_setting_key, + ) def _wire_tracker_callbacks(self) -> None: self._sequencer_tracker_panel.on_clear_row = self._undoable( @@ -331,7 +349,7 @@ def _wire_tracker_callbacks(self) -> None: detail=self._history_detail.adjust_volume, coalesce=self._adjustment_key, ) - self._sequencer_tracker_logic.on_settings_changed = self._sequencer_module_panel.update_settings + self._sequencer_tracker_logic.on_settings_changed = self._on_settings_changed self._sequencer_tracker_logic.on_tracker_changed = self._sequencer_tracker_panel.update_tracker self._sequencer_tracker_logic.on_frame_changed = self._sequencer_order_panel.select_position @@ -614,6 +632,18 @@ def _module_setting_key(self, _value: int) -> CoalesceKey: """Marks a module-wide setting as one target, shared by its whole streak.""" return () + def _on_settings_changed( + self, + view_model: SequencerSettingsViewModel, + ) -> None: + """Hands the module settings to the two panels that show them. + + The module panel shows the values themselves; the tracker reads the metre out of them, + so an edited highlight retints the grid in the same round-trip that refreshes the field. + """ + self._sequencer_module_panel.update_settings(view_model) + self._sequencer_tracker_panel.update_settings(view_model) + def _on_project_replaced(self) -> None: """Realigns the tab with a replaced project, keeping the mute set across history navigation. diff --git a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py index 44551c39a..27b8023de 100644 --- a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py +++ b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py @@ -6,15 +6,12 @@ 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. + The grouping the rows are tinted by is the project's own metre, read from its highlights, + so this model carries the geometry alone. """ 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/tags/sequencer.py b/src/sampletones_application/tags/sequencer.py index 0fddc3982..c065165a0 100644 --- a/src/sampletones_application/tags/sequencer.py +++ b/src/sampletones_application/tags/sequencer.py @@ -98,6 +98,18 @@ Widget.INPUT, "speed", ) +TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT = TagName( + Page.SEQUENCER, + Panel.MODULE, + Widget.INPUT, + "first_highlight", +) +TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT = TagName( + Page.SEQUENCER, + Panel.MODULE, + Widget.INPUT, + "second_highlight", +) TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY = TagName( Page.SEQUENCER, Panel.MODULE, diff --git a/src/sampletones_application/ui/panels/sequencer/module.py b/src/sampletones_application/ui/panels/sequencer/module.py index 5b22790dc..0d3ffc463 100644 --- a/src/sampletones_application/ui/panels/sequencer/module.py +++ b/src/sampletones_application/ui/panels/sequencer/module.py @@ -9,8 +9,10 @@ from sampletones_application.tags.general import SUF_HANDLER_REGISTRY from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_MODULE_GROUP_OPTIONS, + TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT, TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, TAG_SEQUENCER_MODULE_INPUT_ROWS, + TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT, TAG_SEQUENCER_MODULE_INPUT_SPEED, TAG_SEQUENCER_MODULE_INPUT_TEMPO, TAG_SEQUENCER_MODULE_PANEL, @@ -28,7 +30,9 @@ ) from sampletones_core.constants.general import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY from sampletones_shared.constants.project import ( + MAX_HIGHLIGHT, MAX_ROWS_PER_PATTERN, + MIN_HIGHLIGHT, MIN_ROWS_PER_PATTERN, ) from sampletones_shared.types.application import Sender @@ -58,6 +62,8 @@ def __init__( self.on_rows_per_pattern: Optional[Callable[[int], None]] = None self.on_tempo: Optional[Callable[[int], None]] = None self.on_speed: Optional[Callable[[int], None]] = None + self.on_first_highlight: Optional[Callable[[int], None]] = None + self.on_second_highlight: Optional[Callable[[int], None]] = None self._msg_status_input = language_manager["global.status.message.input"] @@ -134,12 +140,42 @@ def _create_module_options(self) -> None: width=self._input_width, callback=self._on_speed_input, ) + with labeled_field( + self._language_manager["sequencer.module.label.first_highlight"], + self._label_width, + ): + dpg.add_input_int( + default_value=settings.first_highlight, + tag=TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT, + min_value=MIN_HIGHLIGHT, + max_value=MAX_HIGHLIGHT, + min_clamped=True, + max_clamped=True, + width=self._input_width, + callback=self._on_first_highlight_input, + ) + with labeled_field( + self._language_manager["sequencer.module.label.second_highlight"], + self._label_width, + ): + dpg.add_input_int( + default_value=settings.second_highlight, + tag=TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT, + min_value=MIN_HIGHLIGHT, + max_value=MAX_HIGHLIGHT, + min_clamped=True, + max_clamped=True, + width=self._input_width, + callback=self._on_second_highlight_input, + ) for tag in ( TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, TAG_SEQUENCER_MODULE_INPUT_ROWS, TAG_SEQUENCER_MODULE_INPUT_TEMPO, TAG_SEQUENCER_MODULE_INPUT_SPEED, + TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT, + TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT, ): FontRegistry.bind_to_item(tag, Font.MONO) @@ -154,6 +190,14 @@ def _create_module_options(self) -> None: self._on_rows_per_pattern_input, ) show_tooltip(TAG_SEQUENCER_MODULE_INPUT_ROWS, self._language_manager["sequencer.module.tooltip.rows"]) + show_tooltip( + TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT, + self._language_manager["sequencer.module.tooltip.first_highlight"], + ) + show_tooltip( + TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT, + self._language_manager["sequencer.module.tooltip.second_highlight"], + ) self._status_bar.bind_to_item( TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, self._msg_status_input, @@ -170,6 +214,14 @@ def _create_module_options(self) -> None: TAG_SEQUENCER_MODULE_INPUT_SPEED, self._msg_status_input, ) + self._status_bar.bind_to_item( + TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT, + self._msg_status_input, + ) + self._status_bar.bind_to_item( + TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT, + self._msg_status_input, + ) def update_settings(self, view_model: SequencerSettingsViewModel) -> None: dpg.set_value( @@ -182,6 +234,14 @@ def update_settings(self, view_model: SequencerSettingsViewModel) -> None: ) dpg.set_value(TAG_SEQUENCER_MODULE_INPUT_TEMPO, view_model.tempo) dpg.set_value(TAG_SEQUENCER_MODULE_INPUT_SPEED, view_model.speed) + dpg.set_value( + TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT, + view_model.first_highlight, + ) + dpg.set_value( + TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT, + view_model.second_highlight, + ) def set_enabled(self, enabled: bool) -> None: dpg_configure_item(TAG_SEQUENCER_MODULE_GROUP_OPTIONS, enabled=enabled) @@ -227,3 +287,15 @@ def _on_speed_input(self, _sender: Sender, _app_data: int) -> None: self.on_speed, int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_SPEED)), ) + + def _on_first_highlight_input(self, _sender: Sender, _app_data: int) -> None: + self.call( + self.on_first_highlight, + int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT)), + ) + + def _on_second_highlight_input(self, _sender: Sender, _app_data: int) -> None: + self.call( + self.on_second_highlight, + int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT)), + ) diff --git a/src/sampletones_application/ui/panels/sequencer/rows.py b/src/sampletones_application/ui/panels/sequencer/rows.py index be529c089..c34b19d4f 100644 --- a/src/sampletones_application/ui/panels/sequencer/rows.py +++ b/src/sampletones_application/ui/panels/sequencer/rows.py @@ -2,9 +2,11 @@ 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 +from sampletones_application.view_model.sequencer.settings import ( + SequencerSettingsViewModel, +) @dataclass(frozen=True) @@ -17,18 +19,19 @@ class RowCues: def group_color( row_index: int, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, colors: SequencerColors, ) -> Optional[BaseColor]: - """The emphasis a row takes from the group it opens. + """The emphasis a row takes from the group the project's metre opens on it. - 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. + The second highlight marks the bar and the first the beat, so a row opening a bar takes + the stronger of the two shades even where a beat opens there as well. A row inside a beat + keeps the zebra stripe it already has, and a highlight of one marks every row. """ - if tracker.rows_per_bar > 0 and row_index % tracker.rows_per_bar == 0: + if row_index % settings.second_highlight == 0: return colors.rows.bar - if tracker.rows_per_beat > 0 and row_index % tracker.rows_per_beat == 0: + if row_index % settings.first_highlight == 0: return colors.rows.beat return None @@ -55,7 +58,7 @@ def cue_color( def row_background( row_index: int, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, colors: SequencerColors, cues: RowCues, ) -> Optional[BaseColor]: @@ -66,7 +69,7 @@ def row_background( 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) + group = group_color(row_index, settings, colors) cue = cue_color(row_index, cues, colors) if group is None: return cue diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 642cd0bdc..f54d0e286 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -80,6 +80,9 @@ from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) +from sampletones_application.view_model.sequencer.settings import ( + SequencerSettingsViewModel, +) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.sequencer.tracker import ( SequencerRowViewModel, @@ -113,6 +116,7 @@ class GUISequencerTrackerPanel(GUIPanel): def __init__( self, + initial_settings: SequencerSettingsViewModel, *, layout: SequencerLayout, language_manager: LanguageManager, @@ -122,6 +126,7 @@ def __init__( initial_collapsed: bool = False, ) -> None: self._layout = layout + self._settings = initial_settings self._language_manager = language_manager self._router = key_router self._tab_active = tab_active @@ -463,12 +468,17 @@ def repaint(self) -> None: self._apply_row_backgrounds() self._update_cursor() + def update_settings(self, view_model: SequencerSettingsViewModel) -> None: + """Takes the metre the project states, retinting the rows its highlights now open.""" + self._settings = view_model + self._apply_row_backgrounds() + def _row_background(self, row_index: int) -> Optional[BaseColor]: """The colour a pattern row's background carries under the marks standing on it now.""" cursor = self._input_state.cursor return row_background( row_index, - self._layout.tracker, + self._settings, self._layout.colors, RowCues( cursor=cursor.row if cursor is not None else None, diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 4377c9049..e28f4f4c3 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -434,6 +434,10 @@ sequencer.module.label.rows: "Rows" sequencer.module.tooltip.rows: "Press Enter to apply the new row count." sequencer.module.label.tempo: "Tempo" sequencer.module.label.speed: "Speed" +sequencer.module.label.first_highlight: "First highlight" +sequencer.module.label.second_highlight: "Second highlight" +sequencer.module.tooltip.first_highlight: "Rows per beat. The tempo counts these." +sequencer.module.tooltip.second_highlight: "Rows per bar. These group the beats." # ============================================================================= # Sequencer tab — Tracker diff --git a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml index b9310835d..34c6001a1 100644 --- a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml @@ -1,7 +1,5 @@ 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/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py index aeb6a2ad3..47cb8e2b8 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_rows.py @@ -3,7 +3,6 @@ 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, @@ -17,9 +16,32 @@ from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.colors.layered import LayeredColor from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.view_model.sequencer.settings import ( + SequencerSettingsViewModel, +) NO_CUES = RowCues(cursor=None, playing=None) +PATTERN_ROWS = 64 +BEAT_ROWS = 4 +BAR_ROWS = 16 + + +def _settings( + *, + first_highlight: int = BEAT_ROWS, + second_highlight: int = BAR_ROWS, +) -> SequencerSettingsViewModel: + """The module settings the row tinting reads, carrying the metre under test.""" + return SequencerSettingsViewModel( + nes_frequency=60, + tempo=150, + speed=6, + rows_per_pattern=PATTERN_ROWS, + first_highlight=first_highlight, + second_highlight=second_highlight, + ) + @pytest.fixture def sequencer_layout() -> SequencerLayout: @@ -28,8 +50,8 @@ def sequencer_layout() -> SequencerLayout: @pytest.fixture -def tracker(sequencer_layout: SequencerLayout) -> TrackerLayout: - return sequencer_layout.tracker +def settings() -> SequencerSettingsViewModel: + return _settings() @pytest.fixture @@ -40,121 +62,144 @@ def colors(sequencer_layout: SequencerLayout) -> SequencerColors: class TestGrouping: def test_the_row_opening_a_bar_takes_the_bar_shade( self, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, colors: SequencerColors, ) -> None: - assert group_color(0, tracker, colors) == colors.rows.bar - assert group_color(tracker.rows_per_bar, tracker, colors) == colors.rows.bar + assert group_color(0, settings, colors) == colors.rows.bar + assert group_color(settings.second_highlight, settings, colors) == colors.rows.bar def test_the_row_opening_a_beat_takes_the_beat_shade( self, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, colors: SequencerColors, ) -> None: beats = ( - tracker.rows_per_beat, - 2 * tracker.rows_per_beat, - tracker.rows_per_bar + tracker.rows_per_beat, + settings.first_highlight, + 2 * settings.first_highlight, + settings.second_highlight + settings.first_highlight, ) for row_index in beats: - assert group_color(row_index, tracker, colors) == colors.rows.beat + assert group_color(row_index, settings, colors) == colors.rows.beat def test_a_row_inside_a_beat_keeps_its_stripe( self, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, 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 + for row_index in range(settings.rows_per_pattern): + if row_index % settings.first_highlight != 0: + assert group_color(row_index, settings, colors) is None def test_the_bar_shade_outranks_the_beat_shade_where_they_meet( self, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, 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 + assert settings.second_highlight % settings.first_highlight == 0 + assert group_color(settings.second_highlight, settings, colors) == colors.rows.bar + + def test_a_metre_the_project_states_moves_the_shades( + self, + colors: SequencerColors, + ) -> None: + """Three beats of four rows: the bar closes after twelve, where common time runs on to sixteen.""" + settings = _settings(first_highlight=4, second_highlight=12) + + assert group_color(12, settings, colors) == colors.rows.bar + assert group_color(4, settings, colors) == colors.rows.beat + assert group_color(8, settings, colors) == colors.rows.beat + assert group_color(16, settings, colors) == colors.rows.beat + assert group_color(3, settings, colors) is None + + def test_a_bar_shorter_than_a_beat_marks_every_bar_row( + self, + colors: SequencerColors, + ) -> None: + """The bar shade wins wherever the two groupings meet, so the shorter span is what shows.""" + settings = _settings(first_highlight=8, second_highlight=2) + + assert group_color(2, settings, colors) == colors.rows.bar + assert group_color(8, settings, colors) == colors.rows.bar + assert group_color(1, settings, colors) is None - def test_grouping_counts_of_zero_leave_every_row_even( + def test_a_highlight_of_one_marks_every_row( self, - tracker: TrackerLayout, colors: SequencerColors, ) -> None: - flat = tracker.model_copy(update={"rows_per_beat": 0, "rows_per_bar": 0}) + settings = _settings(first_highlight=1, second_highlight=1) - for row_index in range(tracker.rows): - assert group_color(row_index, flat, colors) is None + for row_index in range(settings.rows_per_pattern): + assert group_color(row_index, settings, colors) == colors.rows.bar class TestCues: def test_the_playhead_outranks_the_cursor( self, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, colors: SequencerColors, ) -> None: cues = RowCues(cursor=5, playing=5) - assert row_background(5, tracker, colors, cues) == colors.playback_row + assert row_background(5, settings, colors, cues) == colors.playback_row def test_the_cursor_marks_the_row_it_rests_on( self, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, colors: SequencerColors, ) -> None: cues = RowCues(cursor=5, playing=9) - assert row_background(5, tracker, colors, cues) == colors.cursor_row + assert row_background(5, settings, colors, cues) == colors.cursor_row def test_a_row_no_mark_stands_on_keeps_its_stripe( self, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, colors: SequencerColors, ) -> None: cues = RowCues(cursor=5, playing=9) - assert row_background(6, tracker, colors, cues) is None + assert row_background(6, settings, colors, cues) is None class TestComposition: def test_a_marked_group_row_carries_the_cue_over_the_group_shade( self, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, colors: SequencerColors, ) -> None: - row_index = tracker.rows_per_beat + row_index = settings.first_highlight cues = RowCues(cursor=row_index, playing=None) - assert row_background(row_index, tracker, colors, cues) == LayeredColor( + assert row_background(row_index, settings, colors, cues) == LayeredColor( base=colors.rows.beat, overlay=colors.cursor_row, ) def test_the_composed_shade_covers_more_than_either_alone( self, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, colors: SequencerColors, ) -> None: - row_index = tracker.rows_per_bar + row_index = settings.second_highlight cues = RowCues(cursor=None, playing=row_index) - composed = row_background(row_index, tracker, colors, cues) + composed = row_background(row_index, settings, colors, cues) assert composed is not None assert composed.rgba[3] > max(colors.rows.bar.rgba[3], colors.playback_row.rgba[3]) def test_an_unmarked_group_row_carries_the_group_shade_alone( self, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, 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 + assert row_background(0, settings, colors, NO_CUES) == colors.rows.bar + assert row_background(settings.first_highlight, settings, colors, NO_CUES) == colors.rows.beat def test_a_plain_unmarked_row_leaves_the_layer_free( self, - tracker: TrackerLayout, + settings: SequencerSettingsViewModel, colors: SequencerColors, ) -> None: - assert row_background(1, tracker, colors, NO_CUES) is None + assert row_background(1, settings, colors, NO_CUES) is None 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 index 49c889e35..fa7f45175 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -13,6 +13,9 @@ 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.settings import ( + SequencerSettingsViewModel, +) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName from sampletones_core.project.song_position import SongPosition @@ -71,14 +74,27 @@ def unhighlight_table_cell(self, table: str, row: int, column: int) -> None: self.unhighlighted_cells.append((row, column)) +def _settings( + *, + first_highlight: int = ROWS_PER_BEAT, + second_highlight: int = ROWS_PER_BAR, +) -> SequencerSettingsViewModel: + """The module settings the panel reads its metre out of.""" + return SequencerSettingsViewModel( + nes_frequency=60, + tempo=150, + speed=6, + rows_per_pattern=PATTERN_ROWS, + first_highlight=first_highlight, + second_highlight=second_highlight, + ) + + def _panel() -> GUISequencerTrackerPanel: """Builds a panel around the state the row backgrounds read, with no DearPyGui context.""" panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._settings = _settings() panel._layout = SimpleNamespace( - 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), @@ -175,6 +191,20 @@ def test_the_header_row_takes_no_row_background(self, recorder: _TableRecorder) assert HEADER_TABLE_ROW not in recorder.highlighted_rows assert HEADER_TABLE_ROW not in recorder.unhighlighted_rows + def test_an_edited_metre_retints_the_rows_at_once(self, recorder: _TableRecorder) -> None: + """The highlights are the project's, so a change to them reaches the grid as a repaint.""" + panel = _panel() + panel._apply_row_backgrounds() + + panel.update_settings(_settings(first_highlight=1, second_highlight=PATTERN_ROWS)) + + assert recorder.highlighted_rows == { + tracker_table_row(0): BAR_ROW, + tracker_table_row(1): BEAT_ROW, + tracker_table_row(2): BEAT_ROW, + tracker_table_row(3): BEAT_ROW, + } + def test_a_row_past_the_live_table_never_reaches_dearpygui(self, recorder: _TableRecorder) -> None: panel = _panel() From aaa29df6ac417560a05a69d3c5fa4cbe3347b8bd Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 20:29:55 +0200 Subject: [PATCH 051/152] Added: metric highlight fields to project properties --- docs/guide/sequencer.md | 17 +- src/sampletones_application/application.py | 14 +- .../categories/elements/settings.py | 2 + .../coordinators/tabs/sequencer.py | 19 +- .../logic/history/action.py | 2 - .../logic/sequencer/tracker.py | 6 - src/sampletones_application/tags/sequencer.py | 12 -- src/sampletones_application/tags/settings.py | 12 ++ .../ui/panels/dialogs/project_properties.py | 83 ++++++++- .../ui/panels/sequencer/module.py | 72 -------- .../view_model/shared/project_properties.py | 4 +- src/sampletones_config/lang/en.yaml | 10 +- .../layout/project_properties/root.yaml | 2 +- .../test_project_properties_history.py | 55 +++++- .../panels/dialogs/test_project_properties.py | 167 ++++++++++++++++++ 15 files changed, 340 insertions(+), 137 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 7080d21c3..cccb1a79a 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -108,17 +108,16 @@ Set the song's timing in **Module options** on the right: **Rows** per pattern, after samples exist re-times how they all play back, so it asks **Change NES frequency** first (with a **Don't ask again** option). -**First highlight** and **Second highlight** state the metre the song is counted -in: how many rows make a beat, and how many make a bar. The tracker tints the row -that opens each one. The bar divided by the beat is how many beats you hear in a -bar, so the default 4 and 16 give four beats of four rows — common time. Waltz time -keeps the four-row beat and shortens the bar to 12, for three beats. The beat is -what the tempo counts, so the two together say how fast the song is felt as well as -how it looks. - The project's title, author, and comment — which carry into the exported module — are set in **Project properties**, from the button or **File ▸ Project -properties...**. +properties...**, along with the metre the song is counted in. + +**First highlight** and **Second highlight** are that metre: how many rows make a +beat, and how many make a bar. The tracker tints the row that opens each one. The +bar divided by the beat is how many beats you hear in a bar, so the default 4 and +16 give four beats of four rows — common time. Waltz time keeps the four-row beat +and shortens the bar to 12, for three beats. The beat is what the tempo counts, so +the two together say how fast the song is felt as well as how it looks. ## Undo and export diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index c54e35a9a..848b17fc7 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1040,11 +1040,14 @@ def _open_project_properties(self) -> None: return info = self.project_controller.project.info + settings = self.project_controller.project.settings self.project_properties_window.open( ProjectPropertiesViewModel( title=info.title, author=info.author, comment=info.comment, + first_highlight=settings.first_highlight, + second_highlight=settings.second_highlight, created=info.created, modified=info.modified, ) @@ -1055,13 +1058,16 @@ def _commit_project_properties( title: str, author: str, comment: str, + first_highlight: int, + second_highlight: int, ) -> None: """Applies the properties dialog's values as one undoable gesture. - Only fields that differ from the current project info reach the controller, - so confirming the dialog with no edits is a no-op. + Only fields that differ from the current project reach the controller, so + confirming the dialog with no edits is a no-op. """ info = self.project_controller.project.info + settings = self.project_controller.project.settings with self.history.transaction(HistoryAction.EDIT_PROJECT_PROPERTIES): if title != info.title: self.project_controller.set_title(title) @@ -1069,6 +1075,10 @@ def _commit_project_properties( self.project_controller.set_author(author) if comment != info.comment: self.project_controller.set_comment(comment) + if first_highlight != settings.first_highlight: + self.project_controller.set_first_highlight(first_highlight) + if second_highlight != settings.second_highlight: + self.project_controller.set_second_highlight(second_highlight) def _open_audio_settings(self) -> None: """Opens the audio settings dialog seeded with the device manager's state.""" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index a43bec06f..25338b7e2 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -20,6 +20,8 @@ class ProjectPropertiesElements(AbstractElement): TITLE = "title" AUTHOR = "author" COMMENT = "comment" + FIRST_HIGHLIGHT = "first_highlight" + SECOND_HIGHLIGHT = "second_highlight" CREATED = "created" MODIFIED = "modified" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 1e33709d7..78c87724b 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -298,18 +298,6 @@ def _wire_module_callbacks(self) -> None: detail=self._history_detail.value, coalesce=self._module_setting_key, ) - self._sequencer_module_panel.on_first_highlight = self._undoable( - HistoryAction.SET_FIRST_HIGHLIGHT, - self._sequencer_tracker_logic.set_first_highlight, - detail=self._history_detail.value, - coalesce=self._module_setting_key, - ) - self._sequencer_module_panel.on_second_highlight = self._undoable( - HistoryAction.SET_SECOND_HIGHLIGHT, - self._sequencer_tracker_logic.set_second_highlight, - detail=self._history_detail.value, - coalesce=self._module_setting_key, - ) def _wire_tracker_callbacks(self) -> None: self._sequencer_tracker_panel.on_clear_row = self._undoable( @@ -636,10 +624,11 @@ def _on_settings_changed( self, view_model: SequencerSettingsViewModel, ) -> None: - """Hands the module settings to the two panels that show them. + """Hands the project's song settings to the two panels that read them. - The module panel shows the values themselves; the tracker reads the metre out of them, - so an edited highlight retints the grid in the same round-trip that refreshes the field. + The module panel shows the timing fields themselves; the tracker reads the metre out of + the same view model, so a highlight edited in the project properties retints the grid as + soon as the dialog commits. """ self._sequencer_module_panel.update_settings(view_model) self._sequencer_tracker_panel.update_settings(view_model) diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py index 6b711babb..ce0f6ce5a 100644 --- a/src/sampletones_application/logic/history/action.py +++ b/src/sampletones_application/logic/history/action.py @@ -30,8 +30,6 @@ class HistoryAction(AbstractElement): SET_SAMPLE_LOOP = "set_sample_loop" SET_TEMPO = "set_tempo" SET_SPEED = "set_speed" - SET_FIRST_HIGHLIGHT = "set_first_highlight" - SET_SECOND_HIGHLIGHT = "set_second_highlight" SET_NES_FREQUENCY = "set_nes_frequency" SET_ROWS_PER_PATTERN = "set_rows_per_pattern" EDIT_RECONSTRUCTION = "edit_reconstruction" diff --git a/src/sampletones_application/logic/sequencer/tracker.py b/src/sampletones_application/logic/sequencer/tracker.py index 5879f5dce..7fe714e54 100644 --- a/src/sampletones_application/logic/sequencer/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker.py @@ -123,12 +123,6 @@ def set_tempo(self, tempo: int) -> None: def set_speed(self, speed: int) -> None: self._controller.set_speed(speed) - def set_first_highlight(self, first_highlight: int) -> None: - self._controller.set_first_highlight(first_highlight) - - def set_second_highlight(self, second_highlight: int) -> None: - self._controller.set_second_highlight(second_highlight) - def set_row( self, generator: GeneratorName, diff --git a/src/sampletones_application/tags/sequencer.py b/src/sampletones_application/tags/sequencer.py index c065165a0..0fddc3982 100644 --- a/src/sampletones_application/tags/sequencer.py +++ b/src/sampletones_application/tags/sequencer.py @@ -98,18 +98,6 @@ Widget.INPUT, "speed", ) -TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT = TagName( - Page.SEQUENCER, - Panel.MODULE, - Widget.INPUT, - "first_highlight", -) -TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT = TagName( - Page.SEQUENCER, - Panel.MODULE, - Widget.INPUT, - "second_highlight", -) TAG_SEQUENCER_MODULE_DIALOG_NES_FREQUENCY = TagName( Page.SEQUENCER, Panel.MODULE, diff --git a/src/sampletones_application/tags/settings.py b/src/sampletones_application/tags/settings.py index e487e2733..3f0905750 100644 --- a/src/sampletones_application/tags/settings.py +++ b/src/sampletones_application/tags/settings.py @@ -251,6 +251,18 @@ Widget.INPUT, "comment", ) +TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT = TagName( + Page.SETTINGS, + Panel.PROPERTIES, + Widget.INPUT, + "first_highlight", +) +TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT = TagName( + Page.SETTINGS, + Panel.PROPERTIES, + Widget.INPUT, + "second_highlight", +) TAG_SETTINGS_PROPERTIES_BUTTON_OK = TagName( Page.SETTINGS, Panel.PROPERTIES, diff --git a/src/sampletones_application/ui/panels/dialogs/project_properties.py b/src/sampletones_application/ui/panels/dialogs/project_properties.py index 57ba3b580..1254a6666 100644 --- a/src/sampletones_application/ui/panels/dialogs/project_properties.py +++ b/src/sampletones_application/ui/panels/dialogs/project_properties.py @@ -13,6 +13,8 @@ TAG_SETTINGS_PROPERTIES_BUTTON_OK, TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR, TAG_SETTINGS_PROPERTIES_INPUT_COMMENT, + TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, + TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, TAG_SETTINGS_PROPERTIES_INPUT_TITLE, TAG_SETTINGS_PROPERTIES_WINDOW, ) @@ -25,23 +27,30 @@ from sampletones_application.utils.gui.dialog_navigation import FocusStop from sampletones_application.utils.gui.keyboard import KeyRouter from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.utils.gui.tooltip import show_tooltip +from sampletones_application.utils.gui.widgets import clamp_widget_value from sampletones_application.view_model.shared.project_properties import ( ProjectPropertiesViewModel, ) from sampletones_shared.constants.project import ( + DEFAULT_FIRST_HIGHLIGHT, + DEFAULT_SECOND_HIGHLIGHT, + MAX_HIGHLIGHT, MAX_PROJECT_AUTHOR_LENGTH, MAX_PROJECT_COMMENT_LENGTH, MAX_PROJECT_TITLE_LENGTH, + MIN_HIGHLIGHT, ) class GUIProjectPropertiesWindow(GUIDialogWindow): - """Modal form to view and edit the project's title, author, and comment. + """Modal form to view and edit the project's title, author, comment, and metre. Each appearance renders the view model handed to :meth:`open`, and the edited values reach the ``on_commit`` hook on confirmation, so the owner applies them as one undoable gesture. The title/author/comment feed the exported - ``.ftm`` INFO block. + ``.ftm`` INFO block, and the two metric highlights say how many rows a beat + and a bar span. """ def __init__( @@ -55,11 +64,13 @@ def __init__( self._language_manager = language_manager self._layout = layout - self.on_commit: Optional[Callable[[str, str, str], None]] = None + self.on_commit: Optional[Callable[[str, str, str, int, int], None]] = None self._title_value = "" self._author_value = "" self._comment_value = "" + self._first_highlight_value = DEFAULT_FIRST_HIGHLIGHT + self._second_highlight_value = DEFAULT_SECOND_HIGHLIGHT self._created_text = "" self._modified_text = "" @@ -75,6 +86,14 @@ def __init__( language_manager, ProjectPropertiesElements.COMMENT, ) + self._lbl_first_highlight = self._label( + language_manager, + ProjectPropertiesElements.FIRST_HIGHLIGHT, + ) + self._lbl_second_highlight = self._label( + language_manager, + ProjectPropertiesElements.SECOND_HIGHLIGHT, + ) self._lbl_created = self._label( language_manager, ProjectPropertiesElements.CREATED, @@ -94,15 +113,21 @@ def __init__( def open(self, view_model: ProjectPropertiesViewModel) -> None: """Shows the dialog seeded with the given project info.""" + self._seed(view_model) + self.show() + + def prepare(self, *_args: Any, **_kwargs: Any) -> None: + """The rendered values are seeded by :meth:`open` before the tree rebuilds.""" + + def _seed(self, view_model: ProjectPropertiesViewModel) -> None: + """Holds the values the next appearance renders.""" self._title_value = view_model.title self._author_value = view_model.author self._comment_value = view_model.comment + self._first_highlight_value = view_model.first_highlight + self._second_highlight_value = view_model.second_highlight self._created_text = view_model.created_text self._modified_text = view_model.modified_text - self.show() - - def prepare(self, *_args: Any, **_kwargs: Any) -> None: - """The rendered values are seeded by :meth:`open` before the tree rebuilds.""" def create_window(self) -> None: with self.dialog_window( @@ -119,6 +144,8 @@ def create_window(self) -> None: self._lbl_author, self._author_value, ) + self._create_highlight_fields() + dpg.add_separator() self._create_comment_field() dpg.add_separator() self._create_metadata() @@ -129,12 +156,16 @@ def create_window(self) -> None: TAG_SETTINGS_PROPERTIES_INPUT_TITLE, TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR, TAG_SETTINGS_PROPERTIES_INPUT_COMMENT, + TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, + TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, ) self._install_navigation( [ FocusStop.field(TAG_SETTINGS_PROPERTIES_INPUT_TITLE), FocusStop.field(TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR), + FocusStop.field(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT), + FocusStop.field(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT), FocusStop.field(TAG_SETTINGS_PROPERTIES_INPUT_COMMENT), FocusStop.button(TAG_SETTINGS_PROPERTIES_BUTTON_CANCEL, self.hide), FocusStop.button(TAG_SETTINGS_PROPERTIES_BUTTON_OK, self._commit), @@ -161,6 +192,42 @@ def _create_comment_field(self) -> None: height=self._layout.comment_height, ) + def _create_highlight_fields(self) -> None: + """Renders the two metric highlights, the row counts a beat and a bar span.""" + self._create_highlight_field( + TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, + self._lbl_first_highlight, + self._first_highlight_value, + self._language_manager["settings.properties.tooltip.first_highlight"], + ) + self._create_highlight_field( + TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, + self._lbl_second_highlight, + self._second_highlight_value, + self._language_manager["settings.properties.tooltip.second_highlight"], + ) + + def _create_highlight_field( + self, + tag: str, + label: str, + value: int, + tooltip: str, + ) -> None: + with labeled_field(label, self._layout.label_width): + dpg.add_input_int( + tag=tag, + default_value=value, + min_value=MIN_HIGHLIGHT, + max_value=MAX_HIGHLIGHT, + min_clamped=True, + max_clamped=True, + width=self._layout.input_width, + ) + + FontRegistry.bind_to_item(tag, Font.MONO) + show_tooltip(tag, tooltip) + def _create_metadata(self) -> None: self._create_metadata_row(self._lbl_created, self._created_text) self._create_metadata_row(self._lbl_modified, self._modified_text) @@ -192,6 +259,8 @@ def _commit(self) -> None: dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE)[:MAX_PROJECT_TITLE_LENGTH], dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR)[:MAX_PROJECT_AUTHOR_LENGTH], dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_COMMENT)[:MAX_PROJECT_COMMENT_LENGTH], + int(clamp_widget_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT)), + int(clamp_widget_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT)), ) self.hide() diff --git a/src/sampletones_application/ui/panels/sequencer/module.py b/src/sampletones_application/ui/panels/sequencer/module.py index 0d3ffc463..5b22790dc 100644 --- a/src/sampletones_application/ui/panels/sequencer/module.py +++ b/src/sampletones_application/ui/panels/sequencer/module.py @@ -9,10 +9,8 @@ from sampletones_application.tags.general import SUF_HANDLER_REGISTRY from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_MODULE_GROUP_OPTIONS, - TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT, TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, TAG_SEQUENCER_MODULE_INPUT_ROWS, - TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT, TAG_SEQUENCER_MODULE_INPUT_SPEED, TAG_SEQUENCER_MODULE_INPUT_TEMPO, TAG_SEQUENCER_MODULE_PANEL, @@ -30,9 +28,7 @@ ) from sampletones_core.constants.general import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY from sampletones_shared.constants.project import ( - MAX_HIGHLIGHT, MAX_ROWS_PER_PATTERN, - MIN_HIGHLIGHT, MIN_ROWS_PER_PATTERN, ) from sampletones_shared.types.application import Sender @@ -62,8 +58,6 @@ def __init__( self.on_rows_per_pattern: Optional[Callable[[int], None]] = None self.on_tempo: Optional[Callable[[int], None]] = None self.on_speed: Optional[Callable[[int], None]] = None - self.on_first_highlight: Optional[Callable[[int], None]] = None - self.on_second_highlight: Optional[Callable[[int], None]] = None self._msg_status_input = language_manager["global.status.message.input"] @@ -140,42 +134,12 @@ def _create_module_options(self) -> None: width=self._input_width, callback=self._on_speed_input, ) - with labeled_field( - self._language_manager["sequencer.module.label.first_highlight"], - self._label_width, - ): - dpg.add_input_int( - default_value=settings.first_highlight, - tag=TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT, - min_value=MIN_HIGHLIGHT, - max_value=MAX_HIGHLIGHT, - min_clamped=True, - max_clamped=True, - width=self._input_width, - callback=self._on_first_highlight_input, - ) - with labeled_field( - self._language_manager["sequencer.module.label.second_highlight"], - self._label_width, - ): - dpg.add_input_int( - default_value=settings.second_highlight, - tag=TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT, - min_value=MIN_HIGHLIGHT, - max_value=MAX_HIGHLIGHT, - min_clamped=True, - max_clamped=True, - width=self._input_width, - callback=self._on_second_highlight_input, - ) for tag in ( TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, TAG_SEQUENCER_MODULE_INPUT_ROWS, TAG_SEQUENCER_MODULE_INPUT_TEMPO, TAG_SEQUENCER_MODULE_INPUT_SPEED, - TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT, - TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT, ): FontRegistry.bind_to_item(tag, Font.MONO) @@ -190,14 +154,6 @@ def _create_module_options(self) -> None: self._on_rows_per_pattern_input, ) show_tooltip(TAG_SEQUENCER_MODULE_INPUT_ROWS, self._language_manager["sequencer.module.tooltip.rows"]) - show_tooltip( - TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT, - self._language_manager["sequencer.module.tooltip.first_highlight"], - ) - show_tooltip( - TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT, - self._language_manager["sequencer.module.tooltip.second_highlight"], - ) self._status_bar.bind_to_item( TAG_SEQUENCER_MODULE_INPUT_NES_FREQUENCY, self._msg_status_input, @@ -214,14 +170,6 @@ def _create_module_options(self) -> None: TAG_SEQUENCER_MODULE_INPUT_SPEED, self._msg_status_input, ) - self._status_bar.bind_to_item( - TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT, - self._msg_status_input, - ) - self._status_bar.bind_to_item( - TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT, - self._msg_status_input, - ) def update_settings(self, view_model: SequencerSettingsViewModel) -> None: dpg.set_value( @@ -234,14 +182,6 @@ def update_settings(self, view_model: SequencerSettingsViewModel) -> None: ) dpg.set_value(TAG_SEQUENCER_MODULE_INPUT_TEMPO, view_model.tempo) dpg.set_value(TAG_SEQUENCER_MODULE_INPUT_SPEED, view_model.speed) - dpg.set_value( - TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT, - view_model.first_highlight, - ) - dpg.set_value( - TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT, - view_model.second_highlight, - ) def set_enabled(self, enabled: bool) -> None: dpg_configure_item(TAG_SEQUENCER_MODULE_GROUP_OPTIONS, enabled=enabled) @@ -287,15 +227,3 @@ def _on_speed_input(self, _sender: Sender, _app_data: int) -> None: self.on_speed, int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_SPEED)), ) - - def _on_first_highlight_input(self, _sender: Sender, _app_data: int) -> None: - self.call( - self.on_first_highlight, - int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_FIRST_HIGHLIGHT)), - ) - - def _on_second_highlight_input(self, _sender: Sender, _app_data: int) -> None: - self.call( - self.on_second_highlight, - int(clamp_widget_value(TAG_SEQUENCER_MODULE_INPUT_SECOND_HIGHLIGHT)), - ) diff --git a/src/sampletones_application/view_model/shared/project_properties.py b/src/sampletones_application/view_model/shared/project_properties.py index 6e3daa825..a223175fb 100644 --- a/src/sampletones_application/view_model/shared/project_properties.py +++ b/src/sampletones_application/view_model/shared/project_properties.py @@ -7,11 +7,13 @@ class ProjectPropertiesViewModel(BaseModel, frozen=True): - """The project info the properties dialog renders and offers for editing.""" + """The project info and metre the properties dialog renders and offers for editing.""" title: str author: str comment: str + first_highlight: int + second_highlight: int created: datetime modified: datetime diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index e28f4f4c3..9736afe25 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -434,10 +434,6 @@ sequencer.module.label.rows: "Rows" sequencer.module.tooltip.rows: "Press Enter to apply the new row count." sequencer.module.label.tempo: "Tempo" sequencer.module.label.speed: "Speed" -sequencer.module.label.first_highlight: "First highlight" -sequencer.module.label.second_highlight: "Second highlight" -sequencer.module.tooltip.first_highlight: "Rows per beat. The tempo counts these." -sequencer.module.tooltip.second_highlight: "Rows per bar. These group the beats." # ============================================================================= # Sequencer tab — Tracker @@ -547,8 +543,6 @@ sequencer.history.label.loop_on: "on" sequencer.history.label.loop_off: "off" sequencer.history.label.set_tempo: "Set tempo" sequencer.history.label.set_speed: "Set speed" -sequencer.history.label.set_first_highlight: "Set first highlight" -sequencer.history.label.set_second_highlight: "Set second highlight" sequencer.history.label.set_nes_frequency: "Set NES frequency" sequencer.history.label.set_rows_per_pattern: "Set rows per pattern" sequencer.history.label.edit_reconstruction: "Edit reconstruction" @@ -780,5 +774,9 @@ settings.properties.title.window_title: "Project properties" settings.properties.label.title: "Title" settings.properties.label.author: "Author" settings.properties.label.comment: "Comment" +settings.properties.label.first_highlight: "First highlight" +settings.properties.label.second_highlight: "Second highlight" +settings.properties.tooltip.first_highlight: "Rows per beat. The tempo counts these." +settings.properties.tooltip.second_highlight: "Rows per bar. These group the beats." settings.properties.label.created: "Created" settings.properties.label.modified: "Modified" diff --git a/src/sampletones_config/layout/project_properties/root.yaml b/src/sampletones_config/layout/project_properties/root.yaml index 1e624be58..f6044b8c8 100644 --- a/src/sampletones_config/layout/project_properties/root.yaml +++ b/src/sampletones_config/layout/project_properties/root.yaml @@ -1,3 +1,3 @@ -label_width: 90 +label_width: 140 input_width: -1 comment_height: 160 diff --git a/tests/unit/sampletones_application/test_project_properties_history.py b/tests/unit/sampletones_application/test_project_properties_history.py index 8e9b9df20..ce9ff781b 100644 --- a/tests/unit/sampletones_application/test_project_properties_history.py +++ b/tests/unit/sampletones_application/test_project_properties_history.py @@ -7,6 +7,8 @@ from sampletones_application.logic.project.manager import ProjectManager HISTORY_BUDGET: Final[int] = 10 +FIRST_HIGHLIGHT: Final[int] = 3 +SECOND_HIGHLIGHT: Final[int] = 12 def _application() -> Application: @@ -32,21 +34,46 @@ class TestPropertiesCommitHistory: def test_changed_fields_group_into_one_entry(self) -> None: application = _application() - application._commit_project_properties("Title", "Author", "Comment") + application._commit_project_properties( + "Title", + "Author", + "Comment", + FIRST_HIGHLIGHT, + SECOND_HIGHLIGHT, + ) assert len(application.history.entries) == 2 assert application.history.entries[-1].action is HistoryAction.EDIT_PROJECT_PROPERTIES info = application.project_controller.project.info assert (info.title, info.author, info.comment) == ("Title", "Author", "Comment") + def test_the_metre_joins_the_same_entry_as_the_info(self) -> None: + """The highlights are project settings, and the dialog commits them beside the info.""" + application = _application() + + application._commit_project_properties( + "Title", + "Author", + "Comment", + FIRST_HIGHLIGHT, + SECOND_HIGHLIGHT, + ) + + settings = application.project_controller.project.settings + assert (settings.first_highlight, settings.second_highlight) == (FIRST_HIGHLIGHT, SECOND_HIGHLIGHT) + assert len(application.history.entries) == 2 + def test_unchanged_confirmation_records_nothing(self) -> None: application = _application() info = application.project_controller.project.info + settings = application.project_controller.project.settings application._commit_project_properties( info.title, info.author, info.comment, + settings.first_highlight, + settings.second_highlight, ) assert len(application.history.entries) == 1 @@ -54,10 +81,30 @@ def test_unchanged_confirmation_records_nothing(self) -> None: def test_undo_restores_the_previous_properties(self) -> None: application = _application() info = application.project_controller.project.info - previous = (info.title, info.author, info.comment) + settings = application.project_controller.project.settings + previous = ( + info.title, + info.author, + info.comment, + settings.first_highlight, + settings.second_highlight, + ) - application._commit_project_properties("Title", "Author", "Comment") + application._commit_project_properties( + "Title", + "Author", + "Comment", + FIRST_HIGHLIGHT, + SECOND_HIGHLIGHT, + ) application.history.undo() info = application.project_controller.project.info - assert (info.title, info.author, info.comment) == previous + settings = application.project_controller.project.settings + assert ( + info.title, + info.author, + info.comment, + settings.first_highlight, + settings.second_highlight, + ) == previous diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py new file mode 100644 index 000000000..0dd8e62f5 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_project_properties.py @@ -0,0 +1,167 @@ +from datetime import datetime +from typing import Final, List, Tuple + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.paths import LANG_EN +from sampletones_application.tags.settings import ( + TAG_SETTINGS_PROPERTIES_BUTTON_CANCEL, + TAG_SETTINGS_PROPERTIES_BUTTON_OK, + TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR, + TAG_SETTINGS_PROPERTIES_INPUT_COMMENT, + TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, + TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, + TAG_SETTINGS_PROPERTIES_INPUT_TITLE, +) +from sampletones_application.ui.panels.dialogs.project_properties import ( + GUIProjectPropertiesWindow, +) +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.view_model.shared.project_properties import ( + ProjectPropertiesViewModel, +) +from sampletones_shared.constants.project import MAX_HIGHLIGHT, MIN_HIGHLIGHT +from tests.suite.shortcuts import shipped_source + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) + +TIMESTAMP: Final[datetime] = datetime(2026, 8, 10, 12, 30) +FIRST_HIGHLIGHT: Final[int] = 4 +SECOND_HIGHLIGHT: Final[int] = 12 + +Committed = Tuple[str, str, str, int, int] + + +def view_model( + *, + first_highlight: int = FIRST_HIGHLIGHT, + second_highlight: int = SECOND_HIGHLIGHT, +) -> ProjectPropertiesViewModel: + return ProjectPropertiesViewModel( + title="Chiptune", + author="Composer", + comment="A note to self", + first_highlight=first_highlight, + second_highlight=second_highlight, + created=TIMESTAMP, + modified=TIMESTAMP, + ) + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIProjectPropertiesWindow: + return GUIProjectPropertiesWindow( + layout=layout_config.project_properties, + language_manager=LANGUAGE_MANAGER, + key_router=KeyRouter(), + shortcut_source=shipped_source(), + ) + + +def render( + window: GUIProjectPropertiesWindow, + *, + first_highlight: int = FIRST_HIGHLIGHT, + second_highlight: int = SECOND_HIGHLIGHT, +) -> None: + """Builds the widget tree for the given project, the way ``open`` does without a live frame.""" + window._seed( + view_model( + first_highlight=first_highlight, + second_highlight=second_highlight, + ) + ) + window.create_window() + + +class TestProjectPropertiesWindow: + def test_the_info_shows_the_project_s_own(self, window: GUIProjectPropertiesWindow) -> None: + render(window) + + assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE) == "Chiptune" + assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_AUTHOR) == "Composer" + assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_COMMENT) == "A note to self" + + def test_the_metre_shows_the_project_s_highlights(self, window: GUIProjectPropertiesWindow) -> None: + render(window) + + assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT) == FIRST_HIGHLIGHT + assert dpg.get_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT) == SECOND_HIGHLIGHT + + def test_each_highlight_field_holds_the_range_the_project_accepts( + self, + window: GUIProjectPropertiesWindow, + ) -> None: + render(window) + + for tag in ( + TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, + TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, + ): + configuration = dpg.get_item_configuration(tag) + assert configuration["min_value"] == MIN_HIGHLIGHT + assert configuration["max_value"] == MAX_HIGHLIGHT + + def test_both_actions_are_offered(self, window: GUIProjectPropertiesWindow) -> None: + render(window) + + assert dpg.does_item_exist(TAG_SETTINGS_PROPERTIES_BUTTON_OK) + assert dpg.does_item_exist(TAG_SETTINGS_PROPERTIES_BUTTON_CANCEL) + + +class TestCommit: + """Confirming reports the whole form at once, so the owner applies one undoable gesture.""" + + @pytest.fixture(name="committed") + def committed_fixture(self, window: GUIProjectPropertiesWindow) -> List[Committed]: + committed: List[Committed] = [] + window.on_commit = lambda title, author, comment, first_highlight, second_highlight: committed.append( + (title, author, comment, first_highlight, second_highlight) + ) + render(window) + return committed + + def test_the_edited_metre_reaches_the_owner( + self, + window: GUIProjectPropertiesWindow, + committed: List[Committed], + ) -> None: + dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, 3) + dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, 9) + + window._commit() + + assert committed == [("Chiptune", "Composer", "A note to self", 3, 9)] + + def test_a_highlight_past_the_range_arrives_clamped( + self, + window: GUIProjectPropertiesWindow, + committed: List[Committed], + ) -> None: + """The project rejects a highlight outside its bounds, so the dialog reports one inside.""" + dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_FIRST_HIGHLIGHT, MAX_HIGHLIGHT + 1) + dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_SECOND_HIGHLIGHT, MIN_HIGHLIGHT - 1) + + window._commit() + + assert committed[-1][3:] == (MAX_HIGHLIGHT, MIN_HIGHLIGHT) + + def test_the_metre_carries_the_info_with_it( + self, + window: GUIProjectPropertiesWindow, + committed: List[Committed], + ) -> None: + dpg.set_value(TAG_SETTINGS_PROPERTIES_INPUT_TITLE, "Another song") + + window._commit() + + assert committed[-1] == ( + "Another song", + "Composer", + "A note to self", + FIRST_HIGHLIGHT, + SECOND_HIGHLIGHT, + ) From 7c00894981bca834609e9137fe7c86211d4f6cb7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 21:03:15 +0200 Subject: [PATCH 052/152] =?UTF-8?q?Changed:=20NES=20frequency=20default=20?= =?UTF-8?q?to=2060=20and=20tempo=20range=20to=2032=E2=80=93255?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/concepts/reconstruction.md | 4 ++-- docs/formats/famitracker.md | 2 +- docs/formats/instruction-libraries.md | 4 ++-- .../layout/tabs/sequencer/__init__.py | 4 ---- .../layout/tabs/sequencer/speed.py | 7 ------- .../layout/tabs/sequencer/tempo.py | 7 ------- .../ui/panels/main/config.py | 2 +- .../ui/panels/sequencer/module.py | 14 +++++++++----- .../layout/tabs/sequencer/speed.yaml | 3 --- .../layout/tabs/sequencer/tempo.yaml | 3 --- src/sampletones_core/configs/library.py | 10 ++++++---- src/sampletones_core/constants/general.py | 6 ------ src/sampletones_core/formats/famitracker/notes.py | 3 +-- .../famitracker/specification/parameters.py | 2 -- src/sampletones_core/project/settings.py | 2 +- src/sampletones_shared/constants/nes.py | 10 ++++++++++ src/sampletones_shared/constants/project.py | 8 +++++--- tests/integration/config/module.yaml | 2 +- .../formats/famitracker/test_builder.py | 13 +++++++++++-- .../formats/famitracker/test_ftm.py | 3 ++- .../unit/sampletones_core/project/test_settings.py | 4 ++-- .../reconstruction/test_reconstruction.py | 5 +++-- 22 files changed, 57 insertions(+), 61 deletions(-) delete mode 100644 src/sampletones_application/layout/tabs/sequencer/speed.py delete mode 100644 src/sampletones_application/layout/tabs/sequencer/tempo.py delete mode 100644 src/sampletones_config/layout/tabs/sequencer/speed.yaml delete mode 100644 src/sampletones_config/layout/tabs/sequencer/tempo.yaml create mode 100644 src/sampletones_shared/constants/nes.py diff --git a/docs/concepts/reconstruction.md b/docs/concepts/reconstruction.md index a78178ff3..05ebc6027 100644 --- a/docs/concepts/reconstruction.md +++ b/docs/concepts/reconstruction.md @@ -242,12 +242,12 @@ reconstruction and the original can be shown and played on a common scale. ## Appendix — key parameters and where things live -Default configuration (44.1 kHz, 30 Hz change rate, channels pulse 1 + triangle + +Default configuration (44.1 kHz, 60 Hz change rate, channels pulse 1 + triangle + noise): | parameter | default | notes | |--------------------------|---------|----------------------------------------------------| -| frame length | 1470 | `sample_rate / nes_frequency`, ~33 ms | +| frame length | 735 | `sample_rate / nes_frequency`, ~17 ms | | spectrum method | `cqt` | `fft` / `logfft` / `cqt` | | `transformation_gamma` | 0 | 0 = power spectrum, 100 = log | | spectral / temporal weight | 0.8 / 0.2 | criterion blend | diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index ab90b2504..d0ba9971e 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -194,7 +194,7 @@ checklist. | Note range | C-0..B-7 (pitch 24–119) | `initial_pitch` 33–119 + `transpose` −24..+36 can exceed it | clamps to the nearest playable note (fidelity loss at the extremes) | | Title / author | 32 bytes each | 64 characters | truncates to 32 bytes | | Comment | free text (COMMENTS block) | 65536 characters | carried in full | -| Tempo / speed | engine-dependent (split at row `speed_split_point`) | tempo 1–300, speed 1–31 | written verbatim from settings | +| Tempo / speed | engine-dependent (split at row `speed_split_point`) | tempo 32–255, speed 1–31 | written verbatim from settings | | DPCM samples | 64 | not modelled | always empty by design | The exporter also reserves a per-channel empty pattern index (`max used index + 1`) diff --git a/docs/formats/instruction-libraries.md b/docs/formats/instruction-libraries.md index fbdba2e2c..6cc8eb725 100644 --- a/docs/formats/instruction-libraries.md +++ b/docs/formats/instruction-libraries.md @@ -45,13 +45,13 @@ Libraries are stored as `.ins` files in the documents folder, with the configuration embedded in the file name: ``` -sr_44100_nf_30_ws_13579_tg_0_sm_cqt_ch_384e710987cb958adf2b214df1267d10.ins +sr_44100_nf_60_ws_13579_tg_0_sm_cqt_ch_384e710987cb958adf2b214df1267d10.ins ``` | Fragment | Meaning | | --- | --- | | `sr_44100` | sample rate 44100 Hz | -| `nf_30` | NES frequency 30 Hz | +| `nf_60` | NES frequency 60 Hz | | `ws_13579` | FFT window size (samples) | | `tg_0` | transformation gamma 0 | | `sm_cqt` | spectrum method (`fft` / `logfft` / `cqt`) | diff --git a/src/sampletones_application/layout/tabs/sequencer/__init__.py b/src/sampletones_application/layout/tabs/sequencer/__init__.py index 104f2c786..c21774f1d 100644 --- a/src/sampletones_application/layout/tabs/sequencer/__init__.py +++ b/src/sampletones_application/layout/tabs/sequencer/__init__.py @@ -4,17 +4,13 @@ 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.tables.cells import SequencerTableCells -from sampletones_application.layout.tabs.sequencer.tempo import TempoLayout from sampletones_application.layout.tabs.sequencer.tracker.tracker import TrackerLayout class SequencerLayout(BaseModel, extra="forbid", frozen=True): order: OrderLayout table_cells: SequencerTableCells - tempo: TempoLayout - speed: SpeedLayout tracker: TrackerLayout history: HistoryLayout colors: SequencerColors diff --git a/src/sampletones_application/layout/tabs/sequencer/speed.py b/src/sampletones_application/layout/tabs/sequencer/speed.py deleted file mode 100644 index ed54b7f9c..000000000 --- a/src/sampletones_application/layout/tabs/sequencer/speed.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel - - -class SpeedLayout(BaseModel, extra="forbid", frozen=True): - min: int - max: int - default: int diff --git a/src/sampletones_application/layout/tabs/sequencer/tempo.py b/src/sampletones_application/layout/tabs/sequencer/tempo.py deleted file mode 100644 index fe52a26cf..000000000 --- a/src/sampletones_application/layout/tabs/sequencer/tempo.py +++ /dev/null @@ -1,7 +0,0 @@ -from pydantic import BaseModel - - -class TempoLayout(BaseModel, extra="forbid", frozen=True): - min: int - max: int - default: int diff --git a/src/sampletones_application/ui/panels/main/config.py b/src/sampletones_application/ui/panels/main/config.py index 7495e5aba..133313625 100644 --- a/src/sampletones_application/ui/panels/main/config.py +++ b/src/sampletones_application/ui/panels/main/config.py @@ -27,7 +27,7 @@ LibrarySettingsUpdate, ) from sampletones_core.constants.audio import MAX_SAMPLE_RATE, MIN_SAMPLE_RATE -from sampletones_core.constants.general import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY +from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY from sampletones_shared.types.application import Sender diff --git a/src/sampletones_application/ui/panels/sequencer/module.py b/src/sampletones_application/ui/panels/sequencer/module.py index 5b22790dc..13a8e54db 100644 --- a/src/sampletones_application/ui/panels/sequencer/module.py +++ b/src/sampletones_application/ui/panels/sequencer/module.py @@ -26,10 +26,14 @@ from sampletones_application.view_model.sequencer.settings import ( SequencerSettingsViewModel, ) -from sampletones_core.constants.general import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY +from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY from sampletones_shared.constants.project import ( MAX_ROWS_PER_PATTERN, + MAX_SPEED, + MAX_TEMPO, MIN_ROWS_PER_PATTERN, + MIN_SPEED, + MIN_TEMPO, ) from sampletones_shared.types.application import Sender @@ -113,8 +117,8 @@ def _create_module_options(self) -> None: dpg.add_input_int( default_value=settings.tempo, tag=TAG_SEQUENCER_MODULE_INPUT_TEMPO, - min_value=self._layout.tempo.min, - max_value=self._layout.tempo.max, + min_value=MIN_TEMPO, + max_value=MAX_TEMPO, min_clamped=True, max_clamped=True, width=self._input_width, @@ -127,8 +131,8 @@ def _create_module_options(self) -> None: dpg.add_input_int( default_value=settings.speed, tag=TAG_SEQUENCER_MODULE_INPUT_SPEED, - min_value=self._layout.speed.min, - max_value=self._layout.speed.max, + min_value=MIN_SPEED, + max_value=MAX_SPEED, min_clamped=True, max_clamped=True, width=self._input_width, diff --git a/src/sampletones_config/layout/tabs/sequencer/speed.yaml b/src/sampletones_config/layout/tabs/sequencer/speed.yaml deleted file mode 100644 index 5a95e49a7..000000000 --- a/src/sampletones_config/layout/tabs/sequencer/speed.yaml +++ /dev/null @@ -1,3 +0,0 @@ -min: 1 -max: 31 -default: 6 diff --git a/src/sampletones_config/layout/tabs/sequencer/tempo.yaml b/src/sampletones_config/layout/tabs/sequencer/tempo.yaml deleted file mode 100644 index 8f1d02551..000000000 --- a/src/sampletones_config/layout/tabs/sequencer/tempo.yaml +++ /dev/null @@ -1,3 +0,0 @@ -min: 32 -max: 255 -default: 150 diff --git a/src/sampletones_core/configs/library.py b/src/sampletones_core/configs/library.py index 785c7b022..0cdfa86f4 100644 --- a/src/sampletones_core/configs/library.py +++ b/src/sampletones_core/configs/library.py @@ -11,14 +11,16 @@ from sampletones_core.constants.general import ( A4_FREQUENCY, A4_PITCH, - DEFAULT_NES_FREQUENCY, LIMIT_MAX_PITCH, - MAX_NES_FREQUENCY, MIN_FREQUENCY, - MIN_NES_FREQUENCY, ) from sampletones_core.constants.spectrum import BINS_PER_OCTAVE, CQT_CUTOFF_FREQUENCY from sampletones_core.data import DataModel +from sampletones_shared.constants.nes import ( + DEFAULT_NES_FREQUENCY, + MAX_NES_FREQUENCY, + MIN_NES_FREQUENCY, +) class InstructionsLibraryConfig(DataModel): @@ -28,7 +30,7 @@ class InstructionsLibraryConfig(DataModel): default=DEFAULT_NES_FREQUENCY, ge=MIN_NES_FREQUENCY, le=MAX_NES_FREQUENCY, - description="Instruction change rate in Hz; the default equals half of the NTSC frame rate.", + description="Instruction change rate in Hz; the default is the NTSC frame rate.", validation_alias=AliasChoices( "change_rate", "nes_frequency", diff --git a/src/sampletones_core/constants/general.py b/src/sampletones_core/constants/general.py index dad4e91b9..7179e39ec 100644 --- a/src/sampletones_core/constants/general.py +++ b/src/sampletones_core/constants/general.py @@ -1,11 +1,5 @@ from typing import Final, Tuple -# NES limits - -DEFAULT_NES_FREQUENCY: Final[int] = 30 -MIN_NES_FREQUENCY: Final[int] = 15 -MAX_NES_FREQUENCY: Final[int] = 300 - # Pitches and frequencies APU_CLOCK: Final[float] = 1789773.0 diff --git a/src/sampletones_core/formats/famitracker/notes.py b/src/sampletones_core/formats/famitracker/notes.py index dc05f0d98..19849bf62 100644 --- a/src/sampletones_core/formats/famitracker/notes.py +++ b/src/sampletones_core/formats/famitracker/notes.py @@ -4,8 +4,6 @@ from sampletones_core.formats.famitracker.model.pattern import NoteCell from sampletones_core.formats.famitracker.specification.parameters import ( ENGINE_SPEED_MACHINE_DEFAULT, - NTSC_FREQUENCY, - PAL_FREQUENCY, Machine, ) from sampletones_core.formats.famitracker.specification.patterns import ( @@ -14,6 +12,7 @@ NOTE_RANGE, PITCH_OCTAVE_OFFSET, ) +from sampletones_shared.constants.nes import NTSC_FREQUENCY, PAL_FREQUENCY def pitch_to_note_cell(pitch: int) -> NoteCell: diff --git a/src/sampletones_core/formats/famitracker/specification/parameters.py b/src/sampletones_core/formats/famitracker/specification/parameters.py index 910270248..9788b081a 100644 --- a/src/sampletones_core/formats/famitracker/specification/parameters.py +++ b/src/sampletones_core/formats/famitracker/specification/parameters.py @@ -16,8 +16,6 @@ class Machine(IntEnum): DEFAULT_HIGHLIGHT_FIRST: Final[int] = 4 DEFAULT_HIGHLIGHT_SECOND: Final[int] = 16 ENGINE_SPEED_MACHINE_DEFAULT: Final[int] = 0 -NTSC_FREQUENCY: Final[int] = 60 -PAL_FREQUENCY: Final[int] = 50 SINGLE_TRACK_COUNT: Final[int] = 1 FIRST_TRACK_INDEX: Final[int] = 0 diff --git a/src/sampletones_core/project/settings.py b/src/sampletones_core/project/settings.py index 7d9942331..8d7d0a203 100644 --- a/src/sampletones_core/project/settings.py +++ b/src/sampletones_core/project/settings.py @@ -5,7 +5,7 @@ MAX_SAMPLE_RATE, MIN_SAMPLE_RATE, ) -from sampletones_core.constants.general import ( +from sampletones_shared.constants.nes import ( DEFAULT_NES_FREQUENCY, MAX_NES_FREQUENCY, MIN_NES_FREQUENCY, diff --git a/src/sampletones_shared/constants/nes.py b/src/sampletones_shared/constants/nes.py new file mode 100644 index 000000000..5de3bd6c8 --- /dev/null +++ b/src/sampletones_shared/constants/nes.py @@ -0,0 +1,10 @@ +from typing import Final + +# Console refresh rates +NTSC_FREQUENCY: Final[int] = 60 +PAL_FREQUENCY: Final[int] = 50 + +# Engine refresh rate +DEFAULT_NES_FREQUENCY: Final[int] = NTSC_FREQUENCY +MIN_NES_FREQUENCY: Final[int] = 15 +MAX_NES_FREQUENCY: Final[int] = 300 diff --git a/src/sampletones_shared/constants/project.py b/src/sampletones_shared/constants/project.py index 77ff11515..88fbac354 100644 --- a/src/sampletones_shared/constants/project.py +++ b/src/sampletones_shared/constants/project.py @@ -1,5 +1,7 @@ from typing import Final +from sampletones_shared.constants.nes import NTSC_FREQUENCY + # Project archive layout PROJECT_DOCUMENT_NAME: Final[str] = "project.json" RECONSTRUCTIONS_DIRECTORY: Final[str] = "reconstructions" @@ -19,12 +21,12 @@ # Tick formula calibration # speed == ticks_per_row at these reference values REFERENCE_TEMPO: Final[int] = 150 -REFERENCE_NES_FREQUENCY: Final[int] = 60 +REFERENCE_NES_FREQUENCY: Final[int] = NTSC_FREQUENCY # Song timing DEFAULT_TEMPO: Final[int] = 150 -MIN_TEMPO: Final[int] = 1 -MAX_TEMPO: Final[int] = 300 +MIN_TEMPO: Final[int] = 32 +MAX_TEMPO: Final[int] = 255 DEFAULT_SPEED: Final[int] = 6 MIN_SPEED: Final[int] = 1 diff --git a/tests/integration/config/module.yaml b/tests/integration/config/module.yaml index 80b383244..cd248a745 100644 --- a/tests/integration/config/module.yaml +++ b/tests/integration/config/module.yaml @@ -2,4 +2,4 @@ title: Drum Demo author: Integration tempo: 150 speed: 6 -nes_frequency: 30 +nes_frequency: 60 diff --git a/tests/unit/sampletones_core/formats/famitracker/test_builder.py b/tests/unit/sampletones_core/formats/famitracker/test_builder.py index 07bde575b..d5c66db34 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py @@ -14,6 +14,7 @@ MAX_INSTRUMENTS, ) from sampletones_core.formats.famitracker.specification.parameters import ( + ENGINE_SPEED_MACHINE_DEFAULT, EXPANSION_NONE, Machine, ) @@ -34,6 +35,7 @@ LEAD_PITCH = 60 OCTAVE = 12 +CUSTOM_NES_FREQUENCY = 30 class TestBuildInstrumentTable: @@ -103,10 +105,17 @@ def test_expansion_and_channel_count(self, project_fixture: ProjectFixture) -> N assert module.parameters.channel_count == CHANNEL_COUNT_2A03 def test_machine_and_engine_speed_from_default_frequency(self, project_fixture: ProjectFixture) -> None: - # default nes_frequency is 30 -> NTSC with an explicit engine-speed override module = project_to_module(project_fixture.project) assert module.parameters.machine == Machine.NTSC - assert module.parameters.engine_speed == project_fixture.project.settings.nes_frequency + assert module.parameters.engine_speed == ENGINE_SPEED_MACHINE_DEFAULT + + def test_machine_and_engine_speed_from_a_custom_frequency(self, project_fixture: ProjectFixture) -> None: + project_fixture.project.settings.nes_frequency = CUSTOM_NES_FREQUENCY + + module = project_to_module(project_fixture.project) + + assert module.parameters.machine == Machine.NTSC + assert module.parameters.engine_speed == CUSTOM_NES_FREQUENCY def test_information_and_comment_carry_through(self, project_fixture: ProjectFixture) -> None: module = project_to_module(project_fixture.project) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_ftm.py b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py index 65a5d31bf..815bd7509 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_ftm.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_ftm.py @@ -21,6 +21,7 @@ ) from sampletones_core.formats.famitracker.specification.parameters import ( DEFAULT_SPEED_SPLIT_POINT, + ENGINE_SPEED_MACHINE_DEFAULT, EXPANSION_NONE, Machine, ) @@ -85,7 +86,7 @@ def test_expansion_and_channels(self, project_fixture: ProjectFixture) -> None: def test_machine_and_engine_speed(self, project_fixture: ProjectFixture) -> None: params = _parsed(project_fixture).params assert params.machine == int(Machine.NTSC) - assert params.engine_speed == project_fixture.project.settings.nes_frequency + assert params.engine_speed == ENGINE_SPEED_MACHINE_DEFAULT def test_speed_split_point(self, project_fixture: ProjectFixture) -> None: assert _parsed(project_fixture).params.speed_split_point == DEFAULT_SPEED_SPLIT_POINT diff --git a/tests/unit/sampletones_core/project/test_settings.py b/tests/unit/sampletones_core/project/test_settings.py index 23054392a..fb74bb525 100644 --- a/tests/unit/sampletones_core/project/test_settings.py +++ b/tests/unit/sampletones_core/project/test_settings.py @@ -3,11 +3,11 @@ import pytest from pydantic import ValidationError -from sampletones_core.constants.general import ( +from sampletones_core.project.settings import ProjectSettings +from sampletones_shared.constants.nes import ( MAX_NES_FREQUENCY, MIN_NES_FREQUENCY, ) -from sampletones_core.project.settings import ProjectSettings from sampletones_shared.constants.project import ( DEFAULT_FIRST_HIGHLIGHT, DEFAULT_SECOND_HIGHLIGHT, diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index ef79ec57e..2f3e92487 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -14,6 +14,7 @@ from sampletones_shared.application import ( SAMPLETONES_RECONSTRUCTION_DATA_VERSION, ) +from sampletones_shared.constants.nes import DEFAULT_NES_FREQUENCY from sampletones_shared.exceptions import ( DeserializationError, IncompatibleReconstructionVersionError, @@ -28,8 +29,8 @@ from tests.suite.case import BaseRegularTestCase from tests.suite.errors import DIRECTORY_READ_ERRORS -_RETUNED_FREQUENCY: Final[int] = 60 -_FASTER_FREQUENCY: Final[int] = 120 +_RETUNED_FREQUENCY: Final[int] = DEFAULT_NES_FREQUENCY // 2 +_FASTER_FREQUENCY: Final[int] = DEFAULT_NES_FREQUENCY * 2 _AUDIO_LENGTH: Final[int] = 64 _BASE_PITCH: Final[int] = 60 From cdc0a8d04f9d6e3d9de93b82ea7cd00f0c90d7d8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 21:34:13 +0200 Subject: [PATCH 053/152] Changed: playback row duration to the project's groove --- docs/development/playback.md | 5 + docs/glossary.md | 8 ++ docs/guide/sequencer.md | 5 + .../constants/playback.py | 14 ++ .../logic/sequencer/playback/synthesizer.py | 80 +++++++++-- .../sequencer/playback/test_synthesizer.py | 126 +++++++++++++++--- 6 files changed, 207 insertions(+), 31 deletions(-) diff --git a/docs/development/playback.md b/docs/development/playback.md index 025e4aad9..165312e08 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -29,6 +29,10 @@ a control over what is heard. The contracts here bind every tab and every player 6. **Live state is pulled while sound is produced.** A player reads the settings that shape its sound as it renders, so a change is heard as the render-ahead buffer drains. This is what lets a listening control take effect inside the sound already playing. +7. **A row's duration belongs to the song, not to the player.** How long a row lasts follows from + the project's tempo and metre together with the row's place in the pattern, so it is a function + of position: the same row lasts the same time however playback reached it, and a module exported + from the song can state the same figures. ## Two kinds of sound @@ -188,6 +192,7 @@ terminating would reclaim. | Where the playhead stands, and both grids' marks for it | `SequencerTabCoordinator` (`coordinators/tabs/sequencer.py`) | | Marking and revealing the sounding row in the tracker | `GUISequencerTrackerPanel` (`ui/panels/sequencer/tracker.py`) | | Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer.py`) | +| How long each row of a pattern lasts | `Groove` (`sampletones_core/timing/`), indexed by row while rendering | | The song's render-ahead buffer | `services/song_player/` | The sequencer song is an ordinary intentional source alongside the reconstruction and instruction diff --git a/docs/glossary.md b/docs/glossary.md index 466ade550..8c53c90bd 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -189,6 +189,14 @@ rows one beat spans — and the **second highlight** is the bar that gathers bea The tracker tints the row that opens each, and the beat is what a tempo counts: `beats_per_minute = 60 × nes_frequency / (ticks_per_row × first_highlight)`. +### Groove + +The engine ticks each row of a pattern lasts. An engine holds a row for a whole +number of ticks, so a tempo landing between two counts is played by varying the +count from row to row, and the metre places the longer rows on the bar, then the +beat, then inside the beat. Playback reads the groove by the row's position in the +pattern, so the pattern's first row starts it afresh. + ### Order The list that arranges patterns into the song's timeline. diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index cccb1a79a..acefed069 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -119,6 +119,11 @@ bar divided by the beat is how many beats you hear in a bar, so the default 4 an and shortens the bar to 12, for three beats. The beat is what the tempo counts, so the two together say how fast the song is felt as well as how it looks. +The metre also places the song's timing. Most tempos ask for a row length the engine +can only reach on average, so the rows of a bar differ a little: the metre gives the +extra time to the row that opens the bar, then to the row that opens each beat, which +keeps the beat audible where you expect it. + ## Undo and export Every change is undoable. The **History** panel on the right shows the stack, with diff --git a/src/sampletones_application/constants/playback.py b/src/sampletones_application/constants/playback.py index 307d89fda..31324fedc 100644 --- a/src/sampletones_application/constants/playback.py +++ b/src/sampletones_application/constants/playback.py @@ -1,6 +1,11 @@ from enum import StrEnum +from math import ceil from typing import Final +from sampletones_core.timing import RowRate +from sampletones_shared.constants.nes import MAX_NES_FREQUENCY +from sampletones_shared.constants.project import MAX_SPEED, MIN_TEMPO + class FollowMode(StrEnum): """How far the sequencer view chases the playhead during song playback. @@ -25,3 +30,12 @@ def follows_row(self) -> bool: DEFAULT_FOLLOW_MODE: Final[FollowMode] = FollowMode.ROWS + +MIN_TICKS_PER_ROW: Final[int] = 1 +MAX_TICKS_PER_ROW: Final[int] = ceil( + RowRate.from_parameters( + tempo=MIN_TEMPO, + speed=MAX_SPEED, + nes_frequency=MAX_NES_FREQUENCY, + ).ticks_per_row +) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer.py index 12e1d65a6..01d943f87 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer.py @@ -1,8 +1,14 @@ +from __future__ import annotations + from dataclasses import dataclass, field, replace from typing import Callable, Dict, FrozenSet, List, Optional, Tuple import numpy as np +from sampletones_application.constants.playback import ( + MAX_TICKS_PER_ROW, + MIN_TICKS_PER_ROW, +) from sampletones_application.logic.project.controller import ProjectController from sampletones_core.audio import clip_audio_inplace from sampletones_core.configs import Config @@ -19,10 +25,9 @@ from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row -from sampletones_core.project.settings import ProjectSettings from sampletones_core.project.song import Song from sampletones_core.project.song_position import SongPosition -from sampletones_shared.constants.project import REFERENCE_NES_FREQUENCY, REFERENCE_TEMPO +from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove from .protocol import ChannelGeneratorProtocol @@ -36,6 +41,44 @@ class _ChannelState: volume: int = field(default=MAX_VOLUME) +@dataclass(frozen=True) +class _SongTiming: + """Everything a project's groove is built from, held together so a change is one comparison. + + Attributes: + rate: The exact ticks one row lasts under the project's tempo, speed and tick rate. + metre: The pattern length and the beat and bar grouping the ticks are spread over. + """ + + rate: RowRate + metre: Metre + + @classmethod + def from_project(cls, project: Project) -> _SongTiming: + """Reads the timing a project plays at, taking the pattern length from its song.""" + return cls( + rate=RowRate.from_settings(project.settings), + metre=Metre.from_settings( + project.settings, + rows=project.song.rows_per_pattern, + ), + ) + + def groove(self) -> Groove: + """Spreads the row rate across a pattern's rows. + + Playback follows whatever tempo the project states, so the one bound it sets is that + every row lasts at least a tick and keeps sounding; the ceiling is the fastest row the + settings can ask for, which leaves the groove free to realize the rate exactly. + """ + return calculate_groove( + self.rate, + self.metre, + minimum_ticks=MIN_TICKS_PER_ROW, + maximum_ticks=MAX_TICKS_PER_ROW, + ) + + def _silence(samples: int) -> np.ndarray: return np.zeros(samples, dtype=np.float32) @@ -67,6 +110,10 @@ class RowSynthesizer: call so that pattern edits, tempo changes, and sample swaps take effect immediately while playback keeps running. + A row lasts the ticks the project's groove gives its position within the pattern, so the + row a pattern's tenth row plays for is the row an exported module plays it for: both index + the same groove from the pattern's first row. + Generators are constructed once from ``config`` and carry timer state across rows for phase continuity within a sustained note. Triggering a new note calls ``generator.reset()`` for a clean phase start. @@ -89,7 +136,8 @@ def __init__( self._active_channels = active_channels self._nes_frequency: int = config.library.nes_frequency self._position = SongPosition() - self._tick_debt: int = 0 + self._timing: _SongTiming = _SongTiming.from_project(project_controller.project) + self._groove: Groove = self._timing.groove() self._channel_states: Dict[GeneratorName, _ChannelState] = { generator_name: _ChannelState( generator=GENERATOR_CLASSES[generator_name]( @@ -118,7 +166,6 @@ def set_position(self, order_position: int, row_index: int) -> None: self._position.row_index = row_index def reset(self) -> None: - self._tick_debt = 0 for state in self._channel_states.values(): state.sample_id = None state.tick_index = 0 @@ -157,9 +204,10 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: song = project.song self._position.wrap_overflow(song.rows_per_pattern) self._ensure_generators(settings.nes_frequency) + self._ensure_groove(project) frame_length = round(self._config.library.sample_rate / settings.nes_frequency) - ticks_per_row = self._ticks_for_row(settings) + ticks_per_row = self._groove.ticks[self._position.row_index] chunk_length = frame_length * ticks_per_row position_before = replace(self._position) @@ -177,16 +225,20 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: return mixed, position_before - def _ticks_for_row(self, settings: ProjectSettings) -> int: - """ticks_per_row == speed at REFERENCE_TEMPO and REFERENCE_NES_FREQUENCY.""" - self._tick_debt += settings.speed * settings.nes_frequency * REFERENCE_TEMPO - return self._drain_tick_debt(settings.tempo) + def _ensure_groove(self, project: Project) -> None: + """Rebuilds the groove when the row rate or the metre it is spread over changes. + + An engine that holds a row for a whole number of ticks reaches a fractional row rate by + varying that number from row to row, and the groove is where those counts are decided. + Rebuilding only on a timing edit keeps a tempo change immediate while the distribution + itself, which spans a whole pattern, is computed once. + """ + timing = _SongTiming.from_project(project) + if timing == self._timing: + return - def _drain_tick_debt(self, tempo: int) -> int: - divisor = tempo * REFERENCE_NES_FREQUENCY - ticks = self._tick_debt // divisor - self._tick_debt -= ticks * divisor - return ticks + self._timing = timing + self._groove = timing.groove() def _mix_channels( self, 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 ba289a089..1283c04a4 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -1,17 +1,19 @@ from dataclasses import dataclass, field -from typing import Dict, FrozenSet, List, Optional +from typing import Dict, FrozenSet, List, Optional, Tuple import numpy as np +from sampletones_application.constants.playback import ( + MAX_TICKS_PER_ROW, + MIN_TICKS_PER_ROW, +) +from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.sequencer.channels import ALL_CHANNELS from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_VOLUME -from sampletones_shared.constants.project import ( - REFERENCE_NES_FREQUENCY, - REFERENCE_TEMPO, -) +from sampletones_core.timing import Metre, RowRate, calculate_groove from tests.suite.scenario import BaseTestScenario, ScenarioStep from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( add_sample, @@ -74,6 +76,27 @@ def _render(context: SynthesizerContext) -> np.ndarray: return audio +def _groove_ticks(controller: ProjectController) -> Tuple[int, ...]: + """The ticks each row of a pattern owes the project's timing, from the timing package itself.""" + settings = controller.project.settings + return calculate_groove( + RowRate.from_settings(settings), + Metre.from_settings(settings, rows=controller.project.song.rows_per_pattern), + minimum_ticks=MIN_TICKS_PER_ROW, + maximum_ticks=MAX_TICKS_PER_ROW, + ).ticks + + +def _row_ticks( + synthesizer: RowSynthesizer, + rows: int, +) -> Tuple[int, ...]: + """The ticks ``rows`` consecutive rendered rows last, read back from the audio they produced.""" + settings = synthesizer._project_controller.project.settings + frame_length = round(settings.sample_rate / settings.nes_frequency) + return tuple(len(synthesizer.render_row()[0]) // frame_length for _ in range(rows)) + + class TestTriggerSetsDefaults: def test_transpose_and_volume_default_to_zero_and_max(self) -> None: def place_pulse_sample_on_row_0(context: SynthesizerContext) -> None: @@ -662,18 +685,16 @@ def render_beyond_end_and_assert_silence(context: SynthesizerContext) -> None: class TestFrameCount: - def test_chunk_length_matches_speed_sample_rate_and_nes_frequency(self) -> None: + def test_chunk_length_matches_the_groove_row_and_the_frame_length(self) -> None: def render_and_assert_chunk_length(context: SynthesizerContext) -> None: - settings = _controller(context).project.settings + controller = _controller(context) + settings = controller.project.settings frame_length = settings.sample_rate // settings.nes_frequency - ticks_per_row = (settings.speed * settings.nes_frequency * REFERENCE_TEMPO) // ( - settings.tempo * REFERENCE_NES_FREQUENCY - ) audio = _render(context) - assert len(audio) == frame_length * ticks_per_row + assert len(audio) == frame_length * _groove_ticks(controller)[0] BaseTestScenario( - label="chunk length matches timing formula", + label="chunk length matches the groove's first row", build=_make_context, steps=[ ScenarioStep( @@ -684,6 +705,79 @@ def render_and_assert_chunk_length(context: SynthesizerContext) -> None: ).run() +class TestGroove: + def test_a_pattern_plays_the_groove_the_metre_yields( + self, + controller: ProjectController, + synthesizer: RowSynthesizer, + ) -> None: + """Speed 6 at 60 Hz against tempo 210 asks for 30/7 ticks a row, which no single speed + value states. Spread over a 16-row bar of four-row beats it comes out as the bar, its + half, and each beat carrying the longer row. + """ + controller.set_rows_per_pattern(16) + controller.set_tempo(210) + + rendered = _row_ticks(synthesizer, controller.project.song.rows_per_pattern) + + assert rendered == (5, 4, 5, 4, 5, 4, 4, 4, 5, 4, 4, 4, 5, 4, 4, 4) + assert rendered == _groove_ticks(controller) + + def test_the_groove_restarts_with_the_pattern( + self, + controller: ProjectController, + synthesizer: RowSynthesizer, + ) -> None: + """Every row reads the groove entry its position in the pattern names, so returning to + row 0 plays row 0's duration again — the phase an exported module also restarts on. + """ + controller.set_rows_per_pattern(16) + controller.set_tempo(210) + + opening = _row_ticks(synthesizer, 3) + synthesizer.set_position(0, 0) + again = _row_ticks(synthesizer, 1) + + assert opening == (5, 4, 5) + assert again == (opening[0],) + + def test_tempo_change_between_rows_rebuilds_the_groove( + self, + controller: ProjectController, + synthesizer: RowSynthesizer, + ) -> None: + """A tempo edit is heard on the next row, at that row's place in the new groove.""" + controller.set_rows_per_pattern(16) + speed = controller.project.settings.speed + + at_reference_tempo = _row_ticks(synthesizer, 1) + controller.set_tempo(210) + after_change = _row_ticks(synthesizer, 1) + + assert at_reference_tempo == (speed,) + assert after_change == (_groove_ticks(controller)[1],) + + def test_highlight_change_regroups_the_same_row_rate( + self, + controller: ProjectController, + synthesizer: RowSynthesizer, + ) -> None: + """The beat decides where the longer rows land, so narrowing it moves them without + changing how long the pattern lasts. + """ + controller.set_rows_per_pattern(16) + controller.set_tempo(210) + rows = controller.project.song.rows_per_pattern + + on_four_row_beats = _row_ticks(synthesizer, rows) + controller.set_first_highlight(3) + synthesizer.set_position(0, 0) + on_three_row_beats = _row_ticks(synthesizer, rows) + + assert on_three_row_beats == (5, 4, 4, 5, 4, 4, 5, 4, 4, 5, 4, 4, 5, 4, 4, 4) + assert sum(on_three_row_beats) == sum(on_four_row_beats) + + class TestNesFrequencyTempo: def test_frame_length_follows_project_nes_frequency(self) -> None: """Each tick spans ``sample_rate / nes_frequency`` samples taken from the project's @@ -696,13 +790,11 @@ def lower_nes_frequency(context: SynthesizerContext) -> None: def render_and_assert_chunk_uses_project_frequency( context: SynthesizerContext, ) -> None: - settings = _controller(context).project.settings + controller = _controller(context) + settings = controller.project.settings frame_length = round(settings.sample_rate / settings.nes_frequency) - ticks_per_row = (settings.speed * settings.nes_frequency * REFERENCE_TEMPO) // ( - settings.tempo * REFERENCE_NES_FREQUENCY - ) audio = _render(context) - assert len(audio) == frame_length * ticks_per_row + assert len(audio) == frame_length * _groove_ticks(controller)[0] BaseTestScenario( label="frame length tracks the project NES frequency", From c045eaa6a7988f95f7696e65604db61ebb560dcf Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 21:48:28 +0200 Subject: [PATCH 054/152] Added: silencing volume column to the Bitphase export --- docs/formats/bitphase.md | 15 ++++++++---- src/sampletones_core/constants/general.py | 1 + .../formats/bitphase/builder.py | 23 +++++++++++++++++-- .../formats/bitphase/model/pattern.py | 5 ++-- .../bitphase/specification/patterns.py | 1 + src/sampletones_core/project/patterns/row.py | 5 ++-- .../integration/bitphase/test_btp_pipeline.py | 3 ++- tests/suite/bitphase.py | 9 +++++--- .../formats/bitphase/test_project_builder.py | 13 +++++++++++ 9 files changed, 61 insertions(+), 14 deletions(-) diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index 256757e90..9c3855f82 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -175,6 +175,14 @@ Row cells follow from the columns: an instrument command writes the note from `initial_pitch + transpose`, the instrument number, the table column and the row's volume; a note-off writes note name `1`; a blank line leaves every column alone. +**The volume column names silence.** In Bitphase you type `0` to silence a channel and +leave the cell blank to carry its level forward — and the file stores those two as `-1` and +`0`. The volume field is declared `allowZeroValue`, so Bitphase parses a typed `0` to `-1` +and prints a stored `-1` back as `0`, while a stored `0` shows as a blank cell; its engine +reads `-1` as volume zero. So a row asking for silence writes `-1`, a row naming a level +writes it verbatim, and a row with an empty volume cell writes `0` — which is the same cell +you would see in the tracker either way. + ## E. Bitphase capacity limits | Quantity | Bitphase limit | Exporter behaviour | @@ -184,6 +192,7 @@ volume; a note-off writes note name `1`; a blank line leaves every column alone. | Instruments | the instrument column holds 2 base-36 digits, so 1–1295 | raises past 1295 | | Tables | the table column holds 1 base-36 digit, so ids 0–34 | raises past 35 tables | | Note range | the 96-entry tuning table, pitch 24–119 | clamps to the nearest playable note | +| Volume column | `-1` silences (the tracker shows `0`), `0` carries the level forward (shown blank), 1–15 set the level | writes the row's level, and `-1` where a row asks for silence | | Pattern length (rows) | 1–256 | clamps the preview pattern; a project keeps `rows_per_pattern` | | Order positions | unbounded | matches | | Speed | 1–255 | written verbatim from settings | @@ -195,14 +204,12 @@ raises rather than writing a document whose later voices cannot be named. ## F. What does not cross over -Three things the SampleToNES model holds have no counterpart in a Bitphase document, -and the exporter leaves them behind: +These parts of the SampleToNES model have no counterpart in a Bitphase document, and the +exporter leaves them behind: - **`ProjectInfo.comment`** — a Bitphase project carries a name and an author only. - **`ProjectSettings.tempo`** — Bitphase's engine is speed-only, so `initialSpeed` carries `speed` and the tempo is left to the tick rate. -- **A volume column of `0`** — Bitphase reads it as "leave the volume alone", so a row - that asks for silence through the volume column alone reaches playback unchanged. `interruptFrequency` carries the reconstruction's own tick rate. Bitphase's settings panel offers 50 and 60 Hz, and its loader and timeline accept any value, so a rate diff --git a/src/sampletones_core/constants/general.py b/src/sampletones_core/constants/general.py index 7179e39ec..aca1463b8 100644 --- a/src/sampletones_core/constants/general.py +++ b/src/sampletones_core/constants/general.py @@ -36,6 +36,7 @@ # Instruction parameters ranges +SILENT_VOLUME: Final[int] = 0 MIN_VOLUME: Final[int] = 1 MAX_VOLUME: Final[int] = 15 VOLUME_RANGE: Final[range] = range(MAX_VOLUME + 1) diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index 50f0ed958..0163376ac 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -1,8 +1,9 @@ import math from dataclasses import dataclass -from typing import Dict, List, Sequence, Tuple +from typing import Dict, List, Optional, Sequence, Tuple from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.exporters.slices import iterate_sample_slices from sampletones_core.formats.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes from sampletones_core.formats.bitphase.identifiers import format_instrument_id @@ -40,6 +41,7 @@ MIN_PATTERN_LENGTH, NO_VOLUME_CHANGE, TABLE_COLUMN_OFFSET, + VOLUME_OFF, NoteName, ) from sampletones_core.formats.bitphase.tuning import generate_tuning_table @@ -322,6 +324,23 @@ def _resolve_voice(reference: Instrument, voices: VoiceTable) -> Voice: return voice +def _volume_column(volume: Optional[int]) -> int: + """Writes a tracker line's volume column as the value Bitphase reads it as. + + Bitphase spends ``0`` on carrying the channel's level forward, so silence holds a value + of its own: a line asking for volume ``0`` writes ``VOLUME_OFF`` and the channel falls + silent from that line on, while a line naming a level writes it verbatim. Bitphase's own + editor prints ``VOLUME_OFF`` as the digit ``0``, so this is the cell a user types there. + """ + if volume is None: + return NO_VOLUME_CHANGE + + if volume == SILENT_VOLUME: + return VOLUME_OFF + + return volume + + def _row_cell( row: Row, channel_generator: GeneratorName, @@ -332,7 +351,7 @@ def _row_cell( Raises: ValueError: If the line references a sample slice that has no instrument. """ - volume = row.volume if row.volume is not None else NO_VOLUME_CHANGE + volume = _volume_column(row.volume) cell = BitphaseRow(volume=volume) match row.command: diff --git a/src/sampletones_core/formats/bitphase/model/pattern.py b/src/sampletones_core/formats/bitphase/model/pattern.py index b2514f15a..98a61c2c6 100644 --- a/src/sampletones_core/formats/bitphase/model/pattern.py +++ b/src/sampletones_core/formats/bitphase/model/pattern.py @@ -11,6 +11,7 @@ NO_INSTRUMENT_CHANGE, NO_TABLE_CHANGE, NO_VOLUME_CHANGE, + VOLUME_OFF, NoteName, ) @@ -69,9 +70,9 @@ class BitphaseRow(BaseModel): table: int = Field(default=NO_TABLE_CHANGE, description="Table to attach from this line on.") volume: int = Field( default=NO_VOLUME_CHANGE, - ge=NO_VOLUME_CHANGE, + ge=VOLUME_OFF, le=FULL_VOLUME, - description="Channel volume from this line on.", + description="Channel volume from this line on, where VOLUME_OFF silences the channel.", ) diff --git a/src/sampletones_core/formats/bitphase/specification/patterns.py b/src/sampletones_core/formats/bitphase/specification/patterns.py index 3601cac7c..239098e76 100644 --- a/src/sampletones_core/formats/bitphase/specification/patterns.py +++ b/src/sampletones_core/formats/bitphase/specification/patterns.py @@ -35,6 +35,7 @@ class NoteName(IntEnum): TABLE_COLUMN_OFFSET: Final[int] = 1 NO_VOLUME_CHANGE: Final[int] = 0 +VOLUME_OFF: Final[int] = -1 FULL_VOLUME: Final[int] = 15 MIN_PATTERN_LENGTH: Final[int] = 1 diff --git a/src/sampletones_core/project/patterns/row.py b/src/sampletones_core/project/patterns/row.py index 76465ea71..54af3b1ee 100644 --- a/src/sampletones_core/project/patterns/row.py +++ b/src/sampletones_core/project/patterns/row.py @@ -6,6 +6,7 @@ MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE, + SILENT_VOLUME, ) from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff @@ -35,9 +36,9 @@ class Row(BaseModel): ) volume: Optional[int] = Field( default=None, - ge=0, + ge=SILENT_VOLUME, le=MAX_VOLUME, - description="Volume column, or None for an empty cell.", + description="Volume column, where SILENT_VOLUME silences the channel, or None for an empty cell.", ) def is_empty(self) -> bool: diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py index e28737a0e..862b28a83 100644 --- a/tests/integration/bitphase/test_btp_pipeline.py +++ b/tests/integration/bitphase/test_btp_pipeline.py @@ -33,6 +33,7 @@ NO_INSTRUMENT_CHANGE, NOTE_RANGE, TABLE_COLUMN_OFFSET, + VOLUME_OFF, NoteName, ) from sampletones_core.project.project import Project @@ -188,7 +189,7 @@ def test_every_note_lands_inside_the_tuning_table(self, triggers: List[LoadedRow assert all(MIN_NOTE_INDEX <= index <= MAX_NOTE_INDEX for index in indices) def test_every_volume_column_stays_within_the_channel_range(self, document: LoadedProject) -> None: - assert all(0 <= row.volume <= FULL_VOLUME for row in every_row(document)) + assert all(VOLUME_OFF <= row.volume <= FULL_VOLUME for row in every_row(document)) class TestTheInstrumentRowsArePlayable: diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py index 584d468dc..a4551c872 100644 --- a/tests/suite/bitphase.py +++ b/tests/suite/bitphase.py @@ -16,6 +16,9 @@ BITPHASE_DEFAULT_CHIP_TYPE: Final[str] = "ay" BITPHASE_DEFAULT_NOTE_NAME: Final[int] = 0 BITPHASE_DEFAULT_OCTAVE: Final[int] = 0 +BITPHASE_DEFAULT_INSTRUMENT: Final[int] = 0 +BITPHASE_DEFAULT_TABLE: Final[int] = 0 +BITPHASE_DEFAULT_VOLUME: Final[int] = 0 BITPHASE_DEFAULT_INSTRUMENT_ID: Final[str] = "01" BITPHASE_DEFAULT_LOOP: Final[int] = 0 BITPHASE_DEFAULT_TABLE_ID: Final[int] = 0 @@ -123,9 +126,9 @@ def _note(data: Optional[Dict[str, Any]]) -> LoadedNote: def _row(data: Dict[str, Any]) -> LoadedRow: return LoadedRow( note=_note(data.get("note")), - instrument=data.get("instrument", 0), - table=data.get("table", 0), - volume=data.get("volume", 0), + instrument=data.get("instrument", BITPHASE_DEFAULT_INSTRUMENT), + table=data.get("table", BITPHASE_DEFAULT_TABLE), + volume=data.get("volume", BITPHASE_DEFAULT_VOLUME), ) diff --git a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py index 7e6139e2b..bdc8f10a0 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -6,6 +6,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.formats.bitphase.builder import project_to_bitphase from sampletones_core.formats.bitphase.model.project import BitphaseProject from sampletones_core.formats.bitphase.notes import ( @@ -19,6 +20,7 @@ NO_VOLUME_CHANGE, SYMBOL_BASE, TABLE_COLUMN_OFFSET, + VOLUME_OFF, NoteName, ) from sampletones_core.instructions.implementation.pulse import PulseInstruction @@ -46,6 +48,7 @@ NOTE_OFF_ROW: Final[int] = 2 TRANSPOSED_ROW: Final[int] = 4 EMPTY_ROW: Final[int] = 6 +SILENCED_ROW: Final[int] = 7 def build_reconstruction( @@ -105,6 +108,7 @@ def source_fixture(lead: Sample, bass: Sample) -> Project: command=Instrument(sample_id=lead.id, generator_name=GeneratorName.PULSE1), transpose=TRANSPOSE, ) + pulse_rows[SILENCED_ROW] = Row(volume=SILENT_VOLUME) triangle_rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] triangle_rows[TRIGGER_ROW] = Row( @@ -209,6 +213,15 @@ def test_a_row_that_sets_no_volume_leaves_the_column_alone(self, document: Bitph row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[TRANSPOSED_ROW] assert row.volume == NO_VOLUME_CHANGE + def test_a_row_asking_for_silence_silences_the_channel(self, document: BitphaseProject) -> None: + """Bitphase reads a stored volume of ``0`` as "carry the level forward", so silence + is the value below it — the one its editor prints as the digit ``0`` — and a row + asking for silence has to reach a different column than a row asking for nothing. + """ + rows = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows + assert rows[SILENCED_ROW].volume == VOLUME_OFF + assert rows[SILENCED_ROW].volume != rows[TRANSPOSED_ROW].volume + def test_a_note_off_stops_the_channel(self, document: BitphaseProject) -> None: row = document.songs[0].patterns[0].channels[int(ChannelIndex.SQUARE1)].rows[NOTE_OFF_ROW] assert row.note.name == int(NoteName.OFF) From 07c61e27d8bb7b8b461fff98c20e978506287e53 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 22:11:55 +0200 Subject: [PATCH 055/152] Added: tempo as a groove to the Bitphase export --- docs/development/bugs-and-todos.md | 2 +- docs/formats/bitphase.md | 64 +++++-- .../formats/bitphase/builder.py | 180 ++++++++++++++++-- .../formats/bitphase/model/table.py | 13 +- .../formats/bitphase/specification/effects.py | 16 ++ .../formats/famitracker/builder.py | 9 +- tests/integration/bitphase/conftest.py | 12 +- .../integration/bitphase/test_btp_pipeline.py | 113 ++++++++++- tests/integration/paths.py | 1 + tests/suite/bitphase.py | 34 ++++ .../formats/bitphase/test_project_builder.py | 80 +++++++- 11 files changed, 481 insertions(+), 43 deletions(-) create mode 100644 src/sampletones_core/formats/bitphase/specification/effects.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 85b9863f2..20ec98d23 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -31,7 +31,7 @@ * Code documentation (docstrings) * Backward compatibility: library/reconstruction upgrade scheme * Respecting FamiTracker limitations -* Carrying the project comment and tempo into a Bitphase document, once the format holds them +* Carrying the project comment into a Bitphase document, once the format holds it * Per-tab undo routing * Delete duplicated HistoryAction enumeration diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index 9c3855f82..5973e08f6 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -88,7 +88,7 @@ this, the same flag the FamiTracker exporter reads. counters, so they share a length and a loop point and stay in step for as long as the note sounds. `equalize_lengths` in `exporters/lengths.py` supplies that shared length — the same rule the FamiTracker exporter applies, with the item limit left unbounded -here (section D). +here (section F). ## C. Pitch @@ -146,7 +146,41 @@ against the pitch the slice was reconstructed at, under the tuning a freshly cre Bitphase document plays — NTSC at concert pitch. The noise channel takes its period from the note, so its preset rows hold a flat offset. -## D. What the exporter builds per scope +## D. Tempo as a groove + +A Bitphase song states a **speed** — the engine ticks each row lasts — where a _SampleToNES_ +project states a tempo and a speed together. The row rate the pair asks for is fractional at +most tempi, so the exporter carries it as a [groove](../glossary.md#groove): whole tick counts, +one per row of a pattern, averaging out to that rate with the longer rows on the bar and the +beat. `sampletones_core/timing/` builds them and in-app playback reads the same groove, so a +document plays the rows the sequencer played. At 60 Hz, speed 6 and tempo 210, a 16-row +pattern in common time comes to + +``` +5 4 5 4 5 4 4 4 5 4 4 4 5 4 4 4 69 ticks, a rate of 30/7 per row +``` + +**The groove reaches the engine as a table.** A speed effect that names a table reads one of +its entries per pattern row, which is what carries a per-row tick count into a song: + +| Part | What the exporter writes | +| --- | --- | +| `initialSpeed` | the ticks the pattern's first row lasts | +| The table | one entry per pattern row, `loop = 0`, taking the id above the last slice table | +| The effect | `S` with `delay = 0` and an empty parameter, naming that table | +| Its place | the first row of the DPCM channel, in every pattern | + +A speed effect applies from whichever channel carries it, so the groove rides the DPCM channel +this exporter leaves silent and every sounding channel keeps the one effect column the chip +gives it. The table advances an entry per row and resumes from where a trigger placed it, so +triggering it again at each pattern start holds every row on the entry that describes it, +however the order jumps. + +**A tempo the speed column states writes neither.** Where every row lasts alike — tempo 150 at +60 Hz, where the rate is the speed itself — `initialSpeed` carries the tempo whole, and the +document holds one table per slice with every effect column empty. + +## E. What the exporter builds per scope A `.btp` holds a whole document, so every scope lands in one file; a preset holds one instrument, so a reconstruction lands as a set of them beside the name the export was @@ -183,33 +217,31 @@ reads `-1` as volume zero. So a row asking for silence writes `-1`, a row naming writes it verbatim, and a row with an empty volume cell writes `0` — which is the same cell you would see in the tracker either way. -## E. Bitphase capacity limits +## F. Bitphase capacity limits | Quantity | Bitphase limit | Exporter behaviour | | --- | --- | --- | | Items per instrument row list | unbounded | writes the envelope whole | -| Rows per table | unbounded | writes the contour whole | +| Rows per table | unbounded | writes the contour, or the groove, whole | | Instruments | the instrument column holds 2 base-36 digits, so 1–1295 | raises past 1295 | -| Tables | the table column holds 1 base-36 digit, so ids 0–34 | raises past 35 tables | +| Tables | the table column holds 1 base-36 digit, so ids 0–34 | raises past 35 tables, one of which a groove takes | | Note range | the 96-entry tuning table, pitch 24–119 | clamps to the nearest playable note | | Volume column | `-1` silences (the tracker shows `0`), `0` carries the level forward (shown blank), 1–15 set the level | writes the row's level, and `-1` where a row asks for silence | | Pattern length (rows) | 1–256 | clamps the preview pattern; a project keeps `rows_per_pattern` | | Order positions | unbounded | matches | -| Speed | 1–255 | written verbatim from settings | -| DPCM channel | present | emitted empty | +| Speed | 1–255 | the groove's tick counts, bounded to that range | +| DPCM channel | present | rests, apart from the groove trigger each pattern's first row carries | Tables and instruments are numbered together — each slice takes one of each — so the -table column is what a wide document reaches first: 35 slices fit, and the exporter -raises rather than writing a document whose later voices cannot be named. - -## F. What does not cross over +table column is what a wide document reaches first, and the exporter raises rather than +writing a document whose later voices cannot be named. A song whose rows vary spends one +of those ids on its groove, so the slices a document holds are those the table column can +still name. -These parts of the SampleToNES model have no counterpart in a Bitphase document, and the -exporter leaves them behind: +## G. What does not cross over -- **`ProjectInfo.comment`** — a Bitphase project carries a name and an author only. -- **`ProjectSettings.tempo`** — Bitphase's engine is speed-only, so `initialSpeed` - carries `speed` and the tempo is left to the tick rate. +**`ProjectInfo.comment`** has no counterpart in a Bitphase document, which carries a name and +an author only, so the exporter leaves the comment behind. `interruptFrequency` carries the reconstruction's own tick rate. Bitphase's settings panel offers 50 and 60 Hz, and its loader and timeline accept any value, so a rate diff --git a/src/sampletones_core/formats/bitphase/builder.py b/src/sampletones_core/formats/bitphase/builder.py index 0163376ac..e91dba294 100644 --- a/src/sampletones_core/formats/bitphase/builder.py +++ b/src/sampletones_core/formats/bitphase/builder.py @@ -5,13 +5,17 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.exporters.slices import iterate_sample_slices -from sampletones_core.formats.bitphase.envelopes import ChannelEnvelopes, features_to_envelopes +from sampletones_core.formats.bitphase.envelopes import ( + ChannelEnvelopes, + features_to_envelopes, +) from sampletones_core.formats.bitphase.identifiers import format_instrument_id from sampletones_core.formats.bitphase.model.instrument import BitphaseInstrument from sampletones_core.formats.bitphase.model.pattern import ( BitphaseChannel, BitphasePattern, BitphaseRow, + EffectCell, NoteCell, ) from sampletones_core.formats.bitphase.model.project import BitphaseProject @@ -22,13 +26,25 @@ note_index_to_note_cell, pitch_to_note_index, ) -from sampletones_core.formats.bitphase.specification.channels import CHANNEL_LABELS, GENERATOR_NAME_TO_CHANNEL_INDEX +from sampletones_core.formats.bitphase.specification.channels import ( + CHANNEL_LABELS, + GENERATOR_NAME_TO_CHANNEL_INDEX, + ChannelIndex, +) from sampletones_core.formats.bitphase.specification.chip import ( CPU_FREQUENCIES, DEFAULT_A4_TUNING, DEFAULT_CHIP_VARIANT, + MAX_INITIAL_SPEED, + MIN_INITIAL_SPEED, +) +from sampletones_core.formats.bitphase.specification.effects import ( + NO_EFFECT_PARAMETER, + SPEED_EFFECT_DELAY, + EffectId, ) from sampletones_core.formats.bitphase.specification.instruments import ( + LOOP_FROM_START, MAX_INSTRUMENT_ID, MAX_TABLE_ID, MIN_INSTRUMENT_ID, @@ -49,6 +65,7 @@ from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row from sampletones_core.project.project import Project +from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove from sampletones_core.trackers.request import InstrumentExport, SampleExport from sampletones_shared.constants.project import DEFAULT_ROWS_PER_PATTERN, DEFAULT_SPEED @@ -57,6 +74,11 @@ PREVIEW_REST_PATTERN_ID = FIRST_PATTERN_ID + 1 NO_AUTHOR = "" +GROOVE_CHANNEL = ChannelIndex.DPCM +GROOVE_TRIGGER_ROW = 0 +GROOVE_TABLE_NAME = "Groove" +GROOVE_TABLE_COUNT = 1 + @dataclass(frozen=True) class Voice: @@ -88,22 +110,26 @@ def _build_voice( generator: GeneratorName, initial_pitch: int, envelopes: ChannelEnvelopes, + *, + maximum_table_id: int, ) -> Voice: """Numbers one generator slice and packages it as an instrument-and-table pair. Instruments and tables are numbered alike, so a pattern cell names the same position - in both columns. + in both columns. The document states how far the table numbering reaches, since a song + that carries a groove holds one table of its own above the slices. Raises: - ValueError: If the position runs past what a pattern column can name. + ValueError: If the position runs past what a pattern column can name, or past the + table ids the document leaves to its slices. """ number = index + MIN_INSTRUMENT_ID if number > MAX_INSTRUMENT_ID: raise ValueError(f"Document exceeds the Bitphase limit of {MAX_INSTRUMENT_ID} instruments") table_id = index + MIN_TABLE_ID - if table_id > MAX_TABLE_ID: - raise ValueError(f"Document exceeds the Bitphase limit of {MAX_TABLE_ID + 1} tables") + if table_id > maximum_table_id: + raise ValueError(f"Document holds room for {maximum_table_id + 1} slice tables") return Voice( number=number, @@ -255,6 +281,7 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject: instrument.generator, loop=instrument.loop, ), + maximum_table_id=MAX_TABLE_ID, ) for index, instrument in enumerate(request.instruments) ] @@ -266,7 +293,13 @@ def sample_to_bitphase(request: SampleExport) -> BitphaseProject: return BitphaseProject( name=request.name, author=NO_AUTHOR, - songs=(_build_song(patterns, speed=PREVIEW_SPEED, nes_frequency=request.nes_frequency),), + songs=( + _build_song( + patterns, + speed=PREVIEW_SPEED, + nes_frequency=request.nes_frequency, + ), + ), pattern_order=order, tables=tuple(voice.table for voice in voices), instruments=tuple(voice.instrument for voice in voices), @@ -290,7 +323,11 @@ def instrument_to_bitphase(request: InstrumentExport) -> BitphaseProject: return sample_to_bitphase(sample) -def _build_voice_table(project: Project) -> Tuple[List[Voice], VoiceTable]: +def _build_voice_table( + project: Project, + *, + maximum_table_id: int, +) -> Tuple[List[Voice], VoiceTable]: voices: List[Voice] = [] by_reference: VoiceTable = {} @@ -306,6 +343,7 @@ def _build_voice_table(project: Project) -> Tuple[List[Voice], VoiceTable]: sample_slice.generator, sample_slice.features.initial_pitch, envelopes, + maximum_table_id=maximum_table_id, ) voices.append(voice) by_reference[sample_slice.key] = voice @@ -385,12 +423,97 @@ def _channel_rows( return cells -def _project_patterns(project: Project, voices: VoiceTable) -> Tuple[BitphasePattern, ...]: +def _project_groove(project: Project) -> Groove: + """Spreads the tempo a project states across the rows of one pattern. + + A Bitphase song holds a speed alone, so the fractional row rate a tempo asks for is + carried by a groove: whole tick counts that vary from row to row and average out to the + rate, placed by the metre so the longer rows fall on the bar and the beat. The engine's + own speed range bounds them, and the groove's mean states the rate it reached. + """ + settings = project.settings + return calculate_groove( + RowRate.from_settings(settings), + Metre.from_settings(settings, rows=project.song.rows_per_pattern), + minimum_ticks=MIN_INITIAL_SPEED, + maximum_ticks=MAX_INITIAL_SPEED, + ) + + +def _maximum_slice_table_id(groove: Groove) -> int: + """The last table id the document leaves to its slices. + + A groove whose rows differ occupies the table above the last slice, so the slices reach + one id less far; a groove whose rows last alike is carried by the song's initial speed + and leaves the whole column to them. + """ + if groove.is_uniform: + return MAX_TABLE_ID + + return MAX_TABLE_ID - GROOVE_TABLE_COUNT + + +def _groove_table(groove: Groove, table_id: int) -> BitphaseTable: + """Writes the groove as the table a speed effect reads one entry per pattern row from.""" + return BitphaseTable( + id=table_id, + rows=groove.ticks, + loop=LOOP_FROM_START, + name=GROOVE_TABLE_NAME, + ) + + +def _speed_effect(table_id: int) -> EffectCell: + """Names the table a row takes its own duration from. + + The parameter states a speed directly where an effect carries no table, so an effect + that names one leaves it empty; the delay stays at zero, which is what Bitphase reads + on a speed effect. + """ + return EffectCell( + effect=int(EffectId.SPEED), + delay=SPEED_EFFECT_DELAY, + parameter=NO_EFFECT_PARAMETER, + table_index=table_id, + ) + + +def _groove_channel_rows(length: int, table_id: int) -> List[BitphaseRow]: + """Rests a channel for a whole pattern beyond the groove trigger its first row carries. + + A speed effect applies from whichever channel holds it, so the groove rides the silent + DPCM channel and leaves every sounding channel its own effect column. The table then + advances one entry per row from where the trigger placed it, and triggering it again on + each pattern's first row keeps every row on the entry that describes it. + """ + rows = [BitphaseRow() for _ in range(length)] + rows[GROOVE_TRIGGER_ROW] = BitphaseRow(effects=(_speed_effect(table_id),)) + return rows + + +def _document_tables( + voices: Sequence[Voice], + groove_table: Optional[BitphaseTable], +) -> Tuple[BitphaseTable, ...]: + """Gathers the tables a document holds: one per slice, and the groove where it takes one.""" + tables = tuple(voice.table for voice in voices) + if groove_table is None: + return tables + + return tables + (groove_table,) + + +def _project_patterns( + project: Project, + voices: VoiceTable, + groove_table: Optional[BitphaseTable], +) -> Tuple[BitphasePattern, ...]: """Flattens the song's per-channel arrangement into whole-pattern order positions. A SampleToNES order frame points every channel at its own pattern, where a Bitphase order position names one pattern that spans all channels, so each frame becomes a - pattern of its own carrying that frame's channels side by side. + pattern of its own carrying that frame's channels side by side. Every pattern triggers + the groove table it is given, so the tempo holds wherever the order jumps. """ song = project.song length = song.rows_per_pattern @@ -398,6 +521,12 @@ def _project_patterns(project: Project, voices: VoiceTable) -> Tuple[BitphasePat for position, frame in enumerate(song.order): channel_rows = _empty_channels(length) + if groove_table is not None: + channel_rows[int(GROOVE_CHANNEL)] = _groove_channel_rows( + length, + groove_table.id, + ) + for generator in GeneratorName.items(): index = frame.get(generator) if index is None: @@ -421,7 +550,10 @@ def _project_patterns(project: Project, voices: VoiceTable) -> Tuple[BitphasePat def project_to_bitphase(project: Project) -> BitphaseProject: - """Maps a project's samples and song onto the Bitphase document IR. + """Maps a project's samples, song and tempo onto the Bitphase document IR. + + The song carries the project's tempo as a groove, which is the initial speed on its own + where every row lasts alike and a table the patterns trigger where the rows differ. Args: project: The project to write. @@ -433,16 +565,34 @@ def project_to_bitphase(project: Project) -> BitphaseProject: ValueError: If the project holds more than Bitphase has room for, or a row references a sample slice that has no instrument. """ - voices, by_reference = _build_voice_table(project) - patterns = _project_patterns(project, by_reference) + groove = _project_groove(project) + voices, by_reference = _build_voice_table( + project, + maximum_table_id=_maximum_slice_table_id(groove), + ) + groove_table = ( + None + if groove.is_uniform + else _groove_table( + groove, + len(voices) + MIN_TABLE_ID, + ) + ) + patterns = _project_patterns(project, by_reference, groove_table) settings = project.settings info = project.info return BitphaseProject( name=info.title, author=info.author, - songs=(_build_song(patterns, speed=settings.speed, nes_frequency=settings.nes_frequency),), + songs=( + _build_song( + patterns, + speed=groove.ticks[GROOVE_TRIGGER_ROW], + nes_frequency=settings.nes_frequency, + ), + ), pattern_order=tuple(pattern.id for pattern in patterns), - tables=tuple(voice.table for voice in voices), + tables=_document_tables(voices, groove_table), instruments=tuple(voice.instrument for voice in voices), ) diff --git a/src/sampletones_core/formats/bitphase/model/table.py b/src/sampletones_core/formats/bitphase/model/table.py index c2f3d3d00..65243d4e8 100644 --- a/src/sampletones_core/formats/bitphase/model/table.py +++ b/src/sampletones_core/formats/bitphase/model/table.py @@ -11,11 +11,14 @@ class BitphaseTable(BaseModel): - """A per-tick semitone contour a pattern cell attaches to a channel. + """A list of one value per step, whose meaning the column or effect reading it fixes. - Playback adds ``rows[position]`` to the channel's note every tick, advancing one - row per tick, so a table carries the pitch movement a reconstruction's arpeggio - envelope describes. + A pattern's table column reads it as a semitone contour, adding ``rows[position]`` to + the channel's note and advancing a row every tick, so a table carries the pitch movement + a reconstruction's arpeggio envelope describes. A speed effect reads it as tick counts, + advancing a row every pattern line, so a table carries a song's groove. + + Playback returns to ``loop`` once it runs off the end, whichever column drives it. """ model_config = BITPHASE_MODEL_CONFIG @@ -28,7 +31,7 @@ class BitphaseTable(BaseModel): ) rows: Tuple[int, ...] = Field( ..., - description="Semitone offset applied on each tick.", + description="Value applied on each step.", ) loop: int = Field( default=LOOP_FROM_START, diff --git a/src/sampletones_core/formats/bitphase/specification/effects.py b/src/sampletones_core/formats/bitphase/specification/effects.py new file mode 100644 index 000000000..04404c644 --- /dev/null +++ b/src/sampletones_core/formats/bitphase/specification/effects.py @@ -0,0 +1,16 @@ +from enum import IntEnum +from typing import Final + + +class EffectId(IntEnum): + """Identifier an effect column carries, as the code point of the letter Bitphase prints. + + ``SPEED`` states how many engine ticks the row it sits on lasts, taken from the effect's + own parameter or, where the effect names a table, from one table entry per pattern row. + """ + + SPEED = ord("S") + + +SPEED_EFFECT_DELAY: Final[int] = 0 +NO_EFFECT_PARAMETER: Final[int] = 0 diff --git a/src/sampletones_core/formats/famitracker/builder.py b/src/sampletones_core/formats/famitracker/builder.py index 39030a2bc..8d797231b 100644 --- a/src/sampletones_core/formats/famitracker/builder.py +++ b/src/sampletones_core/formats/famitracker/builder.py @@ -1,5 +1,3 @@ -from __future__ import annotations - from typing import List, Optional, Tuple from sampletones_core.constants.enums import GeneratorName @@ -252,6 +250,7 @@ def _build_order(song: Song) -> Tuple[OrderFrame, ...]: for generator in GeneratorName.items(): index = frame.get(generator) entries.append(index if index is not None else empty_indices[generator]) + entries.append(DPCM_EMPTY_PATTERN_INDEX) frames.append(tuple(entries)) @@ -285,7 +284,11 @@ def project_to_module(project: Project) -> FamiTrackerModule: patterns: List[PatternData] = [] for generator in GeneratorName.items(): patterns.extend( - _channel_patterns(generator, song.channels[generator], slots), + _channel_patterns( + generator, + song.channels[generator], + slots, + ), ) track = Track( diff --git a/tests/integration/bitphase/conftest.py b/tests/integration/bitphase/conftest.py index b55d74ac4..d1dd846e8 100644 --- a/tests/integration/bitphase/conftest.py +++ b/tests/integration/bitphase/conftest.py @@ -4,7 +4,11 @@ import pytest from tests.integration.output import resolve_output_directory, resolve_output_path -from tests.integration.paths import BTP_OUTPUT_ENV, DOCUMENT_FILENAME +from tests.integration.paths import ( + BTP_OUTPUT_ENV, + DOCUMENT_FILENAME, + GROOVE_DOCUMENT_FILENAME, +) @pytest.fixture(scope="session") @@ -17,3 +21,9 @@ def btp_output_dir() -> Optional[Path]: def document_path(btp_output_dir: Optional[Path], tmp_path: Path) -> Path: """Where a produced ``.btp`` is written.""" return resolve_output_path(btp_output_dir, tmp_path, DOCUMENT_FILENAME) + + +@pytest.fixture +def groove_document_path(btp_output_dir: Optional[Path], tmp_path: Path) -> Path: + """Where the document carrying a groove is written, beside the one at the song's own tempo.""" + return resolve_output_path(btp_output_dir, tmp_path, GROOVE_DOCUMENT_FILENAME) diff --git a/tests/integration/bitphase/test_btp_pipeline.py b/tests/integration/bitphase/test_btp_pipeline.py index 862b28a83..6882361a6 100644 --- a/tests/integration/bitphase/test_btp_pipeline.py +++ b/tests/integration/bitphase/test_btp_pipeline.py @@ -13,11 +13,18 @@ from sampletones_core.formats.bitphase.specification.chip import ( CHIP_TYPE_NES, CPU_FREQUENCIES, + MAX_INITIAL_SPEED, MAX_TUNING_PERIOD, + MIN_INITIAL_SPEED, MIN_TUNING_PERIOD, TUNING_TABLE_LENGTH, ChipVariant, ) +from sampletones_core.formats.bitphase.specification.effects import ( + NO_EFFECT_PARAMETER, + SPEED_EFFECT_DELAY, + EffectId, +) from sampletones_core.formats.bitphase.specification.instruments import ( MAX_PULSE_WIDTH, MAX_VOLUME_OR_RATE, @@ -37,9 +44,19 @@ NoteName, ) from sampletones_core.project.project import Project -from tests.suite.bitphase import LoadedNote, LoadedProject, LoadedRow, parse_btp +from sampletones_core.timing import Metre, RowRate, calculate_groove +from tests.suite.bitphase import ( + BITPHASE_NO_EFFECTS, + LoadedEffect, + LoadedNote, + LoadedProject, + LoadedRow, + LoadedTable, + parse_btp, +) EXPECTED_INSTRUMENT_COUNT: Final[int] = 5 +GROOVE_TEMPO: Final[int] = 210 PLAYED_CHANNELS: Final[List[int]] = [ int(ChannelIndex.SQUARE1), int(ChannelIndex.SQUARE2), @@ -58,12 +75,30 @@ def note_index(note: LoadedNote) -> int: return note.name - int(NoteName.C) + (note.octave - FIRST_OCTAVE) * NOTE_RANGE +def at_tempo(project: Project, tempo: int) -> Project: + """The same project played at another tempo, leaving the session-wide fixture as it is.""" + return Project( + metadata=project.metadata, + info=project.info, + settings=project.settings.model_copy(update={"tempo": tempo}), + samples=project.samples, + song=project.song, + ) + + @pytest.fixture def document(integration_project: Project, document_path: Path) -> LoadedProject: write_btp(document_path, project_to_bitphase(integration_project)) return parse_btp(document_path.read_bytes(), list(CHANNEL_LABELS)) +@pytest.fixture +def groove_document(integration_project: Project, groove_document_path: Path) -> LoadedProject: + project = at_tempo(integration_project, GROOVE_TEMPO) + write_btp(groove_document_path, project_to_bitphase(project)) + return parse_btp(groove_document_path.read_bytes(), list(CHANNEL_LABELS)) + + class TestBtpPipeline: """End-to-end: synthesized + reconstructed samples -> Project -> `.btp` -> load.""" @@ -192,6 +227,82 @@ def test_every_volume_column_stays_within_the_channel_range(self, document: Load assert all(VOLUME_OFF <= row.volume <= FULL_VOLUME for row in every_row(document)) +class TestTheGrooveReachesTheFile: + """A tempo the speed column cannot state travels as a table of per-row tick counts and a + trigger that names it, so the file has to hold the groove the calculator produced and + re-trigger it wherever the order takes playback. + """ + + @pytest.fixture(name="groove_table") + def groove_table_fixture(self, groove_document: LoadedProject) -> LoadedTable: + return groove_document.tables[-1] + + def test_the_groove_takes_the_table_above_the_slices( + self, + groove_document: LoadedProject, + groove_table: LoadedTable, + ) -> None: + assert groove_table.id == len(groove_document.instruments) + + def test_the_table_holds_one_entry_per_pattern_row( + self, + groove_document: LoadedProject, + groove_table: LoadedTable, + ) -> None: + lengths = {pattern.length for pattern in groove_document.songs[0].patterns} + assert lengths == {len(groove_table.rows)} + + def test_every_entry_is_a_speed_the_engine_reads(self, groove_table: LoadedTable) -> None: + assert all(MIN_INITIAL_SPEED <= ticks <= MAX_INITIAL_SPEED for ticks in groove_table.rows) + + def test_the_table_holds_the_groove_the_project_plays( + self, + integration_project: Project, + groove_table: LoadedTable, + ) -> None: + project = at_tempo(integration_project, GROOVE_TEMPO) + groove = calculate_groove( + RowRate.from_settings(project.settings), + Metre.from_settings(project.settings, rows=project.song.rows_per_pattern), + minimum_ticks=MIN_INITIAL_SPEED, + maximum_ticks=MAX_INITIAL_SPEED, + ) + assert groove_table.rows == list(groove.ticks) + + def test_the_song_starts_on_the_ticks_its_first_row_lasts( + self, + groove_document: LoadedProject, + groove_table: LoadedTable, + ) -> None: + assert groove_document.songs[0].initial_speed == groove_table.rows[0] + + def test_every_pattern_triggers_the_groove_on_its_first_row( + self, + groove_document: LoadedProject, + groove_table: LoadedTable, + ) -> None: + trigger = LoadedEffect( + effect=int(EffectId.SPEED), + delay=SPEED_EFFECT_DELAY, + parameter=NO_EFFECT_PARAMETER, + table_index=groove_table.id, + ) + triggers = [ + pattern.channels[int(ChannelIndex.DPCM)].rows[0].effects for pattern in groove_document.songs[0].patterns + ] + assert triggers == [[trigger]] * len(triggers) + + def test_a_tempo_the_speed_column_states_leaves_every_effect_column_empty( + self, + document: LoadedProject, + ) -> None: + """The song's own tempo divides into whole ticks, so its document carries the speed + and nothing beside it. + """ + assert len(document.tables) == len(document.instruments) + assert all(row.effects == list(BITPHASE_NO_EFFECTS) for row in every_row(document)) + + class TestTheInstrumentRowsArePlayable: def test_every_row_holds_a_waveform_the_channel_reads(self, document: LoadedProject) -> None: rows = [row for instrument in document.instruments for row in instrument.rows] diff --git a/tests/integration/paths.py b/tests/integration/paths.py index 2c5d9c577..dc63ef711 100644 --- a/tests/integration/paths.py +++ b/tests/integration/paths.py @@ -23,4 +23,5 @@ def _repo_root() -> Path: FTM_OUTPUT_ENV: Final[str] = "SAMPLETONES_FTM_OUTPUT_DIR" DOCUMENT_FILENAME: Final[str] = "drums.btp" +GROOVE_DOCUMENT_FILENAME: Final[str] = "drums-groove.btp" BTP_OUTPUT_ENV: Final[str] = "SAMPLETONES_BTP_OUTPUT_DIR" diff --git a/tests/suite/bitphase.py b/tests/suite/bitphase.py index a4551c872..c511104d6 100644 --- a/tests/suite/bitphase.py +++ b/tests/suite/bitphase.py @@ -19,6 +19,10 @@ BITPHASE_DEFAULT_INSTRUMENT: Final[int] = 0 BITPHASE_DEFAULT_TABLE: Final[int] = 0 BITPHASE_DEFAULT_VOLUME: Final[int] = 0 +BITPHASE_DEFAULT_EFFECT: Final[int] = 0 +BITPHASE_DEFAULT_EFFECT_DELAY: Final[int] = 0 +BITPHASE_DEFAULT_EFFECT_PARAMETER: Final[int] = 0 +BITPHASE_NO_EFFECTS: Final[Tuple[None, ...]] = (None,) BITPHASE_DEFAULT_INSTRUMENT_ID: Final[str] = "01" BITPHASE_DEFAULT_LOOP: Final[int] = 0 BITPHASE_DEFAULT_TABLE_ID: Final[int] = 0 @@ -35,9 +39,18 @@ class LoadedNote: octave: int +@dataclass(frozen=True) +class LoadedEffect: + effect: int + delay: int + parameter: int + table_index: Optional[int] + + @dataclass(frozen=True) class LoadedRow: note: LoadedNote + effects: List[Optional[LoadedEffect]] instrument: int table: int volume: int @@ -123,9 +136,30 @@ def _note(data: Optional[Dict[str, Any]]) -> LoadedNote: ) +def _effect(data: Optional[Dict[str, Any]]) -> Optional[LoadedEffect]: + if data is None: + return None + + return LoadedEffect( + effect=data.get("effect", BITPHASE_DEFAULT_EFFECT), + delay=data.get("delay", BITPHASE_DEFAULT_EFFECT_DELAY), + parameter=data.get("parameter", BITPHASE_DEFAULT_EFFECT_PARAMETER), + table_index=data.get("tableIndex"), + ) + + +def _effects(data: Optional[List[Optional[Dict[str, Any]]]]) -> List[Optional[LoadedEffect]]: + """One entry per effect column, which a row naming none reaches playback holding empty.""" + if not data: + return list(BITPHASE_NO_EFFECTS) + + return [_effect(entry) for entry in data] + + def _row(data: Dict[str, Any]) -> LoadedRow: return LoadedRow( note=_note(data.get("note")), + effects=_effects(data.get("effects")), instrument=data.get("instrument", BITPHASE_DEFAULT_INSTRUMENT), table=data.get("table", BITPHASE_DEFAULT_TABLE), volume=data.get("volume", BITPHASE_DEFAULT_VOLUME), diff --git a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py index bdc8f10a0..c1ff20a1d 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_project_builder.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, Final, List, Mapping, Optional, Sequence +from typing import Dict, Final, List, Mapping, Optional, Sequence, Tuple import numpy as np import pytest @@ -8,12 +8,19 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import SILENT_VOLUME from sampletones_core.formats.bitphase.builder import project_to_bitphase +from sampletones_core.formats.bitphase.model.pattern import BitphaseRow, EffectCell from sampletones_core.formats.bitphase.model.project import BitphaseProject from sampletones_core.formats.bitphase.notes import ( note_index_to_note_cell, pitch_to_note_index, ) from sampletones_core.formats.bitphase.specification.channels import ChannelIndex +from sampletones_core.formats.bitphase.specification.effects import ( + NO_EFFECT_PARAMETER, + SPEED_EFFECT_DELAY, + EffectId, +) +from sampletones_core.formats.bitphase.specification.instruments import LOOP_FROM_START from sampletones_core.formats.bitphase.specification.patterns import ( NO_INSTRUMENT_CHANGE, NO_TABLE_CHANGE, @@ -49,6 +56,8 @@ TRANSPOSED_ROW: Final[int] = 4 EMPTY_ROW: Final[int] = 6 SILENCED_ROW: Final[int] = 7 +GROOVE_TEMPO: Final[int] = 210 +GROOVE_TICKS: Final[Tuple[int, ...]] = (5, 4, 4, 4, 5, 4, 4, 4) def build_reconstruction( @@ -138,6 +147,18 @@ def document_fixture(source: Project) -> BitphaseProject: return project_to_bitphase(source) +@pytest.fixture(name="grooved_document") +def grooved_document_fixture(source: Project) -> BitphaseProject: + """The same project at a tempo whose row rate falls between two whole tick counts.""" + source.settings.tempo = GROOVE_TEMPO + return project_to_bitphase(source) + + +def groove_channel_rows(document: BitphaseProject, pattern_index: int) -> Tuple[BitphaseRow, ...]: + """The lines of the channel the groove rides, within one pattern.""" + return document.songs[0].patterns[pattern_index].channels[int(ChannelIndex.DPCM)].rows + + class TestTheDocumentCarriesTheProject: def test_the_title_and_author_cross_over(self, document: BitphaseProject, source: Project) -> None: assert (document.name, document.author) == ( @@ -237,6 +258,63 @@ def test_a_blank_line_leaves_every_column_alone(self, document: BitphaseProject) ) +class TestTheTempoBecomesAGroove: + """A Bitphase song holds one speed value per row, so the fractional row rate most tempi + ask for is carried by a groove: whole tick counts that vary from row to row. The groove + reaches the engine as a table a speed effect reads a row at a time, triggered from the + channel this exporter leaves silent. A tempo whose rows all last alike is carried by the + song's initial speed alone. + """ + + def test_a_tempo_the_speed_column_states_needs_no_table(self, document: BitphaseProject) -> None: + assert len(document.tables) == len(document.instruments) + + def test_a_tempo_the_speed_column_states_leaves_the_groove_channel_resting( + self, + document: BitphaseProject, + ) -> None: + assert all(row == BitphaseRow() for row in groove_channel_rows(document, 0)) + + def test_a_groove_takes_the_table_above_the_slices(self, grooved_document: BitphaseProject) -> None: + table = grooved_document.tables[-1] + assert table.id == len(grooved_document.instruments) + assert table.loop == LOOP_FROM_START + + def test_the_table_holds_the_ticks_each_row_lasts(self, grooved_document: BitphaseProject) -> None: + assert grooved_document.tables[-1].rows == GROOVE_TICKS + + def test_the_song_starts_on_the_ticks_its_first_row_lasts(self, grooved_document: BitphaseProject) -> None: + assert grooved_document.songs[0].initial_speed == GROOVE_TICKS[TRIGGER_ROW] + + def test_every_pattern_triggers_the_groove_on_its_first_row(self, grooved_document: BitphaseProject) -> None: + """The speed table advances one entry per row and returns to the entry the trigger + names, so triggering it again at each pattern start holds every row on the entry that + describes it however the order jumps. + """ + trigger = EffectCell( + effect=int(EffectId.SPEED), + delay=SPEED_EFFECT_DELAY, + parameter=NO_EFFECT_PARAMETER, + table_index=grooved_document.tables[-1].id, + ) + triggers = [ + groove_channel_rows(grooved_document, index)[TRIGGER_ROW].effects + for index in range(len(grooved_document.songs[0].patterns)) + ] + assert triggers == [(trigger,)] * len(triggers) + + def test_the_groove_channel_carries_nothing_but_the_trigger(self, grooved_document: BitphaseProject) -> None: + rows = groove_channel_rows(grooved_document, 0) + assert all(row == BitphaseRow() for row in rows[TRIGGER_ROW + 1 :]) + + def test_the_sounding_channels_keep_their_effect_columns(self, grooved_document: BitphaseProject) -> None: + """The groove rides the silent channel, so every channel that plays keeps the one + effect column the chip gives it. + """ + channels = grooved_document.songs[0].patterns[0].channels[: int(ChannelIndex.DPCM)] + assert all(row.effects == (None,) for channel in channels for row in channel.rows) + + class TestAnUnbuildableRow: def test_a_row_naming_a_slice_with_no_instrument_is_refused(self, source: Project, lead: Sample) -> None: rows: List[Row] = [Row() for _ in range(ROWS_PER_PATTERN)] From 9ea9f84ea6d42fb54adcfbce3d20fd64eae32aa1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 22:37:58 +0200 Subject: [PATCH 056/152] Fixed: spectrum method typing in the calibration sweep --- Makefile | 3 +- scripts/calibration.py | 5 ++- src/sampletones_core/calibration/runner.py | 12 +++++-- .../calibration/test_runner.py | 34 +++++++++++++++++++ 4 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 tests/unit/sampletones_core/calibration/test_runner.py diff --git a/Makefile b/Makefile index 0fec3476d..9997def7d 100644 --- a/Makefile +++ b/Makefile @@ -70,6 +70,7 @@ help: @echo $(Q) make release - Compile standalone executable with the release deployment config$(Q) @echo $(Q) make test - Run unit tests with coverage$(Q) @echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q) + @echo $(Q) make calibration - Score the reconstruction corpus; the report lands in Documents/SampleToNES/calibration$(Q) @echo $(Q) make clean - Remove build artifacts and cache files$(Q) @echo $(Q) make lint - Run linting (pylint, mypy)$(Q) @echo $(Q) make format - Auto-format code (isort, black)$(Q) @@ -124,7 +125,7 @@ check-palette-colors: uv run scripts/checks/palette_colors.py calibration: - uv run scripts/calibration.py --all + uv run scripts/calibration.py lint: $(call script,dev/lint) diff --git a/scripts/calibration.py b/scripts/calibration.py index e092868ed..870735ea6 100644 --- a/scripts/calibration.py +++ b/scripts/calibration.py @@ -81,7 +81,10 @@ def main() -> None: output = arguments.output or DEFAULT_OUTPUT_ROOT / datetime.now(UTC).strftime("run-%Y%m%d-%H%M%S") output.mkdir(parents=True, exist_ok=True) - methods = [method.strip() for method in arguments.methods.split(",") if method.strip()] + methods = [SpectrumMethod(name.strip()) for name in arguments.methods.split(",") if name.strip()] + if not methods: + parser.error("--methods requires at least one spectrum method") + exponents = [float(value) for value in arguments.perceptual_exponents.split(",") if value.strip()] temporal_weights = [float(value) for value in arguments.temporal_weights.split(",") if value.strip()] diff --git a/src/sampletones_core/calibration/runner.py b/src/sampletones_core/calibration/runner.py index b8c1d12d8..a4036c9a8 100644 --- a/src/sampletones_core/calibration/runner.py +++ b/src/sampletones_core/calibration/runner.py @@ -5,6 +5,7 @@ import numpy as np from sampletones_core.configs import Config +from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.fft import Window from sampletones_core.library import InstructionLibrary from sampletones_core.reconstructions import Reconstructor @@ -32,7 +33,7 @@ class CalibrationRow: def build_variants( base: Config, - methods: List[str], + methods: List[SpectrumMethod], perceptual_exponents: List[float], temporal_weights: List[float], ) -> List[CalibrationVariant]: @@ -59,7 +60,7 @@ def build_variants( for method in methods: for exponent in perceptual_exponents: for temporal_weight in swept_temporal: - label = f"{method}-pe{exponent:g}" + label = f"{method.value}-pe{exponent:g}" generation = base.generation.model_copy( update={ "metric": base.generation.metric.model_copy(update={"perceptual_exponent": exponent}), @@ -84,7 +85,12 @@ def build_variants( "generation": generation, } ) - variants.append(CalibrationVariant(label=label, config=config)) + variants.append( + CalibrationVariant( + label=label, + config=config, + ) + ) return variants diff --git a/tests/unit/sampletones_core/calibration/test_runner.py b/tests/unit/sampletones_core/calibration/test_runner.py new file mode 100644 index 000000000..2eb447e31 --- /dev/null +++ b/tests/unit/sampletones_core/calibration/test_runner.py @@ -0,0 +1,34 @@ +import warnings +from typing import Final, List + +import pytest + +from sampletones_core.calibration.runner import build_variants +from sampletones_core.configs import Config +from sampletones_core.constants.enums import SpectrumMethod + +METHODS: Final[List[SpectrumMethod]] = [SpectrumMethod.FFT, SpectrumMethod.CQT] +EXPONENTS: Final[List[float]] = [1.0] + + +class TestBuildVariants: + def test_sweeps_every_combination(self) -> None: + variants = build_variants(Config(), METHODS, [1.0, 0.5], [0.25]) + assert [variant.label for variant in variants] == [ + "fft-pe1-tw0.25", + "fft-pe0.5-tw0.25", + "cqt-pe1-tw0.25", + "cqt-pe0.5-tw0.25", + ] + + @pytest.mark.parametrize("method", METHODS, ids=lambda method: method.value) + def test_variant_holds_the_spectrum_method_member(self, method: SpectrumMethod) -> None: + (variant,) = build_variants(Config(), [method], EXPONENTS, []) + assert variant.config.library.spectrum_method is method + + def test_variant_configuration_serializes_cleanly(self) -> None: + variants = build_variants(Config(), METHODS, EXPONENTS, []) + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + for variant in variants: + variant.config.model_dump() From 250f2d69d5c041a4fee90ff039f430119345ba9e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 23:04:31 +0200 Subject: [PATCH 057/152] Added: tick clock for exact engine-tick duration in samples --- .../logic/sequencer/playback/protocol.py | 6 + .../logic/sequencer/playback/synthesizer.py | 133 ++++++++---- src/sampletones_core/generators/generator.py | 17 +- src/sampletones_core/timing/__init__.py | 2 + src/sampletones_core/timing/clock.py | 98 +++++++++ .../sequencer/playback/test_tick_clock.py | 175 +++++++++++++++ .../sampletones_core/timing/test_clock.py | 200 ++++++++++++++++++ 7 files changed, 592 insertions(+), 39 deletions(-) create mode 100644 src/sampletones_core/timing/clock.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py create mode 100644 tests/unit/sampletones_core/timing/test_clock.py diff --git a/src/sampletones_application/logic/sequencer/playback/protocol.py b/src/sampletones_application/logic/sequencer/playback/protocol.py index 71986e11f..c605c8649 100644 --- a/src/sampletones_application/logic/sequencer/playback/protocol.py +++ b/src/sampletones_application/logic/sequencer/playback/protocol.py @@ -17,8 +17,14 @@ class ChannelGeneratorProtocol(Protocol): The instruction parameter is typed ``Any`` because the generator-to-instruction pairing is a runtime invariant maintained by ``GENERATOR_CLASSES`` dispatch, which lies outside the static type system. + + ``frame_length`` is settable so the synthesiser can give each tick the span its clock + states, which is what keeps a rendered tick lasting ``1 / nes_frequency`` seconds at a + sample rate the tick divides unevenly. """ + frame_length: int + def __call__( self, instruction: Any, diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer.py index 01d943f87..9cadf7f4b 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass, field, replace +from itertools import accumulate from typing import Callable, Dict, FrozenSet, List, Optional, Tuple import numpy as np @@ -27,7 +28,7 @@ from sampletones_core.project.patterns.row import Row from sampletones_core.project.song import Song from sampletones_core.project.song_position import SongPosition -from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove +from sampletones_core.timing import Groove, Metre, RowRate, TickClock, calculate_groove from .protocol import ChannelGeneratorProtocol @@ -79,6 +80,48 @@ def groove(self) -> Groove: ) +@dataclass(frozen=True) +class _RowFrames: + """Where each of a row's ticks starts and ends within the row's audio. + + A tick clock gives consecutive ticks whole sample counts that sum to their exact span, so the + lengths within one row vary where the sample rate does not divide the tick rate. Resolving the + boundaries once per row is what lets every channel write into the same offsets. + + Attributes: + lengths: The samples each of the row's ticks spans, in order. + bounds: Each tick's start offset, ending with the row's total length. + """ + + lengths: Tuple[int, ...] + bounds: Tuple[int, ...] + + @classmethod + def from_clock( + cls, + clock: TickClock, + *, + elapsed_ticks: int, + ticks: int, + ) -> _RowFrames: + """Resolves the row starting at ``elapsed_ticks`` and spanning ``ticks`` ticks.""" + lengths = tuple(clock.frame_length(elapsed_ticks + tick) for tick in range(ticks)) + return cls( + lengths=lengths, + bounds=tuple(accumulate(lengths, initial=0)), + ) + + @property + def total(self) -> int: + """The samples the whole row spans.""" + return self.bounds[-1] + + @property + def longest(self) -> int: + """The samples the row's longest tick spans.""" + return max(self.lengths, default=0) + + def _silence(samples: int) -> np.ndarray: return np.zeros(samples, dtype=np.float32) @@ -114,6 +157,10 @@ class RowSynthesizer: row a pattern's tenth row plays for is the row an exported module plays it for: both index the same groove from the pattern's first row. + Each of those ticks spans the samples the :class:`~sampletones_core.timing.clock.TickClock` + gives its position in the run, so a tick lasts ``1 / nes_frequency`` seconds at every sample + rate and the groove's tempo is the tempo heard. + Generators are constructed once from ``config`` and carry timer state across rows for phase continuity within a sustained note. Triggering a new note calls ``generator.reset()`` for a clean phase start. @@ -138,6 +185,8 @@ def __init__( self._position = SongPosition() self._timing: _SongTiming = _SongTiming.from_project(project_controller.project) self._groove: Groove = self._timing.groove() + self._tick_clock: TickClock = self._clock_for(config.library.nes_frequency) + self._elapsed_ticks: int = 0 self._channel_states: Dict[GeneratorName, _ChannelState] = { generator_name: _ChannelState( generator=GENERATOR_CLASSES[generator_name]( @@ -166,6 +215,7 @@ def set_position(self, order_position: int, row_index: int) -> None: self._position.row_index = row_index def reset(self) -> None: + self._elapsed_ticks = 0 for state in self._channel_states.values(): state.sample_id = None state.tick_index = 0 @@ -182,11 +232,14 @@ def _ensure_generators(self, nes_frequency: int) -> None: to the frequency). Pitch is derived from the APU clock, not this rate, so only the per-tick frame length changes; the generators' phase continuity resets, which is acceptable for an occasional settings edit. + + The tick clock follows the same value, since it states how long one of those ticks lasts. """ if nes_frequency == self._nes_frequency: return self._nes_frequency = nes_frequency + self._tick_clock = self._clock_for(nes_frequency) config = self._playback_config(nes_frequency) for generator_name, state in self._channel_states.items(): state.generator = GENERATOR_CLASSES[generator_name]( @@ -194,6 +247,12 @@ def _ensure_generators(self, nes_frequency: int) -> None: generator_name.value, ) + def _clock_for(self, nes_frequency: int) -> TickClock: + return TickClock.from_parameters( + sample_rate=self._config.library.sample_rate, + nes_frequency=nes_frequency, + ) + def _playback_config(self, nes_frequency: int) -> Config: library = self._config.library.model_copy(update={"nes_frequency": nes_frequency}) return self._config.model_copy(update={"library": library}) @@ -206,22 +265,27 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: self._ensure_generators(settings.nes_frequency) self._ensure_groove(project) - frame_length = round(self._config.library.sample_rate / settings.nes_frequency) - ticks_per_row = self._groove.ticks[self._position.row_index] - chunk_length = frame_length * ticks_per_row + frames = _RowFrames.from_clock( + self._tick_clock, + elapsed_ticks=self._elapsed_ticks, + ticks=self._groove.ticks[self._position.row_index], + ) position_before = replace(self._position) - if self.is_finished: - return np.zeros(chunk_length, dtype=np.float32), position_before - - mixed = self._mix_channels( - project, - song, - frame_length, - ticks_per_row, - chunk_length, + finished = self.is_finished + mixed = ( + _silence(frames.total) + if finished + else self._mix_channels( + project, + song, + frames, + ) ) - self._advance_position(song) + + self._elapsed_ticks += len(frames.lengths) + if not finished: + self._advance_position(song) return mixed, position_before @@ -244,19 +308,15 @@ def _mix_channels( self, project: Project, song: Song, - frame_length: int, - ticks_per_row: int, - chunk_length: int, + frames: _RowFrames, ) -> np.ndarray: - mixed = _silence(chunk_length) + mixed = _silence(frames.total) for generator_name in GeneratorName.items(): channel_audio = self._render_channel( generator_name, project, song, - frame_length, - ticks_per_row, - chunk_length, + frames, ) mixed += channel_audio @@ -267,9 +327,7 @@ def _render_channel( generator_name: GeneratorName, project: Project, song: Song, - frame_length: int, - ticks_per_row: int, - chunk_length: int, + frames: _RowFrames, ) -> np.ndarray: state = self._channel_states[generator_name] @@ -279,16 +337,14 @@ def _render_channel( sample_id = state.sample_id if sample_id is None or generator_name not in self._active_channels(): - return _silence(chunk_length) + return _silence(frames.total) return self._synthesize_ticks( state, sample_id, project, generator_name, - frame_length, - ticks_per_row, - chunk_length, + frames, ) def _resolve_row(self, generator_name: GeneratorName, song: Song) -> Optional[Row]: @@ -329,29 +385,28 @@ def _synthesize_ticks( sample_id: str, project: Project, generator_name: GeneratorName, - frame_length: int, - ticks_per_row: int, - chunk_length: int, + frames: _RowFrames, ) -> np.ndarray: sample = project.sample(sample_id) if sample is None: - return _silence(chunk_length) + return _silence(frames.total) instructions = sample.reconstruction.instructions.get(generator_name) if not instructions: - return _silence(chunk_length) + return _silence(frames.total) - output = _silence(chunk_length) - silence_frame = _silence(frame_length) + output = _silence(frames.total) + silence_frame = _silence(frames.longest) - for tick in range(ticks_per_row): + for tick, frame_length in enumerate(frames.lengths): frame = self._synthesize_tick( state, instructions, - silence_frame, + silence_frame[:frame_length], sample.loop, + frame_length, ) - output[tick * frame_length : (tick + 1) * frame_length] = frame + output[frames.bounds[tick] : frames.bounds[tick + 1]] = frame state.tick_index += 1 return output @@ -362,6 +417,7 @@ def _synthesize_tick( instructions: List[InstructionUnion], silence_frame: np.ndarray, loop: bool, + frame_length: int, ) -> np.ndarray: if loop: instruction = instructions[state.tick_index % len(instructions)] @@ -370,6 +426,7 @@ def _synthesize_tick( else: return silence_frame + state.generator.frame_length = frame_length return state.generator( _apply_modifiers( instruction, diff --git a/src/sampletones_core/generators/generator.py b/src/sampletones_core/generators/generator.py index e211e7166..19fc7cedc 100644 --- a/src/sampletones_core/generators/generator.py +++ b/src/sampletones_core/generators/generator.py @@ -203,7 +203,22 @@ def get_possible_instructions(self) -> List[InstructionT]: @property def frame_length(self) -> int: - return self.config.library.frame_length + """The samples the next rendered frame spans. + + The timer holds the length, seeded from the configuration it was built with. Setting it + renders the next frame over that many samples instead, which is how a caller driving the + engine's ticks gives each tick the span its clock states. Oscillator continuity is carried + by the timer's own state, so a frame of a different length resumes exactly where the last + one ended. + """ + return self.timer.frame_length + + @frame_length.setter + def frame_length(self, value: int) -> None: + if value < 1: + raise ValueError(f"frame_length must be at least 1, got {value}") + + self.timer.frame_length = value @classmethod @abstractmethod diff --git a/src/sampletones_core/timing/__init__.py b/src/sampletones_core/timing/__init__.py index 83f5fddcd..11b4d4cab 100644 --- a/src/sampletones_core/timing/__init__.py +++ b/src/sampletones_core/timing/__init__.py @@ -1,3 +1,4 @@ +from .clock import TickClock from .distribution import distribute_by_halving, distribute_proportionally from .groove import Groove, calculate_groove from .metre import Metre @@ -7,6 +8,7 @@ "Groove", "Metre", "RowRate", + "TickClock", "calculate_groove", "distribute_by_halving", "distribute_proportionally", diff --git a/src/sampletones_core/timing/clock.py b/src/sampletones_core/timing/clock.py new file mode 100644 index 000000000..8c93cb202 --- /dev/null +++ b/src/sampletones_core/timing/clock.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction +from math import floor + + +@dataclass(frozen=True) +class TickClock: + """The audio samples each engine tick spans, held exact so a tick lasts what the engine holds it for. + + A tick is the interrupt the engine consumes one instruction on, and it lasts + ``1 / nes_frequency`` seconds whatever rate the audio is rendered at. Where that duration + falls between two samples, giving every tick the same rounded length shifts the tempo by + the rounding, and the shift accumulates over a song. Spreading the fractional part across + consecutive ticks instead holds the running total on the exact clock, so the tempo a + :class:`~sampletones_core.timing.groove.Groove` states is the tempo the audio plays at, at + any sample rate. + + This is the rule a groove applies, one level down: a groove spreads a fractional ticks-per-row + across a pattern's rows, and a tick clock spreads a fractional samples-per-tick across the + ticks themselves. Both answer with whole numbers that sum to the exact total. + + Attributes: + samples_per_tick: The exact samples one tick spans. + """ + + samples_per_tick: Fraction + + def __post_init__(self) -> None: + if self.samples_per_tick < 1: + raise ValueError(f"samples_per_tick must be at least 1, got {self.samples_per_tick}") + + @classmethod + def from_parameters( + cls, + *, + sample_rate: int, + nes_frequency: int, + ) -> TickClock: + """Derives the clock a render at ``sample_rate`` runs the engine's ticks on. + + Args: + sample_rate: The audio sample rate in Hz. + nes_frequency: The engine tick rate in Hz. + + Returns: + TickClock: The exact samples one tick spans at those rates. + + Raises: + ValueError: If either rate is below 1, or a tick spans less than one sample. + """ + if sample_rate < 1: + raise ValueError(f"sample_rate must be at least 1, got {sample_rate}") + + if nes_frequency < 1: + raise ValueError(f"nes_frequency must be at least 1, got {nes_frequency}") + + return cls(samples_per_tick=Fraction(sample_rate, nes_frequency)) + + @property + def is_exact(self) -> bool: + """Whether every tick spans the same whole number of samples.""" + return self.samples_per_tick.denominator == 1 + + def samples_at(self, ticks: int) -> int: + """The samples the first ``ticks`` ticks span together. + + Args: + ticks: How many ticks have elapsed, at least 0. + + Returns: + int: The cumulative sample count, within one sample of the exact duration. + + Raises: + ValueError: If ``ticks`` is negative. + """ + if ticks < 0: + raise ValueError(f"ticks must be at least 0, got {ticks}") + + return floor(self.samples_per_tick * ticks) + + def frame_length(self, tick_index: int) -> int: + """The samples the tick at ``tick_index`` spans. + + Taking the difference of two cumulative counts is what makes a run of frame lengths sum + to the exact span of the ticks it covers, however the fraction falls. + + Args: + tick_index: The tick's position in the run, counted from 0. + + Returns: + int: The tick's length in samples. + + Raises: + ValueError: If ``tick_index`` is negative. + """ + return self.samples_at(tick_index + 1) - self.samples_at(tick_index) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py new file mode 100644 index 000000000..ae9cf7c11 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -0,0 +1,175 @@ +from fractions import Fraction +from typing import Final, Tuple + +import numpy as np +import pytest + +from sampletones_application.constants.playback import MAX_TICKS_PER_ROW, MIN_TICKS_PER_ROW +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.timing import Metre, RowRate, TickClock, calculate_groove +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( + add_sample, + all_channels, + make_controller, + make_pulse_reconstruction, + place_row, +) + +UNEVEN_SAMPLE_RATE: Final[int] = 22050 +EVEN_SAMPLE_RATE: Final[int] = 44100 +UNEVEN_RATES: Final[Tuple[int, ...]] = (8000, 16000, 22050) + + +def _config(sample_rate: int) -> Config: + config = Config() + return config.model_copy(update={"library": config.library.model_copy(update={"sample_rate": sample_rate})}) + + +def _expected_ticks(controller: ProjectController) -> Tuple[int, ...]: + settings = controller.project.settings + return calculate_groove( + RowRate.from_settings(settings), + Metre.from_settings(settings, rows=controller.project.song.rows_per_pattern), + minimum_ticks=MIN_TICKS_PER_ROW, + maximum_ticks=MAX_TICKS_PER_ROW, + ).ticks + + +class TestRowsFollowTheTickClock(BaseTestSuite): + """A rendered row spans the samples its ticks span, so the groove's tempo is the tempo heard.""" + + @pytest.mark.parametrize("sample_rate", UNEVEN_RATES + (EVEN_SAMPLE_RATE, 48000)) + def test_a_pattern_spans_its_exact_duration(self, sample_rate: int) -> None: + controller = make_controller() + synthesizer = RowSynthesizer(controller, _config(sample_rate), active_channels=all_channels) + ticks = _expected_ticks(controller) + + rendered = sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks))) + + clock = TickClock.from_parameters( + sample_rate=sample_rate, + nes_frequency=controller.project.settings.nes_frequency, + ) + assert rendered == clock.samples_at(sum(ticks)) + + @pytest.mark.parametrize("sample_rate", UNEVEN_RATES) + def test_a_long_run_does_not_drift(self, sample_rate: int) -> None: + """The property a fixed rounded frame length loses: the error stays below one sample.""" + controller = make_controller() + synthesizer = RowSynthesizer(controller, _config(sample_rate), active_channels=all_channels) + ticks = _expected_ticks(controller) + patterns = 40 + + rendered = 0 + for _ in range(patterns): + synthesizer.set_position(0, 0) + rendered += sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks))) + + exact = Fraction(sample_rate, controller.project.settings.nes_frequency) * sum(ticks) * patterns + assert abs(rendered - exact) < 1 + + def test_a_row_spans_the_sum_of_its_ticks(self) -> None: + controller = make_controller() + synthesizer = RowSynthesizer( + controller, + _config(UNEVEN_SAMPLE_RATE), + active_channels=all_channels, + ) + clock = TickClock.from_parameters( + sample_rate=UNEVEN_SAMPLE_RATE, + nes_frequency=controller.project.settings.nes_frequency, + ) + ticks = _expected_ticks(controller) + + elapsed = 0 + for row_ticks in ticks: + chunk, _ = synthesizer.render_row() + expected = clock.samples_at(elapsed + row_ticks) - clock.samples_at(elapsed) + assert len(chunk) == expected + elapsed += row_ticks + + def test_rows_vary_in_length_where_their_ticks_straddle_a_sample(self) -> None: + """The variation is the mechanism; a run of identical lengths would mean the drift is back. + + An odd tick count is what makes it visible at the row: five ticks of 367.5 samples span + 1837.5, so consecutive rows take the floor and the ceiling in turn. + """ + controller = make_controller() + controller.set_speed(5) + synthesizer = RowSynthesizer( + controller, + _config(UNEVEN_SAMPLE_RATE), + active_channels=all_channels, + ) + lengths = {len(synthesizer.render_row()[0]) for _ in range(len(_expected_ticks(controller)))} + assert lengths == {1837, 1838} + + def test_reset_returns_the_clock_to_the_first_tick(self) -> None: + controller = make_controller() + synthesizer = RowSynthesizer( + controller, + _config(UNEVEN_SAMPLE_RATE), + active_channels=all_channels, + ) + first = len(synthesizer.render_row()[0]) + + synthesizer.set_position(0, 0) + synthesizer.reset() + + assert len(synthesizer.render_row()[0]) == first + + def test_a_frequency_change_rebuilds_the_clock(self) -> None: + controller = make_controller() + synthesizer = RowSynthesizer(controller, _config(EVEN_SAMPLE_RATE), active_channels=all_channels) + + controller.set_nes_frequency(60) + synthesizer.render_row() + controller.set_nes_frequency(30) + synthesizer.set_position(0, 0) + synthesizer.reset() + ticks = _expected_ticks(controller) + + rendered = sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks))) + clock = TickClock.from_parameters(sample_rate=EVEN_SAMPLE_RATE, nes_frequency=30) + assert rendered == clock.samples_at(sum(ticks)) + + +class TestChannelsFillTheRow(BaseTestSuite): + """Every channel writes into the same tick boundaries, so a mix never leaves a gap.""" + + def test_a_sounding_channel_fills_every_tick(self) -> None: + controller = make_controller() + reconstruction = make_pulse_reconstruction(count=1) + sample = add_sample(controller, reconstruction, loop=True) + place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) + synthesizer = RowSynthesizer( + controller, + _config(UNEVEN_SAMPLE_RATE), + active_channels=all_channels, + ) + + chunk, _ = synthesizer.render_row() + + assert np.any(chunk != 0.0) + assert not np.any(np.isnan(chunk)) + + def test_a_sounding_note_stays_continuous_across_a_tick_length_change(self) -> None: + """A tick of a different length resumes the oscillator where the last one ended.""" + controller = make_controller() + reconstruction = make_pulse_reconstruction(count=1) + sample = add_sample(controller, reconstruction, loop=True) + place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) + synthesizer = RowSynthesizer( + controller, + _config(UNEVEN_SAMPLE_RATE), + active_channels=all_channels, + ) + + chunk, _ = synthesizer.render_row() + steps = np.abs(np.diff(chunk)) + + assert float(steps.max()) <= 1.0 diff --git a/tests/unit/sampletones_core/timing/test_clock.py b/tests/unit/sampletones_core/timing/test_clock.py new file mode 100644 index 000000000..93a2dff85 --- /dev/null +++ b/tests/unit/sampletones_core/timing/test_clock.py @@ -0,0 +1,200 @@ +from __future__ import annotations + +from dataclasses import dataclass +from fractions import Fraction +from typing import Final, Tuple + +import pytest + +from sampletones_core.constants.audio import SAMPLE_RATES +from sampletones_core.timing.clock import TickClock +from sampletones_shared.constants.nes import MAX_NES_FREQUENCY, MIN_NES_FREQUENCY +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseAutolabelTestCase + +LONG_RUN_TICKS: Final[int] = 36000 +NES_FREQUENCIES: Final[Tuple[int, ...]] = (15, 24, 25, 30, 50, 60, 100, 120, 199, 300) + + +class TestTickClock(BaseTestSuite): + """One case table, read both for the frame lengths it produces and for the rules they obey.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseAutolabelTestCase): + expected: Tuple[int, ...] + sample_rate: int + nes_frequency: int + + @property + def label(self) -> str: + return f"{self.sample_rate}hz_{self.nes_frequency}tick" + + @property + def clock(self) -> TickClock: + return TickClock.from_parameters( + sample_rate=self.sample_rate, + nes_frequency=self.nes_frequency, + ) + + test_cases = ( + TestCase(sample_rate=44100, nes_frequency=60, expected=(735,) * 8), + TestCase(sample_rate=48000, nes_frequency=60, expected=(800,) * 8), + TestCase(sample_rate=96000, nes_frequency=60, expected=(1600,) * 8), + TestCase(sample_rate=44100, nes_frequency=30, expected=(1470,) * 8), + TestCase( + sample_rate=22050, + nes_frequency=60, + expected=(367, 368, 367, 368, 367, 368, 367, 368), + ), + TestCase( + sample_rate=8000, + nes_frequency=60, + expected=(133, 133, 134, 133, 133, 134, 133, 133), + ), + TestCase( + sample_rate=16000, + nes_frequency=60, + expected=(266, 267, 267, 266, 267, 267, 266, 267), + ), + TestCase( + sample_rate=44100, + nes_frequency=120, + expected=(367, 368, 367, 368, 367, 368, 367, 368), + ), + TestCase( + sample_rate=8000, + nes_frequency=300, + expected=(26, 27, 27, 26, 27, 27, 26, 27), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_frame_lengths_match(self, test_case: TestCase) -> None: + clock = test_case.clock + lengths = tuple(clock.frame_length(tick) for tick in range(len(test_case.expected))) + assert lengths == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_frame_lengths_sum_to_the_cumulative_count(self, test_case: TestCase) -> None: + clock = test_case.clock + assert sum(clock.frame_length(tick) for tick in range(len(test_case.expected))) == clock.samples_at( + len(test_case.expected) + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_only_the_floor_and_the_ceiling_appear(self, test_case: TestCase) -> None: + """Consecutive ticks differ by at most one sample, so no tick is audibly off on its own.""" + clock = test_case.clock + lengths = {clock.frame_length(tick) for tick in range(LONG_RUN_TICKS)} + assert max(lengths) - min(lengths) <= 1 + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_is_exact_reports_a_uniform_run(self, test_case: TestCase) -> None: + clock = test_case.clock + lengths = {clock.frame_length(tick) for tick in range(LONG_RUN_TICKS)} + assert clock.is_exact == (len(lengths) == 1) + + +class TestTheClockHoldsTheTempo(BaseTestSuite): + """The property the whole clock exists for: a run of ticks lands on its exact duration.""" + + @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) + @pytest.mark.parametrize("sample_rate", SAMPLE_RATES) + def test_a_long_run_lands_on_the_exact_sample_count( + self, + sample_rate: int, + nes_frequency: int, + ) -> None: + clock = TickClock.from_parameters( + sample_rate=sample_rate, + nes_frequency=nes_frequency, + ) + rendered = sum(clock.frame_length(tick) for tick in range(LONG_RUN_TICKS)) + exact = Fraction(sample_rate, nes_frequency) * LONG_RUN_TICKS + assert rendered == int(exact) if exact.denominator == 1 else abs(rendered - exact) < 1 + + @pytest.mark.parametrize("nes_frequency", NES_FREQUENCIES) + @pytest.mark.parametrize("sample_rate", SAMPLE_RATES) + def test_the_cumulative_count_never_drifts_past_one_sample( + self, + sample_rate: int, + nes_frequency: int, + ) -> None: + clock = TickClock.from_parameters( + sample_rate=sample_rate, + nes_frequency=nes_frequency, + ) + rate = Fraction(sample_rate, nes_frequency) + assert all(abs(clock.samples_at(ticks) - rate * ticks) < 1 for ticks in range(0, LONG_RUN_TICKS, 97)) + + @pytest.mark.parametrize("sample_rate", SAMPLE_RATES) + def test_a_whole_division_gives_every_tick_the_rounded_length(self, sample_rate: int) -> None: + """Where the division is whole the clock agrees with the length a timer is built with.""" + for nes_frequency in NES_FREQUENCIES: + if sample_rate % nes_frequency: + continue + + clock = TickClock.from_parameters( + sample_rate=sample_rate, + nes_frequency=nes_frequency, + ) + expected = round(sample_rate / nes_frequency) + assert clock.is_exact + assert all(clock.frame_length(tick) == expected for tick in range(64)) + + +class TestTickClockBounds(BaseTestSuite): + def test_the_first_tick_starts_at_zero(self) -> None: + clock = TickClock.from_parameters(sample_rate=44100, nes_frequency=60) + assert clock.samples_at(0) == 0 + + def test_every_tick_spans_at_least_one_sample(self) -> None: + clock = TickClock.from_parameters( + sample_rate=min(SAMPLE_RATES), + nes_frequency=MAX_NES_FREQUENCY, + ) + assert all(clock.frame_length(tick) >= 1 for tick in range(1024)) + + @pytest.mark.parametrize("nes_frequency", (MIN_NES_FREQUENCY, MAX_NES_FREQUENCY)) + def test_the_engine_range_is_covered_at_every_rate(self, nes_frequency: int) -> None: + for sample_rate in SAMPLE_RATES: + clock = TickClock.from_parameters( + sample_rate=sample_rate, + nes_frequency=nes_frequency, + ) + assert clock.samples_per_tick == Fraction(sample_rate, nes_frequency) + + def test_a_tick_shorter_than_a_sample_is_rejected(self) -> None: + with pytest.raises(ValueError, match="samples_per_tick must be at least 1"): + TickClock.from_parameters(sample_rate=100, nes_frequency=300) + + @pytest.mark.parametrize("sample_rate", (0, -1)) + def test_a_rate_below_one_is_rejected(self, sample_rate: int) -> None: + with pytest.raises(ValueError, match="sample_rate must be at least 1"): + TickClock.from_parameters(sample_rate=sample_rate, nes_frequency=60) + + @pytest.mark.parametrize("nes_frequency", (0, -1)) + def test_a_tick_rate_below_one_is_rejected(self, nes_frequency: int) -> None: + with pytest.raises(ValueError, match="nes_frequency must be at least 1"): + TickClock.from_parameters(sample_rate=44100, nes_frequency=nes_frequency) + + def test_a_negative_tick_count_is_rejected(self) -> None: + clock = TickClock.from_parameters(sample_rate=44100, nes_frequency=60) + with pytest.raises(ValueError, match="ticks must be at least 0"): + clock.samples_at(-1) From ba550ce5574848cf434921fb042add4a8ff826ae Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 10 Aug 2026 23:36:52 +0200 Subject: [PATCH 058/152] Added: streaming audio writers for WAV and MP3 --- pyproject.toml | 1 + src/sampletones_application/application.py | 13 +- .../coordinators/tabs/sequencer.py | 1 + .../logic/sequencer/playback/synthesizer.py | 110 +++++++---- src/sampletones_core/audio/manager.py | 10 + .../audio/writers/__init__.py | 44 +++++ src/sampletones_core/audio/writers/bitrate.py | 117 ++++++++++++ .../audio/writers/capability.py | 68 +++++++ src/sampletones_core/audio/writers/format.py | 49 +++++ .../audio/writers/protocol.py | 30 +++ .../audio/writers/selection.py | 80 ++++++++ .../audio/writers/soundfile.py | 116 ++++++++++++ src/sampletones_core/audio/writers/spec.py | 85 +++++++++ src/sampletones_core/configs/config.py | 33 +++- .../reconstruction/reconstruction.py | 4 +- src/sampletones_shared/exceptions/__init__.py | 3 +- src/sampletones_shared/exceptions/audio.py | 4 + .../logic/sequencer/playback/conftest.py | 21 ++- .../sequencer/playback/test_synthesizer.py | 22 ++- .../sequencer/playback/test_tick_clock.py | 81 ++++---- .../audio/writers/__init__.py | 0 .../audio/writers/test_spec.py | 107 +++++++++++ .../audio/writers/test_writer.py | 174 ++++++++++++++++++ uv.lock | 2 + 24 files changed, 1085 insertions(+), 90 deletions(-) create mode 100644 src/sampletones_core/audio/writers/__init__.py create mode 100644 src/sampletones_core/audio/writers/bitrate.py create mode 100644 src/sampletones_core/audio/writers/capability.py create mode 100644 src/sampletones_core/audio/writers/format.py create mode 100644 src/sampletones_core/audio/writers/protocol.py create mode 100644 src/sampletones_core/audio/writers/selection.py create mode 100644 src/sampletones_core/audio/writers/soundfile.py create mode 100644 src/sampletones_core/audio/writers/spec.py create mode 100644 tests/unit/sampletones_core/audio/writers/__init__.py create mode 100644 tests/unit/sampletones_core/audio/writers/test_spec.py create mode 100644 tests/unit/sampletones_core/audio/writers/test_writer.py diff --git a/pyproject.toml b/pyproject.toml index 19f08acd7..99ca6af68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,6 +46,7 @@ dependencies = [ "rich>=13.0,<16", "scipy>=1.13,<2", "screeninfo>=0.8,<0.9", + "soundfile>=0.13,<0.14", "tqdm>=4.66,<5", "jeepney>=0.8,<1; sys_platform == 'linux'", "pytaskbar>=0.1.1,<0.2; platform_system == 'Windows'", diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 848b17fc7..078505f1f 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1140,8 +1140,17 @@ def _apply_audio_settings( sample_rate: SampleRate, buffer_size: BufferSize, ) -> None: - """Applies the dialog's committed device, sample rate, and buffer size.""" - self.audio_device_manager.configure_device(device_index, sample_rate) + """Applies the dialog's committed device, sample rate, and buffer size. + + Switching devices needs the output free; a source that keeps hold of it leaves the + settings as they stand and reports the failure. + """ + try: + self.audio_device_manager.configure_device(device_index, sample_rate) + except PlaybackError as exception: + self._on_playback_error(exception) + return + self.audio_device_manager.set_buffer_size(buffer_size) def _owning_project_sample(self) -> Optional[Sample]: diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 78c87724b..cfe0bb403 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -197,6 +197,7 @@ def __init__( project_controller, config_manager.config, active_channels=lambda: self._sequencer_channels_logic.active_channels, + sample_rate=lambda: audio_device_manager.sample_rate, ), should_loop=lambda: session_manager.loop_song, master_gain=lambda: session_manager.master_gain, diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer.py index 9cadf7f4b..706d2d692 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer.py @@ -80,6 +80,31 @@ def groove(self) -> Groove: ) +@dataclass(frozen=True) +class _EngineRates: + """The pair of rates a tick is sized from, held together so a change is one comparison. + + Each rate is owned elsewhere: the project states how many instructions the engine consumes + each second, and whoever takes the audio states the rate it is rendered at — the output + device for playback, the chosen format for a file. Together they fix how many samples one + tick spans, so the synthesiser follows both. + + Attributes: + nes_frequency: The engine ticks consumed each second. + sample_rate: The samples the rendered audio holds each second. + """ + + nes_frequency: int + sample_rate: int + + def clock(self) -> TickClock: + """The samples each tick spans under this pair of rates.""" + return TickClock.from_parameters( + sample_rate=self.sample_rate, + nes_frequency=self.nes_frequency, + ) + + @dataclass(frozen=True) class _RowFrames: """Where each of a row's ticks starts and ends within the row's audio. @@ -161,9 +186,13 @@ class RowSynthesizer: gives its position in the run, so a tick lasts ``1 / nes_frequency`` seconds at every sample rate and the groove's tempo is the tempo heard. - Generators are constructed once from ``config`` and carry timer state across - rows for phase continuity within a sustained note. Triggering a new note - calls ``generator.reset()`` for a clean phase start. + ``sample_rate`` reports the rate the audio is rendered at, and is what the caller taking that + audio runs at: the output device for live playback, the chosen format for a file. Reading it + per row keeps the two in step, so a rendered second is a second wherever the audio goes. + + Generators are constructed from ``config`` at the rates in force and carry timer + state across rows for phase continuity within a sustained note. Triggering a new + note calls ``generator.reset()`` for a clean phase start. ``active_channels`` reports which channels sound and is consulted once per channel per row, so muting or unmuting during playback is heard as the render-ahead buffer drains. A @@ -177,24 +206,21 @@ def __init__( config: Config, *, active_channels: Callable[[], FrozenSet[GeneratorName]], + sample_rate: Callable[[], int], ) -> None: self._project_controller = project_controller self._config = config self._active_channels = active_channels - self._nes_frequency: int = config.library.nes_frequency + self._sample_rate = sample_rate self._position = SongPosition() self._timing: _SongTiming = _SongTiming.from_project(project_controller.project) self._groove: Groove = self._timing.groove() - self._tick_clock: TickClock = self._clock_for(config.library.nes_frequency) + self._rates: _EngineRates = self._current_rates() + self._tick_clock: TickClock = self._rates.clock() self._elapsed_ticks: int = 0 self._channel_states: Dict[GeneratorName, _ChannelState] = { - generator_name: _ChannelState( - generator=GENERATOR_CLASSES[generator_name]( - config, - generator_name.value, - ), - ) - for generator_name in GeneratorName.items() + generator_name: _ChannelState(generator=generator) + for generator_name, generator in self._build_generators(self._rates).items() } @property @@ -222,47 +248,55 @@ def reset(self) -> None: state.transpose = 0 state.volume = MAX_VOLUME - def _ensure_generators(self, nes_frequency: int) -> None: - """Rebuilds the channel generators when the engine refresh rate changes. + def _ensure_generators(self) -> None: + """Rebuilds the channel generators when either rate a tick is sized from changes. - ``nes_frequency`` is the rate at which instructions (engine ticks) are - consumed, so each tick spans ``sample_rate / nes_frequency`` audio samples. - The generators must follow the project's current value so a row keeps a - constant real-time duration as the rate changes (the tempo is otherwise tied - to the frequency). Pitch is derived from the APU clock, not this rate, so only - the per-tick frame length changes; the generators' phase continuity resets, - which is acceptable for an occasional settings edit. + The engine consumes ``nes_frequency`` instructions a second and the audio holds + ``sample_rate`` samples a second, so a tick spans the quotient of the two. Following the + project's frequency keeps a row a constant real-time duration as that frequency changes, + and following the output's rate keeps a rendered second a second wherever the audio goes. + Pitch derives from the APU clock rather than either rate, so a change moves only the + per-tick frame length; the generators' phase continuity resets, which is acceptable for an + occasional settings edit. - The tick clock follows the same value, since it states how long one of those ticks lasts. + The tick clock follows the same pair, since it states how long one of those ticks lasts. """ - if nes_frequency == self._nes_frequency: + rates = self._current_rates() + if rates == self._rates: return - self._nes_frequency = nes_frequency - self._tick_clock = self._clock_for(nes_frequency) - config = self._playback_config(nes_frequency) - for generator_name, state in self._channel_states.items(): - state.generator = GENERATOR_CLASSES[generator_name]( + self._rates = rates + self._tick_clock = rates.clock() + for generator_name, generator in self._build_generators(rates).items(): + self._channel_states[generator_name].generator = generator + + def _current_rates(self) -> _EngineRates: + return _EngineRates( + nes_frequency=self._project_controller.project.settings.nes_frequency, + sample_rate=self._sample_rate(), + ) + + def _build_generators(self, rates: _EngineRates) -> Dict[GeneratorName, ChannelGeneratorProtocol]: + config = self._engine_config(rates) + return { + generator_name: GENERATOR_CLASSES[generator_name]( config, generator_name.value, ) + for generator_name in GeneratorName.items() + } - def _clock_for(self, nes_frequency: int) -> TickClock: - return TickClock.from_parameters( - sample_rate=self._config.library.sample_rate, - nes_frequency=nes_frequency, + def _engine_config(self, rates: _EngineRates) -> Config: + return self._config.with_library( + nes_frequency=rates.nes_frequency, + sample_rate=rates.sample_rate, ) - def _playback_config(self, nes_frequency: int) -> Config: - library = self._config.library.model_copy(update={"nes_frequency": nes_frequency}) - return self._config.model_copy(update={"library": library}) - def render_row(self) -> Tuple[np.ndarray, SongPosition]: project = self._project_controller.project - settings = project.settings song = project.song self._position.wrap_overflow(song.rows_per_pattern) - self._ensure_generators(settings.nes_frequency) + self._ensure_generators() self._ensure_groove(project) frames = _RowFrames.from_clock( diff --git a/src/sampletones_core/audio/manager.py b/src/sampletones_core/audio/manager.py index bbcfab300..66530df58 100644 --- a/src/sampletones_core/audio/manager.py +++ b/src/sampletones_core/audio/manager.py @@ -429,12 +429,18 @@ def configure_device( Stops any active playback and switches to the specified device and sample rate. If the sample rate is not supported, falls back to the first supported rate for that device. + A stream handed out to a streaming source was opened on the device and rate in force at + the time, so the new settings reach it only once it is wound down; the source is asked to + hand the output back before the switch, and playback resumes under the new settings. + Args: device_index: Index of the device to configure. sample_rate: Desired sample rate in Hz. Raises: ValueError: If the device index is not found. + PlaybackError: If a handed-out stream survives its release, which leaves the device + and rate as they stand. """ if device_index not in self._devices: raise ValueError(f"Device with index {device_index} not found") @@ -448,6 +454,10 @@ def configure_device( sample_rate = fallback_rate self.stop() + self.call(self.on_acquire_output) + if not self._release_output_streams(): + raise PlaybackError("An output stream is still held; the audio device stays as it is") + self.device_index = device_index self.sample_rate = sample_rate logger.info(f"Audio device configured: '{self.device_name}' (index={device_index}, sample_rate={sample_rate})") diff --git a/src/sampletones_core/audio/writers/__init__.py b/src/sampletones_core/audio/writers/__init__.py new file mode 100644 index 000000000..25d33237f --- /dev/null +++ b/src/sampletones_core/audio/writers/__init__.py @@ -0,0 +1,44 @@ +from .bitrate import ( + MP3_LADDERS, + MP3_SAMPLE_RATES, + default_mp3_bitrate, + mp3_bitrates, + mp3_compression_level, +) +from .capability import FORMAT_CAPABILITIES, FormatCapability, capability_of +from .format import ( + AUDIO_DEPTHS, + DEFAULT_AUDIO_DEPTH, + DEFAULT_AUDIO_FORMAT, + AudioDepth, + AudioFormat, +) +from .protocol import AudioWriter +from .selection import available_audio_formats, available_depths, open_audio_writer +from .soundfile import SoundFileAudioWriter +from .spec import AudioOutputSpec, AudioOutputSpecBase, Mp3OutputSpec, WaveOutputSpec + +__all__ = [ + "AUDIO_DEPTHS", + "DEFAULT_AUDIO_DEPTH", + "DEFAULT_AUDIO_FORMAT", + "FORMAT_CAPABILITIES", + "MP3_LADDERS", + "MP3_SAMPLE_RATES", + "AudioDepth", + "AudioFormat", + "AudioOutputSpec", + "AudioOutputSpecBase", + "AudioWriter", + "FormatCapability", + "Mp3OutputSpec", + "SoundFileAudioWriter", + "WaveOutputSpec", + "available_audio_formats", + "available_depths", + "capability_of", + "default_mp3_bitrate", + "mp3_bitrates", + "mp3_compression_level", + "open_audio_writer", +] diff --git a/src/sampletones_core/audio/writers/bitrate.py b/src/sampletones_core/audio/writers/bitrate.py new file mode 100644 index 000000000..1891cdec6 --- /dev/null +++ b/src/sampletones_core/audio/writers/bitrate.py @@ -0,0 +1,117 @@ +from typing import Final, Mapping, Tuple + +MPEG_1_LADDER: Final[Mapping[int, float]] = { + 320: 0.05, + 256: 0.19, + 224: 0.33, + 192: 0.44, + 160: 0.55, + 128: 0.65, + 112: 0.72, + 96: 0.78, + 80: 0.83, + 64: 0.88, + 56: 0.91, + 48: 0.94, + 40: 0.97, + 32: 0.99, +} + +MPEG_2_LADDER: Final[Mapping[int, float]] = { + 160: 0.02, + 144: 0.10, + 128: 0.21, + 112: 0.31, + 96: 0.42, + 80: 0.52, + 64: 0.62, + 56: 0.68, + 48: 0.73, + 40: 0.78, + 32: 0.84, + 24: 0.89, + 16: 0.94, + 8: 0.98, +} + +MPEG_2_5_LADDER: Final[Mapping[int, float]] = { + 64: 0.03, + 56: 0.13, + 48: 0.27, + 40: 0.41, + 32: 0.56, + 24: 0.70, + 16: 0.84, + 8: 0.96, +} + +MP3_LADDERS: Final[Mapping[int, Mapping[int, float]]] = { + 8000: MPEG_2_5_LADDER, + 16000: MPEG_2_LADDER, + 22050: MPEG_2_LADDER, + 44100: MPEG_1_LADDER, + 48000: MPEG_1_LADDER, +} + +MP3_SAMPLE_RATES: Final[Tuple[int, ...]] = tuple(sorted(MP3_LADDERS)) +PREFERRED_MP3_BITRATE: Final[int] = 192 + + +def mp3_bitrates(sample_rate: int) -> Tuple[int, ...]: + """The bitrates MP3 encodes at ``sample_rate``, highest first. + + Each MPEG audio version defines its own ladder of bitrates and covers its own set of sample + rates, so the choice on offer narrows as the rate drops: the full ladder up to 320 kbps at + 44100 and 48000 Hz, a ladder topping out at 160 kbps at 16000 and 22050 Hz, and one topping + out at 64 kbps at 8000 Hz. + + Args: + sample_rate: The rate the file is written at. + + Returns: + Tuple[int, ...]: The bitrates in kbps, highest first. + + Raises: + KeyError: If MP3 does not encode at ``sample_rate``. + """ + return tuple(MP3_LADDERS[sample_rate]) + + +def mp3_compression_level(sample_rate: int, bitrate: int) -> float: + """The encoder setting that reaches ``bitrate`` at ``sample_rate``. + + libsndfile asks for MP3 quality as a compression level between 0 and 1 and turns that into a + rung on the ladder its MPEG version defines, so the level standing for a given bitrate depends + on the sample rate as well. Each level here sits in the middle of the band that selects its + rung, which leaves room either side for the rounding an encoder build applies. + + Args: + sample_rate: The rate the file is written at. + bitrate: The bitrate in kbps, one of those :func:`mp3_bitrates` reports. + + Returns: + float: The compression level to open the file with. + + Raises: + KeyError: If MP3 does not encode at ``sample_rate``, or does not reach ``bitrate`` there. + """ + return MP3_LADDERS[sample_rate][bitrate] + + +def default_mp3_bitrate(sample_rate: int) -> int: + """The bitrate a render starts at: the preferred one where the rate reaches it, else its best. + + Args: + sample_rate: The rate the file is written at. + + Returns: + int: The bitrate in kbps. + + Raises: + KeyError: If MP3 does not encode at ``sample_rate``. + """ + bitrates = mp3_bitrates(sample_rate) + return next( + (bitrate for bitrate in bitrates if bitrate <= PREFERRED_MP3_BITRATE), + bitrates[-1], + ) diff --git a/src/sampletones_core/audio/writers/capability.py b/src/sampletones_core/audio/writers/capability.py new file mode 100644 index 000000000..aa8ad78f8 --- /dev/null +++ b/src/sampletones_core/audio/writers/capability.py @@ -0,0 +1,68 @@ +from dataclasses import dataclass +from typing import Final, Mapping, Tuple + +from sampletones_core.constants.audio import SAMPLE_RATES +from sampletones_core.paths import EXT_FILE_MP3, EXT_FILE_WAVE + +from .bitrate import MP3_SAMPLE_RATES +from .format import AUDIO_DEPTHS, AudioDepth, AudioFormat + + +@dataclass(frozen=True) +class FormatCapability: + """What one container holds, as the format itself defines it. + + A chooser reads this to offer the settings a format accepts, and a specification is checked + against it before a file is opened, so a combination the encoder would reject is caught while + it is still a request. + + Attributes: + extension: The suffix a file of this format carries. + sample_rates: The rates the format encodes, lowest first. + depths: The sample forms the format stores, coarsest first; empty where the format sets + its own and offers a bitrate instead. + """ + + extension: str + sample_rates: Tuple[int, ...] + depths: Tuple[AudioDepth, ...] + + @property + def stores_samples(self) -> bool: + """Whether the format stores samples directly, which is what gives it a depth to choose.""" + return bool(self.depths) + + def supports_sample_rate(self, sample_rate: int) -> bool: + return sample_rate in self.sample_rates + + def supports_depth(self, depth: AudioDepth) -> bool: + return depth in self.depths + + +FORMAT_CAPABILITIES: Final[Mapping[AudioFormat, FormatCapability]] = { + AudioFormat.WAVE: FormatCapability( + extension=EXT_FILE_WAVE, + sample_rates=tuple(SAMPLE_RATES), + depths=AUDIO_DEPTHS, + ), + AudioFormat.MP3: FormatCapability( + extension=EXT_FILE_MP3, + sample_rates=MP3_SAMPLE_RATES, + depths=(), + ), +} + + +def capability_of(audio_format: AudioFormat) -> FormatCapability: + """What ``audio_format`` holds. + + Args: + audio_format: The container to describe. + + Returns: + FormatCapability: The settings that format accepts. + + Raises: + KeyError: If the format has no entry in the registry. + """ + return FORMAT_CAPABILITIES[audio_format] diff --git a/src/sampletones_core/audio/writers/format.py b/src/sampletones_core/audio/writers/format.py new file mode 100644 index 000000000..1ef86d987 --- /dev/null +++ b/src/sampletones_core/audio/writers/format.py @@ -0,0 +1,49 @@ +from enum import StrEnum +from typing import Final, Mapping, Tuple + + +class AudioFormat(StrEnum): + """The container a rendered song is written into.""" + + WAVE = "wave" + MP3 = "mp3" + + +class AudioDepth(StrEnum): + """The form each sample takes in a file that stores samples directly. + + The integer depths quantize the signal to a fixed number of steps, coarsest first; the float + depth stores the rendered value as it stands. Eight bits gives 256 steps across the range, the + grain a chip render is often chosen for. + """ + + PCM_U8 = "pcm_u8" + PCM_16 = "pcm_16" + PCM_24 = "pcm_24" + PCM_32 = "pcm_32" + FLOAT_32 = "float_32" + + @property + def bits(self) -> int: + """The bits one stored sample occupies.""" + return DEPTH_BITS[self] + + +DEPTH_BITS: Final[Mapping[AudioDepth, int]] = { + AudioDepth.PCM_U8: 8, + AudioDepth.PCM_16: 16, + AudioDepth.PCM_24: 24, + AudioDepth.PCM_32: 32, + AudioDepth.FLOAT_32: 32, +} + +AUDIO_DEPTHS: Final[Tuple[AudioDepth, ...]] = ( + AudioDepth.PCM_U8, + AudioDepth.PCM_16, + AudioDepth.PCM_24, + AudioDepth.PCM_32, + AudioDepth.FLOAT_32, +) + +DEFAULT_AUDIO_FORMAT: Final[AudioFormat] = AudioFormat.WAVE +DEFAULT_AUDIO_DEPTH: Final[AudioDepth] = AudioDepth.PCM_16 diff --git a/src/sampletones_core/audio/writers/protocol.py b/src/sampletones_core/audio/writers/protocol.py new file mode 100644 index 000000000..612003076 --- /dev/null +++ b/src/sampletones_core/audio/writers/protocol.py @@ -0,0 +1,30 @@ +from types import TracebackType +from typing import Optional, Protocol, Self, Type + +import numpy as np + + +class AudioWriter(Protocol): + """A file open for audio, taking it a chunk at a time for the length of a ``with`` block. + + Writing incrementally is what lets a render of any length report its progress and answer a + cancel: the caller hands over each chunk as it is produced, and the whole song never has to + exist in memory at once. Leaving the block finalizes the file, whether the render finished or + stopped partway, so the destination is a complete file of whatever was written. + """ + + def __enter__(self) -> Self: ... + + def __exit__( + self, + exception_type: Optional[Type[BaseException]], + exception: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: ... + + def write(self, chunk: np.ndarray) -> None: + """Appends one chunk of mono float32 audio to the file. + + Args: + chunk: The samples to append, in the range [-1, 1]. + """ diff --git a/src/sampletones_core/audio/writers/selection.py b/src/sampletones_core/audio/writers/selection.py new file mode 100644 index 000000000..9713342f7 --- /dev/null +++ b/src/sampletones_core/audio/writers/selection.py @@ -0,0 +1,80 @@ +from pathlib import Path +from typing import Mapping, Tuple + +import soundfile + +from sampletones_shared.exceptions import UnsupportedAudioFormatError + +from .capability import capability_of +from .format import AudioDepth, AudioFormat +from .protocol import AudioWriter +from .soundfile import CONTAINERS, FIXED_SUBTYPES, SUBTYPES, SoundFileAudioWriter +from .spec import AudioOutputSpec + + +def available_audio_formats() -> Tuple[AudioFormat, ...]: + """The formats this installation writes, in the order a chooser offers them. + + libsndfile is built with a codec set that varies by platform and packaging, and the MP3 encoder + in particular is present only where it was compiled in. Asking the library what it holds keeps + a chooser honest about the machine it is running on. + + Returns: + Tuple[AudioFormat, ...]: The formats that can be written here. + """ + containers = soundfile.available_formats() + return tuple(audio_format for audio_format in AudioFormat if _is_writable(audio_format, containers)) + + +def available_depths(audio_format: AudioFormat) -> Tuple[AudioDepth, ...]: + """The depths this installation stores ``audio_format`` samples at, coarsest first. + + Args: + audio_format: The container to describe. + + Returns: + Tuple[AudioDepth, ...]: The depths the format declares that the encoder also writes; empty + for a format that sets its own and offers a bitrate instead. + """ + container = CONTAINERS[audio_format] + return tuple( + depth + for depth in capability_of(audio_format).depths + if soundfile.check_format( + container, + SUBTYPES[depth], + ) + ) + + +def open_audio_writer(path: Path, spec: AudioOutputSpec) -> AudioWriter: + """Opens a writer for ``path`` in the format ``spec`` states. + + The writer is a context manager: entering it opens the file and leaving it finalizes what was + written. + + Args: + path: Where the file is written. + spec: The format, rate, and quality it is written at. + + Returns: + AudioWriter: A writer ready to be entered. + + Raises: + UnsupportedAudioFormatError: If this installation does not write the requested format. + """ + if spec.audio_format not in available_audio_formats(): + raise UnsupportedAudioFormatError(f"This installation does not write {spec.audio_format} files") + + return SoundFileAudioWriter(path, spec) + + +def _is_writable(audio_format: AudioFormat, containers: Mapping[str, str]) -> bool: + container = CONTAINERS[audio_format] + if container not in containers: + return False + + if capability_of(audio_format).stores_samples: + return bool(available_depths(audio_format)) + + return bool(soundfile.check_format(container, FIXED_SUBTYPES[audio_format])) diff --git a/src/sampletones_core/audio/writers/soundfile.py b/src/sampletones_core/audio/writers/soundfile.py new file mode 100644 index 000000000..b1e0d633d --- /dev/null +++ b/src/sampletones_core/audio/writers/soundfile.py @@ -0,0 +1,116 @@ +from pathlib import Path +from types import TracebackType +from typing import Any, Dict, Final, Mapping, Optional, Self, Type + +import numpy as np +import soundfile + +from sampletones_shared.exceptions import AudioWriteError + +from .bitrate import mp3_compression_level +from .format import AudioDepth, AudioFormat +from .spec import AudioOutputSpec, Mp3OutputSpec, WaveOutputSpec + +CONTAINERS: Final[Mapping[AudioFormat, str]] = { + AudioFormat.WAVE: "WAV", + AudioFormat.MP3: "MP3", +} + +SUBTYPES: Final[Mapping[AudioDepth, str]] = { + AudioDepth.PCM_U8: "PCM_U8", + AudioDepth.PCM_16: "PCM_16", + AudioDepth.PCM_24: "PCM_24", + AudioDepth.PCM_32: "PCM_32", + AudioDepth.FLOAT_32: "FLOAT", +} + +MP3_SUBTYPE: Final[str] = "MPEG_LAYER_III" + +FIXED_SUBTYPES: Final[Mapping[AudioFormat, str]] = { + AudioFormat.MP3: MP3_SUBTYPE, +} + +CONSTANT_BITRATE_MODE: Final[str] = "CONSTANT" +WRITE_MODE: Final[str] = "w" +CHANNELS: Final[int] = 1 + + +def encoding_arguments(spec: AudioOutputSpec) -> Dict[str, Any]: + """The libsndfile settings that write ``spec``. + + This is where the encoder's vocabulary is spoken: eight-bit WAV is unsigned where the deeper + integer forms are signed, the float form is named for its width alone, and MP3 takes its + quality as a compression level rather than a bitrate. + + Args: + spec: The format, rate, and quality the file is written at. + + Returns: + Dict[str, Any]: Keyword arguments for opening a ``soundfile.SoundFile`` for writing. + """ + match spec: + case WaveOutputSpec(depth=depth): + return { + "format": CONTAINERS[AudioFormat.WAVE], + "subtype": SUBTYPES[depth], + } + case Mp3OutputSpec(sample_rate=sample_rate, bitrate=bitrate): + return { + "format": CONTAINERS[AudioFormat.MP3], + "subtype": MP3_SUBTYPE, + "bitrate_mode": CONSTANT_BITRATE_MODE, + "compression_level": mp3_compression_level(sample_rate, bitrate), + } + + +class SoundFileAudioWriter: + """Writes rendered audio to a file through libsndfile. + + Holds the file open for the length of a ``with`` block and appends each chunk as it arrives, + so a render streams to disk while it is being produced. + + Attributes: + path: Where the file is written. + spec: The format, rate, and quality it is written at. + """ + + def __init__(self, path: Path, spec: AudioOutputSpec) -> None: + self.path = path + self.spec = spec + self._file: Optional[soundfile.SoundFile] = None + + def __enter__(self) -> Self: + self._file = soundfile.SoundFile( + self.path, + mode=WRITE_MODE, + samplerate=self.spec.sample_rate, + channels=CHANNELS, + **encoding_arguments(self.spec), + ) + return self + + def __exit__( + self, + exception_type: Optional[Type[BaseException]], + exception: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + opened, self._file = self._file, None + if opened is not None: + opened.close() + + def write(self, chunk: np.ndarray) -> None: + """Appends one chunk of mono float32 audio to the file. + + Args: + chunk: The samples to append, in the range [-1, 1]. Values outside it are held at the + range's edge by the integer depths and kept as they stand by the float depth. + + Raises: + AudioWriteError: If the file is not open, which is to say the call is outside the + ``with`` block that owns it. + """ + if self._file is None: + raise AudioWriteError(f"No file open at '{self.path}'; write within the writer's context") + + self._file.write(chunk) diff --git a/src/sampletones_core/audio/writers/spec.py b/src/sampletones_core/audio/writers/spec.py new file mode 100644 index 000000000..574ea35a0 --- /dev/null +++ b/src/sampletones_core/audio/writers/spec.py @@ -0,0 +1,85 @@ +from typing import Literal, Self, Union + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from sampletones_core.constants.audio import MAX_SAMPLE_RATE, MIN_SAMPLE_RATE + +from .bitrate import default_mp3_bitrate, mp3_bitrates +from .capability import FormatCapability, capability_of +from .format import DEFAULT_AUDIO_DEPTH, AudioDepth, AudioFormat + + +class AudioOutputSpecBase(BaseModel): + """What every request to write audio states, whatever the container. + + The rate is checked against the format's capability on construction, so a specification that + exists is one the encoder accepts. + """ + + model_config = ConfigDict(extra="forbid", frozen=True) + + audio_format: AudioFormat = Field(..., description="The container the audio is written into.") + sample_rate: int = Field( + ..., + ge=MIN_SAMPLE_RATE, + le=MAX_SAMPLE_RATE, + description="The samples the written audio holds each second.", + ) + + @property + def capability(self) -> FormatCapability: + return capability_of(self.audio_format) + + @property + def extension(self) -> str: + return self.capability.extension + + @model_validator(mode="after") + def _validate_sample_rate(self) -> Self: + if not self.capability.supports_sample_rate(self.sample_rate): + raise ValueError(f"{self.audio_format} does not encode at {self.sample_rate} Hz") + + return self + + +class WaveOutputSpec(AudioOutputSpecBase): + """A WAV file, which stores each sample at a chosen depth.""" + + audio_format: Literal[AudioFormat.WAVE] = AudioFormat.WAVE + depth: AudioDepth = Field( + default=DEFAULT_AUDIO_DEPTH, + description="The form each stored sample takes.", + ) + + @model_validator(mode="after") + def _validate_depth(self) -> Self: + if not self.capability.supports_depth(self.depth): + raise ValueError(f"WAV does not store samples as {self.depth}") + + return self + + +class Mp3OutputSpec(AudioOutputSpecBase): + """An MP3 file, which encodes to a chosen bitrate rather than storing samples. + + The bitrates on offer depend on the sample rate, since each MPEG audio version defines its own + ladder, so the pair is validated together. + """ + + audio_format: Literal[AudioFormat.MP3] = AudioFormat.MP3 + bitrate: int = Field(..., description="The kilobits the encoded audio holds each second.") + + @classmethod + def at(cls, sample_rate: int) -> Self: + """A specification at ``sample_rate`` and the bitrate a render starts at there.""" + return cls(sample_rate=sample_rate, bitrate=default_mp3_bitrate(sample_rate)) + + @model_validator(mode="after") + def _validate_bitrate(self) -> Self: + if self.bitrate not in mp3_bitrates(self.sample_rate): + raise ValueError(f"MP3 at {self.sample_rate} Hz does not encode at {self.bitrate} kbps") + + return self + + +AudioOutputSpec = Union[WaveOutputSpec, Mp3OutputSpec] diff --git a/src/sampletones_core/configs/config.py b/src/sampletones_core/configs/config.py index e1a1c8f3d..5390791f6 100644 --- a/src/sampletones_core/configs/config.py +++ b/src/sampletones_core/configs/config.py @@ -1,7 +1,7 @@ from __future__ import annotations from pathlib import Path -from typing import List, Self +from typing import Dict, List, Optional, Self from pydantic import ConfigDict, Field @@ -63,6 +63,37 @@ def save(self, path: Pathlike) -> None: config_dict = self.model_dump() save_json(path, config_dict) + def with_library( + self, + *, + nes_frequency: Optional[int] = None, + sample_rate: Optional[int] = None, + ) -> Self: + """A copy running at the given engine and audio rates, keeping every other setting. + + The rates a generator is built with decide how many samples one engine tick spans, so a + caller driving the engine at rates of its own — a render at a chosen output rate, a + reconstruction retuned to a project's frequency — asks for a configuration here rather + than editing the one it was handed. + + Args: + nes_frequency: The engine ticks consumed each second, or ``None`` to keep the current + value. + sample_rate: The samples the audio holds each second, or ``None`` to keep the current + value. + + Returns: + Self: The configuration at those rates. + """ + updates: Dict[str, int] = {} + if nes_frequency is not None: + updates["nes_frequency"] = nes_frequency + + if sample_rate is not None: + updates["sample_rate"] = sample_rate + + return self.model_copy(update={"library": self.library.model_copy(update=updates)}) + @property def max_workers(self) -> int: return self.general.max_workers diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index addc2ce45..c4c882ce4 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -247,9 +247,7 @@ def with_nes_frequency(self, nes_frequency: int) -> Reconstruction: if self.config.nes_frequency == nes_frequency: return self - library = self.config.library.model_copy(update={"nes_frequency": nes_frequency}) - config = self.config.model_copy(update={"library": library}) - return self._resynthesized(config) + return self._resynthesized(self.config.with_library(nes_frequency=nes_frequency)) def _resynthesized(self, config: Config) -> Reconstruction: """Re-renders every generator's approximation from its instructions at ``config``. diff --git a/src/sampletones_shared/exceptions/__init__.py b/src/sampletones_shared/exceptions/__init__.py index 7dce22852..169cd46b2 100644 --- a/src/sampletones_shared/exceptions/__init__.py +++ b/src/sampletones_shared/exceptions/__init__.py @@ -1,4 +1,4 @@ -from .audio import PlaybackError, UnsupportedAudioFormatError +from .audio import AudioWriteError, PlaybackError, UnsupportedAudioFormatError from .base import SampleToNESError from .callback import CallbackQueueStop from .cuda import CuPyNotInstalledWarning @@ -42,6 +42,7 @@ from .window import WindowError, WindowNotAvailableError __all__ = [ + "AudioWriteError", "CallbackQueueStop", "CuPyNotInstalledWarning", "DeserializationError", diff --git a/src/sampletones_shared/exceptions/audio.py b/src/sampletones_shared/exceptions/audio.py index 86d14e878..c7ef79698 100644 --- a/src/sampletones_shared/exceptions/audio.py +++ b/src/sampletones_shared/exceptions/audio.py @@ -11,3 +11,7 @@ class UnsupportedAudioFormatError(AudioError): class PlaybackError(AudioError): """Base class for exceptions raised during playback.""" + + +class AudioWriteError(AudioError): + """Exception raised when audio cannot be written to a file.""" diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py index 9c8eac416..5548b6beb 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import FrozenSet +from typing import Callable, FrozenSet import numpy as np import pytest @@ -9,6 +9,7 @@ from sampletones_application.logic.sequencer.channels import ALL_CHANNELS from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config +from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE from sampletones_core.constants.enums import GeneratorName from sampletones_core.instructions import ( NoiseInstruction, @@ -30,6 +31,22 @@ def all_channels() -> FrozenSet[GeneratorName]: return ALL_CHANNELS +def make_synthesizer( + controller: ProjectController, + config: Config, + *, + sample_rate: int = DEFAULT_SAMPLE_RATE, + active_channels: Callable[[], FrozenSet[GeneratorName]] = all_channels, +) -> RowSynthesizer: + """A synthesiser rendering at ``sample_rate``, standing in for the output a caller supplies.""" + return RowSynthesizer( + controller, + config, + active_channels=active_channels, + sample_rate=lambda: sample_rate, + ) + + def make_pulse_reconstruction( *, pitch: int = 60, @@ -156,4 +173,4 @@ def controller() -> ProjectController: @pytest.fixture def synthesizer(controller: ProjectController, config: Config) -> RowSynthesizer: - return RowSynthesizer(controller, config, active_channels=all_channels) + return make_synthesizer(controller, config) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py index 1283c04a4..5d34066fd 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -1,5 +1,5 @@ from dataclasses import dataclass, field -from typing import Dict, FrozenSet, List, Optional, Tuple +from typing import Dict, Final, FrozenSet, List, Optional, Tuple import numpy as np @@ -11,20 +11,23 @@ from sampletones_application.logic.sequencer.channels import ALL_CHANNELS from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config +from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.timing import Metre, RowRate, calculate_groove from tests.suite.scenario import BaseTestScenario, ScenarioStep from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( add_sample, - all_channels, make_controller, make_pulse_reconstruction, + make_synthesizer, place_modifier_row, place_note_off, place_row, ) +SAMPLE_RATE: Final[int] = DEFAULT_SAMPLE_RATE + class MaskProvider: """A channel mask a test moves between rows, standing in for the channels logic.""" @@ -54,7 +57,7 @@ def _make_context() -> SynthesizerContext: controller = make_controller() mask = MaskProvider() return SynthesizerContext( - synthesizer=RowSynthesizer(controller, Config(), active_channels=mask), + synthesizer=make_synthesizer(controller, Config(), active_channels=mask), mask=mask, ) @@ -327,7 +330,7 @@ def mute_pulse1(context: SynthesizerContext) -> None: def render_and_compare_against_unmasked(context: SynthesizerContext) -> None: audio_masked = _render(context) - audible_synthesizer = RowSynthesizer(_controller(context), Config(), active_channels=all_channels) + audible_synthesizer = make_synthesizer(_controller(context), Config()) audio_with_pulse1, _ = audible_synthesizer.render_row() assert np.allclose(audio_masked, 0.0) @@ -816,10 +819,10 @@ def test_tempo_is_independent_of_nes_frequency(self) -> None: def pattern_duration_seconds(nes_frequency: int) -> float: controller = make_controller() controller.set_nes_frequency(nes_frequency) - synthesizer = RowSynthesizer(controller, Config(), active_channels=all_channels) + synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE) rows = controller.project.song.rows_per_pattern total_samples = sum(len(synthesizer.render_row()[0]) for _ in range(rows)) - return total_samples / controller.project.settings.sample_rate + return total_samples / SAMPLE_RATE assert abs(pattern_duration_seconds(60) - pattern_duration_seconds(30)) < 0.1 @@ -831,16 +834,15 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non recon = make_pulse_reconstruction(count=12) sample = add_sample(controller, recon) place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) - synthesizer = RowSynthesizer(controller, Config(), active_channels=all_channels) - sample_rate = controller.project.settings.sample_rate + synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE) pulse_state = synthesizer._channel_states[GeneratorName.PULSE1] controller.set_nes_frequency(60) synthesizer.render_row() - assert pulse_state.generator.frame_length == round(sample_rate / 60) + assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 60) controller.set_nes_frequency(30) synthesizer.render_row() - assert pulse_state.generator.frame_length == round(sample_rate / 30) + assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 30) assert pulse_state.sample_id is not None diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py index ae9cf7c11..c5c2cdf3c 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -16,6 +16,7 @@ all_channels, make_controller, make_pulse_reconstruction, + make_synthesizer, place_row, ) @@ -24,11 +25,6 @@ UNEVEN_RATES: Final[Tuple[int, ...]] = (8000, 16000, 22050) -def _config(sample_rate: int) -> Config: - config = Config() - return config.model_copy(update={"library": config.library.model_copy(update={"sample_rate": sample_rate})}) - - def _expected_ticks(controller: ProjectController) -> Tuple[int, ...]: settings = controller.project.settings return calculate_groove( @@ -45,7 +41,7 @@ class TestRowsFollowTheTickClock(BaseTestSuite): @pytest.mark.parametrize("sample_rate", UNEVEN_RATES + (EVEN_SAMPLE_RATE, 48000)) def test_a_pattern_spans_its_exact_duration(self, sample_rate: int) -> None: controller = make_controller() - synthesizer = RowSynthesizer(controller, _config(sample_rate), active_channels=all_channels) + synthesizer = make_synthesizer(controller, Config(), sample_rate=sample_rate) ticks = _expected_ticks(controller) rendered = sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks))) @@ -60,7 +56,7 @@ def test_a_pattern_spans_its_exact_duration(self, sample_rate: int) -> None: def test_a_long_run_does_not_drift(self, sample_rate: int) -> None: """The property a fixed rounded frame length loses: the error stays below one sample.""" controller = make_controller() - synthesizer = RowSynthesizer(controller, _config(sample_rate), active_channels=all_channels) + synthesizer = make_synthesizer(controller, Config(), sample_rate=sample_rate) ticks = _expected_ticks(controller) patterns = 40 @@ -74,11 +70,7 @@ def test_a_long_run_does_not_drift(self, sample_rate: int) -> None: def test_a_row_spans_the_sum_of_its_ticks(self) -> None: controller = make_controller() - synthesizer = RowSynthesizer( - controller, - _config(UNEVEN_SAMPLE_RATE), - active_channels=all_channels, - ) + synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) clock = TickClock.from_parameters( sample_rate=UNEVEN_SAMPLE_RATE, nes_frequency=controller.project.settings.nes_frequency, @@ -100,21 +92,13 @@ def test_rows_vary_in_length_where_their_ticks_straddle_a_sample(self) -> None: """ controller = make_controller() controller.set_speed(5) - synthesizer = RowSynthesizer( - controller, - _config(UNEVEN_SAMPLE_RATE), - active_channels=all_channels, - ) + synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) lengths = {len(synthesizer.render_row()[0]) for _ in range(len(_expected_ticks(controller)))} assert lengths == {1837, 1838} def test_reset_returns_the_clock_to_the_first_tick(self) -> None: controller = make_controller() - synthesizer = RowSynthesizer( - controller, - _config(UNEVEN_SAMPLE_RATE), - active_channels=all_channels, - ) + synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) first = len(synthesizer.render_row()[0]) synthesizer.set_position(0, 0) @@ -124,7 +108,7 @@ def test_reset_returns_the_clock_to_the_first_tick(self) -> None: def test_a_frequency_change_rebuilds_the_clock(self) -> None: controller = make_controller() - synthesizer = RowSynthesizer(controller, _config(EVEN_SAMPLE_RATE), active_channels=all_channels) + synthesizer = make_synthesizer(controller, Config(), sample_rate=EVEN_SAMPLE_RATE) controller.set_nes_frequency(60) synthesizer.render_row() @@ -138,6 +122,45 @@ def test_a_frequency_change_rebuilds_the_clock(self) -> None: assert rendered == clock.samples_at(sum(ticks)) +class TestTheOutputRateIsFollowed(BaseTestSuite): + """The audio is rendered at the rate its consumer reports, so a rendered second lasts a second. + + Live playback opens its device stream at that rate and a render writes its file at it, so a + synthesiser fixed to some other rate plays the song at the ratio between the two. + """ + + @pytest.mark.parametrize("sample_rate", UNEVEN_RATES + (EVEN_SAMPLE_RATE, 48000)) + def test_a_pattern_lasts_the_seconds_its_ticks_last(self, sample_rate: int) -> None: + controller = make_controller() + synthesizer = make_synthesizer(controller, Config(), sample_rate=sample_rate) + ticks = _expected_ticks(controller) + + rendered = sum(len(synthesizer.render_row()[0]) for _ in range(len(ticks))) + expected = Fraction(sum(ticks), controller.project.settings.nes_frequency) + + assert abs(Fraction(rendered, sample_rate) - expected) < Fraction(1, sample_rate) + + def test_a_rate_change_is_picked_up_on_the_next_row(self) -> None: + """Selecting another output device rate re-times the audio rather than the song.""" + controller = make_controller() + rates = [EVEN_SAMPLE_RATE] + synthesizer = RowSynthesizer( + controller, + Config(), + active_channels=all_channels, + sample_rate=lambda: rates[0], + ) + at_even = len(synthesizer.render_row()[0]) + + rates[0] = UNEVEN_SAMPLE_RATE + synthesizer.set_position(0, 0) + synthesizer.reset() + at_uneven = len(synthesizer.render_row()[0]) + + difference = abs(Fraction(at_even, EVEN_SAMPLE_RATE) - Fraction(at_uneven, UNEVEN_SAMPLE_RATE)) + assert difference < Fraction(1, UNEVEN_SAMPLE_RATE) + + class TestChannelsFillTheRow(BaseTestSuite): """Every channel writes into the same tick boundaries, so a mix never leaves a gap.""" @@ -146,11 +169,7 @@ def test_a_sounding_channel_fills_every_tick(self) -> None: reconstruction = make_pulse_reconstruction(count=1) sample = add_sample(controller, reconstruction, loop=True) place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) - synthesizer = RowSynthesizer( - controller, - _config(UNEVEN_SAMPLE_RATE), - active_channels=all_channels, - ) + synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) chunk, _ = synthesizer.render_row() @@ -163,11 +182,7 @@ def test_a_sounding_note_stays_continuous_across_a_tick_length_change(self) -> N reconstruction = make_pulse_reconstruction(count=1) sample = add_sample(controller, reconstruction, loop=True) place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) - synthesizer = RowSynthesizer( - controller, - _config(UNEVEN_SAMPLE_RATE), - active_channels=all_channels, - ) + synthesizer = make_synthesizer(controller, Config(), sample_rate=UNEVEN_SAMPLE_RATE) chunk, _ = synthesizer.render_row() steps = np.abs(np.diff(chunk)) diff --git a/tests/unit/sampletones_core/audio/writers/__init__.py b/tests/unit/sampletones_core/audio/writers/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_core/audio/writers/test_spec.py b/tests/unit/sampletones_core/audio/writers/test_spec.py new file mode 100644 index 000000000..cf3961be4 --- /dev/null +++ b/tests/unit/sampletones_core/audio/writers/test_spec.py @@ -0,0 +1,107 @@ +from typing import Final + +import pytest +from pydantic import ValidationError + +from sampletones_core.audio.writers import ( + AUDIO_DEPTHS, + MP3_SAMPLE_RATES, + AudioDepth, + AudioFormat, + Mp3OutputSpec, + WaveOutputSpec, + capability_of, + default_mp3_bitrate, + mp3_bitrates, +) +from sampletones_core.constants.audio import SAMPLE_RATES +from sampletones_core.paths import EXT_FILE_MP3, EXT_FILE_WAVE +from tests.suite.base import BaseTestSuite + +MPEG_1_RATE: Final[int] = 44100 +MPEG_2_RATE: Final[int] = 22050 +MPEG_2_5_RATE: Final[int] = 8000 + + +class TestWaveOutputSpec(BaseTestSuite): + @pytest.mark.parametrize("sample_rate", SAMPLE_RATES) + @pytest.mark.parametrize("depth", AUDIO_DEPTHS) + def test_every_rate_and_depth_is_accepted(self, sample_rate: int, depth: AudioDepth) -> None: + spec = WaveOutputSpec(sample_rate=sample_rate, depth=depth) + + assert spec.audio_format is AudioFormat.WAVE + assert spec.sample_rate == sample_rate + assert spec.depth is depth + + def test_the_extension_comes_from_the_capability(self) -> None: + assert WaveOutputSpec(sample_rate=MPEG_1_RATE).extension == EXT_FILE_WAVE + + def test_a_rate_outside_the_offered_set_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="does not encode at 44101 Hz"): + WaveOutputSpec(sample_rate=44101) + + def test_a_specification_is_frozen(self) -> None: + spec = WaveOutputSpec(sample_rate=MPEG_1_RATE) + + with pytest.raises(ValidationError): + spec.sample_rate = MPEG_2_RATE + + +class TestMp3OutputSpec(BaseTestSuite): + @pytest.mark.parametrize("sample_rate", MP3_SAMPLE_RATES) + def test_every_bitrate_on_the_ladder_is_accepted(self, sample_rate: int) -> None: + for bitrate in mp3_bitrates(sample_rate): + spec = Mp3OutputSpec(sample_rate=sample_rate, bitrate=bitrate) + + assert spec.audio_format is AudioFormat.MP3 + assert spec.bitrate == bitrate + + def test_the_extension_comes_from_the_capability(self) -> None: + assert Mp3OutputSpec.at(MPEG_1_RATE).extension == EXT_FILE_MP3 + + @pytest.mark.parametrize("sample_rate", (96000, 192000)) + def test_a_rate_the_encoder_rejects_is_rejected_here(self, sample_rate: int) -> None: + """MPEG audio defines its sample rates, and 96 kHz is not among them.""" + with pytest.raises(ValidationError, match="does not encode at"): + Mp3OutputSpec(sample_rate=sample_rate, bitrate=192) + + def test_a_bitrate_above_the_rate_s_ladder_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="does not encode at 320 kbps"): + Mp3OutputSpec(sample_rate=MPEG_2_RATE, bitrate=320) + + def test_a_bitrate_off_the_ladder_is_rejected(self) -> None: + with pytest.raises(ValidationError, match="does not encode at 200 kbps"): + Mp3OutputSpec(sample_rate=MPEG_1_RATE, bitrate=200) + + @pytest.mark.parametrize( + ("sample_rate", "expected"), + ( + (MPEG_1_RATE, 192), + (48000, 192), + (MPEG_2_RATE, 160), + (16000, 160), + (MPEG_2_5_RATE, 64), + ), + ) + def test_the_default_bitrate_is_the_best_the_rate_reaches(self, sample_rate: int, expected: int) -> None: + assert default_mp3_bitrate(sample_rate) == expected + assert Mp3OutputSpec.at(sample_rate).bitrate == expected + + +class TestFormatCapabilities(BaseTestSuite): + def test_wave_stores_samples_and_mp3_does_not(self) -> None: + assert capability_of(AudioFormat.WAVE).stores_samples + assert not capability_of(AudioFormat.MP3).stores_samples + + def test_the_mp3_rates_are_the_rates_a_ladder_is_declared_for(self) -> None: + assert capability_of(AudioFormat.MP3).sample_rates == MP3_SAMPLE_RATES + assert all(mp3_bitrates(sample_rate) for sample_rate in MP3_SAMPLE_RATES) + + def test_the_ladders_run_from_highest_to_lowest(self) -> None: + for sample_rate in MP3_SAMPLE_RATES: + bitrates = mp3_bitrates(sample_rate) + + assert list(bitrates) == sorted(bitrates, reverse=True) + + def test_the_wave_rates_are_the_rates_the_application_offers(self) -> None: + assert capability_of(AudioFormat.WAVE).sample_rates == tuple(SAMPLE_RATES) diff --git a/tests/unit/sampletones_core/audio/writers/test_writer.py b/tests/unit/sampletones_core/audio/writers/test_writer.py new file mode 100644 index 000000000..1a3b58eec --- /dev/null +++ b/tests/unit/sampletones_core/audio/writers/test_writer.py @@ -0,0 +1,174 @@ +from pathlib import Path +from typing import Dict, Final, Tuple + +import numpy as np +import pytest +import soundfile + +from sampletones_core.audio.writers import ( + AUDIO_DEPTHS, + MP3_SAMPLE_RATES, + AudioDepth, + AudioFormat, + AudioOutputSpec, + Mp3OutputSpec, + WaveOutputSpec, + available_audio_formats, + available_depths, + open_audio_writer, +) +from sampletones_shared.exceptions import AudioWriteError +from tests.suite.base import BaseTestSuite + +SAMPLE_RATE: Final[int] = 44100 +SECONDS: Final[float] = 1.0 +CHUNK: Final[int] = 367 +TONE_FREQUENCY: Final[float] = 440.0 +DEPTH_TOLERANCES: Final[Dict[AudioDepth, float]] = { + AudioDepth.PCM_U8: 1.0 / 128, + AudioDepth.PCM_16: 1.0 / 32768, + AudioDepth.PCM_24: 1.0 / 8388608, + AudioDepth.PCM_32: 1.0 / 8388608, + AudioDepth.FLOAT_32: 1e-6, +} + + +def _tone(sample_rate: int, seconds: float = SECONDS) -> np.ndarray: + samples = int(sample_rate * seconds) + return (0.5 * np.sin(2 * np.pi * TONE_FREQUENCY * np.arange(samples) / sample_rate)).astype(np.float32) + + +def _chunks(audio: np.ndarray, size: int = CHUNK) -> Tuple[np.ndarray, ...]: + return tuple(audio[offset : offset + size] for offset in range(0, len(audio), size)) + + +def _write(path: Path, spec: AudioOutputSpec, audio: np.ndarray) -> None: + with open_audio_writer(path, spec) as writer: + for chunk in _chunks(audio): + writer.write(chunk) + + +class TestTheEncoderIsProbed(BaseTestSuite): + """What the registry declares is offered only where the installed encoder also writes it.""" + + def test_wave_is_always_available(self) -> None: + assert AudioFormat.WAVE in available_audio_formats() + + def test_the_offered_depths_are_the_declared_ones_the_encoder_writes(self) -> None: + assert set(available_depths(AudioFormat.WAVE)) <= set(AUDIO_DEPTHS) + + def test_a_format_that_sets_its_own_depth_offers_none(self) -> None: + assert available_depths(AudioFormat.MP3) == () + + +class TestWaveRoundTrip(BaseTestSuite): + """Audio written a chunk at a time reads back whole, at the depth it was asked for.""" + + @pytest.mark.parametrize("depth", AUDIO_DEPTHS) + def test_a_render_reads_back_at_its_depth(self, tmp_path: Path, depth: AudioDepth) -> None: + audio = _tone(SAMPLE_RATE) + path = tmp_path / f"render{WaveOutputSpec(sample_rate=SAMPLE_RATE).extension}" + + _write(path, WaveOutputSpec(sample_rate=SAMPLE_RATE, depth=depth), audio) + restored, sample_rate = soundfile.read(path, dtype="float32") + + assert sample_rate == SAMPLE_RATE + assert len(restored) == len(audio) + assert float(np.abs(restored - audio).max()) <= DEPTH_TOLERANCES[depth] + + @pytest.mark.parametrize("sample_rate", (8000, 22050, 48000, 96000, 192000)) + def test_every_offered_rate_is_written(self, tmp_path: Path, sample_rate: int) -> None: + audio = _tone(sample_rate, seconds=0.1) + path = tmp_path / "render.wav" + + _write(path, WaveOutputSpec(sample_rate=sample_rate), audio) + info = soundfile.info(path) + + assert info.samplerate == sample_rate + assert info.frames == len(audio) + + def test_chunks_of_differing_lengths_are_written_whole(self, tmp_path: Path) -> None: + """A row varies in length where the tick clock spreads a fraction, so chunks do too.""" + path = tmp_path / "render.wav" + lengths = (367, 368, 367, 1, 4096, 12) + audio = _tone(SAMPLE_RATE) + + offset = 0 + with open_audio_writer(path, WaveOutputSpec(sample_rate=SAMPLE_RATE)) as writer: + for length in lengths: + writer.write(audio[offset : offset + length]) + offset += length + + assert soundfile.info(path).frames == sum(lengths) + + def test_a_finished_file_stands_on_its_own(self, tmp_path: Path) -> None: + path = tmp_path / "render.wav" + + _write(path, WaveOutputSpec(sample_rate=SAMPLE_RATE), _tone(SAMPLE_RATE)) + + assert path.exists() + assert path.stat().st_size > 0 + + +class TestMp3RoundTrip(BaseTestSuite): + @pytest.mark.parametrize("sample_rate", MP3_SAMPLE_RATES) + def test_a_render_reads_back_at_its_rate(self, tmp_path: Path, sample_rate: int) -> None: + audio = _tone(sample_rate) + path = tmp_path / "render.mp3" + + _write(path, Mp3OutputSpec.at(sample_rate), audio) + info = soundfile.info(path) + + assert info.samplerate == sample_rate + assert info.frames == len(audio) + + @pytest.mark.parametrize("bitrate", (320, 192, 128, 64)) + def test_the_encoded_rate_follows_the_chosen_bitrate(self, tmp_path: Path, bitrate: int) -> None: + """The ladder is what makes a bitrate choice mean something, so it is measured.""" + seconds = 8.0 + audio = _tone(SAMPLE_RATE, seconds=seconds) + path = tmp_path / "render.mp3" + + _write(path, Mp3OutputSpec(sample_rate=SAMPLE_RATE, bitrate=bitrate), audio) + measured = path.stat().st_size * 8 / seconds / 1000 + + assert abs(measured - bitrate) < 0.05 * bitrate + + def test_a_higher_bitrate_makes_a_larger_file(self, tmp_path: Path) -> None: + audio = _tone(SAMPLE_RATE, seconds=4.0) + sizes = [] + for bitrate in (64, 128, 320): + path = tmp_path / f"render_{bitrate}.mp3" + _write(path, Mp3OutputSpec(sample_rate=SAMPLE_RATE, bitrate=bitrate), audio) + sizes.append(path.stat().st_size) + + assert sizes == sorted(sizes) + + +class TestTheWriterOwnsItsFile(BaseTestSuite): + def test_writing_outside_the_block_is_refused(self, tmp_path: Path) -> None: + writer = open_audio_writer(tmp_path / "render.wav", WaveOutputSpec(sample_rate=SAMPLE_RATE)) + + with pytest.raises(AudioWriteError, match="write within the writer's context"): + writer.write(_tone(SAMPLE_RATE, seconds=0.01)) + + def test_writing_after_the_block_is_refused(self, tmp_path: Path) -> None: + path = tmp_path / "render.wav" + with open_audio_writer(path, WaveOutputSpec(sample_rate=SAMPLE_RATE)) as writer: + writer.write(_tone(SAMPLE_RATE, seconds=0.01)) + + with pytest.raises(AudioWriteError, match="write within the writer's context"): + writer.write(_tone(SAMPLE_RATE, seconds=0.01)) + + def test_a_render_interrupted_partway_leaves_a_readable_file(self, tmp_path: Path) -> None: + """A cancel leaves the file finalized, so the caller decides whether to keep it.""" + path = tmp_path / "render.wav" + audio = _tone(SAMPLE_RATE) + written = 0 + + with open_audio_writer(path, WaveOutputSpec(sample_rate=SAMPLE_RATE)) as writer: + for chunk in _chunks(audio)[:10]: + writer.write(chunk) + written += len(chunk) + + assert soundfile.info(path).frames == written diff --git a/uv.lock b/uv.lock index 0098df28c..d8dc1be31 100644 --- a/uv.lock +++ b/uv.lock @@ -1735,6 +1735,7 @@ dependencies = [ { name = "rich" }, { name = "scipy" }, { name = "screeninfo" }, + { name = "soundfile" }, { name = "tqdm" }, ] @@ -1789,6 +1790,7 @@ requires-dist = [ { name = "rich", specifier = ">=13.0,<16" }, { name = "scipy", specifier = ">=1.13,<2" }, { name = "screeninfo", specifier = ">=0.8,<0.9" }, + { name = "soundfile", specifier = ">=0.13,<0.14" }, { name = "tqdm", specifier = ">=4.66,<5" }, ] provides-extras = ["build", "gpu", "gpu-cuda11"] From c6c57f12a6a935883f59281d6c1c18ade18d5f4f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 00:13:46 +0200 Subject: [PATCH 059/152] Added: song render service --- THIRD-PARTY-NOTICES.md | 16 +- docs/development/playback.md | 4 +- scripts/checks/import_boundary.py | 1 + .../logic/sequencer/playback/synthesizer.py | 474 ------------------ .../playback/synthesizer/__init__.py | 17 + .../sequencer/playback/synthesizer/bank.py | 86 ++++ .../sequencer/playback/synthesizer/frames.py | 47 ++ .../playback/synthesizer/modifiers.py | 43 ++ .../sequencer/playback/synthesizer/rates.py | 38 ++ .../sequencer/playback/synthesizer/state.py | 41 ++ .../playback/synthesizer/synthesizer.py | 284 +++++++++++ .../sequencer/playback/synthesizer/timing.py | 47 ++ .../services/__init__.py | 10 + .../services/render/__init__.py | 18 + .../services/render/constants.py | 5 + .../services/render/progress.py | 49 ++ .../services/render/result.py | 31 ++ .../services/render/scratch.py | 86 ++++ .../services/render/service.py | 167 ++++++ .../services/render/sink.py | 194 +++++++ .../services/song_player/player.py | 2 +- .../services/song_player/protocol.py | 30 -- .../services/synthesis/__init__.py | 5 + .../services/synthesis/protocol.py | 33 ++ src/sampletones_core/audio/__init__.py | 2 + src/sampletones_core/audio/processing.py | 13 + .../playback/test_apply_modifiers.py | 12 +- .../sequencer/playback/test_synthesizer.py | 4 +- .../services/render/__init__.py | 0 .../services/render/conftest.py | 79 +++ .../services/render/test_service.py | 270 ++++++++++ .../services/render/test_sink.py | 158 ++++++ 32 files changed, 1746 insertions(+), 520 deletions(-) delete mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/frames.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/state.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py create mode 100644 src/sampletones_application/services/render/__init__.py create mode 100644 src/sampletones_application/services/render/constants.py create mode 100644 src/sampletones_application/services/render/progress.py create mode 100644 src/sampletones_application/services/render/result.py create mode 100644 src/sampletones_application/services/render/scratch.py create mode 100644 src/sampletones_application/services/render/service.py create mode 100644 src/sampletones_application/services/render/sink.py delete mode 100644 src/sampletones_application/services/song_player/protocol.py create mode 100644 src/sampletones_application/services/synthesis/__init__.py create mode 100644 src/sampletones_application/services/synthesis/protocol.py create mode 100644 tests/unit/sampletones_application/services/render/__init__.py create mode 100644 tests/unit/sampletones_application/services/render/conftest.py create mode 100644 tests/unit/sampletones_application/services/render/test_service.py create mode 100644 tests/unit/sampletones_application/services/render/test_sink.py diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 5e6f29fb4..65d37c0fe 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -35,12 +35,16 @@ are not used in any SampleToNES component name. Every dependency is installed separately by `pip`/`uv` from PyPI and imported at runtime. -Most dependencies are permissively licensed (MIT, BSD, Apache-2.0, ISC). Two are under the -GNU Lesser General Public License — [Pebble](https://pypi.org/project/Pebble/) (LGPL-3.0, -a direct dependency) and [soxr](https://pypi.org/project/soxr/) (LGPL-2.1-or-later, a -transitive dependency of `librosa`) — and two, `certifi` and `tqdm`, are under MPL-2.0. - -All four are used as unmodified, separately installed libraries loaded dynamically at +Most dependencies are permissively licensed (MIT, BSD, Apache-2.0, ISC). Three carry code +under the GNU Lesser General Public License — [Pebble](https://pypi.org/project/Pebble/) +(LGPL-3.0, a direct dependency), [soxr](https://pypi.org/project/soxr/) +(LGPL-2.1-or-later, a transitive dependency of `librosa`), and +[soundfile](https://pypi.org/project/soundfile/) (BSD-3-Clause itself, a direct +dependency, whose wheel carries the libsndfile shared library under LGPL-2.1-or-later with +LAME and mpg123 statically linked into it) — and two, `certifi` and `tqdm`, are under +MPL-2.0. + +All of them are used as unmodified, separately installed libraries loaded dynamically at import time. No LGPL- or MPL-licensed code is copied into the wheel or the sdist, so the MIT License applies to the PyPI package without further obligation. diff --git a/docs/development/playback.md b/docs/development/playback.md index 165312e08..96892f3fd 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -191,8 +191,10 @@ terminating would reclaim. | The reach the sequencer view follows the playhead at | `FollowMode` (`constants/playback.py`), held by `SongPlayerLogic` (`logic/sequencer/playback/song_player.py`) | | Where the playhead stands, and both grids' marks for it | `SequencerTabCoordinator` (`coordinators/tabs/sequencer.py`) | | Marking and revealing the sounding row in the tracker | `GUISequencerTrackerPanel` (`ui/panels/sequencer/tracker.py`) | -| Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer.py`) | +| Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) | +| The channel generators and the rates they are built at | `ChannelBank` (`logic/sequencer/playback/synthesizer/bank.py`) | | How long each row of a pattern lasts | `Groove` (`sampletones_core/timing/`), indexed by row while rendering | +| How many samples one of that row's ticks spans | `TickClock` (`sampletones_core/timing/`), followed by `EngineRates` | | The song's render-ahead buffer | `services/song_player/` | The sequencer song is an ordinary intentional source alongside the reconstruction and instruction diff --git a/scripts/checks/import_boundary.py b/scripts/checks/import_boundary.py index f373fb8ed..14c61f15a 100755 --- a/scripts/checks/import_boundary.py +++ b/scripts/checks/import_boundary.py @@ -39,6 +39,7 @@ SERVICE_CONTRACTS = [ "sampletones_application.services.result", + "sampletones_application.services.render.result", "sampletones_application.services.song_player.result", ] diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer.py deleted file mode 100644 index 706d2d692..000000000 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer.py +++ /dev/null @@ -1,474 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass, field, replace -from itertools import accumulate -from typing import Callable, Dict, FrozenSet, List, Optional, Tuple - -import numpy as np - -from sampletones_application.constants.playback import ( - MAX_TICKS_PER_ROW, - MIN_TICKS_PER_ROW, -) -from sampletones_application.logic.project.controller import ProjectController -from sampletones_core.audio import clip_audio_inplace -from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName -from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH -from sampletones_core.generators.maps import GENERATOR_CLASSES -from sampletones_core.instructions import ( - InstructionUnion, - NoiseInstruction, - PulseInstruction, - TriangleInstruction, -) -from sampletones_core.project import Project -from sampletones_core.project.instruments.instrument import Instrument -from sampletones_core.project.instruments.note_off import NoteOff -from sampletones_core.project.patterns.row import Row -from sampletones_core.project.song import Song -from sampletones_core.project.song_position import SongPosition -from sampletones_core.timing import Groove, Metre, RowRate, TickClock, calculate_groove - -from .protocol import ChannelGeneratorProtocol - - -@dataclass -class _ChannelState: - generator: ChannelGeneratorProtocol - sample_id: Optional[str] = field(default=None) - tick_index: int = field(default=0) - transpose: int = field(default=0) - volume: int = field(default=MAX_VOLUME) - - -@dataclass(frozen=True) -class _SongTiming: - """Everything a project's groove is built from, held together so a change is one comparison. - - Attributes: - rate: The exact ticks one row lasts under the project's tempo, speed and tick rate. - metre: The pattern length and the beat and bar grouping the ticks are spread over. - """ - - rate: RowRate - metre: Metre - - @classmethod - def from_project(cls, project: Project) -> _SongTiming: - """Reads the timing a project plays at, taking the pattern length from its song.""" - return cls( - rate=RowRate.from_settings(project.settings), - metre=Metre.from_settings( - project.settings, - rows=project.song.rows_per_pattern, - ), - ) - - def groove(self) -> Groove: - """Spreads the row rate across a pattern's rows. - - Playback follows whatever tempo the project states, so the one bound it sets is that - every row lasts at least a tick and keeps sounding; the ceiling is the fastest row the - settings can ask for, which leaves the groove free to realize the rate exactly. - """ - return calculate_groove( - self.rate, - self.metre, - minimum_ticks=MIN_TICKS_PER_ROW, - maximum_ticks=MAX_TICKS_PER_ROW, - ) - - -@dataclass(frozen=True) -class _EngineRates: - """The pair of rates a tick is sized from, held together so a change is one comparison. - - Each rate is owned elsewhere: the project states how many instructions the engine consumes - each second, and whoever takes the audio states the rate it is rendered at — the output - device for playback, the chosen format for a file. Together they fix how many samples one - tick spans, so the synthesiser follows both. - - Attributes: - nes_frequency: The engine ticks consumed each second. - sample_rate: The samples the rendered audio holds each second. - """ - - nes_frequency: int - sample_rate: int - - def clock(self) -> TickClock: - """The samples each tick spans under this pair of rates.""" - return TickClock.from_parameters( - sample_rate=self.sample_rate, - nes_frequency=self.nes_frequency, - ) - - -@dataclass(frozen=True) -class _RowFrames: - """Where each of a row's ticks starts and ends within the row's audio. - - A tick clock gives consecutive ticks whole sample counts that sum to their exact span, so the - lengths within one row vary where the sample rate does not divide the tick rate. Resolving the - boundaries once per row is what lets every channel write into the same offsets. - - Attributes: - lengths: The samples each of the row's ticks spans, in order. - bounds: Each tick's start offset, ending with the row's total length. - """ - - lengths: Tuple[int, ...] - bounds: Tuple[int, ...] - - @classmethod - def from_clock( - cls, - clock: TickClock, - *, - elapsed_ticks: int, - ticks: int, - ) -> _RowFrames: - """Resolves the row starting at ``elapsed_ticks`` and spanning ``ticks`` ticks.""" - lengths = tuple(clock.frame_length(elapsed_ticks + tick) for tick in range(ticks)) - return cls( - lengths=lengths, - bounds=tuple(accumulate(lengths, initial=0)), - ) - - @property - def total(self) -> int: - """The samples the whole row spans.""" - return self.bounds[-1] - - @property - def longest(self) -> int: - """The samples the row's longest tick spans.""" - return max(self.lengths, default=0) - - -def _silence(samples: int) -> np.ndarray: - return np.zeros(samples, dtype=np.float32) - - -def _apply_modifiers( - instruction: InstructionUnion, - transpose: int, - row_volume: int, -) -> InstructionUnion: - match instruction: - case PulseInstruction(): - scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME))) - effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose)) - return instruction.model_copy(update={"pitch": effective_pitch, "volume": scaled_volume}) - case TriangleInstruction(): - effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose)) - on = instruction.on and row_volume > MAX_VOLUME // 2 - return instruction.model_copy(update={"pitch": effective_pitch, "on": on}) - case NoiseInstruction(): - scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME))) - effective_period = (instruction.period + transpose) % 16 - return instruction.model_copy(update={"period": effective_period, "volume": scaled_volume}) - - -class RowSynthesizer: - """Real-time synthesis engine for tracker song playback. - - Reads the live ``Project`` from ``project_controller`` on every ``render_row`` - call so that pattern edits, tempo changes, and sample swaps take effect - immediately while playback keeps running. - - A row lasts the ticks the project's groove gives its position within the pattern, so the - row a pattern's tenth row plays for is the row an exported module plays it for: both index - the same groove from the pattern's first row. - - Each of those ticks spans the samples the :class:`~sampletones_core.timing.clock.TickClock` - gives its position in the run, so a tick lasts ``1 / nes_frequency`` seconds at every sample - rate and the groove's tempo is the tempo heard. - - ``sample_rate`` reports the rate the audio is rendered at, and is what the caller taking that - audio runs at: the output device for live playback, the chosen format for a file. Reading it - per row keeps the two in step, so a rendered second is a second wherever the audio goes. - - Generators are constructed from ``config`` at the rates in force and carry timer - state across rows for phase continuity within a sustained note. Triggering a new - note calls ``generator.reset()`` for a clean phase start. - - ``active_channels`` reports which channels sound and is consulted once per channel per - row, so muting or unmuting during playback is heard as the render-ahead buffer drains. A - silenced channel still takes each row's instrument, transpose, and volume, so unmuting - resumes on the state the pattern has reached. - """ - - def __init__( - self, - project_controller: ProjectController, - config: Config, - *, - active_channels: Callable[[], FrozenSet[GeneratorName]], - sample_rate: Callable[[], int], - ) -> None: - self._project_controller = project_controller - self._config = config - self._active_channels = active_channels - self._sample_rate = sample_rate - self._position = SongPosition() - self._timing: _SongTiming = _SongTiming.from_project(project_controller.project) - self._groove: Groove = self._timing.groove() - self._rates: _EngineRates = self._current_rates() - self._tick_clock: TickClock = self._rates.clock() - self._elapsed_ticks: int = 0 - self._channel_states: Dict[GeneratorName, _ChannelState] = { - generator_name: _ChannelState(generator=generator) - for generator_name, generator in self._build_generators(self._rates).items() - } - - @property - def order_position(self) -> int: - return self._position.order_position - - @property - def row_index(self) -> int: - return self._position.row_index - - @property - def is_finished(self) -> bool: - project = self._project_controller.project - return self._position.order_position >= project.song.order_length() - - def set_position(self, order_position: int, row_index: int) -> None: - self._position.order_position = order_position - self._position.row_index = row_index - - def reset(self) -> None: - self._elapsed_ticks = 0 - for state in self._channel_states.values(): - state.sample_id = None - state.tick_index = 0 - state.transpose = 0 - state.volume = MAX_VOLUME - - def _ensure_generators(self) -> None: - """Rebuilds the channel generators when either rate a tick is sized from changes. - - The engine consumes ``nes_frequency`` instructions a second and the audio holds - ``sample_rate`` samples a second, so a tick spans the quotient of the two. Following the - project's frequency keeps a row a constant real-time duration as that frequency changes, - and following the output's rate keeps a rendered second a second wherever the audio goes. - Pitch derives from the APU clock rather than either rate, so a change moves only the - per-tick frame length; the generators' phase continuity resets, which is acceptable for an - occasional settings edit. - - The tick clock follows the same pair, since it states how long one of those ticks lasts. - """ - rates = self._current_rates() - if rates == self._rates: - return - - self._rates = rates - self._tick_clock = rates.clock() - for generator_name, generator in self._build_generators(rates).items(): - self._channel_states[generator_name].generator = generator - - def _current_rates(self) -> _EngineRates: - return _EngineRates( - nes_frequency=self._project_controller.project.settings.nes_frequency, - sample_rate=self._sample_rate(), - ) - - def _build_generators(self, rates: _EngineRates) -> Dict[GeneratorName, ChannelGeneratorProtocol]: - config = self._engine_config(rates) - return { - generator_name: GENERATOR_CLASSES[generator_name]( - config, - generator_name.value, - ) - for generator_name in GeneratorName.items() - } - - def _engine_config(self, rates: _EngineRates) -> Config: - return self._config.with_library( - nes_frequency=rates.nes_frequency, - sample_rate=rates.sample_rate, - ) - - def render_row(self) -> Tuple[np.ndarray, SongPosition]: - project = self._project_controller.project - song = project.song - self._position.wrap_overflow(song.rows_per_pattern) - self._ensure_generators() - self._ensure_groove(project) - - frames = _RowFrames.from_clock( - self._tick_clock, - elapsed_ticks=self._elapsed_ticks, - ticks=self._groove.ticks[self._position.row_index], - ) - - position_before = replace(self._position) - finished = self.is_finished - mixed = ( - _silence(frames.total) - if finished - else self._mix_channels( - project, - song, - frames, - ) - ) - - self._elapsed_ticks += len(frames.lengths) - if not finished: - self._advance_position(song) - - return mixed, position_before - - def _ensure_groove(self, project: Project) -> None: - """Rebuilds the groove when the row rate or the metre it is spread over changes. - - An engine that holds a row for a whole number of ticks reaches a fractional row rate by - varying that number from row to row, and the groove is where those counts are decided. - Rebuilding only on a timing edit keeps a tempo change immediate while the distribution - itself, which spans a whole pattern, is computed once. - """ - timing = _SongTiming.from_project(project) - if timing == self._timing: - return - - self._timing = timing - self._groove = timing.groove() - - def _mix_channels( - self, - project: Project, - song: Song, - frames: _RowFrames, - ) -> np.ndarray: - mixed = _silence(frames.total) - for generator_name in GeneratorName.items(): - channel_audio = self._render_channel( - generator_name, - project, - song, - frames, - ) - mixed += channel_audio - - return clip_audio_inplace(mixed) - - def _render_channel( - self, - generator_name: GeneratorName, - project: Project, - song: Song, - frames: _RowFrames, - ) -> np.ndarray: - state = self._channel_states[generator_name] - - row = self._resolve_row(generator_name, song) - if row is not None: - self._apply_row_to_state(state, row) - - sample_id = state.sample_id - if sample_id is None or generator_name not in self._active_channels(): - return _silence(frames.total) - - return self._synthesize_ticks( - state, - sample_id, - project, - generator_name, - frames, - ) - - def _resolve_row(self, generator_name: GeneratorName, song: Song) -> Optional[Row]: - if self._position.order_position >= song.order_length(): - return None - - order_entry = song.order[self._position.order_position].get(generator_name) - if order_entry is None: - return None - - pattern = song.pattern(generator_name, order_entry) - if pattern is None or self._position.row_index >= len(pattern.rows): - return None - - return pattern.rows[self._position.row_index] - - def _apply_row_to_state(self, state: _ChannelState, row: Row) -> None: - match row.command: - case Instrument() as instrument: - state.generator.reset() - state.sample_id = instrument.sample_id - state.tick_index = 0 - state.transpose = row.transpose if row.transpose is not None else 0 - state.volume = row.volume if row.volume is not None else MAX_VOLUME - case NoteOff(): - state.generator.reset() - state.sample_id = None - state.tick_index = 0 - case None: - if row.transpose is not None: - state.transpose = row.transpose - if row.volume is not None: - state.volume = row.volume - - def _synthesize_ticks( - self, - state: _ChannelState, - sample_id: str, - project: Project, - generator_name: GeneratorName, - frames: _RowFrames, - ) -> np.ndarray: - sample = project.sample(sample_id) - if sample is None: - return _silence(frames.total) - - instructions = sample.reconstruction.instructions.get(generator_name) - if not instructions: - return _silence(frames.total) - - output = _silence(frames.total) - silence_frame = _silence(frames.longest) - - for tick, frame_length in enumerate(frames.lengths): - frame = self._synthesize_tick( - state, - instructions, - silence_frame[:frame_length], - sample.loop, - frame_length, - ) - output[frames.bounds[tick] : frames.bounds[tick + 1]] = frame - state.tick_index += 1 - - return output - - def _synthesize_tick( - self, - state: _ChannelState, - instructions: List[InstructionUnion], - silence_frame: np.ndarray, - loop: bool, - frame_length: int, - ) -> np.ndarray: - if loop: - instruction = instructions[state.tick_index % len(instructions)] - elif state.tick_index < len(instructions): - instruction = instructions[state.tick_index] - else: - return silence_frame - - state.generator.frame_length = frame_length - return state.generator( - _apply_modifiers( - instruction, - state.transpose, - state.volume, - ), - save=True, - ) - - def _advance_position(self, song: Song) -> None: - self._position.advance(song.rows_per_pattern, song.order_length()) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py new file mode 100644 index 000000000..36dfc2530 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py @@ -0,0 +1,17 @@ +from .bank import ChannelBank +from .frames import RowFrames +from .modifiers import apply_modifiers +from .rates import EngineRates +from .state import ChannelState +from .synthesizer import RowSynthesizer +from .timing import SongTiming + +__all__ = [ + "ChannelBank", + "ChannelState", + "EngineRates", + "RowFrames", + "RowSynthesizer", + "SongTiming", + "apply_modifiers", +] diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py new file mode 100644 index 000000000..1e8ab94aa --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/bank.py @@ -0,0 +1,86 @@ +from typing import Dict + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.generators.maps import GENERATOR_CLASSES +from sampletones_core.timing import TickClock + +from ..protocol import ChannelGeneratorProtocol +from .rates import EngineRates +from .state import ChannelState + + +class ChannelBank: + """The channels a song sounds through, and the rates they are built at. + + One generator per NES channel, each holding the timer state that carries a note's phase across + ticks and rows, beside the pattern state its channel has reached. Holding the rates here as + well is what makes following them a single decision: the generators and the tick clock are + built from the same pair, so they agree on how long a tick is. + """ + + def __init__(self, config: Config, rates: EngineRates) -> None: + self._config = config + self._rates = rates + self._clock: TickClock = rates.clock() + self._states: Dict[GeneratorName, ChannelState] = { + generator_name: ChannelState(generator=generator) + for generator_name, generator in self._build_generators(rates).items() + } + + @property + def clock(self) -> TickClock: + """The samples each tick spans at the rates in force.""" + return self._clock + + def state(self, generator_name: GeneratorName) -> ChannelState: + """What ``generator_name`` carries from row to row.""" + return self._states[generator_name] + + def reset(self) -> None: + """Returns every channel to silence at full volume, as a song starts them.""" + for state in self._states.values(): + state.reset() + + def follow(self, rates: EngineRates) -> None: + """Rebuilds the generators when either rate a tick is sized from changes. + + The engine consumes ``nes_frequency`` instructions a second and the audio holds + ``sample_rate`` samples a second, so a tick spans the quotient of the two. Following the + project's frequency keeps a row a constant real-time duration as that frequency changes, + and following the output's rate keeps a rendered second a second wherever the audio goes. + Pitch derives from the APU clock rather than either rate, so a change moves only the + per-tick frame length; the generators' phase continuity resets, which is acceptable for an + occasional settings edit. + + The tick clock follows the same pair, since it states how long one of those ticks lasts. + + Args: + rates: The pair in force for the row about to be rendered. + """ + if rates == self._rates: + return + + self._rates = rates + self._clock = rates.clock() + for generator_name, generator in self._build_generators(rates).items(): + self._states[generator_name].generator = generator + + def _build_generators( + self, + rates: EngineRates, + ) -> Dict[GeneratorName, ChannelGeneratorProtocol]: + config = self._engine_config(rates) + return { + generator_name: GENERATOR_CLASSES[generator_name]( + config, + generator_name.value, + ) + for generator_name in GeneratorName.items() + } + + def _engine_config(self, rates: EngineRates) -> Config: + return self._config.with_library( + nes_frequency=rates.nes_frequency, + sample_rate=rates.sample_rate, + ) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/frames.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/frames.py new file mode 100644 index 000000000..8f461055f --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/frames.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from itertools import accumulate +from typing import Self, Tuple + +from sampletones_core.timing import TickClock + + +@dataclass(frozen=True) +class RowFrames: + """Where each of a row's ticks starts and ends within the row's audio. + + A tick clock gives consecutive ticks whole sample counts that sum to their exact span, so the + lengths within one row vary where the sample rate does not divide the tick rate. Resolving the + boundaries once per row is what lets every channel write into the same offsets. + + Attributes: + lengths: The samples each of the row's ticks spans, in order. + bounds: Each tick's start offset, ending with the row's total length. + """ + + lengths: Tuple[int, ...] + bounds: Tuple[int, ...] + + @classmethod + def from_clock( + cls, + clock: TickClock, + *, + elapsed_ticks: int, + ticks: int, + ) -> Self: + """Resolves the row starting at ``elapsed_ticks`` and spanning ``ticks`` ticks.""" + lengths = tuple(clock.frame_length(elapsed_ticks + tick) for tick in range(ticks)) + return cls( + lengths=lengths, + bounds=tuple(accumulate(lengths, initial=0)), + ) + + @property + def total(self) -> int: + """The samples the whole row spans.""" + return self.bounds[-1] + + @property + def longest(self) -> int: + """The samples the row's longest tick spans.""" + return max(self.lengths, default=0) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py new file mode 100644 index 000000000..52a88b7e4 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/modifiers.py @@ -0,0 +1,43 @@ +from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) + + +def apply_modifiers( + instruction: InstructionUnion, + transpose: int, + row_volume: int, +) -> InstructionUnion: + """Bends one tick's instruction by the transpose and volume the pattern has reached. + + A sample carries the instructions it was reconstructed from; a pattern states how loud and how + high it is played. Each channel takes both in the terms it understands: the pulse channels + scale their volume and shift their pitch, the triangle shifts its pitch and sounds while the + row asks for more than half volume, and the noise channel scales its volume and walks its + period around the sixteen the hardware offers. + + Args: + instruction: The tick's instruction as the sample holds it. + transpose: The semitone offset the pattern has reached, held within the pitch range. + row_volume: The level the pattern has reached, scaling the instruction's own. + + Returns: + InstructionUnion: A copy of the instruction as the channel sounds it. + """ + match instruction: + case PulseInstruction(): + scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME))) + effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose)) + return instruction.model_copy(update={"pitch": effective_pitch, "volume": scaled_volume}) + case TriangleInstruction(): + effective_pitch = max(MIN_PITCH, min(MAX_PITCH, instruction.pitch + transpose)) + on = instruction.on and row_volume > MAX_VOLUME // 2 + return instruction.model_copy(update={"pitch": effective_pitch, "on": on}) + case NoiseInstruction(): + scaled_volume = max(0, min(MAX_VOLUME, round(instruction.volume * row_volume / MAX_VOLUME))) + effective_period = (instruction.period + transpose) % 16 + return instruction.model_copy(update={"period": effective_period, "volume": scaled_volume}) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py new file mode 100644 index 000000000..b256c6b06 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/rates.py @@ -0,0 +1,38 @@ +from dataclasses import dataclass +from typing import Self + +from sampletones_core.project import Project +from sampletones_core.timing import TickClock + + +@dataclass(frozen=True) +class EngineRates: + """The pair of rates a tick is sized from, held together so a change is one comparison. + + Each rate is owned elsewhere: the project states how many instructions the engine consumes + each second, and whoever takes the audio states the rate it is rendered at — the output + device for playback, the chosen format for a file. Together they fix how many samples one + tick spans, so the synthesiser follows both. + + Attributes: + nes_frequency: The engine ticks consumed each second. + sample_rate: The samples the rendered audio holds each second. + """ + + nes_frequency: int + sample_rate: int + + @classmethod + def from_project(cls, project: Project, sample_rate: int) -> Self: + """The rates in force for ``project`` rendered at ``sample_rate``.""" + return cls( + nes_frequency=project.settings.nes_frequency, + sample_rate=sample_rate, + ) + + def clock(self) -> TickClock: + """The samples each tick spans under this pair of rates.""" + return TickClock.from_parameters( + sample_rate=self.sample_rate, + nes_frequency=self.nes_frequency, + ) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py new file mode 100644 index 000000000..ca2974a84 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass, field +from typing import Optional + +from sampletones_core.constants.general import MAX_VOLUME + +from ..protocol import ChannelGeneratorProtocol + + +@dataclass +class ChannelState: + """What one channel carries from row to row. + + A pattern states a channel's instrument, transpose, and volume only where it changes them, so + the channel keeps the last of each until another row states otherwise. The tick index is how + far into the sounding sample's instructions the channel has played, which is what lets a note + sustain across rows. + + Attributes: + generator: The synthesiser filling the channel's ticks. + sample_id: The sample the channel is sounding, or ``None`` while it is silent. + tick_index: How many ticks of that sample's instructions the channel has played. + transpose: The semitone offset a row last set. + volume: The level a row last set. + """ + + generator: ChannelGeneratorProtocol + sample_id: Optional[str] = field(default=None) + tick_index: int = field(default=0) + transpose: int = field(default=0) + volume: int = field(default=MAX_VOLUME) + + def reset(self) -> None: + """Returns the channel to silence at full volume, as a song starts it. + + The generator is kept, since it is built from the rates in force rather than from + anything a song reaches. + """ + self.sample_id = None + self.tick_index = 0 + self.transpose = 0 + self.volume = MAX_VOLUME diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py new file mode 100644 index 000000000..34aeb1f77 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -0,0 +1,284 @@ +from dataclasses import replace +from typing import Callable, FrozenSet, List, Optional, Tuple + +import numpy as np + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_core.audio import clip_audio_inplace, silence +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.instructions import InstructionUnion +from sampletones_core.project import Project +from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.patterns.row import Row +from sampletones_core.project.song import Song +from sampletones_core.project.song_position import SongPosition +from sampletones_core.timing import Groove + +from .bank import ChannelBank +from .frames import RowFrames +from .modifiers import apply_modifiers +from .rates import EngineRates +from .state import ChannelState +from .timing import SongTiming + + +class RowSynthesizer: + """Real-time synthesis engine for tracker song playback. + + Reads the live ``Project`` from ``project_controller`` on every ``render_row`` + call so that pattern edits, tempo changes, and sample swaps take effect + immediately while playback keeps running. + + A row lasts the ticks the project's groove gives its position within the pattern, so the + row a pattern's tenth row plays for is the row an exported module plays it for: both index + the same groove from the pattern's first row. + + Each of those ticks spans the samples the :class:`~sampletones_core.timing.clock.TickClock` + gives its position in the run, so a tick lasts ``1 / nes_frequency`` seconds at every sample + rate and the groove's tempo is the tempo heard. + + ``sample_rate`` reports the rate the audio is rendered at, and is what the caller taking that + audio runs at: the output device for live playback, the chosen format for a file. Reading it + per row keeps the two in step, so a rendered second is a second wherever the audio goes. + + Generators are held in a :class:`ChannelBank` built from ``config`` at the rates in force, + carrying timer state across rows for phase continuity within a sustained note. Triggering a + new note calls ``generator.reset()`` for a clean phase start. + + ``active_channels`` reports which channels sound and is consulted once per channel per + row, so muting or unmuting during playback is heard as the render-ahead buffer drains. A + silenced channel still takes each row's instrument, transpose, and volume, so unmuting + resumes on the state the pattern has reached. + """ + + def __init__( + self, + project_controller: ProjectController, + config: Config, + *, + active_channels: Callable[[], FrozenSet[GeneratorName]], + sample_rate: Callable[[], int], + ) -> None: + self._project_controller = project_controller + self._active_channels = active_channels + self._sample_rate = sample_rate + self._position = SongPosition() + self._timing: SongTiming = SongTiming.from_project(project_controller.project) + self._groove: Groove = self._timing.groove() + self._channels = ChannelBank(config, self._current_rates()) + self._elapsed_ticks: int = 0 + + @property + def order_position(self) -> int: + return self._position.order_position + + @property + def row_index(self) -> int: + return self._position.row_index + + @property + def is_finished(self) -> bool: + project = self._project_controller.project + return self._position.order_position >= project.song.order_length() + + def set_position(self, order_position: int, row_index: int) -> None: + self._position.order_position = order_position + self._position.row_index = row_index + + def reset(self) -> None: + self._elapsed_ticks = 0 + self._channels.reset() + + def render_row(self) -> Tuple[np.ndarray, SongPosition]: + project = self._project_controller.project + song = project.song + self._position.wrap_overflow(song.rows_per_pattern) + self._channels.follow(self._current_rates()) + self._ensure_groove(project) + + frames = RowFrames.from_clock( + self._channels.clock, + elapsed_ticks=self._elapsed_ticks, + ticks=self._groove.ticks[self._position.row_index], + ) + + position_before = replace(self._position) + finished = self.is_finished + mixed = ( + silence(frames.total) + if finished + else self._mix_channels( + project, + song, + frames, + ) + ) + + self._elapsed_ticks += len(frames.lengths) + if not finished: + self._advance_position(song) + + return mixed, position_before + + def _current_rates(self) -> EngineRates: + return EngineRates.from_project( + self._project_controller.project, + self._sample_rate(), + ) + + def _ensure_groove(self, project: Project) -> None: + """Rebuilds the groove when the row rate or the metre it is spread over changes. + + An engine that holds a row for a whole number of ticks reaches a fractional row rate by + varying that number from row to row, and the groove is where those counts are decided. + Rebuilding only on a timing edit keeps a tempo change immediate while the distribution + itself, which spans a whole pattern, is computed once. + """ + timing = SongTiming.from_project(project) + if timing == self._timing: + return + + self._timing = timing + self._groove = timing.groove() + + def _mix_channels( + self, + project: Project, + song: Song, + frames: RowFrames, + ) -> np.ndarray: + mixed = silence(frames.total) + for generator_name in GeneratorName.items(): + channel_audio = self._render_channel( + generator_name, + project, + song, + frames, + ) + mixed += channel_audio + + return clip_audio_inplace(mixed) + + def _render_channel( + self, + generator_name: GeneratorName, + project: Project, + song: Song, + frames: RowFrames, + ) -> np.ndarray: + state = self._channels.state(generator_name) + + row = self._resolve_row(generator_name, song) + if row is not None: + self._apply_row_to_state(state, row) + + sample_id = state.sample_id + if sample_id is None or generator_name not in self._active_channels(): + return silence(frames.total) + + return self._synthesize_ticks( + state, + sample_id, + project, + generator_name, + frames, + ) + + def _resolve_row( + self, + generator_name: GeneratorName, + song: Song, + ) -> Optional[Row]: + if self._position.order_position >= song.order_length(): + return None + + order_entry = song.order[self._position.order_position].get(generator_name) + if order_entry is None: + return None + + pattern = song.pattern(generator_name, order_entry) + if pattern is None or self._position.row_index >= len(pattern.rows): + return None + + return pattern.rows[self._position.row_index] + + def _apply_row_to_state(self, state: ChannelState, row: Row) -> None: + match row.command: + case Instrument() as instrument: + state.generator.reset() + state.sample_id = instrument.sample_id + state.tick_index = 0 + state.transpose = row.transpose if row.transpose is not None else 0 + state.volume = row.volume if row.volume is not None else MAX_VOLUME + case NoteOff(): + state.generator.reset() + state.sample_id = None + state.tick_index = 0 + case None: + if row.transpose is not None: + state.transpose = row.transpose + if row.volume is not None: + state.volume = row.volume + + def _synthesize_ticks( + self, + state: ChannelState, + sample_id: str, + project: Project, + generator_name: GeneratorName, + frames: RowFrames, + ) -> np.ndarray: + sample = project.sample(sample_id) + if sample is None: + return silence(frames.total) + + instructions = sample.reconstruction.instructions.get(generator_name) + if not instructions: + return silence(frames.total) + + output = silence(frames.total) + silence_frame = silence(frames.longest) + + for tick, frame_length in enumerate(frames.lengths): + frame = self._synthesize_tick( + state, + instructions, + silence_frame[:frame_length], + sample.loop, + frame_length, + ) + output[frames.bounds[tick] : frames.bounds[tick + 1]] = frame + state.tick_index += 1 + + return output + + def _synthesize_tick( + self, + state: ChannelState, + instructions: List[InstructionUnion], + silence_frame: np.ndarray, + loop: bool, + frame_length: int, + ) -> np.ndarray: + if loop: + instruction = instructions[state.tick_index % len(instructions)] + elif state.tick_index < len(instructions): + instruction = instructions[state.tick_index] + else: + return silence_frame + + state.generator.frame_length = frame_length + return state.generator( + apply_modifiers( + instruction, + state.transpose, + state.volume, + ), + save=True, + ) + + def _advance_position(self, song: Song) -> None: + self._position.advance(song.rows_per_pattern, song.order_length()) diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py new file mode 100644 index 000000000..96c72dd78 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/timing.py @@ -0,0 +1,47 @@ +from dataclasses import dataclass +from typing import Self + +from sampletones_application.constants.playback import ( + MAX_TICKS_PER_ROW, + MIN_TICKS_PER_ROW, +) +from sampletones_core.project import Project +from sampletones_core.timing import Groove, Metre, RowRate, calculate_groove + + +@dataclass(frozen=True) +class SongTiming: + """Everything a project's groove is built from, held together so a change is one comparison. + + Attributes: + rate: The exact ticks one row lasts under the project's tempo, speed and tick rate. + metre: The pattern length and the beat and bar grouping the ticks are spread over. + """ + + rate: RowRate + metre: Metre + + @classmethod + def from_project(cls, project: Project) -> Self: + """Reads the timing a project plays at, taking the pattern length from its song.""" + return cls( + rate=RowRate.from_settings(project.settings), + metre=Metre.from_settings( + project.settings, + rows=project.song.rows_per_pattern, + ), + ) + + def groove(self) -> Groove: + """Spreads the row rate across a pattern's rows. + + Playback follows whatever tempo the project states, so the one bound it sets is that + every row lasts at least a tick and keeps sounding; the ceiling is the fastest row the + settings can ask for, which leaves the groove free to realize the rate exactly. + """ + return calculate_groove( + self.rate, + self.metre, + minimum_ticks=MIN_TICKS_PER_ROW, + maximum_ticks=MAX_TICKS_PER_ROW, + ) diff --git a/src/sampletones_application/services/__init__.py b/src/sampletones_application/services/__init__.py index 2ce762af9..59f0dcca7 100644 --- a/src/sampletones_application/services/__init__.py +++ b/src/sampletones_application/services/__init__.py @@ -10,6 +10,11 @@ RegenerationResult, RegenerationService, ) +from sampletones_application.services.render import ( + RenderResult, + RenderStage, + SongRenderService, +) from sampletones_application.services.result import ( ConversionResult, ServiceCancelled, @@ -20,6 +25,7 @@ ServiceSuccess, ) from sampletones_application.services.retune import RetunedSample, RetuneResult, SampleRetuneService +from sampletones_application.services.synthesis import RowSynthesizerProtocol __all__ = [ "ConversionResult", @@ -32,8 +38,11 @@ "RegeneratedInstrument", "RegenerationResult", "RegenerationService", + "RenderResult", + "RenderStage", "RetuneResult", "RetunedSample", + "RowSynthesizerProtocol", "SampleRetuneService", "ServiceBase", "ServiceCancelled", @@ -42,4 +51,5 @@ "ServiceProgress", "ServiceStarted", "ServiceSuccess", + "SongRenderService", ] diff --git a/src/sampletones_application/services/render/__init__.py b/src/sampletones_application/services/render/__init__.py new file mode 100644 index 000000000..589c798b4 --- /dev/null +++ b/src/sampletones_application/services/render/__init__.py @@ -0,0 +1,18 @@ +from sampletones_application.services.render.result import RenderResult, RenderStage +from sampletones_application.services.render.service import SongRenderService +from sampletones_application.services.render.sink import ( + DirectRenderSink, + NormalizingRenderSink, + RenderSink, + build_render_sink, +) + +__all__ = [ + "DirectRenderSink", + "NormalizingRenderSink", + "RenderResult", + "RenderSink", + "RenderStage", + "SongRenderService", + "build_render_sink", +] diff --git a/src/sampletones_application/services/render/constants.py b/src/sampletones_application/services/render/constants.py new file mode 100644 index 000000000..df1ca36ec --- /dev/null +++ b/src/sampletones_application/services/render/constants.py @@ -0,0 +1,5 @@ +from typing import Final + +PROGRESS_STEPS: Final[int] = 200 +ENCODE_BLOCK_SAMPLES: Final[int] = 1 << 16 +SCRATCH_SUFFIX: Final[str] = ".scratch" diff --git a/src/sampletones_application/services/render/progress.py b/src/sampletones_application/services/render/progress.py new file mode 100644 index 000000000..da09d67e1 --- /dev/null +++ b/src/sampletones_application/services/render/progress.py @@ -0,0 +1,49 @@ +from typing import Callable + +from sampletones_application.services.render.constants import PROGRESS_STEPS +from sampletones_application.services.render.result import RenderStage +from sampletones_application.services.result import ServiceProgress +from sampletones_core.parallelization import ETAEstimator + + +class StageProgress: + """One pass of a render, reported at a bounded rate. + + A render walks a song sample by sample, so reporting every step would fill the callback + queue with updates no eye resolves and no bar redraws. Emitting on a fraction of the total + holds the report rate steady whatever the song's length, and the last position is always + reported, so a bar arrives at its end. + """ + + def __init__( + self, + stage: RenderStage, + total: int, + *, + emit: Callable[[ServiceProgress[RenderStage]], None], + ) -> None: + self._stage = stage + self._total = total + self._emit = emit + self._estimator = ETAEstimator(total=total) + self._interval = max(1, total // PROGRESS_STEPS) + self._reported: int = 0 + + def advance(self, completed: int) -> None: + """Reports the pass at ``completed`` samples where a step is due. + + Args: + completed: The samples this pass has covered so far. + """ + if completed < self._total and completed - self._reported < self._interval: + return + + self._reported = completed + self._emit( + ServiceProgress( + completed=completed, + total=self._total, + current_item=self._stage, + eta_seconds=self._estimator.update(completed), + ) + ) diff --git a/src/sampletones_application/services/render/result.py b/src/sampletones_application/services/render/result.py new file mode 100644 index 000000000..9ee105e5e --- /dev/null +++ b/src/sampletones_application/services/render/result.py @@ -0,0 +1,31 @@ +from enum import StrEnum +from pathlib import Path +from typing import Union + +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceProgress, + ServiceStarted, + ServiceSuccess, +) + + +class RenderStage(StrEnum): + """The pass a render is on, naming what a progress report is counting. + + Both passes count the samples the song holds, so a report reads the same way whichever one + it comes from and a bar crosses the same axis twice. + """ + + SYNTHESIS = "synthesis" + ENCODING = "encoding" + + +RenderResult = Union[ + ServiceStarted, + ServiceProgress[RenderStage], + ServiceSuccess[Path], + ServiceError, + ServiceCancelled, +] diff --git a/src/sampletones_application/services/render/scratch.py b/src/sampletones_application/services/render/scratch.py new file mode 100644 index 000000000..0e838ab70 --- /dev/null +++ b/src/sampletones_application/services/render/scratch.py @@ -0,0 +1,86 @@ +from pathlib import Path +from typing import BinaryIO, Final, Iterator, Optional + +import numpy as np + +from sampletones_shared.exceptions import AudioWriteError + +NO_PEAK: Final[float] = 0.0 + + +class ScratchAudio: + """A render's samples spilled to disk beside its destination while their peak is discovered. + + Scaling a render to its peak needs the whole render before any of it can be written, and a + song is longer than a buffer worth holding in memory. Raw float32 samples are what a private + intermediate needs: the file is written once, read back once in blocks, and removed, so a + container would only describe what the writer already knows. + + Attributes: + path: Where the samples are spilled. + """ + + def __init__(self, path: Path) -> None: + self.path = path + self._handle: Optional[BinaryIO] = None + self._peak: float = NO_PEAK + self._samples: int = 0 + + @property + def samples(self) -> int: + """How many samples have been spilled.""" + return self._samples + + @property + def peak(self) -> float: + """The loudest sample spilled so far, as an absolute amplitude.""" + return self._peak + + def start(self) -> None: + """Opens the spill file, replacing anything a previous run left at the path.""" + self._handle = self.path.open("wb") + + def write(self, chunk: np.ndarray) -> None: + """Appends one chunk, keeping the loudest sample seen across the whole spill. + + Args: + chunk: Float samples to spill. + + Raises: + AudioWriteError: If the spill file is not open. + """ + if self._handle is None: + raise AudioWriteError(f"No spill file open at '{self.path}'; write between start and seal") + + chunk.astype(np.float32, copy=False).tofile(self._handle) + self._peak = max(self._peak, float(np.max(np.abs(chunk), initial=NO_PEAK))) + self._samples += len(chunk) + + def seal(self) -> None: + """Closes the spill file, leaving what was written ready to read back.""" + if self._handle is None: + return + + self._handle.close() + self._handle = None + + def blocks(self, size: int) -> Iterator[np.ndarray]: + """Reads the spilled samples back in order, in blocks of at most ``size`` samples. + + Args: + size: The samples one block holds at most; the last block holds what remains. + + Yields: + np.ndarray: One block of the spilled float samples. + """ + with self.path.open("rb") as handle: + while True: + block = np.fromfile(handle, dtype=np.float32, count=size) + if not block.size: + return + + yield block + + def remove(self) -> None: + """Deletes the spill file, whether or not it was read back.""" + self.path.unlink(missing_ok=True) diff --git a/src/sampletones_application/services/render/service.py b/src/sampletones_application/services/render/service.py new file mode 100644 index 000000000..fd43c2844 --- /dev/null +++ b/src/sampletones_application/services/render/service.py @@ -0,0 +1,167 @@ +import threading +from functools import partial +from pathlib import Path + +from sampletones_application.services.base import ServiceBase +from sampletones_application.services.render.progress import StageProgress +from sampletones_application.services.render.result import RenderResult, RenderStage +from sampletones_application.services.render.sink import ( + EncodeReporter, + RenderSink, + build_render_sink, +) +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceStarted, + ServiceSuccess, +) +from sampletones_application.services.synthesis.protocol import RowSynthesizerProtocol +from sampletones_application.utils.parallelization.thread import SingleThreadExecutor +from sampletones_core.audio.writers import AudioOutputSpec +from sampletones_shared.logger import logger + + +class SongRenderService(ServiceBase[RenderResult]): + """Renders a whole song to a file on a background thread, reporting each pass as it runs. + + The synthesiser arrives per call, so the service holds no opinion on what a song sounds + like: it drives the same kernel the player drives, one row at a time, and hands each row to + a sink. The sink decides what becomes of a row — straight to the encoder, or spilled and + written back at the level the whole render turned out to reach — so the service reports one + pass or two without knowing which format waits on the other side. + + A render is one at a time. Cancelling is honoured between rows and between encoded blocks, + and the file a cancelled or failed run was writing is removed, so a result names a path only + where a finished file stands. + """ + + def __init__(self, priority: int = 0) -> None: + super().__init__(priority) + self._executor = SingleThreadExecutor() + self._cancel_event = threading.Event() + self._running = threading.Event() + + def start( + self, + *, + synthesizer: RowSynthesizerProtocol, + destination: Path, + spec: AudioOutputSpec, + normalize: bool, + total_samples: int, + ) -> bool: + """Begins a render on the worker thread; reports whether it took the request. + + Args: + synthesizer: The kernel the song is rendered through, from its first row. + destination: Where the finished file is written. + spec: The format, rate, and quality it is written at. + normalize: Whether the render is scaled so its loudest sample reaches full scale. + total_samples: The samples the whole song holds, which the passes are measured against. + + Returns: + bool: Whether a render started; a request arriving while one runs is declined. + """ + if self.is_running(): + logger.warning(f"{self.class_name}: a render is already running; start ignored") + return False + + self._cancel_event.clear() + self._running.set() + sink = build_render_sink(destination, spec, normalize=normalize) + started = self._executor.execute( + partial(self._run, synthesizer, sink, total_samples), + wait=False, + ) + if not started: + self._running.clear() + + return started + + def cancel(self) -> None: + """Asks a running render to stop at its next row or block.""" + self._cancel_event.set() + + def is_running(self) -> bool: + return self._running.is_set() + + def shutdown(self) -> None: + """Winds a running render down for application exit. + + The worker runs on a :class:`SingleThreadExecutor`, so the teardown that joins every + background worker reaches this one; asking it to stop first is what keeps that join short. + """ + self._cancel_event.set() + + def _run( + self, + synthesizer: RowSynthesizerProtocol, + sink: RenderSink, + total_samples: int, + ) -> None: + try: + self._emit(ServiceStarted(total=total_samples)) + self._report_outcome(sink, self._render(synthesizer, sink, total_samples)) + except Exception as exception: # pylint: disable=broad-exception-caught + logger.error_with_traceback(exception, f"{self.class_name}: failed to render to {sink.destination}") + sink.discard() + self._emit(ServiceError(exception=exception)) + finally: + self._running.clear() + + def _render( + self, + synthesizer: RowSynthesizerProtocol, + sink: RenderSink, + total_samples: int, + ) -> bool: + with sink: + if not self._synthesize(synthesizer, sink, total_samples): + return False + + return sink.finish(self._encode_reporter(total_samples)) + + def _synthesize( + self, + synthesizer: RowSynthesizerProtocol, + sink: RenderSink, + total_samples: int, + ) -> bool: + """Renders the song from its first row into the sink; reports whether it reached the end. + + The song is rendered as the document holds it, from the top: the position a listener left + the playhead at is a listening choice, and a render describes the whole song. + """ + progress = StageProgress(RenderStage.SYNTHESIS, total_samples, emit=self._emit) + synthesizer.set_position(0, 0) + synthesizer.reset() + + rendered = 0 + while not synthesizer.is_finished: + if self._cancel_event.is_set(): + return False + + chunk, _ = synthesizer.render_row() + sink.write(chunk) + rendered = min(total_samples, rendered + len(chunk)) + progress.advance(rendered) + + return not self._cancel_event.is_set() + + def _encode_reporter(self, total_samples: int) -> EncodeReporter: + progress = StageProgress(RenderStage.ENCODING, total_samples, emit=self._emit) + return partial(self._report_encoded, progress) + + def _report_encoded(self, progress: StageProgress, encoded: int) -> bool: + progress.advance(encoded) + return not self._cancel_event.is_set() + + def _report_outcome(self, sink: RenderSink, completed: bool) -> None: + if not completed: + sink.discard() + self._emit(ServiceCancelled()) + return + + logger.info(f"Rendered the song to: {logger.format_path(sink.destination)}") + self._emit(ServiceSuccess(value=sink.destination)) diff --git a/src/sampletones_application/services/render/sink.py b/src/sampletones_application/services/render/sink.py new file mode 100644 index 000000000..4f46dae3a --- /dev/null +++ b/src/sampletones_application/services/render/sink.py @@ -0,0 +1,194 @@ +from contextlib import ExitStack +from pathlib import Path +from types import TracebackType +from typing import Callable, Final, Optional, Protocol, Self, Type + +import numpy as np + +from sampletones_application.services.render.constants import ( + ENCODE_BLOCK_SAMPLES, + SCRATCH_SUFFIX, +) +from sampletones_application.services.render.scratch import NO_PEAK, ScratchAudio +from sampletones_core.audio.writers import AudioOutputSpec, AudioWriter, open_audio_writer +from sampletones_shared.constants.audio import UNITY_GAIN +from sampletones_shared.exceptions import AudioWriteError + +FULL_SCALE: Final[float] = 1.0 + +EncodeReporter = Callable[[int], bool] + + +class RenderSink(Protocol): + """Where a render's rows go on their way to the destination file. + + A sink is entered for the length of one render: rows arrive through ``write`` in the order + they are synthesised, and ``finish`` completes whatever the sink still owes the destination. + Leaving the sink closes what it opened and clears what was only ever temporary; ``discard`` + is how a caller that decided against the result removes the file itself. + + Attributes: + destination: The file the render is written to. + """ + + destination: Path + + def __enter__(self) -> Self: ... + + def __exit__( + self, + exception_type: Optional[Type[BaseException]], + exception: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: ... + + def write(self, chunk: np.ndarray) -> None: ... + + def finish(self, report: EncodeReporter, /) -> bool: ... + + def discard(self) -> None: ... + + +class DirectRenderSink: + """Writes each row to the destination as it is synthesised. + + One pass over the song, at the level the synthesiser produced: the encoder receives a row as + soon as it exists, so the file grows with the render and nothing is held between the two. + """ + + def __init__(self, destination: Path, spec: AudioOutputSpec) -> None: + self.destination = destination + self._spec = spec + self._stack = ExitStack() + self._writer: Optional[AudioWriter] = None + + def __enter__(self) -> Self: + self._writer = self._stack.enter_context(open_audio_writer(self.destination, self._spec)) + return self + + def __exit__( + self, + exception_type: Optional[Type[BaseException]], + exception: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + self._writer = None + self._stack.close() + + def write(self, chunk: np.ndarray) -> None: + """Hands one row to the encoder. + + Args: + chunk: The row's samples. + + Raises: + AudioWriteError: If the sink has not been entered. + """ + if self._writer is None: + raise AudioWriteError(f"No file open at '{self.destination}'; write within the sink's context") + + self._writer.write(chunk) + + def finish(self, _report: EncodeReporter, /) -> bool: + """Reports the destination complete, since every row was written as it arrived.""" + return True + + def discard(self) -> None: + """Deletes the destination, so a render the caller dropped names no file.""" + self.destination.unlink(missing_ok=True) + + +class NormalizingRenderSink: + """Spills the render, then writes it at the scale that brings its peak to full. + + The loudest sample is known only once the last row is synthesised, so the rows are spilled + beside the destination as they arrive and read back in blocks against the peak they turned + out to hold. The destination is opened for the second pass alone, which is what makes the + encoder see the finished levels rather than the raw ones. + """ + + def __init__(self, destination: Path, spec: AudioOutputSpec) -> None: + self.destination = destination + self._spec = spec + self._scratch = ScratchAudio(destination.with_name(destination.name + SCRATCH_SUFFIX)) + + def __enter__(self) -> Self: + self._scratch.start() + return self + + def __exit__( + self, + exception_type: Optional[Type[BaseException]], + exception: Optional[BaseException], + traceback: Optional[TracebackType], + ) -> None: + self._scratch.seal() + self._scratch.remove() + + def write(self, chunk: np.ndarray) -> None: + """Spills one row, keeping the peak the render has reached. + + Args: + chunk: The row's samples. + + Raises: + AudioWriteError: If the sink has not been entered. + """ + self._scratch.write(chunk) + + def finish(self, report: EncodeReporter, /) -> bool: + """Encodes the spilled render at its scale, reporting how far the pass has come. + + Args: + report: Takes the samples encoded so far and states whether to carry on. + + Returns: + bool: Whether the destination holds the whole render. + """ + self._scratch.seal() + scale = self._scale() + encoded = 0 + with open_audio_writer(self.destination, self._spec) as writer: + for block in self._scratch.blocks(ENCODE_BLOCK_SAMPLES): + writer.write(block * scale) + encoded += len(block) + if not report(encoded): + return False + + return True + + def discard(self) -> None: + """Deletes the destination, so a render the caller dropped names no file.""" + self.destination.unlink(missing_ok=True) + + def _scale(self) -> float: + """The factor bringing the spilled render's peak to full scale. + + A render that stayed silent has no peak to reach for, so it is written as it stands. + """ + if self._scratch.peak <= NO_PEAK: + return UNITY_GAIN + + return FULL_SCALE / self._scratch.peak + + +def build_render_sink( + destination: Path, + spec: AudioOutputSpec, + *, + normalize: bool, +) -> RenderSink: + """The sink a render writes through, chosen by whether its level is scaled to its peak. + + Args: + destination: The file the render is written to. + spec: The format, rate, and quality it is written at. + normalize: Whether the render is scaled so its loudest sample reaches full scale. + + Returns: + RenderSink: A sink ready to be entered. + """ + if normalize: + return NormalizingRenderSink(destination, spec) + + return DirectRenderSink(destination, spec) diff --git a/src/sampletones_application/services/song_player/player.py b/src/sampletones_application/services/song_player/player.py index d8ec39f05..2cdc565c5 100644 --- a/src/sampletones_application/services/song_player/player.py +++ b/src/sampletones_application/services/song_player/player.py @@ -12,13 +12,13 @@ STOP_JOIN_TIMEOUT, STOP_POLL_TIMEOUT, ) -from sampletones_application.services.song_player.protocol import RowSynthesizerProtocol from sampletones_application.services.song_player.result import ( SongPlaybackError, SongPlaybackStopped, SongPlayerResult, SongPositionUpdate, ) +from sampletones_application.services.synthesis.protocol import RowSynthesizerProtocol from sampletones_core.audio import AudioDeviceManager, clip_audio_inplace from sampletones_core.constants.audio import DEFAULT_BUFFER_SIZE from sampletones_core.project.song_position import SongPosition diff --git a/src/sampletones_application/services/song_player/protocol.py b/src/sampletones_application/services/song_player/protocol.py deleted file mode 100644 index 58a7dd0cc..000000000 --- a/src/sampletones_application/services/song_player/protocol.py +++ /dev/null @@ -1,30 +0,0 @@ -from typing import Protocol, Tuple - -import numpy as np - -from sampletones_core.project.song_position import SongPosition - - -class RowSynthesizerProtocol(Protocol): - """Streaming synthesis kernel the song-player service drives, one row at a time. - - This is the service's input contract; the concrete synthesiser lives in the logic layer - and satisfies it structurally. Each ``render_row`` call produces one row's worth of audio - (ticks_per_row × frame_length samples), advances the internal position cursor, and returns - a snapshot of the cursor from before the advance so callers can post accurate position events. - """ - - @property - def order_position(self) -> int: ... - - @property - def row_index(self) -> int: ... - - @property - def is_finished(self) -> bool: ... - - def set_position(self, order_position: int, row_index: int) -> None: ... - - def render_row(self) -> Tuple[np.ndarray, SongPosition]: ... - - def reset(self) -> None: ... diff --git a/src/sampletones_application/services/synthesis/__init__.py b/src/sampletones_application/services/synthesis/__init__.py new file mode 100644 index 000000000..767f0ca53 --- /dev/null +++ b/src/sampletones_application/services/synthesis/__init__.py @@ -0,0 +1,5 @@ +from sampletones_application.services.synthesis.protocol import RowSynthesizerProtocol + +__all__ = [ + "RowSynthesizerProtocol", +] diff --git a/src/sampletones_application/services/synthesis/protocol.py b/src/sampletones_application/services/synthesis/protocol.py new file mode 100644 index 000000000..7b257f702 --- /dev/null +++ b/src/sampletones_application/services/synthesis/protocol.py @@ -0,0 +1,33 @@ +from typing import Protocol, Tuple + +import numpy as np + +from sampletones_core.project.song_position import SongPosition + + +class RowSynthesizerProtocol(Protocol): + """Streaming synthesis kernel a service drives, one row at a time. + + This is the input contract every consumer of a song's audio takes; the concrete synthesiser + lives in the logic layer and satisfies it structurally. Each ``render_row`` call produces one + row's worth of audio, advances the internal position cursor, and returns a snapshot of the + cursor from before the advance so callers can post accurate position events. + + The player and the renderer drive the same kernel through this one contract, which is what + makes a rendered file sound like what playback produces: the synthesis code is written once. + """ + + @property + def order_position(self) -> int: ... + + @property + def row_index(self) -> int: ... + + @property + def is_finished(self) -> bool: ... + + def set_position(self, order_position: int, row_index: int) -> None: ... + + def render_row(self) -> Tuple[np.ndarray, SongPosition]: ... + + def reset(self) -> None: ... diff --git a/src/sampletones_core/audio/__init__.py b/src/sampletones_core/audio/__init__.py index fda067bed..436ef3c6f 100644 --- a/src/sampletones_core/audio/__init__.py +++ b/src/sampletones_core/audio/__init__.py @@ -11,6 +11,7 @@ normalize, quantize, resample, + silence, to_mono, ) from .validation import ( @@ -36,6 +37,7 @@ "quantize", "read_wave", "resample", + "silence", "to_mono", "validate_audio_array", "validate_buffer_size", diff --git a/src/sampletones_core/audio/processing.py b/src/sampletones_core/audio/processing.py index 99518d481..53f5bfe8a 100644 --- a/src/sampletones_core/audio/processing.py +++ b/src/sampletones_core/audio/processing.py @@ -14,6 +14,19 @@ from .validation import validate_audio_array +def silence(samples: int) -> np.ndarray: + """ + Build a buffer of the given length holding no sound. + + Args: + samples: How many samples the buffer spans. + + Returns: + A float32 array of zeros, ready to be mixed into or written over. + """ + return np.zeros(samples, dtype=np.float32) + + def clip_audio(audio: np.ndarray) -> np.ndarray: """ Clip audio samples to the valid range [-1.0, 1.0]. diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_apply_modifiers.py index d3d8161fa..2b4dcaa08 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 @@ -4,7 +4,7 @@ import pytest from sampletones_application.logic.sequencer.playback.synthesizer import ( - _apply_modifiers, + apply_modifiers, ) from sampletones_core.constants.general import MAX_PITCH, MAX_VOLUME, MIN_PITCH from sampletones_core.instructions import ( @@ -86,7 +86,7 @@ def test_volume_scaled_correctly( volume=case.instruction_volume, duty_cycle=0, ) - result = _apply_modifiers( + result = apply_modifiers( instruction, transpose=0, row_volume=case.row_volume, @@ -107,7 +107,7 @@ def test_volume_scaled_correctly( volume=case.instruction_volume, short=False, ) - result = _apply_modifiers( + result = apply_modifiers( instruction, transpose=0, row_volume=case.row_volume, @@ -180,7 +180,7 @@ def test_pitch_transposed_correctly( volume=15, duty_cycle=0, ) - result = _apply_modifiers( + result = apply_modifiers( instruction, transpose=case.transpose, row_volume=MAX_VOLUME, @@ -253,7 +253,7 @@ def test_period_transposed_correctly( volume=15, short=False, ) - result = _apply_modifiers( + result = apply_modifiers( instruction, transpose=case.transpose, row_volume=MAX_VOLUME, @@ -359,7 +359,7 @@ def test_modifiers_applied( case: TriangleModifiersCase, ) -> None: instruction = TriangleInstruction(on=True, pitch=case.pitch) - result = _apply_modifiers( + result = apply_modifiers( instruction, transpose=case.transpose, row_volume=case.row_volume, 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 5d34066fd..53e728640 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -70,7 +70,7 @@ def _state( context: SynthesizerContext, generator: GeneratorName = GeneratorName.PULSE1, ): - return context.synthesizer._channel_states[generator] + return context.synthesizer._channels.state(generator) def _render(context: SynthesizerContext) -> np.ndarray: @@ -835,7 +835,7 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non sample = add_sample(controller, recon) place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE) - pulse_state = synthesizer._channel_states[GeneratorName.PULSE1] + pulse_state = synthesizer._channels.state(GeneratorName.PULSE1) controller.set_nes_frequency(60) synthesizer.render_row() diff --git a/tests/unit/sampletones_application/services/render/__init__.py b/tests/unit/sampletones_application/services/render/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/services/render/conftest.py b/tests/unit/sampletones_application/services/render/conftest.py new file mode 100644 index 000000000..a67b0d581 --- /dev/null +++ b/tests/unit/sampletones_application/services/render/conftest.py @@ -0,0 +1,79 @@ +from pathlib import Path +from typing import Callable, Final, List, Optional, Tuple + +import numpy as np +import soundfile + +from sampletones_core.audio.writers import AudioOutputSpec, WaveOutputSpec +from sampletones_core.project.song_position import SongPosition + +SAMPLE_RATE: Final[int] = 44100 +ROW_SAMPLES: Final[int] = 735 +ROWS: Final[int] = 24 +TOTAL_SAMPLES: Final[int] = ROW_SAMPLES * ROWS +LEVEL: Final[float] = 0.25 + + +def wave_spec(sample_rate: int = SAMPLE_RATE) -> AudioOutputSpec: + return WaveOutputSpec(sample_rate=sample_rate) + + +class FakeSynthesizer: + """A kernel that renders a fixed number of identical rows, standing in for a song. + + Each row is a constant level, so a normalising pass has a peak to find and a written file + can be checked sample by sample without modelling a generator. + """ + + def __init__( + self, + *, + rows: int = ROWS, + row_samples: int = ROW_SAMPLES, + level: float = LEVEL, + on_row: Optional[Callable[[int], None]] = None, + error: Optional[Exception] = None, + ) -> None: + self._rows = rows + self._row_samples = row_samples + self._level = level + self._on_row = on_row + self._error = error + self.rendered: int = 0 + self.resets: int = 0 + self.positions: List[Tuple[int, int]] = [] + + @property + def order_position(self) -> int: + return self.rendered + + @property + def row_index(self) -> int: + return 0 + + @property + def is_finished(self) -> bool: + return self.rendered >= self._rows + + def set_position(self, order_position: int, row_index: int) -> None: + self.positions.append((order_position, row_index)) + + def reset(self) -> None: + self.resets += 1 + self.rendered = 0 + + def render_row(self) -> Tuple[np.ndarray, SongPosition]: + if self._error is not None and self.rendered == self._rows // 2: + raise self._error + + if self._on_row is not None: + self._on_row(self.rendered) + + self.rendered += 1 + row = np.full(self._row_samples, self._level, dtype=np.float32) + return row, SongPosition() + + +def read_samples(path: Path) -> np.ndarray: + audio, _ = soundfile.read(path, dtype="float32") + return np.asarray(audio, dtype=np.float32) diff --git a/tests/unit/sampletones_application/services/render/test_service.py b/tests/unit/sampletones_application/services/render/test_service.py new file mode 100644 index 000000000..2a1ce82c3 --- /dev/null +++ b/tests/unit/sampletones_application/services/render/test_service.py @@ -0,0 +1,270 @@ +from pathlib import Path +from typing import List + +import numpy as np +import pytest + +from sampletones_application.services.render.constants import SCRATCH_SUFFIX +from sampletones_application.services.render.result import RenderResult, RenderStage +from sampletones_application.services.render.service import SongRenderService +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceProgress, + ServiceStarted, + ServiceSuccess, +) +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.services.render.conftest import ( + LEVEL, + TOTAL_SAMPLES, + FakeSynthesizer, + read_samples, + wave_spec, +) + + +def _render( + destination: Path, + synthesizer: FakeSynthesizer, + *, + normalize: bool = False, + total_samples: int = TOTAL_SAMPLES, +) -> List[RenderResult]: + """Runs one render to completion, returning everything it reported.""" + service = SongRenderService() + results: List[RenderResult] = [] + service.subscribe(results.append) + service.start( + synthesizer=synthesizer, + destination=destination, + spec=wave_spec(), + normalize=normalize, + total_samples=total_samples, + ) + return results + + +def _progress(results: List[RenderResult], stage: RenderStage) -> List[ServiceProgress[RenderStage]]: + return [result for result in results if isinstance(result, ServiceProgress) and result.current_item is stage] + + +class TestARenderReachesItsFile(BaseTestSuite): + """A render that runs to the end leaves the whole song at the destination.""" + + def test_the_success_names_the_destination(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + results = _render(destination, FakeSynthesizer()) + + assert results[-1] == ServiceSuccess(value=destination) + + def test_the_file_holds_every_row(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + _render(destination, FakeSynthesizer()) + + assert len(read_samples(destination)) == TOTAL_SAMPLES + + def test_the_song_is_rendered_from_its_first_row(self, tmp_path: Path) -> None: + """A render describes the document, so where a listener left the playhead does not reach it.""" + synthesizer = FakeSynthesizer() + + _render(tmp_path / "song.wav", synthesizer) + + assert synthesizer.positions[0] == (0, 0) + assert synthesizer.resets == 1 + + def test_the_first_report_states_the_total(self, tmp_path: Path) -> None: + results = _render(tmp_path / "song.wav", FakeSynthesizer()) + + assert results[0] == ServiceStarted(total=TOTAL_SAMPLES) + + +class TestProgressIsReported(BaseTestSuite): + """Every pass reports the samples it has covered, against the total the song holds.""" + + def test_synthesis_progress_climbs_to_the_total(self, tmp_path: Path) -> None: + results = _render(tmp_path / "song.wav", FakeSynthesizer()) + reports = _progress(results, RenderStage.SYNTHESIS) + + assert [report.completed for report in reports] == sorted(report.completed for report in reports) + assert reports[-1].completed == TOTAL_SAMPLES + + def test_every_report_is_measured_against_the_song(self, tmp_path: Path) -> None: + results = _render(tmp_path / "song.wav", FakeSynthesizer()) + reports = _progress(results, RenderStage.SYNTHESIS) + + assert all(report.total == TOTAL_SAMPLES for report in reports) + + def test_a_direct_render_reports_one_pass(self, tmp_path: Path) -> None: + results = _render(tmp_path / "song.wav", FakeSynthesizer()) + + assert not _progress(results, RenderStage.ENCODING) + + def test_a_normalized_render_reports_both_passes(self, tmp_path: Path) -> None: + results = _render(tmp_path / "song.wav", FakeSynthesizer(), normalize=True) + + assert _progress(results, RenderStage.SYNTHESIS) + assert _progress(results, RenderStage.ENCODING) + + def test_the_encoding_pass_climbs_to_the_total(self, tmp_path: Path) -> None: + results = _render(tmp_path / "song.wav", FakeSynthesizer(), normalize=True) + reports = _progress(results, RenderStage.ENCODING) + + assert reports[-1].completed == TOTAL_SAMPLES + + +class TestNormalizing(BaseTestSuite): + """Normalising scales the whole render by what its loudest sample turned out to be.""" + + def test_the_peak_reaches_full_scale(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + _render(destination, FakeSynthesizer(), normalize=True) + + assert float(np.abs(read_samples(destination)).max()) == pytest.approx(1.0, abs=1e-4) + + def test_a_direct_render_keeps_the_level_it_was_given(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + _render(destination, FakeSynthesizer()) + + assert float(np.abs(read_samples(destination)).max()) == pytest.approx(LEVEL, abs=1e-4) + + def test_silence_is_written_as_it_stands(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + _render(destination, FakeSynthesizer(level=0.0), normalize=True) + + assert not float(np.abs(read_samples(destination)).max()) + + def test_the_spill_file_is_removed(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + _render(destination, FakeSynthesizer(), normalize=True) + + assert not (tmp_path / f"song.wav{SCRATCH_SUFFIX}").exists() + + +class TestCancelling(BaseTestSuite): + """A cancelled render reports itself cancelled and names no file.""" + + def _cancelling_synthesizer(self, service: SongRenderService) -> FakeSynthesizer: + return FakeSynthesizer(on_row=lambda rendered: service.cancel() if rendered == 4 else None) + + def test_a_cancelled_render_leaves_no_file(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + service = SongRenderService() + service.start( + synthesizer=self._cancelling_synthesizer(service), + destination=destination, + spec=wave_spec(), + normalize=False, + total_samples=TOTAL_SAMPLES, + ) + + assert not destination.exists() + + def test_a_cancelled_render_reports_itself_cancelled(self, tmp_path: Path) -> None: + service = SongRenderService() + results: List[RenderResult] = [] + service.subscribe(results.append) + service.start( + synthesizer=self._cancelling_synthesizer(service), + destination=tmp_path / "song.wav", + spec=wave_spec(), + normalize=False, + total_samples=TOTAL_SAMPLES, + ) + + assert results[-1] == ServiceCancelled() + + def test_a_cancelled_normalized_render_leaves_no_spill(self, tmp_path: Path) -> None: + service = SongRenderService() + service.start( + synthesizer=self._cancelling_synthesizer(service), + destination=tmp_path / "song.wav", + spec=wave_spec(), + normalize=True, + total_samples=TOTAL_SAMPLES, + ) + + assert not list(tmp_path.iterdir()) + + +class TestFailing(BaseTestSuite): + """A render that raises reports the failure and takes its partial file with it.""" + + def test_the_failure_is_reported(self, tmp_path: Path) -> None: + error = RuntimeError("no sample") + + results = _render(tmp_path / "song.wav", FakeSynthesizer(error=error)) + reported = results[-1] + + assert isinstance(reported, ServiceError) + assert reported.exception is error + + def test_the_partial_file_is_removed(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + + _render(destination, FakeSynthesizer(error=RuntimeError("no sample"))) + + assert not destination.exists() + + def test_a_failing_render_stops_running(self, tmp_path: Path) -> None: + service = SongRenderService() + service.start( + synthesizer=FakeSynthesizer(error=RuntimeError("no sample")), + destination=tmp_path / "song.wav", + spec=wave_spec(), + normalize=False, + total_samples=TOTAL_SAMPLES, + ) + + assert not service.is_running() + + +class TestOneRenderAtATime(BaseTestSuite): + """A render holds the service until it finishes, so a second request is declined.""" + + def test_a_request_arriving_mid_render_is_declined(self, tmp_path: Path) -> None: + service = SongRenderService() + declined: List[bool] = [] + + def request_again(rendered: int) -> None: + if rendered: + return + + declined.append( + service.start( + synthesizer=FakeSynthesizer(), + destination=tmp_path / "second.wav", + spec=wave_spec(), + normalize=False, + total_samples=TOTAL_SAMPLES, + ) + ) + + service.start( + synthesizer=FakeSynthesizer(on_row=request_again), + destination=tmp_path / "song.wav", + spec=wave_spec(), + normalize=False, + total_samples=TOTAL_SAMPLES, + ) + + assert declined == [False] + assert not (tmp_path / "second.wav").exists() + + def test_the_service_is_free_once_a_render_finishes(self, tmp_path: Path) -> None: + service = SongRenderService() + service.start( + synthesizer=FakeSynthesizer(), + destination=tmp_path / "song.wav", + spec=wave_spec(), + normalize=False, + total_samples=TOTAL_SAMPLES, + ) + + assert not service.is_running() diff --git a/tests/unit/sampletones_application/services/render/test_sink.py b/tests/unit/sampletones_application/services/render/test_sink.py new file mode 100644 index 000000000..38fe6160d --- /dev/null +++ b/tests/unit/sampletones_application/services/render/test_sink.py @@ -0,0 +1,158 @@ +from pathlib import Path +from typing import List + +import numpy as np +import pytest + +from sampletones_application.services.render.constants import SCRATCH_SUFFIX +from sampletones_application.services.render.scratch import ScratchAudio +from sampletones_application.services.render.sink import ( + DirectRenderSink, + NormalizingRenderSink, + build_render_sink, +) +from sampletones_shared.exceptions import AudioWriteError +from tests.suite.base import BaseTestSuite +from tests.unit.sampletones_application.services.render.conftest import ( + read_samples, + wave_spec, +) + +KEEP_GOING = True + + +def _rows(count: int, samples: int, level: float) -> List[np.ndarray]: + return [np.full(samples, level, dtype=np.float32) for _ in range(count)] + + +class TestTheSinkIsChosenByTheLevelChoice(BaseTestSuite): + def test_a_plain_render_writes_straight_out(self, tmp_path: Path) -> None: + sink = build_render_sink(tmp_path / "song.wav", wave_spec(), normalize=False) + + assert isinstance(sink, DirectRenderSink) + + def test_a_normalized_render_spills_first(self, tmp_path: Path) -> None: + sink = build_render_sink(tmp_path / "song.wav", wave_spec(), normalize=True) + + assert isinstance(sink, NormalizingRenderSink) + + +class TestTheSinkOwnsItsFile(BaseTestSuite): + def test_writing_outside_the_block_is_refused(self, tmp_path: Path) -> None: + sink = DirectRenderSink(tmp_path / "song.wav", wave_spec()) + + with pytest.raises(AudioWriteError, match="write within the sink's context"): + sink.write(np.zeros(4, dtype=np.float32)) + + def test_spilling_outside_the_block_is_refused(self, tmp_path: Path) -> None: + sink = NormalizingRenderSink(tmp_path / "song.wav", wave_spec()) + + with pytest.raises(AudioWriteError, match="write between start and seal"): + sink.write(np.zeros(4, dtype=np.float32)) + + def test_discarding_removes_the_destination(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + sink = DirectRenderSink(destination, wave_spec()) + with sink: + sink.write(np.zeros(64, dtype=np.float32)) + + sink.discard() + + assert not destination.exists() + + def test_discarding_a_render_that_never_ran_is_harmless(self, tmp_path: Path) -> None: + sink = DirectRenderSink(tmp_path / "song.wav", wave_spec()) + + sink.discard() + + assert not list(tmp_path.iterdir()) + + +class TestNormalizingSink(BaseTestSuite): + def _write(self, sink: NormalizingRenderSink, rows: List[np.ndarray]) -> bool: + with sink: + for row in rows: + sink.write(row) + + return sink.finish(lambda _encoded: KEEP_GOING) + + def test_the_loudest_row_sets_the_scale_for_every_row(self, tmp_path: Path) -> None: + destination = tmp_path / "song.wav" + sink = NormalizingRenderSink(destination, wave_spec()) + + self._write(sink, [*_rows(1, 32, 0.1), *_rows(1, 32, 0.5)]) + written = read_samples(destination) + + assert float(written[:32].max()) == pytest.approx(0.2, abs=1e-4) + assert float(written[32:].max()) == pytest.approx(1.0, abs=1e-4) + + def test_a_pass_stopped_partway_reports_the_file_unfinished(self, tmp_path: Path) -> None: + sink = NormalizingRenderSink(tmp_path / "song.wav", wave_spec()) + + with sink: + sink.write(np.full(4, 0.5, dtype=np.float32)) + completed = sink.finish(lambda _encoded: not KEEP_GOING) + + assert not completed + + def test_the_spill_stands_beside_the_destination(self, tmp_path: Path) -> None: + sink = NormalizingRenderSink(tmp_path / "song.wav", wave_spec()) + + with sink: + sink.write(np.full(4, 0.5, dtype=np.float32)) + + assert (tmp_path / f"song.wav{SCRATCH_SUFFIX}").exists() + + +class TestScratchAudio(BaseTestSuite): + """The spill file holds what it was given, and reports what it holds.""" + + def test_the_samples_read_back_in_the_order_they_were_written(self, tmp_path: Path) -> None: + scratch = ScratchAudio(tmp_path / "spill") + written = np.arange(10, dtype=np.float32) + + scratch.start() + scratch.write(written[:4]) + scratch.write(written[4:]) + scratch.seal() + + assert np.array_equal(np.concatenate(list(scratch.blocks(3))), written) + + def test_the_blocks_are_bounded_by_the_size_asked_for(self, tmp_path: Path) -> None: + scratch = ScratchAudio(tmp_path / "spill") + + scratch.start() + scratch.write(np.zeros(10, dtype=np.float32)) + scratch.seal() + + assert [len(block) for block in scratch.blocks(4)] == [4, 4, 2] + + def test_the_peak_spans_every_chunk(self, tmp_path: Path) -> None: + scratch = ScratchAudio(tmp_path / "spill") + + scratch.start() + scratch.write(np.full(4, 0.2, dtype=np.float32)) + scratch.write(np.full(4, -0.7, dtype=np.float32)) + scratch.write(np.full(4, 0.3, dtype=np.float32)) + scratch.seal() + + assert scratch.peak == pytest.approx(0.7) + assert scratch.samples == 12 + + def test_sealing_twice_is_harmless(self, tmp_path: Path) -> None: + scratch = ScratchAudio(tmp_path / "spill") + + scratch.start() + scratch.seal() + scratch.seal() + + assert not scratch.samples + + def test_removing_clears_the_spill(self, tmp_path: Path) -> None: + scratch = ScratchAudio(tmp_path / "spill") + + scratch.start() + scratch.seal() + scratch.remove() + + assert not scratch.path.exists() From 1b2c299d3b6fbd0795278bea8c152e11c2e7b09a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 00:28:06 +0200 Subject: [PATCH 060/152] Added: project source seam for rendering --- .../logic/history/manager.py | 3 +- .../logic/history/snapshot.py | 19 +----- .../playback/synthesizer/synthesizer.py | 24 +++---- .../logic/sequencer/renderer.py | 31 --------- .../logic/shared/project_source.py | 55 ++++++++++++++++ .../coordinators/tabs/test_sequencer.py | 6 +- .../logic/history/test_fingerprint.py | 2 +- .../logic/history/test_snapshot.py | 27 -------- .../sequencer/playback/test_synthesizer.py | 8 ++- .../logic/shared/test_project_source.py | 65 +++++++++++++++++++ 10 files changed, 144 insertions(+), 96 deletions(-) delete mode 100644 src/sampletones_application/logic/sequencer/renderer.py create mode 100644 src/sampletones_application/logic/shared/project_source.py delete mode 100644 tests/unit/sampletones_application/logic/history/test_snapshot.py create mode 100644 tests/unit/sampletones_application/logic/shared/test_project_source.py diff --git a/src/sampletones_application/logic/history/manager.py b/src/sampletones_application/logic/history/manager.py index ac591141c..62bb8677c 100644 --- a/src/sampletones_application/logic/history/manager.py +++ b/src/sampletones_application/logic/history/manager.py @@ -3,6 +3,7 @@ from typing import Iterator, List, Optional, Tuple from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_application.view_model.shared.history import HistoryDetail from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -11,7 +12,7 @@ from .action import HistoryAction from .errors import HistoryIntegrityError, UntrackedMutationError from .fingerprint import ReconstructionHashCache, fingerprint_project -from .snapshot import HistoryEntry, snapshot_project +from .snapshot import HistoryEntry from .transaction import CoalesceKey, PendingTransaction diff --git a/src/sampletones_application/logic/history/snapshot.py b/src/sampletones_application/logic/history/snapshot.py index d3ed3c4af..711fe0f90 100644 --- a/src/sampletones_application/logic/history/snapshot.py +++ b/src/sampletones_application/logic/history/snapshot.py @@ -1,7 +1,6 @@ -import copy from dataclasses import dataclass, field from datetime import datetime -from typing import Dict, Optional +from typing import Optional from sampletones_application.view_model.shared.history import HistoryDetail from sampletones_core.project import Project @@ -9,22 +8,6 @@ from .action import HistoryAction -def snapshot_project(project: Project) -> Project: - """Captures an independent copy of a project that shares reconstruction audio. - - The song, settings, metadata and sample shells are deep-copied so later edits - to the live project leave the snapshot untouched. Each sample's reconstruction - is shared by reference, so the snapshot reuses those multi-megabyte audio - arrays. Reconstruction edits are copy-on-write — each installs a fresh - reconstruction — so the shared reconstruction stays valid for the life of the - snapshot. - """ - shared_reconstructions: Dict[int, object] = { - id(sample.reconstruction): sample.reconstruction for sample in project.samples - } - return copy.deepcopy(project, shared_reconstructions) - - @dataclass(frozen=True) class HistoryEntry: """One committed state in the history stack. diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py index 34aeb1f77..0ded782ed 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -3,7 +3,7 @@ import numpy as np -from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.shared.project_source import ProjectSource from sampletones_core.audio import clip_audio_inplace, silence from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName @@ -26,11 +26,13 @@ class RowSynthesizer: - """Real-time synthesis engine for tracker song playback. + """Synthesis engine for tracker song audio, one row at a time. - Reads the live ``Project`` from ``project_controller`` on every ``render_row`` - call so that pattern edits, tempo changes, and sample swaps take effect - immediately while playback keeps running. + Reads the ``Project`` from ``project_source`` on every ``render_row`` call. Over the live + controller that makes pattern edits, tempo changes, and sample swaps take effect immediately + while playback keeps running; over a + :class:`~sampletones_application.logic.shared.project_source.ProjectSnapshot` it makes a whole + render describe one state of the document. A row lasts the ticks the project's groove gives its position within the pattern, so the row a pattern's tenth row plays for is the row an exported module plays it for: both index @@ -56,17 +58,17 @@ class RowSynthesizer: def __init__( self, - project_controller: ProjectController, + project_source: ProjectSource, config: Config, *, active_channels: Callable[[], FrozenSet[GeneratorName]], sample_rate: Callable[[], int], ) -> None: - self._project_controller = project_controller + self._project_source = project_source self._active_channels = active_channels self._sample_rate = sample_rate self._position = SongPosition() - self._timing: SongTiming = SongTiming.from_project(project_controller.project) + self._timing: SongTiming = SongTiming.from_project(project_source.project) self._groove: Groove = self._timing.groove() self._channels = ChannelBank(config, self._current_rates()) self._elapsed_ticks: int = 0 @@ -81,7 +83,7 @@ def row_index(self) -> int: @property def is_finished(self) -> bool: - project = self._project_controller.project + project = self._project_source.project return self._position.order_position >= project.song.order_length() def set_position(self, order_position: int, row_index: int) -> None: @@ -93,7 +95,7 @@ def reset(self) -> None: self._channels.reset() def render_row(self) -> Tuple[np.ndarray, SongPosition]: - project = self._project_controller.project + project = self._project_source.project song = project.song self._position.wrap_overflow(song.rows_per_pattern) self._channels.follow(self._current_rates()) @@ -125,7 +127,7 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: def _current_rates(self) -> EngineRates: return EngineRates.from_project( - self._project_controller.project, + self._project_source.project, self._sample_rate(), ) diff --git a/src/sampletones_application/logic/sequencer/renderer.py b/src/sampletones_application/logic/sequencer/renderer.py deleted file mode 100644 index 7c8bf44df..000000000 --- a/src/sampletones_application/logic/sequencer/renderer.py +++ /dev/null @@ -1,31 +0,0 @@ -from typing import Protocol - -import numpy as np - -from sampletones_core.project import Project - - -class SongRenderer(Protocol): - """Renders a project's song into a single playable mono waveform. - - This is the seam between the sequencer and audio output. An implementation - walks each channel's ``order`` → ``patterns`` → ``rows``, feeds every active - row's referenced sample reconstruction instructions (shifted by the row - ``transpose`` and scaled by ``volume``) through the matching - :class:`~sampletones_core.generators.generator.Generator`, advancing one - tracker row every ``speed`` engine ticks at the project ``tempo`` / - ``nes_frequency``, then mixes the four channels into one buffer. - """ - - def render(self, project: Project) -> np.ndarray: ... - - -class UnimplementedSongRenderer: - """Placeholder renderer until the synthesis engine lands. - - Kept as a concrete type so the sequencer can hold a renderer reference and - fail loudly if play is wired before the engine exists. - """ - - def render(self, project: Project) -> np.ndarray: - raise NotImplementedError("Song rendering is not implemented yet; see SongRenderer.") diff --git a/src/sampletones_application/logic/shared/project_source.py b/src/sampletones_application/logic/shared/project_source.py new file mode 100644 index 000000000..208546746 --- /dev/null +++ b/src/sampletones_application/logic/shared/project_source.py @@ -0,0 +1,55 @@ +import copy +from dataclasses import dataclass +from typing import Dict, Protocol, Self + +from sampletones_core.project import Project + + +def snapshot_project(project: Project) -> Project: + """Captures an independent copy of a project that shares reconstruction audio. + + The song, settings, metadata and sample shells are deep-copied so later edits + to the live project leave the snapshot untouched. Each sample's reconstruction + is shared by reference, so the snapshot reuses those multi-megabyte audio + arrays. Reconstruction edits are copy-on-write — each installs a fresh + reconstruction — so the shared reconstruction stays valid for the life of the + snapshot. + """ + shared_reconstructions: Dict[int, object] = { + id(sample.reconstruction): sample.reconstruction for sample in project.samples + } + return copy.deepcopy(project, shared_reconstructions) + + +class ProjectSource(Protocol): + """Where a reader of the open document finds the project it works on. + + A reader of the song needs the project and nothing else about where it came from. + :class:`~sampletones_application.logic.project.controller.ProjectController` satisfies this, so + playback follows every edit as it is made; :class:`ProjectSnapshot` satisfies it too, so a long + operation describes the document as it stood when it was asked for. Depending on this protocol + is what lets one synthesis kernel serve both. + """ + + @property + def project(self) -> Project: ... + + +@dataclass(frozen=True) +class ProjectSnapshot: + """One project held still, the document a long operation reads. + + A render walks the whole song on a worker thread while the user keeps editing. Reading a + snapshot makes the result describe one state of the document: the state it was requested in, + from the first row to the last. + + Attributes: + project: The document as it stood when the snapshot was taken. + """ + + project: Project + + @classmethod + def capture(cls, source: ProjectSource) -> Self: + """Takes the document ``source`` currently holds, copied through :func:`snapshot_project`.""" + return cls(project=snapshot_project(source.project)) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 464f0aafe..cda08806e 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -12,16 +12,14 @@ from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator from sampletones_application.logic.history.action import HistoryAction from sampletones_application.logic.history.manager import HistoryManager -from sampletones_application.logic.history.snapshot import ( - HistoryEntry, - snapshot_project, -) +from sampletones_application.logic.history.snapshot import HistoryEntry from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.channels import ( ALL_CHANNELS, SequencerChannelsLogic, ) +from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_application.paths import LANG_EN from sampletones_application.ui.panels.sequencer import channels as channels_module from sampletones_application.ui.panels.sequencer import tracker as tracker_module diff --git a/tests/unit/sampletones_application/logic/history/test_fingerprint.py b/tests/unit/sampletones_application/logic/history/test_fingerprint.py index d5f054fec..a1f08ffdd 100644 --- a/tests/unit/sampletones_application/logic/history/test_fingerprint.py +++ b/tests/unit/sampletones_application/logic/history/test_fingerprint.py @@ -8,8 +8,8 @@ ReconstructionHashCache, fingerprint_project, ) -from sampletones_application.logic.history.snapshot import snapshot_project from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_core.reconstructions import Reconstruction from sampletones_shared.utils.serialization import hash_model from tests.conftest import ReconstructionFactory diff --git a/tests/unit/sampletones_application/logic/history/test_snapshot.py b/tests/unit/sampletones_application/logic/history/test_snapshot.py deleted file mode 100644 index 4e15bcb7b..000000000 --- a/tests/unit/sampletones_application/logic/history/test_snapshot.py +++ /dev/null @@ -1,27 +0,0 @@ -from typing import Callable - -from sampletones_application.logic.history.snapshot import snapshot_project -from sampletones_application.logic.project.controller import ProjectController -from sampletones_core.reconstructions import Reconstruction - - -class TestSnapshotIndependence: - def test_light_structure_is_deep_copied(self, project_controller: ProjectController) -> None: - project_controller.set_tempo(120) - - snapshot = snapshot_project(project_controller.project) - project_controller.set_tempo(200) - - assert snapshot.settings.tempo == 120 - assert snapshot.song is not project_controller.project.song - - def test_reconstruction_audio_is_shared( - self, - project_controller: ProjectController, - reconstruction_factory: Callable[[], Reconstruction], - ) -> None: - sample = project_controller.add_sample(reconstruction_factory(), name="lead") - - snapshot = snapshot_project(project_controller.project) - - assert snapshot.samples[sample.id].reconstruction is sample.reconstruction diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py index 53e728640..71a2e6a79 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -47,6 +47,7 @@ def __call__(self) -> FrozenSet[GeneratorName]: @dataclass class SynthesizerContext: synthesizer: RowSynthesizer + controller: ProjectController mask: MaskProvider chunks: List[np.ndarray] = field(default_factory=list) tick_snapshots: Dict[str, int] = field(default_factory=dict) @@ -58,12 +59,13 @@ def _make_context() -> SynthesizerContext: mask = MaskProvider() return SynthesizerContext( synthesizer=make_synthesizer(controller, Config(), active_channels=mask), + controller=controller, mask=mask, ) -def _controller(context: SynthesizerContext): - return context.synthesizer._project_controller +def _controller(context: SynthesizerContext) -> ProjectController: + return context.controller def _state( @@ -95,7 +97,7 @@ def _row_ticks( rows: int, ) -> Tuple[int, ...]: """The ticks ``rows`` consecutive rendered rows last, read back from the audio they produced.""" - settings = synthesizer._project_controller.project.settings + settings = synthesizer._project_source.project.settings frame_length = round(settings.sample_rate / settings.nes_frequency) return tuple(len(synthesizer.render_row()[0]) // frame_length for _ in range(rows)) diff --git a/tests/unit/sampletones_application/logic/shared/test_project_source.py b/tests/unit/sampletones_application/logic/shared/test_project_source.py new file mode 100644 index 000000000..4d8b081ca --- /dev/null +++ b/tests/unit/sampletones_application/logic/shared/test_project_source.py @@ -0,0 +1,65 @@ +from typing import Callable + +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.shared.project_source import ( + ProjectSnapshot, + ProjectSource, + snapshot_project, +) +from sampletones_core.reconstructions import Reconstruction +from tests.suite.base import BaseTestSuite + + +@pytest.fixture +def project_controller() -> ProjectController: + return ProjectController(ProjectManager()) + + +class TestSnapshotIndependence(BaseTestSuite): + def test_light_structure_is_deep_copied(self, project_controller: ProjectController) -> None: + project_controller.set_tempo(120) + + snapshot = snapshot_project(project_controller.project) + project_controller.set_tempo(200) + + assert snapshot.settings.tempo == 120 + assert snapshot.song is not project_controller.project.song + + def test_reconstruction_audio_is_shared( + self, + project_controller: ProjectController, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + sample = project_controller.add_sample(reconstruction_factory(), name="lead") + + snapshot = snapshot_project(project_controller.project) + + assert snapshot.samples[sample.id].reconstruction is sample.reconstruction + + +class TestASnapshotIsASource(BaseTestSuite): + """A captured document reads as the source a synthesiser takes.""" + + def test_the_live_controller_is_a_source(self, project_controller: ProjectController) -> None: + source: ProjectSource = project_controller + + assert source.project is project_controller.project + + def test_a_snapshot_is_a_source(self, project_controller: ProjectController) -> None: + source: ProjectSource = ProjectSnapshot.capture(project_controller) + + assert source.project.settings.tempo == project_controller.project.settings.tempo + + def test_the_document_stands_still_while_the_project_moves_on( + self, + project_controller: ProjectController, + ) -> None: + project_controller.set_tempo(120) + + snapshot = ProjectSnapshot.capture(project_controller) + project_controller.set_tempo(200) + + assert snapshot.project.settings.tempo == 120 From 4d2482a3656d146f088b483b8543a3aed08ce3e6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 00:50:02 +0200 Subject: [PATCH 061/152] Added: render logic and view model for song rendering --- .../categories/hierarchy.py | 1 + .../logic/project/controller.py | 5 + .../logic/render/__init__.py | 7 + .../logic/render/logic.py | 308 ++++++++++++++ .../logic/render/protocol.py | 34 ++ .../playback/synthesizer/__init__.py | 2 + .../sequencer/playback/synthesizer/length.py | 43 ++ .../view_model/shared/display_settings.py | 12 +- .../view_model/shared/nearest.py | 24 ++ .../view_model/shared/render.py | 264 ++++++++++++ src/sampletones_config/lang/en.yaml | 6 + src/sampletones_shared/utils/system/paths.py | 24 ++ .../logic/render/__init__.py | 0 .../logic/render/test_logic.py | 378 ++++++++++++++++++ .../sequencer/playback/test_song_length.py | 68 ++++ .../view_model/shared/test_nearest.py | 18 + .../view_model/shared/test_render.py | 158 ++++++++ .../utils/system/test_paths.py | 62 +++ 18 files changed, 1404 insertions(+), 10 deletions(-) create mode 100644 src/sampletones_application/logic/render/__init__.py create mode 100644 src/sampletones_application/logic/render/logic.py create mode 100644 src/sampletones_application/logic/render/protocol.py create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/length.py create mode 100644 src/sampletones_application/view_model/shared/nearest.py create mode 100644 src/sampletones_application/view_model/shared/render.py create mode 100644 tests/unit/sampletones_application/logic/render/__init__.py create mode 100644 tests/unit/sampletones_application/logic/render/test_logic.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py create mode 100644 tests/unit/sampletones_application/view_model/shared/test_nearest.py create mode 100644 tests/unit/sampletones_application/view_model/shared/test_render.py diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py index 50d32efcc..0a5fbfa43 100644 --- a/src/sampletones_application/categories/hierarchy.py +++ b/src/sampletones_application/categories/hierarchy.py @@ -95,3 +95,4 @@ class Panel(StrEnum): DISPLAY = auto() KEYBINDINGS = auto() PROPERTIES = auto() + RENDER = auto() diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index fc5d9c95b..1c5d1352a 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -53,6 +53,11 @@ def order_length(self) -> int: def is_open(self) -> bool: return self._project_manager.is_open + @property + def name(self) -> str: + """The name the open project is known by, which a project saved to a file takes from it.""" + return self._project_manager.name + @property def has_samples(self) -> bool: return bool(self.project.samples) diff --git a/src/sampletones_application/logic/render/__init__.py b/src/sampletones_application/logic/render/__init__.py new file mode 100644 index 000000000..d44f30fdd --- /dev/null +++ b/src/sampletones_application/logic/render/__init__.py @@ -0,0 +1,7 @@ +from .logic import SongRenderLogic +from .protocol import SongRenderServiceProtocol + +__all__ = [ + "SongRenderLogic", + "SongRenderServiceProtocol", +] diff --git a/src/sampletones_application/logic/render/logic.py b/src/sampletones_application/logic/render/logic.py new file mode 100644 index 000000000..0cc91e353 --- /dev/null +++ b/src/sampletones_application/logic/render/logic.py @@ -0,0 +1,308 @@ +from pathlib import Path +from typing import Callable, Dict, Optional, Tuple + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.config.managers.session import SessionManager +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.channels import ALL_CHANNELS +from sampletones_application.logic.sequencer.playback.synthesizer import ( + RowSynthesizer, + SongLength, +) +from sampletones_application.logic.shared.project_source import ProjectSnapshot +from sampletones_application.services.render.result import RenderResult, RenderStage +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceProgress, + ServiceStarted, + ServiceSuccess, +) +from sampletones_application.view_model.shared.render import ( + ACTIVE_PHASES, + RenderPhase, + SongRenderSettings, + SongRenderViewModel, +) +from sampletones_core.audio.writers import ( + DEFAULT_AUDIO_FORMAT, + AudioFormat, + available_audio_formats, + available_depths, +) +from sampletones_core.parallelization import ETAEstimator +from sampletones_shared.constants.project import DEFAULT_EXPORT_NAME +from sampletones_shared.logger import logger +from sampletones_shared.types.callback import PathCallback, VoidCallback +from sampletones_shared.utils.callbacks import CallbackMixin +from sampletones_shared.utils.system.paths import get_filename, replace_suffix + +from .protocol import SongRenderServiceProtocol + + +class SongRenderLogic(CallbackMixin): + """Owns writing the open song to an audio file: what is written, where, and how far it has got. + + The song is rendered through the engine that plays it, over the document as it stood when the + render was asked for, so a file describes one state of the project however the editing goes + on. The rate is whatever the chosen format is written at, which the engine follows, so the + tempo the groove states is the tempo the file holds. + + A render is an exclusive operation, held from the moment the dialog opens until it closes, so + the phase alone reports whether the application is busy with one. + """ + + def __init__( + self, + project_controller: ProjectController, + config_manager: ConfigManager, + session_manager: SessionManager, + render_service: SongRenderServiceProtocol, + *, + language_manager: LanguageManager, + is_operation_active: Callable[[], bool], + ) -> None: + self._project_controller = project_controller + self._config_manager = config_manager + self._session_manager = session_manager + self._service = render_service + self._is_operation_active = is_operation_active + self._msg_cancelling = language_manager["settings.render.message.status_cancelling"] + self._msg_cancelled = language_manager["settings.render.message.status_cancelled"] + self._msg_completed = language_manager["settings.render.message.status_completed"] + self._msg_failed = language_manager["settings.render.message.status_failed"] + self._eta_template = language_manager["global.dialog.template.time_estimation"] + self._stage_messages: Dict[RenderStage, str] = { + RenderStage.SYNTHESIS: language_manager["settings.render.message.status_synthesis"], + RenderStage.ENCODING: language_manager["settings.render.message.status_encoding"], + } + + self._formats: Tuple[AudioFormat, ...] = available_audio_formats() + self._settings = SongRenderSettings.initial(self._offered_format()) + self._phase: RenderPhase = RenderPhase.IDLE + self._destination: Optional[Path] = None + self._status_text: str = "" + self._progress: float = 0.0 + + self._service.subscribe(self._on_service_result) + + self.on_view_changed: Optional[Callable[[SongRenderViewModel], None]] = None + self.on_choose_destination: Optional[Callable[[Path, AudioFormat], None]] = None + self.on_success: Optional[PathCallback] = None + self.on_error: Optional[Callable[[Exception], None]] = None + self.on_cancelled: Optional[VoidCallback] = None + + @property + def is_active(self) -> bool: + """A render occupies the application from the dialog opening until it closes.""" + return self._phase in ACTIVE_PHASES + + def open(self) -> bool: + """Offers the render settings for the open song, reporting whether the dialog took over. + + The destination is proposed afresh each time, so it carries the name the project is known + by into the directory audio was last written to. This is where the exclusivity is claimed, + which is why the busy authority is asked here and nowhere else along the way. + """ + if self._is_operation_active(): + logger.warning("An exclusive operation is already in progress; the render was not offered") + return False + + self._phase = RenderPhase.CONFIGURING + self._destination = self._proposed_destination() + self._status_text = "" + self._progress = 0.0 + self._emit_view() + return True + + def close(self) -> None: + """Returns to idle once the dialog is done with, releasing the application.""" + self._phase = RenderPhase.IDLE + self._progress = 0.0 + + def apply(self, settings: SongRenderSettings) -> None: + """Takes the choices the dialog stands at, renaming the destination after the format. + + Args: + settings: The reconciled choices the dialog reports. + """ + if self._phase != RenderPhase.CONFIGURING: + return + + previous = self._settings.spec.extension + self._settings = settings + extension = settings.spec.extension + if extension != previous: + self._destination = replace_suffix(self._require_destination(), previous, extension) + + self._emit_view() + + def request_destination(self) -> None: + """Asks for the file the render writes, starting from the one standing.""" + self.call( + self.on_choose_destination, + self._require_destination(), + self._settings.spec.audio_format, + ) + + def set_destination(self, destination: Path) -> None: + """Writes the render to ``destination``, remembering its directory for the next one.""" + self._destination = destination + self._session_manager.set_audio_path(destination) + self._emit_view() + + def start(self) -> None: + """Renders the song to the chosen file, from its first row to its last. + + The kernel is built here, over a snapshot of the document and at the rate the chosen + format is written at, so the worker reads a project that stands still while the editing + carries on. + """ + if self._phase != RenderPhase.CONFIGURING: + return + + length = self._length() + if length.samples <= 0: + logger.warning("The song holds no rows to render") + return + + started = self._service.start( + synthesizer=self._build_synthesizer(), + destination=self._require_destination(), + spec=self._settings.spec, + normalize=self._settings.normalize, + total_samples=length.samples, + ) + if not started: + return + + self._phase = RenderPhase.RENDERING + self._status_text = self._stage_messages[RenderStage.SYNTHESIS] + self._progress = 0.0 + self._emit_view() + + def cancel(self) -> None: + """Asks a running render to stop at its next row or block.""" + if not self._service.is_running(): + return + + self._phase = RenderPhase.CANCELLING + self._status_text = self._msg_cancelling + self._emit_view() + self._service.cancel() + + def cleanup(self) -> None: + """Winds a running render down for application exit.""" + self._service.shutdown() + + def _on_service_result(self, result: RenderResult) -> None: + match result: + case ServiceStarted(): + self._report(self._stage_messages[RenderStage.SYNTHESIS], 0.0) + case ServiceProgress() as progress: + self._handle_progress(progress) + case ServiceSuccess(value=destination): + self._on_render_complete(destination) + case ServiceError(exception=exception): + self._on_render_error(exception) + case ServiceCancelled(): + self._on_cancellation_complete() + + def _handle_progress(self, progress: ServiceProgress[RenderStage]) -> None: + """Puts a pass's report on the bar, holding the message a stop was asked under.""" + if self._phase == RenderPhase.CANCELLING: + return + + self._phase = RenderPhase.RENDERING + stage = progress.current_item + status_text = self._status_text if stage is None else self._stage_status(stage, progress.eta_seconds) + self._report(status_text, progress.completed / max(progress.total, 1)) + + def _stage_status(self, stage: RenderStage, eta_seconds: Optional[float]) -> str: + """What the pass is doing, and how long it has left where an estimate stands.""" + status_text = self._stage_messages[stage] + eta_string = ETAEstimator.format_duration(eta_seconds) + if eta_string: + status_text += self._eta_template.format(eta_string=eta_string) + + return status_text + + def _on_render_complete(self, destination: Path) -> None: + self._phase = RenderPhase.COMPLETED + self._report(self._msg_completed, 1.0) + self.call(self.on_success, destination) + + def _on_render_error(self, exception: Exception) -> None: + self._phase = RenderPhase.FAILED + self._report(self._msg_failed, 0.0) + self.call(self.on_error, exception) + + def _on_cancellation_complete(self) -> None: + self._phase = RenderPhase.CANCELLED + self._report(self._msg_cancelled, 0.0) + self.call(self.on_cancelled) + + def _report(self, status_text: str, progress: float) -> None: + self._status_text = status_text + self._progress = progress + self._emit_view() + + def _build_synthesizer(self) -> RowSynthesizer: + """The kernel a render runs on: the engine that plays the song, over a held document. + + Every channel sounds and the level stays at unity, since muting and the master gain are + choices a listener makes about what reaches the speakers, while a render describes the + document. + """ + sample_rate = self._settings.spec.sample_rate + return RowSynthesizer( + ProjectSnapshot.capture(self._project_controller), + self._config_manager.config.with_library(sample_rate=sample_rate), + active_channels=lambda: ALL_CHANNELS, + sample_rate=lambda: sample_rate, + ) + + def _length(self) -> SongLength: + return SongLength.measure( + self._project_controller.project, + sample_rate=self._settings.spec.sample_rate, + ) + + def _offered_format(self) -> AudioFormat: + """The container a dialog opens on: the usual one, or the first this installation writes.""" + if DEFAULT_AUDIO_FORMAT in self._formats: + return DEFAULT_AUDIO_FORMAT + + return next(iter(self._formats), DEFAULT_AUDIO_FORMAT) + + def _proposed_destination(self) -> Path: + """The file the dialog opens on: the project's name, where audio was last written.""" + name = self._project_controller.name or DEFAULT_EXPORT_NAME + return self._session_manager.get_audio_path() / get_filename(name, self._settings.spec.extension) + + def _require_destination(self) -> Path: + """The file the open dialog writes to. + + Raises: + SystemError: when a render is driven while its dialog is closed. + """ + if self._destination is None: + raise SystemError("A render is set up only while its dialog is open") + + return self._destination + + def _emit_view(self) -> None: + self.call(self.on_view_changed, self._build_view()) + + def _build_view(self) -> SongRenderViewModel: + return SongRenderViewModel( + phase=self._phase, + formats=self._formats, + depths=available_depths(self._settings.spec.audio_format), + settings=self._settings, + destination=self._require_destination(), + total_samples=self._length().samples, + status_text=self._status_text, + progress=self._progress, + ) diff --git a/src/sampletones_application/logic/render/protocol.py b/src/sampletones_application/logic/render/protocol.py new file mode 100644 index 000000000..03461dbdc --- /dev/null +++ b/src/sampletones_application/logic/render/protocol.py @@ -0,0 +1,34 @@ +from pathlib import Path +from typing import Callable, Protocol + +from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer +from sampletones_application.services.render.result import RenderResult +from sampletones_core.audio.writers import AudioOutputSpec + + +class SongRenderServiceProtocol(Protocol): + """The slice of the render service the render logic drives. + + Typing the collaborator structurally keeps the logic layer bound to the service's result + contract alone; the composition root supplies the real service. The kernel named here is the + one the logic builds, which the service takes through the wider contract every consumer of a + song's audio is written against. + """ + + def subscribe(self, handler: Callable[[RenderResult], None]) -> None: ... + + def start( + self, + *, + synthesizer: RowSynthesizer, + destination: Path, + spec: AudioOutputSpec, + normalize: bool, + total_samples: int, + ) -> bool: ... + + def cancel(self) -> None: ... + + def is_running(self) -> bool: ... + + def shutdown(self) -> None: ... diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py index 36dfc2530..40b55f8c5 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py @@ -1,5 +1,6 @@ from .bank import ChannelBank from .frames import RowFrames +from .length import SongLength from .modifiers import apply_modifiers from .rates import EngineRates from .state import ChannelState @@ -12,6 +13,7 @@ "EngineRates", "RowFrames", "RowSynthesizer", + "SongLength", "SongTiming", "apply_modifiers", ] diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py new file mode 100644 index 000000000..718e9ff5c --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/length.py @@ -0,0 +1,43 @@ +from dataclasses import dataclass +from typing import Self + +from sampletones_core.project import Project + +from .rates import EngineRates +from .timing import SongTiming + + +@dataclass(frozen=True) +class SongLength: + """How long a song runs, in the units the audio it produces is measured in. + + Every row lasts the ticks the project's groove gives it, so a song's length is a whole + number of engine ticks before a sample is rendered. The rates the audio is produced at + turn those ticks into samples, which is the total a progress bar crosses and the duration + a dialog projects. + + Attributes: + ticks: The engine ticks the whole order lasts. + rates: The engine and audio rates those ticks are rendered at. + """ + + ticks: int + rates: EngineRates + + @classmethod + def measure(cls, project: Project, *, sample_rate: int) -> Self: + """The length ``project`` runs to when rendered at ``sample_rate``. + + Every pattern holds the song's row count, so one groove covers the whole order and the + tick total is that groove's, once for each position the order plays. + """ + groove = SongTiming.from_project(project).groove() + return cls( + ticks=project.song.order_length() * groove.total_ticks, + rates=EngineRates.from_project(project, sample_rate), + ) + + @property + def samples(self) -> int: + """The samples the whole song holds at the rate it is rendered at.""" + return self.rates.clock().samples_at(self.ticks) diff --git a/src/sampletones_application/view_model/shared/display_settings.py b/src/sampletones_application/view_model/shared/display_settings.py index 74c7d74b1..804c2d00f 100644 --- a/src/sampletones_application/view_model/shared/display_settings.py +++ b/src/sampletones_application/view_model/shared/display_settings.py @@ -4,6 +4,7 @@ from pydantic import BaseModel +from sampletones_application.view_model.shared.nearest import nearest_offered from sampletones_shared.display import UNLIMITED_FRAME_RATE, Resolution @@ -60,19 +61,10 @@ def frame_rate_labels(frame_rates: Tuple[int, ...], *, unlimited_label: str) -> 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)) + return nearest_offered(max_fps, frame_rates) def nearest_resolution( diff --git a/src/sampletones_application/view_model/shared/nearest.py b/src/sampletones_application/view_model/shared/nearest.py new file mode 100644 index 000000000..485020cc4 --- /dev/null +++ b/src/sampletones_application/view_model/shared/nearest.py @@ -0,0 +1,24 @@ +from typing import Tuple + + +def nearest_offered(value: int, offered: Tuple[int, ...]) -> int: + """The offered number a standing choice selects: the closest one, the smaller where two tie. + + A choice outlives the list that was offered when it was made — a frame rate a build has + since dropped, a sample rate the newly chosen format encodes nothing near — so snapping it + onto the offer keeps a combo showing a value that is in force. + + Args: + value: The number a choice stands at. + offered: The numbers on offer. + + Returns: + int: The offered number the choice selects. + + Raises: + ValueError: when nothing is offered. + """ + if not offered: + raise ValueError("Selecting a value requires at least one offered number") + + return min(offered, key=lambda candidate: (abs(candidate - value), candidate)) diff --git a/src/sampletones_application/view_model/shared/render.py b/src/sampletones_application/view_model/shared/render.py new file mode 100644 index 000000000..26f20dafc --- /dev/null +++ b/src/sampletones_application/view_model/shared/render.py @@ -0,0 +1,264 @@ +from enum import StrEnum +from pathlib import Path +from typing import Final, FrozenSet, Optional, Self, Tuple + +from pydantic import BaseModel + +from sampletones_application.view_model.shared.nearest import nearest_offered +from sampletones_application.view_model.shared.percent import format_percent +from sampletones_core.audio.writers import ( + DEFAULT_AUDIO_DEPTH, + MP3_SAMPLE_RATES, + AudioDepth, + AudioFormat, + AudioOutputSpec, + Mp3OutputSpec, + WaveOutputSpec, + capability_of, + default_mp3_bitrate, + mp3_bitrates, +) +from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE +from sampletones_core.parallelization import ETAEstimator + + +class RenderPhase(StrEnum): + IDLE = "idle" + CONFIGURING = "configuring" + RENDERING = "rendering" + CANCELLING = "cancelling" + COMPLETED = "completed" + CANCELLED = "cancelled" + FAILED = "failed" + + +ACTIVE_PHASES: Final[FrozenSet[RenderPhase]] = frozenset( + { + RenderPhase.CONFIGURING, + RenderPhase.RENDERING, + RenderPhase.CANCELLING, + } +) + + +def build_spec( + audio_format: AudioFormat, + sample_rate: int, + *, + depth: Optional[AudioDepth], + bitrate: Optional[int], +) -> AudioOutputSpec: + """The specification a set of standing choices states for ``audio_format``. + + Each choice is snapped onto what the container accepts: the rate becomes the offered one + nearest it, a depth the format stores is kept, and a bitrate stays where the rate's own + ladder reaches it. A choice the format leaves behind falls back to what it opens on, so + moving between containers always arrives at a specification the encoder writes. + + Args: + audio_format: The container the audio is written into. + sample_rate: The rate the choices stand at. + depth: The form each stored sample takes, where one was chosen. + bitrate: The kilobits each encoded second holds, where one was chosen. + + Returns: + AudioOutputSpec: The specification for that format. + """ + match audio_format: + case AudioFormat.WAVE: + capability = capability_of(AudioFormat.WAVE) + return WaveOutputSpec( + sample_rate=nearest_offered(sample_rate, capability.sample_rates), + depth=depth if depth is not None and capability.supports_depth(depth) else DEFAULT_AUDIO_DEPTH, + ) + case AudioFormat.MP3: + rate = nearest_offered(sample_rate, MP3_SAMPLE_RATES) + return Mp3OutputSpec( + sample_rate=rate, + bitrate=bitrate if bitrate in mp3_bitrates(rate) else default_mp3_bitrate(rate), + ) + + +class SongRenderSettings(BaseModel, frozen=True): + """The choices a render is made under: what the file is written as, and at what level. + + Each ``with_`` method answers with the settings carrying one choice changed and the others + reconciled against what that choice leaves possible, so every value held here is one the + encoder accepts. The reconciliation runs in one place because the offers depend on each + other: a container encodes its own set of rates, and each rate offers the bitrates its MPEG + version defines. + """ + + spec: AudioOutputSpec + normalize: bool + + @classmethod + def initial(cls, audio_format: AudioFormat) -> Self: + """The choices a dialog opens on: ``audio_format`` at the usual rate, rendered at unity.""" + return cls( + spec=build_spec( + audio_format, + DEFAULT_SAMPLE_RATE, + depth=DEFAULT_AUDIO_DEPTH, + bitrate=None, + ), + normalize=False, + ) + + @property + def depth(self) -> Optional[AudioDepth]: + """The form each stored sample takes, where the format stores samples directly.""" + match self.spec: + case WaveOutputSpec() as wave: + return wave.depth + case Mp3OutputSpec(): + return None + + @property + def bitrate(self) -> Optional[int]: + """The kilobits each encoded second holds, where the format encodes to a bitrate.""" + match self.spec: + case WaveOutputSpec(): + return None + case Mp3OutputSpec() as mp3: + return mp3.bitrate + + def with_format(self, audio_format: AudioFormat) -> Self: + """The settings written as ``audio_format``, at the nearest rate it encodes.""" + return self._with_spec( + build_spec( + audio_format, + self.spec.sample_rate, + depth=self.depth, + bitrate=self.bitrate, + ) + ) + + def with_sample_rate(self, sample_rate: int) -> Self: + """The settings written at ``sample_rate``, keeping the quality it reaches there.""" + return self._with_spec( + build_spec( + self.spec.audio_format, + sample_rate, + depth=self.depth, + bitrate=self.bitrate, + ) + ) + + def with_depth(self, depth: AudioDepth) -> Self: + """The settings storing each sample as ``depth``.""" + return self._with_spec( + build_spec( + self.spec.audio_format, + self.spec.sample_rate, + depth=depth, + bitrate=self.bitrate, + ) + ) + + def with_bitrate(self, bitrate: int) -> Self: + """The settings encoding each second to ``bitrate`` kilobits.""" + return self._with_spec( + build_spec( + self.spec.audio_format, + self.spec.sample_rate, + depth=self.depth, + bitrate=bitrate, + ) + ) + + def with_normalize(self, normalize: bool) -> Self: + """The settings scaled so the loudest sample reaches full scale, or left at unity.""" + return self.model_copy(update={"normalize": normalize}) + + def _with_spec(self, spec: AudioOutputSpec) -> Self: + return self.model_copy(update={"spec": spec}) + + +class SongRenderViewModel(BaseModel, frozen=True): + """What the render dialog draws: the options this installation offers, the choices standing, + and how far a running render has got. + + The setup and the progress are two faces of one dialog, so the phase decides which is shown + and the derived flags are read rather than stored. The offers narrow with the choices — the + rates a container encodes, the bitrates a rate reaches — so a combo repopulates from here as + soon as the choice above it changes. + + Attributes: + phase: Where the render stands, from the dialog opening to the outcome it reports. + formats: The containers this installation writes, in the order they are offered. + depths: The forms this installation stores the chosen container's samples in. + settings: The choices the dialog is standing at. + destination: The file a render writes. + total_samples: The samples the whole song holds at the chosen rate. + status_text: What the running pass is doing, and how long it has left. + progress: How far the running pass has got, from 0 to 1. + """ + + phase: RenderPhase + formats: Tuple[AudioFormat, ...] + depths: Tuple[AudioDepth, ...] + settings: SongRenderSettings + destination: Path + total_samples: int + status_text: str + progress: float + + @property + def spec(self) -> AudioOutputSpec: + return self.settings.spec + + @property + def sample_rates(self) -> Tuple[int, ...]: + """The rates the chosen container encodes, lowest first.""" + return self.spec.capability.sample_rates + + @property + def bitrates(self) -> Tuple[int, ...]: + """The bitrates the chosen rate encodes at, for a container that offers a bitrate.""" + if self.spec.capability.stores_samples: + return () + + return mp3_bitrates(self.spec.sample_rate) + + @property + def stores_samples(self) -> bool: + """Whether the chosen container stores samples, which is what gives it a depth to choose.""" + return self.spec.capability.stores_samples + + @property + def duration_seconds(self) -> float: + """How long the song plays for, in seconds.""" + return self.total_samples / self.spec.sample_rate + + @property + def duration_label(self) -> str: + """The length the render is projected to run to, as the dialog states it.""" + return ETAEstimator.format_duration(self.duration_seconds) + + @property + def progress_overlay(self) -> str: + """The percentage label rendered over the progress bar, derived from the fraction.""" + return format_percent(self.progress) + + @property + def is_active(self) -> bool: + return self.phase in ACTIVE_PHASES + + @property + def setup_visible(self) -> bool: + return self.phase == RenderPhase.CONFIGURING + + @property + def progress_visible(self) -> bool: + return self.phase != RenderPhase.CONFIGURING + + @property + def render_enabled(self) -> bool: + """Whether a render starts from here: a song with something to write, still being set up.""" + return self.phase == RenderPhase.CONFIGURING and self.total_samples > 0 + + @property + def cancel_enabled(self) -> bool: + """Whether a running render still takes a stop, which one already stopping has taken.""" + return self.phase == RenderPhase.RENDERING diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 9736afe25..a27a59e72 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -661,6 +661,12 @@ settings.display.label.keep_editing_button: "Keep editing" settings.display.message.countdown: "Keep the window the way it looks now? It goes back to how it was when the count reaches zero." settings.display.message.discard_confirmation: "Discard the changes to the display settings?" settings.display.template.countdown_remaining: "Reverting in {seconds} s" +settings.render.message.status_synthesis: "Rendering the song..." +settings.render.message.status_encoding: "Writing the file..." +settings.render.message.status_cancelling: "Stopping the render..." +settings.render.message.status_cancelled: "Render cancelled." +settings.render.message.status_completed: "Render complete." +settings.render.message.status_failed: "Render failed." settings.keybindings.title.window_title: "Keyboard shortcuts" settings.keybindings.title.application: "Application" settings.keybindings.title.order: "Order list" diff --git a/src/sampletones_shared/utils/system/paths.py b/src/sampletones_shared/utils/system/paths.py index 30f198670..3b3ce37fa 100644 --- a/src/sampletones_shared/utils/system/paths.py +++ b/src/sampletones_shared/utils/system/paths.py @@ -93,6 +93,30 @@ def ensure_suffix(path: Path, suffix: str) -> Path: return path.with_name(f"{path.name}{normalized_suffix}") +def replace_suffix(path: Path, previous: str, suffix: str) -> Path: + """ + Returns the path carrying ``suffix`` where its name ends with ``previous``. + + A destination follows the format written to it, so choosing another format renames the + file the destination points at. The ending is compared case-insensitively, and a name + ending in anything else keeps every part of itself and takes the new suffix on the end, + the way :func:`ensure_suffix` leaves incidental dots intact (``my.mix`` becomes + ``my.mix.mp3``). + + Args: + path (Path): The path whose extension follows a change of format. + previous (str): The extension the name is expected to end with, leading dot included. + suffix (str): The extension the path takes, with or without a leading dot. + + Returns: + Path: The path ending with the given suffix. + """ + if previous and path.name.lower().endswith(previous.lower()): + return path.with_name(get_filename(path.name[: -len(previous)], suffix)) + + return ensure_suffix(path, suffix) + + def shorten_path(path: GeneralPathlike, levels: int = SHORTEN_PATH_LEVELS) -> str: """ Shortens a file path for display by keeping the root, first directory, and last few parts. diff --git a/tests/unit/sampletones_application/logic/render/__init__.py b/tests/unit/sampletones_application/logic/render/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/render/test_logic.py b/tests/unit/sampletones_application/logic/render/test_logic.py new file mode 100644 index 000000000..b656454c1 --- /dev/null +++ b/tests/unit/sampletones_application/logic/render/test_logic.py @@ -0,0 +1,378 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Final, List, Optional +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.render.logic import SongRenderLogic +from sampletones_application.logic.sequencer.playback.synthesizer import ( + RowSynthesizer, + SongLength, +) +from sampletones_application.services.render.result import RenderResult, RenderStage +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceProgress, + ServiceSuccess, +) +from sampletones_application.view_model.shared.render import ( + RenderPhase, + SongRenderViewModel, +) +from sampletones_core.audio.writers import AudioFormat, AudioOutputSpec +from sampletones_core.configs import Config +from tests.suite.language import FakeLanguageManager + +AUDIO_DIRECTORY: Final[Path] = Path("/home/user/audio") +PROJECT_NAME: Final[str] = "chiptune" +LOW_RATE: Final[int] = 8000 + + +@dataclass(frozen=True) +class RenderRequest: + synthesizer: RowSynthesizer + destination: Path + spec: AudioOutputSpec + normalize: bool + total_samples: int + + +class FakeRenderService: + """The render service as the logic drives it, holding what it was asked to render. + + Results are delivered through the handler the logic subscribes, so a test walks a render the + way the worker reports one. + """ + + def __init__(self, *, accepts: bool = True) -> None: + self.accepts = accepts + self.requests: List[RenderRequest] = [] + self.cancels: int = 0 + self.shutdowns: int = 0 + self.running: bool = False + self._handler: Optional[Callable[[RenderResult], None]] = None + + def subscribe(self, handler: Callable[[RenderResult], None]) -> None: + self._handler = handler + + def start( + self, + *, + synthesizer: RowSynthesizer, + destination: Path, + spec: AudioOutputSpec, + normalize: bool, + total_samples: int, + ) -> bool: + if not self.accepts: + return False + + self.requests.append( + RenderRequest( + synthesizer=synthesizer, + destination=destination, + spec=spec, + normalize=normalize, + total_samples=total_samples, + ) + ) + self.running = True + return True + + def cancel(self) -> None: + self.cancels += 1 + + def is_running(self) -> bool: + return self.running + + def shutdown(self) -> None: + self.shutdowns += 1 + + def emit(self, result: RenderResult) -> None: + assert self._handler is not None, "The logic subscribes to the service it is given" + self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCancelled)) + self._handler(result) + + @property + def request(self) -> RenderRequest: + assert self.requests, "A render was expected to start" + return self.requests[-1] + + +class RenderFixture: + """A render logic wired to a real project and a service that records what it is asked for.""" + + def __init__(self, *, operation_active: bool = False, accepts: bool = True) -> None: + project_manager = ProjectManager() + project_manager.session.mark_loaded(PROJECT_NAME) + self.controller = ProjectController(project_manager) + self.session_manager = MagicMock() + self.session_manager.get_audio_path.return_value = AUDIO_DIRECTORY + self.service = FakeRenderService(accepts=accepts) + self.views: List[SongRenderViewModel] = [] + self.logic = SongRenderLogic( + self.controller, + MagicMock(config=Config()), + self.session_manager, + self.service, + language_manager=FakeLanguageManager(), # type: ignore[arg-type] + is_operation_active=lambda: operation_active, + ) + self.logic.on_view_changed = self.views.append + + @property + def view(self) -> SongRenderViewModel: + assert self.views, "A view was expected to be emitted" + return self.views[-1] + + def configure(self) -> None: + self.logic.open() + + def start_at(self, sample_rate: int) -> None: + self.configure() + self.logic.apply(self.view.settings.with_sample_rate(sample_rate)) + self.logic.start() + + +@pytest.fixture +def render() -> RenderFixture: + return RenderFixture() + + +def render_whole_song(synthesizer: RowSynthesizer) -> int: + """The samples a kernel produces when the song is played from its first row to its last.""" + synthesizer.set_position(0, 0) + synthesizer.reset() + rendered = 0 + while not synthesizer.is_finished: + chunk, _ = synthesizer.render_row() + rendered += len(chunk) + + return rendered + + +class TestOfferingTheRender: + def test_the_dialog_opens_on_a_destination_named_after_the_project( + self, + render: RenderFixture, + ) -> None: + render.configure() + + assert render.view.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav" + assert render.view.phase == RenderPhase.CONFIGURING + + def test_the_render_occupies_the_application_from_the_dialog_opening( + self, + render: RenderFixture, + ) -> None: + assert not render.logic.is_active + + render.configure() + + assert render.logic.is_active + + def test_another_exclusive_operation_holds_the_dialog_closed(self) -> None: + render = RenderFixture(operation_active=True) + + assert not render.logic.open() + assert not render.logic.is_active + assert not render.views + + def test_closing_releases_the_application(self, render: RenderFixture) -> None: + render.configure() + + render.logic.close() + + assert not render.logic.is_active + + +class TestTheDestinationFollowsTheFormat: + def test_choosing_another_container_renames_the_file(self, render: RenderFixture) -> None: + render.configure() + + render.logic.apply(render.view.settings.with_format(AudioFormat.MP3)) + + assert render.view.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.mp3" + + def test_a_chosen_destination_is_remembered_for_the_next_render( + self, + render: RenderFixture, + ) -> None: + render.configure() + chosen = Path("/home/user/renders/take one.wav") + + render.logic.set_destination(chosen) + + assert render.view.destination == chosen + render.session_manager.set_audio_path.assert_called_once_with(chosen) + + def test_the_destination_is_asked_for_from_where_it_stands(self, render: RenderFixture) -> None: + render.configure() + on_choose_destination = MagicMock() + render.logic.on_choose_destination = on_choose_destination + + render.logic.request_destination() + + on_choose_destination.assert_called_once_with( + AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav", + AudioFormat.WAVE, + ) + + +class TestStartingTheRender: + def test_the_service_is_asked_for_the_chosen_file(self, render: RenderFixture) -> None: + render.configure() + render.logic.apply(render.view.settings.with_normalize(True)) + + render.logic.start() + + request = render.service.request + assert request.destination == render.view.destination + assert request.spec == render.view.settings.spec + assert request.normalize + + def test_the_song_is_measured_at_the_rate_it_is_written_at(self, render: RenderFixture) -> None: + render.start_at(LOW_RATE) + + expected = SongLength.measure(render.controller.project, sample_rate=LOW_RATE) + assert render.service.request.total_samples == expected.samples + + def test_the_kernel_renders_the_measured_song_over_a_held_document( + self, + render: RenderFixture, + ) -> None: + """The kernel reads a snapshot at the chosen rate, so the audio it produces is the length + the service was told to expect however the project moves on.""" + render.start_at(LOW_RATE) + request = render.service.request + + render.controller.set_tempo(render.controller.project.settings.tempo + 40) + + assert render_whole_song(request.synthesizer) == request.total_samples + + def test_a_declined_request_leaves_the_dialog_setting_up(self) -> None: + render = RenderFixture(accepts=False) + render.configure() + + render.logic.start() + + assert render.view.phase == RenderPhase.CONFIGURING + + def test_a_render_starts_from_the_setup_alone(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + + render.logic.start() + + assert len(render.service.requests) == 1 + + +class TestReportingTheRender: + def test_a_pass_reports_how_far_it_has_got(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + + render.service.emit( + ServiceProgress( + completed=25, + total=100, + current_item=RenderStage.SYNTHESIS, + ) + ) + + assert render.view.phase == RenderPhase.RENDERING + assert render.view.progress == 0.25 + assert render.view.status_text.startswith("settings.render.message.status_synthesis") + + def test_the_second_pass_names_itself(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + + render.service.emit( + ServiceProgress( + completed=50, + total=100, + current_item=RenderStage.ENCODING, + ) + ) + + assert render.view.status_text.startswith("settings.render.message.status_encoding") + + def test_a_stop_holds_its_message_over_the_reports_still_arriving( + self, + render: RenderFixture, + ) -> None: + render.configure() + render.logic.start() + render.logic.cancel() + + render.service.emit( + ServiceProgress( + completed=75, + total=100, + current_item=RenderStage.SYNTHESIS, + ) + ) + + assert render.view.phase == RenderPhase.CANCELLING + assert render.view.status_text == "settings.render.message.status_cancelling" + + def test_a_stop_reaches_the_service(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + + render.logic.cancel() + + assert render.service.cancels == 1 + + def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + on_success = MagicMock() + render.logic.on_success = on_success + written = AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav" + + render.service.emit(ServiceSuccess(value=written)) + + on_success.assert_called_once_with(written) + assert render.view.phase == RenderPhase.COMPLETED + assert render.view.progress == 1.0 + assert not render.logic.is_active + + def test_a_stopped_render_reports_the_cancellation(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + on_cancelled = MagicMock() + render.logic.on_cancelled = on_cancelled + + render.logic.cancel() + render.service.emit(ServiceCancelled()) + + on_cancelled.assert_called_once() + assert render.view.phase == RenderPhase.CANCELLED + assert not render.logic.is_active + + def test_a_failed_render_reports_what_went_wrong(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + on_error = MagicMock() + render.logic.on_error = on_error + failure = OSError("no room on the device") + + render.service.emit(ServiceError(exception=failure)) + + on_error.assert_called_once_with(failure) + assert render.view.phase == RenderPhase.FAILED + assert not render.logic.is_active + + def test_exit_winds_a_running_render_down(self, render: RenderFixture) -> None: + render.configure() + render.logic.start() + + render.logic.cleanup() + + assert render.service.shutdowns == 1 diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py new file mode 100644 index 000000000..ab23991af --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_song_length.py @@ -0,0 +1,68 @@ +from typing import Final + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.playback.synthesizer import SongLength +from sampletones_application.logic.sequencer.playback.synthesizer.timing import SongTiming + +FRACTIONAL_RATE: Final[int] = 22050 +EXACT_RATE: Final[int] = 44100 + + +def measure(controller: ProjectController, sample_rate: int) -> SongLength: + return SongLength.measure(controller.project, sample_rate=sample_rate) + + +class TestTheOrderStatesTheTicks: + def test_the_song_lasts_its_groove_once_for_each_order_position( + self, + controller: ProjectController, + ) -> None: + groove = SongTiming.from_project(controller.project).groove() + + length = measure(controller, EXACT_RATE) + + assert length.ticks == controller.project.song.order_length() * groove.total_ticks + + def test_appending_a_frame_lengthens_the_song_by_a_pattern( + self, + controller: ProjectController, + ) -> None: + before = measure(controller, EXACT_RATE) + groove = SongTiming.from_project(controller.project).groove() + + controller.append_frame() + + assert measure(controller, EXACT_RATE).ticks == before.ticks + groove.total_ticks + + +class TestTheRateStatesTheSamples: + """The clock spreads a fractional samples-per-tick across ticks, so a total lands on the exact + duration whatever rate it is rendered at.""" + + def test_a_rate_dividing_evenly_gives_a_whole_frame_for_every_tick( + self, + controller: ProjectController, + ) -> None: + length = measure(controller, EXACT_RATE) + nes_frequency = controller.project.settings.nes_frequency + + assert length.samples == length.ticks * EXACT_RATE // nes_frequency + + def test_a_rate_dividing_fractionally_still_lands_on_the_exact_duration( + self, + controller: ProjectController, + ) -> None: + length = measure(controller, FRACTIONAL_RATE) + nes_frequency = controller.project.settings.nes_frequency + + assert length.samples == length.ticks * FRACTIONAL_RATE // nes_frequency + assert length.samples * 2 - measure(controller, EXACT_RATE).samples <= 1 + + def test_a_song_plays_for_the_same_time_at_every_rate( + self, + controller: ProjectController, + ) -> None: + fractional = measure(controller, FRACTIONAL_RATE) + exact = measure(controller, EXACT_RATE) + + assert abs(fractional.samples / FRACTIONAL_RATE - exact.samples / EXACT_RATE) < 1e-3 diff --git a/tests/unit/sampletones_application/view_model/shared/test_nearest.py b/tests/unit/sampletones_application/view_model/shared/test_nearest.py new file mode 100644 index 000000000..09f032562 --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/test_nearest.py @@ -0,0 +1,18 @@ +import pytest + +from sampletones_application.view_model.shared.nearest import nearest_offered + + +class TestNearestOffered: + def test_an_offered_value_selects_itself(self) -> None: + assert nearest_offered(48000, (8000, 44100, 48000)) == 48000 + + def test_a_value_between_offers_selects_the_closer_one(self) -> None: + assert nearest_offered(96000, (8000, 44100, 48000)) == 48000 + + def test_two_offers_equally_close_select_the_smaller(self) -> None: + assert nearest_offered(30, (20, 40)) == 20 + + def test_nothing_offered_reports_the_empty_choice(self) -> None: + with pytest.raises(ValueError): + nearest_offered(44100, ()) diff --git a/tests/unit/sampletones_application/view_model/shared/test_render.py b/tests/unit/sampletones_application/view_model/shared/test_render.py new file mode 100644 index 000000000..f2f37bf01 --- /dev/null +++ b/tests/unit/sampletones_application/view_model/shared/test_render.py @@ -0,0 +1,158 @@ +from pathlib import Path +from typing import Final + +from sampletones_application.view_model.shared.render import ( + RenderPhase, + SongRenderSettings, + SongRenderViewModel, +) +from sampletones_core.audio.writers import ( + AudioDepth, + AudioFormat, + Mp3OutputSpec, + WaveOutputSpec, +) + +DESTINATION: Final[Path] = Path("/home/user/song.wav") +TOTAL_SAMPLES: Final[int] = 44100 + + +def wave_settings( + *, + sample_rate: int = 44100, + depth: AudioDepth = AudioDepth.PCM_16, +) -> SongRenderSettings: + return SongRenderSettings( + spec=WaveOutputSpec(sample_rate=sample_rate, depth=depth), + normalize=False, + ) + + +def view_model( + settings: SongRenderSettings, + *, + phase: RenderPhase = RenderPhase.CONFIGURING, + total_samples: int = TOTAL_SAMPLES, +) -> SongRenderViewModel: + return SongRenderViewModel( + phase=phase, + formats=(AudioFormat.WAVE, AudioFormat.MP3), + depths=(AudioDepth.PCM_16, AudioDepth.PCM_24), + settings=settings, + destination=DESTINATION, + total_samples=total_samples, + status_text="", + progress=0.0, + ) + + +class TestChoicesFollowTheFormat: + """Every choice a dialog stands at is reconciled against what the container accepts.""" + + def test_a_rate_the_new_format_encodes_is_kept(self) -> None: + settings = wave_settings(sample_rate=48000).with_format(AudioFormat.MP3) + + assert settings.spec.audio_format == AudioFormat.MP3 + assert settings.spec.sample_rate == 48000 + + def test_a_rate_the_new_format_leaves_behind_moves_to_the_nearest(self) -> None: + settings = wave_settings(sample_rate=192000).with_format(AudioFormat.MP3) + + assert settings.spec.sample_rate == 48000 + + def test_a_container_storing_samples_opens_on_a_depth(self) -> None: + settings = SongRenderSettings.initial(AudioFormat.MP3).with_format(AudioFormat.WAVE) + + assert settings.depth is not None + assert settings.bitrate is None + + def test_a_depth_survives_a_rate_change(self) -> None: + settings = wave_settings(depth=AudioDepth.PCM_U8).with_sample_rate(8000) + + assert settings.spec.sample_rate == 8000 + assert settings.depth == AudioDepth.PCM_U8 + + def test_the_normalise_choice_stands_through_a_format_change(self) -> None: + settings = wave_settings().with_normalize(True).with_format(AudioFormat.MP3) + + assert settings.normalize + + +class TestBitratesFollowTheRate: + """Each MPEG version defines its own ladder, so the bitrate follows the rate that selects it.""" + + def test_a_bitrate_the_new_rate_reaches_is_kept(self) -> None: + settings = SongRenderSettings( + spec=Mp3OutputSpec(sample_rate=44100, bitrate=64), + normalize=False, + ).with_sample_rate(22050) + + assert settings.bitrate == 64 + + def test_a_bitrate_beyond_the_new_ladder_moves_onto_it(self) -> None: + settings = SongRenderSettings( + spec=Mp3OutputSpec(sample_rate=44100, bitrate=320), + normalize=False, + ).with_sample_rate(8000) + + assert settings.bitrate == 64 + + def test_the_chosen_bitrate_is_taken(self) -> None: + settings = SongRenderSettings.initial(AudioFormat.MP3).with_bitrate(96) + + assert settings.bitrate == 96 + + +class TestWhatTheDialogDraws: + def test_a_container_storing_samples_offers_depths_and_no_bitrates(self) -> None: + view = view_model(wave_settings()) + + assert view.stores_samples + assert view.bitrates == () + + def test_a_container_encoding_to_a_bitrate_offers_the_ladder_of_its_rate(self) -> None: + view = view_model(SongRenderSettings.initial(AudioFormat.MP3).with_sample_rate(8000)) + + assert not view.stores_samples + assert view.bitrates == (64, 56, 48, 40, 32, 24, 16, 8) + + def test_the_offered_rates_are_the_containers_own(self) -> None: + view = view_model(SongRenderSettings.initial(AudioFormat.MP3)) + + assert view.sample_rates == (8000, 16000, 22050, 44100, 48000) + + def test_the_projected_duration_is_the_song_at_the_chosen_rate(self) -> None: + view = view_model(wave_settings(sample_rate=44100), total_samples=88200) + + assert view.duration_seconds == 2.0 + + def test_setting_up_shows_the_setup_alone(self) -> None: + view = view_model(wave_settings(), phase=RenderPhase.CONFIGURING) + + assert view.setup_visible + assert not view.progress_visible + assert view.render_enabled + assert view.is_active + + def test_rendering_shows_the_progress_alone(self) -> None: + view = view_model(wave_settings(), phase=RenderPhase.RENDERING) + + assert view.progress_visible + assert not view.setup_visible + assert not view.render_enabled + assert view.cancel_enabled + + def test_a_render_already_stopping_takes_no_further_stop(self) -> None: + view = view_model(wave_settings(), phase=RenderPhase.CANCELLING) + + assert view.is_active + assert not view.cancel_enabled + + def test_a_song_holding_no_rows_starts_no_render(self) -> None: + view = view_model(wave_settings(), total_samples=0) + + assert not view.render_enabled + + def test_an_outcome_releases_the_application(self) -> None: + for phase in (RenderPhase.COMPLETED, RenderPhase.CANCELLED, RenderPhase.FAILED): + assert not view_model(wave_settings(), phase=phase).is_active diff --git a/tests/unit/sampletones_shared/utils/system/test_paths.py b/tests/unit/sampletones_shared/utils/system/test_paths.py index 23b162f8c..b0ab5a04b 100644 --- a/tests/unit/sampletones_shared/utils/system/test_paths.py +++ b/tests/unit/sampletones_shared/utils/system/test_paths.py @@ -14,6 +14,7 @@ open_directory_in_explorer_linux, open_file_in_explorer_linux, open_path_in_explorer, + replace_suffix, shorten_filename, shorten_path, to_path, @@ -264,6 +265,67 @@ def test_ensure_suffix(self, test_case: TestCase) -> None: assert result == Path(test_case.expected) +class TestReplaceSuffix(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + input_path: str + previous: str + suffix: str + expected: str + + test_cases = ( + TestCase( + input_path="song.wav", + previous=".wav", + suffix=".mp3", + expected="song.mp3", + label="replaces_the_previous_extension", + ), + TestCase( + input_path="song.WAV", + previous=".wav", + suffix=".mp3", + expected="song.mp3", + label="replaces_case_insensitively", + ), + TestCase( + input_path="/home/user/my song v1.2.wav", + previous=".wav", + suffix=".mp3", + expected="/home/user/my song v1.2.mp3", + label="keeps_incidental_dots_and_directory", + ), + TestCase( + input_path="my.mix", + previous=".wav", + suffix=".mp3", + expected="my.mix.mp3", + label="appends_where_the_name_ends_otherwise", + ), + TestCase( + input_path="song", + previous=".wav", + suffix=".wav", + expected="song.wav", + label="appends_where_the_name_carries_no_extension", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_replace_suffix(self, test_case: TestCase) -> None: + result = replace_suffix( + Path(test_case.input_path), + test_case.previous, + test_case.suffix, + ) + + assert result == Path(test_case.expected) + + class TestShortenPath(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class TestCase(BaseRegularTestCase): From 5dc24e34c3a9b3c6f9ba3be600c9a36c320070c7 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 07:17:56 +0200 Subject: [PATCH 062/152] Added: render dialog window --- .../layout/settings/__init__.py | 2 + .../layout/settings/render.py | 7 + src/sampletones_application/tags/settings.py | 115 +++++ .../ui/panels/dialogs/render.py | 450 ++++++++++++++++++ .../view_model/shared/render.py | 37 ++ src/sampletones_config/lang/en.yaml | 19 + .../layout/settings/render.yaml | 3 + .../ui/panels/dialogs/test_render.py | 309 ++++++++++++ 8 files changed, 942 insertions(+) create mode 100644 src/sampletones_application/layout/settings/render.py create mode 100644 src/sampletones_application/ui/panels/dialogs/render.py create mode 100644 src/sampletones_config/layout/settings/render.yaml create mode 100644 tests/unit/sampletones_application/ui/panels/dialogs/test_render.py diff --git a/src/sampletones_application/layout/settings/__init__.py b/src/sampletones_application/layout/settings/__init__.py index dcebe4263..99c11b68b 100644 --- a/src/sampletones_application/layout/settings/__init__.py +++ b/src/sampletones_application/layout/settings/__init__.py @@ -3,6 +3,7 @@ from sampletones_application.layout.settings.audio import AudioSettingsLayout from sampletones_application.layout.settings.display import DisplaySettingsLayout from sampletones_application.layout.settings.keybindings import KeybindingsSettingsLayout +from sampletones_application.layout.settings.render import RenderSettingsLayout class SettingsLayout(BaseModel, extra="forbid", frozen=True): @@ -17,3 +18,4 @@ class SettingsLayout(BaseModel, extra="forbid", frozen=True): audio: AudioSettingsLayout display: DisplaySettingsLayout keybindings: KeybindingsSettingsLayout + render: RenderSettingsLayout diff --git a/src/sampletones_application/layout/settings/render.py b/src/sampletones_application/layout/settings/render.py new file mode 100644 index 000000000..58dc9e84a --- /dev/null +++ b/src/sampletones_application/layout/settings/render.py @@ -0,0 +1,7 @@ +from pydantic import BaseModel + +from sampletones_application.layout.primitives import Dimensions + + +class RenderSettingsLayout(BaseModel, extra="forbid", frozen=True): + window: Dimensions diff --git a/src/sampletones_application/tags/settings.py b/src/sampletones_application/tags/settings.py index 3f0905750..8a48c2f2e 100644 --- a/src/sampletones_application/tags/settings.py +++ b/src/sampletones_application/tags/settings.py @@ -137,6 +137,121 @@ "revert", ) +TAG_SETTINGS_RENDER_WINDOW = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.WINDOW, + "render", +) +TAG_SETTINGS_RENDER_GROUP_SETUP = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.GROUP, + "setup", +) +TAG_SETTINGS_RENDER_GROUP_PROGRESS = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.GROUP, + "progress", +) +TAG_SETTINGS_RENDER_GROUP_DEPTH = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.GROUP, + "depth", +) +TAG_SETTINGS_RENDER_GROUP_BITRATE = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.GROUP, + "bitrate", +) +TAG_SETTINGS_RENDER_GROUP_DESTINATION = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.GROUP, + "destination", +) +TAG_SETTINGS_RENDER_COMBO_FORMAT = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.COMBO, + "format", +) +TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.COMBO, + "sample_rate", +) +TAG_SETTINGS_RENDER_COMBO_DEPTH = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.COMBO, + "depth", +) +TAG_SETTINGS_RENDER_COMBO_BITRATE = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.COMBO, + "bitrate", +) +TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.CHECKBOX, + "normalize", +) +TAG_SETTINGS_RENDER_TEXT_DURATION = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.TEXT, + "duration", +) +TAG_SETTINGS_RENDER_TEXT_STATUS = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.TEXT, + "status", +) +TAG_SETTINGS_RENDER_PATH_DESTINATION = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.PATH, + "destination", +) +TAG_SETTINGS_RENDER_PROGRESS = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.PROGRESS, + "render", +) +TAG_SETTINGS_RENDER_BUTTON_BROWSE = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.BUTTON, + "browse", +) +TAG_SETTINGS_RENDER_BUTTON_START = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.BUTTON, + "start", +) +TAG_SETTINGS_RENDER_BUTTON_CLOSE = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.BUTTON, + "close", +) +TAG_SETTINGS_RENDER_BUTTON_CANCEL = TagName( + Page.SETTINGS, + Panel.RENDER, + Widget.BUTTON, + "cancel", +) + TAG_SETTINGS_KEYBINDINGS_WINDOW = TagName( Page.SETTINGS, Panel.KEYBINDINGS, diff --git a/src/sampletones_application/ui/panels/dialogs/render.py b/src/sampletones_application/ui/panels/dialogs/render.py new file mode 100644 index 000000000..fad454d3f --- /dev/null +++ b/src/sampletones_application/ui/panels/dialogs/render.py @@ -0,0 +1,450 @@ +from typing import Any, Callable, Dict, Optional + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.general.colors.path import PathColors +from sampletones_application.layout.settings import SettingsLayout +from sampletones_application.tags.settings import ( + TAG_SETTINGS_RENDER_BUTTON_BROWSE, + TAG_SETTINGS_RENDER_BUTTON_CANCEL, + TAG_SETTINGS_RENDER_BUTTON_CLOSE, + TAG_SETTINGS_RENDER_BUTTON_START, + TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, + TAG_SETTINGS_RENDER_COMBO_BITRATE, + TAG_SETTINGS_RENDER_COMBO_DEPTH, + TAG_SETTINGS_RENDER_COMBO_FORMAT, + TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + TAG_SETTINGS_RENDER_GROUP_BITRATE, + TAG_SETTINGS_RENDER_GROUP_DEPTH, + TAG_SETTINGS_RENDER_GROUP_DESTINATION, + TAG_SETTINGS_RENDER_GROUP_PROGRESS, + TAG_SETTINGS_RENDER_GROUP_SETUP, + TAG_SETTINGS_RENDER_PATH_DESTINATION, + TAG_SETTINGS_RENDER_PROGRESS, + TAG_SETTINGS_RENDER_TEXT_DURATION, + TAG_SETTINGS_RENDER_TEXT_STATUS, + TAG_SETTINGS_RENDER_WINDOW, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.dialog import GUIDialogWindow +from sampletones_application.ui.elements.field import labeled_field +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.ui.elements.path import GUIPathText +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.utils.gui.align import table_wrapper +from sampletones_application.utils.gui.dialog_navigation import FocusStop +from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.view_model.shared.render import ( + SongRenderSettings, + SongRenderViewModel, +) +from sampletones_core.audio.writers import AudioDepth, AudioFormat +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback + +SettingsCallback = Callable[[SongRenderSettings], None] + + +class GUIRenderWindow(GUIDialogWindow): + """Modal form over writing the open song to an audio file. + + The dialog has two faces and shows one at a time: the setup, where the file is described and + the render is started, and the progress, where the running pass reports itself and takes a + stop. Which one stands is the phase the view model carries, so the window draws whatever it + is handed. + + Every control reports the whole edited state through ``on_settings_changed``, which the owner + reconciles and hands back — so what a container accepts decides what the next combo offers, + and the dialog shows the choices as they end up rather than as they were asked for. + """ + + def __init__( + self, + *, + layout: SettingsLayout, + path_colors: PathColors, + language_manager: LanguageManager, + key_router: KeyRouter, + shortcut_source: ShortcutSource, + status_bar: GUIStatusBar, + ) -> None: + self._language_manager = language_manager + self._layout = layout + self._path_colors = path_colors + self._status_bar = status_bar + self._view_model: Optional[SongRenderViewModel] = None + self._destination_text: Optional[GUIPathText] = None + + self.on_settings_changed: Optional[SettingsCallback] = None + self.on_browse: Optional[VoidCallback] = None + self.on_render: Optional[VoidCallback] = None + self.on_cancel: Optional[VoidCallback] = None + self.on_close: Optional[VoidCallback] = None + + self._formats_by_label: Dict[str, AudioFormat] = {} + self._sample_rates_by_label: Dict[str, int] = {} + self._depths_by_label: Dict[str, AudioDepth] = {} + self._bitrates_by_label: Dict[str, int] = {} + + self._fmt_sample_rate = language_manager["settings.render.template.sample_rate"] + self._fmt_bitrate = language_manager["settings.render.template.bitrate"] + self._msg_path = language_manager["global.status.message.path"] + self._format_labels: Dict[AudioFormat, str] = { + AudioFormat.WAVE: language_manager["settings.render.label.format_wave"], + AudioFormat.MP3: language_manager["settings.render.label.format_mp3"], + } + self._depth_labels: Dict[AudioDepth, str] = { + AudioDepth.PCM_U8: language_manager["settings.render.label.depth_pcm_u8"], + AudioDepth.PCM_16: language_manager["settings.render.label.depth_pcm_16"], + AudioDepth.PCM_24: language_manager["settings.render.label.depth_pcm_24"], + AudioDepth.PCM_32: language_manager["settings.render.label.depth_pcm_32"], + AudioDepth.FLOAT_32: language_manager["settings.render.label.depth_float_32"], + } + + super().__init__( + tag=TAG_SETTINGS_RENDER_WINDOW, + width=layout.render.window.width, + height=layout.render.window.height, + key_router=key_router, + shortcut_source=shortcut_source, + ) + + def open(self, view_model: SongRenderViewModel) -> None: + """Shows the window seeded with the render being set up.""" + self._view_model = view_model + self.show() + + def prepare(self, *_args: Any, **_kwargs: Any) -> None: + """The rendered values are seeded by :meth:`open` before the tree rebuilds.""" + + def update_view(self, view_model: SongRenderViewModel) -> None: + """Re-seeds the controls of the open window from where the render stands.""" + self._view_model = view_model + self._render() + + def create_window(self) -> None: + with self.dialog_window( + label=self._language_manager["settings.render.title.window_title"], + on_close=self._request_close, + ): + self._create_setup() + self._create_progress() + + self._bind_dialog_theme( + TAG_SETTINGS_RENDER_COMBO_FORMAT, + TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + TAG_SETTINGS_RENDER_COMBO_DEPTH, + TAG_SETTINGS_RENDER_COMBO_BITRATE, + ) + + self._render() + self._install_navigation( + [ + FocusStop.field(TAG_SETTINGS_RENDER_COMBO_FORMAT), + FocusStop.field(TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE), + FocusStop.field(TAG_SETTINGS_RENDER_COMBO_DEPTH), + FocusStop.field(TAG_SETTINGS_RENDER_COMBO_BITRATE), + FocusStop.field(TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE), + FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_BROWSE, self._request_destination), + FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_CLOSE, self._request_close), + FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_START, self._request_render), + FocusStop.button(TAG_SETTINGS_RENDER_BUTTON_CANCEL, self._request_cancel), + ], + on_escape=self._request_close, + ) + + def _create_setup(self) -> None: + with dpg.group(tag=TAG_SETTINGS_RENDER_GROUP_SETUP): + self._create_format_selection() + self._create_sample_rate_selection() + self._create_depth_selection() + self._create_bitrate_selection() + self._create_normalize_switch() + self._create_duration() + dpg.add_separator() + self._create_destination() + dpg.add_separator() + self._create_setup_buttons() + + def _create_format_selection(self) -> None: + with labeled_field( + self._language_manager["settings.render.label.format"], + self._layout.label_width, + ): + dpg.add_combo( + tag=TAG_SETTINGS_RENDER_COMBO_FORMAT, + items=[], + width=self._layout.combo_width, + callback=self._on_format_changed, + ) + + def _create_sample_rate_selection(self) -> None: + with labeled_field( + self._language_manager["settings.render.label.sample_rate"], + self._layout.label_width, + ): + dpg.add_combo( + tag=TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + items=[], + width=self._layout.combo_width, + callback=self._on_sample_rate_changed, + ) + + def _create_depth_selection(self) -> None: + with ( + dpg.group(tag=TAG_SETTINGS_RENDER_GROUP_DEPTH), + labeled_field( + self._language_manager["settings.render.label.depth"], + self._layout.label_width, + ), + ): + dpg.add_combo( + tag=TAG_SETTINGS_RENDER_COMBO_DEPTH, + items=[], + width=self._layout.combo_width, + callback=self._on_depth_changed, + ) + + def _create_bitrate_selection(self) -> None: + with ( + dpg.group(tag=TAG_SETTINGS_RENDER_GROUP_BITRATE), + labeled_field( + self._language_manager["settings.render.label.bitrate"], + self._layout.label_width, + ), + ): + dpg.add_combo( + tag=TAG_SETTINGS_RENDER_COMBO_BITRATE, + items=[], + width=self._layout.combo_width, + callback=self._on_bitrate_changed, + ) + + def _create_normalize_switch(self) -> None: + dpg.add_checkbox( + tag=TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, + label=self._language_manager["settings.render.label.normalize"], + callback=self._on_normalize_changed, + ) + + def _create_duration(self) -> None: + with labeled_field( + self._language_manager["settings.render.label.duration"], + self._layout.label_width, + ): + dpg.add_text("", tag=TAG_SETTINGS_RENDER_TEXT_DURATION) + FontRegistry.bind_to_item(TAG_SETTINGS_RENDER_TEXT_DURATION, Font.MONO) + + def _create_destination(self) -> None: + """Lays out the file a render writes, with the browse button beside the path it stands at. + + The button leads the row so it holds its place whatever the path reads, and the path + follows it as the answer to what the button asks. + """ + with labeled_field( + self._language_manager["settings.render.label.destination"], + self._layout.label_width, + ): + dpg.add_group(horizontal=True, tag=TAG_SETTINGS_RENDER_GROUP_DESTINATION) + + GUIButton( + tag=TAG_SETTINGS_RENDER_BUTTON_BROWSE, + label=self._language_manager["settings.render.label.browse_button"], + parent=TAG_SETTINGS_RENDER_GROUP_DESTINATION, + callback=self._request_destination, + ) + self._destination_text = GUIPathText( + tag=TAG_SETTINGS_RENDER_PATH_DESTINATION, + path=self._require_view_model().destination, + parent=TAG_SETTINGS_RENDER_GROUP_DESTINATION, + color=self._path_colors.default, + hover_color=self._path_colors.hover, + status_message=self._msg_path, + font=Font.REGULAR_SMALL, + status_bar=self._status_bar, + ) + + @table_wrapper(columns=2) + def _create_setup_buttons(self) -> None: + GUIButton( + tag=TAG_SETTINGS_RENDER_BUTTON_CLOSE, + label=self._language_manager["global.dialog.label.cancel"], + callback=self._request_close, + width=-1, + ) + GUIButton( + tag=TAG_SETTINGS_RENDER_BUTTON_START, + label=self._language_manager["settings.render.label.render_button"], + callback=self._request_render, + width=-1, + ) + + def _create_progress(self) -> None: + with dpg.group(tag=TAG_SETTINGS_RENDER_GROUP_PROGRESS, show=False): + dpg.add_text("", tag=TAG_SETTINGS_RENDER_TEXT_STATUS) + FontRegistry.bind_to_item(TAG_SETTINGS_RENDER_TEXT_STATUS, Font.MONO_SMALL) + dpg.add_progress_bar( + tag=TAG_SETTINGS_RENDER_PROGRESS, + default_value=0.0, + width=-1, + ) + FontRegistry.bind_to_item(TAG_SETTINGS_RENDER_PROGRESS, Font.MONO) + dpg.add_separator() + GUIButton( + tag=TAG_SETTINGS_RENDER_BUTTON_CANCEL, + label=self._language_manager["global.dialog.label.cancel"], + callback=self._request_cancel, + width=-1, + ) + + def _render(self) -> None: + """Shows the face the phase calls for, with each choice standing at what it reconciled to.""" + view_model = self._require_view_model() + self._render_setup(view_model) + self._render_progress(view_model) + + def _render_setup(self, view_model: SongRenderViewModel) -> None: + dpg_configure_item(TAG_SETTINGS_RENDER_GROUP_SETUP, show=view_model.setup_visible) + self._render_formats(view_model) + self._render_sample_rates(view_model) + self._render_depths(view_model) + self._render_bitrates(view_model) + dpg_configure_item( + TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, + enabled=view_model.setup_visible, + ) + dpg_set_value(TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, view_model.settings.normalize) + dpg_set_value(TAG_SETTINGS_RENDER_TEXT_DURATION, view_model.duration_label) + if self._destination_text is not None: + self._destination_text.set_path(view_model.destination) + + dpg_configure_item(TAG_SETTINGS_RENDER_BUTTON_BROWSE, enabled=view_model.setup_visible) + dpg_configure_item(TAG_SETTINGS_RENDER_BUTTON_CLOSE, enabled=view_model.setup_visible) + dpg_configure_item(TAG_SETTINGS_RENDER_BUTTON_START, enabled=view_model.render_enabled) + + def _render_formats(self, view_model: SongRenderViewModel) -> None: + self._formats_by_label = { + self._format_labels[audio_format]: audio_format for audio_format in view_model.formats + } + dpg_configure_item( + TAG_SETTINGS_RENDER_COMBO_FORMAT, + items=list(self._formats_by_label), + enabled=view_model.setup_visible, + ) + dpg_set_value( + TAG_SETTINGS_RENDER_COMBO_FORMAT, + self._format_labels[view_model.spec.audio_format], + ) + + def _render_sample_rates(self, view_model: SongRenderViewModel) -> None: + self._sample_rates_by_label = dict( + zip( + view_model.sample_rate_labels(self._fmt_sample_rate), + view_model.sample_rates, + ) + ) + dpg_configure_item( + TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + items=list(self._sample_rates_by_label), + enabled=view_model.setup_visible, + ) + dpg_set_value( + TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + view_model.sample_rate_label(self._fmt_sample_rate), + ) + + def _render_depths(self, view_model: SongRenderViewModel) -> None: + self._depths_by_label = {self._depth_labels[depth]: depth for depth in view_model.depths} + depth = view_model.settings.depth + dpg_configure_item(TAG_SETTINGS_RENDER_GROUP_DEPTH, show=view_model.depth_visible) + dpg_configure_item( + TAG_SETTINGS_RENDER_COMBO_DEPTH, + items=list(self._depths_by_label), + enabled=view_model.depth_enabled, + ) + dpg_set_value( + TAG_SETTINGS_RENDER_COMBO_DEPTH, + self._depth_labels[depth] if depth is not None else "", + ) + + def _render_bitrates(self, view_model: SongRenderViewModel) -> None: + self._bitrates_by_label = dict( + zip( + view_model.bitrate_labels(self._fmt_bitrate), + view_model.bitrates, + ) + ) + dpg_configure_item(TAG_SETTINGS_RENDER_GROUP_BITRATE, show=view_model.bitrate_visible) + dpg_configure_item( + TAG_SETTINGS_RENDER_COMBO_BITRATE, + items=list(self._bitrates_by_label), + enabled=view_model.bitrate_enabled, + ) + dpg_set_value( + TAG_SETTINGS_RENDER_COMBO_BITRATE, + view_model.bitrate_label(self._fmt_bitrate), + ) + + def _render_progress(self, view_model: SongRenderViewModel) -> None: + dpg_configure_item(TAG_SETTINGS_RENDER_GROUP_PROGRESS, show=view_model.progress_visible) + dpg_set_value(TAG_SETTINGS_RENDER_TEXT_STATUS, view_model.status_text) + dpg_set_value(TAG_SETTINGS_RENDER_PROGRESS, view_model.progress) + dpg_configure_item(TAG_SETTINGS_RENDER_PROGRESS, overlay=view_model.progress_overlay) + dpg_configure_item(TAG_SETTINGS_RENDER_BUTTON_CANCEL, enabled=view_model.cancel_enabled) + + def _on_format_changed(self, _sender: Sender, app_data: str) -> None: + self._emit(self._settings().with_format(self._formats_by_label[app_data])) + + def _on_sample_rate_changed(self, _sender: Sender, app_data: str) -> None: + self._emit(self._settings().with_sample_rate(self._sample_rates_by_label[app_data])) + + def _on_depth_changed(self, _sender: Sender, app_data: str) -> None: + self._emit(self._settings().with_depth(self._depths_by_label[app_data])) + + def _on_bitrate_changed(self, _sender: Sender, app_data: str) -> None: + self._emit(self._settings().with_bitrate(self._bitrates_by_label[app_data])) + + def _on_normalize_changed(self, _sender: Sender, app_data: bool) -> None: + self._emit(self._settings().with_normalize(bool(app_data))) + + def _emit(self, settings: SongRenderSettings) -> None: + self.call(self.on_settings_changed, settings) + + def _request_destination(self) -> None: + self.call(self.on_browse) + + def _request_render(self) -> None: + self.call(self.on_render) + + def _request_cancel(self) -> None: + self.call(self.on_cancel) + + def _request_close(self) -> None: + """Answers Escape and the title bar: a setup is done with, a running render is asked to stop. + + A render already stopping, and one that has reported its outcome, answer neither — what + they are waiting for is the service, which arrives on its own. + """ + view_model = self._require_view_model() + if view_model.cancel_enabled: + self._request_cancel() + elif view_model.setup_visible: + self.call(self.on_close) + + def _settings(self) -> SongRenderSettings: + return self._require_view_model().settings + + def _require_view_model(self) -> SongRenderViewModel: + """The render on screen. + + Raises: + SystemError: when the window is drawn before :meth:`open` seeds it. + """ + if self._view_model is None: + raise SystemError("The render window is drawn from a view model it was opened with") + + return self._view_model diff --git a/src/sampletones_application/view_model/shared/render.py b/src/sampletones_application/view_model/shared/render.py index 26f20dafc..5e8928c7d 100644 --- a/src/sampletones_application/view_model/shared/render.py +++ b/src/sampletones_application/view_model/shared/render.py @@ -226,6 +226,23 @@ def stores_samples(self) -> bool: """Whether the chosen container stores samples, which is what gives it a depth to choose.""" return self.spec.capability.stores_samples + def sample_rate_labels(self, template: str) -> Tuple[str, ...]: + """The rates on offer, each as ``template`` states it.""" + return tuple(template.format(rate=sample_rate) for sample_rate in self.sample_rates) + + def sample_rate_label(self, template: str) -> str: + """The chosen rate, as ``template`` states it.""" + return template.format(rate=self.spec.sample_rate) + + def bitrate_labels(self, template: str) -> Tuple[str, ...]: + """The bitrates on offer, each as ``template`` states it.""" + return tuple(template.format(bitrate=bitrate) for bitrate in self.bitrates) + + def bitrate_label(self, template: str) -> str: + """The chosen bitrate, as ``template`` states it, for a container that offers one.""" + bitrate = self.settings.bitrate + return template.format(bitrate=bitrate) if bitrate is not None else "" + @property def duration_seconds(self) -> float: """How long the song plays for, in seconds.""" @@ -253,6 +270,26 @@ def setup_visible(self) -> bool: def progress_visible(self) -> bool: return self.phase != RenderPhase.CONFIGURING + @property + def depth_visible(self) -> bool: + """Whether the depth is chosen here, which a container storing samples is what offers.""" + return self.stores_samples + + @property + def bitrate_visible(self) -> bool: + """Whether the bitrate is chosen here, which a container encoding to one is what offers.""" + return not self.stores_samples + + @property + def depth_enabled(self) -> bool: + """Whether the depth takes an edit: offered by the container, while the setup is showing.""" + return self.setup_visible and self.depth_visible + + @property + def bitrate_enabled(self) -> bool: + """Whether the bitrate takes an edit: offered by the container, while the setup is showing.""" + return self.setup_visible and self.bitrate_visible + @property def render_enabled(self) -> bool: """Whether a render starts from here: a song with something to write, still being set up.""" diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index a27a59e72..5eeb09a1e 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -661,6 +661,25 @@ settings.display.label.keep_editing_button: "Keep editing" settings.display.message.countdown: "Keep the window the way it looks now? It goes back to how it was when the count reaches zero." settings.display.message.discard_confirmation: "Discard the changes to the display settings?" settings.display.template.countdown_remaining: "Reverting in {seconds} s" +settings.render.title.window_title: "Render song" +settings.render.label.format: "Format" +settings.render.label.sample_rate: "Sample rate" +settings.render.label.depth: "Bit depth" +settings.render.label.bitrate: "Bitrate" +settings.render.label.normalize: "Normalize peak" +settings.render.label.duration: "Length" +settings.render.label.destination: "File" +settings.render.label.browse_button: "Browse..." +settings.render.label.render_button: "Render" +settings.render.label.format_wave: "WAV" +settings.render.label.format_mp3: "MP3" +settings.render.label.depth_pcm_u8: "8-bit PCM" +settings.render.label.depth_pcm_16: "16-bit PCM" +settings.render.label.depth_pcm_24: "24-bit PCM" +settings.render.label.depth_pcm_32: "32-bit PCM" +settings.render.label.depth_float_32: "32-bit float" +settings.render.template.sample_rate: "{rate} Hz" +settings.render.template.bitrate: "{bitrate} kbps" settings.render.message.status_synthesis: "Rendering the song..." settings.render.message.status_encoding: "Writing the file..." settings.render.message.status_cancelling: "Stopping the render..." diff --git a/src/sampletones_config/layout/settings/render.yaml b/src/sampletones_config/layout/settings/render.yaml new file mode 100644 index 000000000..2f91551f5 --- /dev/null +++ b/src/sampletones_config/layout/settings/render.yaml @@ -0,0 +1,3 @@ +window: + width: 480 + height: 0 diff --git a/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py b/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py new file mode 100644 index 000000000..9b8cece34 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/dialogs/test_render.py @@ -0,0 +1,309 @@ +from pathlib import Path +from typing import Final, List, Optional + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.paths import LANG_EN +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import SUF_BUTTON +from sampletones_application.tags.settings import ( + TAG_SETTINGS_RENDER_BUTTON_BROWSE, + TAG_SETTINGS_RENDER_BUTTON_CANCEL, + TAG_SETTINGS_RENDER_BUTTON_CLOSE, + TAG_SETTINGS_RENDER_BUTTON_START, + TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, + TAG_SETTINGS_RENDER_COMBO_BITRATE, + TAG_SETTINGS_RENDER_COMBO_DEPTH, + TAG_SETTINGS_RENDER_COMBO_FORMAT, + TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + TAG_SETTINGS_RENDER_GROUP_BITRATE, + TAG_SETTINGS_RENDER_GROUP_DEPTH, + TAG_SETTINGS_RENDER_GROUP_PROGRESS, + TAG_SETTINGS_RENDER_GROUP_SETUP, + TAG_SETTINGS_RENDER_PATH_DESTINATION, + TAG_SETTINGS_RENDER_PROGRESS, + TAG_SETTINGS_RENDER_TEXT_DURATION, + TAG_SETTINGS_RENDER_TEXT_STATUS, +) +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.panels.dialogs.render import GUIRenderWindow +from sampletones_application.utils.gui.keyboard import KeyRouter +from sampletones_application.view_model.shared.render import ( + RenderPhase, + SongRenderSettings, + SongRenderViewModel, +) +from sampletones_core.audio.writers import AudioDepth, AudioFormat +from sampletones_shared.utils.system.paths import shorten_path +from tests.suite.shortcuts import shipped_source + +LANGUAGE_MANAGER: Final[LanguageManager] = LanguageManager(LANG_EN) +DESTINATION: Final[Path] = Path("/home/user/audio/chiptune.wav") +TOTAL_SAMPLES: Final[int] = 44100 * 90 +STATUS_TEXT: Final[str] = "Rendering the song..." + + +def view_model( + *, + settings: SongRenderSettings, + phase: RenderPhase = RenderPhase.CONFIGURING, + progress: float = 0.0, +) -> SongRenderViewModel: + return SongRenderViewModel( + phase=phase, + formats=(AudioFormat.WAVE, AudioFormat.MP3), + depths=(AudioDepth.PCM_U8, AudioDepth.PCM_16, AudioDepth.PCM_24), + settings=settings, + destination=DESTINATION, + total_samples=TOTAL_SAMPLES, + status_text=STATUS_TEXT if phase == RenderPhase.RENDERING else "", + progress=progress, + ) + + +def wave_settings() -> SongRenderSettings: + return SongRenderSettings.initial(AudioFormat.WAVE) + + +def mp3_settings() -> SongRenderSettings: + return SongRenderSettings.initial(AudioFormat.MP3) + + +@pytest.fixture(name="window") +def window_fixture(dpg_context: None, layout_config: LayoutConfig) -> GUIRenderWindow: + return GUIRenderWindow( + layout=layout_config.settings, + path_colors=layout_config.general.colors.paths, + language_manager=LANGUAGE_MANAGER, + key_router=KeyRouter(), + shortcut_source=shipped_source(), + status_bar=GUIStatusBar(display_time=1.0), + ) + + +def render( + window: GUIRenderWindow, + *, + settings: Optional[SongRenderSettings] = None, + phase: RenderPhase = RenderPhase.CONFIGURING, + progress: float = 0.0, +) -> None: + """Builds the widget tree for the given state, the way ``open`` does without a live frame.""" + window.update_view( + view_model( + settings=settings if settings is not None else wave_settings(), + phase=phase, + progress=progress, + ) + ) + window.create_window() + + +def press(tag: str) -> None: + dpg.get_item_callback(compose_tag(tag, SUF_BUTTON))() + + +class TestTheSetup: + def test_every_written_container_reaches_the_combo(self, window: GUIRenderWindow) -> None: + render(window) + + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_FORMAT)["items"] == ["WAV", "MP3"] + + def test_the_rates_offered_are_the_containers_own(self, window: GUIRenderWindow) -> None: + render(window, settings=mp3_settings()) + + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE)["items"] == [ + "8000 Hz", + "16000 Hz", + "22050 Hz", + "44100 Hz", + "48000 Hz", + ] + + def test_a_container_storing_samples_offers_a_depth(self, window: GUIRenderWindow) -> None: + render(window) + + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_DEPTH)["show"] + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_BITRATE)["show"] + assert dpg.get_value(TAG_SETTINGS_RENDER_COMBO_DEPTH) == "16-bit PCM" + + def test_a_container_encoding_to_a_bitrate_offers_one(self, window: GUIRenderWindow) -> None: + render(window, settings=mp3_settings()) + + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_BITRATE)["show"] + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_DEPTH)["show"] + assert dpg.get_value(TAG_SETTINGS_RENDER_COMBO_BITRATE) == "192 kbps" + + def test_the_song_is_shown_at_the_length_it_renders_to(self, window: GUIRenderWindow) -> None: + render(window) + + assert dpg.get_value(TAG_SETTINGS_RENDER_TEXT_DURATION) == "1m 30s" + + def test_the_file_and_the_actions_over_it_are_offered(self, window: GUIRenderWindow) -> None: + render(window) + + assert dpg.get_value(TAG_SETTINGS_RENDER_PATH_DESTINATION) == shorten_path(DESTINATION) + assert dpg.does_item_exist(TAG_SETTINGS_RENDER_BUTTON_BROWSE) + assert dpg.does_item_exist(TAG_SETTINGS_RENDER_BUTTON_START) + assert dpg.does_item_exist(TAG_SETTINGS_RENDER_BUTTON_CLOSE) + + +class TestTheTwoFaces: + def test_setting_up_shows_the_setup_alone(self, window: GUIRenderWindow) -> None: + render(window) + + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_SETUP)["show"] + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_PROGRESS)["show"] + + def test_rendering_shows_the_progress_alone(self, window: GUIRenderWindow) -> None: + render(window, phase=RenderPhase.RENDERING, progress=0.5) + + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_PROGRESS)["show"] + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_GROUP_SETUP)["show"] + assert dpg.get_value(TAG_SETTINGS_RENDER_PROGRESS) == pytest.approx(0.5) + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_PROGRESS)["overlay"] == "50%" + assert dpg.get_value(TAG_SETTINGS_RENDER_TEXT_STATUS) == STATUS_TEXT + + def test_a_control_off_screen_takes_no_focus(self, window: GUIRenderWindow) -> None: + """The focus ring skips a disabled stop, which is what keeps Tab on the face being shown.""" + render(window, phase=RenderPhase.RENDERING) + + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_FORMAT)["enabled"] + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_DEPTH)["enabled"] + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_BUTTON_CANCEL)["enabled"] + + def test_a_render_already_stopping_takes_no_further_stop(self, window: GUIRenderWindow) -> None: + render(window, phase=RenderPhase.CANCELLING) + + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_BUTTON_CANCEL)["enabled"] + + def test_the_hidden_choice_takes_no_focus(self, window: GUIRenderWindow) -> None: + render(window, settings=mp3_settings()) + + assert not dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_DEPTH)["enabled"] + assert dpg.get_item_configuration(TAG_SETTINGS_RENDER_COMBO_BITRATE)["enabled"] + + +class TestReportedEdits: + """Every control reports the whole edited state, so the owner reconciles one value.""" + + @pytest.fixture(name="reported") + def reported_fixture(self, window: GUIRenderWindow) -> List[SongRenderSettings]: + reported: List[SongRenderSettings] = [] + window.on_settings_changed = reported.append + render(window) + return reported + + def test_picking_a_container_reports_it( + self, + window: GUIRenderWindow, + reported: List[SongRenderSettings], + ) -> None: + dpg.get_item_callback(TAG_SETTINGS_RENDER_COMBO_FORMAT)(TAG_SETTINGS_RENDER_COMBO_FORMAT, "MP3") + + assert reported[-1].spec.audio_format == AudioFormat.MP3 + + def test_picking_a_rate_reports_it( + self, + window: GUIRenderWindow, + reported: List[SongRenderSettings], + ) -> None: + dpg.get_item_callback(TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE)( + TAG_SETTINGS_RENDER_COMBO_SAMPLE_RATE, + "8000 Hz", + ) + + assert reported[-1].spec.sample_rate == 8000 + + def test_picking_a_depth_reports_it( + self, + window: GUIRenderWindow, + reported: List[SongRenderSettings], + ) -> None: + dpg.get_item_callback(TAG_SETTINGS_RENDER_COMBO_DEPTH)( + TAG_SETTINGS_RENDER_COMBO_DEPTH, + "8-bit PCM", + ) + + assert reported[-1].depth == AudioDepth.PCM_U8 + + def test_picking_a_bitrate_reports_it(self, window: GUIRenderWindow) -> None: + reported: List[SongRenderSettings] = [] + window.on_settings_changed = reported.append + render(window, settings=mp3_settings()) + + dpg.get_item_callback(TAG_SETTINGS_RENDER_COMBO_BITRATE)( + TAG_SETTINGS_RENDER_COMBO_BITRATE, + "128 kbps", + ) + + assert reported[-1].bitrate == 128 + + def test_asking_for_the_peak_to_reach_full_scale_reports_it( + self, + window: GUIRenderWindow, + reported: List[SongRenderSettings], + ) -> None: + dpg.get_item_callback(TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE)( + TAG_SETTINGS_RENDER_CHECKBOX_NORMALIZE, + True, + ) + + assert reported[-1].normalize + + +class TestReportedActions: + def test_the_browse_button_asks_for_a_file(self, window: GUIRenderWindow) -> None: + asked: List[None] = [] + window.on_browse = lambda: asked.append(None) + render(window) + + press(TAG_SETTINGS_RENDER_BUTTON_BROWSE) + + assert asked + + def test_the_render_button_starts_the_render(self, window: GUIRenderWindow) -> None: + started: List[None] = [] + window.on_render = lambda: started.append(None) + render(window) + + press(TAG_SETTINGS_RENDER_BUTTON_START) + + assert started + + def test_the_stop_button_stops_a_running_render(self, window: GUIRenderWindow) -> None: + stopped: List[None] = [] + window.on_cancel = lambda: stopped.append(None) + render(window, phase=RenderPhase.RENDERING) + + press(TAG_SETTINGS_RENDER_BUTTON_CANCEL) + + assert stopped + + def test_leaving_the_setup_closes_the_dialog(self, window: GUIRenderWindow) -> None: + closed: List[None] = [] + stopped: List[None] = [] + window.on_close = lambda: closed.append(None) + window.on_cancel = lambda: stopped.append(None) + render(window) + + press(TAG_SETTINGS_RENDER_BUTTON_CLOSE) + + assert closed + assert not stopped + + def test_leaving_a_running_render_stops_it_instead(self, window: GUIRenderWindow) -> None: + """Escape and the title bar answer through the same handler the Cancel button does.""" + closed: List[None] = [] + stopped: List[None] = [] + window.on_close = lambda: closed.append(None) + window.on_cancel = lambda: stopped.append(None) + render(window, phase=RenderPhase.RENDERING) + + press(TAG_SETTINGS_RENDER_BUTTON_CLOSE) + + assert stopped + assert not closed From a4c88097a91b24fac5906508bf2fb50012a1441c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 10:11:19 +0200 Subject: [PATCH 063/152] Added: song render wiring, menu entry and shortcut --- src/sampletones_application/application.py | 61 ++- .../categories/elements/global_.py | 1 + .../categories/elements/settings.py | 1 + .../coordinators/render.py | 170 +++++++++ src/sampletones_application/shell.py | 2 + src/sampletones_application/tags/general.py | 6 + src/sampletones_application/ui/menu.py | 11 + .../utils/gui/shortcuts/ids.py | 1 + .../view_model/shared/menu.py | 6 + .../keybindings/default.yaml | 1 + src/sampletones_config/keybindings/macos.yaml | 1 + src/sampletones_config/lang/en.yaml | 7 + tests/suite/render.py | 83 ++++ .../coordinators/test_render.py | 361 ++++++++++++++++++ .../logic/render/test_logic.py | 79 +--- .../sampletones_application/test_busy_lock.py | 34 +- .../sampletones_application/ui/test_menu.py | 1 + .../view_model/shared/test_menu.py | 2 + 18 files changed, 742 insertions(+), 86 deletions(-) create mode 100644 src/sampletones_application/coordinators/render.py create mode 100644 tests/suite/render.py create mode 100644 tests/unit/sampletones_application/coordinators/test_render.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 078505f1f..f1b0653dd 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -23,6 +23,7 @@ from sampletones_application.coordinators.reconstruction import ( ReconstructionCoordinator, ) +from sampletones_application.coordinators.render import SongRenderCoordinator from sampletones_application.coordinators.tabs.instructions import ( InstructionsTabCoordinator, ) @@ -46,6 +47,7 @@ ) from sampletones_application.logic.reconstruction.browser_manager import BrowserManager from sampletones_application.logic.reconstruction.manager import ReconstructionManager +from sampletones_application.logic.render import SongRenderLogic from sampletones_application.parameters import ( InstructionsTabParameters, MainTabParameters, @@ -72,6 +74,7 @@ ServiceCancelled, ServiceError, ServiceSuccess, + SongRenderService, ) from sampletones_application.shell import ApplicationShell, ShortcutBindings from sampletones_application.tags.general import ( @@ -100,6 +103,7 @@ from sampletones_application.ui.panels.dialogs.project_properties import ( GUIProjectPropertiesWindow, ) +from sampletones_application.ui.panels.dialogs.render import GUIRenderWindow from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.utils.callbacks.queue import CallbackQueue @@ -228,6 +232,7 @@ def __init__( self.conversion_service: ConversionService = ConversionService(priority=_priority) self.regeneration_service: RegenerationService = RegenerationService(priority=_priority) self.export_service: ExportService = ExportService(priority=_priority) + self.render_service: SongRenderService = SongRenderService(priority=_priority) self.retune_service: SampleRetuneService = SampleRetuneService(priority=_priority) self.retune_service.subscribe(self._on_retune_result) @@ -279,6 +284,14 @@ def __init__( key_router=self.key_router, shortcut_source=self._shortcut_source, ) + self.render_window: GUIRenderWindow = GUIRenderWindow( + layout=self.layout.settings, + path_colors=self.layout.general.colors.paths, + language_manager=self.language_manager, + key_router=self.key_router, + shortcut_source=self._shortcut_source, + status_bar=self.status_bar, + ) self.project_properties_window: GUIProjectPropertiesWindow = GUIProjectPropertiesWindow( layout=self.layout.project_properties, language_manager=self.language_manager, @@ -466,6 +479,23 @@ def __init__( language_manager=self.language_manager, ) + self._render_logic = SongRenderLogic( + self.project_controller, + self.config_manager, + self.session_manager, + self.render_service, + language_manager=self.language_manager, + is_operation_active=self._is_operation_active, + ) + + self._render_coordinator = SongRenderCoordinator( + self._render_logic, + window=self.render_window, + dialogs=self.dialogs, + language_manager=self.language_manager, + on_activity_changed=self._on_render_activity_changed, + ) + self._shell = ApplicationShell( session_manager=self.session_manager, language_manager=self.language_manager, @@ -554,6 +584,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: save_project_as=self._project_coordinator.save_as_dialog, project_properties=self._open_project_properties, export_project=self._project_coordinator.export_project_dialog, + render_song=self._render_coordinator.open, close_project=self._project_coordinator.close_with_confirmation, exit=self._on_close, undo=self._sequencer_tab.undo, @@ -663,6 +694,7 @@ def _build_initial_menu_state(self) -> MenuBarViewModel: reconstruction_in_project=self._editing_project_sample(), reconstruction_file_backed=self._reconstruction_coordinator.is_saveable(), reconstruction_audio_recorded=self.reconstruction_manager.audio_filepath is not None, + operation_active=self._is_operation_active(), can_undo=self.history.can_undo, can_redo=self.history.can_redo, play_label=self.language_manager["global.menu.label.item_playback_play"], @@ -701,6 +733,7 @@ def _build_menu_bar_viewmodel(self) -> MenuBarViewModel: reconstruction_in_project=self._editing_project_sample(), reconstruction_file_backed=self._reconstruction_coordinator.is_saveable(), reconstruction_audio_recorded=self.reconstruction_manager.audio_filepath is not None, + operation_active=self._is_operation_active(), can_undo=self.history.can_undo, can_redo=self.history.can_redo, play_label=self._playback_router.play_label, @@ -809,14 +842,30 @@ def _is_converter_panel_visible(self) -> bool: return self._main_tab.is_converter_panel_visible() def _is_operation_active(self) -> bool: - return self._main_tab.is_converter_active() or self._instructions_tab.is_library_generating() + return ( + self._main_tab.is_converter_active() + or self._instructions_tab.is_library_generating() + or self._render_coordinator.is_active + ) def _refresh_busy_state(self) -> None: - """Re-evaluate the reconstruct and generate-library buttons whenever a conversion or library - generation starts or finishes, keeping the two long operations mutually exclusive. Each panel - reads the live ``_is_operation_active`` state for itself; this only nudges them to - re-apply, so the busy truth lives in one place.""" + """Re-evaluate the reconstruct and generate-library buttons whenever a conversion, library + generation or render starts or finishes, keeping the long operations mutually exclusive. Each + panel reads the live ``_is_operation_active`` state for itself; this only nudges them to + re-apply, so the busy truth lives in one place. The menu follows the same edge, since what + greys an entry offering another such operation is one already running.""" self._instructions_tab.refresh_generate_button() + self._update_menu() + + def _on_render_activity_changed(self) -> None: + """Follows a render claiming the application and handing it back. + + What a render occupies is the same ground a conversion or a library generation occupies, + so its edges reach the same busy state — the action buttons of each tab, the converter's + own view, and the menu entries that would start another exclusive operation. + """ + self._refresh_busy_state() + self._main_tab.refresh_converter_view() def _on_library_operation_changed(self) -> None: """Responds to a library generation starting or finishing: refreshes the cross-tab action @@ -1361,6 +1410,7 @@ def _is_project_open(self) -> bool: return self.project_controller.is_open def _exit_application(self) -> None: + self._render_coordinator.cleanup() stop_background_workers() self._playback_router.shutdown() self._main_tab.cleanup() @@ -1417,6 +1467,7 @@ def run(self) -> None: except KeyboardInterrupt: return finally: + self._render_coordinator.cleanup() stop_background_workers() self._playback_router.shutdown() self._main_tab.cleanup() diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index d2c524a0b..5dfca380a 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -67,6 +67,7 @@ class MenuElements(AbstractElement): GROUP_FILE_EXPORT = "group_file_export" ITEM_FILE_EXPORT_FAMITRACKER = "item_file_export_famitracker" ITEM_FILE_EXPORT_BITPHASE = "item_file_export_bitphase" + ITEM_FILE_RENDER_SONG = "item_file_render_song" ITEM_FILE_CLOSE_PROJECT = "item_file_close_project" ITEM_FILE_EXIT = "item_file_exit" GROUP_EDIT = "group_edit" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 25338b7e2..6c9943c0a 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -41,6 +41,7 @@ class KeybindingActionElements(AbstractElement): PROJECT_PROPERTIES = "project_properties" EXPORT_PROJECT_FAMITRACKER = "export_project_famitracker" EXPORT_PROJECT_BITPHASE = "export_project_bitphase" + RENDER_SONG = "render_song" CLOSE_PROJECT = "close_project" EXIT = "exit" UNDO = "undo" diff --git a/src/sampletones_application/coordinators/render.py b/src/sampletones_application/coordinators/render.py new file mode 100644 index 000000000..b2ed163a5 --- /dev/null +++ b/src/sampletones_application/coordinators/render.py @@ -0,0 +1,170 @@ +from functools import partial +from pathlib import Path +from typing import Dict, Optional, Tuple + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.logic.render.logic import SongRenderLogic +from sampletones_application.ui.panels.dialogs.render import GUIRenderWindow +from sampletones_application.utils.file_dialogs.api import save_file_dialog +from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_application.utils.file_dialogs.result import ignore_none_path +from sampletones_application.utils.gui.dialogs import DialogsRenderer +from sampletones_application.utils.gui.frame import FrameCallbackManager +from sampletones_application.view_model.shared.render import SongRenderViewModel +from sampletones_core.audio.writers import AudioFormat, capability_of +from sampletones_shared.types.callback import VoidCallback + + +class SongRenderCoordinator: + """Owns writing the song to an audio file from the reader's side: the dialog, the destination + it is asked for, and the report a finished render makes. + + The logic holds the render itself, so what is orchestrated here is the screen: the window + opens over the settings the logic offers and follows every view it emits, the destination is + asked for through the OS dialog, and each outcome leaves the window and raises the dialog that + reports it. + + A render claims the application for as long as its dialog stands, so every way out passes + through one close, which releases the claim and tells the application to read it again. + """ + + def __init__( + self, + render_logic: SongRenderLogic, + *, + window: GUIRenderWindow, + dialogs: DialogsRenderer, + language_manager: LanguageManager, + on_activity_changed: VoidCallback, + ) -> None: + self._logic = render_logic + self._window = window + self._dialogs = dialogs + self._on_activity_changed = on_activity_changed + self._view_model: Optional[SongRenderViewModel] = None + self._window_open = False + + self._title_destination = language_manager["settings.render.title.destination_dialog"] + self._title_rendered = language_manager["settings.render.title.rendered"] + self._msg_rendered = language_manager["settings.render.message.rendered"] + self._msg_failed = language_manager["settings.render.message.render_failed"] + self._filter_names: Dict[AudioFormat, str] = { + AudioFormat.WAVE: language_manager["global.dialog.filter.wave"], + AudioFormat.MP3: language_manager["global.dialog.filter.mp3"], + } + + self._logic.on_view_changed = self._on_view_changed + self._logic.on_choose_destination = self._choose_destination + self._logic.on_success = self._on_success + self._logic.on_error = self._on_error + self._logic.on_cancelled = self._on_cancelled + + self._window.on_settings_changed = self._logic.apply + self._window.on_browse = self._logic.request_destination + self._window.on_render = self._logic.start + self._window.on_cancel = self._logic.cancel + self._window.on_close = self._close + + @property + def is_active(self) -> bool: + """A render occupies the application from the dialog opening until it closes.""" + return self._logic.is_active + + def open(self) -> None: + """Offers the render settings for the open song, over the document as it stands now.""" + if not self._logic.open(): + return + + self._window_open = True + self._window.open(self._require_view_model()) + self._on_activity_changed() + + def cleanup(self) -> None: + """Winds a running render down for application exit.""" + self._logic.cleanup() + + def _on_view_changed(self, view_model: SongRenderViewModel) -> None: + """Keeps the open window standing at where the render has got to.""" + self._view_model = view_model + if self._window_open: + self._window.update_view(view_model) + + def _choose_destination( + self, + destination: Path, + audio_format: AudioFormat, + ) -> None: + """Asks for the file the render writes, starting from the one the dialog stands at.""" + filepath = save_file_dialog( + title=self._title_destination, + initial_directory=destination.parent, + default_filename=destination.name, + filters=self._destination_filters(audio_format), + ) + + self._set_destination(filepath) + + @ignore_none_path + def _set_destination(self, filepath: Path) -> None: + self._logic.set_destination(filepath) + + def _destination_filters(self, audio_format: AudioFormat) -> Tuple[FileFilter, ...]: + """The type a destination is offered under: the container the dialog stands at. + + The format is chosen in the dialog itself, so the file type follows it and a name typed + without an extension takes the one that container is written under. + """ + return ( + FileFilter.for_extensions( + self._filter_names[audio_format], + [capability_of(audio_format).extension], + ), + ) + + def _on_success(self, destination: Path) -> None: + """Reports the file a finished render wrote, as a path that opens in the file manager.""" + self._close() + self._present( + partial( + self._dialogs.show_message_with_path, + self._title_rendered, + self._msg_rendered, + destination, + ) + ) + + def _on_error(self, exception: Exception) -> None: + """Reports what a render failed on, leaving the destination as it was.""" + self._close() + self._present(partial(self._dialogs.show_error, exception, self._msg_failed)) + + def _on_cancelled(self) -> None: + """Closes the dialog of a render that was stopped, which leaves no file to report.""" + self._close() + + def _close(self) -> None: + """Takes the dialog off screen and hands the application back.""" + self._window_open = False + self._view_model = None + self._window.hide() + self._logic.close() + self._on_activity_changed() + + def _present(self, raise_dialog: VoidCallback) -> None: + """Raises ``raise_dialog`` once the frame the window left the screen in has finished. + + The render window is modal and DearPyGui carries one modal at a time, so a report waits + for the frame that draws the screen without it and opens onto a clear screen. + """ + FrameCallbackManager.set_frame_callback(raise_dialog) + + def _require_view_model(self) -> SongRenderViewModel: + """The render the dialog opens on. + + Raises: + SystemError: when the window is raised before the logic offers a view. + """ + if self._view_model is None: + raise SystemError("The render window is opened over the view the logic emits") + + return self._view_model diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 9d1569360..e74b9bbad 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -71,6 +71,7 @@ class ShortcutBindings: save_project_as: Callback project_properties: Callback export_project: Callable[[TrackerFormat], None] + render_song: Callback close_project: Callback exit: Callback undo: Callback @@ -216,6 +217,7 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback ShortcutId.SAVE_PROJECT: bindings.save_project, ShortcutId.SAVE_PROJECT_AS: bindings.save_project_as, ShortcutId.PROJECT_PROPERTIES: bindings.project_properties, + ShortcutId.RENDER_SONG: bindings.render_song, ShortcutId.CLOSE_PROJECT: bindings.close_project, ShortcutId.EXIT: bindings.exit, ShortcutId.UNDO: bindings.undo, diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 6d6065d87..7cd592fbb 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -416,6 +416,12 @@ Widget.MENU, "item_file_export", ) +TAG_GLOBAL_MENU_ITEM_FILE_RENDER_SONG = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "item_file_render_song", +) TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index c1e06fdbf..6b63ed914 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -25,6 +25,7 @@ TAG_GLOBAL_MENU_ITEM_FILE_NEW_PROJECT, TAG_GLOBAL_MENU_ITEM_FILE_OPEN_PROJECT, TAG_GLOBAL_MENU_ITEM_FILE_PROJECT_PROPERTIES, + TAG_GLOBAL_MENU_ITEM_FILE_RENDER_SONG, TAG_GLOBAL_MENU_ITEM_FILE_SAVE_PROJECT, TAG_GLOBAL_MENU_ITEM_FILE_SAVE_PROJECT_AS, TAG_GLOBAL_MENU_ITEM_PLAYBACK_AUTOPLAY, @@ -203,6 +204,12 @@ def _create_file_menu(self, state: MenuBarViewModel) -> None: enabled=state.project_open, ) self._create_project_export_menu(state) + self._shortcut_manager.add_menu_item( + ShortcutId.RENDER_SONG, + tag=TAG_GLOBAL_MENU_ITEM_FILE_RENDER_SONG, + label=self._label(MenuElements.ITEM_FILE_RENDER_SONG), + enabled=state.render_enabled, + ) dpg.add_separator() self._shortcut_manager.add_menu_item( ShortcutId.CLOSE_PROJECT, @@ -513,6 +520,10 @@ def update(self, state: MenuBarViewModel) -> None: for project_item_tag in PROJECT_ITEM_TAGS: dpg_configure_item(project_item_tag, enabled=state.project_open) + dpg_configure_item( + TAG_GLOBAL_MENU_ITEM_FILE_RENDER_SONG, + enabled=state.render_enabled, + ) dpg_configure_item( TAG_GLOBAL_MENU_ITEM_EDIT_UNDO, enabled=state.undo_enabled, diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index aa2adaf80..f8298e9a9 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -47,6 +47,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: PROJECT_PROPERTIES = ("ProjectProperties", ShortcutCategory.APPLICATION) EXPORT_PROJECT_FAMITRACKER = ("ExportProjectFamiTracker", ShortcutCategory.APPLICATION) EXPORT_PROJECT_BITPHASE = ("ExportProjectBitphase", ShortcutCategory.APPLICATION) + RENDER_SONG = ("RenderSong", ShortcutCategory.APPLICATION) CLOSE_PROJECT = ("CloseProject", ShortcutCategory.APPLICATION) EXIT = ("Exit", ShortcutCategory.APPLICATION) UNDO = ("Undo", ShortcutCategory.APPLICATION) diff --git a/src/sampletones_application/view_model/shared/menu.py b/src/sampletones_application/view_model/shared/menu.py index 04bb3798f..b14d4d154 100644 --- a/src/sampletones_application/view_model/shared/menu.py +++ b/src/sampletones_application/view_model/shared/menu.py @@ -7,6 +7,7 @@ class MenuBarViewModel(BaseModel, frozen=True): channels: SequencerChannelsViewModel project_open: bool + operation_active: bool reconstruction_loaded: bool reconstruction_saveable: bool reconstruction_in_project: bool @@ -35,6 +36,11 @@ def undo_enabled(self) -> bool: def redo_enabled(self) -> bool: return self.project_open and self.can_redo + @property + def render_enabled(self) -> bool: + """Rendering the song needs a song to render, while the application is free to run one.""" + return self.project_open and not self.operation_active + @property def add_to_sequencer_enabled(self) -> bool: """Adding the loaded reconstruction needs an open project that does not already hold it.""" diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 140c84e3b..79a9d4063 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -9,6 +9,7 @@ bindings: ProjectProperties: {combination: "Alt+P"} ExportProjectFamiTracker: {combination: "Ctrl+M"} ExportProjectBitphase: {combination: "Ctrl+B"} + RenderSong: {combination: "Ctrl+Shift+E"} CloseProject: {combination: "Ctrl+W"} Exit: {combination: "Alt+F4"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 175a1dfc8..096486787 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -9,6 +9,7 @@ bindings: ProjectProperties: {combination: "Cmd+Alt+P"} ExportProjectFamiTracker: {combination: "Cmd+M"} ExportProjectBitphase: {combination: "Cmd+B"} + RenderSong: {combination: "Cmd+Shift+E"} CloseProject: {combination: "Cmd+W"} Exit: {combination: "Cmd+Q"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 5eeb09a1e..5909684e1 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -55,6 +55,7 @@ global.dialog.filter.bitphase_preset: "Bitphase instrument preset" global.dialog.filter.config: "Configuration files" global.dialog.filter.audio: "Audio files" global.dialog.filter.wave: "WAV audio" +global.dialog.filter.mp3: "MP3 audio" # Global — Dialog messages global.dialog.message.tree_no_results: "No results found." @@ -165,6 +166,7 @@ global.menu.label.item_file_project_properties: "Project properties..." global.menu.label.group_file_export: "Export" global.menu.label.item_file_export_famitracker: "FamiTracker module..." global.menu.label.item_file_export_bitphase: "Bitphase project..." +global.menu.label.item_file_render_song: "Render song..." global.menu.label.item_file_close_project: "Close project" global.menu.label.item_file_exit: "Exit" global.menu.label.group_edit: "Edit" @@ -662,6 +664,8 @@ settings.display.message.countdown: "Keep the window the way it looks now? It go settings.display.message.discard_confirmation: "Discard the changes to the display settings?" settings.display.template.countdown_remaining: "Reverting in {seconds} s" settings.render.title.window_title: "Render song" +settings.render.title.destination_dialog: "Save rendered song" +settings.render.title.rendered: "Song rendered" settings.render.label.format: "Format" settings.render.label.sample_rate: "Sample rate" settings.render.label.depth: "Bit depth" @@ -686,6 +690,8 @@ settings.render.message.status_cancelling: "Stopping the render..." settings.render.message.status_cancelled: "Render cancelled." settings.render.message.status_completed: "Render complete." settings.render.message.status_failed: "Render failed." +settings.render.message.rendered: "The song was rendered successfully." +settings.render.message.render_failed: "Failed to render the song." settings.keybindings.title.window_title: "Keyboard shortcuts" settings.keybindings.title.application: "Application" settings.keybindings.title.order: "Order list" @@ -716,6 +722,7 @@ settings.keybindings.label.save_project_as: "Save project as" settings.keybindings.label.project_properties: "Project properties" settings.keybindings.label.export_project_famitracker: "Export project to FamiTracker" settings.keybindings.label.export_project_bitphase: "Export project to Bitphase" +settings.keybindings.label.render_song: "Render song to an audio file" settings.keybindings.label.close_project: "Close project" settings.keybindings.label.exit: "Exit" settings.keybindings.label.undo: "Undo" diff --git a/tests/suite/render.py b/tests/suite/render.py new file mode 100644 index 000000000..5831f05ff --- /dev/null +++ b/tests/suite/render.py @@ -0,0 +1,83 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, List, Optional + +from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer +from sampletones_application.services.render.result import RenderResult +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceSuccess, +) +from sampletones_core.audio.writers import AudioOutputSpec + + +@dataclass(frozen=True) +class RenderRequest: + synthesizer: RowSynthesizer + destination: Path + spec: AudioOutputSpec + normalize: bool + total_samples: int + + +class FakeRenderService: + """The render service as the logic drives it, holding what it was asked to render. + + Results are delivered through the handler the logic subscribes, so a test walks a render the + way the worker reports one. + """ + + def __init__(self, *, accepts: bool = True) -> None: + self.accepts = accepts + self.requests: List[RenderRequest] = [] + self.cancels: int = 0 + self.shutdowns: int = 0 + self.running: bool = False + self._handler: Optional[Callable[[RenderResult], None]] = None + + def subscribe(self, handler: Callable[[RenderResult], None]) -> None: + self._handler = handler + + def start( + self, + *, + synthesizer: RowSynthesizer, + destination: Path, + spec: AudioOutputSpec, + normalize: bool, + total_samples: int, + ) -> bool: + if not self.accepts: + return False + + self.requests.append( + RenderRequest( + synthesizer=synthesizer, + destination=destination, + spec=spec, + normalize=normalize, + total_samples=total_samples, + ) + ) + self.running = True + return True + + def cancel(self) -> None: + self.cancels += 1 + + def is_running(self) -> bool: + return self.running + + def shutdown(self) -> None: + self.shutdowns += 1 + + def emit(self, result: RenderResult) -> None: + assert self._handler is not None, "The logic subscribes to the service it is given" + self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCancelled)) + self._handler(result) + + @property + def request(self) -> RenderRequest: + assert self.requests, "A render was expected to start" + return self.requests[-1] diff --git a/tests/unit/sampletones_application/coordinators/test_render.py b/tests/unit/sampletones_application/coordinators/test_render.py new file mode 100644 index 000000000..8e642d443 --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/test_render.py @@ -0,0 +1,361 @@ +from pathlib import Path +from typing import Any, Dict, Final, List, Optional +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.coordinators import render as render_module +from sampletones_application.coordinators.render import SongRenderCoordinator +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.render.logic import SongRenderLogic +from sampletones_application.services.result import ( + ServiceCancelled, + ServiceError, + ServiceSuccess, +) +from sampletones_application.utils.file_dialogs.filter import FileFilter +from sampletones_application.view_model.shared.render import ( + SongRenderSettings, + SongRenderViewModel, +) +from sampletones_core.audio.writers import AudioFormat +from sampletones_core.configs import Config +from sampletones_shared.types.callback import VoidCallback +from tests.suite.language import FakeLanguageManager +from tests.suite.render import FakeRenderService + +AUDIO_DIRECTORY: Final[Path] = Path("/home/user/audio") +PROJECT_NAME: Final[str] = "chiptune" +CHOSEN: Final[Path] = Path("/home/user/renders/take one.wav") + + +class _WindowRecorder: + """Stands in for the render dialog, holding what it was told to show.""" + + def __init__(self) -> None: + self.view_models: List[SongRenderViewModel] = [] + self.visible = False + self.hides = 0 + self.on_settings_changed: Any = None + self.on_browse: Any = None + self.on_render: Any = None + self.on_cancel: Any = None + self.on_close: Any = None + + def open(self, view_model: SongRenderViewModel) -> None: + self.visible = True + self.view_models.append(view_model) + + def update_view(self, view_model: SongRenderViewModel) -> None: + self.view_models.append(view_model) + + def hide(self) -> None: + self.hides += 1 + self.visible = False + + @property + def view(self) -> SongRenderViewModel: + assert self.view_models, "A view was expected to reach the window" + return self.view_models[-1] + + +class _DialogsRecorder: + def __init__(self) -> None: + self.paths: List[Dict[str, Any]] = [] + self.errors: List[Dict[str, Any]] = [] + + def show_message_with_path(self, title: str, message: str, path: Path) -> None: + self.paths.append({"title": title, "message": message, "path": path}) + + def show_error(self, exception: Exception, message: Optional[str] = None) -> None: + self.errors.append({"exception": exception, "message": message}) + + +class _SaveDialogRecorder: + """The OS save dialog as the coordinator asks it, answering with a stated path.""" + + def __init__(self) -> None: + self.answer: Optional[Path] = CHOSEN + self.requests: List[Dict[str, Any]] = [] + + def __call__(self, **kwargs: Any) -> Optional[Path]: + self.requests.append(kwargs) + return self.answer + + @property + def filters(self) -> List[FileFilter]: + assert self.requests, "A destination was expected to be asked for" + return list(self.requests[-1]["filters"]) + + +class RenderFixture: + """The coordinator over a real render logic, a recording service, and a recorded screen. + + The frame the report waits for is taken as passing when a test asks for it, so the hand-off + from the window to the dialog that reports an outcome is walked one step at a time. + """ + + def __init__( + self, + monkeypatch: pytest.MonkeyPatch, + *, + operation_active: bool = False, + ) -> None: + project_manager = ProjectManager() + project_manager.session.mark_loaded(PROJECT_NAME) + self.controller = ProjectController(project_manager) + self.session_manager = MagicMock() + self.session_manager.get_audio_path.return_value = AUDIO_DIRECTORY + self.service = FakeRenderService() + self.logic = SongRenderLogic( + self.controller, + MagicMock(config=Config()), + self.session_manager, + self.service, + language_manager=FakeLanguageManager(), # type: ignore[arg-type] + is_operation_active=lambda: operation_active, + ) + + self.window = _WindowRecorder() + self.dialogs = _DialogsRecorder() + self.save_dialog = _SaveDialogRecorder() + self.activity = 0 + self.pending: List[VoidCallback] = [] + + monkeypatch.setattr(render_module, "save_file_dialog", self.save_dialog) + monkeypatch.setattr( + render_module.FrameCallbackManager, + "set_frame_callback", + lambda callback, frame_count=1: self.pending.append(callback), + ) + + self.coordinator = SongRenderCoordinator( + self.logic, + window=self.window, # type: ignore[arg-type] + dialogs=self.dialogs, # type: ignore[arg-type] + language_manager=FakeLanguageManager(), # type: ignore[arg-type] + on_activity_changed=self._on_activity_changed, + ) + + def _on_activity_changed(self) -> None: + self.activity += 1 + + def open(self) -> None: + self.coordinator.open() + + def edit(self, settings: SongRenderSettings) -> None: + self.window.on_settings_changed(settings) + + def browse(self) -> None: + self.window.on_browse() + + def start(self) -> None: + self.window.on_render() + + def stop(self) -> None: + self.window.on_cancel() + + def close(self) -> None: + self.window.on_close() + + def advance_frame(self) -> None: + """Runs what was waiting for the frame the window left the screen in.""" + pending = self.pending + self.pending = [] + for callback in pending: + callback() + + +@pytest.fixture +def render(monkeypatch: pytest.MonkeyPatch) -> RenderFixture: + return RenderFixture(monkeypatch) + + +class TestOfferingTheRender: + def test_the_dialog_opens_over_the_render_being_set_up(self, render: RenderFixture) -> None: + render.open() + + assert render.window.visible + assert render.window.view.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav" + + def test_opening_claims_the_application(self, render: RenderFixture) -> None: + render.open() + + assert render.coordinator.is_active + assert render.activity == 1 + + def test_another_exclusive_operation_leaves_the_dialog_closed( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + render = RenderFixture(monkeypatch, operation_active=True) + + render.open() + + assert not render.window.visible + assert not render.window.view_models + assert not render.activity + + def test_closing_the_setup_hands_the_application_back(self, render: RenderFixture) -> None: + render.open() + + render.close() + + assert render.window.hides == 1 + assert not render.coordinator.is_active + assert render.activity == 2 + + +class TestTheEditsTheDialogReports: + def test_an_edit_comes_back_reconciled(self, render: RenderFixture) -> None: + render.open() + + render.edit(render.window.view.settings.with_format(AudioFormat.MP3)) + + assert render.window.view.spec.audio_format == AudioFormat.MP3 + assert render.window.view.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.mp3" + + def test_the_running_render_reaches_the_window(self, render: RenderFixture) -> None: + render.open() + render.start() + + render.service.emit(ServiceSuccess(value=CHOSEN)) + + assert render.window.view.progress == 1.0 + + +class TestAskingForTheDestination: + def test_the_file_is_asked_for_from_where_it_stands(self, render: RenderFixture) -> None: + render.open() + + render.browse() + + request = render.save_dialog.requests[-1] + assert request["initial_directory"] == AUDIO_DIRECTORY + assert request["default_filename"] == f"{PROJECT_NAME}.wav" + + def test_the_type_offered_is_the_container_standing(self, render: RenderFixture) -> None: + render.open() + render.edit(render.window.view.settings.with_format(AudioFormat.MP3)) + + render.browse() + + assert [file_filter.extensions for file_filter in render.save_dialog.filters] == [(".mp3",)] + + def test_a_chosen_file_becomes_the_one_the_render_writes(self, render: RenderFixture) -> None: + render.open() + + render.browse() + + assert render.window.view.destination == CHOSEN + render.session_manager.set_audio_path.assert_called_once_with(CHOSEN) + + def test_a_dismissed_dialog_leaves_the_file_alone(self, render: RenderFixture) -> None: + render.open() + render.save_dialog.answer = None + standing = render.window.view.destination + + render.browse() + + assert render.window.view.destination == standing + + +class TestDrivingTheRender: + def test_the_start_reaches_the_service(self, render: RenderFixture) -> None: + render.open() + + render.start() + + assert render.service.request.destination == AUDIO_DIRECTORY / f"{PROJECT_NAME}.wav" + + def test_the_stop_reaches_the_service(self, render: RenderFixture) -> None: + render.open() + render.start() + + render.stop() + + assert render.service.cancels == 1 + + def test_exit_winds_a_running_render_down(self, render: RenderFixture) -> None: + render.open() + render.start() + + render.coordinator.cleanup() + + assert render.service.shutdowns == 1 + + +class TestReportingTheOutcome: + def test_a_finished_render_reports_the_file_it_wrote(self, render: RenderFixture) -> None: + render.open() + render.start() + + render.service.emit(ServiceSuccess(value=CHOSEN)) + render.advance_frame() + + assert render.dialogs.paths == [ + { + "title": "settings.render.title.rendered", + "message": "settings.render.message.rendered", + "path": CHOSEN, + } + ] + + def test_the_report_waits_for_the_screen_the_window_left(self, render: RenderFixture) -> None: + render.open() + render.start() + + render.service.emit(ServiceSuccess(value=CHOSEN)) + + assert render.window.hides == 1 + assert not render.dialogs.paths + + def test_a_failed_render_reports_what_went_wrong(self, render: RenderFixture) -> None: + render.open() + render.start() + failure = OSError("no room on the device") + + render.service.emit(ServiceError(exception=failure)) + render.advance_frame() + + assert render.dialogs.errors == [ + { + "exception": failure, + "message": "settings.render.message.render_failed", + } + ] + + def test_a_stopped_render_closes_without_a_report(self, render: RenderFixture) -> None: + render.open() + render.start() + render.stop() + + render.service.emit(ServiceCancelled()) + render.advance_frame() + + assert render.window.hides == 1 + assert not render.dialogs.paths + assert not render.dialogs.errors + + @pytest.mark.parametrize( + "outcome", + [ + ServiceSuccess(value=CHOSEN), + ServiceError(exception=OSError("no room on the device")), + ServiceCancelled(), + ], + ids=["completed", "failed", "cancelled"], + ) + def test_every_outcome_hands_the_application_back( + self, + render: RenderFixture, + outcome: Any, + ) -> None: + render.open() + render.start() + + render.service.emit(outcome) + + assert not render.coordinator.is_active + assert not render.window.visible diff --git a/tests/unit/sampletones_application/logic/render/test_logic.py b/tests/unit/sampletones_application/logic/render/test_logic.py index b656454c1..df2ee2414 100644 --- a/tests/unit/sampletones_application/logic/render/test_logic.py +++ b/tests/unit/sampletones_application/logic/render/test_logic.py @@ -1,6 +1,5 @@ -from dataclasses import dataclass from pathlib import Path -from typing import Callable, Final, List, Optional +from typing import Final, List from unittest.mock import MagicMock import pytest @@ -12,7 +11,7 @@ RowSynthesizer, SongLength, ) -from sampletones_application.services.render.result import RenderResult, RenderStage +from sampletones_application.services.render.result import RenderStage from sampletones_application.services.result import ( ServiceCancelled, ServiceError, @@ -23,86 +22,16 @@ RenderPhase, SongRenderViewModel, ) -from sampletones_core.audio.writers import AudioFormat, AudioOutputSpec +from sampletones_core.audio.writers import AudioFormat from sampletones_core.configs import Config from tests.suite.language import FakeLanguageManager +from tests.suite.render import FakeRenderService AUDIO_DIRECTORY: Final[Path] = Path("/home/user/audio") PROJECT_NAME: Final[str] = "chiptune" LOW_RATE: Final[int] = 8000 -@dataclass(frozen=True) -class RenderRequest: - synthesizer: RowSynthesizer - destination: Path - spec: AudioOutputSpec - normalize: bool - total_samples: int - - -class FakeRenderService: - """The render service as the logic drives it, holding what it was asked to render. - - Results are delivered through the handler the logic subscribes, so a test walks a render the - way the worker reports one. - """ - - def __init__(self, *, accepts: bool = True) -> None: - self.accepts = accepts - self.requests: List[RenderRequest] = [] - self.cancels: int = 0 - self.shutdowns: int = 0 - self.running: bool = False - self._handler: Optional[Callable[[RenderResult], None]] = None - - def subscribe(self, handler: Callable[[RenderResult], None]) -> None: - self._handler = handler - - def start( - self, - *, - synthesizer: RowSynthesizer, - destination: Path, - spec: AudioOutputSpec, - normalize: bool, - total_samples: int, - ) -> bool: - if not self.accepts: - return False - - self.requests.append( - RenderRequest( - synthesizer=synthesizer, - destination=destination, - spec=spec, - normalize=normalize, - total_samples=total_samples, - ) - ) - self.running = True - return True - - def cancel(self) -> None: - self.cancels += 1 - - def is_running(self) -> bool: - return self.running - - def shutdown(self) -> None: - self.shutdowns += 1 - - def emit(self, result: RenderResult) -> None: - assert self._handler is not None, "The logic subscribes to the service it is given" - self.running = not isinstance(result, (ServiceSuccess, ServiceError, ServiceCancelled)) - self._handler(result) - - @property - def request(self) -> RenderRequest: - assert self.requests, "A render was expected to start" - return self.requests[-1] - - class RenderFixture: """A render logic wired to a real project and a service that records what it is asked for.""" diff --git a/tests/unit/sampletones_application/test_busy_lock.py b/tests/unit/sampletones_application/test_busy_lock.py index d2835987a..5449f8eef 100644 --- a/tests/unit/sampletones_application/test_busy_lock.py +++ b/tests/unit/sampletones_application/test_busy_lock.py @@ -3,7 +3,12 @@ from sampletones_application.application import Application -def _application(*, converter_running: bool = False, library_generating: bool = False) -> Application: +def _application( + *, + converter_running: bool = False, + library_generating: bool = False, + rendering: bool = False, +) -> Application: """An application with only the attributes the busy methods touch, bypassing the full composition root constructor.""" application = Application.__new__(Application) @@ -12,12 +17,15 @@ def _application(*, converter_running: bool = False, library_generating: bool = application._instructions_tab = MagicMock() application._instructions_tab.is_library_generating.return_value = library_generating application._reconstructions_tab = MagicMock() + application._render_coordinator = MagicMock() + application._render_coordinator.is_active = rendering + application._update_menu = MagicMock() return application class TestBusySourceOfTruth: - """``_is_operation_active`` is the single busy authority: a conversion or a library - generation each make it true, and only an idle pair makes it false.""" + """``_is_operation_active`` is the single busy authority: a conversion, a library generation or + a render each make it true, and only an idle set makes it false.""" def test_busy_while_converter_runs(self) -> None: assert _application(converter_running=True)._is_operation_active() is True @@ -25,15 +33,29 @@ def test_busy_while_converter_runs(self) -> None: def test_busy_while_library_generates(self) -> None: assert _application(library_generating=True)._is_operation_active() is True - def test_idle_when_neither_runs(self) -> None: + def test_busy_while_song_renders(self) -> None: + assert _application(rendering=True)._is_operation_active() is True + + def test_idle_when_none_runs(self) -> None: assert _application()._is_operation_active() is False class TestBusyRefreshPropagation: - """A busy-state change nudges both tabs to re-evaluate their action buttons; each panel reads the - live busy authority for itself, so no value is pushed.""" + """A busy-state change nudges both tabs to re-evaluate their action buttons and the menu to + re-read what may start another such operation; each reads the live busy authority for itself, + so no value is pushed.""" def test_refresh_nudges_both_tabs(self) -> None: application = _application() application._refresh_busy_state() application._instructions_tab.refresh_generate_button.assert_called_once_with() + + def test_refresh_reaches_the_menu(self) -> None: + application = _application() + application._refresh_busy_state() + application._update_menu.assert_called_once_with() + + def test_a_render_edge_refreshes_the_converter_view(self) -> None: + application = _application() + application._on_render_activity_changed() + application._main_tab.refresh_converter_view.assert_called_once_with() diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index 4dd228e46..1f13cf653 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -89,6 +89,7 @@ def _state( reconstruction_in_project=False, reconstruction_file_backed=False, reconstruction_audio_recorded=False, + operation_active=False, can_undo=False, can_redo=False, play_label="Play", 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 6341a05c4..0b0dd2e31 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_menu.py +++ b/tests/unit/sampletones_application/view_model/shared/test_menu.py @@ -67,6 +67,7 @@ def test_enablement_follows_project_and_history_state( reconstruction_in_project=False, reconstruction_file_backed=False, reconstruction_audio_recorded=False, + operation_active=False, can_undo=case.can_undo, can_redo=case.can_redo, play_label="Play", @@ -103,6 +104,7 @@ def test_save_flag_is_carried_verbatim( reconstruction_in_project=False, reconstruction_file_backed=False, reconstruction_audio_recorded=False, + operation_active=False, can_undo=False, can_redo=False, play_label="Play", From dbac510c2d2a198ef802efb3d28a25b58a7a037a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 10:38:36 +0200 Subject: [PATCH 064/152] Documented: song rendering to an audio file --- CHANGELOG.md | 2 ++ docs/development/dependencies.md | 21 +++++++++++++++ docs/development/playback.md | 44 ++++++++++++++++++++++++++++++-- docs/guide/interface.md | 7 ++++- docs/guide/sequencer.md | 26 +++++++++++++++++++ docs/index.md | 4 +-- 6 files changed, 99 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 51593f8dd..c29505921 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ * Theme selector * Keybinding settings * Bumped the reconstruction data-version to `2.1`. +* Improved Sequencer module playback. +* Added song export to WAV/MP3. ## v0.3.0 [2026-07-31] diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index a64068f6e..7d553cb03 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -24,6 +24,27 @@ Playback goes through PortAudio, reached with the `pyaudio` package. PyPI carrie Compiling on macOS also depends on the interpreter's architecture. The python.org installer ships a universal2 build, which compiles extensions for both Apple Silicon and Intel, while Homebrew's `libportaudio` carries the machine's own architecture. Pinning `ARCHFLAGS` to `uname -m` settles it on the native one: `make setup` sets it directly, and the CI workflows take it from `scripts/macos/build/build_env.sh`, which reports it as a `KEY=VALUE` line alongside the PortAudio prefix for a Homebrew installed outside its usual place. +## Audio rendering + +Audio files are written with libsndfile, reached with the `soundfile` package. Its wheels carry a +prebuilt libsndfile 1.2.2 for every supported platform, so the encoders come with the package and +need nothing installed alongside them. + +Which formats an installation writes is asked of the library at runtime, because libsndfile is built +with a codec set that varies by platform and packaging — the MP3 encoder in particular arrived in +1.2.0 and is present where it was compiled in. The chooser offers the formats the library reports, +so what a user is shown describes the machine it is running on. + +| Format | Sample rates | Quality | +| --- | --- | --- | +| WAV | 8000, 16000, 22050, 44100, 48000, 96000, 192000 Hz | 8, 16, 24 or 32-bit PCM, or 32-bit float | +| MP3 | 8000, 16000, 22050, 44100, 48000 Hz | a bitrate from the ladder its MPEG version defines | + +The bitrates on offer narrow with the sample rate: up to 320 kbps at 44100 and 48000 Hz, 160 kbps at +16000 and 22050 Hz, and 64 kbps at 8000 Hz. libsndfile takes MP3 quality as a compression level +between 0 and 1 and turns it into a rung on that ladder, so a bitrate is reached through the level +its rate maps it to, measured per rate and held in `sampletones_core/audio/writers/bitrate.py`. + ## File dialogs Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser`), reached over D-Bus with the pure-Python `jeepney` package on Linux. The portal lists every offered file type in its selector and reports back the one the user picked, which is what lets a save settle its format from the type chosen there. Where no portal answers, `kdialog` and `zenity` take over, and Tk last. diff --git a/docs/development/playback.md b/docs/development/playback.md index 96892f3fd..ebfb62001 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -25,14 +25,17 @@ a control over what is heard. The contracts here bind every tab and every player same behaviour. 5. **Listening choices stay out of the document.** What the user chooses to hear is session state; what the project holds is the whole song. Saving, export, rendering, and history read the - document, so each of them works on the full song whatever the user is listening to. + document, so each of them works on the full song whatever the user is listening to. A render + reads the document as it stood when it was asked for: every channel sounding, at unity gain, + played through once. 6. **Live state is pulled while sound is produced.** A player reads the settings that shape its sound as it renders, so a change is heard as the render-ahead buffer drains. This is what lets a listening control take effect inside the sound already playing. 7. **A row's duration belongs to the song, not to the player.** How long a row lasts follows from the project's tempo and metre together with the row's place in the pattern, so it is a function of position: the same row lasts the same time however playback reached it, and a module exported - from the song can state the same figures. + from the song can state the same figures. The integer tick counts the groove places *are* the + tempo, so a render realises them exactly at every rate it offers. ## Two kinds of sound @@ -159,6 +162,37 @@ 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. +## Rendering the song to a file + +A render writes the whole song to an audio file through the kernel that plays it. `RowSynthesizer` +serves both: the player drives it to feed the device, the render drives it to feed a file writer. +The synthesis is therefore written once, and the file and the playback agree on what the song +sounds like by construction. + +Two things differ between them, and each is stated by whoever asks for the audio. The **document** +is a seam: a kernel reads its project through `ProjectSource`, which the live controller satisfies +for playback and a frozen `ProjectSnapshot` satisfies for a render — so the player follows every +edit as the buffer drains (principle 6), while a render describes one state of the document however +the project moves on. The **rate** is the consumer's: the device for playback, the chosen output +format for a render. The kernel rebuilds its generators and its tick clock when either moves, so a +file is written at the rate its engine ran at. + +The song's exact length follows from the timing model before a sample is rendered: the order's +length in rows gives the ticks, the tick clock gives the samples those ticks span. That figure is +what the progress bar counts against and what a finished file measures. + +Rendering is an exclusive operation (architecture principle 10). It occupies the application from +the moment its dialog opens until that dialog closes, and it joins the same busy authority as +conversion and library generation, so each of the three holds the others off and every surface +offering one reads a single answer. + +The write itself takes one pass, or two where the user asks for a normalised peak: the first pass +spills raw samples and discovers the peak, the second reads them back and encodes at the scale that +peak sets. Each pass names itself, so the bar crosses one axis — samples — twice, holding a single +unit across both. A cancel is honoured between rows and between encoded blocks, and a render that +is stopped or fails clears the destination and the spill, so a result names a path where a finished +file stands. + ## Teardown The device is torn down once every source holding a stream has released it. A source that streams to @@ -196,6 +230,12 @@ terminating would reclaim. | How long each row of a pattern lasts | `Groove` (`sampletones_core/timing/`), indexed by row while rendering | | How many samples one of that row's ticks spans | `TickClock` (`sampletones_core/timing/`), followed by `EngineRates` | | The song's render-ahead buffer | `services/song_player/` | +| The document a kernel reads, live or captured | `ProjectSource` / `ProjectSnapshot` (`logic/shared/project_source.py`) | +| The ticks the order lasts and the samples they span | `SongLength` (`logic/sequencer/playback/synthesizer/length.py`) | +| Rendering the song to a file, its passes and its progress | `SongRenderService` (`services/render/`) | +| Where a rendered file's samples go, normalised or direct | `RenderSink` (`services/render/sink.py`) | +| The choices a render is made under, and the phase it is in | `SongRenderLogic` (`logic/render/`) | +| The formats a file may be written in, and what each accepts | `sampletones_core/audio/writers/` | The sequencer song is an ordinary intentional source alongside the reconstruction and instruction players: it implements the same protocol and is arbitrated by the same rules. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index af07b27e7..3d88b4e45 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -78,7 +78,12 @@ 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 +Two items write audio you can play anywhere: **Reconstruction ▸ Export to WAV...** +for the reconstruction on show, and **File ▸ Render song...** (`Ctrl+Shift+E`) for +the sequencer's whole song, as a WAV or an MP3 — +[rendering to audio](sequencer.md#rendering-to-audio) covers the options it offers. + +Two other items are easy to miss. **View ▸ Show advanced settings** reveals the extra options on the **Main** tab. **Playback ▸ Audio settings...** picks the playback device, sample rate, and buffer size; these change what you hear, while the **Sample rate** and **NES frequency** on the **Main** tab change how audio is diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index acefed069..f9b974dfb 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -134,3 +134,29 @@ When the song is ready, **Export as FamiTracker module** (or **File ▸ Export FamiTracker module...**) writes the `.ftm`. See [FamiTracker export](../formats/famitracker.md) for what the module contains and the limits it respects. + +## Rendering to audio + +A module is for a tracker. To get a file anyone can play, use **File ▸ Render +song...** (`Ctrl+Shift+E`), which writes the whole song as audio. + +The dialog holds the choices: + +| Setting | What it does | +|---------|--------------| +| **Format** | **WAV** for the full-quality file, **MP3** for a smaller one | +| **Sample rate** | How many samples a second the file holds; 44100 Hz is the usual choice | +| **Bit depth** (WAV) | How finely each sample is stored. 16-bit PCM is the usual choice; 8-bit is there for the crunch the NES itself has | +| **Bitrate** (MP3) | How much the file spends per second — higher sounds better and takes more room. What is on offer depends on the sample rate, so the list follows when you change it | +| **Normalize peak** | Lifts the whole song so its loudest moment reaches full scale, keeping the balance between channels as it was | +| **File** | Where it is written. **Browse...** opens the save dialog, and the folder you pick is offered again next time | + +**Length** tells you how long the file will be before you start. **Render** begins, +and a bar reports how far it has got; **Cancel** stops it and leaves the file +unwritten. When it finishes, _SampleToNES_ shows the file it wrote — click the path +to open its folder. + +A render takes the song itself, once through, with every channel sounding: muting +and **Loop song** are for listening and stay out of the file. It is one of the long +jobs that run alone, so the item is unavailable while a conversion or a library +generation is going, and those wait for a render in the same way. diff --git a/docs/index.md b/docs/index.md index 92a22ec41..f53832907 100644 --- a/docs/index.md +++ b/docs/index.md @@ -19,7 +19,7 @@ The [**guide**](guide/) walks through the application from installation onward. - [Installation](guide/installation.md) — the standalone build, running from source, and GPU acceleration. - [Getting started](guide/getting-started.md) — your first reconstruction and your first song. - [The interface](guide/interface.md) — the Main, Reconstructions, and Instructions tabs, and the menus. -- [The sequencer](guide/sequencer.md) — the tracker: arranging samples into a song and exporting a module. +- [The sequencer](guide/sequencer.md) — the tracker: arranging samples into a song, exporting a module, and rendering it to audio. - [Command line](guide/command-line.md) — running without the graphical interface. - [Where your files live](guide/files.md) — the folders and file types _SampleToNES_ uses. - [Configuration](guide/configuration.md) — the settings you can change, and where. @@ -56,7 +56,7 @@ The [**development**](development/) section is for contributors. - [Architecture](development/architecture.md) — the application's layers and the contracts between them. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. -- [Playback](development/playback.md) — the audio transport shared by every view. +- [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file. - [Configuration](development/config-organization.md) — how the YAML configuration package is laid out. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. - [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on. From bd6ef69c11676875ccab87ee30e1573594deac4f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 13:25:29 +0200 Subject: [PATCH 065/152] Added: destination path text --- docs/guide/interface.md | 6 +- docs/guide/sequencer.md | 2 +- .../ui/elements/path.py | 27 +++++ .../ui/panels/dialogs/render.py | 10 +- .../ui/panels/main/converter.py | 9 +- src/sampletones_config/lang/en.yaml | 1 + .../layout/settings/render.yaml | 2 +- .../ui/elements/test_path.py | 103 ++++++++++++++++++ 8 files changed, 147 insertions(+), 13 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/test_path.py diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 3d88b4e45..275784de9 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -19,8 +19,10 @@ left, set up how the reconstruction is done in the centre, and click **Convert sample** (or **Convert directory** for a folder). The [instruction library](../concepts/instruction-library.md) for your settings is built automatically the first time it is needed, so you can convert straight away. -When a single file finishes, **Load** opens the result on the **Reconstructions** -tab; **Cancel** stops a run, and only one runs at a time. +While it runs, the panel names the file going in and where the result is going, and +clicking either path shows it in your file manager. When a single file finishes, +**Load** opens the result on the **Reconstructions** tab; **Cancel** stops a run, and +only one runs at a time. A few settings are worth knowing before you convert. Under **Reconstructor settings**, the **Generators** toggles choose which channels take part — at least diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index f9b974dfb..49361de51 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -149,7 +149,7 @@ The dialog holds the choices: | **Bit depth** (WAV) | How finely each sample is stored. 16-bit PCM is the usual choice; 8-bit is there for the crunch the NES itself has | | **Bitrate** (MP3) | How much the file spends per second — higher sounds better and takes more room. What is on offer depends on the sample rate, so the list follows when you change it | | **Normalize peak** | Lifts the whole song so its loudest moment reaches full scale, keeping the balance between channels as it was | -| **File** | Where it is written. **Browse...** opens the save dialog, and the folder you pick is offered again next time | +| **File** | Where it is written. **Browse...** opens the save dialog, clicking the path shows where the file is going in your file manager, and the folder you pick is offered again next time | **Length** tells you how long the file will be before you start. **Render** begins, and a bar reports how far it has got; **Cancel** stops it and leaves the file diff --git a/src/sampletones_application/ui/elements/path.py b/src/sampletones_application/ui/elements/path.py index 0d29bbf47..0c394378d 100644 --- a/src/sampletones_application/ui/elements/path.py +++ b/src/sampletones_application/ui/elements/path.py @@ -164,3 +164,30 @@ def get_path(self) -> Path: def destroy(self) -> None: dpg_delete_item(self.handler_tag) dpg_delete_item(self.tag) + + +class GUIDestinationPathText(GUIPathText): + """A path an operation is going to write to, which reveals the nearest place that stands. + + A destination names what the operation will leave behind, so it points into the filesystem + before anything is there. A click therefore reaches the destination itself once it is written, + and the closest directory on its way while it is still being described. + """ + + def _on_clicked(self) -> None: + standing = self._nearest_standing() + if standing is not None: + open_path_in_explorer(standing) + + def _nearest_standing(self) -> Optional[Path]: + if not self.path.name: + return None + + if self.path.exists(): + return self.path + + for directory in self.path.parents: + if directory.is_dir(): + return directory + + return None diff --git a/src/sampletones_application/ui/panels/dialogs/render.py b/src/sampletones_application/ui/panels/dialogs/render.py index fad454d3f..459494d26 100644 --- a/src/sampletones_application/ui/panels/dialogs/render.py +++ b/src/sampletones_application/ui/panels/dialogs/render.py @@ -31,7 +31,7 @@ from sampletones_application.ui.elements.field import labeled_field from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry -from sampletones_application.ui.elements.path import GUIPathText +from sampletones_application.ui.elements.path import GUIDestinationPathText from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.utils.gui.align import table_wrapper from sampletones_application.utils.gui.dialog_navigation import FocusStop @@ -77,7 +77,7 @@ def __init__( self._path_colors = path_colors self._status_bar = status_bar self._view_model: Optional[SongRenderViewModel] = None - self._destination_text: Optional[GUIPathText] = None + self._destination_text: Optional[GUIDestinationPathText] = None self.on_settings_changed: Optional[SettingsCallback] = None self.on_browse: Optional[VoidCallback] = None @@ -92,7 +92,7 @@ def __init__( self._fmt_sample_rate = language_manager["settings.render.template.sample_rate"] self._fmt_bitrate = language_manager["settings.render.template.bitrate"] - self._msg_path = language_manager["global.status.message.path"] + self._msg_destination = language_manager["global.status.message.destination"] self._format_labels: Dict[AudioFormat, str] = { AudioFormat.WAVE: language_manager["settings.render.label.format_wave"], AudioFormat.MP3: language_manager["settings.render.label.format_mp3"], @@ -257,13 +257,13 @@ def _create_destination(self) -> None: parent=TAG_SETTINGS_RENDER_GROUP_DESTINATION, callback=self._request_destination, ) - self._destination_text = GUIPathText( + self._destination_text = GUIDestinationPathText( tag=TAG_SETTINGS_RENDER_PATH_DESTINATION, path=self._require_view_model().destination, parent=TAG_SETTINGS_RENDER_GROUP_DESTINATION, color=self._path_colors.default, hover_color=self._path_colors.hover, - status_message=self._msg_path, + status_message=self._msg_destination, font=Font.REGULAR_SMALL, status_bar=self._status_bar, ) diff --git a/src/sampletones_application/ui/panels/main/converter.py b/src/sampletones_application/ui/panels/main/converter.py index 93853a45c..a5de9f920 100644 --- a/src/sampletones_application/ui/panels/main/converter.py +++ b/src/sampletones_application/ui/panels/main/converter.py @@ -28,7 +28,7 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel -from sampletones_application.ui.elements.path import GUIPathText +from sampletones_application.ui.elements.path import GUIDestinationPathText, GUIPathText from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.theme import Theme @@ -57,7 +57,7 @@ def __init__( ) -> None: self._language_manager = language_manager self.input_path_text: Optional[GUIPathText] = None - self.output_path_text: Optional[GUIPathText] = None + self.output_path_text: Optional[GUIDestinationPathText] = None self._status_bar = status_bar self._action_button: Optional[GUIButton] = None self._theme_convert: Optional[Theme] = None @@ -69,6 +69,7 @@ def __init__( self._layout = layout self._path_colors = path_colors self._msg_path = language_manager["global.status.message.path"] + self._msg_destination = language_manager["global.status.message.destination"] self._msg_status_convert = language_manager["main.converter.message.status_convert"] self._status_action_message = self._msg_status_convert @@ -193,14 +194,14 @@ def _create_summary(self) -> None: font=Font.REGULAR_SMALL, status_bar=self._status_bar, ) - self.output_path_text = GUIPathText( + self.output_path_text = GUIDestinationPathText( path=None, prefix=self._language_manager["main.converter.message.status_output_label"], tag=TAG_MAIN_CONVERTER_TEXT_OUTPUT_PATH, parent=TAG_MAIN_CONVERTER_GROUP_SUMMARY, color=self._path_colors.default, hover_color=self._path_colors.hover, - status_message=self._msg_path, + status_message=self._msg_destination, font=Font.REGULAR_SMALL, status_bar=self._status_bar, ) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 5909684e1..88cd8bc99 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -217,6 +217,7 @@ global.menu.label.tab_sequencer: "Sequencer" # Global — Status bar messages # ============================================================================= global.status.message.path: "Click to open path in file explorer." +global.status.message.destination: "Click to show the destination in file explorer." global.status.message.node_reconstruction_no_autoplay: "Double-click to open reconstruction. Right-click to open context menu." global.status.message.node_reconstruction: "Click to play reconstruction. Double-click to open reconstruction. Right-click to open context menu." global.status.message.node_library: "Double-click to open instructions library. Right-click to open context menu." diff --git a/src/sampletones_config/layout/settings/render.yaml b/src/sampletones_config/layout/settings/render.yaml index 2f91551f5..3703a5caf 100644 --- a/src/sampletones_config/layout/settings/render.yaml +++ b/src/sampletones_config/layout/settings/render.yaml @@ -1,3 +1,3 @@ window: - width: 480 + width: 540 height: 0 diff --git a/tests/unit/sampletones_application/ui/elements/test_path.py b/tests/unit/sampletones_application/ui/elements/test_path.py new file mode 100644 index 000000000..0d4c5d4ef --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/test_path.py @@ -0,0 +1,103 @@ +from pathlib import Path +from typing import List + +import pytest + +from sampletones_application.ui.elements import path as path_module +from sampletones_application.ui.elements.path import GUIDestinationPathText, GUIPathText + +FILENAME = "chiptune.wav" + + +def _path_text(path: Path) -> GUIPathText: + """A path text carrying only the path a click reads, bypassing the DearPyGui-dependent + constructor.""" + instance = GUIPathText.__new__(GUIPathText) + instance.path = path + return instance + + +def _destination_text(path: Path) -> GUIDestinationPathText: + instance = GUIDestinationPathText.__new__(GUIDestinationPathText) + instance.path = path + return instance + + +@pytest.fixture +def opened(monkeypatch: pytest.MonkeyPatch) -> List[Path]: + revealed: List[Path] = [] + monkeypatch.setattr(path_module, "open_path_in_explorer", revealed.append) + return revealed + + +class TestAPathThatStands: + """A path text shows what it points at, so a click reaches the file itself.""" + + def test_an_existing_file_is_revealed(self, tmp_path: Path, opened: List[Path]) -> None: + filepath = tmp_path / FILENAME + filepath.touch() + + _path_text(filepath)._on_clicked() + + assert opened == [filepath] + + def test_a_file_yet_to_be_written_reveals_nothing( + self, + tmp_path: Path, + opened: List[Path], + ) -> None: + _path_text(tmp_path / FILENAME)._on_clicked() + + assert not opened + + +class TestADestination: + """A destination names what an operation will leave behind, so a click reaches the nearest place + that stands however far the operation has got.""" + + def test_the_directory_a_file_is_written_into_is_revealed( + self, + tmp_path: Path, + opened: List[Path], + ) -> None: + _destination_text(tmp_path / FILENAME)._on_clicked() + + assert opened == [tmp_path] + + def test_a_written_file_is_revealed_itself( + self, + tmp_path: Path, + opened: List[Path], + ) -> None: + filepath = tmp_path / FILENAME + filepath.touch() + + _destination_text(filepath)._on_clicked() + + assert opened == [filepath] + + def test_a_written_directory_is_revealed_itself( + self, + tmp_path: Path, + opened: List[Path], + ) -> None: + directory = tmp_path / "renders" + directory.mkdir() + + _destination_text(directory)._on_clicked() + + assert opened == [directory] + + def test_a_directory_yet_to_be_created_falls_back_to_the_one_holding_it( + self, + tmp_path: Path, + opened: List[Path], + ) -> None: + _destination_text(tmp_path / "renders" / "session" / FILENAME)._on_clicked() + + assert opened == [tmp_path] + + def test_an_empty_path_reveals_nothing(self, opened: List[Path]) -> None: + _destination_text(Path())._on_clicked() + + assert not opened From 05af9fc79e87389f75fd8edcfd47e59ded0fcf0d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 14:00:49 +0200 Subject: [PATCH 066/152] Fixed: song synthesis reading the output rate before there is a consumer --- docs/development/playback.md | 5 ++ .../playback/synthesizer/synthesizer.py | 40 ++++++++++--- .../sequencer/playback/test_synthesizer.py | 2 +- .../sequencer/playback/test_tick_clock.py | 59 ++++++++++++++++++- .../sampletones_application/test_startup.py | 29 ++++++++- 5 files changed, 124 insertions(+), 11 deletions(-) diff --git a/docs/development/playback.md b/docs/development/playback.md index ebfb62001..4f33ace3c 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -177,6 +177,11 @@ the project moves on. The **rate** is the consumer's: the device for playback, t format for a render. The kernel rebuilds its generators and its tick clock when either moves, so a file is written at the rate its engine ran at. +A rate is therefore asked for once there is audio to take it, which is the first row a kernel +renders: a device has been chosen by the time playback starts, and a format by the time a render +does. A session on a machine offering no output device opens on that rule, and everything that +writes rather than sounds — editing, exporting a module, rendering to a file — works on it. + The song's exact length follows from the timing model before a sample is rendered: the order's length in rows gives the ticks, the tick clock gives the samples those ticks span. That figure is what the progress bar counts against and what a finished file measures. diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py index 0ded782ed..3035821ef 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -46,9 +46,11 @@ class RowSynthesizer: audio runs at: the output device for live playback, the chosen format for a file. Reading it per row keeps the two in step, so a rendered second is a second wherever the audio goes. - Generators are held in a :class:`ChannelBank` built from ``config`` at the rates in force, - carrying timer state across rows for phase continuity within a sustained note. Triggering a - new note calls ``generator.reset()`` for a clean phase start. + Generators are held in a :class:`ChannelBank` built from ``config`` at the rates the first row + is rendered at, so the rate is asked for once there is audio to take it — a device is chosen by + the time playback starts, and a format by the time a render does. They carry timer state across + rows for phase continuity within a sustained note, and triggering a new note calls + ``generator.reset()`` for a clean phase start. ``active_channels`` reports which channels sound and is consulted once per channel per row, so muting or unmuting during playback is heard as the render-ahead buffer drains. A @@ -65,12 +67,13 @@ def __init__( sample_rate: Callable[[], int], ) -> None: self._project_source = project_source + self._config = config self._active_channels = active_channels self._sample_rate = sample_rate self._position = SongPosition() self._timing: SongTiming = SongTiming.from_project(project_source.project) self._groove: Groove = self._timing.groove() - self._channels = ChannelBank(config, self._current_rates()) + self._channels: Optional[ChannelBank] = None self._elapsed_ticks: int = 0 @property @@ -92,17 +95,18 @@ def set_position(self, order_position: int, row_index: int) -> None: def reset(self) -> None: self._elapsed_ticks = 0 - self._channels.reset() + if self._channels is not None: + self._channels.reset() def render_row(self) -> Tuple[np.ndarray, SongPosition]: project = self._project_source.project song = project.song self._position.wrap_overflow(song.rows_per_pattern) - self._channels.follow(self._current_rates()) + channels = self._bank() self._ensure_groove(project) frames = RowFrames.from_clock( - self._channels.clock, + channels.clock, elapsed_ticks=self._elapsed_ticks, ticks=self._groove.ticks[self._position.row_index], ) @@ -116,6 +120,7 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: project, song, frames, + channels, ) ) @@ -125,6 +130,22 @@ def render_row(self) -> Tuple[np.ndarray, SongPosition]: return mixed, position_before + def _bank(self) -> ChannelBank: + """The channels the row about to be rendered sounds through, at the rates in force. + + Building them here is what lets a session start where nothing yet takes the audio: the rate + belongs to whoever consumes it, so it is asked for at the moment there is a consumer to + answer. Every later row follows the pair, so a device or a format changing underneath is + heard from the next row on. + """ + rates = self._current_rates() + if self._channels is None: + self._channels = ChannelBank(self._config, rates) + else: + self._channels.follow(rates) + + return self._channels + def _current_rates(self) -> EngineRates: return EngineRates.from_project( self._project_source.project, @@ -151,6 +172,7 @@ def _mix_channels( project: Project, song: Song, frames: RowFrames, + channels: ChannelBank, ) -> np.ndarray: mixed = silence(frames.total) for generator_name in GeneratorName.items(): @@ -159,6 +181,7 @@ def _mix_channels( project, song, frames, + channels, ) mixed += channel_audio @@ -170,8 +193,9 @@ def _render_channel( project: Project, song: Song, frames: RowFrames, + channels: ChannelBank, ) -> np.ndarray: - state = self._channels.state(generator_name) + state = channels.state(generator_name) row = self._resolve_row(generator_name, song) if row is not None: 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 71a2e6a79..cba1f8084 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -837,10 +837,10 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non sample = add_sample(controller, recon) place_row(controller, generator=GeneratorName.PULSE1, row_index=0, sample_id=sample.id) synthesizer = make_synthesizer(controller, Config(), sample_rate=SAMPLE_RATE) - pulse_state = synthesizer._channels.state(GeneratorName.PULSE1) controller.set_nes_frequency(60) synthesizer.render_row() + pulse_state = synthesizer._channels.state(GeneratorName.PULSE1) assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 60) controller.set_nes_frequency(30) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py index c5c2cdf3c..7d18bc324 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_tick_clock.py @@ -1,5 +1,5 @@ from fractions import Fraction -from typing import Final, Tuple +from typing import Final, Optional, Tuple import numpy as np import pytest @@ -161,6 +161,63 @@ def test_a_rate_change_is_picked_up_on_the_next_row(self) -> None: assert difference < Fraction(1, UNEVEN_SAMPLE_RATE) +class _LateRate: + """The rate a machine with no output device reports: none, until a device is chosen.""" + + def __init__(self) -> None: + self.rate: Optional[int] = None + self.reads: int = 0 + + def __call__(self) -> int: + self.reads += 1 + if self.rate is None: + raise ValueError("No audio device selected") + + return self.rate + + +class TestTheRateIsAskedForWhenAudioIsTaken(BaseTestSuite): + """The rate belongs to whoever takes the audio, so it is asked for once there is audio to take. + + That is what lets a session come up on a machine where nothing can play it: the song is edited, + exported and rendered to a file all the same, and the first row sounds at the rate the consumer + that reached it reports. + """ + + def test_a_synthesizer_stands_where_no_rate_can_be_stated(self) -> None: + rate = _LateRate() + + synthesizer = RowSynthesizer( + make_controller(), + Config(), + active_channels=all_channels, + sample_rate=rate, + ) + synthesizer.set_position(0, 0) + synthesizer.reset() + + assert rate.reads == 0 + + def test_the_first_row_renders_at_the_rate_that_answers(self) -> None: + controller = make_controller() + rate = _LateRate() + synthesizer = RowSynthesizer( + controller, + Config(), + active_channels=all_channels, + sample_rate=rate, + ) + + rate.rate = UNEVEN_SAMPLE_RATE + rendered = len(synthesizer.render_row()[0]) + + clock = TickClock.from_parameters( + sample_rate=UNEVEN_SAMPLE_RATE, + nes_frequency=controller.project.settings.nes_frequency, + ) + assert rendered == clock.samples_at(_expected_ticks(controller)[0]) + + class TestChannelsFillTheRow(BaseTestSuite): """Every channel writes into the same tick boundaries, so a mix never leaves a gap.""" diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index b6cc4525b..ad01f3133 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -1,4 +1,4 @@ -from contextlib import ExitStack +from contextlib import ExitStack, contextmanager from pathlib import Path from typing import Any, Callable, Dict, Final, Generator, List from unittest.mock import PropertyMock, patch @@ -67,6 +67,19 @@ def _display_patches() -> List[Any]: return display_patches +@contextmanager +def _no_audio_devices() -> Generator[None, None, None]: + """The machine a headless run comes up on: the backend reports no output device at all.""" + with ( + patch("pyaudio.PyAudio.get_device_count", return_value=0), + patch( + "pyaudio.PyAudio.get_default_output_device_info", + side_effect=OSError, + ), + ): + yield + + def _profile(directory: Path) -> UserProfile: """Starts the application on a profile of its own, in the state a first run finds. @@ -96,6 +109,20 @@ def test_initialises_without_error(self, tmp_path: Path) -> None: Application(profile=_profile(tmp_path)) + def test_initialises_where_nothing_can_play(self, tmp_path: Path) -> None: + """Editing a song, exporting a module and rendering to a file need no output device. + + The rate the audio is rendered at is the consumer's to state, so a machine offering no + device to play through still opens the window and everything that writes rather than + sounds works on it. + """ + with ExitStack() as stack: + for display_patch in _display_patches(): + stack.enter_context(display_patch) + stack.enter_context(_no_audio_devices()) + + Application(profile=_profile(tmp_path)) + @pytest.fixture def app(tmp_path: Path) -> Generator[Any, Application, Any]: From 49f09b455c25661468bc75857642e64973dd706a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 14:43:35 +0200 Subject: [PATCH 067/152] Added: shallow frame duplication beside deep cloning --- .../categories/elements/sequencer.py | 1 + .../categories/elements/settings.py | 1 + .../coordinators/tabs/sequencer.py | 29 ++++-- .../logic/history/action.py | 1 + .../logic/project/controller.py | 9 +- .../logic/sequencer/history_detail.py | 3 +- .../logic/sequencer/order.py | 6 +- .../ui/panels/sequencer/order.py | 9 ++ .../utils/gui/shortcuts/ids.py | 1 + .../keybindings/default.yaml | 1 + src/sampletones_config/keybindings/macos.yaml | 1 + src/sampletones_config/lang/en.yaml | 3 + .../project/patterns/channel.py | 2 +- src/sampletones_core/project/song.py | 23 +++-- .../logic/project/test_controller.py | 4 +- .../logic/sequencer/test_history_detail.py | 5 +- .../logic/sequencer/test_order.py | 14 ++- .../ui/panels/sequencer/test_order_keys.py | 9 ++ .../utils/gui/shortcuts/test_shipped.py | 10 ++ .../project/patterns/test_channel.py | 8 +- .../sampletones_core/project/test_song.py | 91 ++++++++++++++++--- 21 files changed, 189 insertions(+), 42 deletions(-) diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 6660cec9a..51db2e5d9 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -63,6 +63,7 @@ class SequencerOrderElements(AbstractElement): LABEL_MASTER = "label_master" CONTEXT_PLAY = "context_play" CONTEXT_DUPLICATE = "context_duplicate" + CONTEXT_CLONE = "context_clone" CONTEXT_INSERT = "context_insert" CONTEXT_CLEAR = "context_clear" CONTEXT_REMOVE = "context_remove" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 6c9943c0a..f9da5d03f 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -97,6 +97,7 @@ class KeybindingActionElements(AbstractElement): ORDER_INSERT_FRAME = "order_insert_frame" ORDER_REMOVE_FRAME = "order_remove_frame" ORDER_DUPLICATE_FRAME = "order_duplicate_frame" + ORDER_CLONE_FRAME = "order_clone_frame" ORDER_CLEAR_FRAME = "order_clear_frame" ORDER_CLEAR_CELL = "order_clear_cell" ORDER_CLEAR_PREVIOUS_CELL = "order_clear_previous_cell" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index cfe0bb403..ea6fac185 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -401,7 +401,12 @@ def _wire_order_callbacks(self) -> None: self._sequencer_order_panel.on_duplicate_requested = self._undoable( HistoryAction.DUPLICATE_FRAME, self._on_order_duplicate, - detail=self._history_detail.duplicate_frame, + detail=self._history_detail.copy_frame, + ) + self._sequencer_order_panel.on_clone_requested = self._undoable( + HistoryAction.CLONE_FRAME, + self._on_order_clone, + detail=self._history_detail.copy_frame, ) self._sequencer_order_panel.on_insert_requested = self._undoable( HistoryAction.ADD_FRAME, @@ -1264,13 +1269,21 @@ def _on_order_remove(self, position: int) -> None: def _on_order_duplicate(self, position: int) -> None: self._sequencer_order_logic.duplicate_frame(position) - self._relocate_playhead( - lambda playhead: remap_after_insert( - playhead, - position + 1, - ) - ) - self._select_frame_when_idle(position + 1) + self._settle_inserted_frame(position + 1) + + def _on_order_clone(self, position: int) -> None: + self._sequencer_order_logic.clone_frame(position) + self._settle_inserted_frame(position + 1) + + def _settle_inserted_frame(self, position: int) -> None: + """Carries the playhead and the shown frame over a frame that has just been inserted. + + A frame arriving at ``position`` pushes every later frame one along, so a playhead + standing on one of them follows it, and the grid moves to the new frame for the reader + to work on. + """ + self._relocate_playhead(lambda playhead: remap_after_insert(playhead, position)) + self._select_frame_when_idle(position) def _on_order_insert(self, position: int) -> None: self._sequencer_order_logic.insert_frame(position + 1) diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py index ce0f6ce5a..9a40d5231 100644 --- a/src/sampletones_application/logic/history/action.py +++ b/src/sampletones_application/logic/history/action.py @@ -18,6 +18,7 @@ class HistoryAction(AbstractElement): ADD_FRAME = "add_frame" REMOVE_FRAME = "remove_frame" DUPLICATE_FRAME = "duplicate_frame" + CLONE_FRAME = "clone_frame" CLEAR_FRAME = "clear_frame" MOVE_FRAME = "move_frame" SET_ORDER_ENTRY = "set_order_entry" diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 1c5d1352a..5e80a04ec 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -235,12 +235,12 @@ def add_pattern(self, generator: GeneratorName) -> int: self.call(self.on_song_changed) return index - def duplicate_pattern( + def clone_pattern( self, generator: GeneratorName, pattern_index: int, ) -> int: - clone_index = self.song.duplicate_pattern(generator, pattern_index) + clone_index = self.song.clone_pattern(generator, pattern_index) self._touch() self.call(self.on_song_changed) return clone_index @@ -404,6 +404,11 @@ def duplicate_frame(self, position: int) -> None: self._touch() self.call(self.on_song_changed) + def clone_frame(self, position: int) -> None: + self.song.clone_frame(position) + self._touch() + self.call(self.on_song_changed) + def clear_frame(self, position: int) -> None: self.song.clear_frame(position) self._touch() diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index b6babae7d..354e92a32 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -163,7 +163,8 @@ def remove_frame(self, position: int) -> Segments: def clear_frame(self, position: int) -> Segments: return (self._frame(position),) - def duplicate_frame(self, position: int) -> Segments: + def copy_frame(self, position: int) -> Segments: + """Reads as source frame to copy, which is what both duplicating and cloning produce.""" return (self._frame(position), self._arrow(), self._frame(position + 1)) def move_frame(self, from_position: int, to_position: int) -> Segments: diff --git a/src/sampletones_application/logic/sequencer/order.py b/src/sampletones_application/logic/sequencer/order.py index 2275ea49e..0d7e3fb99 100644 --- a/src/sampletones_application/logic/sequencer/order.py +++ b/src/sampletones_application/logic/sequencer/order.py @@ -58,9 +58,13 @@ def insert_frame(self, position: int) -> None: self._controller.insert_frame(position) def duplicate_frame(self, position: int) -> None: - """Inserts a copy of the frame at ``position`` directly after it.""" + """Repeats the frame at ``position`` directly after it, playing the same patterns.""" self._controller.duplicate_frame(position) + def clone_frame(self, position: int) -> None: + """Inserts a copy of the frame at ``position`` directly after it, with its own patterns.""" + self._controller.clone_frame(position) + def clear_frame(self, position: int) -> None: """Empties every channel in the frame at ``position``.""" self._controller.clear_frame(position) diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index ecf4748ae..32ae85571 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -149,6 +149,7 @@ def __init__( self.on_frame_selected: Optional[OnFrameSelectedCallback] = None self.on_remove_requested: Optional[OnRemoveCallback] = None self.on_duplicate_requested: Optional[OnFrameActionCallback] = None + self.on_clone_requested: Optional[OnFrameActionCallback] = None self.on_insert_requested: Optional[OnFrameActionCallback] = None self.on_clear_requested: Optional[OnFrameActionCallback] = None self.on_play_from_requested: Optional[OnFrameActionCallback] = None @@ -197,6 +198,7 @@ def label(element: SequencerOrderElements) -> str: self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY) self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE) + self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE) self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT) self._lbl_context_clear = label(SequencerOrderElements.CONTEXT_CLEAR) self._lbl_context_remove = label(SequencerOrderElements.CONTEXT_REMOVE) @@ -882,6 +884,11 @@ def _show_context_menu(self, position: int) -> None: shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME), callback=lambda: self.call(self.on_duplicate_requested, position), ) + dpg.add_menu_item( + label=self._lbl_context_clone, + shortcut=self._shortcuts.display(ShortcutId.ORDER_CLONE_FRAME), + callback=lambda: self.call(self.on_clone_requested, position), + ) dpg.add_menu_item( label=self._lbl_context_insert, shortcut=self._shortcuts.display(ShortcutId.ORDER_INSERT_FRAME), @@ -1039,6 +1046,8 @@ def _act_on_frame(self, shortcut_id: ShortcutId, position: int) -> bool: self._on_remove_clicked() case ShortcutId.ORDER_DUPLICATE_FRAME: self.call(self.on_duplicate_requested, position) + case ShortcutId.ORDER_CLONE_FRAME: + self.call(self.on_clone_requested, position) case ShortcutId.ORDER_CLEAR_FRAME: self.call(self.on_clear_requested, position) case _: diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index f8298e9a9..f830b80b2 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -103,6 +103,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: ORDER_INSERT_FRAME = ("OrderInsertFrame", ShortcutCategory.ORDER) ORDER_REMOVE_FRAME = ("OrderRemoveFrame", ShortcutCategory.ORDER) ORDER_DUPLICATE_FRAME = ("OrderDuplicateFrame", ShortcutCategory.ORDER) + ORDER_CLONE_FRAME = ("OrderCloneFrame", ShortcutCategory.ORDER) ORDER_CLEAR_FRAME = ("OrderClearFrame", ShortcutCategory.ORDER) ORDER_CLEAR_CELL = ("OrderClearCell", ShortcutCategory.ORDER) ORDER_CLEAR_PREVIOUS_CELL = ("OrderClearPreviousCell", ShortcutCategory.ORDER) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 79a9d4063..02946c4f4 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -74,6 +74,7 @@ bindings: OrderInsertFrame: {combination: "Plus", aliases: ["NumPlus", "Shift+Plus"]} OrderRemoveFrame: {combination: "Minus", aliases: ["NumMinus"]} OrderDuplicateFrame: {combination: "Ctrl+Ins"} + OrderCloneFrame: {combination: "Ctrl+Shift+Ins"} OrderClearFrame: {combination: "Shift+Del"} OrderClearCell: {combination: "Del"} OrderClearPreviousCell: {combination: "Backspace"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 096486787..c0e3e2f0a 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -74,6 +74,7 @@ bindings: OrderInsertFrame: {combination: "Plus", aliases: ["NumPlus", "Shift+Plus"]} OrderRemoveFrame: {combination: "Minus", aliases: ["NumMinus"]} OrderDuplicateFrame: {combination: "Ctrl+Ins", aliases: ["Cmd+Alt+Enter"]} + OrderCloneFrame: {combination: "Ctrl+Shift+Ins", aliases: ["Cmd+Alt+Shift+Enter"]} OrderClearFrame: {combination: "Shift+Del", aliases: ["Cmd+Shift+Backspace"]} OrderClearCell: {combination: "Del", aliases: ["Cmd+Backspace"]} OrderClearPreviousCell: {combination: "Backspace"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 88cd8bc99..ca4faa1bc 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -484,6 +484,7 @@ sequencer.order.label.row_triangle: "Triangle" sequencer.order.label.row_noise: "Noise" sequencer.order.label.context_play: "Play from this frame" sequencer.order.label.context_duplicate: "Duplicate" +sequencer.order.label.context_clone: "Clone" sequencer.order.label.context_insert: "Insert frame" sequencer.order.label.context_clear: "Clear frame" sequencer.order.label.context_remove: "Remove" @@ -532,6 +533,7 @@ sequencer.history.label.adjust_volume: "Adjust volume" sequencer.history.label.add_frame: "Add frame" sequencer.history.label.remove_frame: "Remove frame" sequencer.history.label.duplicate_frame: "Duplicate frame" +sequencer.history.label.clone_frame: "Clone frame" sequencer.history.label.clear_frame: "Clear frame" sequencer.history.label.move_frame: "Move frame" sequencer.history.label.set_order_entry: "Set order entry" @@ -778,6 +780,7 @@ settings.keybindings.label.order_add_frame: "Add frame" settings.keybindings.label.order_insert_frame: "Insert frame" settings.keybindings.label.order_remove_frame: "Remove frame" settings.keybindings.label.order_duplicate_frame: "Duplicate frame" +settings.keybindings.label.order_clone_frame: "Clone frame" settings.keybindings.label.order_clear_frame: "Clear frame" settings.keybindings.label.order_clear_cell: "Clear cell" settings.keybindings.label.order_clear_previous_cell: "Clear the previous cell" diff --git a/src/sampletones_core/project/patterns/channel.py b/src/sampletones_core/project/patterns/channel.py index b1bc65a96..eb4f81df2 100644 --- a/src/sampletones_core/project/patterns/channel.py +++ b/src/sampletones_core/project/patterns/channel.py @@ -62,7 +62,7 @@ def ensure_pattern(self, index: int, length: int) -> Pattern: return self.patterns[index] - def duplicate_pattern(self, index: int, *, reserved_indices: AbstractSet[int] = frozenset()) -> int: + def clone_pattern(self, index: int, *, reserved_indices: AbstractSet[int] = frozenset()) -> int: """Clones the pattern at ``index`` into a fresh index and returns that index. ``reserved_indices`` are extra indices the clone must avoid beyond the pool's diff --git a/src/sampletones_core/project/song.py b/src/sampletones_core/project/song.py index e1bccc2ee..f779f8d06 100644 --- a/src/sampletones_core/project/song.py +++ b/src/sampletones_core/project/song.py @@ -91,18 +91,29 @@ def add_pattern(self, generator: GeneratorName) -> int: reserved_indices=self._referenced_indices(generator), ) - def duplicate_pattern(self, generator: GeneratorName, index: int) -> int: + def clone_pattern(self, generator: GeneratorName, index: int) -> int: """Clones ``generator``'s pattern at ``index`` into a free index and returns it. The clone index clears the channel's pool and every order-referenced index, so the copy stays independent of any slot the order already plays. """ - return self.channels[generator].duplicate_pattern( + return self.channels[generator].clone_pattern( index, reserved_indices=self._referenced_indices(generator), ) def duplicate_frame(self, position: int) -> None: + """Inserts a frame playing the same patterns directly after ``position``. + + The pattern indices are copied as they stand, so both frames play one shared + pattern per channel and an edit to either is heard in both. The copy is a fresh + mapping, so assigning a channel a different pattern in one frame leaves the + other frame where it was. Silent slots stay silent, and an index whose pattern + is not yet materialised is carried across as the reference it is. + """ + self.order.insert(position + 1, dict(self.order[position])) + + def clone_frame(self, position: int) -> None: """Inserts an independent copy of the frame directly after ``position``. Each channel's referenced pattern is cloned into a fresh index within that @@ -110,17 +121,17 @@ def duplicate_frame(self, position: int) -> None: the other unchanged. Silent slots stay silent. """ source_frame = self.order[position] - duplicate: Dict[GeneratorName, Optional[int]] = {} + clone: Dict[GeneratorName, Optional[int]] = {} for generator in GeneratorName.items(): index = source_frame.get(generator) if index is None: - duplicate[generator] = None + clone[generator] = None continue self.channels[generator].ensure_pattern(index, self.rows_per_pattern) - duplicate[generator] = self.duplicate_pattern(generator, index) + clone[generator] = self.clone_pattern(generator, index) - self.order.insert(position + 1, duplicate) + self.order.insert(position + 1, clone) def _referenced_indices(self, generator: GeneratorName) -> Set[int]: return {index for frame in self.order if (index := frame.get(generator)) is not None} diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 86efdd502..4f0c996a6 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -519,10 +519,10 @@ def test_add_pattern_returns_int_index(self) -> None: index = controller.add_pattern(GeneratorName.PULSE1) assert isinstance(index, int) - def test_duplicate_pattern_creates_independent_copy(self) -> None: + def test_clone_pattern_creates_independent_copy(self) -> None: controller = _controller() original_index = controller.add_pattern(GeneratorName.TRIANGLE) - clone_index = controller.duplicate_pattern( + clone_index = controller.clone_pattern( GeneratorName.TRIANGLE, original_index, ) 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 b236a09ad..f1395a350 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -157,10 +157,11 @@ def test_add_frame_reports_the_landing_index(self) -> None: assert _pairs(formatter.add_frame(2)) == [("03", HistoryDetailRole.FRAME)] - def test_duplicate_frame_points_source_to_the_copy(self) -> None: + def test_copy_frame_points_source_to_the_copy(self) -> None: + """One builder serves both duplicating and cloning, since each lands a copy after its source.""" formatter = _formatter(_controller()) - assert _pairs(formatter.duplicate_frame(2)) == [ + assert _pairs(formatter.copy_frame(2)) == [ ("02", HistoryDetailRole.FRAME), (">", HistoryDetailRole.SEPARATOR), ("03", HistoryDetailRole.FRAME), diff --git a/tests/unit/sampletones_application/logic/sequencer/test_order.py b/tests/unit/sampletones_application/logic/sequencer/test_order.py index 95d72b014..7a12b4682 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_order.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_order.py @@ -66,15 +66,23 @@ def test_insert_frame_adds_empty_frame_at_position(self) -> None: assert _order_column(logic, GeneratorName.PULSE1) == [None, 5] - def test_duplicate_frame_gives_the_copy_its_own_pattern(self) -> None: + def test_duplicate_frame_repeats_the_same_pattern(self) -> None: logic = _logic() logic.set_order_entry(GeneratorName.PULSE1, 0, 5) logic.duplicate_frame(0) - source_index, duplicate_index = _order_column(logic, GeneratorName.PULSE1) + assert _order_column(logic, GeneratorName.PULSE1) == [5, 5] + + def test_clone_frame_gives_the_copy_its_own_pattern(self) -> None: + logic = _logic() + logic.set_order_entry(GeneratorName.PULSE1, 0, 5) + + logic.clone_frame(0) + + source_index, clone_index = _order_column(logic, GeneratorName.PULSE1) assert source_index == 5 - assert duplicate_index != 5 + assert clone_index != 5 def test_clear_frame_empties_every_channel(self) -> None: logic = _logic() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py index 3f9d476c4..1b6c90bd5 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py @@ -26,6 +26,7 @@ class OrderPanelFixture: panel: GUISequencerOrderPanel inserted: List[int] = field(default_factory=list) duplicated: List[int] = field(default_factory=list) + cloned: List[int] = field(default_factory=list) cleared: List[int] = field(default_factory=list) removed: List[int] = field(default_factory=list) moved: List[Move] = field(default_factory=list) @@ -45,6 +46,7 @@ def order(monkeypatch: pytest.MonkeyPatch) -> OrderPanelFixture: fixture = OrderPanelFixture(panel=panel) panel.on_insert_requested = fixture.inserted.append panel.on_duplicate_requested = fixture.duplicated.append + panel.on_clone_requested = fixture.cloned.append panel.on_clear_requested = fixture.cleared.append panel.on_remove_requested = fixture.removed.append panel.on_move_requested = lambda position, target: fixture.moved.append((position, target)) @@ -63,6 +65,13 @@ class TestFrameActions: def test_the_duplicate_key_duplicates_the_cursor_frame(self, order: OrderPanelFixture) -> None: assert order.panel._on_key_pressed(_press("Ctrl+Ins")) is True assert order.duplicated == [CURSOR_POSITION] + assert order.cloned == [] + + def test_the_clone_key_clones_the_cursor_frame(self, order: OrderPanelFixture) -> None: + """Shift separates the two copies: the plain key repeats, the shifted one clones.""" + assert order.panel._on_key_pressed(_press("Ctrl+Shift+Ins")) is True + assert order.cloned == [CURSOR_POSITION] + assert order.duplicated == [] def test_the_display_settings_key_reaches_the_application(self, order: OrderPanelFixture) -> None: """Ctrl+D belongs to the display settings now, so the table hands it to the shortcut scope.""" diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py index 71493710a..be485188e 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py @@ -15,6 +15,7 @@ DISPLAY_SETTINGS_COMBINATION = "Ctrl+D" DUPLICATE_FRAME_COMBINATION = "Ctrl+Ins" +CLONE_FRAME_COMBINATION = "Ctrl+Shift+Ins" def _press(text: str) -> KeyEvent: @@ -57,6 +58,15 @@ def test_duplicate_frame_answers_its_press_in_the_order_table(self, shipped: Sho assert action is ShortcutId.ORDER_DUPLICATE_FRAME + def test_clone_frame_reads_under_the_combination_it_answers(self, shipped: ShortcutScheme) -> None: + assert shipped.shortcut(ShortcutId.ORDER_CLONE_FRAME).display() == CLONE_FRAME_COMBINATION + + def test_clone_frame_answers_its_press_in_the_order_table(self, shipped: ShortcutScheme) -> None: + """Shift is what separates the deep copy from the repeat, so the two keys stay adjacent.""" + action = shipped.action(ShortcutCategory.ORDER, _press(CLONE_FRAME_COMBINATION)) + + assert action is ShortcutId.ORDER_CLONE_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 diff --git a/tests/unit/sampletones_core/project/patterns/test_channel.py b/tests/unit/sampletones_core/project/patterns/test_channel.py index 9d2998fed..bf55412ce 100644 --- a/tests/unit/sampletones_core/project/patterns/test_channel.py +++ b/tests/unit/sampletones_core/project/patterns/test_channel.py @@ -17,7 +17,7 @@ def test_add_pattern_appends_with_requested_length(self) -> None: assert index in channel.patterns assert channel.pattern(index).length == 8 - def test_duplicate_pattern_copies_rows_with_new_identity(self) -> None: + def test_clone_pattern_copies_rows_with_new_identity(self) -> None: channel = _channel() source = channel.patterns[0] source.rows[0] = Row( @@ -25,17 +25,17 @@ def test_duplicate_pattern_copies_rows_with_new_identity(self) -> None: volume=10, ) - clone_index = channel.duplicate_pattern(0) + clone_index = channel.clone_pattern(0) clone = channel.pattern(clone_index) assert clone_index != 0 assert clone is not source assert clone.rows[0] == source.rows[0] - def test_duplicate_pattern_avoids_reserved_indices(self) -> None: + def test_clone_pattern_avoids_reserved_indices(self) -> None: channel = _channel() - clone_index = channel.duplicate_pattern(0, reserved_indices={1, 2}) + clone_index = channel.clone_pattern(0, reserved_indices={1, 2}) assert clone_index == 3 diff --git a/tests/unit/sampletones_core/project/test_song.py b/tests/unit/sampletones_core/project/test_song.py index 4313d67c3..c6a38264b 100644 --- a/tests/unit/sampletones_core/project/test_song.py +++ b/tests/unit/sampletones_core/project/test_song.py @@ -168,38 +168,105 @@ def test_duplicate_inserts_frame_after_position(self) -> None: assert song.order_length() == 3 assert song.order[2][GeneratorName.PULSE1] == 3 - def test_duplicate_points_channels_at_fresh_patterns(self) -> None: + def test_duplicate_points_channels_at_the_same_patterns(self) -> None: song = _song() song.duplicate_frame(0) source_index = song.order[0][GeneratorName.PULSE1] duplicate_index = song.order[1][GeneratorName.PULSE1] - assert duplicate_index != source_index + assert duplicate_index == source_index - def test_duplicate_avoids_indices_referenced_by_other_frames(self) -> None: + def test_duplicate_allocates_no_pattern(self) -> None: song = _song() - song.append_frame() - song.set_order_entry(1, GeneratorName.PULSE1, 7) + pattern_count = len(song[GeneratorName.PULSE1].patterns) song.duplicate_frame(0) + assert len(song[GeneratorName.PULSE1].patterns) == pattern_count + + def test_editing_a_shared_pattern_is_heard_in_both_frames(self) -> None: + song = _song() + song.duplicate_frame(0) duplicate_index = song.order[1][GeneratorName.PULSE1] - assert duplicate_index != 7 + assert duplicate_index is not None + + _place_instrument(song, GeneratorName.PULSE1, "sample-a", row_index=0) + + shared_pattern = song.pattern(GeneratorName.PULSE1, duplicate_index) + assert shared_pattern is not None + assert shared_pattern.rows[0].command is not None - def test_editing_duplicated_pattern_leaves_the_source_untouched(self) -> None: + def test_repointing_one_frame_leaves_the_other_where_it_was(self) -> None: + """The copy is a fresh mapping, so the two frames' slots move independently.""" + song = _song() + song.duplicate_frame(0) + + song.set_order_entry(1, GeneratorName.PULSE1, 9) + + assert song.order[0][GeneratorName.PULSE1] == 0 + + def test_duplicate_carries_an_unmaterialised_index_across(self) -> None: + song = _song() + song.set_order_entry(0, GeneratorName.PULSE1, 7) + + song.duplicate_frame(0) + + assert song.order[1][GeneratorName.PULSE1] == 7 + assert song.pattern(GeneratorName.PULSE1, 7) is None + + +class TestSongCloneFrame: + def test_clone_inserts_frame_after_position(self) -> None: + song = _song() + song.append_frame() + song.set_order_entry(1, GeneratorName.PULSE1, 3) + + song.clone_frame(0) + + assert song.order_length() == 3 + assert song.order[2][GeneratorName.PULSE1] == 3 + + def test_clone_points_channels_at_fresh_patterns(self) -> None: + song = _song() + + song.clone_frame(0) + + source_index = song.order[0][GeneratorName.PULSE1] + clone_index = song.order[1][GeneratorName.PULSE1] + assert clone_index != source_index + + def test_clone_avoids_indices_referenced_by_other_frames(self) -> None: + song = _song() + song.append_frame() + song.set_order_entry(1, GeneratorName.PULSE1, 7) + + song.clone_frame(0) + + clone_index = song.order[1][GeneratorName.PULSE1] + assert clone_index != 7 + + def test_editing_a_cloned_pattern_leaves_the_source_untouched(self) -> None: song = _song() _place_instrument(song, GeneratorName.PULSE1, "sample-a", row_index=0) source_index = song.order[0][GeneratorName.PULSE1] - song.duplicate_frame(0) - duplicate_index = song.order[1][GeneratorName.PULSE1] - song[GeneratorName.PULSE1].set_row(duplicate_index, 0, Row()) + song.clone_frame(0) + clone_index = song.order[1][GeneratorName.PULSE1] + song[GeneratorName.PULSE1].set_row(clone_index, 0, Row()) source_pattern = song.pattern(GeneratorName.PULSE1, source_index) assert source_pattern is not None assert source_pattern.rows[0].command is not None + def test_clone_keeps_a_silent_slot_silent(self) -> None: + song = _song() + song.set_order_entry(0, GeneratorName.NOISE, None) + + song.clone_frame(0) + + assert song.order[1][GeneratorName.NOISE] is None + class TestSongPatternAllocation: def test_add_pattern_skips_indices_referenced_by_the_order(self) -> None: @@ -211,12 +278,12 @@ def test_add_pattern_skips_indices_referenced_by_the_order(self) -> None: assert index != 4 assert index in song[GeneratorName.PULSE1].patterns - def test_duplicate_pattern_skips_indices_referenced_by_the_order(self) -> None: + def test_clone_pattern_skips_indices_referenced_by_the_order(self) -> None: song = _song() song.append_frame() song.set_order_entry(1, GeneratorName.PULSE1, 6) - clone_index = song.duplicate_pattern(GeneratorName.PULSE1, 0) + clone_index = song.clone_pattern(GeneratorName.PULSE1, 0) assert clone_index != 6 From 5403e59f8aa043aad1380ac8134ea96576dc97a9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 15:03:19 +0200 Subject: [PATCH 068/152] Extracted: shared sequencer channel axis and slot vocabulary --- .../constants/sequencer.py | 5 ++ .../ui/panels/sequencer/columns.py | 21 +------ .../ui/panels/sequencer/input/order.py | 6 +- .../ui/panels/sequencer/input/state.py | 34 +++++++---- .../ui/panels/sequencer/order.py | 6 +- .../view_model/sequencer/slot.py | 58 ++++++++++++++++++ .../sequencer/input/test_order_input.py | 10 ++-- .../view_model/sequencer/test_slot.py | 59 +++++++++++++++++++ 8 files changed, 159 insertions(+), 40 deletions(-) create mode 100644 src/sampletones_application/constants/sequencer.py create mode 100644 src/sampletones_application/view_model/sequencer/slot.py create mode 100644 tests/unit/sampletones_application/view_model/sequencer/test_slot.py diff --git a/src/sampletones_application/constants/sequencer.py b/src/sampletones_application/constants/sequencer.py new file mode 100644 index 000000000..0415da07c --- /dev/null +++ b/src/sampletones_application/constants/sequencer.py @@ -0,0 +1,5 @@ +from typing import Final, Optional, Tuple + +from sampletones_core.constants.enums import GeneratorName + +CHANNEL_AXIS: Final[Tuple[Optional[GeneratorName], ...]] = (None,) + tuple(GeneratorName.items()) diff --git a/src/sampletones_application/ui/panels/sequencer/columns.py b/src/sampletones_application/ui/panels/sequencer/columns.py index 90c17a39a..7d2ed4ca9 100644 --- a/src/sampletones_application/ui/panels/sequencer/columns.py +++ b/src/sampletones_application/ui/panels/sequencer/columns.py @@ -1,14 +1,9 @@ -from typing import Final, Optional, Tuple +from typing import Final, Optional 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 -COLUMNS: Final[Tuple[Optional[GeneratorName], ...]] = (None,) + tuple(GeneratorName.items()) -SUBCOLUMNS: Final[Tuple[SubColumn, ...]] = tuple(SubColumn) - _LEADING_TABLE_COLUMNS: Final[int] = 2 SAMPLE_TABLE_COLUMN: Final[int] = _LEADING_TABLE_COLUMNS DIVIDER_TABLE_COLUMN: Final[int] = SAMPLE_TABLE_COLUMN + 1 @@ -20,16 +15,6 @@ HEADER_TABLE_ROWS: Final[int] = HEADER_TABLE_ROW + 1 -def flat_index(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int: - return COLUMNS.index(generator) * len(SUBCOLUMNS) + SUBCOLUMNS.index(subcolumn) - - -def from_flat(row: int, index: int) -> TrackerCursor: - index %= len(COLUMNS) * len(SUBCOLUMNS) - column, sub = divmod(index, len(SUBCOLUMNS)) - return TrackerCursor(row, COLUMNS[column], SUBCOLUMNS[sub]) - - def channel_color(colors: ChannelColors, generator: GeneratorName) -> BaseColor: match generator: case GeneratorName.PULSE1: @@ -47,8 +32,8 @@ def tracker_table_column(generator: Optional[GeneratorName]) -> int: The visual divider between the sample column and the channels occupies a table column of its own, so the channels sit one slot further right than their - logical position. The divider is purely visual, so :data:`COLUMNS` covers only - the cursor-addressable columns. + logical position. The divider is purely visual, so :data:`CHANNEL_AXIS` covers + only the cursor-addressable columns. """ if generator is None: return SAMPLE_TABLE_COLUMN diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index f6042e91b..868f29100 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -4,10 +4,10 @@ from pydantic.dataclasses import dataclass +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_core.constants.enums import GeneratorName INDEX_DIGITS: Final[int] = 2 -ORDER_ROWS: Final[Tuple[Optional[GeneratorName], ...]] = (None,) + tuple(GeneratorName.items()) @dataclass(frozen=True) @@ -58,8 +58,8 @@ def navigate_channel(self, value: int) -> OrderInputState: if self.cursor is None: return self - current = ORDER_ROWS.index(self.cursor.generator) - new_generator = ORDER_ROWS[(current + value) % len(ORDER_ROWS)] + current = CHANNEL_AXIS.index(self.cursor.generator) + new_generator = CHANNEL_AXIS[(current + value) % len(CHANNEL_AXIS)] return OrderInputState( cursor=OrderCursor(new_generator, self.cursor.position), pending="", diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index 850c986b8..d642e07ac 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -4,17 +4,18 @@ from pydantic.dataclasses import dataclass -from sampletones_application.ui.panels.sequencer.columns import ( - COLUMNS, - SUBCOLUMNS, - flat_index, - from_flat, -) +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.edit import ( ClearAction, EditAction, ) +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + SUBCOLUMNS, + TrackerSlot, + slot_from_flat, +) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.general import MAX_VOLUME from sampletones_shared.constants.symbols import MINUS, PLUS, PLUS_MINUS, SIGNS @@ -95,6 +96,12 @@ def navigate_subcolumn( value: int, absolute: bool = False, ) -> TrackerInputState: + """Steps the cursor along the flattened slot axis, wrapping at either end. + + Wrapping is a navigation policy the cursor owns: walking right off the last + volume slot lands on the sample column's instrument, so a held arrow key + tours the whole row. + """ if self.cursor is None: return self @@ -109,9 +116,10 @@ def navigate_subcolumn( pending="", ) - current = flat_index(self.cursor.generator, self.cursor.subcolumn) + current = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index + slot = slot_from_flat((current + value) % SLOT_COUNT) return TrackerInputState( - cursor=from_flat(self.cursor.row, current + value), + cursor=TrackerCursor(self.cursor.row, slot.generator, slot.subcolumn), pending="", ) @@ -119,10 +127,14 @@ def navigate_column_by(self, delta: int) -> TrackerInputState: if self.cursor is None: return self - current_idx = COLUMNS.index(self.cursor.generator) - next_idx = (current_idx + delta) % len(COLUMNS) + current_idx = CHANNEL_AXIS.index(self.cursor.generator) + next_idx = (current_idx + delta) % len(CHANNEL_AXIS) return TrackerInputState( - cursor=TrackerCursor(self.cursor.row, COLUMNS[next_idx], self.cursor.subcolumn), + cursor=TrackerCursor( + self.cursor.row, + CHANNEL_AXIS[next_idx], + self.cursor.subcolumn, + ), pending="", ) diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 32ae85571..3e497806f 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -5,6 +5,7 @@ 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.constants.sequencer import CHANNEL_AXIS from sampletones_application.layout.general.plus_minus_buttons import ( PlusMinusButtonsLayout, ) @@ -43,7 +44,6 @@ from sampletones_application.ui.panels.sequencer.columns import channel_color from sampletones_application.ui.panels.sequencer.input.order import ( INDEX_DIGITS, - ORDER_ROWS, OrderCursor, OrderInputState, ) @@ -489,7 +489,7 @@ def _build_table(self, position_count: int) -> None: ) self._label_rows = {} - for generator in ORDER_ROWS: + for generator in CHANNEL_AXIS: self._build_row(generator, position_count) if generator is None: self._build_divider_row(position_count) @@ -691,7 +691,7 @@ def _render_cell(self, key: OrderKey) -> str: def _table_row(self, generator: Optional[GeneratorName]) -> int: if generator is None: return MASTER_TABLE_ROW - return ORDER_ROWS.index(generator) + 1 + return CHANNEL_AXIS.index(generator) + 1 def _apply_cursor_highlight(self, cursor: OrderCursor) -> None: dpg.highlight_table_cell( diff --git a/src/sampletones_application/view_model/sequencer/slot.py b/src/sampletones_application/view_model/sequencer/slot.py new file mode 100644 index 000000000..f4902ea38 --- /dev/null +++ b/src/sampletones_application/view_model/sequencer/slot.py @@ -0,0 +1,58 @@ +from __future__ import annotations + +from typing import Final, Optional, Tuple + +from pydantic.dataclasses import dataclass + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName + +SUBCOLUMNS: Final[Tuple[SubColumn, ...]] = tuple(SubColumn) +SLOT_COUNT: Final[int] = len(CHANNEL_AXIS) * len(SUBCOLUMNS) + + +@dataclass(frozen=True) +class TrackerSlot: + """One addressable cell of the tracker grid: a column paired with a subcolumn. + + The grid lays the sample column and the four channels out along + :data:`CHANNEL_AXIS`, each holding the same :data:`SUBCOLUMNS`, so a slot reads + equally as that pair and as a single index into the flattened axis. Both + readings are load-bearing: navigation and range selection walk the flat index, + while an edit addresses the column and the subcolumn it lands in. + """ + + generator: Optional[GeneratorName] + subcolumn: SubColumn + + @property + def flat_index(self) -> int: + return column_slot_base(self.generator) + SUBCOLUMNS.index(self.subcolumn) + + +def column_slot_base(generator: Optional[GeneratorName]) -> int: + """The flat index of ``generator``'s first subcolumn. + + Every base is a multiple of ``len(SUBCOLUMNS)``, which is what keeps an offset + measured from one column's base addressing the same kind of subcolumn at any + other column it is replayed against. + """ + return CHANNEL_AXIS.index(generator) * len(SUBCOLUMNS) + + +def slot_from_flat(index: int) -> TrackerSlot: + """Reads a flat index back as the column and subcolumn it addresses. + + The mapping is exact over the axis, so a caller that walks off either end is + asking for a slot the grid has no cell for: navigation wraps its index before + calling, and a range selection clips to the axis. + + Raises: + IndexError: if ``index`` lies outside ``0`` up to :data:`SLOT_COUNT`. + """ + if not 0 <= index < SLOT_COUNT: + raise IndexError(f"Tracker slot index out of range: {index}") + + column, subcolumn = divmod(index, len(SUBCOLUMNS)) + return TrackerSlot(CHANNEL_AXIS[column], SUBCOLUMNS[subcolumn]) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index ee068f1a9..86ce83f2f 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -1,7 +1,7 @@ from typing import Optional +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer.input.order import ( - ORDER_ROWS, OrderCursor, OrderInputState, ) @@ -31,13 +31,13 @@ def test_position_is_a_no_op_without_positions(self) -> None: def test_channel_cycles_master_then_channels_and_wraps(self) -> None: visited = [] - state = OrderInputState(cursor=OrderCursor(ORDER_ROWS[0], 0)) - for _ in range(len(ORDER_ROWS)): + state = OrderInputState(cursor=OrderCursor(CHANNEL_AXIS[0], 0)) + for _ in range(len(CHANNEL_AXIS)): visited.append(state.cursor.generator) state = state.navigate_channel(1) - assert visited == list(ORDER_ROWS) - assert state.cursor.generator == ORDER_ROWS[0] + assert visited == list(CHANNEL_AXIS) + assert state.cursor.generator == CHANNEL_AXIS[0] class TestEntry: diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_slot.py b/tests/unit/sampletones_application/view_model/sequencer/test_slot.py new file mode 100644 index 000000000..34fb7cc8d --- /dev/null +++ b/tests/unit/sampletones_application/view_model/sequencer/test_slot.py @@ -0,0 +1,59 @@ +from typing import Optional + +import pytest + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + SUBCOLUMNS, + TrackerSlot, + column_slot_base, + slot_from_flat, +) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName + +_OUT_OF_RANGE = [-1, -SLOT_COUNT, SLOT_COUNT, SLOT_COUNT + 1] + + +class TestAxis: + def test_the_sample_column_leads_the_four_channels(self) -> None: + assert CHANNEL_AXIS == (None, *GeneratorName.items()) + + def test_the_axis_covers_every_column_once_over(self) -> None: + assert SLOT_COUNT == len(CHANNEL_AXIS) * len(SUBCOLUMNS) + + +class TestFlatIndex: + @pytest.mark.parametrize("index", range(SLOT_COUNT)) + def test_every_index_round_trips_through_its_slot(self, index: int) -> None: + assert slot_from_flat(index).flat_index == index + + def test_the_axis_maps_onto_the_whole_index_range(self) -> None: + indices = { + TrackerSlot(generator, subcolumn).flat_index for generator in CHANNEL_AXIS for subcolumn in SUBCOLUMNS + } + + assert indices == set(range(SLOT_COUNT)) + + def test_the_sample_columns_instrument_opens_the_axis(self) -> None: + assert TrackerSlot(None, SubColumn.INSTRUMENT).flat_index == 0 + + +class TestColumnBase: + @pytest.mark.parametrize("generator", CHANNEL_AXIS) + def test_every_base_starts_a_whole_column(self, generator: Optional[GeneratorName]) -> None: + """Kind alignment rests on this: an offset from any base addresses the same subcolumn.""" + assert column_slot_base(generator) % len(SUBCOLUMNS) == 0 + + @pytest.mark.parametrize("generator", CHANNEL_AXIS) + def test_a_base_addresses_its_columns_first_subcolumn(self, generator: Optional[GeneratorName]) -> None: + assert slot_from_flat(column_slot_base(generator)) == TrackerSlot(generator, SUBCOLUMNS[0]) + + +class TestBounds: + @pytest.mark.parametrize("index", _OUT_OF_RANGE) + def test_an_index_off_the_axis_is_rejected(self, index: int) -> None: + """A selection clips at the edge, so a slot outside the axis is a caller's mistake.""" + with pytest.raises(IndexError): + slot_from_flat(index) From f29265df8fb27e90ba3ac6ef681a146eec974dfc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 15:33:31 +0200 Subject: [PATCH 069/152] Fixed: an explicit zero transpose reading as an empty one --- src/sampletones_core/utils/display.py | 13 ++++-- .../sampletones_core/utils/test_display.py | 45 ++++++++++++++++++- 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/src/sampletones_core/utils/display.py b/src/sampletones_core/utils/display.py index c9a3b45b9..fe330b6ee 100644 --- a/src/sampletones_core/utils/display.py +++ b/src/sampletones_core/utils/display.py @@ -70,9 +70,14 @@ def display_volume(value: Optional[int]) -> str: def display_transpose(value: Optional[int]) -> str: - if value is None or value == 0: + """Render a transpose as a signed two-digit offset, or ``...`` for an empty one. + + An explicit zero reads ``+00``, since a row storing it resets the channel's + transpose to the sample's own pitch, while an empty cell keeps whatever + transpose is already in force. + """ + if value is None: return NOTE_BLANK - sign = PLUS if value > 0 else MINUS - abs_value = abs(value) - return f"{sign}{abs_value:02X}" + sign = PLUS if value >= 0 else MINUS + return f"{sign}{abs(value):02X}" diff --git a/tests/unit/sampletones_core/utils/test_display.py b/tests/unit/sampletones_core/utils/test_display.py index 57faf5515..c342f7e13 100644 --- a/tests/unit/sampletones_core/utils/test_display.py +++ b/tests/unit/sampletones_core/utils/test_display.py @@ -1,17 +1,22 @@ -from typing import List, Tuple +from typing import List, Optional, Tuple from unittest.mock import Mock +import pytest + from sampletones_core.constants.enums import GeneratorName from sampletones_core.project import Project from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.instruments.sample import Sample from sampletones_core.utils.display import ( + NOTE_BLANK, NOTE_OFF, display_command, display_id, display_sample, display_sample_label, + display_transpose, + display_volume, ) @@ -121,3 +126,41 @@ def test_note_off_renders_dashes(self) -> None: ) == NOTE_OFF ) + + +_TRANSPOSE_CASES = [ + (5, "+05"), + (-5, "-05"), + (26, "+1A"), + (-26, "-1A"), +] + + +class TestDisplayTranspose: + @pytest.mark.parametrize(("value", "expected"), _TRANSPOSE_CASES) + def test_signed_offset_is_two_hexadecimal_digits(self, value: int, expected: str) -> None: + assert display_transpose(value) == expected + + def test_explicit_zero_reads_as_a_zero_offset(self) -> None: + """A row storing zero resets the channel's transpose, so the cell shows the reset.""" + assert display_transpose(0) == "+00" + + def test_absent_transpose_is_placeholder(self) -> None: + assert display_transpose(None) == NOTE_BLANK + + def test_zero_and_absent_read_apart(self) -> None: + assert display_transpose(0) != display_transpose(None) + + @pytest.mark.parametrize("value", [None, 0, 5, -5, 26, -26]) + def test_every_rendering_is_the_same_width(self, value: Optional[int]) -> None: + """The grid lays transpose out in a fixed field, so every value fills it exactly.""" + assert len(display_transpose(value)) == len(NOTE_BLANK) + + +class TestDisplayVolume: + def test_silent_volume_reads_as_zero(self) -> None: + """Volume already tells a stored zero apart from an empty cell; this pins it.""" + assert display_volume(0) == "0" + + def test_absent_volume_is_placeholder(self) -> None: + assert display_volume(None) == "." From 00239dd66e2898a262fa3779e02f62c63c00f575 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 16:02:17 +0200 Subject: [PATCH 070/152] Fixed: channel disagreement indicator --- .../view_model/sequencer/aggregate.py | 9 +- .../view_model/sequencer/tracker.py | 40 +++---- src/sampletones_shared/utils/agreement.py | 59 ++++++++++ .../view_model/sequencer/test_tracker.py | 20 +++- .../utils/test_agreement.py | 101 ++++++++++++++++++ 5 files changed, 193 insertions(+), 36 deletions(-) create mode 100644 src/sampletones_shared/utils/agreement.py create mode 100644 tests/unit/sampletones_shared/utils/test_agreement.py diff --git a/src/sampletones_application/view_model/sequencer/aggregate.py b/src/sampletones_application/view_model/sequencer/aggregate.py index edd67d554..69892e6a2 100644 --- a/src/sampletones_application/view_model/sequencer/aggregate.py +++ b/src/sampletones_application/view_model/sequencer/aggregate.py @@ -1,6 +1,7 @@ from typing import Set from sampletones_shared.constants.symbols import MIXED +from sampletones_shared.utils.agreement import Agreement def aggregate_labels(values: Set[str], *, default: str) -> str: @@ -10,10 +11,4 @@ def aggregate_labels(values: Set[str], *, default: str) -> str: ``default`` (no relevant cells), a single shared value is shown verbatim, and any disagreement collapses to :data:`MIXED`. """ - if not values: - return default - - if len(values) == 1: - return next(iter(values)) - - return MIXED + return Agreement.collapse(values).resolve(absent=default, mixed=MIXED) diff --git a/src/sampletones_application/view_model/sequencer/tracker.py b/src/sampletones_application/view_model/sequencer/tracker.py index addac95c9..54012c51f 100644 --- a/src/sampletones_application/view_model/sequencer/tracker.py +++ b/src/sampletones_application/view_model/sequencer/tracker.py @@ -5,7 +5,6 @@ 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, @@ -41,52 +40,39 @@ class SequencerRowViewModel(BaseModel, frozen=True): @property def subcolumn_generators(self) -> FrozenSet[GeneratorName]: - """Channels the sample column's transpose/volume span. + """Channels every sample column summary spans. - Transpose and volume exist independently of an instrument, so on a row with - no sample they fall back to every channel; otherwise they track the - sample's channels exactly like the instrument does. + A sample governs the channels its reconstruction covers, so its subcolumns + summarise exactly those. Transpose and volume exist independently of an + instrument, so a row with no sample spans every channel. """ return self.relevant_generators or frozenset(self.cells) @property def sample_instrument(self) -> str: - """The sample column's note value. - - A referenced sample wins: the column shows its position (or :data:`MIXED` when the sample - spans more channels than it occupies here). With no sample present, the column reads ``--`` - only when every channel is a note-off; any other mix — including a half-cut row of some - note-off and some blank — reads as empty. - """ - if self.relevant_generators: - return self._aggregate(self.relevant_generators, lambda cell: cell.instrument, display_id(None)) - - if self.cells and all(cell.instrument == NOTE_OFF for cell in self.cells.values()): - return NOTE_OFF - - return display_id(None) + return self._aggregate(lambda cell: cell.instrument, display_id(None)) @property def sample_transpose(self) -> str: - return self._aggregate(self.subcolumn_generators, lambda cell: cell.transpose, display_transpose(None)) + return self._aggregate(lambda cell: cell.transpose, display_transpose(None)) @property def sample_volume(self) -> str: - return self._aggregate(self.subcolumn_generators, lambda cell: cell.volume, display_volume(None)) + return self._aggregate(lambda cell: cell.volume, display_volume(None)) def _aggregate( self, - generators: FrozenSet[GeneratorName], select: Callable[[SequencerCellViewModel], str], default: str, ) -> str: - """Summarise one subcolumn across the given channels. + """Summarise one subcolumn across the channels the sample column spans. - The summary holds a value only when every channel agrees on it, so a sample - missing from one of its channels (an empty cell there) reads as - :data:`MIXED`. With no channels the empty default is shown. + The summary holds a value only where every channel agrees on it, so + :data:`MIXED` marks each way they can differ: a sample missing from one of + its channels, a transpose set on some of them, or a row cut on some and + blank on the rest. A row with no cells at all shows the empty default. """ - values: Set[str] = {select(self.cells[generator]) for generator in generators} + values: Set[str] = {select(self.cells[generator]) for generator in self.subcolumn_generators} return aggregate_labels(values, default=default) diff --git a/src/sampletones_shared/utils/agreement.py b/src/sampletones_shared/utils/agreement.py new file mode 100644 index 000000000..68abcf84c --- /dev/null +++ b/src/sampletones_shared/utils/agreement.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from collections.abc import Hashable +from dataclasses import dataclass +from typing import FrozenSet, Generic, Iterable, TypeVar + +ValueT = TypeVar("ValueT", bound=Hashable) + + +@dataclass(frozen=True) +class Agreement(Generic[ValueT]): + """Whether a group of sources holds one value in common. + + Three outcomes are kept apart: the group is empty, every source holds the same + value, or the sources hold differing ones. Reporting the agreed value separately + from the fact of agreement is what lets an absent value count as agreement — a + transpose every channel leaves empty is a value they share. + """ + + distinct: FrozenSet[ValueT] + + @classmethod + def collapse(cls, values: Iterable[ValueT]) -> Agreement[ValueT]: + return cls(distinct=frozenset(values)) + + @property + def is_absent(self) -> bool: + return not self.distinct + + @property + def is_unanimous(self) -> bool: + return len(self.distinct) == 1 + + @property + def is_mixed(self) -> bool: + return len(self.distinct) > 1 + + @property + def value(self) -> ValueT: + """The value every source holds. + + Raises: + ValueError: if the group is empty or its sources differ, so that no + single value describes them. + """ + if not self.is_unanimous: + raise ValueError(f"Agreement over {len(self.distinct)} distinct values holds no single value") + + return next(iter(self.distinct)) + + def resolve(self, *, absent: ValueT, mixed: ValueT) -> ValueT: + """The agreed value, or the stand-in named for the outcome that reached instead.""" + if self.is_absent: + return absent + + if self.is_mixed: + return mixed + + return self.value diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py index 462e7baec..e9dd5c3ad 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_tracker.py @@ -146,13 +146,29 @@ class AggregateCase(BaseRegularTestCase): expected_volume=_EMPTY_VOLUME, ), AggregateCase( - label="partial_note_off_reads_as_empty", + label="half_cut_row_is_mixed", cells=_row_cells(pulse1=_cell(instrument=NOTE_OFF)), relevant_generators=frozenset(), - expected_instrument=_EMPTY_INSTRUMENT, + expected_instrument=MIXED, expected_transpose=_EMPTY_TRANSPOSE, expected_volume=_EMPTY_VOLUME, ), + AggregateCase( + label="zero_transpose_beside_an_empty_one_is_mixed", + cells=_row_cells(pulse1=_cell(transpose=display_transpose(0))), + relevant_generators=frozenset(), + expected_instrument=_EMPTY_INSTRUMENT, + expected_transpose=MIXED, + expected_volume=_EMPTY_VOLUME, + ), + AggregateCase( + label="zero_transpose_shared_by_every_channel_reads_as_zero", + cells={generator: _cell(transpose=display_transpose(0)) for generator in GeneratorName.items()}, + relevant_generators=frozenset(), + expected_instrument=_EMPTY_INSTRUMENT, + expected_transpose=display_transpose(0), + expected_volume=_EMPTY_VOLUME, + ), ) @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) diff --git a/tests/unit/sampletones_shared/utils/test_agreement.py b/tests/unit/sampletones_shared/utils/test_agreement.py new file mode 100644 index 000000000..5429883f1 --- /dev/null +++ b/tests/unit/sampletones_shared/utils/test_agreement.py @@ -0,0 +1,101 @@ +from dataclasses import dataclass +from typing import Optional, Tuple + +import pytest + +from sampletones_shared.utils.agreement import Agreement +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +_ABSENT = -1 +_MIXED = -2 + + +class TestAgreementOutcomes(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class OutcomeCase(BaseRegularTestCase): + values: Tuple[Optional[int], ...] + expected_absent: bool + expected_unanimous: bool + expected_mixed: bool + expected_resolved: Optional[int] + + test_cases = ( + OutcomeCase( + label="no_sources_are_absent", + values=(), + expected_absent=True, + expected_unanimous=False, + expected_mixed=False, + expected_resolved=_ABSENT, + ), + OutcomeCase( + label="one_source_is_unanimous", + values=(5,), + expected_absent=False, + expected_unanimous=True, + expected_mixed=False, + expected_resolved=5, + ), + OutcomeCase( + label="repeated_value_is_unanimous", + values=(5, 5, 5), + expected_absent=False, + expected_unanimous=True, + expected_mixed=False, + expected_resolved=5, + ), + OutcomeCase( + label="differing_values_are_mixed", + values=(5, 7), + expected_absent=False, + expected_unanimous=False, + expected_mixed=True, + expected_resolved=_MIXED, + ), + OutcomeCase( + label="every_source_absent_is_unanimous_on_absence", + values=(None, None), + expected_absent=False, + expected_unanimous=True, + expected_mixed=False, + expected_resolved=None, + ), + OutcomeCase( + label="absence_beside_a_value_is_mixed", + values=(None, 5), + expected_absent=False, + expected_unanimous=False, + expected_mixed=True, + expected_resolved=_MIXED, + ), + ) + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_outcome_flags_and_resolution(self, case: OutcomeCase) -> None: + agreement: Agreement[Optional[int]] = Agreement.collapse(case.values) + + assert agreement.is_absent is case.expected_absent + assert agreement.is_unanimous is case.expected_unanimous + assert agreement.is_mixed is case.expected_mixed + assert agreement.resolve(absent=_ABSENT, mixed=_MIXED) == case.expected_resolved + + +class TestAgreementValue: + def test_unanimous_absence_reports_absence_as_the_agreed_value(self) -> None: + """The outcome and the agreed value are read apart, so ``None`` can be what they share.""" + agreement: Agreement[Optional[int]] = Agreement.collapse((None, None)) + + assert agreement.is_unanimous + assert agreement.value is None + + def test_no_sources_have_no_agreed_value(self) -> None: + with pytest.raises(ValueError): + Agreement.collapse(()).value + + def test_differing_sources_have_no_agreed_value(self) -> None: + with pytest.raises(ValueError): + Agreement.collapse((5, 7)).value + + def test_order_of_sources_leaves_the_agreement_equal(self) -> None: + assert Agreement.collapse((5, 7)) == Agreement.collapse((7, 5)) From e7576c58663e1f7c8e9208a642a98706371c6a46 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 16:25:04 +0200 Subject: [PATCH 071/152] Moved: sample column dispatch into the tracker logic --- .../coordinators/tabs/sequencer.py | 102 +------ .../logic/sequencer/tracker.py | 225 +++++++++++++--- .../coordinators/tabs/test_sequencer.py | 20 -- .../logic/sequencer/test_tracker.py | 254 ++++++++++++++++++ 4 files changed, 454 insertions(+), 147 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index ea6fac185..25a6f41b2 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -89,7 +89,6 @@ SequencerSettingsViewModel, ) from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel -from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( HistoryDetail, HistoryDetailSegment, @@ -97,7 +96,6 @@ ) from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.song_position import SongPosition from sampletones_core.reconstructions import Reconstruction from sampletones_shared.exceptions import SampleToNESError @@ -303,23 +301,23 @@ def _wire_module_callbacks(self) -> None: def _wire_tracker_callbacks(self) -> None: self._sequencer_tracker_panel.on_clear_row = self._undoable( HistoryAction.CLEAR_ROW, - self._on_clear_row, + self._sequencer_tracker_logic.clear_cell, detail=self._history_detail.clear_row, ) self._sequencer_tracker_panel.on_clear_subcolumn = self._undoable( HistoryAction.CLEAR_SUBCOLUMN, - self._on_clear_subcolumn, + self._sequencer_tracker_logic.clear_cell_subcolumn, detail=self._history_detail.clear_subcolumn, ) self._sequencer_tracker_panel.on_set_row = self._undoable( HistoryAction.EDIT_ROW, - self._on_set_row, + self._sequencer_tracker_logic.write_cell, detail=self._history_detail.edit_row, coalesce=self._edit_row_key, ) self._sequencer_tracker_panel.on_set_note_off = self._undoable( HistoryAction.NOTE_OFF, - self._on_set_note_off, + self._sequencer_tracker_logic.cut_note, detail=self._history_detail.note_off, coalesce=self._cell_key, ) @@ -328,13 +326,13 @@ def _wire_tracker_callbacks(self) -> None: self._sequencer_tracker_panel.on_play_from_frame = self.play_from_current_frame self._sequencer_tracker_panel.on_adjust_transpose = self._undoable( HistoryAction.ADJUST_TRANSPOSE, - self._on_adjust_transpose, + self._sequencer_tracker_logic.adjust_cell_transpose, detail=self._history_detail.adjust_transpose, coalesce=self._adjustment_key, ) self._sequencer_tracker_panel.on_adjust_volume = self._undoable( HistoryAction.ADJUST_VOLUME, - self._on_adjust_volume, + self._sequencer_tracker_logic.adjust_cell_volume, detail=self._history_detail.adjust_volume, coalesce=self._adjustment_key, ) @@ -1026,94 +1024,6 @@ def _replace_target_label(self) -> Optional[str]: def _dispatch_edit_sample(self, sample_id: str) -> None: self._on_edit_sample_requested(sample_id) - def _on_clear_row( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: - if generator is None: - self._sequencer_tracker_logic.clear_all_generators(row_index) - else: - self._sequencer_tracker_logic.clear_row(generator, row_index) - - def _on_clear_subcolumn( - self, - row_index: int, - generator: Optional[GeneratorName], - subcolumn: SubColumn, - ) -> None: - instrument = subcolumn is SubColumn.INSTRUMENT - transpose = subcolumn is SubColumn.TRANSPOSE - volume = subcolumn is SubColumn.VOLUME - if generator is None: - if instrument: - self._sequencer_tracker_logic.clear_subcolumn_all_generators( - row_index, - instrument=True, - ) - else: - self._sequencer_tracker_logic.clear_sample_subcolumn( - row_index, - transpose=transpose, - volume=volume, - ) - else: - self._sequencer_tracker_logic.clear_subcolumn( - generator, - row_index, - instrument=instrument, - transpose=transpose, - volume=volume, - ) - - def _on_set_row( - self, - row_index: int, - generator: Optional[GeneratorName], - sample_id: Optional[str], - transpose: Optional[int], - volume: Optional[int], - ) -> None: - if generator is None: - if sample_id is not None: - self._sequencer_tracker_logic.set_sample_instrument( - row_index, - sample_id, - ) - elif transpose is not None or volume is not None: - self._sequencer_tracker_logic.set_sample_subcolumn( - row_index, - transpose=transpose, - volume=volume, - ) - else: - command = ( - Instrument( - sample_id=sample_id, - generator_name=generator, - ) - if sample_id is not None - else None - ) - self._sequencer_tracker_logic.set_row( - generator, - row_index, - command=command, - transpose=transpose, - volume=volume, - ) - - def _on_set_note_off( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: - """Writes a note-off: to one channel, or across every channel from the sample column.""" - if generator is None: - self._sequencer_tracker_logic.set_note_off_all_generators(row_index) - else: - self._sequencer_tracker_logic.set_note_off(generator, row_index) - 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( diff --git a/src/sampletones_application/logic/sequencer/tracker.py b/src/sampletones_application/logic/sequencer/tracker.py index 7fe714e54..ec99b48f6 100644 --- a/src/sampletones_application/logic/sequencer/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker.py @@ -4,6 +4,7 @@ from sampletones_application.view_model.sequencer.settings import ( SequencerSettingsViewModel, ) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.sequencer.tracker import ( SequencerCellViewModel, SequencerRowViewModel, @@ -38,6 +39,10 @@ class SequencerTrackerLogic(CallbackMixin): translates raw panel events into :class:`ProjectController` mutations. The controller's change events are wired (by the coordinator) back to the push methods here, so a single mutation round-trips into a refreshed view. + + Cell-level edits take an ``Optional[GeneratorName]`` naming the column they + address: a generator reaches that channel alone, while ``None`` addresses the + sample column and spreads the edit over the channels that column governs. """ def __init__(self, project_controller: ProjectController) -> None: @@ -66,38 +71,51 @@ def build_grid(self) -> SequencerTrackerViewModel: frame_count = song.order_length() frame_index = self._clamp_frame(frame_count) - patterns: Dict[GeneratorName, Pattern] = {} - if frame_count > 0: - for generator in GeneratorName.items(): - index = song.order[frame_index].get(generator) - pattern = song.pattern(generator, index) if index is not None else None - if pattern is not None: - patterns[generator] = pattern - - row_count = self._frame_row_count(patterns, song.rows_per_pattern) if frame_count > 0 else 0 - rows = tuple(self._build_row(index, patterns) for index in range(row_count)) + patterns = self._frame_patterns() + rows = tuple(self._build_row(index, patterns) for index in range(self.frame_row_count())) return SequencerTrackerViewModel( frame_index=frame_index, frame_count=frame_count, rows=rows, ) - def _frame_row_count( - self, - patterns: Dict[GeneratorName, Pattern], - rows_per_pattern: int, - ) -> int: - """Rows to show for the current frame. + def frame_row_count(self) -> int: + """Rows the current frame holds, the height a whole-frame edit spans. - Empty (None) slots contribute no pattern, so a frame whose channels are all - empty falls back to ``rows_per_pattern`` blank rows — keeping the frame - editable so the first keystroke can auto-create a pattern for that channel. + A frame is as tall as its longest pattern. Empty (None) slots contribute no + pattern, so a frame whose channels are all empty falls back to + ``rows_per_pattern`` blank rows — keeping the frame editable so the first + keystroke can auto-create a pattern for that channel. Until the order holds + its first frame, the count is zero. """ - lengths = [pattern.length for pattern in patterns.values()] + song = self._controller.project.song + if song.order_length() == 0: + return 0 + + lengths = [pattern.length for pattern in self._frame_patterns().values()] if lengths: return max(lengths) - return rows_per_pattern + return song.rows_per_pattern + + def _frame_patterns(self) -> Dict[GeneratorName, Pattern]: + """The patterns the current frame's channels point at. + + A channel contributes an entry once its slot names a pattern the song holds, + so the result covers exactly the channels carrying content at this frame. + """ + song = self._controller.project.song + if self._frame_index >= song.order_length(): + return {} + + patterns: Dict[GeneratorName, Pattern] = {} + for generator in GeneratorName.items(): + index = song.order[self._frame_index].get(generator) + pattern = song.pattern(generator, index) if index is not None else None + if pattern is not None: + patterns[generator] = pattern + + return patterns def push_settings(self) -> None: self.call(self.on_settings_changed, self.settings) @@ -123,6 +141,143 @@ def set_tempo(self, tempo: int) -> None: def set_speed(self, speed: int) -> None: self._controller.set_speed(speed) + def clear_cell( + self, + row_index: int, + generator: Optional[GeneratorName], + ) -> None: + if generator is None: + self.clear_all_generators(row_index) + else: + self.clear_row(generator, row_index) + + def clear_cell_subcolumn( + self, + row_index: int, + generator: Optional[GeneratorName], + subcolumn: SubColumn, + ) -> None: + """Empties one subcolumn of a cell. + + From the sample column an instrument reaches every channel, since the sample + it names is the row's whole note, while transpose and volume follow the + channels that column governs. + """ + instrument = subcolumn is SubColumn.INSTRUMENT + transpose = subcolumn is SubColumn.TRANSPOSE + volume = subcolumn is SubColumn.VOLUME + if generator is not None: + self.clear_subcolumn( + generator, + row_index, + instrument=instrument, + transpose=transpose, + volume=volume, + ) + elif instrument: + self.clear_subcolumn_all_generators(row_index, instrument=True) + else: + self.clear_sample_subcolumn( + row_index, + transpose=transpose, + volume=volume, + ) + + def write_cell( + self, + row_index: int, + generator: Optional[GeneratorName], + sample_id: Optional[str], + transpose: Optional[int], + volume: Optional[int], + ) -> None: + """Writes the value a cell edit carries, keeping the rest of the cell as it stands. + + An edit names one subcolumn, so a sample takes the write whenever one + arrives, and an offset lands on its own otherwise. + """ + if sample_id is not None: + self.place_note(row_index, generator, sample_id) + elif transpose is not None or volume is not None: + self.set_cell_subcolumn( + row_index, + generator, + transpose=transpose, + volume=volume, + ) + + def place_note( + self, + row_index: int, + generator: Optional[GeneratorName], + sample_id: str, + ) -> None: + if generator is None: + self.set_sample_instrument(row_index, sample_id) + else: + self.set_row( + generator, + row_index, + command=Instrument( + sample_id=sample_id, + generator_name=generator, + ), + ) + + def cut_note( + self, + row_index: int, + generator: Optional[GeneratorName], + ) -> None: + if generator is None: + self.set_note_off_all_generators(row_index) + else: + self.set_note_off(generator, row_index) + + def set_cell_subcolumn( + self, + row_index: int, + generator: Optional[GeneratorName], + *, + transpose: Optional[int] = None, + volume: Optional[int] = None, + ) -> None: + if generator is None: + self.set_sample_subcolumn( + row_index, + transpose=transpose, + volume=volume, + ) + else: + self.set_row( + generator, + row_index, + transpose=transpose, + volume=volume, + ) + + def adjust_cell_transpose( + self, + row_index: int, + generator: Optional[GeneratorName], + delta: int, + ) -> None: + if generator is None: + self.adjust_sample_transpose(row_index, delta) + else: + self.adjust_transpose(generator, row_index, delta) + + def adjust_cell_volume( + self, + row_index: int, + generator: Optional[GeneratorName], + delta: int, + ) -> None: + if generator is None: + self.adjust_sample_volume(row_index, delta) + else: + self.adjust_volume(generator, row_index, delta) + def set_row( self, generator: GeneratorName, @@ -321,11 +476,12 @@ def adjust_sample_volume(self, row_index: int, delta: int) -> None: for generator in self._subcolumn_generators(row_index): self.adjust_volume(generator, row_index, delta) - def _current_row( + def row( self, generator: GeneratorName, row_index: int, ) -> Optional[Row]: + """The row stored at a cell, present while its channel holds a pattern reaching that far.""" pattern_index = self._pattern_index_at_frame(generator) if pattern_index is None: return None @@ -341,14 +497,14 @@ def _current_transpose( generator: GeneratorName, row_index: int, ) -> int: - row = self._current_row(generator, row_index) + row = self.row(generator, row_index) if row is None or row.transpose is None: return 0 return row.transpose def _current_volume(self, generator: GeneratorName, row_index: int) -> int: - row = self._current_row(generator, row_index) + row = self.row(generator, row_index) if row is None or row.volume is None: return MAX_VOLUME @@ -419,13 +575,20 @@ def _subcolumn_generators(self, row_index: int) -> List[GeneratorName]: Falls back to every channel when no sample constrains the row, mirroring :attr:`SequencerRowViewModel.subcolumn_generators`. """ - relevant = self._relevant_generators(row_index) - if not relevant: + referenced = self.referenced_generators(row_index) + if not referenced: return GeneratorName.items() - return [generator for generator in GeneratorName.items() if generator in relevant] + return [generator for generator in GeneratorName.items() if generator in referenced] + + def referenced_generators(self, row_index: int) -> FrozenSet[GeneratorName]: + """The channels spanned by the samples a row names. - def _relevant_generators(self, row_index: int) -> FrozenSet[GeneratorName]: + Reads the row from every channel's pattern, so it reports a sample's whole + span even where some of its cells stand empty. A row naming no sample + references no channel, which is what :meth:`relevant_generators` widens to + every channel. + """ rows: Dict[GeneratorName, Optional[Row]] = {} for generator in GeneratorName.items(): pattern_index = self._pattern_index_at_frame(generator) @@ -439,9 +602,9 @@ def _relevant_generators(self, row_index: int) -> FrozenSet[GeneratorName]: ) rows[generator] = pattern.rows[row_index] if pattern is not None else None - return self._relevant_generators_from_rows(rows) + return self._referenced_generators_from_rows(rows) - def _relevant_generators_from_rows( + def _referenced_generators_from_rows( self, rows: Dict[GeneratorName, Optional[Row]], ) -> FrozenSet[GeneratorName]: @@ -491,7 +654,7 @@ def _build_row( return SequencerRowViewModel( index=index, cells=cells, - relevant_generators=self._relevant_generators_from_rows(rows), + relevant_generators=self._referenced_generators_from_rows(rows), ) def _build_cell(self, row: Row) -> SequencerCellViewModel: diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index cda08806e..498e7f2cc 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -342,26 +342,6 @@ def test_a_chosen_mode_reaches_the_player( playback_coordinator._song_player_logic.set_follow_mode.assert_called_once_with(mode) -class TestNoteOffDispatch: - def test_channel_cell_writes_note_off_to_that_channel( - self, - playback_coordinator: SequencerTabCoordinator, - ) -> None: - playback_coordinator._on_set_note_off(2, GeneratorName.PULSE1) - - 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, - playback_coordinator: SequencerTabCoordinator, - ) -> None: - playback_coordinator._on_set_note_off(2, None) - - 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 def order_ops_coordinator() -> SequencerTabCoordinator: """A coordinator with only the collaborators the order-frame handlers touch.""" diff --git a/tests/unit/sampletones_application/logic/sequencer/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/test_tracker.py index fe4d29676..44e42ace0 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_tracker.py @@ -6,6 +6,7 @@ from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME @@ -70,6 +71,259 @@ def _place_instrument( ) +class TestClearCell: + def test_a_channel_cell_clears_only_that_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_row(GeneratorName.PULSE1, 0, transpose=5) + logic.set_row(GeneratorName.PULSE2, 0, transpose=7) + + logic.clear_cell(0, GeneratorName.PULSE1) + + assert _row(controller, GeneratorName.PULSE1).transpose is None + assert _row(controller, GeneratorName.PULSE2).transpose == 7 + + def test_the_sample_column_clears_every_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_sample_subcolumn(0, transpose=5) + + logic.clear_cell(0, None) + + for generator in GeneratorName.items(): + assert _row(controller, generator).transpose is None + + +class TestClearCellSubcolumn: + def test_a_channel_cell_clears_one_subcolumn_of_its_own(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_row(GeneratorName.PULSE1, 0, transpose=5, volume=10) + + logic.clear_cell_subcolumn(0, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + + row = _row(controller, GeneratorName.PULSE1) + assert row.transpose is None + assert row.volume == 10 + + def test_the_sample_column_clears_instruments_from_every_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + name="lead", + ) + logic.set_sample_instrument(0, sample.id) + logic.set_note_off(GeneratorName.NOISE, 0) + + logic.clear_cell_subcolumn(0, None, SubColumn.INSTRUMENT) + + for generator in GeneratorName.items(): + assert _row(controller, generator).command is None + + def test_the_sample_column_clears_transpose_from_the_sample_channels(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + name="lead", + ) + logic.set_sample_instrument(0, sample.id) + for generator in GeneratorName.items(): + logic.set_row(generator, 0, transpose=5) + + logic.clear_cell_subcolumn(0, None, SubColumn.TRANSPOSE) + + for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): + assert _row(controller, generator).transpose is None + + for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): + assert _row(controller, generator).transpose == 5 + + +class TestWriteCell: + def test_a_sample_in_the_sample_column_spreads_over_its_channels(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + name="lead", + ) + + logic.write_cell(0, None, sample.id, None, None) + + for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): + assert isinstance(_row(controller, generator).command, Instrument) + + for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): + assert _row(controller, generator).command is None + + def test_a_sample_in_a_channel_cell_is_named_for_that_channel(self) -> None: + """A cell re-targets the sample onto its own channel, whichever channels the sample covers.""" + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1]), + name="lead", + ) + + logic.write_cell(0, GeneratorName.NOISE, sample.id, None, None) + + command = _row(controller, GeneratorName.NOISE).command + assert isinstance(command, Instrument) + assert command.sample_id == sample.id + assert command.generator_name == GeneratorName.NOISE + assert _row(controller, GeneratorName.PULSE1).command is None + + def test_a_transpose_in_the_sample_column_reaches_every_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + + logic.write_cell(0, None, None, 5, None) + + for generator in GeneratorName.items(): + assert _row(controller, generator).transpose == 5 + + def test_a_volume_in_a_channel_cell_leaves_the_rest_of_the_cell_standing(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_row(GeneratorName.PULSE1, 0, transpose=5) + + logic.write_cell(0, GeneratorName.PULSE1, None, None, 10) + + row = _row(controller, GeneratorName.PULSE1) + assert row.transpose == 5 + assert row.volume == 10 + + def test_an_edit_carrying_no_value_leaves_the_frame_alone(self) -> None: + """Typing a sample index the project has no sample for creates no pattern.""" + controller = _controller() + logic = SequencerTrackerLogic(controller) + controller.append_frame() + logic.select_frame(1) + + logic.write_cell(0, GeneratorName.PULSE1, None, None, None) + + assert controller.project.song.order[1][GeneratorName.PULSE1] is None + + +class TestCutNote: + def test_a_channel_cell_cuts_that_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + + logic.cut_note(0, GeneratorName.PULSE1) + + assert isinstance(_row(controller, GeneratorName.PULSE1).command, NoteOff) + assert _row(controller, GeneratorName.PULSE2).command is None + + def test_the_sample_column_cuts_every_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + + logic.cut_note(0, None) + + for generator in GeneratorName.items(): + assert isinstance(_row(controller, generator).command, NoteOff) + + +class TestAdjustCell: + def test_a_channel_cell_shifts_only_that_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + + logic.adjust_cell_volume(0, GeneratorName.PULSE1, -1) + + assert _row(controller, GeneratorName.PULSE1).volume == MAX_VOLUME - 1 + assert _row(controller, GeneratorName.PULSE2).volume is None + + def test_the_sample_column_shifts_the_sample_channels(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + name="lead", + ) + logic.set_sample_instrument(0, sample.id) + + logic.adjust_cell_transpose(0, None, 3) + + for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): + assert _row(controller, generator).transpose == 3 + + for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): + assert _row(controller, generator).transpose is None + + +class TestFrameRowCount: + def test_counts_the_rows_the_grid_builds(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + + assert logic.frame_row_count() == len(logic.build_grid().rows) + + def test_an_empty_frame_counts_editable_rows(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + controller.append_frame() + logic.select_frame(1) + + assert logic.frame_row_count() == controller.project.song.rows_per_pattern + + def test_an_order_without_frames_counts_nothing(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + controller.remove_frame(0) + + assert logic.frame_row_count() == 0 + + +class TestRowAccess: + def test_reads_the_stored_row(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_row(GeneratorName.PULSE1, 0, transpose=5) + + row = logic.row(GeneratorName.PULSE1, 0) + + assert row is not None + assert row.transpose == 5 + + def test_a_channel_without_a_pattern_has_no_row(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + controller.append_frame() + logic.select_frame(1) + + assert logic.row(GeneratorName.PULSE1, 0) is None + + +class TestReferencedGenerators: + def test_one_placement_reports_the_samples_whole_span(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + sample = controller.add_sample( + _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + name="lead", + ) + _place_instrument(controller, GeneratorName.PULSE1, sample.id) + + assert logic.referenced_generators(0) == frozenset( + { + GeneratorName.PULSE1, + GeneratorName.TRIANGLE, + } + ) + + def test_a_row_naming_no_sample_references_no_channel(self) -> None: + controller = _controller() + logic = SequencerTrackerLogic(controller) + logic.set_note_off(GeneratorName.PULSE1, 0) + + assert logic.referenced_generators(0) == frozenset() + assert logic.relevant_generators(0) == GeneratorName.items() + + class TestSetNoteOff: def test_set_note_off_writes_note_off_command(self) -> None: controller = _controller() From 2bf4921fa1c04fb7c002341b86f30b50bd1da499 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 16:53:58 +0200 Subject: [PATCH 072/152] Added: batched project mutation notifications --- docs/development/architecture.md | 2 +- docs/development/undo.md | 9 +- .../coordinators/tabs/sequencer.py | 46 ++--- .../logic/project/batch.py | 26 +++ .../logic/project/controller.py | 157 +++++++++++++----- .../coordinators/tabs/test_sequencer.py | 29 +++- .../logic/history/test_manager.py | 26 +++ .../logic/project/test_controller.py | 125 ++++++++++++++ 8 files changed, 342 insertions(+), 78 deletions(-) create mode 100644 src/sampletones_application/logic/project/batch.py diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 10dbf8bf5..8aa730c5b 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -253,7 +253,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m *Managers* own a domain object's lifecycle (load, save, close). They hold the current object, a `Session` that tracks dirty state, and fire `CallbackMixin` callbacks when the state changes. -*Controllers* are thin mutation façades over a manager. `ProjectController` exposes named, typed mutation methods (`set_title`, `add_sample`, …) and emits a finer-grained callback per mutation kind (`on_info_changed`, `on_samples_changed`, …). This lets the UI respond precisely to what changed. +*Controllers* are thin mutation façades over a manager. `ProjectController` exposes named, typed mutation methods (`set_title`, `add_sample`, …) and emits a finer-grained callback per mutation kind (`on_info_changed`, `on_samples_changed`, …). This lets the UI respond precisely to what changed. `ProjectController.batch()` widens that grain to a whole gesture: each mutation still applies the moment it is made, while the callbacks it raises wait for the scope to close and then arrive once each, so a gesture writing hundreds of rows rebuilds its subscribers once. *Logic objects* (e.g. `ConverterLogic`) orchestrate multi-step workflows within a feature area. They subscribe to services and translate service results into view model updates. diff --git a/docs/development/undo.md b/docs/development/undo.md index bf818c9fe..e056846be 100644 --- a/docs/development/undo.md +++ b/docs/development/undo.md @@ -34,9 +34,14 @@ the regeneration worker's background thread. consecutive commits sharing the same action and key replace the top entry instead of appending, so a continuous interaction — a graph drag, repeated edits of one cell — records a single entry. Any undo, redo, or jump breaks - the run, so a state the user navigated to is always preserved. + the run, so a state the user navigated to is always preserved. `_undoable` + opens `ProjectController.batch()` inside the transaction, so one gesture is + one entry and one round of view notifications alike. - **Detection — the controller.** `ProjectController._touch()` fires `on_mutation` - on every fine-grained mutation. `HistoryManager.handle_mutation` counts those + on every fine-grained mutation, as it lands — a batch defers the view + notifications and the dirty stamp, leaving this signal immediate so the check + below sees each mutation inside the transaction that caused it. + `HistoryManager.handle_mutation` counts those inside a transaction and rejects any that occur outside one: under strict deployment it raises `UntrackedMutationError`; otherwise it self-heals by recording the mutation as its own entry. This makes completeness a checkable diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 25a6f41b2..4a789f8aa 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -565,6 +565,11 @@ def _undoable( receives, and ``coalesce`` computes the gesture's target key from them: consecutive gestures sharing the same action and target collapse into a single entry. + + The gesture is batched inside its transaction, so however many rows it + writes, the panels rebuild once — and they rebuild before the entry that + undoes them is recorded, because the snapshot reads the project rather + than the views. """ def wrapped( @@ -573,7 +578,14 @@ def wrapped( ) -> None: description = detail(*args, **kwargs) if detail is not None else () key = coalesce(*args, **kwargs) if coalesce is not None else None - with self._history.transaction(action, detail=description, coalesce=key): + with ( + self._history.transaction( + action, + detail=description, + coalesce=key, + ), + self._project_controller.batch(), + ): callback(*args, **kwargs) return wrapped @@ -1031,38 +1043,6 @@ def _on_tracker_play_from_row(self, row_index: int) -> None: row_index, ) - def _on_adjust_transpose( - self, - row_index: int, - generator: Optional[GeneratorName], - delta: int, - ) -> None: - """Shifts transpose: one channel, or across the sample column's channels.""" - if generator is None: - self._sequencer_tracker_logic.adjust_sample_transpose(row_index, delta) - else: - self._sequencer_tracker_logic.adjust_transpose( - generator, - row_index, - delta, - ) - - def _on_adjust_volume( - self, - row_index: int, - generator: Optional[GeneratorName], - delta: int, - ) -> None: - """Shifts volume: one channel, or across the sample column's channels.""" - if generator is None: - self._sequencer_tracker_logic.adjust_sample_volume(row_index, delta) - else: - self._sequencer_tracker_logic.adjust_volume( - generator, - row_index, - delta, - ) - def _on_samples_changed( self, view_model: SequencerSamplesViewModel, diff --git a/src/sampletones_application/logic/project/batch.py b/src/sampletones_application/logic/project/batch.py new file mode 100644 index 000000000..1ffc444db --- /dev/null +++ b/src/sampletones_application/logic/project/batch.py @@ -0,0 +1,26 @@ +from dataclasses import dataclass, field +from typing import List, Optional + +from sampletones_shared.types.callback import VoidCallback + + +@dataclass +class MutationBatch: + """The open batch's accumulating state. + + Bundles the nesting ``depth`` of coalesced ``batch()`` scopes, the + ``announcements`` the mutations raised in the order they first arose, and + whether the project still needs its dirty ``stamp``. Keeping these together + holds one gesture's deferred notifications in lockstep. The presence of a + ``MutationBatch`` instance is itself the signal that a batch is open, and + nesting a scope increments its ``depth``. + """ + + depth: int = 1 + announcements: List[Optional[VoidCallback]] = field(default_factory=list) + stamped: bool = False + + def record(self, announcement: Optional[VoidCallback]) -> None: + """Keeps an announcement for the flush, once per distinct signal.""" + if announcement not in self.announcements: + self.announcements.append(announcement) diff --git a/src/sampletones_application/logic/project/controller.py b/src/sampletones_application/logic/project/controller.py index 5e80a04ec..fd6b4b954 100644 --- a/src/sampletones_application/logic/project/controller.py +++ b/src/sampletones_application/logic/project/controller.py @@ -1,5 +1,6 @@ +from contextlib import contextmanager from pathlib import Path -from typing import Optional +from typing import Iterator, Optional from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME, MIN_TRANSPOSE @@ -13,6 +14,7 @@ from sampletones_shared.utils.arrays import clamp from sampletones_shared.utils.callbacks import CallbackMixin +from .batch import MutationBatch from .manager import ProjectManager @@ -24,10 +26,14 @@ class ProjectController(CallbackMixin): and the observer signal always happen together. - Each mutation kind fires a distinct callback so that subscribers can respond precisely to the specific change. + - :meth:`batch` widens that grain to a whole gesture: its mutations still + apply one at a time, while the dirty stamp and the observer signals they + raise arrive once each, on the way out. """ def __init__(self, project_manager: ProjectManager) -> None: self._project_manager = project_manager + self._batch: Optional[MutationBatch] = None self.on_project_replaced: Optional[VoidCallback] = None self.on_info_changed: Optional[VoidCallback] = None @@ -70,6 +76,29 @@ def sample_count(self) -> int: def is_dirty(self) -> bool: return self._project_manager.is_dirty + @contextmanager + def batch(self) -> Iterator[None]: + """Groups every mutation of one gesture into a single round of notifications. + + A gesture that writes many rows — pasting a block of cells, spreading a + sample across the channels it covers — leaves each mutation applying the + moment it is made, while the dirty stamp and the observer signals it raises + wait for the scope to close and then arrive once each, in the order they + first arose. Subscribers therefore rebuild their views once per gesture + instead of once per row. + + Nested scopes join the outermost one, and the flush runs on scope exit even + when the gesture raises: the mutations that already landed are part of the + live project, so their subscribers hear about them. The history's mutation + signal stays immediate (see :meth:`_touch`), and the lifecycle signals — + a project replaced, a project saved — are unaffected. + """ + self._begin_batch() + try: + yield + finally: + self._flush_batch() + def new(self) -> None: self._project_manager.new() self.call(self.on_project_replaced) @@ -106,57 +135,57 @@ def export_request(self) -> ProjectExport: def mark_updated(self) -> None: self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def set_title(self, title: str) -> None: self.project.info.title = title self._touch() - self.call(self.on_info_changed) + self._announce(self.on_info_changed) def set_author(self, author: str) -> None: self.project.info.author = author self._touch() - self.call(self.on_info_changed) + self._announce(self.on_info_changed) def set_comment(self, comment: str) -> None: self.project.info.comment = comment self._touch() - self.call(self.on_info_changed) + self._announce(self.on_info_changed) def set_tempo(self, tempo: int) -> None: self.project.settings.tempo = tempo self._touch() - self.call(self.on_settings_changed) + self._announce(self.on_settings_changed) def set_speed(self, speed: int) -> None: self.project.settings.speed = speed self._touch() - self.call(self.on_settings_changed) + self._announce(self.on_settings_changed) def set_first_highlight(self, first_highlight: int) -> None: self.project.settings.first_highlight = first_highlight self._touch() - self.call(self.on_settings_changed) + self._announce(self.on_settings_changed) def set_second_highlight(self, second_highlight: int) -> None: self.project.settings.second_highlight = second_highlight self._touch() - self.call(self.on_settings_changed) + self._announce(self.on_settings_changed) def set_nes_frequency(self, nes_frequency: int) -> None: self.project.settings.nes_frequency = nes_frequency self._touch() - self.call(self.on_settings_changed) + self._announce(self.on_settings_changed) def set_sample_rate(self, sample_rate: int) -> None: self.project.settings.sample_rate = sample_rate self._touch() - self.call(self.on_settings_changed) + self._announce(self.on_settings_changed) def set_rows_per_pattern(self, rows_per_pattern: int) -> None: self.song.resize_patterns(rows_per_pattern) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: """Embeds a reconstruction as a project sample, detaching its local source-audio origin. @@ -168,7 +197,7 @@ def add_sample(self, reconstruction: Reconstruction, name: str) -> Sample: sample = Sample(name=name, reconstruction=reconstruction) self.project.samples.append(sample) self._touch() - self.call(self.on_samples_changed) + self._announce(self.on_samples_changed) return sample def replace_sample_reconstruction(self, sample_id: str, reconstruction: Reconstruction) -> None: @@ -181,19 +210,19 @@ def replace_sample_reconstruction(self, sample_id: str, reconstruction: Reconstr reconstruction.detach_source() self.project.samples[sample_id].reconstruction = reconstruction self._touch() - self.call(self.on_samples_changed) - self.call(self.on_song_changed) + self._announce(self.on_samples_changed) + self._announce(self.on_song_changed) def rename_sample(self, sample_id: str, name: str) -> None: self.project.samples[sample_id].name = name self._touch() - self.call(self.on_samples_changed) - self.call(self.on_song_changed) + self._announce(self.on_samples_changed) + self._announce(self.on_song_changed) def set_sample_loop(self, sample_id: str, loop: bool) -> None: self.project.samples[sample_id].loop = loop self._touch() - self.call(self.on_samples_changed) + self._announce(self.on_samples_changed) def is_sample_used(self, sample_id: str) -> bool: return self.song.references_sample(sample_id) @@ -202,8 +231,8 @@ def remove_sample(self, sample_id: str) -> None: self.project.samples.pop(sample_id) self.song.clear_sample_references(sample_id) self._touch() - self.call(self.on_samples_changed) - self.call(self.on_song_changed) + self._announce(self.on_samples_changed) + self._announce(self.on_song_changed) def duplicate_sample(self, sample_id: str) -> Sample: """Appends an independent copy of a sample (same name and loop flag). @@ -214,7 +243,7 @@ def duplicate_sample(self, sample_id: str) -> Sample: clone = self.project.samples[sample_id].clone() self.project.samples.append(clone) self._touch() - self.call(self.on_samples_changed) + self._announce(self.on_samples_changed) return clone def move_sample(self, sample_id: str, to_index: int) -> None: @@ -226,13 +255,13 @@ def move_sample(self, sample_id: str, to_index: int) -> None: """ self.project.samples.move(sample_id, to_index) self._touch() - self.call(self.on_samples_changed) - self.call(self.on_song_changed) + self._announce(self.on_samples_changed) + self._announce(self.on_song_changed) def add_pattern(self, generator: GeneratorName) -> int: index = self.song.add_pattern(generator) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) return index def clone_pattern( @@ -242,7 +271,7 @@ def clone_pattern( ) -> int: clone_index = self.song.clone_pattern(generator, pattern_index) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) return clone_index def remove_pattern( @@ -252,7 +281,7 @@ def remove_pattern( ) -> None: self.song.remove_pattern(generator, pattern_index) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def _clamp_transpose(self, transpose: Optional[int]) -> Optional[int]: if transpose is None: @@ -315,7 +344,7 @@ def set_row( row, ) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def update_row( self, @@ -372,12 +401,12 @@ def clear_row( def append_frame(self) -> None: self.song.append_frame() self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def insert_frame(self, position: int) -> None: self.song.insert_frame(position) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def set_order_entry( self, @@ -387,41 +416,89 @@ def set_order_entry( ) -> None: self.song.set_order_entry(position, generator, pattern_index) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def remove_frame(self, position: int) -> None: self.song.remove_frame(position) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def move_frame(self, from_position: int, to_position: int) -> None: self.song.move_frame(from_position, to_position) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def duplicate_frame(self, position: int) -> None: self.song.duplicate_frame(position) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def clone_frame(self, position: int) -> None: self.song.clone_frame(position) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) def clear_frame(self, position: int) -> None: self.song.clear_frame(position) self._touch() - self.call(self.on_song_changed) + self._announce(self.on_song_changed) - def _touch(self) -> None: - """Stamps the project as modified and signals the mutation to the history. + def _begin_batch(self) -> None: + if self._batch is None: + self._batch = MutationBatch() + return + + self._batch.depth += 1 - ``on_mutation`` is invoked through a direct ``None`` check so mutations stay - silent in history-free contexts (tests, tools), where the hook is intentionally - unwired and :meth:`CallbackMixin.call` would log a warning for each one. + def _flush_batch(self) -> None: + """Delivers the outermost batch's stamp and announcements, each exactly once. + + The batch is closed before anything is delivered, so a subscriber that reads + the project — or mutates it further — sees a controller that notifies + immediately again. """ + if self._batch is None: + return + + self._batch.depth -= 1 + if self._batch.depth > 0: + return + + batch = self._batch + self._batch = None + if batch.stamped: + self._stamp() + + for announcement in batch.announcements: + self.call(announcement) + + def _announce(self, announcement: Optional[VoidCallback]) -> None: + """Signals a change to its subscribers, or keeps it for the open batch's flush.""" + if self._batch is not None: + self._batch.record(announcement) + return + + self.call(announcement) + + def _stamp(self) -> None: + """Records the project as carrying unsaved changes, once per batch while one is open.""" + if self._batch is not None: + self._batch.stamped = True + return + self.project.info.touch() self._project_manager.mark_updated() + + def _touch(self) -> None: + """Stamps the project as modified and signals the mutation to the history. + + ``on_mutation`` fires for every mutation as it lands, batch or no batch, so the + history keeps seeing each one inside the transaction that caused it — that + immediacy is what its completeness check rests on. It is invoked through a + direct ``None`` check so mutations stay silent in history-free contexts (tests, + tools), where the hook is intentionally unwired and :meth:`CallbackMixin.call` + would log a warning for each one. + """ + self._stamp() if self.on_mutation is not None: self.on_mutation() diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 498e7f2cc..798ebb455 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -1,6 +1,6 @@ from datetime import UTC, datetime from pathlib import Path -from typing import Dict, Final +from typing import Dict, Final, List from unittest.mock import MagicMock import pytest @@ -758,9 +758,15 @@ def test_label_is_absent_without_a_selection( @pytest.fixture def history_coordinator() -> SequencerTabCoordinator: - """A coordinator with only the history collaborator wired.""" + """A coordinator with the two collaborators an undoable gesture reaches. + + The history is a mock, so a test reads the transaction a gesture opens; the + controller is real, so a test reads the notifications the gesture's mutations + actually produce. + """ instance = object.__new__(SequencerTabCoordinator) instance._history = MagicMock() + instance._project_controller = ProjectController(ProjectManager()) return instance @@ -1223,6 +1229,25 @@ def test_wrapped_call_passes_computed_coalesce_key( coalesce=("tempo",), ) + def test_wrapped_call_announces_one_song_change_for_the_whole_gesture( + self, + history_coordinator: SequencerTabCoordinator, + ) -> None: + controller = history_coordinator._project_controller + announcements: List[str] = [] + controller.on_song_changed = lambda: announcements.append("song") + initial_length = controller.order_length + + def append_frames(count: int) -> None: + for _ in range(count): + controller.append_frame() + + wrapped = history_coordinator._undoable(HistoryAction.EDIT_ROW, append_frames) + wrapped(3) + + assert controller.order_length == initial_length + 3 + assert announcements == ["song"] + @pytest.fixture def view_coordinator() -> SequencerTabCoordinator: diff --git a/tests/unit/sampletones_application/logic/history/test_manager.py b/tests/unit/sampletones_application/logic/history/test_manager.py index 0e18b1cc5..dc6cc06f8 100644 --- a/tests/unit/sampletones_application/logic/history/test_manager.py +++ b/tests/unit/sampletones_application/logic/history/test_manager.py @@ -92,6 +92,22 @@ def test_nested_transactions_coalesce_into_one_entry( assert len(history.entries) == 2 assert history.entries[-1].action is HistoryAction.ADD_SAMPLE + def test_batched_edit_commits_one_entry( + self, + history_factory: HistoryFactory, + ) -> None: + controller, history = history_factory() + original = controller.project.settings.tempo + + with history.transaction(HistoryAction.SET_TEMPO), controller.batch(): + controller.set_tempo(150) + controller.set_speed(4) + + assert len(history.entries) == 2 + + history.undo() + assert controller.project.settings.tempo == original + def test_exception_inside_transaction_commits_partial_gesture( self, history_factory: HistoryFactory, @@ -447,6 +463,16 @@ def test_untracked_mutation_raises_under_strict( with pytest.raises(UntrackedMutationError): controller.set_tempo(120) + def test_batched_mutation_outside_a_transaction_raises_under_strict( + self, + history_factory: HistoryFactory, + ) -> None: + """A batch defers the notifications a gesture raises, never the mutations it records.""" + controller, _ = history_factory(strict=True) + + with pytest.raises(UntrackedMutationError), controller.batch(): + controller.set_tempo(120) + def test_untracked_mutation_self_heals_when_lenient( self, history_factory: HistoryFactory, diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index 4f0c996a6..bde1c6425 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -2,6 +2,7 @@ from typing import Callable, List import numpy as np +import pytest from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager @@ -583,3 +584,127 @@ def test_in_place_reconstruction_edit_is_visible_through_project( stored = controller.project.sample(sample.id).reconstruction assert stored.get_generator_instructions(GeneratorName.PULSE1) == new_instructions + + +class TestBatch: + def test_a_batch_announces_one_song_change_for_many_rows(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_song_changed = lambda: emitted.append("song") + pattern_index = controller.song.order[0][GeneratorName.PULSE1] + + with controller.batch(): + for row_index in range(8): + controller.set_row( + GeneratorName.PULSE1, + pattern_index, + row_index, + volume=15, + ) + + assert emitted == ["song"] + + def test_a_mutation_applies_before_its_announcement_arrives(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_settings_changed = lambda: emitted.append("settings") + + with controller.batch(): + controller.set_tempo(150) + assert controller.project.settings.tempo == 150 + assert emitted == [] + + assert emitted == ["settings"] + + def test_each_kind_of_change_announces_once_in_the_order_it_first_arose(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_settings_changed = lambda: emitted.append("settings") + controller.on_song_changed = lambda: emitted.append("song") + controller.on_info_changed = lambda: emitted.append("info") + + with controller.batch(): + controller.set_tempo(150) + controller.append_frame() + controller.set_title("Demo") + controller.set_speed(4) + controller.append_frame() + + assert emitted == ["settings", "song", "info"] + + def test_the_dirty_stamp_lands_once_for_the_whole_batch(self) -> None: + project_manager = ProjectManager() + controller = ProjectController(project_manager) + stamps: List[str] = [] + project_manager.session.on_state_changed = lambda: stamps.append("state") + + with controller.batch(): + controller.set_tempo(150) + controller.set_speed(4) + assert controller.is_dirty is False + + assert stamps == ["state"] + assert controller.is_dirty is True + + def test_every_mutation_signals_the_history_as_it_lands(self) -> None: + controller = _controller() + mutations: List[str] = [] + controller.on_mutation = lambda: mutations.append("mutation") + + with controller.batch(): + controller.set_tempo(150) + controller.set_speed(4) + assert mutations == ["mutation", "mutation"] + + assert mutations == ["mutation", "mutation"] + + def test_nested_batches_announce_on_the_outermost_exit(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_settings_changed = lambda: emitted.append("settings") + + with controller.batch(): + controller.set_tempo(150) + with controller.batch(): + controller.set_speed(4) + + assert emitted == [] + + assert emitted == ["settings"] + + def test_a_batch_that_raises_still_announces_what_landed(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_settings_changed = lambda: emitted.append("settings") + + with pytest.raises(RuntimeError), controller.batch(): + controller.set_tempo(150) + raise RuntimeError("boom") + + assert emitted == ["settings"] + assert controller.project.settings.tempo == 150 + assert controller.is_dirty is True + + def test_a_batch_without_mutations_announces_nothing(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_settings_changed = lambda: emitted.append("settings") + controller.on_song_changed = lambda: emitted.append("song") + + with controller.batch(): + pass + + assert emitted == [] + assert controller.is_dirty is False + + def test_announcements_resume_immediately_after_a_batch(self) -> None: + controller = _controller() + emitted: List[str] = [] + controller.on_settings_changed = lambda: emitted.append("settings") + + with controller.batch(): + controller.set_tempo(150) + + controller.set_speed(4) + + assert emitted == ["settings", "settings"] From dfb9ddc1b4ca6266ab01ad20bcee5b51aecb5e69 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 20:59:14 +0200 Subject: [PATCH 073/152] Added: tracker/order selection --- .../categories/elements/settings.py | 12 + .../layout/tabs/sequencer/tracker/tracker.py | 8 +- .../logic/sequencer/{ => tracker}/tracker.py | 0 src/sampletones_application/tags/general.py | 1 + .../ui/elements/table/cells.py | 11 + .../ui/elements/table/drag.py | 21 ++ .../ui/panels/sequencer/input/order.py | 87 ++++- .../ui/panels/sequencer/input/state.py | 98 ++++- .../ui/panels/sequencer/order.py | 230 +++++++++++- .../ui/panels/sequencer/tracker.py | 253 ++++++++++++- .../ui/themes/dpg_constants.py | 1 + .../ui/themes/inline.py | 19 + .../utils/gui/shortcuts/ids.py | 24 ++ .../view_model/sequencer/region.py | 121 ++++++ .../keybindings/default.yaml | 12 + src/sampletones_config/keybindings/macos.yaml | 12 + src/sampletones_config/lang/en.yaml | 12 + .../layout/tabs/sequencer/tracker.yaml | 2 + src/sampletones_config/palettes/dark.yaml | 1 + src/sampletones_config/palettes/light.yaml | 1 + src/sampletones_config/palettes/studio.yaml | 1 + .../theme/tables/order.yaml | 5 + .../theme/tables/pattern.yaml | 15 +- .../sequencer/{ => tracker}/test_tracker.py | 0 .../sequencer/input/test_order_input.py | 70 ++++ .../sequencer/input/test_tracker_input.py | 97 +++++ .../ui/panels/sequencer/test_panel_escape.py | 30 ++ .../panels/sequencer/test_selection_drag.py | 355 ++++++++++++++++++ .../panels/sequencer/test_selection_keys.py | 181 +++++++++ .../utils/gui/shortcuts/test_scheme.py | 9 +- .../view_model/sequencer/test_region.py | 92 +++++ 31 files changed, 1740 insertions(+), 41 deletions(-) rename src/sampletones_application/logic/sequencer/{ => tracker}/tracker.py (100%) create mode 100644 src/sampletones_application/ui/elements/table/drag.py create mode 100644 src/sampletones_application/view_model/sequencer/region.py rename tests/unit/sampletones_application/logic/sequencer/{ => tracker}/test_tracker.py (100%) create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py create mode 100644 tests/unit/sampletones_application/view_model/sequencer/test_region.py diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index f9da5d03f..8b73997b9 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -89,6 +89,12 @@ class KeybindingActionElements(AbstractElement): ORDER_NEXT_CHANNEL = "order_next_channel" ORDER_FIRST_POSITION = "order_first_position" ORDER_LAST_POSITION = "order_last_position" + ORDER_EXTEND_SELECTION_UP = "order_extend_selection_up" + ORDER_EXTEND_SELECTION_DOWN = "order_extend_selection_down" + ORDER_EXTEND_SELECTION_LEFT = "order_extend_selection_left" + ORDER_EXTEND_SELECTION_RIGHT = "order_extend_selection_right" + ORDER_EXTEND_SELECTION_TO_FIRST_POSITION = "order_extend_selection_to_first_position" + ORDER_EXTEND_SELECTION_TO_LAST_POSITION = "order_extend_selection_to_last_position" ORDER_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" @@ -111,6 +117,12 @@ class KeybindingActionElements(AbstractElement): TRACKER_NEXT_COLUMN = "tracker_next_column" TRACKER_FIRST_ROW = "tracker_first_row" TRACKER_LAST_ROW = "tracker_last_row" + TRACKER_EXTEND_SELECTION_UP = "tracker_extend_selection_up" + TRACKER_EXTEND_SELECTION_DOWN = "tracker_extend_selection_down" + TRACKER_EXTEND_SELECTION_LEFT = "tracker_extend_selection_left" + TRACKER_EXTEND_SELECTION_RIGHT = "tracker_extend_selection_right" + TRACKER_EXTEND_SELECTION_TO_FIRST_ROW = "tracker_extend_selection_to_first_row" + TRACKER_EXTEND_SELECTION_TO_LAST_ROW = "tracker_extend_selection_to_last_row" TRACKER_PAGE_UP = "tracker_page_up" TRACKER_PAGE_DOWN = "tracker_page_down" TRACKER_CLEAR_ROW = "tracker_clear_row" diff --git a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py index 27b8023de..07c908cd5 100644 --- a/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py +++ b/src/sampletones_application/layout/tabs/sequencer/tracker/tracker.py @@ -4,14 +4,20 @@ class TrackerLayout(BaseModel, extra="forbid", frozen=True): - """The tracker's row counts, column widths and tint strengths. + """The tracker's row counts, cell sizes and tint strengths. The grouping the rows are tinted by is the project's own metre, read from its highlights, so this model carries the geometry alone. + + A row states its height rather than growing to the text in it, because the grid's tints are + drawn by the cells: a cell that stands exactly as tall as its row lets a selection, a hover + and the cursor cover the row edge to edge. """ rows: int page_size: int + row_height: int + header_height: int subcolumn_widths: SubcolumnWidths channel_column_tint: float muted_text_fraction: float diff --git a/src/sampletones_application/logic/sequencer/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py similarity index 100% rename from src/sampletones_application/logic/sequencer/tracker.py rename to src/sampletones_application/logic/sequencer/tracker/tracker.py diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 7cd592fbb..d1814485f 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -668,6 +668,7 @@ SUF_HANDLER_NODE = compose_tag("handler", "node") SUF_HANDLER_DETAIL_TOOLTIP = compose_tag("handler", "detail_tooltip") SUF_HANDLER_HEADER = compose_tag("handler", "header") +SUF_HANDLER_DRAG = compose_tag("handler", "drag") SUF_LABEL = "label" SUF_PATH = "path" SUF_TEXT = "text" diff --git a/src/sampletones_application/ui/elements/table/cells.py b/src/sampletones_application/ui/elements/table/cells.py index 379ad571a..7943de02e 100644 --- a/src/sampletones_application/ui/elements/table/cells.py +++ b/src/sampletones_application/ui/elements/table/cells.py @@ -35,6 +35,7 @@ class EditableCells(Generic[KeyT]): def __init__(self) -> None: self._widgets: Dict[KeyT, Sender] = {} + self._keys: Dict[Sender, KeyT] = {} self._values: Dict[KeyT, str] = {} @property @@ -44,14 +45,24 @@ def values(self) -> Dict[KeyT, str]: def reset(self, values: Dict[KeyT, str]) -> None: """Drops the widget references and reseeds the value cache for a rebuild.""" self._widgets = {} + self._keys = {} self._values = dict(values) def register(self, key: KeyT, widget: Sender) -> None: self._widgets[key] = widget + self._keys[widget] = key def widget(self, key: KeyT) -> Optional[Sender]: return self._widgets.get(key) + def key(self, widget: Sender) -> Optional[KeyT]: + """The cell a widget stands for, which is what a handler reporting an item needs. + + DearPyGui hands an item handler the widget it fired for, so the cache is read from + both sides: a panel looks a widget up here rather than reading its user data back. + """ + return self._keys.get(widget) + def reconcile(self, values: Dict[KeyT, str], render: Callable[[KeyT], str]) -> None: """Updates only the cells whose label changed since the last reconcile.""" for key, value in values.items(): diff --git a/src/sampletones_application/ui/elements/table/drag.py b/src/sampletones_application/ui/elements/table/drag.py new file mode 100644 index 000000000..bde55c5e4 --- /dev/null +++ b/src/sampletones_application/ui/elements/table/drag.py @@ -0,0 +1,21 @@ +from collections.abc import Hashable +from dataclasses import dataclass +from typing import Generic, TypeVar + +KeyT = TypeVar("KeyT", bound=Hashable) + + +@dataclass +class DragGesture(Generic[KeyT]): + """The press a drag selection grows from. + + ``origin`` is the cell the button went down on, which is the end a plain drag anchors its + selection at. ``extends`` records that the press held Shift, so the drag carries the + selection already on the grid instead of starting a new one. ``moved`` states that the + pointer has reached another cell, which is what tells a drag apart from a click: until it + is set, the press is still a click and the selection is left alone. + """ + + origin: KeyT + extends: bool + moved: bool = False diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index 868f29100..1c2b6a01a 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -5,6 +5,7 @@ from pydantic.dataclasses import dataclass from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.view_model.sequencer.region import OrderRegion from sampletones_core.constants.enums import GeneratorName INDEX_DIGITS: Final[int] = 2 @@ -25,18 +26,85 @@ def _parse(pending: str) -> Optional[int]: @dataclass class OrderInputState: - """Edit cursor and pending hex entry for the order table. + """Edit cursor, pending hex entry and selection anchor for the order table. The order has no subcolumns, so a cell holds a single pattern index; typing accumulates :data:`INDEX_DIGITS` hex digits and then commits the parsed index. - Navigation moves along positions (columns) or channels/master (rows). + Navigation moves along positions (columns) or channels/master (rows). The anchor is where a + range selection was started and the cursor is its other end, so the two together are the + block a copy or a paste acts on. """ cursor: Optional[OrderCursor] = None pending: str = "" + anchor: Optional[OrderCursor] = None def reset_pending(self) -> OrderInputState: - return OrderInputState(cursor=self.cursor, pending="") + """Drops a partial entry, leaving the cursor and any selection where they stand. + + The anchor survives because this runs before every move, the extending ones included: + each gesture then decides whether to hold the selection or collapse it. + """ + return OrderInputState(cursor=self.cursor, pending="", anchor=self.anchor) + + def collapse(self) -> OrderInputState: + """Drops the selection, leaving the cursor's own cell as the whole target.""" + return OrderInputState(cursor=self.cursor, pending=self.pending) + + @property + def region(self) -> Optional[OrderRegion]: + """The block a selection covers, once one has been started.""" + if self.cursor is None or self.anchor is None: + return None + + anchor_row = CHANNEL_AXIS.index(self.anchor.generator) + cursor_row = CHANNEL_AXIS.index(self.cursor.generator) + return OrderRegion( + first_row=min(anchor_row, cursor_row), + last_row=max(anchor_row, cursor_row), + first_position=min(self.anchor.position, self.cursor.position), + last_position=max(self.anchor.position, self.cursor.position), + ) + + def extend_to(self, cursor: OrderCursor) -> OrderInputState: + """Carries the moving end of the selection to ``cursor``, anchoring it where it began. + + A selection that has not been started yet takes the cell the cursor stands on as its + anchor, so the first extending gesture selects the cell it came from as well as the one + it reaches. + """ + return OrderInputState( + cursor=cursor, + pending="", + anchor=self.anchor if self.anchor is not None else self.cursor, + ) + + def extend_position( + self, + value: int, + position_count: int, + absolute: bool = False, + ) -> OrderInputState: + """Carries the selection's moving end to another position of the same row.""" + if self.cursor is None or position_count == 0: + return self + + new_position = value if absolute else self.cursor.position + value + new_position = max(0, min(new_position, position_count - 1)) + return self.extend_to(OrderCursor(self.cursor.generator, new_position)) + + def extend_channel(self, value: int) -> OrderInputState: + """Carries the selection's moving end across the channel axis, stopping at either end. + + A selection covers a run of the table, so the walk stops at the master row and at the last + channel rather than wrapping around the way plain navigation does. + """ + if self.cursor is None: + return self + + current = CHANNEL_AXIS.index(self.cursor.generator) + row = max(0, min(current + value, len(CHANNEL_AXIS) - 1)) + return self.extend_to(OrderCursor(CHANNEL_AXIS[row], self.cursor.position)) def navigate_position( self, @@ -73,7 +141,15 @@ def type_char(self, char: str) -> Tuple[OrderInputState, Optional[int]]: if len(pending) < INDEX_DIGITS: return OrderInputState(cursor=self.cursor, pending=pending), None - return self.reset_pending(), _parse(pending) + return self._after_entry(), _parse(pending) + + def _after_entry(self) -> OrderInputState: + """The state a committed entry leaves: the cursor alone, nothing pending and nothing selected. + + Typing writes the one cell the cursor stands on, so it takes the selection down to that + cell instead of leaving a range for the next gesture to act on. + """ + return self.collapse().reset_pending() def commit_partial(self) -> Tuple[OrderInputState, Optional[int]]: if not self.pending or self.cursor is None: @@ -82,4 +158,5 @@ def commit_partial(self) -> Tuple[OrderInputState, Optional[int]]: return self.reset_pending(), _parse(self.pending.zfill(INDEX_DIGITS)) def cancel(self) -> OrderInputState: - return self.reset_pending() + """Drops a partial entry and any selection, which is what Escape asks of the table.""" + return self.collapse().reset_pending() diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index d642e07ac..51e4f2efe 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -10,6 +10,7 @@ ClearAction, EditAction, ) +from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.slot import ( SLOT_COUNT, SUBCOLUMNS, @@ -65,11 +66,89 @@ def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: @dataclass class TrackerInputState: + """Edit cursor, pending entry and selection anchor for the tracker grid. + + The anchor is where a range selection was started; the cursor is its other end, so the two + together are the region a block operation acts on. Every plain move builds a state without + one, which is what makes a move collapse a selection to the cell it lands in. + """ + cursor: Optional[TrackerCursor] = None pending: str = "" + anchor: Optional[TrackerCursor] = None def reset_pending(self) -> TrackerInputState: - return TrackerInputState(cursor=self.cursor, pending="") + """Drops a partial entry, leaving the cursor and any selection where they stand. + + The anchor survives because this runs before every move, the extending ones included: + each gesture then decides whether to hold the selection or collapse it. + """ + return TrackerInputState(cursor=self.cursor, pending="", anchor=self.anchor) + + def collapse(self) -> TrackerInputState: + """Drops the selection, leaving the cursor's own cell as the whole target.""" + return TrackerInputState(cursor=self.cursor, pending=self.pending) + + @property + def region(self) -> Optional[TrackerRegion]: + """The block a selection covers, once one has been started.""" + if self.cursor is None or self.anchor is None: + return None + + anchor_slot = TrackerSlot(self.anchor.generator, self.anchor.subcolumn).flat_index + cursor_slot = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index + return TrackerRegion( + first_row=min(self.anchor.row, self.cursor.row), + last_row=max(self.anchor.row, self.cursor.row), + first_slot=min(anchor_slot, cursor_slot), + last_slot=max(anchor_slot, cursor_slot), + ) + + def extend_to(self, cursor: TrackerCursor) -> TrackerInputState: + """Carries the moving end of the selection to ``cursor``, anchoring it where it began. + + A selection that has not been started yet takes the cell the cursor stands on as its + anchor, so the first extending gesture selects the cell it came from as well as the one + it reaches. + """ + return TrackerInputState( + cursor=cursor, + pending="", + anchor=self.anchor if self.anchor is not None else self.cursor, + ) + + def extend_row( + self, + value: int, + row_count: int, + absolute: bool = False, + ) -> TrackerInputState: + """Carries the selection's moving end to another row of the same slot.""" + if self.cursor is None or row_count == 0: + return self + + new_row = value if absolute else self.cursor.row + value + new_row = max(0, min(new_row, row_count - 1)) + return self.extend_to( + TrackerCursor( + new_row, + self.cursor.generator, + self.cursor.subcolumn, + ) + ) + + def extend_slot(self, value: int) -> TrackerInputState: + """Carries the selection's moving end along the flat slot axis, stopping at either end. + + A selection covers a run of the grid, so the walk stops at the first and the last slot + rather than wrapping around the way plain navigation does. + """ + if self.cursor is None: + return self + + current = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index + slot = slot_from_flat(max(0, min(current + value, SLOT_COUNT - 1))) + return self.extend_to(TrackerCursor(self.cursor.row, slot.generator, slot.subcolumn)) def navigate_row( self, @@ -146,7 +225,7 @@ def type_char( return self, None if self.cursor.subcolumn is SubColumn.INSTRUMENT and char == MINUS: - return self.reset_pending(), self._note_off_action(self.cursor) + return self._after_entry(), self._note_off_action(self.cursor) if self.cursor.subcolumn is SubColumn.TRANSPOSE: return self._type_transpose_char(char) @@ -160,7 +239,15 @@ def type_char( return TrackerInputState(cursor=self.cursor, pending=pending), None action = _parse(self.cursor, pending) - return self.reset_pending(), action + return self._after_entry(), action + + def _after_entry(self) -> TrackerInputState: + """The state a committed entry leaves: the cursor alone, nothing pending and nothing selected. + + Typing writes the one cell the cursor stands on, so it takes the selection down to that + cell instead of leaving a range for the next gesture to act on. + """ + return self.collapse().reset_pending() def _note_off_action(self, cursor: TrackerCursor) -> EditAction: return EditAction( @@ -203,7 +290,7 @@ def _type_transpose_char( return TrackerInputState(cursor=self.cursor, pending=pending), None action = _parse(self.cursor, pending) - return self.reset_pending(), action + return self._after_entry(), action def commit_partial(self) -> Tuple[TrackerInputState, Optional[EditAction]]: if not self.pending or self.cursor is None: @@ -234,4 +321,5 @@ def clear_subcolumn(self) -> Tuple[TrackerInputState, ClearAction]: return self.reset_pending(), action def cancel(self) -> TrackerInputState: - return self.reset_pending() + """Drops a partial entry and any selection, which is what Escape asks of the grid.""" + return self.collapse().reset_pending() diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 3e497806f..ec7a9917f 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Final, Optional, Tuple +from typing import Callable, Dict, Final, FrozenSet, Optional, Set, Tuple import dearpygui.dearpygui as dpg @@ -12,6 +12,7 @@ from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( + SUF_HANDLER_DRAG, SUF_HANDLER_HEADER, SUF_HANDLER_REGISTRY, ) @@ -36,6 +37,7 @@ ) from sampletones_application.ui.elements.table.caret import CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells, pending_label +from sampletones_application.ui.elements.table.drag import DragGesture from sampletones_application.ui.panels.sequencer.channels import ( ChannelMenuLabels, ChannelSwitch, @@ -60,7 +62,7 @@ KeyRouter, ) from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS -from sampletones_application.utils.gui.keyboard.modifiers import Modifier +from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.utils.gui.tooltip import show_tooltip @@ -132,12 +134,15 @@ def __init__( self._position_count: int = 0 self._order: EditableCells[OrderKey] = EditableCells() self._input_state: OrderInputState = OrderInputState() + self._selection: FrozenSet[OrderKey] = frozenset() + self._drag: Optional[DragGesture[OrderKey]] = None self._highlighted: Optional[OrderCursor] = None self._highlighted_column: Optional[int] = None self._current_position: Optional[int] = None self._playing_position: Optional[int] = None self._cell_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_REGISTRY) self._label_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_HEADER) + self._drag_handler_tag = compose_tag(TAG_SEQUENCER_ORDER_TABLE, SUF_HANDLER_DRAG) self._label_rows: Dict[Sender, Optional[GeneratorName]] = {} self._entry_theme: int = 0 self._muted_entry_theme: int = 0 @@ -311,10 +316,17 @@ def _register_handlers(self) -> None: with dpg.item_handler_registry(tag=self._cell_handler_tag): dpg.add_item_clicked_handler(callback=self._on_cell_right_clicked) + dpg.add_item_active_handler(callback=self._on_cell_held) with dpg.item_handler_registry(tag=self._label_handler_tag): dpg.add_item_clicked_handler(callback=self._on_label_right_clicked) + with dpg.handler_registry(tag=self._drag_handler_tag): + dpg.add_mouse_click_handler( + button=dpg.mvMouseButton_Left, + callback=self._on_pointer_pressed, + ) + def update_order(self, view_model: SequencerOrderTrackerViewModel) -> None: """Reconciles the order table; rebuilds only when the position count changes.""" cell_values = self._compute_cell_values(view_model) @@ -365,6 +377,7 @@ def deselect_cell(self) -> None: self._clear_cursor_highlight() self._clear_column_highlight() self._input_state = OrderInputState() + self._repaint_selection() self._update_caret() if cursor is not None: @@ -446,11 +459,15 @@ def _rebuild_table( state (and any highlight keyed by column index) dangling, which corrupted the heap. Replacing the table item wholesale sidesteps that: the cursor and column highlights die with the old table, so nothing references freed - columns. + columns. The selected cells go with them, and :meth:`_restore_cursor` brings the + cursor back on its own — a table of another width is a table a region no longer + describes. """ dpg_delete_item(TAG_SEQUENCER_ORDER_TABLE) self._highlighted = None self._highlighted_column = None + self._selection = frozenset() + self._drag = None self._order.reset(cell_values) self._position_count = view_model.position_count self._build_table(view_model.position_count) @@ -702,6 +719,34 @@ def _apply_cursor_highlight(self, cursor: OrderCursor) -> None: ) self._highlighted = cursor + def _selected_cells(self) -> FrozenSet[OrderKey]: + """Every cell the selection covers, clipped to the positions the table holds.""" + region = self._input_state.region + if region is None: + return frozenset() + + keys: Set[OrderKey] = set() + for generator in region.generators: + for position in region.positions: + if position < self._position_count: + keys.add((generator, position)) + + return frozenset(keys) + + def _repaint_selection(self) -> None: + """Marks the cells the selection now covers and releases the ones it has left. + + A selected cell is drawn by the selectable's own selected state, which the order table's + theme colours, so a repaint reaches only the cells whose membership actually changed. + """ + selected = self._selected_cells() + for key in self._selection ^ selected: + widget = self._order.widget(key) + if widget is not None: + dpg.set_value(widget, key in selected) + + self._selection = selected + def _clear_cursor_highlight(self) -> None: if self._highlighted is None: return @@ -768,6 +813,7 @@ def _apply_state( if old is None or old.position != new.position: self.call(self.on_frame_selected, new.position) + self._repaint_selection() self._update_caret() self._refresh_remove_enabled() @@ -800,17 +846,126 @@ def _on_cell_clicked( _app_data: bool, user_data: OrderKey, ) -> None: + """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held. + + The click leaves the selectable holding whatever DearPyGui toggled it to, so the cell is + released here and its membership dropped: the repaint that follows is what states whether + the cell the user clicked belongs to the selection. + + A drag that comes back to the cell it started from ends on a click, and that click is the + end of the drag rather than a gesture of its own, so it leaves the selection standing. + """ dpg.set_value(sender, False) - self._committed_state() + self._selection -= {user_data} + if self._drag is not None and self._drag.moved: + self._drag = None + self._repaint_selection() + return + + state = self._committed_state() generator, position = user_data - self._apply_state( - OrderInputState( - cursor=OrderCursor( - generator, - position, - ) + cursor = OrderCursor(generator, position) + if Modifier.SHIFT in capture_modifiers(): + self._apply_state(state.extend_to(cursor)) + return + + self._apply_state(OrderInputState(cursor=cursor)) + + def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: + """Carries the selection to the cell under a held pointer, which is what drags a range out. + + DearPyGui reports no hover for the cells a held pointer passes over, so the cell the drag + has reached is read off the table's own geometry while the held cell names where the press + landed. A press that stays on its own cell is still a click, and the click itself is what + places the cursor there. + """ + if self._drag is None: + origin = self._order.key(app_data) + if origin is None: + return + + self._drag = DragGesture( + origin=origin, + extends=Modifier.SHIFT in capture_modifiers(), ) - ) + return + + reached = self._cell_at() + if reached is None or (reached == self._drag.origin and not self._drag.moved): + return + + self._drag.moved = True + state = self._committed_state() + if not self._drag.extends: + state = OrderInputState(cursor=OrderCursor(*self._drag.origin)) + + self._apply_state(state.extend_to(OrderCursor(*reached))) + + def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: + """Drops the gesture a finished drag left behind, so this press selects on its own. + + A press is where a gesture ends rather than the release before it, because the release + reaches this panel ahead of the click the cell itself reports: a drag that comes back to + the cell it started from would otherwise have its selection taken down by its own click. + """ + self._drag = None + + def _cell_at(self) -> Optional[OrderKey]: + """The cell the pointer stands on, clamped to the table the order lays out. + + A drag that runs past an edge reads as the edge itself, so carrying the pointer beyond + the last channel or the last position selects up to it rather than stopping there. + """ + left, top = dpg.get_mouse_pos(local=False) + position = self._position_at(left) + if position is None: + return None + + return (self._generator_at(top), position) + + def _generator_at(self, top: float) -> Optional[GeneratorName]: + """Which channel row stands at a height, the master row reading ``None``. + + The master row stands apart from the channels beneath it, so the walk asks each row where + it was drawn and takes the first one reaching past the pointer. + """ + for generator in CHANNEL_AXIS: + widget = self._order.widget((generator, 0)) + if widget is None: + continue + + _, row_top = dpg.get_item_rect_min(widget) + _, row_height = dpg.get_item_rect_size(widget) + if top < row_top + row_height: + return generator + + return CHANNEL_AXIS[-1] + + def _position_at(self, left: float) -> Optional[int]: + """Which position stands at a width, counted from the first cell's left edge. + + Every position column is the same width, so the count is arithmetic once two of them + state the pitch; an order of a single position holds every width there is. + """ + first = self._cell_left(0) + if first is None: + return None + + following = self._cell_left(1) + if following is None: + return 0 + + position = int((left - first) // (following - first)) + return max(0, min(position, self._position_count - 1)) + + def _cell_left(self, position: int) -> Optional[float]: + """Where a position column's cells begin, in the coordinates the viewport is drawn in.""" + widget = self._order.widget((None, position)) + if widget is None: + return None + + cell_left, _ = dpg.get_item_rect_min(widget) + return float(cell_left) def _on_cell_right_clicked( self, @@ -974,6 +1129,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._move_cursor(shortcut_id): return True + if self._extend_selection(shortcut_id): + return True + if self._edit_cell(shortcut_id): return True @@ -999,12 +1157,36 @@ def _move_cursor(self, shortcut_id: ShortcutId) -> bool: return True + def _extend_selection(self, shortcut_id: ShortcutId) -> bool: + """Grows or shrinks the selected block, reporting whether the action was one of its reaches. + + Each reach moves the end the cursor holds while the anchor stays where the selection began, + so the same keys that move the cursor select with Shift held. + """ + match shortcut_id: + case ShortcutId.ORDER_EXTEND_SELECTION_UP: + self._extend_channel(-1) + case ShortcutId.ORDER_EXTEND_SELECTION_DOWN: + self._extend_channel(1) + case ShortcutId.ORDER_EXTEND_SELECTION_LEFT: + self._extend_position(-1) + case ShortcutId.ORDER_EXTEND_SELECTION_RIGHT: + self._extend_position(1) + case ShortcutId.ORDER_EXTEND_SELECTION_TO_FIRST_POSITION: + self._extend_to_position(0) + case ShortcutId.ORDER_EXTEND_SELECTION_TO_LAST_POSITION: + self._extend_to_position(self._position_count - 1) + case _: + return False + + return True + def _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. + A cancel with nothing typed and nothing selected leaves the press to the application, so + Escape stops playback while the table holds a cursor. """ match shortcut_id: case ShortcutId.ORDER_CLEAR_CELL: @@ -1014,7 +1196,7 @@ def _edit_cell(self, shortcut_id: ShortcutId) -> bool: self._clear_cell() self._move_position(-1) case ShortcutId.ORDER_CANCEL_ENTRY: - if not self._input_state.pending: + if not self._input_state.pending and self._input_state.anchor is None: return False self._apply_state(self._input_state.cancel()) @@ -1075,6 +1257,26 @@ def _jump_position(self, index: int) -> None: def _move_channel(self, delta: int) -> None: self._apply_state(self._committed_state().navigate_channel(delta)) + def _extend_position(self, delta: int) -> None: + self._apply_state( + self._committed_state().extend_position( + delta, + self._position_count, + ), + ) + + def _extend_to_position(self, index: int) -> None: + self._apply_state( + self._committed_state().extend_position( + index, + self._position_count, + absolute=True, + ), + ) + + def _extend_channel(self, delta: int) -> None: + self._apply_state(self._committed_state().extend_channel(delta)) + def _committed_state(self) -> OrderInputState: state, index = self._input_state.commit_partial() if index is not None: diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index f54d0e286..302771672 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Final, Optional, Tuple +from typing import Callable, Dict, Final, FrozenSet, Optional, Set, Tuple import dearpygui.dearpygui as dpg @@ -10,6 +10,7 @@ from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( + SUF_HANDLER_DRAG, SUF_HANDLER_HEADER, SUF_HANDLER_REGISTRY, ) @@ -29,6 +30,7 @@ from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.table.caret import CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.elements.table.drag import DragGesture from sampletones_application.ui.panels.sequencer import display as tracker_display from sampletones_application.ui.panels.sequencer.channels import ( ChannelMenuLabels, @@ -55,6 +57,7 @@ from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background from sampletones_application.ui.themes.inline import ( create_header_selectable_theme, + create_label_selectable_theme, create_selectable_text_theme, ) from sampletones_application.ui.themes.registry import ThemeRegistry @@ -67,7 +70,7 @@ KeyRouter, ) 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.keyboard.modifiers import Modifier, capture_modifiers from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.utils.gui.tooltip import show_tooltip @@ -83,6 +86,11 @@ from sampletones_application.view_model.sequencer.settings import ( SequencerSettingsViewModel, ) +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + TrackerSlot, + slot_from_flat, +) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.sequencer.tracker import ( SequencerRowViewModel, @@ -142,6 +150,7 @@ def __init__( self._item_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_PANEL, SUF_HANDLER_REGISTRY) self._cell_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_REGISTRY) self._header_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_HEADER) + self._drag_handler_tag = compose_tag(TAG_SEQUENCER_TRACKER_TABLE, SUF_HANDLER_DRAG) self._rows: Dict[Optional[int], Sender] = {} self._header_columns: Dict[Sender, Optional[GeneratorName]] = {} @@ -154,11 +163,14 @@ def __init__( self._painted_row: Optional[int] = None self._follows_playing_row: bool = False self._input_state: TrackerInputState = TrackerInputState() + self._selection: FrozenSet[CellKey] = frozenset() + self._drag: Optional[DragGesture[CellKey]] = None self._subcolumn_themes: Dict[SubColumn, int] = {} self._muted_subcolumn_themes: Dict[SubColumn, int] = {} self._row_number_theme: int = 0 self._header_theme: int = 0 self._muted_header_theme: int = 0 + self._column_label_theme: int = 0 self._current_samples: Optional[SequencerSamplesViewModel] = None self._current_channels: Optional[SequencerChannelsViewModel] = None @@ -284,10 +296,17 @@ def _setup_handlers(self) -> None: with dpg.item_handler_registry(tag=self._cell_handler_tag): dpg.add_item_clicked_handler(callback=self._on_cell_right_clicked) + dpg.add_item_active_handler(callback=self._on_cell_held) with dpg.item_handler_registry(tag=self._header_handler_tag): dpg.add_item_clicked_handler(callback=self._on_header_right_clicked) + with dpg.handler_registry(tag=self._drag_handler_tag): + dpg.add_mouse_click_handler( + button=dpg.mvMouseButton_Left, + callback=self._on_pointer_pressed, + ) + self._router.register( self._on_key_pressed, priority=PRIORITY_PANEL, @@ -338,6 +357,7 @@ def _create_header_themes(self) -> None: header.hovered, header.active, ) + self._column_label_theme = create_label_selectable_theme(self._layout.colors.label) def _create_tracker_view(self, parent: str) -> None: """Builds the tracker card and the empty table its rows are filled into. @@ -445,8 +465,14 @@ def _rebuild_table( A table repopulated this frame reports the scroll extent of the body it replaced, so the reveal is repeated a frame later, when DearPyGui has measured the rows now in it. + + The frame the grid stands on has a row count of its own, so a selection is taken down to + its cursor: the cells it covered belong to the body being replaced. """ dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) + self._input_state = self._input_state.collapse() + self._selection = frozenset() + self._drag = None self._editable_cells.reset(cell_values) self._build_table(view_model) self.repaint() @@ -649,9 +675,18 @@ def _build_header_row(self) -> None: self._add_header_selectable(row_id, generator) def _add_header_label_cell(self, row_id: Sender) -> None: - """Places the row-number column's label, which names a column the user reads only.""" + """Places the row-number column's label, which names a column the user reads only. + + It is laid out as a selectable like the labels beside it, so it takes the header's + height and sits on their line; its own theme leaves it reading as text. + """ label_cell = dpg.add_table_cell(parent=row_id) - dpg.add_text(self._lbl_col_row, parent=label_cell) + label = dpg.add_selectable( + parent=label_cell, + label=self._lbl_col_row, + height=self._layout.tracker.header_height, + ) + dpg.bind_item_theme(label, self._column_label_theme) def _add_header_selectable( self, @@ -669,6 +704,7 @@ def _add_header_selectable( selectable = dpg.add_selectable( parent=header_cell, label=self._column_labels[generator], + height=self._layout.tracker.header_height, user_data=generator, callback=self._on_header_clicked, ) @@ -706,6 +742,7 @@ def _add_row_number_cell(self, row_id: Sender, row_index: int) -> None: selectable = dpg.add_selectable( parent=number_cell, label=display_id(row_index), + height=self._layout.tracker.row_height, user_data=row_index, callback=self._on_row_number_clicked, ) @@ -749,6 +786,7 @@ def _add_subcolumn_selectable( parent=group, label=self._render_cell(key), width=self._subcolumn_widths[subcolumn], + height=self._layout.tracker.row_height, user_data=key, callback=self._on_cell_clicked, ) @@ -765,6 +803,7 @@ def _update_cursor(self) -> None: else: self._input_state = TrackerInputState() + self._repaint_selection() self._update_caret() def deselect_cell(self) -> None: @@ -772,6 +811,7 @@ def deselect_cell(self) -> None: if cursor is not None: self._input_state = TrackerInputState() self._remove_cell_highlight(cursor.row, cursor.generator) + self._repaint_selection() self._update_caret() @@ -798,6 +838,7 @@ def _apply_state(self, new_state: TrackerInputState) -> None: if new_pos != old_pos and new_cursor is not None: self.call(self.on_cell_selected) + self._repaint_selection() self._update_caret() def update_samples(self, view_model: SequencerSamplesViewModel) -> None: @@ -968,6 +1009,40 @@ def _apply_cell_highlight( color=self._layout.colors.cell_cursor.rgba, ) + def _selected_cells(self) -> FrozenSet[CellKey]: + """Every cell the selection covers, clipped to the rows the shown frame holds. + + A region names rows of the grid rather than widgets, so a row past the end of a shorter + frame is left out: the selection reaches as far as the pattern does. + """ + region = self._input_state.region + if region is None: + return frozenset() + + keys: Set[CellKey] = set() + for row_index in region.rows: + if row_index >= self._current_row_count: + continue + + for slot in region.slots: + keys.add((row_index, slot.generator, slot.subcolumn)) + + return frozenset(keys) + + def _repaint_selection(self) -> None: + """Marks the cells the selection now covers and releases the ones it has left. + + A selected cell is drawn by the selectable's own selected state, which the pattern table's + theme colours, so a repaint reaches only the cells whose membership actually changed. + """ + selected = self._selected_cells() + for key in self._selection ^ selected: + widget = self._editable_cells.widget(key) + if widget is not None: + dpg.set_value(widget, key in selected) + + self._selection = selected + def _remove_cell_highlight( self, row_index: int, @@ -991,14 +1066,118 @@ def _on_cell_clicked( _app_data: bool, user_data: Tuple[int, Optional[GeneratorName], SubColumn], ) -> None: + """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held. + + The click leaves the selectable holding whatever DearPyGui toggled it to, so the cell is + released here and its membership dropped: the repaint that follows is what states whether + the cell the user clicked belongs to the selection. + + A drag that comes back to the cell it started from ends on a click, and that click is the + end of the drag rather than a gesture of its own, so it leaves the selection standing. + """ dpg.set_value(sender, False) - self._committed_state() + self._selection -= {user_data} + if self._drag is not None and self._drag.moved: + self._drag = None + self._repaint_selection() + return + + state = self._committed_state() row_index, generator, subcolumn = user_data - new_state = TrackerInputState( - cursor=TrackerCursor(row_index, generator, subcolumn), - pending="", - ) - self._apply_state(new_state) + cursor = TrackerCursor(row_index, generator, subcolumn) + if Modifier.SHIFT in capture_modifiers(): + self._apply_state(state.extend_to(cursor)) + return + + self._apply_state(TrackerInputState(cursor=cursor, pending="")) + + def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: + """Carries the selection to the cell under a held pointer, which is what drags a range out. + + DearPyGui reports no hover for the cells a held pointer passes over, so the cell the drag + has reached is read off the grid's own geometry while the held cell names where the press + landed. A press that stays on its own cell is still a click, and the click itself is what + places the cursor there. + """ + if self._drag is None: + origin = self._editable_cells.key(app_data) + if origin is None: + return + + self._drag = DragGesture( + origin=origin, + extends=Modifier.SHIFT in capture_modifiers(), + ) + return + + reached = self._cell_at() + if reached is None or (reached == self._drag.origin and not self._drag.moved): + return + + self._drag.moved = True + state = self._committed_state() + if not self._drag.extends: + state = TrackerInputState(cursor=TrackerCursor(*self._drag.origin)) + + self._apply_state(state.extend_to(TrackerCursor(*reached))) + + def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: + """Drops the gesture a finished drag left behind, so this press selects on its own. + + A press is where a gesture ends rather than the release before it, because the release + reaches this panel ahead of the click the cell itself reports: a drag that comes back to + the cell it started from would otherwise have its selection taken down by its own click. + """ + self._drag = None + + def _cell_at(self) -> Optional[CellKey]: + """The cell the pointer stands on, clamped to the grid the shown frame lays out. + + A drag that runs past an edge reads as the edge itself, so carrying the pointer beyond + the last row or the last column selects up to it rather than stopping where the grid ends. + """ + left, top = dpg.get_mouse_pos(local=False) + row_index = self._row_at(top) + slot = self._slot_at(left) + if row_index is None or slot is None: + return None + + return (row_index, slot.generator, slot.subcolumn) + + def _row_at(self, top: float) -> Optional[int]: + """Which pattern row stands at a height, counted from the first row's top edge. + + Every row is the height the layout states, so the count is arithmetic: the rows the + grid holds are evenly pitched whether or not they are scrolled into view. + """ + first = self._row_top(0) + if first is None or self._current_row_count == 0: + return None + + row_index = int((top - first) // self._layout.tracker.row_height) + return max(0, min(row_index, self._current_row_count - 1)) + + def _slot_at(self, left: float) -> Optional[TrackerSlot]: + """Which subcolumn stands at a width, taken from where the first row's cells are drawn. + + The subcolumns differ in width and the columns stand apart, so the walk asks each cell + where it was drawn and takes the first one reaching past the pointer. + """ + if self._current_row_count == 0: + return None + + for index in range(SLOT_COUNT): + slot = slot_from_flat(index) + widget = self._editable_cells.widget((0, slot.generator, slot.subcolumn)) + if widget is None: + return None + + cell_left, _ = dpg.get_item_rect_min(widget) + cell_width, _ = dpg.get_item_rect_size(widget) + if left < cell_left + cell_width: + return slot + + return slot_from_flat(SLOT_COUNT - 1) def _on_header_clicked( self, @@ -1243,6 +1422,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._move_cursor(shortcut_id): return True + if self._extend_selection(shortcut_id): + return True + return self._edit_row(shortcut_id) def _move_cursor(self, shortcut_id: ShortcutId) -> bool: @@ -1273,12 +1455,36 @@ def _move_cursor(self, shortcut_id: ShortcutId) -> bool: return True + def _extend_selection(self, shortcut_id: ShortcutId) -> bool: + """Grows or shrinks the selected block, reporting whether the action was one of its reaches. + + Each reach moves the end the cursor holds while the anchor stays where the selection began, + so the same keys that move the cursor select with Shift held. + """ + match shortcut_id: + case ShortcutId.TRACKER_EXTEND_SELECTION_UP: + self._extend_row(-1) + case ShortcutId.TRACKER_EXTEND_SELECTION_DOWN: + self._extend_row(1) + case ShortcutId.TRACKER_EXTEND_SELECTION_LEFT: + self._extend_slot(-1) + case ShortcutId.TRACKER_EXTEND_SELECTION_RIGHT: + self._extend_slot(1) + case ShortcutId.TRACKER_EXTEND_SELECTION_TO_FIRST_ROW: + self._extend_to_row(0) + case ShortcutId.TRACKER_EXTEND_SELECTION_TO_LAST_ROW: + self._extend_to_row(self._current_row_count - 1) + case _: + return False + + return True + def _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. + A cancel with nothing typed and nothing selected leaves the press to the application, so + Escape stops playback while the grid holds a cursor. """ match shortcut_id: case ShortcutId.TRACKER_CLEAR_ROW: @@ -1288,7 +1494,7 @@ def _edit_row(self, shortcut_id: ShortcutId) -> bool: self._clear_row() self._move_row(-1) case ShortcutId.TRACKER_CANCEL_ENTRY: - if not self._input_state.pending: + if not self._input_state.pending and self._input_state.anchor is None: return False self._apply_state(self._input_state.cancel()) @@ -1320,6 +1526,27 @@ def _jump_to_row(self, index: int) -> None: ) self._scroll_cursor_into_view() + def _extend_row(self, delta: int) -> None: + self._apply_state( + self._committed_state().extend_row( + delta, + self._current_row_count, + ) + ) + + def _extend_to_row(self, index: int) -> None: + self._apply_state( + self._committed_state().extend_row( + index, + self._current_row_count, + absolute=True, + ) + ) + self._scroll_cursor_into_view() + + def _extend_slot(self, delta: int) -> None: + self._apply_state(self._committed_state().extend_slot(delta)) + def _move_subcolumn(self, delta: int) -> None: self._apply_state(self._committed_state().navigate_subcolumn(delta)) diff --git a/src/sampletones_application/ui/themes/dpg_constants.py b/src/sampletones_application/ui/themes/dpg_constants.py index 701b0614e..5eed2b854 100644 --- a/src/sampletones_application/ui/themes/dpg_constants.py +++ b/src/sampletones_application/ui/themes/dpg_constants.py @@ -103,6 +103,7 @@ "PopupRounding": dpg.mvStyleVar_PopupRounding, "ScrollbarRounding": dpg.mvStyleVar_ScrollbarRounding, "ScrollbarSize": dpg.mvStyleVar_ScrollbarSize, + "SelectableTextAlign": dpg.mvStyleVar_SelectableTextAlign, "TabRounding": dpg.mvStyleVar_TabRounding, "WindowBorderSize": dpg.mvStyleVar_WindowBorderSize, "WindowPadding": dpg.mvStyleVar_WindowPadding, diff --git a/src/sampletones_application/ui/themes/inline.py b/src/sampletones_application/ui/themes/inline.py index 30ce8d06d..de518002c 100644 --- a/src/sampletones_application/ui/themes/inline.py +++ b/src/sampletones_application/ui/themes/inline.py @@ -4,6 +4,7 @@ from sampletones_application.utils.gui.palette.dpg import dpg_add_palette_theme_color from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.palette.colors.faded import FadedColor def create_selectable_text_theme(color: BaseColor) -> int: @@ -31,6 +32,24 @@ def create_header_selectable_theme( ) +def create_label_selectable_theme(color: BaseColor) -> int: + """Builds a theme for a selectable that carries a label rather than a gesture. + + Every header wash takes the label's own colour at zero alpha, so the cell reads as plain + text while it keeps the layout a selectable lays out with, which is what lets it line up + with the clickable labels beside it. + """ + washed_out = FadedColor(color=color, fraction=0.0) + return _create_selectable_theme( + { + dpg.mvThemeCol_Text: color, + dpg.mvThemeCol_Header: washed_out, + dpg.mvThemeCol_HeaderHovered: washed_out, + dpg.mvThemeCol_HeaderActive: washed_out, + }, + ) + + def _create_selectable_theme(colors: Dict[int, BaseColor]) -> int: """Builds a theme carrying ``colors`` for a selectable in both enabled states. diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index f830b80b2..389714288 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -95,6 +95,18 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: ORDER_NEXT_CHANNEL = ("OrderNextChannel", ShortcutCategory.ORDER) ORDER_FIRST_POSITION = ("OrderFirstPosition", ShortcutCategory.ORDER) ORDER_LAST_POSITION = ("OrderLastPosition", ShortcutCategory.ORDER) + ORDER_EXTEND_SELECTION_UP = ("OrderExtendSelectionUp", ShortcutCategory.ORDER) + ORDER_EXTEND_SELECTION_DOWN = ("OrderExtendSelectionDown", ShortcutCategory.ORDER) + ORDER_EXTEND_SELECTION_LEFT = ("OrderExtendSelectionLeft", ShortcutCategory.ORDER) + ORDER_EXTEND_SELECTION_RIGHT = ("OrderExtendSelectionRight", ShortcutCategory.ORDER) + ORDER_EXTEND_SELECTION_TO_FIRST_POSITION = ( + "OrderExtendSelectionToFirstPosition", + ShortcutCategory.ORDER, + ) + ORDER_EXTEND_SELECTION_TO_LAST_POSITION = ( + "OrderExtendSelectionToLastPosition", + ShortcutCategory.ORDER, + ) ORDER_MOVE_FRAME_LEFT = ("OrderMoveFrameLeft", ShortcutCategory.ORDER) ORDER_MOVE_FRAME_RIGHT = ("OrderMoveFrameRight", ShortcutCategory.ORDER) ORDER_MOVE_FRAME_TO_START = ("OrderMoveFrameToStart", ShortcutCategory.ORDER) @@ -117,6 +129,18 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: TRACKER_NEXT_COLUMN = ("TrackerNextColumn", ShortcutCategory.TRACKER) TRACKER_FIRST_ROW = ("TrackerFirstRow", ShortcutCategory.TRACKER) TRACKER_LAST_ROW = ("TrackerLastRow", ShortcutCategory.TRACKER) + TRACKER_EXTEND_SELECTION_UP = ("TrackerExtendSelectionUp", ShortcutCategory.TRACKER) + TRACKER_EXTEND_SELECTION_DOWN = ("TrackerExtendSelectionDown", ShortcutCategory.TRACKER) + TRACKER_EXTEND_SELECTION_LEFT = ("TrackerExtendSelectionLeft", ShortcutCategory.TRACKER) + TRACKER_EXTEND_SELECTION_RIGHT = ("TrackerExtendSelectionRight", ShortcutCategory.TRACKER) + TRACKER_EXTEND_SELECTION_TO_FIRST_ROW = ( + "TrackerExtendSelectionToFirstRow", + ShortcutCategory.TRACKER, + ) + TRACKER_EXTEND_SELECTION_TO_LAST_ROW = ( + "TrackerExtendSelectionToLastRow", + ShortcutCategory.TRACKER, + ) TRACKER_PAGE_UP = ("TrackerPageUp", ShortcutCategory.TRACKER) TRACKER_PAGE_DOWN = ("TrackerPageDown", ShortcutCategory.TRACKER) TRACKER_CLEAR_ROW = ("TrackerClearRow", ShortcutCategory.TRACKER) diff --git a/src/sampletones_application/view_model/sequencer/region.py b/src/sampletones_application/view_model/sequencer/region.py new file mode 100644 index 000000000..63cac2f1d --- /dev/null +++ b/src/sampletones_application/view_model/sequencer/region.py @@ -0,0 +1,121 @@ +from typing import Optional, Self, Tuple + +from pydantic import BaseModel, Field, model_validator + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + TrackerSlot, + slot_from_flat, +) +from sampletones_core.constants.enums import GeneratorName + + +class TrackerCell(BaseModel, frozen=True): + """The tracker cell a block is written from: a row, and the column it starts in. + + A block carries the subcolumn offsets it was read at, so the cell it is anchored to names a + row and a column while the block supplies the rest. That is why a cell states no subcolumn: + the anchor decides where a block lands, and the block decides which kind of value goes where. + """ + + row: int = Field(ge=0) + generator: Optional[GeneratorName] + + +class OrderCell(BaseModel, frozen=True): + """The order cell a block is written from: a channel row, and the position it starts in.""" + + generator: Optional[GeneratorName] + position: int = Field(ge=0) + + +class GridRegion(BaseModel, frozen=True): + """The rows a selection covers, shared by both sequencer grids. + + Both bounds are inclusive, so a region always covers the cell it was started from and the + smallest one covers exactly that cell. A producer orders the bounds it was given, which is + what makes a selection dragged upwards name the same region as one dragged down to the same + pair of cells. + """ + + first_row: int = Field(ge=0) + last_row: int = Field(ge=0) + + @model_validator(mode="after") + def _validate_rows(self) -> Self: + if self.last_row < self.first_row: + raise ValueError(f"A region's rows end at {self.last_row}, before they begin at {self.first_row}") + + return self + + @property + def row_count(self) -> int: + return self.last_row - self.first_row + 1 + + @property + def rows(self) -> range: + return range(self.first_row, self.last_row + 1) + + +class TrackerRegion(GridRegion, frozen=True): + """A rectangle of the tracker grid: pattern rows crossed with a run of slots. + + The slots are indices into the flattened axis :data:`SLOT_COUNT` spans, so one region reaches + across the sample column and the channels alike and names the subcolumn it begins and ends on. + That is what lets a selection start midway through a cell: its edges are subcolumns. + """ + + first_slot: int = Field(ge=0, lt=SLOT_COUNT) + last_slot: int = Field(ge=0, lt=SLOT_COUNT) + + @model_validator(mode="after") + def _validate_slots(self) -> Self: + if self.last_slot < self.first_slot: + raise ValueError(f"A region's slots end at {self.last_slot}, before they begin at {self.first_slot}") + + return self + + @property + def slot_count(self) -> int: + return self.last_slot - self.first_slot + 1 + + @property + def slots(self) -> Tuple[TrackerSlot, ...]: + """The slots the region covers, each as the column and subcolumn it addresses.""" + return tuple(slot_from_flat(index) for index in range(self.first_slot, self.last_slot + 1)) + + +class OrderRegion(GridRegion, frozen=True): + """A rectangle of the order table: channel rows crossed with a run of positions. + + The rows are indices into :data:`CHANNEL_AXIS`, so row ``0`` is the master row and the + channels follow it in the order the table lays them out. + """ + + first_row: int = Field(ge=0, lt=len(CHANNEL_AXIS)) + last_row: int = Field(ge=0, lt=len(CHANNEL_AXIS)) + first_position: int = Field(ge=0) + last_position: int = Field(ge=0) + + @model_validator(mode="after") + def _validate_positions(self) -> Self: + if self.last_position < self.first_position: + raise ValueError( + f"A region's positions end at {self.last_position}, before they begin at {self.first_position}" + ) + + return self + + @property + def position_count(self) -> int: + return self.last_position - self.first_position + 1 + + @property + def positions(self) -> range: + return range(self.first_position, self.last_position + 1) + + @property + def generators(self) -> Tuple[Optional[GeneratorName], ...]: + """The rows the region covers, each as the channel it addresses, master reading ``None``.""" + return tuple(CHANNEL_AXIS[row] for row in self.rows) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 02946c4f4..aef2c047e 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -66,6 +66,12 @@ bindings: OrderNextChannel: {combination: "Down"} OrderFirstPosition: {combination: "Home"} OrderLastPosition: {combination: "End"} + OrderExtendSelectionUp: {combination: "Shift+Up"} + OrderExtendSelectionDown: {combination: "Shift+Down"} + OrderExtendSelectionLeft: {combination: "Shift+Left"} + OrderExtendSelectionRight: {combination: "Shift+Right"} + OrderExtendSelectionToFirstPosition: {combination: "Shift+Home"} + OrderExtendSelectionToLastPosition: {combination: "Shift+End"} OrderMoveFrameLeft: {combination: "Alt+Left"} OrderMoveFrameRight: {combination: "Alt+Right"} OrderMoveFrameToStart: {combination: "Alt+Home"} @@ -89,6 +95,12 @@ bindings: TrackerNextColumn: {combination: "Tab"} TrackerFirstRow: {combination: "Home"} TrackerLastRow: {combination: "End"} + TrackerExtendSelectionUp: {combination: "Shift+Up"} + TrackerExtendSelectionDown: {combination: "Shift+Down"} + TrackerExtendSelectionLeft: {combination: "Shift+Left"} + TrackerExtendSelectionRight: {combination: "Shift+Right"} + TrackerExtendSelectionToFirstRow: {combination: "Shift+Home"} + TrackerExtendSelectionToLastRow: {combination: "Shift+End"} TrackerPageUp: {combination: "PgUp"} TrackerPageDown: {combination: "PgDn"} TrackerClearRow: {combination: "Del"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index c0e3e2f0a..51a9bbbcc 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -66,6 +66,12 @@ bindings: OrderNextChannel: {combination: "Down"} OrderFirstPosition: {combination: "Home", aliases: ["Cmd+Left"]} OrderLastPosition: {combination: "End", aliases: ["Cmd+Right"]} + OrderExtendSelectionUp: {combination: "Shift+Up"} + OrderExtendSelectionDown: {combination: "Shift+Down"} + OrderExtendSelectionLeft: {combination: "Shift+Left"} + OrderExtendSelectionRight: {combination: "Shift+Right"} + OrderExtendSelectionToFirstPosition: {combination: "Shift+Home", aliases: ["Cmd+Shift+Left"]} + OrderExtendSelectionToLastPosition: {combination: "Shift+End", aliases: ["Cmd+Shift+Right"]} OrderMoveFrameLeft: {combination: "Alt+Left"} OrderMoveFrameRight: {combination: "Alt+Right"} OrderMoveFrameToStart: {combination: "Alt+Home", aliases: ["Cmd+Alt+Left"]} @@ -89,6 +95,12 @@ bindings: TrackerNextColumn: {combination: "Tab"} TrackerFirstRow: {combination: "Home", aliases: ["Cmd+Up"]} TrackerLastRow: {combination: "End", aliases: ["Cmd+Down"]} + TrackerExtendSelectionUp: {combination: "Shift+Up"} + TrackerExtendSelectionDown: {combination: "Shift+Down"} + TrackerExtendSelectionLeft: {combination: "Shift+Left"} + TrackerExtendSelectionRight: {combination: "Shift+Right"} + TrackerExtendSelectionToFirstRow: {combination: "Shift+Home", aliases: ["Cmd+Shift+Up"]} + TrackerExtendSelectionToLastRow: {combination: "Shift+End", aliases: ["Cmd+Shift+Down"]} TrackerPageUp: {combination: "PgUp", aliases: ["Alt+Up"]} TrackerPageDown: {combination: "PgDn", aliases: ["Alt+Down"]} TrackerClearRow: {combination: "Del", aliases: ["Cmd+Backspace"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index ca4faa1bc..319798b8b 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -772,6 +772,12 @@ settings.keybindings.label.order_previous_channel: "Previous channel" settings.keybindings.label.order_next_channel: "Next channel" settings.keybindings.label.order_first_position: "First position" settings.keybindings.label.order_last_position: "Last position" +settings.keybindings.label.order_extend_selection_up: "Extend selection up" +settings.keybindings.label.order_extend_selection_down: "Extend selection down" +settings.keybindings.label.order_extend_selection_left: "Extend selection left" +settings.keybindings.label.order_extend_selection_right: "Extend selection right" +settings.keybindings.label.order_extend_selection_to_first_position: "Extend selection to the first position" +settings.keybindings.label.order_extend_selection_to_last_position: "Extend selection to the last position" settings.keybindings.label.order_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" @@ -793,6 +799,12 @@ settings.keybindings.label.tracker_previous_column: "Previous column" settings.keybindings.label.tracker_next_column: "Next column" settings.keybindings.label.tracker_first_row: "First row" settings.keybindings.label.tracker_last_row: "Last row" +settings.keybindings.label.tracker_extend_selection_up: "Extend selection up" +settings.keybindings.label.tracker_extend_selection_down: "Extend selection down" +settings.keybindings.label.tracker_extend_selection_left: "Extend selection left" +settings.keybindings.label.tracker_extend_selection_right: "Extend selection right" +settings.keybindings.label.tracker_extend_selection_to_first_row: "Extend selection to the first row" +settings.keybindings.label.tracker_extend_selection_to_last_row: "Extend selection to the last row" settings.keybindings.label.tracker_page_up: "Page up" settings.keybindings.label.tracker_page_down: "Page down" settings.keybindings.label.tracker_clear_row: "Clear row" diff --git a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml index 34c6001a1..ffc48a358 100644 --- a/src/sampletones_config/layout/tabs/sequencer/tracker.yaml +++ b/src/sampletones_config/layout/tabs/sequencer/tracker.yaml @@ -1,5 +1,7 @@ rows: 64 page_size: 16 +row_height: 29 +header_height: 30 channel_column_tint: 0.09 muted_text_fraction: 0.45 subcolumn_widths: diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index 1a83491e4..6d8d9580d 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -166,6 +166,7 @@ colors: tracker_beat_row: "#ffffff14" tracker_bar_row: "#ffffff28" + block_selection: "#b98af360" pattern_highlight: "#ffffff40" cell_cursor: "#4fa6ffb0" cursor_row: "#4fa6ff3a" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index 355c44f4d..e72dc7ff9 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -166,6 +166,7 @@ colors: tracker_beat_row: "#00000016" tracker_bar_row: "#0000002c" + block_selection: "#6b4ea840" pattern_highlight: "#00000018" cell_cursor: "#0b4c8c60" cursor_row: "#0b4c8c24" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index e97db6577..a92a7027f 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -166,6 +166,7 @@ colors: tracker_beat_row: "#ffffff14" tracker_bar_row: "#ffffff26" + block_selection: "#b98af360" pattern_highlight: "#ffffff40" cell_cursor: "#66bbffa0" cursor_row: "#ffffff18" diff --git a/src/sampletones_config/theme/tables/order.yaml b/src/sampletones_config/theme/tables/order.yaml index 3e34fb310..71a8af6e8 100644 --- a/src/sampletones_config/theme/tables/order.yaml +++ b/src/sampletones_config/theme/tables/order.yaml @@ -10,3 +10,8 @@ components: - type: color key: HeaderActive value: .transparent + - item_type: Selectable + entries: + - type: color + key: Header + value: .block_selection diff --git a/src/sampletones_config/theme/tables/pattern.yaml b/src/sampletones_config/theme/tables/pattern.yaml index 5f9ccc2a4..1b0d55127 100644 --- a/src/sampletones_config/theme/tables/pattern.yaml +++ b/src/sampletones_config/theme/tables/pattern.yaml @@ -7,7 +7,7 @@ components: - type: style key: CellPadding x: 3 - y: 4 + y: 0 - type: color key: HeaderHovered value: .overlay/0.25 @@ -20,6 +20,19 @@ components: - type: color key: TableRowBgAlt value: .table_row + - item_type: Selectable + entries: + - type: style + key: ItemSpacing + x: 0 + y: 0 + - type: style + key: SelectableTextAlign + x: 0 + y: 0.5 + - type: color + key: Header + value: .block_selection - item_type: All entries: - type: color diff --git a/tests/unit/sampletones_application/logic/sequencer/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py similarity index 100% rename from tests/unit/sampletones_application/logic/sequencer/test_tracker.py rename to tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index 86ce83f2f..4ae8875af 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -7,6 +7,8 @@ ) from sampletones_core.constants.enums import GeneratorName +POSITION_COUNT = 8 + def _state( generator: Optional[GeneratorName] = GeneratorName.PULSE1, @@ -40,6 +42,74 @@ def test_channel_cycles_master_then_channels_and_wraps(self) -> None: assert state.cursor.generator == CHANNEL_AXIS[0] +class TestSelection: + """Shift-extended moves grow a region from the cell the selection was started on.""" + + def test_a_state_without_an_anchor_covers_no_region(self) -> None: + assert _state().region is None + + def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: + extended = _state(position=2).extend_position(1, POSITION_COUNT) + + region = extended.region + assert region is not None + assert (region.first_position, region.last_position) == (2, 3) + assert region.position_count == 2 + + def test_extending_leftwards_names_the_same_region_as_rightwards(self) -> None: + leftwards = _state(position=3).extend_position(-1, POSITION_COUNT).region + rightwards = _state(position=2).extend_position(1, POSITION_COUNT).region + + assert leftwards == rightwards + + def test_extending_channels_reaches_from_master_down(self) -> None: + extended = _state(generator=None).extend_channel(2) + + region = extended.region + assert region is not None + assert region.generators == (None, GeneratorName.PULSE1, GeneratorName.PULSE2) + + def test_extending_channels_stops_at_either_end_of_the_axis(self) -> None: + """A selection covers a run of the table, so its reach stops where plain navigation wraps.""" + first = _state(generator=CHANNEL_AXIS[0]).extend_channel(-1) + last = _state(generator=CHANNEL_AXIS[-1]).extend_channel(1) + + assert first.cursor == OrderCursor(CHANNEL_AXIS[0], 0) + assert last.cursor == OrderCursor(CHANNEL_AXIS[-1], 0) + + def test_a_plain_move_collapses_the_selection(self) -> None: + moved = _state(position=1).extend_position(2, POSITION_COUNT).navigate_position(1, POSITION_COUNT) + + assert moved.anchor is None + assert moved.region is None + + def test_a_plain_channel_move_collapses_the_selection(self) -> None: + moved = _state().extend_position(1, POSITION_COUNT).navigate_channel(1) + + assert moved.region is None + + def test_dropping_a_partial_entry_holds_the_selection(self) -> None: + held = _state(pending="5").extend_position(1, POSITION_COUNT).reset_pending() + + assert held.region is not None + + def test_typing_an_index_collapses_the_selection(self) -> None: + selected = _state(position=1).extend_position(2, POSITION_COUNT) + + partial, first = selected.type_char("0") + committed, index = partial.type_char("2") + + assert first is None + assert index == 2 + assert committed.region is None + + def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: + cancelled = _state(pending="5").extend_position(1, POSITION_COUNT).cancel() + + assert cancelled.region is None + assert cancelled.pending == "" + + class TestEntry: def test_type_char_commits_after_two_digits(self) -> None: partial, first = _state().type_char("A") diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index cd12a80ca..34644f673 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -5,6 +5,8 @@ from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName +ROW_COUNT = 64 + def _state( subcolumn: SubColumn, @@ -37,6 +39,101 @@ def test_minus_in_transpose_is_a_sign_not_note_off(self) -> None: assert new_state.pending.startswith("-") +class TestSelection: + """Shift-extended moves grow a region from the cell the selection was started on.""" + + def test_a_state_without_an_anchor_covers_no_region(self) -> None: + assert _state(SubColumn.INSTRUMENT).region is None + + def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: + extended = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT) + + region = extended.region + assert region is not None + assert (region.first_row, region.last_row) == (4, 5) + assert region.row_count == 2 + + def test_extending_upwards_names_the_same_region_as_downwards(self) -> None: + """The bounds are ordered by the region, so the direction of the drag leaves no trace.""" + upwards = _state(SubColumn.INSTRUMENT, row=5).extend_row(-1, ROW_COUNT).region + downwards = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT).region + + assert upwards == downwards + + def test_a_further_extend_keeps_the_original_anchor(self) -> None: + extended = _state(SubColumn.INSTRUMENT, row=4).extend_row(1, ROW_COUNT).extend_row(3, ROW_COUNT) + + region = extended.region + assert region is not None + assert (region.first_row, region.last_row) == (4, 8) + + def test_extending_slots_reaches_across_the_column_boundary(self) -> None: + extended = _state(SubColumn.VOLUME, generator=None).extend_slot(1) + + region = extended.region + assert region is not None + assert (region.first_slot, region.last_slot) == (2, 3) + assert extended.cursor is not None + assert extended.cursor.generator is GeneratorName.PULSE1 + assert extended.cursor.subcolumn is SubColumn.INSTRUMENT + + def test_extending_slots_stops_at_either_end_of_the_axis(self) -> None: + """A selection covers a run of the grid, so its reach stops where plain navigation wraps.""" + first = _state(SubColumn.INSTRUMENT, generator=None).extend_slot(-1) + last = _state(SubColumn.VOLUME, generator=GeneratorName.NOISE).extend_slot(1) + + assert first.cursor == TrackerCursor(0, None, SubColumn.INSTRUMENT) + assert last.cursor == TrackerCursor(0, GeneratorName.NOISE, SubColumn.VOLUME) + + def test_a_plain_move_collapses_the_selection(self) -> None: + moved = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT).navigate_row(1, ROW_COUNT) + + assert moved.anchor is None + assert moved.region is None + + def test_a_plain_column_move_collapses_the_selection(self) -> None: + moved = _state(SubColumn.INSTRUMENT).extend_row(2, ROW_COUNT).navigate_column_by(1) + + assert moved.region is None + + def test_dropping_a_partial_entry_holds_the_selection(self) -> None: + """Every move commits what was typed first, the extending ones included.""" + held = _state(SubColumn.VOLUME, pending="5").extend_row(1, ROW_COUNT).reset_pending() + + assert held.region is not None + + def test_typing_a_value_collapses_the_selection(self) -> None: + selected = _state(SubColumn.VOLUME, row=4).extend_row(2, ROW_COUNT) + + typed, action = selected.type_char("7") + + assert action is not None + assert typed.region is None + + def test_a_note_off_collapses_the_selection(self) -> None: + selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT) + + typed, action = selected.type_char("-") + + assert action is not None + assert action.note_off is True + assert typed.region is None + + def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: + cancelled = _state(SubColumn.VOLUME, pending="5").extend_row(1, ROW_COUNT).cancel() + + assert cancelled.region is None + assert cancelled.pending == "" + + def test_collapse_keeps_the_cursor_where_it_stands(self) -> None: + selected = _state(SubColumn.TRANSPOSE, row=4).extend_row(2, ROW_COUNT) + + collapsed = selected.collapse() + + assert collapsed.cursor == selected.cursor + assert collapsed.region is None + + class TestColumnNavigation: def test_tab_preserves_subcolumn(self) -> None: state = _state(SubColumn.VOLUME, generator=GeneratorName.PULSE1) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_panel_escape.py index aa62eea82..20e499de5 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 @@ -41,6 +41,21 @@ def test_escape_cancels_a_pending_edit_and_consumes(self, monkeypatch: pytest.Mo assert panel._on_key_pressed(_escape()) is True assert applied and applied[0].pending == "" + def test_escape_drops_a_selection_and_consumes(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A selection is state the grid holds, so Escape takes it down before it reaches Stop.""" + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._shortcuts = shipped_source() + panel._current_row_count = 64 + panel._input_state = TrackerInputState( + cursor=TrackerCursor(4, None, SubColumn.INSTRUMENT), + anchor=TrackerCursor(2, None, SubColumn.INSTRUMENT), + ) + applied: List[TrackerInputState] = [] + monkeypatch.setattr(panel, "_apply_state", applied.append) + + assert panel._on_key_pressed(_escape()) is True + assert applied and applied[0].region is None + class TestOrderEscapeYieldsToGlobalStop: """With no partial cell edit to cancel, the order table lets Escape fall through to global Stop.""" @@ -61,3 +76,18 @@ def test_escape_cancels_a_pending_edit_and_consumes(self, monkeypatch: pytest.Mo assert panel._on_key_pressed(_escape()) is True assert applied and applied[0].pending == "" + + def test_escape_drops_a_selection_and_consumes(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A selection is state the table holds, so Escape takes it down before it reaches Stop.""" + panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + panel._shortcuts = shipped_source() + panel._position_count = 8 + panel._input_state = OrderInputState( + cursor=OrderCursor(None, 3), + anchor=OrderCursor(None, 1), + ) + applied: List[OrderInputState] = [] + monkeypatch.setattr(panel, "_apply_state", applied.append) + + assert panel._on_key_pressed(_escape()) is True + assert applied and applied[0].region is None diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py new file mode 100644 index 000000000..bd79ee363 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -0,0 +1,355 @@ +from typing import List, Optional, Tuple + +import pytest + +from sampletones_application.layout.tabs.sequencer import SequencerLayout +from sampletones_application.paths import ( + BEHAVIOR_DIRECTORY, + LAYOUT_DIRECTORY, + PALETTES_DIRECTORY, +) +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config +from sampletones_application.ui.elements.table.cells import EditableCells +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, OrderKey +from sampletones_application.ui.panels.sequencer.tracker import CellKey, GUISequencerTrackerPanel +from sampletones_application.utils.gui.keyboard.modifiers import Modifier +from sampletones_application.utils.palette.catalog import PaletteCatalog +from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName + +ROW_COUNT = 64 +POSITION_COUNT = 8 +ORIGIN_WIDGET = 101 +ORIGIN_CELL: CellKey = (2, GeneratorName.PULSE1, SubColumn.TRANSPOSE) +ORIGIN_ENTRY: OrderKey = (None, 1) + + +@pytest.fixture +def layout_config() -> LayoutConfig: + source = PaletteSource(PaletteCatalog.load(PALETTES_DIRECTORY).default) + return load_layout_config(LAYOUT_DIRECTORY, BEHAVIOR_DIRECTORY, source) + + +@pytest.fixture +def sequencer_layout(layout_config: LayoutConfig) -> SequencerLayout: + return layout_config.tabs.sequencer + + +def _hold_modifiers( + monkeypatch: pytest.MonkeyPatch, + module: str, + shift: bool, +) -> None: + monkeypatch.setattr( + f"sampletones_application.ui.panels.sequencer.{module}.capture_modifiers", + lambda: {Modifier.SHIFT} if shift else set(), + ) + + +def _tracker( + monkeypatch: pytest.MonkeyPatch, + reached: Optional[CellKey], + shift: bool = False, +) -> Tuple[GUISequencerTrackerPanel, List[TrackerInputState]]: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._input_state = TrackerInputState() + panel._current_row_count = ROW_COUNT + panel._drag = None + panel._editable_cells = EditableCells() + panel._editable_cells.register(ORIGIN_CELL, ORIGIN_WIDGET) + + states: List[TrackerInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + monkeypatch.setattr(panel, "_cell_at", lambda: reached) + _hold_modifiers(monkeypatch, "tracker", shift) + return panel, states + + +def _order( + monkeypatch: pytest.MonkeyPatch, + reached: Optional[OrderKey], + shift: bool = False, +) -> Tuple[GUISequencerOrderPanel, List[OrderInputState]]: + panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + panel._input_state = OrderInputState() + panel._position_count = POSITION_COUNT + panel._drag = None + panel._order = EditableCells() + panel._order.register(ORIGIN_ENTRY, ORIGIN_WIDGET) + + states: List[OrderInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + monkeypatch.setattr(panel, "_cell_at", lambda: reached) + _hold_modifiers(monkeypatch, "order", shift) + return panel, states + + +class TestEditableCellKeys: + """A cell cache answers from both sides, because a handler reports the widget it fired for.""" + + def test_a_registered_widget_reads_back_as_its_key(self) -> None: + cells: EditableCells[CellKey] = EditableCells() + cells.register(ORIGIN_CELL, ORIGIN_WIDGET) + + assert cells.key(ORIGIN_WIDGET) == ORIGIN_CELL + assert cells.widget(ORIGIN_CELL) == ORIGIN_WIDGET + + def test_a_rebuild_drops_both_directions(self) -> None: + cells: EditableCells[CellKey] = EditableCells() + cells.register(ORIGIN_CELL, ORIGIN_WIDGET) + cells.reset({}) + + assert cells.key(ORIGIN_WIDGET) is None + assert cells.widget(ORIGIN_CELL) is None + + def test_an_unknown_widget_names_no_cell(self) -> None: + cells: EditableCells[CellKey] = EditableCells() + + assert cells.key(ORIGIN_WIDGET) is None + + +class TestTrackerDrag: + """A press carries the selection with the pointer, and a press that stays put stays a click.""" + + def test_a_press_alone_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, states = _tracker(monkeypatch, reached=ORIGIN_CELL) + + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert panel._drag is not None + assert panel._drag.origin == ORIGIN_CELL + assert panel._drag.moved is False + assert states == [] + + def test_a_press_held_on_its_own_cell_stays_a_click(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, states = _tracker(monkeypatch, reached=ORIGIN_CELL) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert states == [] + + def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: + reached: CellKey = (5, GeneratorName.TRIANGLE, SubColumn.VOLUME) + panel, states = _tracker(monkeypatch, reached=reached) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert panel._drag is not None + assert panel._drag.moved is True + assert states[-1].region == TrackerRegion( + first_row=2, + last_row=5, + first_slot=4, + last_slot=11, + ) + + def test_a_plain_drag_replaces_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None: + reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + panel, states = _tracker(monkeypatch, reached=reached) + panel._input_state = TrackerInputState( + cursor=TrackerCursor(20, GeneratorName.NOISE, SubColumn.VOLUME), + anchor=TrackerCursor(30, GeneratorName.NOISE, SubColumn.VOLUME), + ) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert states[-1].anchor == TrackerCursor(*ORIGIN_CELL) + assert states[-1].region == TrackerRegion( + first_row=2, + last_row=5, + first_slot=4, + last_slot=4, + ) + + def test_a_shift_press_carries_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None: + reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + panel, states = _tracker(monkeypatch, reached=reached, shift=True) + panel._input_state = TrackerInputState( + cursor=TrackerCursor(9, GeneratorName.PULSE1, SubColumn.TRANSPOSE), + anchor=TrackerCursor(9, GeneratorName.PULSE2, SubColumn.TRANSPOSE), + ) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert states[-1].anchor == TrackerCursor(9, GeneratorName.PULSE2, SubColumn.TRANSPOSE) + + def test_a_drag_back_to_its_origin_selects_that_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: + reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + panel, states = _tracker(monkeypatch, reached=reached) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + monkeypatch.setattr(panel, "_cell_at", lambda: ORIGIN_CELL) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert states[-1].region == TrackerRegion( + first_row=2, + last_row=2, + first_slot=4, + last_slot=4, + ) + + def test_a_press_on_a_cell_the_cache_forgot_starts_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, states = _tracker(monkeypatch, reached=ORIGIN_CELL) + + panel._on_cell_held(0, ORIGIN_WIDGET + 1) + + assert panel._drag is None + assert states == [] + + def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, _ = _tracker(monkeypatch, reached=ORIGIN_CELL) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_pointer_pressed(0, 0) + + assert panel._drag is None + + def test_the_click_ending_a_drag_leaves_the_selection_alone( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A drag returning to its own cell releases there, and that release reports a click.""" + reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + panel, states = _tracker(monkeypatch, reached=reached) + panel._selection = frozenset({ORIGIN_CELL}) + monkeypatch.setattr(panel, "_repaint_selection", lambda: None) + monkeypatch.setattr( + "sampletones_application.ui.panels.sequencer.tracker.dpg.set_value", + lambda widget, value: None, + ) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + applied = len(states) + panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_CELL) + + assert len(states) == applied + assert panel._drag is None + + +class TestTrackerDragHitTest: + """The row under the pointer is counted from the first row, and clipped to the rows there are.""" + + def test_each_row_answers_for_its_own_band( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._current_row_count = ROW_COUNT + monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None) + + height = sequencer_layout.tracker.row_height + assert panel._row_at(100.0) == 0 + assert panel._row_at(100.0 + height - 1) == 0 + assert panel._row_at(100.0 + height) == 1 + assert panel._row_at(100.0 + 3 * height + 2) == 3 + + def test_a_pointer_past_an_edge_reads_as_the_edge( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._current_row_count = ROW_COUNT + monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None) + + assert panel._row_at(-500.0) == 0 + assert panel._row_at(100_000.0) == ROW_COUNT - 1 + + def test_a_grid_awaiting_its_rows_answers_nothing( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._current_row_count = 0 + monkeypatch.setattr(panel, "_row_top", lambda index: None) + + assert panel._row_at(100.0) is None + + +class TestOrderDrag: + """The order table reads a drag the same way, over its channels and positions.""" + + def test_a_press_alone_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, states = _order(monkeypatch, reached=ORIGIN_ENTRY) + + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert panel._drag is not None + assert panel._drag.origin == ORIGIN_ENTRY + assert states == [] + + def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: + reached: OrderKey = (GeneratorName.PULSE2, 4) + panel, states = _order(monkeypatch, reached=reached) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert states[-1].region == OrderRegion( + first_row=0, + last_row=2, + first_position=1, + last_position=4, + ) + + def test_a_shift_press_carries_the_selection_already_held(self, monkeypatch: pytest.MonkeyPatch) -> None: + reached: OrderKey = (GeneratorName.PULSE2, 4) + panel, states = _order(monkeypatch, reached=reached, shift=True) + panel._input_state = OrderInputState( + cursor=OrderCursor(GeneratorName.NOISE, 6), + anchor=OrderCursor(GeneratorName.NOISE, 6), + ) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + + assert states[-1].anchor == OrderCursor(GeneratorName.NOISE, 6) + + def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel, _ = _order(monkeypatch, reached=ORIGIN_ENTRY) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_pointer_pressed(0, 0) + + assert panel._drag is None + + def test_the_click_ending_a_drag_leaves_the_selection_alone( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + reached: OrderKey = (GeneratorName.PULSE2, 4) + panel, states = _order(monkeypatch, reached=reached) + panel._selection = frozenset({ORIGIN_ENTRY}) + monkeypatch.setattr(panel, "_repaint_selection", lambda: None) + monkeypatch.setattr( + "sampletones_application.ui.panels.sequencer.order.dpg.set_value", + lambda widget, value: None, + ) + + panel._on_cell_held(0, ORIGIN_WIDGET) + panel._on_cell_held(0, ORIGIN_WIDGET) + applied = len(states) + panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_ENTRY) + + assert len(states) == applied + assert panel._drag is None diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py new file mode 100644 index 000000000..22a7af00e --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py @@ -0,0 +1,181 @@ +from typing import List, Optional + +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.tracker import GUISequencerTrackerPanel +from sampletones_application.utils.gui.keyboard.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.event import KeyEvent +from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from tests.suite.shortcuts import shipped_source + +ROW_COUNT = 64 +POSITION_COUNT = 8 +CURSOR_ROW = 4 +CURSOR_POSITION = 2 + + +def _press(text: str) -> KeyEvent: + """The press a written combination names, as the router delivers it.""" + combination = KeyCombination.parse(text) + return KeyEvent(key=combination.key, modifiers=combination.modifiers) + + +def _tracker( + generator: Optional[GeneratorName] = GeneratorName.PULSE1, + subcolumn: SubColumn = SubColumn.INSTRUMENT, +) -> GUISequencerTrackerPanel: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._shortcuts = shipped_source() + panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, generator, subcolumn)) + panel._current_row_count = ROW_COUNT + return panel + + +def _order(generator: Optional[GeneratorName] = GeneratorName.PULSE1) -> GUISequencerOrderPanel: + panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + panel._shortcuts = shipped_source() + panel._input_state = OrderInputState(cursor=OrderCursor(generator, CURSOR_POSITION)) + panel._position_count = POSITION_COUNT + return panel + + +def _tracker_states( + monkeypatch: pytest.MonkeyPatch, + panel: GUISequencerTrackerPanel, +) -> List[TrackerInputState]: + """The states a gesture applies, with the scroll a jump asks for left out.""" + states: List[TrackerInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: None) + return states + + +def _order_states( + monkeypatch: pytest.MonkeyPatch, + panel: GUISequencerOrderPanel, +) -> List[OrderInputState]: + states: List[OrderInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + return states + + +class TestTrackerSelectionKeys: + """Shift held with a cursor key selects instead of moving, over the grid the cursor stands in.""" + + def test_shift_down_selects_two_rows(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Down")) is True + assert states[-1].region == TrackerRegion( + first_row=CURSOR_ROW, + last_row=CURSOR_ROW + 1, + first_slot=3, + last_slot=3, + ) + + def test_shift_up_selects_the_row_above(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Up")) is True + region = states[-1].region + assert region is not None + assert (region.first_row, region.last_row) == (CURSOR_ROW - 1, CURSOR_ROW) + + def test_shift_right_selects_the_next_subcolumn(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Right")) is True + region = states[-1].region + assert region is not None + assert region.slots == ( + TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE), + ) + + def test_shift_end_selects_to_the_last_row(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+End")) is True + region = states[-1].region + assert region is not None + assert (region.first_row, region.last_row) == (CURSOR_ROW, ROW_COUNT - 1) + + def test_shift_home_selects_to_the_first_row(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Home")) is True + region = states[-1].region + assert region is not None + assert (region.first_row, region.last_row) == (0, CURSOR_ROW) + + def test_a_plain_arrow_still_moves_the_cursor(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Down")) is True + assert states[-1].region is None + + +class TestOrderSelectionKeys: + """Shift held with a cursor key selects instead of moving, over the table the cursor stands in.""" + + def test_shift_right_selects_two_positions(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Right")) is True + assert states[-1].region == OrderRegion( + first_row=1, + last_row=1, + first_position=CURSOR_POSITION, + last_position=CURSOR_POSITION + 1, + ) + + def test_shift_up_selects_up_to_the_master_row(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Up")) is True + region = states[-1].region + assert region is not None + assert region.generators == (None, GeneratorName.PULSE1) + + def test_shift_end_selects_to_the_last_position(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+End")) is True + region = states[-1].region + assert region is not None + assert (region.first_position, region.last_position) == (CURSOR_POSITION, POSITION_COUNT - 1) + + def test_shift_home_selects_to_the_first_position(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Shift+Home")) is True + region = states[-1].region + assert region is not None + assert (region.first_position, region.last_position) == (0, CURSOR_POSITION) + + def test_a_plain_arrow_still_moves_the_cursor(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Right")) is True + assert states[-1].region is None diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py index f8b3578dd..4a690e0e5 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -139,8 +139,13 @@ def test_each_category_answers_a_shared_combination_with_its_own_action( 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 + """A binding names the modifiers held with it, so Ctrl+Up is not the plain Up move.""" + assert shipped.action(ShortcutCategory.ORDER, _press("Ctrl+Up")) is None + + def test_a_modifier_a_binding_does_name_reaches_its_own_action(self, shipped: ShortcutScheme) -> None: + """Shift+Up selects where Up moves, which is one combination reaching each of two actions.""" + assert shipped.action(ShortcutCategory.ORDER, _press("Up")) is ShortcutId.ORDER_PREVIOUS_CHANNEL + assert shipped.action(ShortcutCategory.ORDER, _press("Shift+Up")) is ShortcutId.ORDER_EXTEND_SELECTION_UP class TestClaimant: diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_region.py b/tests/unit/sampletones_application/view_model/sequencer/test_region.py new file mode 100644 index 000000000..3ea7fb3d0 --- /dev/null +++ b/tests/unit/sampletones_application/view_model/sequencer/test_region.py @@ -0,0 +1,92 @@ +import pytest +from pydantic import ValidationError + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.view_model.sequencer.region import ( + OrderRegion, + TrackerRegion, +) +from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName + + +class TestTrackerRegion: + def test_a_single_cell_region_covers_that_cell(self) -> None: + region = TrackerRegion(first_row=3, last_row=3, first_slot=4, last_slot=4) + + assert region.row_count == 1 + assert region.slot_count == 1 + assert tuple(region.rows) == (3,) + assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),) + + def test_the_slots_read_as_the_columns_and_subcolumns_they_address(self) -> None: + """A region's edges are subcolumns, so a run reaches across a column boundary mid-cell.""" + region = TrackerRegion(first_row=0, last_row=0, first_slot=2, last_slot=3) + + assert region.slots == ( + TrackerSlot(None, SubColumn.VOLUME), + TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + ) + + def test_a_region_spans_the_whole_axis(self) -> None: + region = TrackerRegion(first_row=0, last_row=63, first_slot=0, last_slot=SLOT_COUNT - 1) + + assert region.row_count == 64 + assert region.slot_count == SLOT_COUNT + + def test_inverted_rows_are_rejected(self) -> None: + with pytest.raises(ValidationError): + TrackerRegion(first_row=5, last_row=2, first_slot=0, last_slot=0) + + def test_inverted_slots_are_rejected(self) -> None: + with pytest.raises(ValidationError): + TrackerRegion(first_row=0, last_row=0, first_slot=5, last_slot=2) + + @pytest.mark.parametrize("slot", [-1, SLOT_COUNT]) + def test_a_slot_off_the_axis_is_rejected(self, slot: int) -> None: + with pytest.raises(ValidationError): + TrackerRegion(first_row=0, last_row=0, first_slot=slot, last_slot=slot) + + def test_a_negative_row_is_rejected(self) -> None: + with pytest.raises(ValidationError): + TrackerRegion(first_row=-1, last_row=0, first_slot=0, last_slot=0) + + +class TestOrderRegion: + def test_a_single_cell_region_covers_that_cell(self) -> None: + region = OrderRegion(first_row=0, last_row=0, first_position=2, last_position=2) + + assert region.row_count == 1 + assert region.position_count == 1 + assert region.generators == (None,) + assert tuple(region.positions) == (2,) + + def test_the_rows_read_as_the_channels_they_address(self) -> None: + region = OrderRegion(first_row=0, last_row=2, first_position=0, last_position=0) + + assert region.generators == (None, GeneratorName.PULSE1, GeneratorName.PULSE2) + + def test_a_region_spans_the_whole_channel_axis(self) -> None: + region = OrderRegion( + first_row=0, + last_row=len(CHANNEL_AXIS) - 1, + first_position=0, + last_position=7, + ) + + assert region.generators == CHANNEL_AXIS + assert region.position_count == 8 + + def test_inverted_positions_are_rejected(self) -> None: + with pytest.raises(ValidationError): + OrderRegion(first_row=0, last_row=0, first_position=5, last_position=2) + + def test_a_row_off_the_channel_axis_is_rejected(self) -> None: + with pytest.raises(ValidationError): + OrderRegion( + first_row=0, + last_row=len(CHANNEL_AXIS), + first_position=0, + last_position=0, + ) From 77cfa0586929232f8840213a7d9203279662dadc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 21:00:24 +0200 Subject: [PATCH 074/152] Added: tracker block copy --- .../categories/elements/settings.py | 1 + .../coordinators/tabs/sequencer.py | 23 +- .../logic/sequencer/clipboard.py | 27 ++ .../logic/sequencer/tracker/__init__.py | 10 + .../logic/sequencer/tracker/block.py | 50 ++++ .../logic/sequencer/tracker/reader.py | 109 +++++++ .../ui/panels/sequencer/input/state.py | 21 ++ .../ui/panels/sequencer/tracker.py | 28 ++ .../utils/gui/shortcuts/ids.py | 1 + .../keybindings/default.yaml | 1 + src/sampletones_config/keybindings/macos.yaml | 1 + src/sampletones_config/lang/en.yaml | 1 + tests/conftest.py | 28 +- tests/suite/sequencer.py | 40 +++ .../coordinators/tabs/test_sequencer.py | 73 +++++ .../logic/sequencer/tracker/__init__.py | 0 .../logic/sequencer/tracker/test_reader.py | 280 ++++++++++++++++++ .../logic/sequencer/tracker/test_tracker.py | 72 ++--- .../sequencer/input/test_tracker_input.py | 20 ++ .../ui/panels/sequencer/test_block_keys.py | 80 +++++ .../panels/sequencer/test_selection_drag.py | 4 +- .../sequencer/test_tracker_navigation.py | 4 +- 22 files changed, 791 insertions(+), 83 deletions(-) create mode 100644 src/sampletones_application/logic/sequencer/clipboard.py create mode 100644 src/sampletones_application/logic/sequencer/tracker/__init__.py create mode 100644 src/sampletones_application/logic/sequencer/tracker/block.py create mode 100644 src/sampletones_application/logic/sequencer/tracker/reader.py create mode 100644 tests/suite/sequencer.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/tracker/__init__.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 8b73997b9..e4257bc6a 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -123,6 +123,7 @@ class KeybindingActionElements(AbstractElement): TRACKER_EXTEND_SELECTION_RIGHT = "tracker_extend_selection_right" TRACKER_EXTEND_SELECTION_TO_FIRST_ROW = "tracker_extend_selection_to_first_row" TRACKER_EXTEND_SELECTION_TO_LAST_ROW = "tracker_extend_selection_to_last_row" + TRACKER_COPY_BLOCK = "tracker_copy_block" TRACKER_PAGE_UP = "tracker_page_up" TRACKER_PAGE_DOWN = "tracker_page_down" TRACKER_CLEAR_ROW = "tracker_clear_row" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 4a789f8aa..182fdedf5 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -21,6 +21,7 @@ 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.clipboard import SequencerClipboard from sampletones_application.logic.sequencer.history_detail import ( SequencerHistoryDetail, ) @@ -33,7 +34,10 @@ 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.sequencer.tracker import ( + SequencerTrackerLogic, + TrackerBlockReader, +) from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.sequencer import SequencerTabParameters from sampletones_application.services.song_player.player import SongPlayerService @@ -82,6 +86,7 @@ HistoryEntryViewModel, HistoryViewModel, ) +from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) @@ -178,6 +183,8 @@ def __init__( ) self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller) self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller) + self._clipboard: SequencerClipboard = SequencerClipboard() + self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic) self._sequencer_samples_logic: SequencerSamplesLogic = SequencerSamplesLogic( project_controller, session_manager, @@ -261,6 +268,7 @@ def _wire_callbacks(self) -> None: self._wire_tracker_callbacks() self._wire_channels_callbacks() self._wire_order_callbacks() + self._wire_block_callbacks() self._wire_samples_callbacks() self._wire_browser_callbacks() self._wire_playback_callbacks() @@ -434,6 +442,19 @@ def _wire_order_callbacks(self) -> None: ) self._sequencer_order_panel.on_cell_selected = self._on_order_cell_focused + def _wire_block_callbacks(self) -> None: + """Connects the grids' block gestures to the clipboard they copy into. + + A copy reads the project and leaves it as it stands, so it is wired straight through + instead of through :meth:`_undoable`: a transaction over it would record an entry the + history has nothing to restore for. + """ + self._sequencer_tracker_panel.on_copy_block = self._on_tracker_copy_block + + def _on_tracker_copy_block(self, region: TrackerRegion) -> None: + """Puts the tracker's selected block on the clipboard, for a paste to replay.""" + self._clipboard.store_tracker_block(self._tracker_block_reader.read(region)) + def _wire_samples_callbacks(self) -> None: self._sequencer_samples_logic.on_samples_changed = self._on_samples_changed self._sequencer_samples_logic.on_edit_sample_requested = self._dispatch_edit_sample diff --git a/src/sampletones_application/logic/sequencer/clipboard.py b/src/sampletones_application/logic/sequencer/clipboard.py new file mode 100644 index 000000000..0d18f9e5b --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard.py @@ -0,0 +1,27 @@ +from typing import Optional + +from sampletones_application.logic.sequencer.tracker import TrackerBlock + + +class SequencerClipboard: + """Holds the block each sequencer grid last copied, one slot per grid. + + Separate slots are what keep a paste in the grid it belongs to: the tracker reads only what a + tracker copied, so a block never has to be asked which grid it came from. + + A slot outlives the project it was filled from, because a project is replaced on every undo + and redo as well as on opening a document, and a copy the reader made is theirs to keep across + all of it. A note naming a sample the project in place lacks is settled where the block is + written. + """ + + def __init__(self) -> None: + self._tracker_block: Optional[TrackerBlock] = None + + @property + def tracker_block(self) -> Optional[TrackerBlock]: + """The block the tracker last copied, present once a copy has been made.""" + return self._tracker_block + + def store_tracker_block(self, block: TrackerBlock) -> None: + self._tracker_block = block diff --git a/src/sampletones_application/logic/sequencer/tracker/__init__.py b/src/sampletones_application/logic/sequencer/tracker/__init__.py new file mode 100644 index 000000000..976a0d999 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/tracker/__init__.py @@ -0,0 +1,10 @@ +from .block import BlockNote, TrackerBlock +from .reader import TrackerBlockReader +from .tracker import SequencerTrackerLogic + +__all__ = [ + "BlockNote", + "SequencerTrackerLogic", + "TrackerBlock", + "TrackerBlockReader", +] diff --git a/src/sampletones_application/logic/sequencer/tracker/block.py b/src/sampletones_application/logic/sequencer/tracker/block.py new file mode 100644 index 000000000..899b28374 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/tracker/block.py @@ -0,0 +1,50 @@ +from dataclasses import dataclass +from typing import Dict, Optional, Tuple, Union + +from sampletones_core.project.instruments.note_off import NoteOff + +BlockNote = Union[str, NoteOff] +BlockKey = Tuple[int, int] + + +@dataclass(frozen=True) +class TrackerBlock: + """A rectangle of tracker values, addressed by the offsets it was read at. + + A key is a row offset paired with a slot offset. The row offset counts down from the block's + first row; the slot offset is measured from the base of the column the block begins in, so it + stays a multiple of three apart from the column it is replayed against and each value keeps + the kind of subcolumn it was read from. + + A cell reaches the block in one of three states, and the maps hold them apart: a key carrying + a value writes that value, a key carrying ``None`` writes emptiness, and an absent key states + that the block says nothing about that cell — which is how a sample column its channels + disagree over stays transparent to whatever it is pasted onto. + + Notes, transposes and volumes are kept in maps of their own so the kind of a value is + structural. It also fixes the order a write takes: every note lands before the transposes and + volumes sharing its row, which matters where a sample-column note clears the channels around + it. + + A note names a sample by id rather than by instrument, so it carries a pitch and leaves the + channel to whichever column it is written into. + """ + + row_count: int + first_slot: int + last_slot: int + notes: Dict[BlockKey, Optional[BlockNote]] + transposes: Dict[BlockKey, Optional[int]] + volumes: Dict[BlockKey, Optional[int]] + + @property + def slot_count(self) -> int: + return self.last_slot - self.first_slot + 1 + + @property + def slots(self) -> range: + return range(self.first_slot, self.last_slot + 1) + + @property + def rows(self) -> range: + return range(self.row_count) diff --git a/src/sampletones_application/logic/sequencer/tracker/reader.py b/src/sampletones_application/logic/sequencer/tracker/reader.py new file mode 100644 index 000000000..8bac8910b --- /dev/null +++ b/src/sampletones_application/logic/sequencer/tracker/reader.py @@ -0,0 +1,109 @@ +from collections.abc import Hashable +from typing import Callable, Dict, Optional, TypeVar + +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import ( + column_slot_base, + slot_from_flat, +) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.patterns.row import Row +from sampletones_shared.utils.agreement import Agreement + +from .block import BlockKey, BlockNote, TrackerBlock +from .tracker import SequencerTrackerLogic + +ValueT = TypeVar("ValueT", bound=Hashable) + + +class TrackerBlockReader: + """Reads a selected region of the shown frame into a block a paste can replay. + + The block is anchored at the column the region begins in, so it carries offsets rather than + grid coordinates and lands wherever it is written by the kind of each subcolumn. + """ + + def __init__(self, tracker_logic: SequencerTrackerLogic) -> None: + self._tracker = tracker_logic + + def read(self, region: TrackerRegion) -> TrackerBlock: + """Takes the values a region covers, keeping each kind of subcolumn in a map of its own.""" + base = column_slot_base(slot_from_flat(region.first_slot).generator) + return TrackerBlock( + row_count=region.row_count, + first_slot=region.first_slot - base, + last_slot=region.last_slot - base, + notes=self._read_subcolumn(region, base, SubColumn.INSTRUMENT, self._note_of), + transposes=self._read_subcolumn(region, base, SubColumn.TRANSPOSE, self._transpose_of), + volumes=self._read_subcolumn(region, base, SubColumn.VOLUME, self._volume_of), + ) + + def _read_subcolumn( + self, + region: TrackerRegion, + base: int, + subcolumn: SubColumn, + select: Callable[[Optional[Row]], ValueT], + ) -> Dict[BlockKey, ValueT]: + """The values one kind of subcolumn holds across a region, keyed by the offsets it stands at. + + A cell holding a definite value keeps it, an empty one keeps its emptiness, and a cell + whose channels disagree leaves its key out — which is what carries the sample column's + mixed reading over as a value the paste passes by. + """ + values: Dict[BlockKey, ValueT] = {} + for row_offset, row_index in enumerate(region.rows): + for position, slot in enumerate(region.slots): + if slot.subcolumn is not subcolumn: + continue + + agreement = self._agree(row_index, slot.generator, select) + if agreement.is_unanimous: + values[(row_offset, region.first_slot + position - base)] = agreement.value + + return values + + def _agree( + self, + row_index: int, + generator: Optional[GeneratorName], + select: Callable[[Optional[Row]], ValueT], + ) -> Agreement[ValueT]: + """What a column holds at a cell: a channel's own value, or the one its channels share. + + A channel column answers for itself, so it is a group of one and always agrees. The sample + column answers for the channels it governs, which is the group its display summarises too, + so a block states about a cell exactly what the grid it came from shows there. + """ + if generator is not None: + return Agreement.collapse([select(self._tracker.row(generator, row_index))]) + + return Agreement.collapse( + select(self._tracker.row(channel, row_index)) for channel in self._tracker.relevant_generators(row_index) + ) + + @staticmethod + def _note_of(row: Optional[Row]) -> Optional[BlockNote]: + """The note a row carries: the id of the sample it names, or the cut it holds. + + A sample is taken by id so the note keeps its pitch and takes the channel of whichever + column it is written into. + """ + match row.command if row is not None else None: + case Instrument() as instrument: + return instrument.sample_id + case NoteOff() as note_off: + return note_off + case None: + return None + + @staticmethod + def _transpose_of(row: Optional[Row]) -> Optional[int]: + return row.transpose if row is not None else None + + @staticmethod + def _volume_of(row: Optional[Row]) -> Optional[int]: + return row.volume if row is not None else None diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index 51e4f2efe..b89e11a07 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -104,6 +104,27 @@ def region(self) -> Optional[TrackerRegion]: last_slot=max(anchor_slot, cursor_slot), ) + @property + def target_region(self) -> Optional[TrackerRegion]: + """The region a block gesture acts on: the selection, or the cursor's own cell. + + A cursor with nothing selected stands on a block of one cell, so copying reaches the cell + the reader is working in and needs no selection made first. + """ + if self.region is not None: + return self.region + + if self.cursor is None: + return None + + slot = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index + return TrackerRegion( + first_row=self.cursor.row, + last_row=self.cursor.row, + first_slot=slot, + last_slot=slot, + ) + def extend_to(self, cursor: TrackerCursor) -> TrackerInputState: """Carries the moving end of the selection to ``cursor``, anchoring it where it began. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 302771672..50736240a 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -80,6 +80,7 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) +from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) @@ -114,6 +115,7 @@ OnAdjustCallback = Callable[[int, Optional[GeneratorName], int], None] OnChannelMuteToggledCallback = Callable[[GeneratorName], None] OnChannelSoloedCallback = Callable[[GeneratorName], None] +OnCopyBlockCallback = Callable[[TrackerRegion], None] VOLUME_FINE_STEP: Final[int] = 1 @@ -183,6 +185,7 @@ def __init__( self.on_play_from_frame: Optional[OnPlayFromFrameCallback] = None self.on_adjust_transpose: Optional[OnAdjustCallback] = None self.on_adjust_volume: Optional[OnAdjustCallback] = None + self.on_copy_block: Optional[OnCopyBlockCallback] = None self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None self.on_channels_toggled: Optional[VoidCallback] = None @@ -1425,6 +1428,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._extend_selection(shortcut_id): return True + if self._block_action(shortcut_id): + return True + return self._edit_row(shortcut_id) def _move_cursor(self, shortcut_id: ShortcutId) -> bool: @@ -1479,6 +1485,28 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool: return True + def _block_action(self, shortcut_id: ShortcutId) -> bool: + """Acts on the selected block, reporting whether the action was one of its gestures.""" + match shortcut_id: + case ShortcutId.TRACKER_COPY_BLOCK: + self._copy_block() + case _: + return False + + return True + + def _copy_block(self) -> None: + """Hands the selected block out to be copied, the cell under the cursor standing for itself. + + A partial entry is committed first, so the block carries the value the reader has just + finished typing. + """ + state = self._committed_state() + self._apply_state(state) + region = state.target_region + if region is not None: + self.call(self.on_copy_block, region) + 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. diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 389714288..7210de0d1 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -141,6 +141,7 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: "TrackerExtendSelectionToLastRow", ShortcutCategory.TRACKER, ) + TRACKER_COPY_BLOCK = ("TrackerCopyBlock", ShortcutCategory.TRACKER) TRACKER_PAGE_UP = ("TrackerPageUp", ShortcutCategory.TRACKER) TRACKER_PAGE_DOWN = ("TrackerPageDown", ShortcutCategory.TRACKER) TRACKER_CLEAR_ROW = ("TrackerClearRow", ShortcutCategory.TRACKER) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index aef2c047e..cd6f581f1 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -101,6 +101,7 @@ bindings: TrackerExtendSelectionRight: {combination: "Shift+Right"} TrackerExtendSelectionToFirstRow: {combination: "Shift+Home"} TrackerExtendSelectionToLastRow: {combination: "Shift+End"} + TrackerCopyBlock: {combination: "Ctrl+C"} TrackerPageUp: {combination: "PgUp"} TrackerPageDown: {combination: "PgDn"} TrackerClearRow: {combination: "Del"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 51a9bbbcc..b6a8cea62 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -101,6 +101,7 @@ bindings: TrackerExtendSelectionRight: {combination: "Shift+Right"} TrackerExtendSelectionToFirstRow: {combination: "Shift+Home", aliases: ["Cmd+Shift+Up"]} TrackerExtendSelectionToLastRow: {combination: "Shift+End", aliases: ["Cmd+Shift+Down"]} + TrackerCopyBlock: {combination: "Cmd+C"} TrackerPageUp: {combination: "PgUp", aliases: ["Alt+Up"]} TrackerPageDown: {combination: "PgDn", aliases: ["Alt+Down"]} TrackerClearRow: {combination: "Del", aliases: ["Cmd+Backspace"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 319798b8b..a388681d7 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -805,6 +805,7 @@ settings.keybindings.label.tracker_extend_selection_left: "Extend selection left settings.keybindings.label.tracker_extend_selection_right: "Extend selection right" settings.keybindings.label.tracker_extend_selection_to_first_row: "Extend selection to the first row" settings.keybindings.label.tracker_extend_selection_to_last_row: "Extend selection to the last row" +settings.keybindings.label.tracker_copy_block: "Copy selection" settings.keybindings.label.tracker_page_up: "Page up" settings.keybindings.label.tracker_page_down: "Page down" settings.keybindings.label.tracker_clear_row: "Clear row" diff --git a/tests/conftest.py b/tests/conftest.py index eefcf7ac2..49d3471d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,14 +1,11 @@ -from pathlib import Path 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 from sampletones_core.reconstructions import Reconstruction +from tests.suite.sequencer import sample_reconstruction ReconstructionFactory: TypeAlias = Callable[[], Reconstruction] @@ -29,27 +26,6 @@ def palette_bindings() -> Iterator[None]: @pytest.fixture def reconstruction_factory() -> ReconstructionFactory: def build() -> Reconstruction: - length = 64 - instructions = [ - PulseInstruction( - on=True, - pitch=60, - volume=8, - duty_cycle=0, - ) - ] - return Reconstruction.create( - approximation=np.zeros(length, dtype=np.float32), - approximations={ - GeneratorName.PULSE1: np.zeros( - length, - dtype=np.float32, - ) - }, - instructions={GeneratorName.PULSE1: instructions}, - config=Config(), - coefficient=1.0, - audio_filepath=Path("/dev/null"), - ) + return sample_reconstruction([GeneratorName.PULSE1]) return build diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py new file mode 100644 index 000000000..656f2587f --- /dev/null +++ b/tests/suite/sequencer.py @@ -0,0 +1,40 @@ +from pathlib import Path +from typing import Final, Sequence + +import numpy as np + +from sampletones_core.configs import Config +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.instructions import PulseInstruction +from sampletones_core.reconstructions import Reconstruction + +SAMPLE_LENGTH: Final[int] = 64 + + +def sample_reconstruction(generators: Sequence[GeneratorName]) -> Reconstruction: + """A reconstruction carrying one instruction on each of ``generators``. + + The channels a reconstruction covers are what a sample governs in the sequencer, so this is + the knob a sequencer test turns: the audio itself is silent, since what is under test is which + channels a sample reaches and not how it sounds. + """ + instructions = { + generator: [ + PulseInstruction( + on=True, + pitch=60, + volume=8, + duty_cycle=0, + ) + ] + for generator in generators + } + approximations = {generator: np.zeros(SAMPLE_LENGTH, dtype=np.float32) for generator in generators} + return Reconstruction.create( + approximation=np.zeros(SAMPLE_LENGTH, dtype=np.float32), + approximations=approximations, + instructions=instructions, + config=Config(), + coefficient=1.0, + audio_filepath=Path("/dev/null"), + ) diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 798ebb455..13864e48b 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -19,6 +19,11 @@ ALL_CHANNELS, SequencerChannelsLogic, ) +from sampletones_application.logic.sequencer.clipboard import SequencerClipboard +from sampletones_application.logic.sequencer.tracker import ( + SequencerTrackerLogic, + TrackerBlockReader, +) from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_application.paths import LANG_EN from sampletones_application.ui.panels.sequencer import channels as channels_module @@ -26,8 +31,11 @@ from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.modifiers import CTRL, NO_MODIFIERS +from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.samples import SampleSelection +from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel +from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( HistoryDetailRole, HistoryDetailSegment, @@ -1310,3 +1318,68 @@ def test_player_returns_the_guarded_wrapper( exposure_coordinator: SequencerTabCoordinator, ) -> None: assert isinstance(exposure_coordinator.player, GuardedPlayer) + + +PULSE1_CELL: Final[TrackerRegion] = TrackerRegion( + first_row=0, + last_row=0, + first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME).flat_index, +) + + +@pytest.fixture +def block_coordinator() -> SequencerTabCoordinator: + """A coordinator whose copy path is real, from the tracker logic through to the clipboard. + + A real manager observes the same controller production wires it to, so a test reads the + entries a gesture actually records. + """ + instance = object.__new__(SequencerTabCoordinator) + controller = ProjectController(ProjectManager()) + history = HistoryManager(controller, budget=10, strict=True) + controller.on_mutation = history.handle_mutation + instance._project_controller = controller + instance._history = history + instance._sequencer_tracker_logic = SequencerTrackerLogic(controller) + instance._clipboard = SequencerClipboard() + instance._tracker_block_reader = TrackerBlockReader(instance._sequencer_tracker_logic) + return instance + + +class TestBlockCopy: + def test_a_copy_fills_the_clipboard_with_the_block_it_covers( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + with coordinator._history.transaction(HistoryAction.EDIT_ROW): + coordinator._sequencer_tracker_logic.set_cell_subcolumn( + 0, + GeneratorName.PULSE1, + transpose=5, + ) + + coordinator._on_tracker_copy_block(PULSE1_CELL) + + block = coordinator._clipboard.tracker_block + assert block is not None + assert block.transposes[(0, 1)] == 5 + + def test_a_copy_leaves_the_history_stack_as_it_stands( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """A gesture that only reads the project records nothing, where the edit beside it does.""" + coordinator = block_coordinator + edit = coordinator._undoable( + HistoryAction.EDIT_ROW, + coordinator._sequencer_tracker_logic.write_cell, + ) + edit(0, GeneratorName.PULSE1, None, 5, None) + recorded = len(coordinator._history.entries) + + coordinator._on_tracker_copy_block(PULSE1_CELL) + + assert recorded > 0 + assert len(coordinator._history.entries) == recorded diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/__init__.py b/tests/unit/sampletones_application/logic/sequencer/tracker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py new file mode 100644 index 000000000..bcf080ba0 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py @@ -0,0 +1,280 @@ +from typing import Optional, Tuple + +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.tracker import ( + SequencerTrackerLogic, + TrackerBlockReader, +) +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS, TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.project.instruments.note_off import NoteOff +from tests.suite.sequencer import sample_reconstruction + + +def _key(subcolumn: SubColumn, row_offset: int = 0) -> Tuple[int, int]: + """Where a subcolumn's value stands in a block read from the column it belongs to. + + Offsets run from the base of that column, so a subcolumn's own place in the column is the + slot offset it reaches the block at. + """ + return (row_offset, SUBCOLUMNS.index(subcolumn)) + + +@pytest.fixture +def controller() -> ProjectController: + """A controller over a fresh project, which the samples a test places are added to.""" + return ProjectController(ProjectManager()) + + +@pytest.fixture +def logic(controller: ProjectController) -> SequencerTrackerLogic: + """The tracker logic the reader takes every value through.""" + return SequencerTrackerLogic(controller) + + +@pytest.fixture +def reader(logic: SequencerTrackerLogic) -> TrackerBlockReader: + return TrackerBlockReader(logic) + + +def _slot(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int: + return TrackerSlot(generator, subcolumn).flat_index + + +def _cell( + row_index: int, + generator: Optional[GeneratorName], + subcolumn: SubColumn, +) -> TrackerRegion: + """The region one subcolumn of one cell covers.""" + slot = _slot(generator, subcolumn) + return TrackerRegion( + first_row=row_index, + last_row=row_index, + first_slot=slot, + last_slot=slot, + ) + + +def _column( + generator: Optional[GeneratorName], + *, + last_row: int = 0, +) -> TrackerRegion: + """The region one whole column covers, down to ``last_row``.""" + return TrackerRegion( + first_row=0, + last_row=last_row, + first_slot=_slot(generator, SubColumn.INSTRUMENT), + last_slot=_slot(generator, SubColumn.VOLUME), + ) + + +class TestChannelColumn: + """A channel answers for itself, so every one of its cells reaches the block definite.""" + + def test_a_cell_carries_the_values_it_holds( + self, + controller: ProjectController, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + sample = controller.add_sample( + sample_reconstruction([GeneratorName.PULSE1]), + name="lead", + ) + logic.place_note(0, GeneratorName.PULSE1, sample.id) + logic.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=5, volume=3) + + block = reader.read(_column(GeneratorName.PULSE1)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id + assert block.transposes[_key(SubColumn.TRANSPOSE)] == 5 + assert block.volumes[_key(SubColumn.VOLUME)] == 3 + + def test_an_empty_cell_carries_its_emptiness( + self, + reader: TrackerBlockReader, + ) -> None: + """An untouched channel holds no pattern at all, which reads as the empty cell it shows.""" + block = reader.read(_column(GeneratorName.NOISE)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] is None + assert block.transposes[_key(SubColumn.TRANSPOSE)] is None + assert block.volumes[_key(SubColumn.VOLUME)] is None + + def test_a_cut_cell_carries_the_cut( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + logic.cut_note(0, GeneratorName.PULSE1) + + block = reader.read(_cell(0, GeneratorName.PULSE1, SubColumn.INSTRUMENT)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] == NoteOff() + + def test_a_zero_transpose_carries_as_the_value_it_is( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """An explicit zero resets the channel's transpose, so it is a value and not an absence.""" + logic.set_cell_subcolumn(0, GeneratorName.PULSE2, transpose=0) + + block = reader.read(_cell(0, GeneratorName.PULSE2, SubColumn.TRANSPOSE)) + + assert block.transposes[_key(SubColumn.TRANSPOSE)] == 0 + + def test_rows_past_the_pattern_read_empty( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """A region reaching past the rows a pattern holds takes emptiness from beyond its end.""" + logic.set_rows_per_pattern(2) + logic.set_cell_subcolumn(0, GeneratorName.PULSE1, volume=4) + + block = reader.read(_column(GeneratorName.PULSE1, last_row=3)) + + assert block.row_count == 4 + assert block.volumes[_key(SubColumn.VOLUME)] == 4 + assert block.volumes[_key(SubColumn.VOLUME, 2)] is None + assert block.volumes[_key(SubColumn.VOLUME, 3)] is None + + +class TestSampleColumn: + """The sample column answers for the channels it governs, agreeing or reading as nothing.""" + + def test_a_value_every_governed_channel_shares_carries_over( + self, + controller: ProjectController, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + sample = controller.add_sample( + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + name="lead", + ) + logic.place_note(0, None, sample.id) + logic.set_cell_subcolumn(0, None, transpose=7) + + block = reader.read(_column(None)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id + assert block.transposes[_key(SubColumn.TRANSPOSE)] == 7 + + def test_a_note_carries_as_the_sample_it_names( + self, + controller: ProjectController, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """The channels hold instruments of their own, and the block keeps the sample they share.""" + sample = controller.add_sample( + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]), + name="chord", + ) + logic.place_note(0, None, sample.id) + + block = reader.read(_cell(0, None, SubColumn.INSTRUMENT)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] == sample.id + + def test_a_column_its_channels_disagree_over_leaves_its_key_out( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """No sample governs the row, so the column spans every channel and only one holds a value.""" + logic.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=5) + + block = reader.read(_cell(0, None, SubColumn.TRANSPOSE)) + + assert _key(SubColumn.TRANSPOSE) not in block.transposes + + def test_a_half_cut_row_leaves_its_note_out( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + logic.cut_note(0, GeneratorName.PULSE1) + + block = reader.read(_cell(0, None, SubColumn.INSTRUMENT)) + + assert _key(SubColumn.INSTRUMENT) not in block.notes + + def test_a_wholly_cut_row_carries_the_cut( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + logic.cut_note(0, None) + + block = reader.read(_cell(0, None, SubColumn.INSTRUMENT)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] == NoteOff() + + def test_an_untouched_row_carries_its_emptiness( + self, + reader: TrackerBlockReader, + ) -> None: + """Every channel is equally empty, which is a reading they agree on.""" + block = reader.read(_column(None)) + + assert block.notes[_key(SubColumn.INSTRUMENT)] is None + assert block.transposes[_key(SubColumn.TRANSPOSE)] is None + assert block.volumes[_key(SubColumn.VOLUME)] is None + + +class TestExtent: + """A block states the rectangle it was read from, whatever the cells in it turned out to hold.""" + + def test_a_mixed_edge_column_keeps_its_place_in_the_block( + self, + logic: SequencerTrackerLogic, + reader: TrackerBlockReader, + ) -> None: + """The last slot reads as nothing, and the extent is what still states the block reaches it.""" + logic.set_cell_subcolumn(0, GeneratorName.PULSE1, volume=2) + + block = reader.read( + TrackerRegion( + first_row=0, + last_row=0, + first_slot=_slot(None, SubColumn.INSTRUMENT), + last_slot=_slot(None, SubColumn.VOLUME), + ) + ) + + assert (block.first_slot, block.last_slot) == (0, 2) + assert block.slot_count == 3 + assert _key(SubColumn.VOLUME) not in block.volumes + + def test_the_offsets_are_measured_from_the_column_the_block_begins_in( + self, + reader: TrackerBlockReader, + ) -> None: + """A block beginning midway through a column keeps that column's base as its own zero. + + The offsets stay a whole column apart from the kind they address, which is what lands + each value in a subcolumn of its own kind wherever the block is written. + """ + block = reader.read( + TrackerRegion( + first_row=0, + last_row=0, + first_slot=_slot(GeneratorName.PULSE2, SubColumn.TRANSPOSE), + last_slot=_slot(GeneratorName.TRIANGLE, SubColumn.INSTRUMENT), + ) + ) + + assert (block.first_slot, block.last_slot) == (1, 3) + assert set(block.transposes) == {(0, 1)} + assert set(block.volumes) == {(0, 2)} + assert set(block.notes) == {(0, 3)} diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py index 44e42ace0..52987c990 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py @@ -1,52 +1,20 @@ -from pathlib import Path -from typing import List - -import numpy as np - from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName from sampletones_core.constants.general import MAX_TRANSPOSE, MAX_VOLUME -from sampletones_core.instructions import PulseInstruction from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import Row -from sampletones_core.reconstructions import Reconstruction from sampletones_shared.constants.symbols import MIXED - -_LENGTH = 64 +from tests.suite.sequencer import sample_reconstruction def _controller() -> ProjectController: return ProjectController(ProjectManager()) -def _reconstruction(generators: List[GeneratorName]) -> Reconstruction: - instructions = { - generator: [ - PulseInstruction( - on=True, - pitch=60, - volume=8, - duty_cycle=0, - ) - ] - for generator in generators - } - approximations = {generator: np.zeros(_LENGTH, dtype=np.float32) for generator in generators} - return Reconstruction.create( - approximation=np.zeros(_LENGTH, dtype=np.float32), - approximations=approximations, - instructions=instructions, - config=Config(), - coefficient=1.0, - audio_filepath=Path("/dev/null"), - ) - - def _row( controller: ProjectController, generator: GeneratorName, @@ -110,7 +78,7 @@ def test_the_sample_column_clears_instruments_from_every_channel(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -125,7 +93,7 @@ def test_the_sample_column_clears_transpose_from_the_sample_channels(self) -> No controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -146,7 +114,7 @@ def test_a_sample_in_the_sample_column_spreads_over_its_channels(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) @@ -163,7 +131,7 @@ def test_a_sample_in_a_channel_cell_is_named_for_that_channel(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1]), + sample_reconstruction([GeneratorName.PULSE1]), name="lead", ) @@ -241,7 +209,7 @@ def test_the_sample_column_shifts_the_sample_channels(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -303,7 +271,7 @@ def test_one_placement_reports_the_samples_whole_span(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) _place_instrument(controller, GeneratorName.PULSE1, sample.id) @@ -351,7 +319,7 @@ def test_fills_only_used_generators(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) @@ -370,7 +338,7 @@ def test_clears_channels_the_new_sample_does_not_use(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) stale = controller.add_sample( - _reconstruction([GeneratorName.PULSE2]), + sample_reconstruction([GeneratorName.PULSE2]), name="bass", ) pattern_index = controller.project.song.order[0][GeneratorName.PULSE2] @@ -386,7 +354,7 @@ def test_clears_channels_the_new_sample_does_not_use(self) -> None: ) lead = controller.add_sample( - _reconstruction([GeneratorName.PULSE1]), + sample_reconstruction([GeneratorName.PULSE1]), name="lead", ) logic.set_sample_instrument(0, lead.id) @@ -400,7 +368,7 @@ def test_none_sample_clears_the_whole_row(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1]), + sample_reconstruction([GeneratorName.PULSE1]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -418,7 +386,7 @@ def test_synchronises_across_relevant_channels_even_without_instrument( controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) _place_instrument(controller, GeneratorName.PULSE1, sample.id) @@ -459,7 +427,7 @@ def test_clear_removes_one_subcolumn_across_relevant_channels(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -505,7 +473,7 @@ def test_clamps_to_max_transpose(self) -> None: def test_preserves_instrument_and_volume(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead") _place_instrument(controller, GeneratorName.PULSE1, sample.id) logic.adjust_volume(GeneratorName.PULSE1, 0, -1) @@ -549,7 +517,7 @@ def test_sample_transpose_shifts_only_relevant_channels(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -566,7 +534,7 @@ def test_sample_volume_steps_relevant_channels_down_from_full(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -582,7 +550,7 @@ def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) _place_instrument(controller, GeneratorName.PULSE1, sample.id) @@ -595,7 +563,7 @@ def test_full_placement_reads_as_the_sample(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -609,7 +577,7 @@ def test_diverging_transpose_renders_as_mixed(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) @@ -623,7 +591,7 @@ def test_shared_transpose_is_reflected_in_the_sample_column(self) -> None: controller = _controller() logic = SequencerTrackerLogic(controller) sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), name="lead", ) logic.set_sample_instrument(0, sample.id) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index 34644f673..48289f08e 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -2,6 +2,7 @@ from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName @@ -134,6 +135,25 @@ def test_collapse_keeps_the_cursor_where_it_stands(self) -> None: assert collapsed.region is None +class TestTargetRegion: + """The region a block gesture acts on, which is the selection wherever one has been made.""" + + def test_a_cursor_alone_targets_its_own_cell(self) -> None: + region = _state(SubColumn.TRANSPOSE, row=4).target_region + + assert region is not None + assert (region.first_row, region.last_row) == (4, 4) + assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),) + + def test_a_selection_is_targeted_whole(self) -> None: + selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT) + + assert selected.target_region == selected.region + + def test_a_grid_with_no_cursor_targets_nothing(self) -> None: + assert TrackerInputState().target_region is None + + class TestColumnNavigation: def test_tab_preserves_subcolumn(self) -> None: state = _state(SubColumn.VOLUME, generator=GeneratorName.PULSE1) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py new file mode 100644 index 000000000..4fbbe4077 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -0,0 +1,80 @@ +from typing import List, Optional + +import pytest + +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.combination import KeyCombination +from sampletones_application.utils.gui.keyboard.event import KeyEvent +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from tests.suite.shortcuts import shipped_source + +ROW_COUNT = 64 +CURSOR_ROW = 4 + + +def _press(text: str) -> KeyEvent: + """The press a written combination names, as the router delivers it.""" + combination = KeyCombination.parse(text) + return KeyEvent(key=combination.key, modifiers=combination.modifiers) + + +def _panel( + monkeypatch: pytest.MonkeyPatch, + regions: List[TrackerRegion], + *, + generator: Optional[GeneratorName] = GeneratorName.PULSE1, + subcolumn: SubColumn = SubColumn.INSTRUMENT, +) -> GUISequencerTrackerPanel: + """A tracker panel reporting the blocks it copies, with its grid left unbuilt. + + Applying a state draws into DearPyGui, which has no table here, so the draw is left out and + the gesture is read from the regions the copy hook receives. + """ + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._shortcuts = shipped_source() + panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, generator, subcolumn)) + panel._current_row_count = ROW_COUNT + panel.on_copy_block = regions.append + monkeypatch.setattr(panel, "_apply_state", lambda state: None) + return panel + + +class TestTrackerCopyKey: + def test_a_selection_is_copied_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + regions: List[TrackerRegion] = [] + panel = _panel(monkeypatch, regions) + panel._input_state = panel._input_state.extend_row(2, ROW_COUNT) + + assert panel._on_key_pressed(_press("Ctrl+C")) is True + assert regions == [ + TrackerRegion( + first_row=CURSOR_ROW, + last_row=CURSOR_ROW + 2, + first_slot=3, + last_slot=3, + ) + ] + + def test_a_cursor_alone_copies_the_cell_it_stands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + regions: List[TrackerRegion] = [] + panel = _panel(monkeypatch, regions, subcolumn=SubColumn.VOLUME) + + assert panel._on_key_pressed(_press("Ctrl+C")) is True + assert regions[-1].rows == range(CURSOR_ROW, CURSOR_ROW + 1) + assert regions[-1].slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) + + def test_a_grid_with_no_cursor_copies_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + regions: List[TrackerRegion] = [] + panel = _panel(monkeypatch, regions) + panel._input_state = TrackerInputState() + + assert panel._on_key_pressed(_press("Ctrl+C")) is False + assert regions == [] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py index bd79ee363..b776b479b 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -2,14 +2,14 @@ import pytest +from sampletones_application.layout.config import LayoutConfig +from sampletones_application.layout.loader import load_layout_config from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.paths import ( BEHAVIOR_DIRECTORY, LAYOUT_DIRECTORY, PALETTES_DIRECTORY, ) -from sampletones_application.layout.config import LayoutConfig -from sampletones_application.layout.loader import load_layout_config from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.order import ( 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 index e755baf85..31af3d6fa 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -341,10 +341,10 @@ def test_a_note_key_types_into_the_cell_under_the_cursor(self, monkeypatch: pyte 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.""" + """Ctrl+D opens the display settings, so cell entry keeps the plain hex key alone.""" panel = _panel() states: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) - assert panel._on_key_pressed(_press("Ctrl+C")) is False + assert panel._on_key_pressed(_press("Ctrl+D")) is False assert states == [] From a6c3a0ec148d6a9c7400b137a6614b747d01b29e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 21:39:29 +0200 Subject: [PATCH 075/152] Added: tracker block cut, paste and delete --- .../categories/elements/settings.py | 2 + .../coordinators/tabs/sequencer.py | 33 +- .../logic/history/action.py | 3 + .../logic/sequencer/history_detail.py | 32 ++ .../logic/sequencer/tracker/__init__.py | 2 + .../logic/sequencer/tracker/tracker.py | 4 + .../logic/sequencer/tracker/writer.py | 144 ++++++ .../ui/panels/sequencer/tracker.py | 45 +- .../utils/gui/shortcuts/ids.py | 2 + .../keybindings/default.yaml | 2 + src/sampletones_config/keybindings/macos.yaml | 2 + src/sampletones_config/lang/en.yaml | 5 + tests/suite/sequencer.py | 225 ++++++++- .../coordinators/tabs/test_sequencer.py | 108 ++++- .../logic/sequencer/test_history_detail.py | 90 ++-- .../logic/sequencer/tracker/test_writer.py | 441 ++++++++++++++++++ .../ui/panels/sequencer/test_block_keys.py | 122 ++++- 17 files changed, 1196 insertions(+), 66 deletions(-) create mode 100644 src/sampletones_application/logic/sequencer/tracker/writer.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index e4257bc6a..74a7c16df 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -124,6 +124,8 @@ class KeybindingActionElements(AbstractElement): TRACKER_EXTEND_SELECTION_TO_FIRST_ROW = "tracker_extend_selection_to_first_row" TRACKER_EXTEND_SELECTION_TO_LAST_ROW = "tracker_extend_selection_to_last_row" TRACKER_COPY_BLOCK = "tracker_copy_block" + TRACKER_CUT_BLOCK = "tracker_cut_block" + TRACKER_PASTE_BLOCK = "tracker_paste_block" TRACKER_PAGE_UP = "tracker_page_up" TRACKER_PAGE_DOWN = "tracker_page_down" TRACKER_CLEAR_ROW = "tracker_clear_row" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 182fdedf5..39b3bc2de 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -37,6 +37,7 @@ from sampletones_application.logic.sequencer.tracker import ( SequencerTrackerLogic, TrackerBlockReader, + TrackerBlockWriter, ) from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.sequencer import SequencerTabParameters @@ -86,7 +87,7 @@ HistoryEntryViewModel, HistoryViewModel, ) -from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) @@ -185,6 +186,7 @@ def __init__( self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller) self._clipboard: SequencerClipboard = SequencerClipboard() self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic) + self._tracker_block_writer: TrackerBlockWriter = TrackerBlockWriter(self._sequencer_tracker_logic) self._sequencer_samples_logic: SequencerSamplesLogic = SequencerSamplesLogic( project_controller, session_manager, @@ -447,14 +449,41 @@ def _wire_block_callbacks(self) -> None: A copy reads the project and leaves it as it stands, so it is wired straight through instead of through :meth:`_undoable`: a transaction over it would record an entry the - history has nothing to restore for. + history has nothing to restore for. The three gestures that do write are whole ones, each + recording the single entry that takes the grid back to where it stood. """ self._sequencer_tracker_panel.on_copy_block = self._on_tracker_copy_block + self._sequencer_tracker_panel.on_cut_block = self._undoable( + HistoryAction.CUT_BLOCK, + self._cut_tracker_block, + detail=self._history_detail.tracker_block, + ) + self._sequencer_tracker_panel.on_delete_block = self._undoable( + HistoryAction.DELETE_BLOCK, + self._tracker_block_writer.clear, + detail=self._history_detail.tracker_block, + ) + self._sequencer_tracker_panel.on_paste_block = self._undoable( + HistoryAction.PASTE_BLOCK, + self._paste_tracker_block, + detail=self._history_detail.tracker_paste, + ) def _on_tracker_copy_block(self, region: TrackerRegion) -> None: """Puts the tracker's selected block on the clipboard, for a paste to replay.""" self._clipboard.store_tracker_block(self._tracker_block_reader.read(region)) + def _cut_tracker_block(self, region: TrackerRegion) -> None: + """Takes the block a region covers onto the clipboard, then empties what it covered.""" + self._on_tracker_copy_block(region) + self._tracker_block_writer.clear(region) + + def _paste_tracker_block(self, cell: TrackerCell) -> None: + """Writes the block the tracker last copied at a cell, while a copy has been made.""" + block = self._clipboard.tracker_block + if block is not None: + self._tracker_block_writer.write(block, cell) + def _wire_samples_callbacks(self) -> None: self._sequencer_samples_logic.on_samples_changed = self._on_samples_changed self._sequencer_samples_logic.on_edit_sample_requested = self._dispatch_edit_sample diff --git a/src/sampletones_application/logic/history/action.py b/src/sampletones_application/logic/history/action.py index 9a40d5231..6a6074744 100644 --- a/src/sampletones_application/logic/history/action.py +++ b/src/sampletones_application/logic/history/action.py @@ -15,6 +15,9 @@ class HistoryAction(AbstractElement): CLEAR_SUBCOLUMN = "clear_subcolumn" ADJUST_TRANSPOSE = "adjust_transpose" ADJUST_VOLUME = "adjust_volume" + CUT_BLOCK = "cut_block" + PASTE_BLOCK = "paste_block" + DELETE_BLOCK = "delete_block" ADD_FRAME = "add_frame" REMOVE_FRAME = "remove_frame" DUPLICATE_FRAME = "duplicate_frame" diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 354e92a32..4b850bdd0 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -2,6 +2,7 @@ from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( HistoryDetail, @@ -20,6 +21,7 @@ Segments = HistoryDetail _ARROW: Final[str] = ">" +_RANGE: Final[str] = "-" _SUBCOLUMN_LETTERS: Final[Dict[SubColumn, str]] = { SubColumn.INSTRUMENT: "i", SubColumn.TRANSPOSE: "t", @@ -154,6 +156,18 @@ def adjust_volume( segments.append(self._segment(f"{delta:+d}", HistoryDetailRole.VOLUME)) return tuple(segments) + def tracker_block(self, region: TrackerRegion) -> Segments: + """Reads as the frame, the channels a block spans and the rows it covers.""" + return ( + self._frame(self._tracker_logic.frame_index), + self._channel(self._region_generators(region)), + self._row_range(region.first_row, region.last_row), + ) + + def tracker_paste(self, cell: TrackerCell) -> Segments: + """Reads as the cell a block was written from, the one place a paste chooses.""" + return self._location(cell.row, cell.generator, GeneratorName.items()) + def add_frame(self, position: int) -> Segments: return (self._frame(position + 1),) @@ -304,6 +318,24 @@ def _row(self, index: int) -> HistoryDetailSegment: role=HistoryDetailRole.ROW, ) + def _row_range(self, first_row: int, last_row: int) -> HistoryDetailSegment: + """Reads a span of rows as one row token, a single row standing as its own index.""" + if first_row == last_row: + return self._row(first_row) + + return HistoryDetailSegment( + text=f"{display_id(first_row)}{_RANGE}{display_id(last_row)}", + role=HistoryDetailRole.ROW, + ) + + def _region_generators(self, region: TrackerRegion) -> List[GeneratorName]: + """The channels a region reaches, the sample column standing for every one it governs.""" + covered = {slot.generator for slot in region.slots} + if None in covered: + return GeneratorName.items() + + return [generator for generator in GeneratorName.items() if generator in covered] + def _channel(self, generators: List[GeneratorName]) -> HistoryDetailSegment: return HistoryDetailSegment( text=abbreviate_generator_names(generators), diff --git a/src/sampletones_application/logic/sequencer/tracker/__init__.py b/src/sampletones_application/logic/sequencer/tracker/__init__.py index 976a0d999..5e3c9ecc3 100644 --- a/src/sampletones_application/logic/sequencer/tracker/__init__.py +++ b/src/sampletones_application/logic/sequencer/tracker/__init__.py @@ -1,10 +1,12 @@ from .block import BlockNote, TrackerBlock from .reader import TrackerBlockReader from .tracker import SequencerTrackerLogic +from .writer import TrackerBlockWriter __all__ = [ "BlockNote", "SequencerTrackerLogic", "TrackerBlock", "TrackerBlockReader", + "TrackerBlockWriter", ] diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index ec99b48f6..9cf7c00ae 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -518,6 +518,10 @@ def select_frame(self, frame_index: int) -> None: self._frame_index = frame_index self.push_tracker() + def holds_sample(self, sample_id: str) -> bool: + """Whether the project holds the sample a note names, which is what makes the note placeable.""" + return self._controller.project.samples.get(sample_id) is not None + def used_generators(self, sample_id: str) -> List[GeneratorName]: """The channels a sample provides instructions for, empty when it is unknown.""" sample = self._controller.project.samples.get(sample_id) diff --git a/src/sampletones_application/logic/sequencer/tracker/writer.py b/src/sampletones_application/logic/sequencer/tracker/writer.py new file mode 100644 index 000000000..5849cc7a0 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/tracker/writer.py @@ -0,0 +1,144 @@ +from typing import Callable, Dict, Optional, TypeVar + +from sampletones_application.logic.sequencer.tracker.block import ( + BlockKey, + BlockNote, + TrackerBlock, +) +from sampletones_application.logic.sequencer.tracker.tracker import ( + SequencerTrackerLogic, +) +from sampletones_application.view_model.sequencer.region import ( + TrackerCell, + TrackerRegion, +) +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + column_slot_base, + slot_from_flat, +) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.project.instruments.note_off import NoteOff + +ValueT = TypeVar("ValueT") + + +class TrackerBlockWriter: + """Replays a block into the shown frame, and empties the cells a region covers. + + Every cell reaches the grid through the single-slot edit that already governs it, so a paste + lands exactly the writes a reader typing the same values by hand would make, sample column + included. + """ + + def __init__(self, tracker_logic: SequencerTrackerLogic) -> None: + self._tracker = tracker_logic + + def write(self, block: TrackerBlock, cell: TrackerCell) -> None: + """Writes a block anchored at a cell, the cell supplying the column and the block the rest. + + Each kind of subcolumn is written in a pass of its own, notes first: a note through the + sample column decides the whole row, so the transposes and volumes sharing that row land + on top of the channels it settled. + """ + base = column_slot_base(cell.generator) + self._write_pass(block.notes, cell, base, self._write_note) + self._write_pass(block.transposes, cell, base, self._write_transpose) + self._write_pass(block.volumes, cell, base, self._write_volume) + + def clear(self, region: TrackerRegion) -> None: + """Empties every subcolumn a region covers, each by the rule its own column follows.""" + for row_index in region.rows: + for slot in region.slots: + self._tracker.clear_cell_subcolumn( + row_index, + slot.generator, + slot.subcolumn, + ) + + def _write_pass( + self, + values: Dict[BlockKey, ValueT], + cell: TrackerCell, + base: int, + write: Callable[[int, Optional[GeneratorName], ValueT], None], + ) -> None: + """Writes one kind of subcolumn across the block, dropping what falls outside the grid. + + Keys are taken in reading order, so a row's sample column is written before the channels + beside it and the more specific write is the one that stands. A row past the frame's last + or a slot past the last column is left out, which clips a block at the edge rather than + wrapping it around. + """ + row_count = self._tracker.frame_row_count() + for (row_offset, slot_offset), value in sorted(values.items()): + row_index = cell.row + row_offset + slot_index = base + slot_offset + if row_index >= row_count or slot_index >= SLOT_COUNT: + continue + + write(row_index, slot_from_flat(slot_index).generator, value) + + def _write_note( + self, + row_index: int, + generator: Optional[GeneratorName], + note: Optional[BlockNote], + ) -> None: + """Writes the note a cell carries: a sample by id, a cut, or the emptiness of neither. + + A sample the project no longer holds leaves the cell as it stands, so a block outliving + the project it was read from writes the notes that still name something and passes over + the rest. + """ + match note: + case NoteOff(): + self._tracker.cut_note(row_index, generator) + case str() as sample_id: + if self._tracker.holds_sample(sample_id): + self._tracker.place_note(row_index, generator, sample_id) + case None: + self._tracker.clear_cell_subcolumn( + row_index, + generator, + SubColumn.INSTRUMENT, + ) + + def _write_transpose( + self, + row_index: int, + generator: Optional[GeneratorName], + transpose: Optional[int], + ) -> None: + if transpose is None: + self._tracker.clear_cell_subcolumn( + row_index, + generator, + SubColumn.TRANSPOSE, + ) + else: + self._tracker.set_cell_subcolumn( + row_index, + generator, + transpose=transpose, + ) + + def _write_volume( + self, + row_index: int, + generator: Optional[GeneratorName], + volume: Optional[int], + ) -> None: + if volume is None: + self._tracker.clear_cell_subcolumn( + row_index, + generator, + SubColumn.VOLUME, + ) + else: + self._tracker.set_cell_subcolumn( + row_index, + generator, + volume=volume, + ) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 50736240a..040ff5361 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -80,7 +80,7 @@ from sampletones_application.view_model.sequencer.channels import ( SequencerChannelsViewModel, ) -from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) @@ -115,7 +115,8 @@ OnAdjustCallback = Callable[[int, Optional[GeneratorName], int], None] OnChannelMuteToggledCallback = Callable[[GeneratorName], None] OnChannelSoloedCallback = Callable[[GeneratorName], None] -OnCopyBlockCallback = Callable[[TrackerRegion], None] +OnBlockRegionCallback = Callable[[TrackerRegion], None] +OnPasteBlockCallback = Callable[[TrackerCell], None] VOLUME_FINE_STEP: Final[int] = 1 @@ -185,7 +186,10 @@ def __init__( self.on_play_from_frame: Optional[OnPlayFromFrameCallback] = None self.on_adjust_transpose: Optional[OnAdjustCallback] = None self.on_adjust_volume: Optional[OnAdjustCallback] = None - self.on_copy_block: Optional[OnCopyBlockCallback] = None + self.on_copy_block: Optional[OnBlockRegionCallback] = None + self.on_cut_block: Optional[OnBlockRegionCallback] = None + self.on_delete_block: Optional[OnBlockRegionCallback] = None + self.on_paste_block: Optional[OnPasteBlockCallback] = None self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None self.on_channels_toggled: Optional[VoidCallback] = None @@ -1486,17 +1490,28 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool: return True def _block_action(self, shortcut_id: ShortcutId) -> bool: - """Acts on the selected block, reporting whether the action was one of its gestures.""" + """Acts on the selected block, reporting whether the action was one of its gestures. + + Delete is a block gesture only while a selection stands: with one it empties what the + selection covers and keeps it, and with none it falls through to clearing the cell under + the cursor, the meaning that key already carries. + """ match shortcut_id: case ShortcutId.TRACKER_COPY_BLOCK: - self._copy_block() + self._region_gesture(self.on_copy_block) + case ShortcutId.TRACKER_CUT_BLOCK: + self._region_gesture(self.on_cut_block) + case ShortcutId.TRACKER_CLEAR_ROW if self._input_state.region is not None: + self._region_gesture(self.on_delete_block) + case ShortcutId.TRACKER_PASTE_BLOCK: + self._paste_block() case _: return False return True - def _copy_block(self) -> None: - """Hands the selected block out to be copied, the cell under the cursor standing for itself. + def _region_gesture(self, callback: Optional[OnBlockRegionCallback]) -> None: + """Hands the selected block out to a gesture, the cell under the cursor standing for itself. A partial entry is committed first, so the block carries the value the reader has just finished typing. @@ -1505,7 +1520,21 @@ def _copy_block(self) -> None: self._apply_state(state) region = state.target_region if region is not None: - self.call(self.on_copy_block, region) + self.call(callback, region) + + def _paste_block(self) -> None: + """Names the cell a block is written from, which is wherever the cursor stands.""" + state = self._committed_state() + self._apply_state(state) + cursor = state.cursor + if cursor is not None: + self.call( + self.on_paste_block, + TrackerCell( + row=cursor.row, + generator=cursor.generator, + ), + ) def _edit_row(self, shortcut_id: ShortcutId) -> bool: """Empties the cell under the cursor or drops a partial entry, reporting whether the action diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 7210de0d1..631f5225d 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -142,6 +142,8 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: ShortcutCategory.TRACKER, ) TRACKER_COPY_BLOCK = ("TrackerCopyBlock", ShortcutCategory.TRACKER) + TRACKER_CUT_BLOCK = ("TrackerCutBlock", ShortcutCategory.TRACKER) + TRACKER_PASTE_BLOCK = ("TrackerPasteBlock", ShortcutCategory.TRACKER) TRACKER_PAGE_UP = ("TrackerPageUp", ShortcutCategory.TRACKER) TRACKER_PAGE_DOWN = ("TrackerPageDown", ShortcutCategory.TRACKER) TRACKER_CLEAR_ROW = ("TrackerClearRow", ShortcutCategory.TRACKER) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index cd6f581f1..8159d5bba 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -102,6 +102,8 @@ bindings: TrackerExtendSelectionToFirstRow: {combination: "Shift+Home"} TrackerExtendSelectionToLastRow: {combination: "Shift+End"} TrackerCopyBlock: {combination: "Ctrl+C"} + TrackerCutBlock: {combination: "Ctrl+X"} + TrackerPasteBlock: {combination: "Ctrl+V"} TrackerPageUp: {combination: "PgUp"} TrackerPageDown: {combination: "PgDn"} TrackerClearRow: {combination: "Del"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index b6a8cea62..9a0187936 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -102,6 +102,8 @@ bindings: TrackerExtendSelectionToFirstRow: {combination: "Shift+Home", aliases: ["Cmd+Shift+Up"]} TrackerExtendSelectionToLastRow: {combination: "Shift+End", aliases: ["Cmd+Shift+Down"]} TrackerCopyBlock: {combination: "Cmd+C"} + TrackerCutBlock: {combination: "Cmd+X"} + TrackerPasteBlock: {combination: "Cmd+V"} TrackerPageUp: {combination: "PgUp", aliases: ["Alt+Up"]} TrackerPageDown: {combination: "PgDn", aliases: ["Alt+Down"]} TrackerClearRow: {combination: "Del", aliases: ["Cmd+Backspace"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index a388681d7..8f0d6df1e 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -530,6 +530,9 @@ sequencer.history.label.clear_row: "Clear row" sequencer.history.label.clear_subcolumn: "Clear column" sequencer.history.label.adjust_transpose: "Adjust transpose" sequencer.history.label.adjust_volume: "Adjust volume" +sequencer.history.label.cut_block: "Cut selection" +sequencer.history.label.paste_block: "Paste selection" +sequencer.history.label.delete_block: "Delete selection" sequencer.history.label.add_frame: "Add frame" sequencer.history.label.remove_frame: "Remove frame" sequencer.history.label.duplicate_frame: "Duplicate frame" @@ -806,6 +809,8 @@ settings.keybindings.label.tracker_extend_selection_right: "Extend selection rig settings.keybindings.label.tracker_extend_selection_to_first_row: "Extend selection to the first row" settings.keybindings.label.tracker_extend_selection_to_last_row: "Extend selection to the last row" settings.keybindings.label.tracker_copy_block: "Copy selection" +settings.keybindings.label.tracker_cut_block: "Cut selection" +settings.keybindings.label.tracker_paste_block: "Paste selection" settings.keybindings.label.tracker_page_up: "Page up" settings.keybindings.label.tracker_page_down: "Page down" settings.keybindings.label.tracker_clear_row: "Clear row" diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index 656f2587f..60f3a63c2 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -1,14 +1,32 @@ from pathlib import Path -from typing import Final, Sequence +from typing import Dict, Final, List, Optional, Sequence, Tuple import numpy as np +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.tracker import BlockNote, SequencerTrackerLogic, TrackerBlock +from sampletones_application.logic.sequencer.tracker.block import BlockKey +from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS +from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName from sampletones_core.instructions import PulseInstruction +from sampletones_core.project.instruments.instrument import Instrument +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.project.patterns.row import NoteCommand from sampletones_core.reconstructions import Reconstruction +from sampletones_core.utils.display import ( + BLANK, + NOTE_BLANK, + NOTE_OFF, + display_id, +) +from sampletones_shared.constants.symbols import MINUS, MIXED, PLUS SAMPLE_LENGTH: Final[int] = 64 +COLUMN_SEPARATOR: Final[str] = "|" +UNKNOWN_SAMPLE: Final[str] = "!!" +UNKNOWN_SAMPLE_ID: Final[str] = "a-sample-no-project-holds" def sample_reconstruction(generators: Sequence[GeneratorName]) -> Reconstruction: @@ -38,3 +56,208 @@ def sample_reconstruction(generators: Sequence[GeneratorName]) -> Reconstruction coefficient=1.0, audio_filepath=Path("/dev/null"), ) + + +def render_frame(tracker_logic: SequencerTrackerLogic) -> Tuple[str, ...]: + """Every row of the frame shown, each read as the four channel cells the grid draws. + + A row is written the way it appears on screen, so an expectation and a screenshot read alike. + The sample column is left out because it holds nothing of its own: it summarises these four, + and stating it again would pin the summary rather than what a gesture wrote. + """ + grid = tracker_logic.build_grid() + return tuple( + f" {COLUMN_SEPARATOR} ".join(row.cells[generator].label for generator in GeneratorName.items()) + for row in grid.rows + ) + + +def render_slots( + controller: ProjectController, + frame_index: int, +) -> str: + """The pattern each channel plays at a frame, which is what tells a blank pattern from none. + + A frame renders the same either way, so this is the reading that shows a write materialising a + pattern the channel had not held before. + """ + frame = controller.project.song.order[frame_index] + return " ".join(display_id(frame.get(generator)) for generator in GeneratorName.items()) + + +def parse_block( + rows: Sequence[str], + *, + first_subcolumn: SubColumn, + sample_ids: Sequence[str], +) -> TrackerBlock: + """Reads a block written the way the grid draws it, one line per row. + + Tokens run from ``first_subcolumn`` and cycle through the subcolumns in order, so a line + carries ``|`` at each column boundary it crosses and the bars are held against the subcolumn + the block begins on. A ``?`` states that the block says nothing about that cell, which is what + leaves it out of the maps entirely. + + A note names its sample by the position the grid prints, resolved through ``sample_ids``; + ``!!`` names a sample no project holds. + + Raises: + ValueError: if the rows differ in width, or a bar falls where no column boundary does. + """ + first_slot = SUBCOLUMNS.index(first_subcolumn) + notes: Dict[BlockKey, Optional[BlockNote]] = {} + transposes: Dict[BlockKey, Optional[int]] = {} + volumes: Dict[BlockKey, Optional[int]] = {} + lines = [_tokens(line, first_slot) for line in rows] + widths = {len(tokens) for tokens in lines} + if len(widths) != 1: + raise ValueError(f"A block's rows differ in width: {sorted(widths)}") + + for row_offset, tokens in enumerate(lines): + for offset, token in enumerate(tokens): + slot_offset = first_slot + offset + key = (row_offset, slot_offset) + if token == MIXED: + continue + + match SUBCOLUMNS[slot_offset % len(SUBCOLUMNS)]: + case SubColumn.INSTRUMENT: + notes[key] = parse_note(token, sample_ids) + case SubColumn.TRANSPOSE: + transposes[key] = parse_transpose(token) + case SubColumn.VOLUME: + volumes[key] = parse_volume(token) + + return TrackerBlock( + row_count=len(rows), + first_slot=first_slot, + last_slot=first_slot + widths.pop() - 1, + notes=notes, + transposes=transposes, + volumes=volumes, + ) + + +def fill_frame( + tracker_logic: SequencerTrackerLogic, + rows: Sequence[str], + *, + sample_ids: Sequence[str], +) -> None: + """Writes a frame stated the way the grid draws it, one channel cell at a time. + + Each cell reaches its own channel, so a setup states the frame it wants while the sample + column's fan-out stays out of it — which leaves the gesture under test the only thing that + exercised it. + """ + for row_index, line in enumerate(rows): + for generator, cell in zip(GeneratorName.items(), line.split(COLUMN_SEPARATOR)): + _fill_cell( + tracker_logic, + row_index, + generator, + cell.split(), + sample_ids, + ) + + +def parse_note( + token: str, + sample_ids: Sequence[str], +) -> Optional[BlockNote]: + """The note a token names: a sample by the position it prints, a cut, or emptiness.""" + if token == display_id(None): + return None + + if token == NOTE_OFF: + return NoteOff() + + if token == UNKNOWN_SAMPLE: + return UNKNOWN_SAMPLE_ID + + return sample_ids[int(token, 16)] + + +def parse_transpose(token: str) -> Optional[int]: + if token == NOTE_BLANK: + return None + + magnitude = int(token[1:], 16) + return -magnitude if token.startswith(MINUS) else magnitude + + +def parse_volume(token: str) -> Optional[int]: + if token == BLANK: + return None + + return int(token, 16) + + +def _fill_cell( + tracker_logic: SequencerTrackerLogic, + row_index: int, + generator: GeneratorName, + tokens: Sequence[str], + sample_ids: Sequence[str], +) -> None: + """Writes the values one channel cell states, passing over a cell that states none. + + A cell is written whole where it carries anything, so the row it lands on materialises exactly + once however many of its subcolumns hold a value. + """ + note = parse_note(tokens[0], sample_ids) + transpose = parse_transpose(tokens[1]) + volume = parse_volume(tokens[2]) + if note is None and transpose is None and volume is None: + return + + tracker_logic.set_row( + generator, + row_index, + command=_command(note, generator), + transpose=transpose, + volume=volume, + ) + + +def _command( + note: Optional[BlockNote], + generator: GeneratorName, +) -> Optional[NoteCommand]: + """The command a note becomes in the channel it is written to, which is what carries its pitch.""" + match note: + case NoteOff(): + return note + case str() as sample_id: + return Instrument(sample_id=sample_id, generator_name=generator) + case None: + return None + + +def _tokens(line: str, first_slot: int) -> List[str]: + """The values a line states, checked against the columns a block starting at ``first_slot`` spans. + + Raises: + ValueError: if a bar falls where no column boundary does. + """ + groups = [group.split() for group in line.split(COLUMN_SEPARATOR)] + tokens = [token for group in groups for token in group] + widths = [len(group) for group in groups] + expected = _column_widths(first_slot, len(tokens)) + if widths != expected: + raise ValueError(f"A block line's columns hold {widths} values where its origin spans {expected}: {line!r}") + + return tokens + + +def _column_widths(first_slot: int, count: int) -> List[int]: + """How many values each column a block spans contributes, the first starting part way in.""" + widths: List[int] = [] + width = len(SUBCOLUMNS) - first_slot + remaining = count + while remaining > 0: + widths.append(min(width, remaining)) + remaining -= widths[-1] + width = len(SUBCOLUMNS) + + return widths diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 13864e48b..d7f88af21 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -20,9 +20,11 @@ SequencerChannelsLogic, ) from sampletones_application.logic.sequencer.clipboard import SequencerClipboard +from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail from sampletones_application.logic.sequencer.tracker import ( SequencerTrackerLogic, TrackerBlockReader, + TrackerBlockWriter, ) from sampletones_application.logic.shared.project_source import snapshot_project from sampletones_application.paths import LANG_EN @@ -31,7 +33,7 @@ from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.modifiers import CTRL, NO_MODIFIERS -from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion from sampletones_application.view_model.sequencer.samples import SampleSelection from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel @@ -1330,10 +1332,11 @@ def test_player_returns_the_guarded_wrapper( @pytest.fixture def block_coordinator() -> SequencerTabCoordinator: - """A coordinator whose copy path is real, from the tracker logic through to the clipboard. + """A coordinator whose block path is real, from the tracker logic through to the clipboard. A real manager observes the same controller production wires it to, so a test reads the - entries a gesture actually records. + entries a gesture actually records, and the hooks are the ones ``_wire_block_callbacks`` + assigns rather than wrappers a test built to look like them. """ instance = object.__new__(SequencerTabCoordinator) controller = ProjectController(ProjectManager()) @@ -1344,9 +1347,28 @@ def block_coordinator() -> SequencerTabCoordinator: instance._sequencer_tracker_logic = SequencerTrackerLogic(controller) instance._clipboard = SequencerClipboard() instance._tracker_block_reader = TrackerBlockReader(instance._sequencer_tracker_logic) + instance._tracker_block_writer = TrackerBlockWriter(instance._sequencer_tracker_logic) + instance._history_detail = SequencerHistoryDetail( + instance._sequencer_tracker_logic, + MagicMock(), + ) + instance._sequencer_tracker_panel = MagicMock() + instance._wire_block_callbacks() return instance +def _place_transpose( + coordinator: SequencerTabCoordinator, + transpose: int, +) -> None: + """Puts one value in the frame, through the same wrapper an edit reaches the history by.""" + edit = coordinator._undoable( + HistoryAction.EDIT_ROW, + coordinator._sequencer_tracker_logic.write_cell, + ) + edit(0, GeneratorName.PULSE1, None, transpose, None) + + class TestBlockCopy: def test_a_copy_fills_the_clipboard_with_the_block_it_covers( self, @@ -1372,14 +1394,84 @@ def test_a_copy_leaves_the_history_stack_as_it_stands( ) -> None: """A gesture that only reads the project records nothing, where the edit beside it does.""" coordinator = block_coordinator - edit = coordinator._undoable( - HistoryAction.EDIT_ROW, - coordinator._sequencer_tracker_logic.write_cell, - ) - edit(0, GeneratorName.PULSE1, None, 5, None) + _place_transpose(coordinator, 5) recorded = len(coordinator._history.entries) coordinator._on_tracker_copy_block(PULSE1_CELL) assert recorded > 0 assert len(coordinator._history.entries) == recorded + + +class TestBlockEdits: + """Each gesture that writes records the one entry that takes the grid back.""" + + def test_a_cut_takes_the_block_and_empties_what_it_covered( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + + coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL) + + block = coordinator._clipboard.tracker_block + assert block is not None + assert block.transposes[(0, 1)] == 5 + assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 0).transpose is None + + def test_a_cut_records_one_entry( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + recorded = len(coordinator._history.entries) + + coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL) + + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.CUT_BLOCK + + def test_a_delete_empties_the_region_in_one_entry( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + recorded = len(coordinator._history.entries) + + coordinator._sequencer_tracker_panel.on_delete_block(PULSE1_CELL) + + assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 0).transpose is None + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.DELETE_BLOCK + + def test_a_paste_writes_the_copied_block_in_one_entry( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + coordinator._on_tracker_copy_block(PULSE1_CELL) + recorded = len(coordinator._history.entries) + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE2)) + + assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE2, 1).transpose == 5 + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK + + def test_a_paste_with_nothing_copied_records_nothing( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """A transaction over a gesture that writes nothing commits nothing, so an empty clipboard + leaves the history where it stood.""" + coordinator = block_coordinator + _place_transpose(coordinator, 5) + recorded = len(coordinator._history.entries) + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE2)) + + assert len(coordinator._history.entries) == recorded 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 f1395a350..eed426e9e 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -1,8 +1,6 @@ -from pathlib import Path from typing import List, Tuple from unittest.mock import MagicMock -import numpy as np import pytest from sampletones_application.logic.project.controller import ProjectController @@ -12,6 +10,8 @@ ) from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( HistoryDetailRole, @@ -19,12 +19,8 @@ HistoryDetailWord, HistoryDetailWordSegment, ) -from sampletones_core.configs import Config from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.instructions import PulseInstruction -from sampletones_core.reconstructions import Reconstruction - -_LENGTH = 64 +from tests.suite.sequencer import sample_reconstruction Pair = Tuple[str, HistoryDetailRole] @@ -33,21 +29,6 @@ def _controller() -> ProjectController: return ProjectController(ProjectManager()) -def _reconstruction(generators: List[GeneratorName]) -> Reconstruction: - instructions = { - generator: [PulseInstruction(on=True, pitch=60, volume=8, duty_cycle=0)] for generator in generators - } - approximations = {generator: np.zeros(_LENGTH, dtype=np.float32) for generator in generators} - return Reconstruction.create( - approximation=np.zeros(_LENGTH, dtype=np.float32), - approximations=approximations, - instructions=instructions, - config=Config(), - coefficient=1.0, - audio_filepath=Path("/dev/null"), - ) - - def _formatter(controller: ProjectController) -> SequencerHistoryDetail: tracker_logic = SequencerTrackerLogic(controller) samples_logic = SequencerSamplesLogic( @@ -66,8 +47,8 @@ def _pairs(segments: Tuple[HistoryDetailSegment, ...]) -> List[Pair]: class TestTrackerDetails: def test_edit_row_single_channel_places_sample(self) -> None: controller = _controller() - controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead") - target = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="bass") + controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead") + target = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="bass") formatter = _formatter(controller) segments = formatter.edit_row(10, GeneratorName.PULSE1, target.id, None, None) @@ -83,7 +64,7 @@ def test_edit_row_single_channel_places_sample(self) -> None: def test_edit_row_sample_column_lists_the_samples_channels(self) -> None: controller = _controller() sample = controller.add_sample( - _reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE, GeneratorName.NOISE]), + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE, GeneratorName.NOISE]), name="chord", ) formatter = _formatter(controller) @@ -137,6 +118,53 @@ def test_clear_subcolumn_names_the_column(self) -> None: ("v", HistoryDetailRole.VOLUME), ] + def test_a_block_reads_as_the_channels_and_the_rows_it_covers(self) -> None: + formatter = _formatter(_controller()) + + segments = formatter.tracker_block( + TrackerRegion( + first_row=4, + last_row=11, + first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE).flat_index, + last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index, + ) + ) + + assert _pairs(segments) == [ + ("00", HistoryDetailRole.FRAME), + ("Pp", HistoryDetailRole.CHANNEL), + ("04-0B", HistoryDetailRole.ROW), + ] + + def test_a_block_reaching_the_sample_column_reads_as_every_channel(self) -> None: + formatter = _formatter(_controller()) + + segments = formatter.tracker_block( + TrackerRegion( + first_row=0, + last_row=0, + first_slot=TrackerSlot(None, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(None, SubColumn.VOLUME).flat_index, + ) + ) + + assert _pairs(segments) == [ + ("00", HistoryDetailRole.FRAME), + ("PpTN", HistoryDetailRole.CHANNEL), + ("00", HistoryDetailRole.ROW), + ] + + def test_a_paste_reads_as_the_cell_it_was_written_from(self) -> None: + formatter = _formatter(_controller()) + + segments = formatter.tracker_paste(TrackerCell(row=3, generator=GeneratorName.NOISE)) + + assert _pairs(segments) == [ + ("00", HistoryDetailRole.FRAME), + ("N", HistoryDetailRole.CHANNEL), + ("03", HistoryDetailRole.ROW), + ] + def test_adjust_transpose_shows_signed_delta(self) -> None: controller = _controller() formatter = _formatter(controller) @@ -205,7 +233,7 @@ def test_add_sample_shows_the_name(self) -> None: def test_remove_sample_shows_position_and_name(self) -> None: controller = _controller() - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="Bass") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") formatter = _formatter(controller) assert _pairs(formatter.remove_sample(sample.id)) == [ @@ -215,7 +243,7 @@ def test_remove_sample_shows_position_and_name(self) -> None: def test_replace_sample_shows_position_and_both_names(self) -> None: controller = _controller() - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="Bass") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") formatter = _formatter(controller) assert _pairs(formatter.replace_sample(sample.id, "Kick")) == [ @@ -236,7 +264,7 @@ def test_rename_sample_shows_old_and_new(self) -> None: def test_move_sample_shows_source_position_and_destination(self) -> None: controller = _controller() - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="Bass") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") formatter = _formatter(controller) assert _pairs(formatter.move_sample(sample.id, 5)) == [ @@ -247,7 +275,7 @@ def test_move_sample_shows_source_position_and_destination(self) -> None: def test_set_sample_loop_stores_the_state_as_a_word_key(self) -> None: controller = _controller() - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="Bass") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="Bass") formatter = _formatter(controller) on_segments = formatter.set_sample_loop(sample.id, True) @@ -272,7 +300,7 @@ def test_value_wraps_a_number(self) -> None: class TestReconstructionDetails: def test_edit_reconstruction_names_position_channel_and_feature(self) -> None: controller = _controller() - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead") formatter = _formatter(controller) segments = formatter.edit_reconstruction(sample.id, GeneratorName.PULSE1, FeatureKey.VOLUME) @@ -301,7 +329,7 @@ def test_every_feature_has_a_letter_and_a_colour_role( role: HistoryDetailRole, ) -> None: controller = _controller() - sample = controller.add_sample(_reconstruction([GeneratorName.PULSE1]), name="lead") + sample = controller.add_sample(sample_reconstruction([GeneratorName.PULSE1]), name="lead") formatter = _formatter(controller) segments = formatter.edit_reconstruction(sample.id, GeneratorName.PULSE1, feature_key) diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py new file mode 100644 index 000000000..349dc4600 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_writer.py @@ -0,0 +1,441 @@ +from dataclasses import dataclass +from typing import Final, Tuple + +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.tracker import ( + SequencerTrackerLogic, + TrackerBlockReader, + TrackerBlockWriter, +) +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.sequencer import ( + fill_frame, + parse_block, + render_frame, + render_slots, + sample_reconstruction, +) + +FRAME_ROWS: Final[int] = 4 +EMPTY: Final[str] = ".. ... . | .. ... . | .. ... . | .. ... ." +LEAD: Final[str] = "00" +BASS: Final[str] = "01" + + +@dataclass(frozen=True, kw_only=True) +class Grid: + """A four-row frame with two samples, the state every paste case starts from.""" + + controller: ProjectController + logic: SequencerTrackerLogic + writer: TrackerBlockWriter + sample_ids: Tuple[str, ...] + + +@pytest.fixture +def grid() -> Grid: + """A frame short enough for a case to state whole, holding a sample over two channels and one + over a third. + + Which channels a sample governs is what the sample column fans a write out over, so the pair + covers both readings: a write that reaches some channels and clears the rest, and a note + written into a channel its own reconstruction leaves out. + """ + controller = ProjectController(ProjectManager()) + logic = SequencerTrackerLogic(controller) + logic.set_rows_per_pattern(FRAME_ROWS) + lead = controller.add_sample( + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]), + name="lead", + ) + bass = controller.add_sample( + sample_reconstruction([GeneratorName.TRIANGLE]), + name="bass", + ) + return Grid( + controller=controller, + logic=logic, + writer=TrackerBlockWriter(logic), + sample_ids=(lead.id, bass.id), + ) + + +class TestPaste(BaseTestSuite): + """What a block writes where it lands, stated as the whole frame it leaves behind. + + A block carries the subcolumn offsets it was read at while the cell it is written from supplies + only a row and a column, so every case states its origin as that pair: which subcolumn the + cursor happened to stand in cannot reach the result. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + block: Tuple[str, ...] + first_subcolumn: SubColumn + origin: TrackerCell + expected: Tuple[str, ...] + frame: Tuple[str, ...] = () + + test_cases = ( + TestCase( + label="a block keeps its own kinds wherever the cursor stands", + block=("+02 8",), + first_subcolumn=SubColumn.TRANSPOSE, + origin=TrackerCell(row=1, generator=GeneratorName.PULSE2), + expected=( + EMPTY, + ".. ... . | .. +02 8 | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a sample through the sample column reaches its channels and clears the rest", + frame=(".. ... . | .. ... . | .. ... . | .. ... 5",), + block=(LEAD,), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=None), + expected=( + "00 ... . | 00 ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a channel beside the sample column overwrites what it settled", + block=(f"{LEAD} ... . | {BASS}",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=None), + expected=( + "01 ... . | 00 ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a block read from the sample column writes one channel when written to one", + block=(LEAD,), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=GeneratorName.TRIANGLE), + expected=( + ".. ... . | .. ... . | 00 ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a mixed cell leaves its target as it stands while its neighbours clear theirs", + frame=("00 +03 7 | .. ... . | .. ... . | .. ... .",), + block=(".. ? .",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + expected=( + ".. +03 . | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="an explicit zero transpose lands while an empty one clears", + frame=(".. +03 . | .. +05 . | .. ... . | .. ... .",), + block=("+00 ? | ? ...",), + first_subcolumn=SubColumn.TRANSPOSE, + origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + expected=( + ".. +00 . | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a cut through the sample column cuts every channel", + frame=("00 ... . | 00 ... . | .. ... . | .. ... .",), + block=("~~",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=None), + expected=( + "~~ ... . | ~~ ... . | ~~ ... . | ~~ ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a note naming an absent sample writes nothing into a channel", + frame=("00 +02 5 | .. ... . | .. ... . | .. ... .",), + block=("!! ? ?",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + expected=( + "00 +02 5 | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a note naming an absent sample clears nothing through the sample column", + frame=("00 ... . | 00 ... . | .. ... . | .. ... 5",), + block=("!!",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=None), + expected=( + "00 ... . | 00 ... . | .. ... . | .. ... 5", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="an empty instrument through the sample column clears every channel", + frame=("00 ... . | 00 ... . | .. ... . | ~~ ... .",), + block=("..",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=None), + expected=(EMPTY, EMPTY, EMPTY, EMPTY), + ), + TestCase( + label="an empty transpose through an ungoverned sample column clears every channel", + frame=(".. +02 . | .. +02 . | .. +02 . | .. +02 .",), + block=("...",), + first_subcolumn=SubColumn.TRANSPOSE, + origin=TrackerCell(row=0, generator=None), + expected=(EMPTY, EMPTY, EMPTY, EMPTY), + ), + TestCase( + label="a transpose through a governed sample column reaches its channels alone", + frame=("00 ... . | 00 ... . | .. ... . | .. ... .",), + block=("+02",), + first_subcolumn=SubColumn.TRANSPOSE, + origin=TrackerCell(row=0, generator=None), + expected=( + "00 +02 . | 00 +02 . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a silent volume writes zero rather than emptiness", + block=("0",), + first_subcolumn=SubColumn.VOLUME, + origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + expected=( + ".. ... 0 | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="rows past the frame's last are dropped rather than wrapped", + block=("+01", "+02", "+03"), + first_subcolumn=SubColumn.TRANSPOSE, + origin=TrackerCell(row=2, generator=GeneratorName.PULSE1), + expected=( + EMPTY, + EMPTY, + ".. +01 . | .. ... . | .. ... . | .. ... .", + ".. +02 . | .. ... . | .. ... . | .. ... .", + ), + ), + TestCase( + label="slots past the last column are dropped rather than wrapped", + block=(f"{LEAD} ... . | {BASS}",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=GeneratorName.NOISE), + expected=( + ".. ... . | .. ... . | .. ... . | 00 ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a wholly mixed block leaves the frame as it stands", + frame=("00 +02 5 | .. ... . | .. ... . | .. ... .",), + block=("? ? ?",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + expected=( + "00 +02 5 | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a wholly empty block empties what it covers", + frame=("00 +02 5 | 00 +02 5 | .. ... . | .. ... .",), + block=(".. ... .",), + first_subcolumn=SubColumn.INSTRUMENT, + origin=TrackerCell(row=0, generator=GeneratorName.PULSE1), + expected=( + ".. ... . | 00 +02 5 | .. ... . | .. ... .", + EMPTY, + EMPTY, + EMPTY, + ), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_frame_after_a_paste( + self, + grid: Grid, + test_case: TestCase, + ) -> None: + fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids) + block = parse_block( + test_case.block, + first_subcolumn=test_case.first_subcolumn, + sample_ids=grid.sample_ids, + ) + + grid.writer.write(block, test_case.origin) + + assert render_frame(grid.logic) == test_case.expected + + +class TestSingleSlotEquivalence: + """A block of one cell writes what typing that cell writes, which is what makes a paste + explainable as the edits it is made of.""" + + def test_a_single_cell_block_matches_the_edit_it_stands_for(self, grid: Grid) -> None: + block = parse_block( + ("+02",), + first_subcolumn=SubColumn.TRANSPOSE, + sample_ids=grid.sample_ids, + ) + grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE1)) + pasted = render_frame(grid.logic) + + typed = _typed_grid() + typed.set_cell_subcolumn(0, GeneratorName.PULSE1, transpose=2) + + assert pasted == render_frame(typed) + + +class TestClear: + """What a delete empties, which is every subcolumn its region covers and nothing beside.""" + + def test_a_region_empties_the_subcolumns_it_covers(self, grid: Grid) -> None: + fill_frame( + grid.logic, + ("00 +02 5 | 00 +03 6 | .. ... . | .. ... .",), + sample_ids=grid.sample_ids, + ) + + grid.writer.clear( + TrackerRegion( + first_row=0, + last_row=0, + first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE).flat_index, + last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index, + ) + ) + + assert render_frame(grid.logic)[0] == "00 ... . | .. +03 6 | .. ... . | .. ... ." + + def test_a_region_over_the_sample_column_empties_the_channels_it_governs(self, grid: Grid) -> None: + fill_frame( + grid.logic, + ("00 +02 5 | 00 +02 5 | .. ... . | .. ... 5",), + sample_ids=grid.sample_ids, + ) + + grid.writer.clear( + TrackerRegion( + first_row=0, + last_row=0, + first_slot=TrackerSlot(None, SubColumn.VOLUME).flat_index, + last_slot=TrackerSlot(None, SubColumn.VOLUME).flat_index, + ) + ) + + assert render_frame(grid.logic)[0] == "00 +02 . | 00 +02 . | .. ... . | .. ... 5" + + +class TestRoundTrip: + """Reading a region, emptying it and writing the block back leaves the frame it came from.""" + + def test_a_block_written_back_at_its_origin_restores_the_frame(self, grid: Grid) -> None: + fill_frame( + grid.logic, + ( + "00 +02 5 | 00 ... . | .. ... . | ~~ ... 3", + ".. ... . | 01 +00 0 | .. +07 . | .. ... .", + ), + sample_ids=grid.sample_ids, + ) + before = render_frame(grid.logic) + region = TrackerRegion( + first_row=0, + last_row=1, + first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index, + ) + block = TrackerBlockReader(grid.logic).read(region) + + grid.writer.clear(region) + grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE1)) + + assert render_frame(grid.logic) == before + + +class TestMaterialisation: + """A paste reaches a channel holding no pattern by giving it one, the way an edit does.""" + + def test_a_frame_holding_no_pattern_gains_one_where_a_block_lands(self, grid: Grid) -> None: + position = grid.controller.project.song.order_length() + grid.controller.append_frame() + grid.logic.select_frame(position) + assert render_slots(grid.controller, position) == ".. .. .. .." + + block = parse_block( + ("+02",), + first_subcolumn=SubColumn.TRANSPOSE, + sample_ids=grid.sample_ids, + ) + grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE2)) + + assert render_slots(grid.controller, position) == ".. 01 .. .." + assert render_frame(grid.logic)[0] == ".. ... . | .. +02 . | .. ... . | .. ... ." + + def test_a_wholly_mixed_block_leaves_a_frame_with_no_patterns_at_all(self, grid: Grid) -> None: + position = grid.controller.project.song.order_length() + grid.controller.append_frame() + grid.logic.select_frame(position) + + block = parse_block( + ("? ? ?",), + first_subcolumn=SubColumn.INSTRUMENT, + sample_ids=grid.sample_ids, + ) + grid.writer.write(block, TrackerCell(row=0, generator=GeneratorName.PULSE2)) + + assert render_slots(grid.controller, position) == ".. .. .. .." + + +def _typed_grid() -> SequencerTrackerLogic: + """A second frame of the same shape, reached through the single-slot edits alone.""" + logic = SequencerTrackerLogic(ProjectController(ProjectManager())) + logic.set_rows_per_pattern(FRAME_ROWS) + return logic diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 4fbbe4077..300fb5c11 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -1,13 +1,15 @@ -from typing import List, Optional +from dataclasses import dataclass, field +from typing import List, Optional, Tuple import pytest +from sampletones_application.ui.elements.table.cells import EditableCells 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.combination import KeyCombination from sampletones_application.utils.gui.keyboard.event import KeyEvent -from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName @@ -17,6 +19,17 @@ CURSOR_ROW = 4 +@dataclass +class Gestures: + """What each block hook was handed, which is the whole of what a press reaches the grid with.""" + + copied: List[TrackerRegion] = field(default_factory=list) + cut: List[TrackerRegion] = field(default_factory=list) + deleted: List[TrackerRegion] = field(default_factory=list) + pasted: List[TrackerCell] = field(default_factory=list) + cleared: List[Tuple[int, Optional[GeneratorName]]] = field(default_factory=list) + + def _press(text: str) -> KeyEvent: """The press a written combination names, as the router delivers it.""" combination = KeyCombination.parse(text) @@ -25,33 +38,38 @@ def _press(text: str) -> KeyEvent: def _panel( monkeypatch: pytest.MonkeyPatch, - regions: List[TrackerRegion], + gestures: Gestures, *, generator: Optional[GeneratorName] = GeneratorName.PULSE1, subcolumn: SubColumn = SubColumn.INSTRUMENT, ) -> GUISequencerTrackerPanel: - """A tracker panel reporting the blocks it copies, with its grid left unbuilt. + """A tracker panel reporting the gestures it fires, with its grid left unbuilt. Applying a state draws into DearPyGui, which has no table here, so the draw is left out and - the gesture is read from the regions the copy hook receives. + each gesture is read from what its hook receives. """ panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._shortcuts = shipped_source() panel._input_state = TrackerInputState(cursor=TrackerCursor(CURSOR_ROW, generator, subcolumn)) panel._current_row_count = ROW_COUNT - panel.on_copy_block = regions.append + panel._editable_cells = EditableCells() + panel.on_copy_block = gestures.copied.append + panel.on_cut_block = gestures.cut.append + panel.on_delete_block = gestures.deleted.append + panel.on_paste_block = gestures.pasted.append + panel.on_clear_row = lambda row, generator_name: gestures.cleared.append((row, generator_name)) monkeypatch.setattr(panel, "_apply_state", lambda state: None) return panel class TestTrackerCopyKey: def test_a_selection_is_copied_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: - regions: List[TrackerRegion] = [] - panel = _panel(monkeypatch, regions) + gestures = Gestures() + panel = _panel(monkeypatch, gestures) panel._input_state = panel._input_state.extend_row(2, ROW_COUNT) assert panel._on_key_pressed(_press("Ctrl+C")) is True - assert regions == [ + assert gestures.copied == [ TrackerRegion( first_row=CURSOR_ROW, last_row=CURSOR_ROW + 2, @@ -64,17 +82,89 @@ def test_a_cursor_alone_copies_the_cell_it_stands_on( self, monkeypatch: pytest.MonkeyPatch, ) -> None: - regions: List[TrackerRegion] = [] - panel = _panel(monkeypatch, regions, subcolumn=SubColumn.VOLUME) + gestures = Gestures() + panel = _panel(monkeypatch, gestures, subcolumn=SubColumn.VOLUME) assert panel._on_key_pressed(_press("Ctrl+C")) is True - assert regions[-1].rows == range(CURSOR_ROW, CURSOR_ROW + 1) - assert regions[-1].slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) + assert gestures.copied[-1].rows == range(CURSOR_ROW, CURSOR_ROW + 1) + assert gestures.copied[-1].slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) def test_a_grid_with_no_cursor_copies_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: - regions: List[TrackerRegion] = [] - panel = _panel(monkeypatch, regions) + gestures = Gestures() + panel = _panel(monkeypatch, gestures) panel._input_state = TrackerInputState() assert panel._on_key_pressed(_press("Ctrl+C")) is False - assert regions == [] + assert gestures.copied == [] + + +class TestTrackerCutKey: + def test_a_selection_is_cut_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures) + panel._input_state = panel._input_state.extend_row(2, ROW_COUNT) + + assert panel._on_key_pressed(_press("Ctrl+X")) is True + assert gestures.cut == [ + TrackerRegion( + first_row=CURSOR_ROW, + last_row=CURSOR_ROW + 2, + first_slot=3, + last_slot=3, + ) + ] + assert gestures.copied == [] + + +class TestTrackerPasteKey: + def test_a_paste_names_the_cell_the_cursor_stands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The cell carries a row and a column alone, so the subcolumn under the cursor is left + for the block to decide.""" + gestures = Gestures() + panel = _panel(monkeypatch, gestures, subcolumn=SubColumn.VOLUME) + + assert panel._on_key_pressed(_press("Ctrl+V")) is True + assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, generator=GeneratorName.PULSE1)] + + def test_the_sample_column_is_a_cell_a_block_lands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures, generator=None) + + assert panel._on_key_pressed(_press("Ctrl+V")) is True + assert gestures.pasted == [TrackerCell(row=CURSOR_ROW, generator=None)] + + +class TestTrackerDeleteKey: + def test_a_selection_is_deleted_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures) + panel._input_state = panel._input_state.extend_row(2, ROW_COUNT) + + assert panel._on_key_pressed(_press("Del")) is True + assert gestures.deleted == [ + TrackerRegion( + first_row=CURSOR_ROW, + last_row=CURSOR_ROW + 2, + first_slot=3, + last_slot=3, + ) + ] + assert gestures.cleared == [] + + def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Delete already means something without a selection, so that meaning is what it keeps.""" + gestures = Gestures() + panel = _panel(monkeypatch, gestures) + + assert panel._on_key_pressed(_press("Del")) is True + assert gestures.deleted == [] + assert gestures.cleared == [(CURSOR_ROW, GeneratorName.PULSE1)] From e859b43d1efbb1253d979b0f5a00ab41ea5d162f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 22:48:45 +0200 Subject: [PATCH 076/152] Added: context menu operations --- CHANGELOG.md | 1 + docs/development/bugs-and-todos.md | 3 +- docs/development/sequencer-blocks.md | 131 ++++++ docs/guide/sequencer.md | 45 +- docs/index.md | 1 + .../categories/elements/sequencer.py | 8 + .../categories/elements/settings.py | 3 + .../coordinators/tabs/sequencer.py | 59 ++- .../logic/sequencer/clipboard.py | 10 + .../logic/sequencer/history_detail.py | 54 ++- .../logic/sequencer/order/__init__.py | 11 + .../logic/sequencer/order/block.py | 25 ++ .../logic/sequencer/{ => order}/order.py | 31 ++ .../logic/sequencer/order/reader.py | 55 +++ .../logic/sequencer/order/writer.py | 71 ++++ .../ui/panels/sequencer/input/order.py | 21 + .../ui/panels/sequencer/order.py | 142 ++++++- .../ui/panels/sequencer/tracker.py | 71 ++++ .../utils/gui/shortcuts/ids.py | 3 + .../view_model/sequencer/region.py | 23 + .../keybindings/default.yaml | 3 + src/sampletones_config/keybindings/macos.yaml | 3 + src/sampletones_config/lang/en.yaml | 11 + tests/suite/sequencer.py | 71 ++++ .../coordinators/tabs/test_sequencer.py | 89 +++- .../logic/sequencer/order/__init__.py | 0 .../logic/sequencer/{ => order}/test_order.py | 41 ++ .../logic/sequencer/order/test_reader.py | 205 +++++++++ .../logic/sequencer/order/test_writer.py | 392 ++++++++++++++++++ .../logic/sequencer/test_history_detail.py | 52 ++- .../sequencer/input/test_order_input.py | 19 + .../ui/panels/sequencer/test_block_keys.py | 160 ++++++- .../ui/panels/sequencer/test_block_menu.py | 327 +++++++++++++++ .../view_model/sequencer/test_region.py | 88 +++- 34 files changed, 2206 insertions(+), 23 deletions(-) create mode 100644 docs/development/sequencer-blocks.md create mode 100644 src/sampletones_application/logic/sequencer/order/__init__.py create mode 100644 src/sampletones_application/logic/sequencer/order/block.py rename src/sampletones_application/logic/sequencer/{ => order}/order.py (74%) create mode 100644 src/sampletones_application/logic/sequencer/order/reader.py create mode 100644 src/sampletones_application/logic/sequencer/order/writer.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/order/__init__.py rename tests/unit/sampletones_application/logic/sequencer/{ => order}/test_order.py (74%) create mode 100644 tests/unit/sampletones_application/logic/sequencer/order/test_reader.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/order/test_writer.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c29505921..7114660cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ * Bumped the reconstruction data-version to `2.1`. * Improved Sequencer module playback. * Added song export to WAV/MP3. +* Added tracker selection operations. ## v0.3.0 [2026-07-31] diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 20ec98d23..4658b213e 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -8,12 +8,11 @@ * Drag and drop * Multiple Reconstruction views * Playing a fragment by clicking on a waveform -* Transpose/note pitch display duality +* Note pitch shown as a transpose offset rather than a note name ### Tracker * Basic shapes as instruments -* Selection operations on patterns and orders ### Workflow diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md new file mode 100644 index 000000000..d88b35294 --- /dev/null +++ b/docs/development/sequencer-blocks.md @@ -0,0 +1,131 @@ +# Sequencer blocks + +A **block** is a rectangle of one sequencer grid, lifted out of the song so it can be +written back somewhere else. Copy, cut, paste and delete are the four gestures over it, +and both grids — the tracker's pattern rows and the order's frames — carry the same set. + +This document states the rules those gestures follow. The layering they sit in is +[Architecture](architecture.md); the conventions the code is held to are the +[coding guidelines](guidelines.md). + +## Three vocabularies, kept apart + +A gesture crosses three representations, and each has one owner: + +| Term | Where it lives | What it names | +|------|----------------|---------------| +| **Cursor** | `ui/panels/sequencer/input/` | Where the reader is typing, plus the anchor a selection was started from | +| **Region** / **Cell** | `view_model/sequencer/region.py` | The rectangle a gesture acts on, and the single cell a paste is anchored at — grid coordinates, inclusive bounds | +| **Block** | `logic/sequencer/tracker/`, `logic/sequencer/order/` | The values themselves, keyed by offsets from the cell they were read at | + +A region names *where*; a block carries *what*. A block holds offsets rather than +coordinates, which is what lets it land anywhere it is anchored. + +Two axes underpin both grids: + +- **`constants/sequencer.py::CHANNEL_AXIS`** — `(None,) + GeneratorName.items()`. Index 0 + is the aggregate column (the tracker's **Sample**, the order's **Master**) and 1 to 4 + are the channels. Both grids lay out along it, so a row index means the same thing in + either. +- **`view_model/sequencer/slot.py::TrackerSlot`** — a column paired with a subcolumn, + readable as a single flat index. Navigation and selection walk the flat index; an edit + addresses the pair. + +## A cell reaches a block in one of three states + +The state is carried by the block's map alone, so every consumer reads it the same way: + +| State | In the map | Written as | +|-------|-----------|------------| +| A value | Key present, holding it | That value | +| Empty | Key present, holding `None` | Emptiness — the target is cleared | +| Mixed | Key absent | Nothing — the target keeps what it had | + +Mixed is what an aggregate cell reads when the channels beneath it disagree, the same +`?` the grid displays. Display and clipboard route through one rule, +`sampletones_shared/utils/agreement.py::Agreement`, so a block states about a cell +exactly what the table it was read from shows there. + +Absence is also what settles the order's growth (below): a column a block says nothing +about reaches nothing. + +## Kind alignment is arithmetic + +A tracker block carries subcolumn offsets measured from `column_slot_base(column)`, and +every base is a multiple of the subcolumn count. An offset therefore addresses the same +kind of subcolumn at whichever column it is replayed against: an instrument value cannot +reach a volume slot. The paste hook takes a `TrackerCell` — a row and a column, with no +subcolumn — so the type states the rule: the anchor decides *where* a block lands and the +block decides *which kind* goes where. + +## A paste is a run of the single-cell edits + +The writers resolve every cell to a method the grid already has: +`SequencerTrackerLogic.place_note` / `cut_note` / `set_cell_subcolumn` / +`clear_cell_subcolumn`, and `SequencerOrderLogic.write_entry`. Nothing about the aggregate +column's fan-out is restated in a writer, so a pasted cell means exactly what the same +value typed by hand means. That is why each write is explainable, and why the aggregate's +rules have one home. + +Two consequences follow from the order the writes are taken in: + +- Within a position, the aggregate row is written before the channels beneath it, so a + channel cell in the same block overwrites what the aggregate settled. The more specific + write wins. +- In the tracker, notes land before the transposes and volumes sharing their row, because + placing a sample through the **Sample** column clears the channels of that row. + +## The order grows to what a paste reaches + +A block pasted past the last frame appends frames, and the rule is stated in terms of +writes rather than the block's shape: the order grows to the last position a write +actually lands at. A `?`-only overrun column appends nothing; one holding an empty cell +appends the frame it silences. Rows clipped at **Noise** take their columns' growth with +them. + +Growth runs before the first write, so one history entry covers the appended frames and +the values in them, and a single undo takes both back. Delete keeps the order's length: +emptied trailing frames stand as silent ones. + +## What a gesture acts on + +- **From the keyboard**: the selection, or — with none up — the cursor's own cell. + `target_region` on each input state is where that fallback lives, so copying one cell + needs no selection made first. +- **From a context menu**: the selection when the menu was raised inside it, and the + clicked cell otherwise (`_menu_region` on each panel, over `Region.covers`). A paste + from a menu anchors at the clicked cell; a paste from the keyboard anchors at the cursor. +- **`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot + share a combination inside a shortcut category, so this branch is the route; it also + matches tracker convention. + +Copy is wired straight through rather than through `_undoable` — it mutates nothing, so a +transaction over it would record an entry with nothing to restore. Cut, delete and paste +each record exactly one entry, and none of them coalesces: a block gesture is already a +whole gesture, and folding two consecutive pastes would hide a repeat the reader performed +on purpose. + +## Dragging a range out + +Both panels read the cell under a held pointer off their own geometry, because DearPyGui +reports no hover for the cells a held pointer passes over. A drag carried past an edge +reads as the edge, so it selects up to it. + +The tracker's row lookup is arithmetic: it takes the first row's top edge and divides by +`layout.tracker.row_height`. That holds only while the rows are evenly pitched, which is +what `CellPadding.y = 0` and `ItemSpacing.y = 0` in `theme/tables/pattern.yaml` are for. +A vertical padding there would drift the lookup further down the grid. The order's +position lookup is arithmetic in the same way, taking its pitch from the first two +columns; its channel lookup walks the rows, because the master row stands apart from the +channels beneath it. + +## Accepted limitations + +- **A rebuilt table has no selection.** Both grids reconstruct their input state on + rebuild, so following playback and the rebuild after a growing paste leave the cursor + and drop the selection. The rows a region named belong to the body that was replaced. +- **The selection stays put after a paste** rather than becoming the pasted footprint. +- **Cross-project paste is lossy in the note column and exact in transpose and volume.** + A slot survives a project close, because it must survive `on_project_replaced`, which + fires on every undo; a note naming a sample the project in place lacks is left out of + the write, and the target keeps what it had. diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 49361de51..28a5287d4 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -36,8 +36,49 @@ the cursor row, and **Play from this frame** to start at the top of the shown fr A song plays a sequence of patterns, and the **Order** grid sets that sequence — one column per position, with a row for the master and each channel. Type an entry -to place a pattern, or right-click a frame to **Insert frame**, **Duplicate**, -**Clear frame**, **Remove**, move it, or **Play from this frame**. +to place a pattern, or right-click a frame for the rest: **Duplicate** repeats the +frame with the patterns it already plays, **Clone** gives the copy patterns of its +own so you can change it on its own, and **Insert frame**, **Clear frame**, +**Remove**, the moves, and **Play from this frame** do what they say. + +## Working on a block + +Both grids take a **selection** — a rectangle of cells you copy, cut, paste, and +delete in one go. Hold `Shift` and press the arrow keys to reach out from the +cursor, or drag the pointer across the cells; `Shift`+click carries the selection to +the cell you click. Any plain move, and `Escape`, puts it away again. + +| Key | Action | +|-----|--------| +| `Shift`+arrows | Reach the selection out a cell at a time | +| `Shift+Home` / `Shift+End` | Reach it to the first or the last row (tracker) or position (order) | +| `Ctrl+C` | Copy | +| `Ctrl+X` | Cut — copy, then empty what was selected | +| `Ctrl+V` | Paste, starting at the cursor | +| `Del` | Empty the selection | + +With nothing selected these act on the cell the cursor stands on, so copying one +cell needs no selection first. The same four sit on each grid's right-click menu: +raised inside a selection they act on the whole of it, raised anywhere else on the +cell you clicked. Each grid keeps its own copy, so a tracker block pastes into the +tracker and an order block into the order. + +A paste is anchored: the block starts at the cell you paste onto and lands the rest +down and to the right of it. + +In the **Tracker**, a block keeps the kinds of the cells it came from — a transpose +lands in a transpose, a volume in a volume, whichever column you paste onto — and +whatever reaches past the last row or the last column is left out. A cell reading +`?`, where the **Sample** column's channels disagree, passes over its target and +leaves what was there; an empty cell empties it. + +In the **Order**, a block pasted past the last frame grows the song to hold it, and +one reaching past the **Noise** row stops there. The **Master** row copies the index +its channels share and reads `?` when they differ, which pasted leaves each channel +as it was. + +Emptying cells keeps the rows and frames they sit in, and every block action is one +step in the history, so a single **Undo** takes it all back. ## Playing the song diff --git a/docs/index.md b/docs/index.md index f53832907..3abf6df15 100644 --- a/docs/index.md +++ b/docs/index.md @@ -56,6 +56,7 @@ The [**development**](development/) section is for contributors. - [Architecture](development/architecture.md) — the application's layers and the contracts between them. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. +- [Sequencer blocks](development/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids. - [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file. - [Configuration](development/config-organization.md) — how the YAML configuration package is laid out. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 51db2e5d9..c5df06bfc 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -30,6 +30,10 @@ class SequencerTrackerElements(AbstractElement): HEADER_SAMPLE = "header_sample" CONTEXT_PLAY = "context_play" CONTEXT_PLAY_FROM_FRAME = "context_play_from_frame" + CONTEXT_COPY = "context_copy" + CONTEXT_CUT = "context_cut" + CONTEXT_PASTE = "context_paste" + CONTEXT_DELETE = "context_delete" CONTEXT_NOTE_OFF = "context_note_off" CONTEXT_SET_INSTRUMENT = "context_set_instrument" CONTEXT_NO_SAMPLES = "context_no_samples" @@ -62,6 +66,10 @@ class SequencerOrderElements(AbstractElement): LABEL_CHANNEL = "label_channel" LABEL_MASTER = "label_master" CONTEXT_PLAY = "context_play" + CONTEXT_COPY = "context_copy" + CONTEXT_CUT = "context_cut" + CONTEXT_PASTE = "context_paste" + CONTEXT_DELETE = "context_delete" CONTEXT_DUPLICATE = "context_duplicate" CONTEXT_CLONE = "context_clone" CONTEXT_INSERT = "context_insert" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 74a7c16df..b4a04dac5 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -95,6 +95,9 @@ class KeybindingActionElements(AbstractElement): ORDER_EXTEND_SELECTION_RIGHT = "order_extend_selection_right" ORDER_EXTEND_SELECTION_TO_FIRST_POSITION = "order_extend_selection_to_first_position" ORDER_EXTEND_SELECTION_TO_LAST_POSITION = "order_extend_selection_to_last_position" + ORDER_COPY_BLOCK = "order_copy_block" + ORDER_CUT_BLOCK = "order_cut_block" + ORDER_PASTE_BLOCK = "order_paste_block" ORDER_MOVE_FRAME_LEFT = "order_move_frame_left" ORDER_MOVE_FRAME_RIGHT = "order_move_frame_right" ORDER_MOVE_FRAME_TO_START = "order_move_frame_to_start" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 39b3bc2de..20486780b 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -25,7 +25,11 @@ from sampletones_application.logic.sequencer.history_detail import ( SequencerHistoryDetail, ) -from sampletones_application.logic.sequencer.order import SequencerOrderLogic +from sampletones_application.logic.sequencer.order import ( + OrderBlockReader, + OrderBlockWriter, + SequencerOrderLogic, +) from sampletones_application.logic.sequencer.playback.playhead import ( remap_after_insert, remap_after_move, @@ -87,7 +91,12 @@ HistoryEntryViewModel, HistoryViewModel, ) -from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) from sampletones_application.view_model.sequencer.samples import ( SequencerSamplesViewModel, ) @@ -187,6 +196,8 @@ def __init__( self._clipboard: SequencerClipboard = SequencerClipboard() self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic) self._tracker_block_writer: TrackerBlockWriter = TrackerBlockWriter(self._sequencer_tracker_logic) + self._order_block_reader: OrderBlockReader = OrderBlockReader(self._sequencer_order_logic) + self._order_block_writer: OrderBlockWriter = OrderBlockWriter(self._sequencer_order_logic) self._sequencer_samples_logic: SequencerSamplesLogic = SequencerSamplesLogic( project_controller, session_manager, @@ -451,7 +462,12 @@ def _wire_block_callbacks(self) -> None: instead of through :meth:`_undoable`: a transaction over it would record an entry the history has nothing to restore for. The three gestures that do write are whole ones, each recording the single entry that takes the grid back to where it stood. + + Each grid also asks whether its own slot holds a block, which is what a menu offering + Paste consults before it is opened. """ + self._sequencer_tracker_panel.can_paste_block = self._can_paste_tracker_block + self._sequencer_order_panel.can_paste_block = self._can_paste_order_block self._sequencer_tracker_panel.on_copy_block = self._on_tracker_copy_block self._sequencer_tracker_panel.on_cut_block = self._undoable( HistoryAction.CUT_BLOCK, @@ -468,6 +484,30 @@ def _wire_block_callbacks(self) -> None: self._paste_tracker_block, detail=self._history_detail.tracker_paste, ) + self._sequencer_order_panel.on_copy_block = self._on_order_copy_block + self._sequencer_order_panel.on_cut_block = self._undoable( + HistoryAction.CUT_BLOCK, + self._cut_order_block, + detail=self._history_detail.order_block, + ) + self._sequencer_order_panel.on_delete_block = self._undoable( + HistoryAction.DELETE_BLOCK, + self._order_block_writer.clear, + detail=self._history_detail.order_block, + ) + self._sequencer_order_panel.on_paste_block = self._undoable( + HistoryAction.PASTE_BLOCK, + self._paste_order_block, + detail=self._history_detail.order_paste, + ) + + def _can_paste_tracker_block(self) -> bool: + """Whether the tracker has a block to write, which is what its Paste item is offered on.""" + return self._clipboard.tracker_block is not None + + def _can_paste_order_block(self) -> bool: + """Whether the order has a block to write, which is what its Paste item is offered on.""" + return self._clipboard.order_block is not None def _on_tracker_copy_block(self, region: TrackerRegion) -> None: """Puts the tracker's selected block on the clipboard, for a paste to replay.""" @@ -484,6 +524,21 @@ def _paste_tracker_block(self, cell: TrackerCell) -> None: if block is not None: self._tracker_block_writer.write(block, cell) + def _on_order_copy_block(self, region: OrderRegion) -> None: + """Puts the order's selected block on the clipboard, for a paste to replay.""" + self._clipboard.store_order_block(self._order_block_reader.read(region)) + + def _cut_order_block(self, region: OrderRegion) -> None: + """Takes the block a region covers onto the clipboard, then silences what it covered.""" + self._on_order_copy_block(region) + self._order_block_writer.clear(region) + + def _paste_order_block(self, cell: OrderCell) -> None: + """Writes the block the order last copied at a cell, while a copy has been made.""" + block = self._clipboard.order_block + if block is not None: + self._order_block_writer.write(block, cell) + def _wire_samples_callbacks(self) -> None: self._sequencer_samples_logic.on_samples_changed = self._on_samples_changed self._sequencer_samples_logic.on_edit_sample_requested = self._dispatch_edit_sample diff --git a/src/sampletones_application/logic/sequencer/clipboard.py b/src/sampletones_application/logic/sequencer/clipboard.py index 0d18f9e5b..5d19bd90d 100644 --- a/src/sampletones_application/logic/sequencer/clipboard.py +++ b/src/sampletones_application/logic/sequencer/clipboard.py @@ -1,5 +1,6 @@ from typing import Optional +from sampletones_application.logic.sequencer.order import OrderBlock from sampletones_application.logic.sequencer.tracker import TrackerBlock @@ -17,6 +18,7 @@ class SequencerClipboard: def __init__(self) -> None: self._tracker_block: Optional[TrackerBlock] = None + self._order_block: Optional[OrderBlock] = None @property def tracker_block(self) -> Optional[TrackerBlock]: @@ -25,3 +27,11 @@ def tracker_block(self) -> Optional[TrackerBlock]: def store_tracker_block(self, block: TrackerBlock) -> None: self._tracker_block = block + + @property + def order_block(self) -> Optional[OrderBlock]: + """The block the order last copied, present once a copy has been made.""" + return self._order_block + + def store_order_block(self, block: OrderBlock) -> None: + self._order_block = block diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 4b850bdd0..125cc1816 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -1,8 +1,13 @@ -from typing import Dict, Final, List, Optional +from typing import Dict, Final, List, Optional, Set from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic -from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( HistoryDetail, @@ -22,6 +27,8 @@ _ARROW: Final[str] = ">" _RANGE: Final[str] = "-" + + _SUBCOLUMN_LETTERS: Final[Dict[SubColumn, str]] = { SubColumn.INSTRUMENT: "i", SubColumn.TRANSPOSE: "t", @@ -50,6 +57,11 @@ } +def _span(first: int, last: int) -> str: + """Reads a run of indices as the pair it lies between.""" + return f"{display_id(first)}{_RANGE}{display_id(last)}" + + class SequencerHistoryDetail: """Builds the coloured detail line for each undoable sequencer gesture. @@ -160,7 +172,7 @@ def tracker_block(self, region: TrackerRegion) -> Segments: """Reads as the frame, the channels a block spans and the rows it covers.""" return ( self._frame(self._tracker_logic.frame_index), - self._channel(self._region_generators(region)), + self._channel(self._covered_channels({slot.generator for slot in region.slots})), self._row_range(region.first_row, region.last_row), ) @@ -168,6 +180,20 @@ def tracker_paste(self, cell: TrackerCell) -> Segments: """Reads as the cell a block was written from, the one place a paste chooses.""" return self._location(cell.row, cell.generator, GeneratorName.items()) + def order_block(self, region: OrderRegion) -> Segments: + """Reads as the positions a block covers and the channels its rows reach.""" + return ( + self._frame_range(region.first_position, region.last_position), + self._channel(self._covered_channels(set(region.generators))), + ) + + def order_paste(self, cell: OrderCell) -> Segments: + """Reads as the cell a block was written from, the one place a paste chooses.""" + return ( + self._frame(cell.position), + self._channel(self._covered_channels({cell.generator})), + ) + def add_frame(self, position: int) -> Segments: return (self._frame(position + 1),) @@ -324,13 +350,27 @@ def _row_range(self, first_row: int, last_row: int) -> HistoryDetailSegment: return self._row(first_row) return HistoryDetailSegment( - text=f"{display_id(first_row)}{_RANGE}{display_id(last_row)}", + text=_span(first_row, last_row), role=HistoryDetailRole.ROW, ) - def _region_generators(self, region: TrackerRegion) -> List[GeneratorName]: - """The channels a region reaches, the sample column standing for every one it governs.""" - covered = {slot.generator for slot in region.slots} + def _frame_range(self, first_position: int, last_position: int) -> HistoryDetailSegment: + """Reads a span of positions as one frame token, a single position standing as its own.""" + if first_position == last_position: + return self._frame(first_position) + + return HistoryDetailSegment( + text=_span(first_position, last_position), + role=HistoryDetailRole.FRAME, + ) + + @staticmethod + def _covered_channels(covered: Set[Optional[GeneratorName]]) -> List[GeneratorName]: + """The channels a run of columns names, an aggregate one standing for all it summarises. + + Both grids carry a column that answers for every channel — the tracker's sample column and + the order's master row — so a gesture reaching one of them reads as the whole set. + """ if None in covered: return GeneratorName.items() diff --git a/src/sampletones_application/logic/sequencer/order/__init__.py b/src/sampletones_application/logic/sequencer/order/__init__.py new file mode 100644 index 000000000..f6dd4e09c --- /dev/null +++ b/src/sampletones_application/logic/sequencer/order/__init__.py @@ -0,0 +1,11 @@ +from .block import OrderBlock +from .order import SequencerOrderLogic +from .reader import OrderBlockReader +from .writer import OrderBlockWriter + +__all__ = [ + "OrderBlock", + "OrderBlockReader", + "OrderBlockWriter", + "SequencerOrderLogic", +] diff --git a/src/sampletones_application/logic/sequencer/order/block.py b/src/sampletones_application/logic/sequencer/order/block.py new file mode 100644 index 000000000..f217848fd --- /dev/null +++ b/src/sampletones_application/logic/sequencer/order/block.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from typing import Dict, Optional, Tuple + +BlockKey = Tuple[int, int] + + +@dataclass(frozen=True) +class OrderBlock: + """A rectangle of the order table, addressed by the offsets it was read at. + + A key is a row offset paired with a position offset, both counted from the cell the block + begins at, so a block carries its own shape and lands wherever it is anchored. + + A cell reaches the block in one of three states, and the map holds them apart: a key carrying + an index plays that pattern, a key carrying ``None`` silences the slot, and an absent key + states that the block says nothing about that cell — which is how a master row its channels + disagree over stays transparent to whatever it is pasted onto. + + Absence also settles how far a paste grows the order: a column the block says nothing about + reaches nothing, so the order ends where the last written column does. + """ + + row_count: int + position_count: int + entries: Dict[BlockKey, Optional[int]] diff --git a/src/sampletones_application/logic/sequencer/order.py b/src/sampletones_application/logic/sequencer/order/order.py similarity index 74% rename from src/sampletones_application/logic/sequencer/order.py rename to src/sampletones_application/logic/sequencer/order/order.py index 0d7e3fb99..15d27ac5e 100644 --- a/src/sampletones_application/logic/sequencer/order.py +++ b/src/sampletones_application/logic/sequencer/order/order.py @@ -50,6 +50,37 @@ def set_master_entry(self, position: int, pattern_index: Optional[int]) -> None: for generator in GeneratorName.items(): self._controller.set_order_entry(generator, position, pattern_index) + def write_entry( + self, + generator: Optional[GeneratorName], + position: int, + pattern_index: Optional[int], + ) -> None: + """Plays a pattern index at a position, the master row settling every channel at once. + + This is the rule the table's two kinds of row follow, kept in one place so a gesture + reaching across them writes what the reader typing into each by hand would. + """ + if generator is None: + self.set_master_entry(position, pattern_index) + else: + self.set_order_entry(generator, position, pattern_index) + + def entry(self, generator: GeneratorName, position: int) -> Optional[int]: + """The pattern index a channel plays at a position, empty past the order's last frame.""" + order = self._controller.song.order + if position >= len(order): + return None + + return order[position].get(generator) + + def position_count(self) -> int: + return self._controller.order_length + + def append_frame(self) -> None: + """Adds one empty frame (all channels silent) after the order's last.""" + self._controller.append_frame() + def remove_from_order(self, position: int) -> None: self._controller.remove_frame(position) diff --git a/src/sampletones_application/logic/sequencer/order/reader.py b/src/sampletones_application/logic/sequencer/order/reader.py new file mode 100644 index 000000000..a81f5d847 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/order/reader.py @@ -0,0 +1,55 @@ +from typing import Dict, Optional + +from sampletones_application.view_model.sequencer.region import OrderRegion +from sampletones_core.constants.enums import GeneratorName +from sampletones_shared.utils.agreement import Agreement + +from .block import BlockKey, OrderBlock +from .order import SequencerOrderLogic + + +class OrderBlockReader: + """Reads a selected region of the order table into a block a paste can replay. + + The block is anchored at the cell the region begins in, so it carries offsets rather than + table coordinates and lands wherever it is written. + """ + + def __init__(self, order_logic: SequencerOrderLogic) -> None: + self._order = order_logic + + def read(self, region: OrderRegion) -> OrderBlock: + """Takes the pattern indices a region covers, keyed by the offsets they stand at. + + A cell holding an index keeps it, a silent one keeps its silence, and a master cell whose + channels disagree leaves its key out — which carries the table's mixed reading over as a + value the paste passes by. + """ + entries: Dict[BlockKey, Optional[int]] = {} + for row_offset, generator in enumerate(region.generators): + for position_offset, position in enumerate(region.positions): + agreement = self._agree(generator, position) + if agreement.is_unanimous: + entries[(row_offset, position_offset)] = agreement.value + + return OrderBlock( + row_count=region.row_count, + position_count=region.position_count, + entries=entries, + ) + + def _agree( + self, + generator: Optional[GeneratorName], + position: int, + ) -> Agreement[Optional[int]]: + """What a row holds at a position: a channel's own index, or the one its channels share. + + A channel row answers for itself, so it is a group of one and always agrees. The master row + answers for every channel, which is the group its display summarises too, so a block states + about a cell exactly what the table it came from shows there. + """ + if generator is not None: + return Agreement.collapse([self._order.entry(generator, position)]) + + return Agreement.collapse(self._order.entry(channel, position) for channel in GeneratorName.items()) diff --git a/src/sampletones_application/logic/sequencer/order/writer.py b/src/sampletones_application/logic/sequencer/order/writer.py new file mode 100644 index 000000000..5e4a90de3 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/order/writer.py @@ -0,0 +1,71 @@ +from typing import List, Optional, Tuple + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion + +from .block import OrderBlock +from .order import SequencerOrderLogic + +OrderWrite = Tuple[int, int, Optional[int]] + + +class OrderBlockWriter: + """Replays a block into the order table, and empties the cells a region covers. + + Every cell reaches the table through the single-entry edit that already governs it, so a paste + lands exactly the writes a reader typing the same indices by hand would make, master row + included. + """ + + def __init__(self, order_logic: SequencerOrderLogic) -> None: + self._order = order_logic + + def write(self, block: OrderBlock, cell: OrderCell) -> None: + """Writes a block anchored at a cell, the order growing to hold what it reaches past its end. + + The whole block is resolved before any of it lands, so the frames a write needs exist by the + time it reaches them and the growth belongs to the same gesture as the writes it carries. + """ + writes = self._resolve(block, cell) + self._grow(writes) + for row, position, pattern_index in writes: + self._order.write_entry(CHANNEL_AXIS[row], position, pattern_index) + + def clear(self, region: OrderRegion) -> None: + """Silences every cell a region covers, each by the rule its own row follows. + + The order keeps its length, so emptying the frames at its end leaves them standing as + silent ones rather than taking positions away from the arrangement. + """ + for generator in region.generators: + for position in region.positions: + self._order.write_entry(generator, position, None) + + def _resolve(self, block: OrderBlock, cell: OrderCell) -> List[OrderWrite]: + """Where each of a block's entries lands, in the reading order they are written in. + + Keys are taken in reading order, so a position's master row is written before the channels + beneath it and the more specific write is the one that stands. A row past the last channel + is left out, which clips a block at the bottom edge rather than wrapping it round to the + master row. + """ + base_row = CHANNEL_AXIS.index(cell.generator) + return [ + (base_row + row_offset, cell.position + position_offset, pattern_index) + for (row_offset, position_offset), pattern_index in sorted(block.entries.items()) + if base_row + row_offset < len(CHANNEL_AXIS) + ] + + def _grow(self, writes: List[OrderWrite]) -> None: + """Appends the frames a block reaches past the order's end. + + The order grows to the last position a write actually lands at, so a column the block says + nothing about appends no frame while one it silences appends the frame it silences. + """ + positions = [position for _, position, _ in writes] + if not positions: + return + + required = max(positions) + 1 + for _ in range(required - self._order.position_count()): + self._order.append_frame() diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index 1c2b6a01a..f62bbb40c 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -66,6 +66,27 @@ def region(self) -> Optional[OrderRegion]: last_position=max(self.anchor.position, self.cursor.position), ) + @property + def target_region(self) -> Optional[OrderRegion]: + """The region a block gesture acts on: the selection, or the cursor's own cell. + + A cursor with nothing selected stands on a block of one cell, so copying reaches the cell + the reader is working in and needs no selection made first. + """ + if self.region is not None: + return self.region + + if self.cursor is None: + return None + + row = CHANNEL_AXIS.index(self.cursor.generator) + return OrderRegion( + first_row=row, + last_row=row, + first_position=self.cursor.position, + last_position=self.cursor.position, + ) + def extend_to(self, cursor: OrderCursor) -> OrderInputState: """Carries the moving end of the selection to ``cursor``, anchoring it where it began. diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index ec7a9917f..69dd1ddbc 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -74,6 +74,7 @@ from sampletones_application.view_model.sequencer.order import ( SequencerOrderTrackerViewModel, ) +from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion from sampletones_core.constants.enums import GeneratorName from sampletones_core.utils.display import display_id from sampletones_shared.types.application import ColorRGBA, Sender @@ -89,6 +90,9 @@ OnSetMasterEntryCallback = Callable[[int, Optional[int]], None] OnChannelMuteToggledCallback = Callable[[GeneratorName], None] OnChannelSoloedCallback = Callable[[GeneratorName], None] +OnBlockRegionCallback = Callable[[OrderRegion], None] +OnPasteBlockCallback = Callable[[OrderCell], None] +CanPasteBlockQuery = Callable[[], bool] MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = { ShortcutId.ORDER_MOVE_FRAME_LEFT: MoveDirection.PREVIOUS, @@ -162,6 +166,11 @@ def __init__( self.on_set_order_entry: Optional[OnSetOrderEntryCallback] = None self.on_set_master_entry: Optional[OnSetMasterEntryCallback] = None self.on_cell_selected: Optional[VoidCallback] = None + self.on_copy_block: Optional[OnBlockRegionCallback] = None + self.on_cut_block: Optional[OnBlockRegionCallback] = None + self.on_delete_block: Optional[OnBlockRegionCallback] = None + self.on_paste_block: Optional[OnPasteBlockCallback] = None + self.can_paste_block: Optional[CanPasteBlockQuery] = None self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None self.on_channels_toggled: Optional[VoidCallback] = None @@ -202,6 +211,10 @@ def label(element: SequencerOrderElements) -> str: return self._label(language_manager, element) self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY) + self._lbl_context_copy = label(SequencerOrderElements.CONTEXT_COPY) + self._lbl_context_cut = label(SequencerOrderElements.CONTEXT_CUT) + self._lbl_context_paste = label(SequencerOrderElements.CONTEXT_PASTE) + self._lbl_context_delete = label(SequencerOrderElements.CONTEXT_DELETE) self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE) self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE) self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT) @@ -972,10 +985,10 @@ def _on_cell_right_clicked( _sender: Sender, app_data: Tuple[int, int], ) -> None: - """Opens the frame-operations menu for the right-clicked frame. + """Opens the frame-operations menu for the right-clicked cell. - The menu acts on the clicked frame directly and leaves the edit cursor (and, while - following playback, the playhead) where it is — right-clicking should not seek. + The menu acts on the clicked cell and its frame directly, and leaves the edit cursor (and, + while following playback, the playhead) where it is — right-clicking should not seek. """ mouse_button, clicked_item = app_data if mouse_button != dpg.mvMouseButton_Right: @@ -985,8 +998,8 @@ def _on_cell_right_clicked( if key is None: return - _, position = key - self._show_context_menu(position) + generator, position = key + self._show_context_menu(generator, position) def _on_label_clicked( self, @@ -1023,7 +1036,11 @@ def _show_channel_menu(self, generator: Optional[GeneratorName]) -> None: dpg.add_separator() self._channel_switch.add_menu_items(generator, self._current_channels) - def _show_context_menu(self, position: int) -> None: + def _show_context_menu( + self, + generator: Optional[GeneratorName], + position: int, + ) -> None: with context_menu(): header = dpg.add_text(display_id(position)) FontRegistry.bind_to_item(header, Font.MONO_BOLD) @@ -1034,6 +1051,8 @@ def _show_context_menu(self, position: int) -> None: shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() + self._add_block_items(generator, position) + dpg.add_separator() dpg.add_menu_item( label=self._lbl_context_duplicate, shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME), @@ -1081,6 +1100,67 @@ def _show_context_menu(self, position: int) -> None: position, ) + def _menu_region( + self, + generator: Optional[GeneratorName], + position: int, + ) -> OrderRegion: + """The block a menu raised on a cell acts on: the selection it stands in, or the cell alone. + + A menu opened inside a selection acts on the whole of it, which is what a reader who has + just dragged a range out expects the actions to reach; one opened anywhere else acts on the + cell it was raised on, the same block the cursor alone stands for. + """ + region = self._input_state.region + if region is not None and region.covers(generator, position): + return region + + row = CHANNEL_AXIS.index(generator) + return OrderRegion( + first_row=row, + last_row=row, + first_position=position, + last_position=position, + ) + + def _add_block_items( + self, + generator: Optional[GeneratorName], + position: int, + ) -> None: + """Builds the clipboard items, acting on the block the menu was raised on. + + Paste is offered once a block has been copied, and it anchors at the clicked cell, so the + menu lands a block where the pointer is while the keys land it under the cursor. Delete + prints no key of its own, because ``Del`` empties a selection while one stands and clears + the cell under the cursor otherwise. + """ + region = self._menu_region(generator, position) + cell = OrderCell( + generator=generator, + position=position, + ) + dpg.add_menu_item( + label=self._lbl_context_copy, + shortcut=self._shortcuts.display(ShortcutId.ORDER_COPY_BLOCK), + callback=lambda: self.call(self.on_copy_block, region), + ) + dpg.add_menu_item( + label=self._lbl_context_cut, + shortcut=self._shortcuts.display(ShortcutId.ORDER_CUT_BLOCK), + callback=lambda: self.call(self.on_cut_block, region), + ) + dpg.add_menu_item( + label=self._lbl_context_paste, + shortcut=self._shortcuts.display(ShortcutId.ORDER_PASTE_BLOCK), + enabled=self.query(self.can_paste_block, default=False), + callback=lambda: self.call(self.on_paste_block, cell), + ) + dpg.add_menu_item( + label=self._lbl_context_delete, + callback=lambda: self.call(self.on_delete_block, region), + ) + def _add_move_item( self, label: str, @@ -1132,6 +1212,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._extend_selection(shortcut_id): return True + if self._block_action(shortcut_id): + return True + if self._edit_cell(shortcut_id): return True @@ -1181,6 +1264,53 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool: return True + def _block_action(self, shortcut_id: ShortcutId) -> bool: + """Acts on the selected block, reporting whether the action was one of its gestures. + + Delete is a block gesture only while a selection stands: with one it empties every cell the + selection covers and keeps it, and with none it falls through to clearing the cell under + the cursor, the meaning that key already carries. + """ + match shortcut_id: + case ShortcutId.ORDER_COPY_BLOCK: + self._region_gesture(self.on_copy_block) + case ShortcutId.ORDER_CUT_BLOCK: + self._region_gesture(self.on_cut_block) + case ShortcutId.ORDER_CLEAR_CELL if self._input_state.region is not None: + self._region_gesture(self.on_delete_block) + case ShortcutId.ORDER_PASTE_BLOCK: + self._paste_block() + case _: + return False + + return True + + def _region_gesture(self, callback: Optional[OnBlockRegionCallback]) -> None: + """Hands the selected block out to a gesture, the cell under the cursor standing for itself. + + A partial entry is committed first, so the block carries the index the reader has just + finished typing. + """ + state = self._committed_state() + self._apply_state(state) + region = state.target_region + if region is not None: + self.call(callback, region) + + def _paste_block(self) -> None: + """Names the cell a block is written from, which is wherever the cursor stands.""" + state = self._committed_state() + self._apply_state(state) + cursor = state.cursor + if cursor is not None: + self.call( + self.on_paste_block, + OrderCell( + generator=cursor.generator, + position=cursor.position, + ), + ) + 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. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 040ff5361..126e4daca 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -117,6 +117,7 @@ OnChannelSoloedCallback = Callable[[GeneratorName], None] OnBlockRegionCallback = Callable[[TrackerRegion], None] OnPasteBlockCallback = Callable[[TrackerCell], None] +CanPasteBlockQuery = Callable[[], bool] VOLUME_FINE_STEP: Final[int] = 1 @@ -190,6 +191,7 @@ def __init__( self.on_cut_block: Optional[OnBlockRegionCallback] = None self.on_delete_block: Optional[OnBlockRegionCallback] = None self.on_paste_block: Optional[OnPasteBlockCallback] = None + self.can_paste_block: Optional[CanPasteBlockQuery] = None self.on_channel_mute_toggled: Optional[OnChannelMuteToggledCallback] = None self.on_channel_soloed: Optional[OnChannelSoloedCallback] = None self.on_channels_toggled: Optional[VoidCallback] = None @@ -242,6 +244,10 @@ def label(element: SequencerTrackerElements) -> str: self._lbl_context_play = label(SequencerTrackerElements.CONTEXT_PLAY) self._lbl_context_play_from_frame = label(SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME) + self._lbl_context_copy = label(SequencerTrackerElements.CONTEXT_COPY) + self._lbl_context_cut = label(SequencerTrackerElements.CONTEXT_CUT) + self._lbl_context_paste = label(SequencerTrackerElements.CONTEXT_PASTE) + self._lbl_context_delete = label(SequencerTrackerElements.CONTEXT_DELETE) 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) @@ -1268,6 +1274,8 @@ def _show_context_menu( shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() + self._add_block_items(row_index, generator, subcolumn) + dpg.add_separator() self._add_instrument_submenu(row_index, generator) dpg.add_menu_item( label=self._lbl_context_note_off, @@ -1280,6 +1288,69 @@ def _show_context_menu( dpg.add_separator() self._add_clear_items(row_index, generator, subcolumn) + def _menu_region( + self, + row_index: int, + generator: Optional[GeneratorName], + subcolumn: SubColumn, + ) -> TrackerRegion: + """The block a menu raised on a cell acts on: the selection it stands in, or the cell alone. + + A menu opened inside a selection acts on the whole of it, which is what a reader who has + just dragged a range out expects the actions to reach; one opened anywhere else acts on the + cell it was raised on, the same block the cursor alone stands for. + """ + slot = TrackerSlot(generator, subcolumn) + region = self._input_state.region + if region is not None and region.covers(row_index, slot): + return region + + return TrackerRegion( + first_row=row_index, + last_row=row_index, + first_slot=slot.flat_index, + last_slot=slot.flat_index, + ) + + def _add_block_items( + self, + row_index: int, + generator: Optional[GeneratorName], + subcolumn: SubColumn, + ) -> None: + """Builds the clipboard items, acting on the block the menu was raised on. + + Paste is offered once a block has been copied, and it anchors at the clicked cell, so the + menu lands a block where the pointer is while the keys land it under the cursor. Delete + prints no key of its own, because ``Del`` empties a selection while one stands and clears + the cell under the cursor otherwise. + """ + region = self._menu_region(row_index, generator, subcolumn) + cell = TrackerCell( + row=row_index, + generator=generator, + ) + dpg.add_menu_item( + label=self._lbl_context_copy, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_COPY_BLOCK), + callback=lambda: self.call(self.on_copy_block, region), + ) + dpg.add_menu_item( + label=self._lbl_context_cut, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_CUT_BLOCK), + callback=lambda: self.call(self.on_cut_block, region), + ) + dpg.add_menu_item( + label=self._lbl_context_paste, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_PASTE_BLOCK), + enabled=self.query(self.can_paste_block, default=False), + callback=lambda: self.call(self.on_paste_block, cell), + ) + dpg.add_menu_item( + label=self._lbl_context_delete, + callback=lambda: self.call(self.on_delete_block, region), + ) + def _add_instrument_submenu( self, row_index: int, diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 631f5225d..a14de3b20 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -107,6 +107,9 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: "OrderExtendSelectionToLastPosition", ShortcutCategory.ORDER, ) + ORDER_COPY_BLOCK = ("OrderCopyBlock", ShortcutCategory.ORDER) + ORDER_CUT_BLOCK = ("OrderCutBlock", ShortcutCategory.ORDER) + ORDER_PASTE_BLOCK = ("OrderPasteBlock", ShortcutCategory.ORDER) ORDER_MOVE_FRAME_LEFT = ("OrderMoveFrameLeft", ShortcutCategory.ORDER) ORDER_MOVE_FRAME_RIGHT = ("OrderMoveFrameRight", ShortcutCategory.ORDER) ORDER_MOVE_FRAME_TO_START = ("OrderMoveFrameToStart", ShortcutCategory.ORDER) diff --git a/src/sampletones_application/view_model/sequencer/region.py b/src/sampletones_application/view_model/sequencer/region.py index 63cac2f1d..25dbccd41 100644 --- a/src/sampletones_application/view_model/sequencer/region.py +++ b/src/sampletones_application/view_model/sequencer/region.py @@ -57,6 +57,9 @@ def row_count(self) -> int: def rows(self) -> range: return range(self.first_row, self.last_row + 1) + def covers_row(self, row: int) -> bool: + return self.first_row <= row <= self.last_row + class TrackerRegion(GridRegion, frozen=True): """A rectangle of the tracker grid: pattern rows crossed with a run of slots. @@ -85,6 +88,14 @@ def slots(self) -> Tuple[TrackerSlot, ...]: """The slots the region covers, each as the column and subcolumn it addresses.""" return tuple(slot_from_flat(index) for index in range(self.first_slot, self.last_slot + 1)) + def covers(self, row: int, slot: TrackerSlot) -> bool: + """Whether a cell of the grid falls inside the rectangle. + + This is what a gesture raised on a cell asks to learn which block it belongs to: one + landing inside a selection acts on the whole of it, and one landing outside acts alone. + """ + return self.covers_row(row) and self.first_slot <= slot.flat_index <= self.last_slot + class OrderRegion(GridRegion, frozen=True): """A rectangle of the order table: channel rows crossed with a run of positions. @@ -119,3 +130,15 @@ def positions(self) -> range: def generators(self) -> Tuple[Optional[GeneratorName], ...]: """The rows the region covers, each as the channel it addresses, master reading ``None``.""" return tuple(CHANNEL_AXIS[row] for row in self.rows) + + def covers( + self, + generator: Optional[GeneratorName], + position: int, + ) -> bool: + """Whether a cell of the table falls inside the rectangle. + + This is what a gesture raised on a cell asks to learn which block it belongs to: one + landing inside a selection acts on the whole of it, and one landing outside acts alone. + """ + return self.covers_row(CHANNEL_AXIS.index(generator)) and position in self.positions diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 8159d5bba..c0a88d7a8 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -72,6 +72,9 @@ bindings: OrderExtendSelectionRight: {combination: "Shift+Right"} OrderExtendSelectionToFirstPosition: {combination: "Shift+Home"} OrderExtendSelectionToLastPosition: {combination: "Shift+End"} + OrderCopyBlock: {combination: "Ctrl+C"} + OrderCutBlock: {combination: "Ctrl+X"} + OrderPasteBlock: {combination: "Ctrl+V"} OrderMoveFrameLeft: {combination: "Alt+Left"} OrderMoveFrameRight: {combination: "Alt+Right"} OrderMoveFrameToStart: {combination: "Alt+Home"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 9a0187936..0b70e7293 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -72,6 +72,9 @@ bindings: OrderExtendSelectionRight: {combination: "Shift+Right"} OrderExtendSelectionToFirstPosition: {combination: "Shift+Home", aliases: ["Cmd+Shift+Left"]} OrderExtendSelectionToLastPosition: {combination: "Shift+End", aliases: ["Cmd+Shift+Right"]} + OrderCopyBlock: {combination: "Cmd+C"} + OrderCutBlock: {combination: "Cmd+X"} + OrderPasteBlock: {combination: "Cmd+V"} OrderMoveFrameLeft: {combination: "Alt+Left"} OrderMoveFrameRight: {combination: "Alt+Right"} OrderMoveFrameToStart: {combination: "Alt+Home", aliases: ["Cmd+Alt+Left"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 8f0d6df1e..6f29a1148 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -450,6 +450,10 @@ 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_copy: "Copy" +sequencer.tracker.label.context_cut: "Cut" +sequencer.tracker.label.context_paste: "Paste" +sequencer.tracker.label.context_delete: "Delete" sequencer.tracker.label.context_note_off: "Note off" sequencer.tracker.label.context_set_instrument: "Set instrument" sequencer.tracker.label.context_no_samples: "No samples" @@ -483,6 +487,10 @@ sequencer.order.label.row_pulse_2: "Pulse 2" sequencer.order.label.row_triangle: "Triangle" sequencer.order.label.row_noise: "Noise" sequencer.order.label.context_play: "Play from this frame" +sequencer.order.label.context_copy: "Copy" +sequencer.order.label.context_cut: "Cut" +sequencer.order.label.context_paste: "Paste" +sequencer.order.label.context_delete: "Delete" sequencer.order.label.context_duplicate: "Duplicate" sequencer.order.label.context_clone: "Clone" sequencer.order.label.context_insert: "Insert frame" @@ -781,6 +789,9 @@ settings.keybindings.label.order_extend_selection_left: "Extend selection left" settings.keybindings.label.order_extend_selection_right: "Extend selection right" settings.keybindings.label.order_extend_selection_to_first_position: "Extend selection to the first position" settings.keybindings.label.order_extend_selection_to_last_position: "Extend selection to the last position" +settings.keybindings.label.order_copy_block: "Copy selection" +settings.keybindings.label.order_cut_block: "Cut selection" +settings.keybindings.label.order_paste_block: "Paste selection" settings.keybindings.label.order_move_frame_left: "Move frame left" settings.keybindings.label.order_move_frame_right: "Move frame right" settings.keybindings.label.order_move_frame_to_start: "Move frame to the start" diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index 60f3a63c2..5ed85b863 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -4,6 +4,8 @@ import numpy as np from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.sequencer.order import OrderBlock, SequencerOrderLogic +from sampletones_application.logic.sequencer.order.block import BlockKey as OrderBlockKey from sampletones_application.logic.sequencer.tracker import BlockNote, SequencerTrackerLogic, TrackerBlock from sampletones_application.logic.sequencer.tracker.block import BlockKey from sampletones_application.view_model.sequencer.slot import SUBCOLUMNS @@ -85,6 +87,75 @@ def render_slots( return " ".join(display_id(frame.get(generator)) for generator in GeneratorName.items()) +def render_order(order_logic: SequencerOrderLogic) -> Tuple[str, ...]: + """Every channel's row of the order, each read as the pattern indices the table draws. + + A row is written the way it appears on screen, so an expectation and a screenshot read alike. + The master row is left out because it holds nothing of its own: it summarises these four, and + stating it again would pin the summary rather than what a gesture wrote. + """ + view_model = order_logic.build_order() + return tuple( + " ".join(view_model.entry_label(generator, position) for position in range(view_model.position_count)) + for generator in GeneratorName.items() + ) + + +def parse_order_block(rows: Sequence[str]) -> OrderBlock: + """Reads an order block written the way the table draws it, one line per row. + + A ``?`` states that the block says nothing about that cell, which is what leaves it out of the + map entirely, while ``..`` states the silence it writes. + + Raises: + ValueError: if the rows differ in width. + """ + entries: Dict[OrderBlockKey, Optional[int]] = {} + lines = [line.split() for line in rows] + widths = {len(tokens) for tokens in lines} + if len(widths) != 1: + raise ValueError(f"An order block's rows differ in width: {sorted(widths)}") + + for row_offset, tokens in enumerate(lines): + for position_offset, token in enumerate(tokens): + if token != MIXED: + entries[(row_offset, position_offset)] = parse_index(token) + + return OrderBlock( + row_count=len(rows), + position_count=widths.pop(), + entries=entries, + ) + + +def fill_order( + order_logic: SequencerOrderLogic, + rows: Sequence[str], +) -> None: + """Writes an order stated the way the table draws it, one channel entry at a time. + + Each entry reaches its own channel, so a setup states the arrangement it wants while the master + row's fan-out stays out of it — which leaves the gesture under test the only thing that + exercised it. The order grows to hold the positions the statement names. + """ + lines = [line.split() for line in rows] + reach = max((len(tokens) for tokens in lines), default=0) + for _ in range(reach - order_logic.position_count()): + order_logic.append_frame() + + for generator, tokens in zip(GeneratorName.items(), lines): + for position, token in enumerate(tokens): + order_logic.set_order_entry(generator, position, parse_index(token)) + + +def parse_index(token: str) -> Optional[int]: + """The pattern index a token names, an empty slot reading as none.""" + if token == display_id(None): + return None + + return int(token, 16) + + def parse_block( rows: Sequence[str], *, diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index d7f88af21..8c52f87b2 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -8,6 +8,7 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.categories.manager import LanguageManager from sampletones_application.constants.playback import FollowMode +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.tabs.sequencer import SequencerTabCoordinator from sampletones_application.logic.history.action import HistoryAction @@ -21,6 +22,11 @@ ) from sampletones_application.logic.sequencer.clipboard import SequencerClipboard from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail +from sampletones_application.logic.sequencer.order import ( + OrderBlockReader, + OrderBlockWriter, + SequencerOrderLogic, +) from sampletones_application.logic.sequencer.tracker import ( SequencerTrackerLogic, TrackerBlockReader, @@ -33,7 +39,12 @@ from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.modifiers import CTRL, NO_MODIFIERS -from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) from sampletones_application.view_model.sequencer.samples import SampleSelection from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.song_player import SongPlayerViewModel @@ -1328,6 +1339,12 @@ def test_player_returns_the_guarded_wrapper( first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.INSTRUMENT).flat_index, last_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME).flat_index, ) +PULSE1_FRAME: Final[OrderRegion] = OrderRegion( + first_row=CHANNEL_AXIS.index(GeneratorName.PULSE1), + last_row=CHANNEL_AXIS.index(GeneratorName.PULSE1), + first_position=0, + last_position=0, +) @pytest.fixture @@ -1342,17 +1359,23 @@ def block_coordinator() -> SequencerTabCoordinator: controller = ProjectController(ProjectManager()) history = HistoryManager(controller, budget=10, strict=True) controller.on_mutation = history.handle_mutation + controller.new() + history.reset() instance._project_controller = controller instance._history = history instance._sequencer_tracker_logic = SequencerTrackerLogic(controller) instance._clipboard = SequencerClipboard() instance._tracker_block_reader = TrackerBlockReader(instance._sequencer_tracker_logic) instance._tracker_block_writer = TrackerBlockWriter(instance._sequencer_tracker_logic) + instance._sequencer_order_logic = SequencerOrderLogic(controller) + instance._order_block_reader = OrderBlockReader(instance._sequencer_order_logic) + instance._order_block_writer = OrderBlockWriter(instance._sequencer_order_logic) instance._history_detail = SequencerHistoryDetail( instance._sequencer_tracker_logic, MagicMock(), ) instance._sequencer_tracker_panel = MagicMock() + instance._sequencer_order_panel = MagicMock() instance._wire_block_callbacks() return instance @@ -1462,6 +1485,70 @@ def test_a_paste_writes_the_copied_block_in_one_entry( assert len(coordinator._history.entries) == recorded + 1 assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK + +class TestOrderBlockEdits: + """The order's gestures reach the same clipboard and record the same one entry each.""" + + def test_a_copy_fills_the_clipboard_and_leaves_the_history_as_it_stands( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + recorded = len(coordinator._history.entries) + + coordinator._sequencer_order_panel.on_copy_block(PULSE1_FRAME) + + block = coordinator._clipboard.order_block + assert block is not None + assert block.entries == {(0, 0): 0} + assert len(coordinator._history.entries) == recorded + + def test_a_cut_takes_the_block_and_silences_what_it_covered( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + recorded = len(coordinator._history.entries) + + coordinator._sequencer_order_panel.on_cut_block(PULSE1_FRAME) + + assert coordinator._sequencer_order_logic.entry(GeneratorName.PULSE1, 0) is None + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.CUT_BLOCK + + def test_a_delete_silences_the_region_in_one_entry( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + recorded = len(coordinator._history.entries) + + coordinator._sequencer_order_panel.on_delete_block(PULSE1_FRAME) + + assert coordinator._sequencer_order_logic.entry(GeneratorName.PULSE1, 0) is None + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.DELETE_BLOCK + + def test_a_paste_covers_the_frames_it_appends_and_the_entries_it_writes( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """One entry stands for the whole gesture, so an undo takes the appended frames back too.""" + coordinator = block_coordinator + coordinator._sequencer_order_panel.on_copy_block(PULSE1_FRAME) + recorded = len(coordinator._history.entries) + + coordinator._sequencer_order_panel.on_paste_block(OrderCell(generator=GeneratorName.NOISE, position=1)) + + assert coordinator._sequencer_order_logic.position_count() == 2 + assert coordinator._sequencer_order_logic.entry(GeneratorName.NOISE, 1) == 0 + assert len(coordinator._history.entries) == recorded + 1 + assert coordinator._history.entries[-1].action is HistoryAction.PASTE_BLOCK + + coordinator._history.undo() + + assert coordinator._sequencer_order_logic.position_count() == 1 + def test_a_paste_with_nothing_copied_records_nothing( self, block_coordinator: SequencerTabCoordinator, diff --git a/tests/unit/sampletones_application/logic/sequencer/order/__init__.py b/tests/unit/sampletones_application/logic/sequencer/order/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/sequencer/test_order.py b/tests/unit/sampletones_application/logic/sequencer/order/test_order.py similarity index 74% rename from tests/unit/sampletones_application/logic/sequencer/test_order.py rename to tests/unit/sampletones_application/logic/sequencer/order/test_order.py index 7a12b4682..ec24ec9fc 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_order.py +++ b/tests/unit/sampletones_application/logic/sequencer/order/test_order.py @@ -57,6 +57,47 @@ def test_remove_from_order_drops_the_frame(self) -> None: assert _order_column(logic, generator) == [None] +class TestEntryAccess: + """The reading and writing seam a block gesture goes through, which is the table's own rule.""" + + def test_write_entry_reaches_one_channel(self) -> None: + logic = _logic() + + logic.write_entry(GeneratorName.PULSE1, 0, 3) + + assert _order_column(logic, GeneratorName.PULSE1) == [3] + assert _order_column(logic, GeneratorName.TRIANGLE) == [0] + + def test_write_entry_through_the_master_row_reaches_every_channel(self) -> None: + logic = _logic() + + logic.write_entry(None, 0, 3) + + for generator in GeneratorName.items(): + assert _order_column(logic, generator) == [3] + + def test_entry_reads_the_index_a_channel_plays(self) -> None: + logic = _logic() + logic.set_order_entry(GeneratorName.NOISE, 0, 7) + + assert logic.entry(GeneratorName.NOISE, 0) == 7 + + def test_entry_past_the_last_frame_reads_as_silence(self) -> None: + logic = _logic() + + assert logic.entry(GeneratorName.NOISE, logic.position_count()) is None + + def test_append_frame_lengthens_the_order_by_one(self) -> None: + logic = _logic() + length = logic.position_count() + + logic.append_frame() + + assert logic.position_count() == length + 1 + for generator in GeneratorName.items(): + assert logic.entry(generator, length) is None + + class TestOrderFrameOps: def test_insert_frame_adds_empty_frame_at_position(self) -> None: logic = _logic() diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py new file mode 100644 index 000000000..bc4fde3cb --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py @@ -0,0 +1,205 @@ +from typing import Optional + +import pytest + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.order import ( + OrderBlockReader, + SequencerOrderLogic, +) +from sampletones_application.view_model.sequencer.region import OrderRegion +from sampletones_core.constants.enums import GeneratorName +from tests.suite.sequencer import fill_order + +MASTER_ROW = CHANNEL_AXIS.index(None) +PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1) +NOISE_ROW = CHANNEL_AXIS.index(GeneratorName.NOISE) + + +@pytest.fixture +def logic() -> SequencerOrderLogic: + """The order logic the reader takes every entry through.""" + return SequencerOrderLogic(ProjectController(ProjectManager())) + + +@pytest.fixture +def reader(logic: SequencerOrderLogic) -> OrderBlockReader: + return OrderBlockReader(logic) + + +def _row( + generator: Optional[GeneratorName], + *, + last_position: int = 0, +) -> OrderRegion: + """The region one whole row covers, out to ``last_position``.""" + row = CHANNEL_AXIS.index(generator) + return OrderRegion( + first_row=row, + last_row=row, + first_position=0, + last_position=last_position, + ) + + +class TestChannelRow: + """A channel answers for itself, so every one of its cells reaches the block definite.""" + + def test_a_row_carries_the_indices_it_plays( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + fill_order( + logic, + ( + "00 01 02", + ".. .. ..", + ".. .. ..", + ".. .. ..", + ), + ) + + block = reader.read(_row(GeneratorName.PULSE1, last_position=2)) + + assert block.entries == {(0, 0): 0, (0, 1): 1, (0, 2): 2} + + def test_a_silent_cell_carries_its_silence( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + """A slot playing nothing reads as the empty cell it shows, which a paste writes as silence.""" + fill_order( + logic, + ( + "00", + "00", + "00", + "..", + ), + ) + + block = reader.read(_row(GeneratorName.NOISE)) + + assert block.entries == {(0, 0): None} + + +class TestMasterRow: + """The master row answers for every channel, so it carries what they agree on and nothing else.""" + + def test_a_position_its_channels_share_carries_the_index( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + fill_order( + logic, + ( + "03", + "03", + "03", + "03", + ), + ) + + block = reader.read(_row(None)) + + assert block.entries == {(0, 0): 3} + + def test_a_position_every_channel_leaves_silent_carries_that_silence( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + """Silence is a reading the channels agree on, so it writes where a mixed cell would not.""" + fill_order( + logic, + ( + ".. ..", + ".. ..", + ".. ..", + ".. ..", + ), + ) + + block = reader.read(_row(None, last_position=1)) + + assert block.entries == {(0, 0): None, (0, 1): None} + + def test_a_position_its_channels_disagree_over_is_left_out( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + fill_order( + logic, + ( + "00 04", + "00 05", + "00 04", + "00 04", + ), + ) + + block = reader.read(_row(None, last_position=1)) + + assert block.entries == {(0, 0): 0} + + +class TestExtent: + """A block states the rectangle it was read at, which a mixed edge column cannot take away.""" + + def test_a_region_carries_the_shape_it_covers( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + fill_order( + logic, + ( + "00 01 02", + "00 01 02", + "00 01 02", + "00 01 02", + ), + ) + + block = reader.read( + OrderRegion( + first_row=MASTER_ROW, + last_row=NOISE_ROW, + first_position=1, + last_position=2, + ) + ) + + assert (block.row_count, block.position_count) == (5, 2) + + def test_offsets_run_from_the_cell_the_region_begins_at( + self, + logic: SequencerOrderLogic, + reader: OrderBlockReader, + ) -> None: + fill_order( + logic, + ( + "00 01 02", + "00 01 03", + "00 01 02", + "00 01 02", + ), + ) + + block = reader.read( + OrderRegion( + first_row=PULSE1_ROW, + last_row=CHANNEL_AXIS.index(GeneratorName.PULSE2), + first_position=2, + last_position=2, + ) + ) + + assert block.entries == {(0, 0): 2, (1, 0): 3} diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py b/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py new file mode 100644 index 000000000..9ee95980f --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/order/test_writer.py @@ -0,0 +1,392 @@ +from dataclasses import dataclass +from typing import Optional, Tuple + +import pytest + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.order import ( + OrderBlockReader, + OrderBlockWriter, + SequencerOrderLogic, +) +from sampletones_application.view_model.sequencer.region import OrderCell, OrderRegion +from sampletones_core.constants.enums import GeneratorName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.sequencer import fill_order, parse_order_block, render_order + +SILENT = ".. .. .." + + +@dataclass(frozen=True, kw_only=True) +class Table: + """A three-position order, the state every paste case starts from.""" + + controller: ProjectController + logic: SequencerOrderLogic + writer: OrderBlockWriter + + +@pytest.fixture +def table() -> Table: + """An order short enough for a case to state whole, every channel silent to begin with.""" + controller = ProjectController(ProjectManager()) + logic = SequencerOrderLogic(controller) + fill_order( + logic, + ( + SILENT, + SILENT, + SILENT, + SILENT, + ), + ) + return Table( + controller=controller, + logic=logic, + writer=OrderBlockWriter(logic), + ) + + +def _row(generator: Optional[GeneratorName]) -> int: + return CHANNEL_AXIS.index(generator) + + +class TestPaste(BaseTestSuite): + """What a block writes where it lands, stated as the whole order it leaves behind. + + A block carries the offsets it was read at while the cell it is written from supplies the row + and the position it begins at, so every case states its origin as that pair. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + block: Tuple[str, ...] + origin: OrderCell + expected: Tuple[str, ...] + order: Tuple[str, ...] = () + + test_cases = ( + TestCase( + label="a block lands at the cell it is written from", + block=("07 08",), + origin=OrderCell(generator=GeneratorName.PULSE2, position=1), + expected=( + SILENT, + ".. 07 08", + SILENT, + SILENT, + ), + ), + TestCase( + label="a block through the master row reaches every channel", + block=("05",), + origin=OrderCell(generator=None, position=0), + expected=( + "05 .. ..", + "05 .. ..", + "05 .. ..", + "05 .. ..", + ), + ), + TestCase( + label="a channel beneath the master row overwrites what it settled", + block=( + "05", + "06", + ), + origin=OrderCell(generator=None, position=0), + expected=( + "06 .. ..", + "05 .. ..", + "05 .. ..", + "05 .. ..", + ), + ), + TestCase( + label="a block read from the master row writes one channel when written to one", + block=("05",), + origin=OrderCell(generator=GeneratorName.TRIANGLE, position=2), + expected=( + SILENT, + SILENT, + ".. .. 05", + SILENT, + ), + ), + TestCase( + label="a mixed cell leaves its target as it stands while its neighbours take theirs", + order=( + "01 02 03", + SILENT, + SILENT, + SILENT, + ), + block=("09 ? 0A",), + origin=OrderCell(generator=GeneratorName.PULSE1, position=0), + expected=( + "09 02 0A", + SILENT, + SILENT, + SILENT, + ), + ), + TestCase( + label="an empty cell silences the slot it lands on", + order=( + "01 02 03", + SILENT, + SILENT, + SILENT, + ), + block=(".. ..",), + origin=OrderCell(generator=GeneratorName.PULSE1, position=0), + expected=( + ".. .. 03", + SILENT, + SILENT, + SILENT, + ), + ), + TestCase( + label="the rows a block carries past the last channel are left out", + block=( + "01", + "02", + "03", + ), + origin=OrderCell(generator=GeneratorName.TRIANGLE, position=0), + expected=( + SILENT, + SILENT, + "01 .. ..", + "02 .. ..", + ), + ), + TestCase( + label="a master row written to the last channel keeps that channel alone", + block=( + "01", + "02", + ), + origin=OrderCell(generator=GeneratorName.NOISE, position=0), + expected=( + SILENT, + SILENT, + SILENT, + "01 .. ..", + ), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_order_after_a_paste( + self, + table: Table, + test_case: TestCase, + ) -> None: + fill_order(table.logic, test_case.order) + + table.writer.write(parse_order_block(test_case.block), test_case.origin) + + assert render_order(table.logic) == test_case.expected + + +class TestGrowth(BaseTestSuite): + """How far a paste past the order's end grows it, which is to the last position it writes at.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + block: Tuple[str, ...] + origin: OrderCell + expected: Tuple[str, ...] + + test_cases = ( + TestCase( + label="a block reaching past the end appends exactly the positions it writes", + block=("01 02 03",), + origin=OrderCell(generator=GeneratorName.PULSE1, position=2), + expected=( + ".. .. 01 02 03", + ".. .. .. .. ..", + ".. .. .. .. ..", + ".. .. .. .. ..", + ), + ), + TestCase( + label="a column the block says nothing about appends no position", + block=("01 ? ?",), + origin=OrderCell(generator=GeneratorName.PULSE1, position=2), + expected=( + ".. .. 01", + SILENT, + SILENT, + SILENT, + ), + ), + TestCase( + label="a column the block silences appends the position it silences", + block=("01 ? ..",), + origin=OrderCell(generator=GeneratorName.PULSE1, position=2), + expected=( + ".. .. 01 .. ..", + ".. .. .. .. ..", + ".. .. .. .. ..", + ".. .. .. .. ..", + ), + ), + TestCase( + label="the rows a block loses at the last channel take their growth with them", + block=( + "01 ?", + "? 02", + ), + origin=OrderCell(generator=GeneratorName.NOISE, position=2), + expected=( + SILENT, + SILENT, + SILENT, + ".. .. 01", + ), + ), + TestCase( + label="a wholly mixed block leaves the order the length it was", + block=("? ? ?",), + origin=OrderCell(generator=GeneratorName.PULSE1, position=2), + expected=( + SILENT, + SILENT, + SILENT, + SILENT, + ), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_order_after_a_paste_past_its_end( + self, + table: Table, + test_case: TestCase, + ) -> None: + table.writer.write(parse_order_block(test_case.block), test_case.origin) + + assert render_order(table.logic) == test_case.expected + + +class TestClear: + """What a delete silences, which is every cell its region covers and nothing beside.""" + + def test_a_region_silences_the_cells_it_covers(self, table: Table) -> None: + fill_order( + table.logic, + ( + "01 02 03", + "01 02 03", + "01 02 03", + "01 02 03", + ), + ) + + table.writer.clear( + OrderRegion( + first_row=_row(GeneratorName.PULSE2), + last_row=_row(GeneratorName.TRIANGLE), + first_position=0, + last_position=1, + ) + ) + + assert render_order(table.logic) == ( + "01 02 03", + ".. .. 03", + ".. .. 03", + "01 02 03", + ) + + def test_a_region_over_the_master_row_silences_every_channel(self, table: Table) -> None: + fill_order( + table.logic, + ( + "01 02 03", + "01 02 03", + "01 02 03", + "01 02 03", + ), + ) + + table.writer.clear( + OrderRegion( + first_row=_row(None), + last_row=_row(None), + first_position=1, + last_position=1, + ) + ) + + assert render_order(table.logic) == ( + "01 .. 03", + "01 .. 03", + "01 .. 03", + "01 .. 03", + ) + + def test_a_delete_leaves_the_order_the_length_it_was(self, table: Table) -> None: + """Emptying the frames at the end leaves them standing as silent ones.""" + fill_order( + table.logic, + ( + "01 02 03", + "01 02 03", + "01 02 03", + "01 02 03", + ), + ) + + table.writer.clear( + OrderRegion( + first_row=_row(None), + last_row=_row(GeneratorName.NOISE), + first_position=0, + last_position=2, + ) + ) + + assert table.logic.position_count() == 3 + + +class TestRoundTrip: + """Reading a region, silencing it and writing the block back leaves the order it came from.""" + + def test_a_block_written_back_at_its_origin_restores_the_order(self, table: Table) -> None: + fill_order( + table.logic, + ( + "01 02 03", + "01 04 03", + ".. 02 03", + "01 02 ..", + ), + ) + before = render_order(table.logic) + region = OrderRegion( + first_row=_row(GeneratorName.PULSE1), + last_row=_row(GeneratorName.NOISE), + first_position=0, + last_position=2, + ) + block = OrderBlockReader(table.logic).read(region) + + table.writer.clear(region) + table.writer.write(block, OrderCell(generator=GeneratorName.PULSE1, position=0)) + + assert render_order(table.logic) == before diff --git a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py index eed426e9e..835fe41e0 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -3,6 +3,7 @@ import pytest +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.history_detail import ( @@ -10,7 +11,12 @@ ) from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import SequencerTrackerLogic -from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.shared.history import ( @@ -224,6 +230,50 @@ def test_set_master_entry_lists_every_channel(self) -> None: ("05", HistoryDetailRole.VALUE), ] + def test_a_block_reads_as_the_positions_and_the_channels_it_covers(self) -> None: + formatter = _formatter(_controller()) + + segments = formatter.order_block( + OrderRegion( + first_row=CHANNEL_AXIS.index(GeneratorName.PULSE2), + last_row=CHANNEL_AXIS.index(GeneratorName.TRIANGLE), + first_position=1, + last_position=4, + ) + ) + + assert _pairs(segments) == [ + ("01-04", HistoryDetailRole.FRAME), + ("pT", HistoryDetailRole.CHANNEL), + ] + + def test_a_block_reaching_the_master_row_reads_as_every_channel(self) -> None: + formatter = _formatter(_controller()) + + segments = formatter.order_block( + OrderRegion( + first_row=CHANNEL_AXIS.index(None), + last_row=CHANNEL_AXIS.index(None), + first_position=2, + last_position=2, + ) + ) + + assert _pairs(segments) == [ + ("02", HistoryDetailRole.FRAME), + ("PpTN", HistoryDetailRole.CHANNEL), + ] + + def test_a_paste_reads_as_the_cell_it_was_written_from(self) -> None: + formatter = _formatter(_controller()) + + segments = formatter.order_paste(OrderCell(generator=GeneratorName.NOISE, position=3)) + + assert _pairs(segments) == [ + ("03", HistoryDetailRole.FRAME), + ("N", HistoryDetailRole.CHANNEL), + ] + class TestSampleDetails: def test_add_sample_shows_the_name(self) -> None: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index 4ae8875af..1f36dc476 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -110,6 +110,25 @@ def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: assert cancelled.pending == "" +class TestTarget: + """The region a block gesture acts on, which is the selection wherever one has been made.""" + + def test_a_cursor_alone_targets_its_own_cell(self) -> None: + region = _state(GeneratorName.PULSE2, position=4).target_region + + assert region is not None + assert (region.first_position, region.last_position) == (4, 4) + assert region.generators == (GeneratorName.PULSE2,) + + def test_a_selection_is_targeted_whole(self) -> None: + selected = _state(position=4).extend_position(2, POSITION_COUNT) + + assert selected.target_region == selected.region + + def test_a_table_with_no_cursor_targets_nothing(self) -> None: + assert OrderInputState().target_region is None + + class TestEntry: def test_type_char_commits_after_two_digits(self) -> None: partial, first = _state().type_char("A") diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 300fb5c11..3229aae65 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -3,13 +3,24 @@ import pytest +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.panels.sequencer.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.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.keyboard.event import KeyEvent -from sampletones_application.view_model.sequencer.region import TrackerCell, TrackerRegion +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName @@ -17,6 +28,10 @@ ROW_COUNT = 64 CURSOR_ROW = 4 +POSITION_COUNT = 8 +CURSOR_POSITION = 2 +MASTER_ROW = CHANNEL_AXIS.index(None) +PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1) @dataclass @@ -30,6 +45,17 @@ class Gestures: cleared: List[Tuple[int, Optional[GeneratorName]]] = field(default_factory=list) +@dataclass +class OrderGestures: + """What each of the order's block hooks was handed, read the same way the tracker's are.""" + + copied: List[OrderRegion] = field(default_factory=list) + cut: List[OrderRegion] = field(default_factory=list) + deleted: List[OrderRegion] = field(default_factory=list) + pasted: List[OrderCell] = field(default_factory=list) + cleared: List[Tuple[GeneratorName, int, Optional[int]]] = field(default_factory=list) + + def _press(text: str) -> KeyEvent: """The press a written combination names, as the router delivers it.""" combination = KeyCombination.parse(text) @@ -62,6 +88,26 @@ def _panel( return panel +def _order_panel( + monkeypatch: pytest.MonkeyPatch, + gestures: OrderGestures, + *, + generator: Optional[GeneratorName] = GeneratorName.PULSE1, +) -> GUISequencerOrderPanel: + """An order panel reporting the gestures it fires, with its table left unbuilt.""" + panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + panel._shortcuts = shipped_source() + panel._input_state = OrderInputState(cursor=OrderCursor(generator, CURSOR_POSITION)) + panel._position_count = POSITION_COUNT + panel.on_copy_block = gestures.copied.append + panel.on_cut_block = gestures.cut.append + panel.on_delete_block = gestures.deleted.append + panel.on_paste_block = gestures.pasted.append + panel.on_set_order_entry = lambda channel, position, index: gestures.cleared.append((channel, position, index)) + monkeypatch.setattr(panel, "_apply_state", lambda state, notify=True: None) + return panel + + class TestTrackerCopyKey: def test_a_selection_is_copied_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: gestures = Gestures() @@ -168,3 +214,115 @@ def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on( assert panel._on_key_pressed(_press("Del")) is True assert gestures.deleted == [] assert gestures.cleared == [(CURSOR_ROW, GeneratorName.PULSE1)] + + +class TestOrderCopyKey: + def test_a_selection_is_copied_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures) + panel._input_state = panel._input_state.extend_position(1, POSITION_COUNT) + + assert panel._on_key_pressed(_press("Ctrl+C")) is True + assert gestures.copied == [ + OrderRegion( + first_row=PULSE1_ROW, + last_row=PULSE1_ROW, + first_position=CURSOR_POSITION, + last_position=CURSOR_POSITION + 1, + ) + ] + + def test_a_cursor_alone_copies_the_cell_it_stands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures, generator=None) + + assert panel._on_key_pressed(_press("Ctrl+C")) is True + assert gestures.copied == [ + OrderRegion( + first_row=MASTER_ROW, + last_row=MASTER_ROW, + first_position=CURSOR_POSITION, + last_position=CURSOR_POSITION, + ) + ] + + def test_a_table_with_no_cursor_copies_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures) + panel._input_state = OrderInputState() + + assert panel._on_key_pressed(_press("Ctrl+C")) is False + assert gestures.copied == [] + + +class TestOrderCutKey: + def test_a_selection_is_cut_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures) + panel._input_state = panel._input_state.extend_channel(1) + + assert panel._on_key_pressed(_press("Ctrl+X")) is True + assert gestures.cut == [ + OrderRegion( + first_row=PULSE1_ROW, + last_row=PULSE1_ROW + 1, + first_position=CURSOR_POSITION, + last_position=CURSOR_POSITION, + ) + ] + assert gestures.copied == [] + + +class TestOrderPasteKey: + def test_a_paste_names_the_cell_the_cursor_stands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures) + + assert panel._on_key_pressed(_press("Ctrl+V")) is True + assert gestures.pasted == [OrderCell(generator=GeneratorName.PULSE1, position=CURSOR_POSITION)] + + def test_the_master_row_is_a_cell_a_block_lands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures, generator=None) + + assert panel._on_key_pressed(_press("Ctrl+V")) is True + assert gestures.pasted == [OrderCell(generator=None, position=CURSOR_POSITION)] + + +class TestOrderDeleteKey: + def test_a_selection_is_deleted_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures) + panel._input_state = panel._input_state.extend_position(1, POSITION_COUNT) + + assert panel._on_key_pressed(_press("Del")) is True + assert gestures.deleted == [ + OrderRegion( + first_row=PULSE1_ROW, + last_row=PULSE1_ROW, + first_position=CURSOR_POSITION, + last_position=CURSOR_POSITION + 1, + ) + ] + assert gestures.cleared == [] + + def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Delete already means something without a selection, so that meaning is what it keeps.""" + gestures = OrderGestures() + panel = _order_panel(monkeypatch, gestures) + + assert panel._on_key_pressed(_press("Del")) is True + assert gestures.deleted == [] + assert gestures.cleared == [(GeneratorName.PULSE1, CURSOR_POSITION, None)] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py new file mode 100644 index 000000000..130d1f060 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -0,0 +1,327 @@ +from dataclasses import dataclass, field +from typing import Any, Callable, List, Optional + +import pytest + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.ui.panels.sequencer import order as order_module +from sampletones_application.ui.panels.sequencer import tracker as tracker_module +from sampletones_application.ui.panels.sequencer.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.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from tests.suite.shortcuts import shipped_source + +CLICKED_ROW = 4 +CLICKED_POSITION = 2 +ROW_COUNT = 64 +POSITION_COUNT = 8 + +COPY_ITEM = 0 +CUT_ITEM = 1 +PASTE_ITEM = 2 +DELETE_ITEM = 3 + +PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1) + + +@dataclass +class MenuItem: + """One item as it was registered, which is the whole of what a reader sees and clicks.""" + + label: str + enabled: bool + callback: Callable[[], None] + + +@dataclass +class Gestures: + """What each block hook was handed when its menu item fired.""" + + copied: List[Any] = field(default_factory=list) + cut: List[Any] = field(default_factory=list) + deleted: List[Any] = field(default_factory=list) + pasted: List[Any] = field(default_factory=list) + + +class _MenuRecorder: + """Captures the items a builder registers, in the order it registers them.""" + + def __init__(self) -> None: + self.items: List[MenuItem] = [] + + def add_menu_item(self, **kwargs: Any) -> int: + self.items.append( + MenuItem( + label=kwargs["label"], + enabled=kwargs.get("enabled", True), + callback=kwargs["callback"], + ) + ) + return 0 + + +def _labels(panel: Any) -> None: + panel._lbl_context_copy = "Copy" + panel._lbl_context_cut = "Cut" + panel._lbl_context_paste = "Paste" + panel._lbl_context_delete = "Delete" + + +def _tracker_panel( + gestures: Gestures, + *, + can_paste: bool = True, +) -> tracker_module.GUISequencerTrackerPanel: + """A tracker panel whose menu builder can run with no DearPyGui context behind it.""" + panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) + _labels(panel) + panel._shortcuts = shipped_source() + panel._input_state = TrackerInputState() + panel.on_copy_block = gestures.copied.append + panel.on_cut_block = gestures.cut.append + panel.on_delete_block = gestures.deleted.append + panel.on_paste_block = gestures.pasted.append + panel.can_paste_block = lambda: can_paste + return panel + + +def _order_panel( + gestures: Gestures, + *, + can_paste: bool = True, +) -> order_module.GUISequencerOrderPanel: + """An order panel whose menu builder can run with no DearPyGui context behind it.""" + panel = order_module.GUISequencerOrderPanel.__new__(order_module.GUISequencerOrderPanel) + _labels(panel) + panel._shortcuts = shipped_source() + panel._input_state = OrderInputState() + panel.on_copy_block = gestures.copied.append + panel.on_cut_block = gestures.cut.append + panel.on_delete_block = gestures.deleted.append + panel.on_paste_block = gestures.pasted.append + panel.can_paste_block = lambda: can_paste + return panel + + +@pytest.fixture +def tracker_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: + recorder = _MenuRecorder() + monkeypatch.setattr(tracker_module.dpg, "add_menu_item", recorder.add_menu_item) + return recorder + + +@pytest.fixture +def order_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: + recorder = _MenuRecorder() + monkeypatch.setattr(order_module.dpg, "add_menu_item", recorder.add_menu_item) + return recorder + + +def _selected_tracker_state() -> TrackerInputState: + """A selection running from the clicked row down two rows, over Pulse 1's whole cell.""" + state = TrackerInputState(cursor=TrackerCursor(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT)) + return state.extend_row(2, ROW_COUNT).extend_slot(2) + + +def _selected_order_state() -> OrderInputState: + """A selection running from the clicked position across two positions of Pulse 1's row.""" + state = OrderInputState(cursor=OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION)) + return state.extend_position(2, POSITION_COUNT) + + +class TestTrackerMenuTarget: + def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: + panel = _tracker_panel(Gestures()) + panel._input_state = _selected_tracker_state() + + region = panel._menu_region( + CLICKED_ROW + 1, + GeneratorName.PULSE1, + SubColumn.TRANSPOSE, + ) + + assert region == panel._input_state.region + + def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> None: + panel = _tracker_panel(Gestures()) + panel._input_state = _selected_tracker_state() + + region = panel._menu_region( + CLICKED_ROW, + GeneratorName.TRIANGLE, + SubColumn.VOLUME, + ) + + assert region == TrackerRegion( + first_row=CLICKED_ROW, + last_row=CLICKED_ROW, + first_slot=TrackerSlot(GeneratorName.TRIANGLE, SubColumn.VOLUME).flat_index, + last_slot=TrackerSlot(GeneratorName.TRIANGLE, SubColumn.VOLUME).flat_index, + ) + + def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: + panel = _tracker_panel(Gestures()) + + region = panel._menu_region( + CLICKED_ROW, + None, + SubColumn.INSTRUMENT, + ) + + assert region.rows == range(CLICKED_ROW, CLICKED_ROW + 1) + assert region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),) + + +class TestTrackerMenuItems: + def test_the_items_hand_out_the_block_the_menu_was_raised_on( + self, + tracker_recorder: _MenuRecorder, + ) -> None: + gestures = Gestures() + panel = _tracker_panel(gestures) + panel._input_state = _selected_tracker_state() + selection = panel._input_state.region + + panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + for item in tracker_recorder.items: + item.callback() + + assert gestures.copied == [selection] + assert gestures.cut == [selection] + assert gestures.deleted == [selection] + + def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecorder) -> None: + """The cell carries a row and a column alone, so the clicked subcolumn is left to the block.""" + gestures = Gestures() + panel = _tracker_panel(gestures) + + panel._add_block_items(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME) + tracker_recorder.items[PASTE_ITEM].callback() + + assert gestures.pasted == [TrackerCell(row=CLICKED_ROW, generator=GeneratorName.NOISE)] + + def test_paste_awaits_a_copy(self, tracker_recorder: _MenuRecorder) -> None: + panel = _tracker_panel(Gestures(), can_paste=False) + + panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + + assert tracker_recorder.items[PASTE_ITEM].enabled is False + assert [item.enabled for item in tracker_recorder.items] == [True, True, False, True] + + def test_the_section_reads_as_the_four_clipboard_actions( + self, + tracker_recorder: _MenuRecorder, + ) -> None: + panel = _tracker_panel(Gestures()) + + panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + + assert [item.label for item in tracker_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] + + +class TestOrderMenuTarget: + def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: + panel = _order_panel(Gestures()) + panel._input_state = _selected_order_state() + + region = panel._menu_region(GeneratorName.PULSE1, CLICKED_POSITION + 1) + + assert region == panel._input_state.region + + def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> None: + panel = _order_panel(Gestures()) + panel._input_state = _selected_order_state() + + region = panel._menu_region(None, CLICKED_POSITION) + + assert region == OrderRegion( + first_row=CHANNEL_AXIS.index(None), + last_row=CHANNEL_AXIS.index(None), + first_position=CLICKED_POSITION, + last_position=CLICKED_POSITION, + ) + + def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: + panel = _order_panel(Gestures()) + + region = panel._menu_region(GeneratorName.PULSE1, CLICKED_POSITION) + + assert region == OrderRegion( + first_row=PULSE1_ROW, + last_row=PULSE1_ROW, + first_position=CLICKED_POSITION, + last_position=CLICKED_POSITION, + ) + + +class TestOrderMenuItems: + def test_the_items_hand_out_the_block_the_menu_was_raised_on( + self, + order_recorder: _MenuRecorder, + ) -> None: + gestures = Gestures() + panel = _order_panel(gestures) + panel._input_state = _selected_order_state() + selection = panel._input_state.region + + panel._add_block_items(GeneratorName.PULSE1, CLICKED_POSITION) + for item in order_recorder.items: + item.callback() + + assert gestures.copied == [selection] + assert gestures.cut == [selection] + assert gestures.deleted == [selection] + + def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder) -> None: + gestures = Gestures() + panel = _order_panel(gestures) + + panel._add_block_items(None, CLICKED_POSITION) + order_recorder.items[PASTE_ITEM].callback() + + assert gestures.pasted == [OrderCell(generator=None, position=CLICKED_POSITION)] + + def test_paste_awaits_a_copy(self, order_recorder: _MenuRecorder) -> None: + panel = _order_panel(Gestures(), can_paste=False) + + panel._add_block_items(GeneratorName.PULSE1, CLICKED_POSITION) + + assert order_recorder.items[PASTE_ITEM].enabled is False + assert [item.enabled for item in order_recorder.items] == [True, True, False, True] + + def test_the_section_reads_as_the_four_clipboard_actions( + self, + order_recorder: _MenuRecorder, + ) -> None: + panel = _order_panel(Gestures()) + + panel._add_block_items(GeneratorName.PULSE1, CLICKED_POSITION) + + assert [item.label for item in order_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] + + +class TestMenuItemOrder: + """The four items keep the order the indices name, which is what the item tests read them by.""" + + def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _MenuRecorder) -> None: + panel = _tracker_panel(Gestures()) + + panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + + labels = [item.label for item in tracker_recorder.items] + assert labels[COPY_ITEM] == "Copy" + assert labels[CUT_ITEM] == "Cut" + assert labels[PASTE_ITEM] == "Paste" + assert labels[DELETE_ITEM] == "Delete" diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_region.py b/tests/unit/sampletones_application/view_model/sequencer/test_region.py index 3ea7fb3d0..fdf7f284d 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_region.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_region.py @@ -1,3 +1,5 @@ +from typing import Optional + import pytest from pydantic import ValidationError @@ -6,7 +8,11 @@ OrderRegion, TrackerRegion, ) -from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + TrackerSlot, + slot_from_flat, +) from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName @@ -90,3 +96,83 @@ def test_a_row_off_the_channel_axis_is_rejected(self) -> None: first_position=0, last_position=0, ) + + +class TestTrackerRegionMembership: + """Which cells a rectangle holds, which is what a gesture raised on one asks.""" + + @pytest.fixture + def region(self) -> TrackerRegion: + return TrackerRegion(first_row=2, last_row=5, first_slot=3, last_slot=7) + + @pytest.mark.parametrize( + ("row", "slot_index"), + [ + (2, 3), + (5, 7), + (3, 5), + ], + ) + def test_a_cell_inside_the_rectangle_belongs_to_it( + self, + region: TrackerRegion, + row: int, + slot_index: int, + ) -> None: + assert region.covers(row, slot_from_flat(slot_index)) is True + + @pytest.mark.parametrize( + ("row", "slot_index"), + [ + (1, 5), + (6, 5), + (3, 2), + (3, 8), + ], + ) + def test_a_cell_outside_the_rectangle_stands_on_its_own( + self, + region: TrackerRegion, + row: int, + slot_index: int, + ) -> None: + assert region.covers(row, slot_from_flat(slot_index)) is False + + +class TestOrderRegionMembership: + @pytest.fixture + def region(self) -> OrderRegion: + return OrderRegion(first_row=1, last_row=2, first_position=3, last_position=6) + + @pytest.mark.parametrize( + ("generator", "position"), + [ + (GeneratorName.PULSE1, 3), + (GeneratorName.PULSE2, 6), + (GeneratorName.PULSE1, 5), + ], + ) + def test_a_cell_inside_the_rectangle_belongs_to_it( + self, + region: OrderRegion, + generator: GeneratorName, + position: int, + ) -> None: + assert region.covers(generator, position) is True + + @pytest.mark.parametrize( + ("generator", "position"), + [ + (None, 5), + (GeneratorName.TRIANGLE, 5), + (GeneratorName.PULSE1, 2), + (GeneratorName.PULSE1, 7), + ], + ) + def test_a_cell_outside_the_rectangle_stands_on_its_own( + self, + region: OrderRegion, + generator: Optional[GeneratorName], + position: int, + ) -> None: + assert region.covers(generator, position) is False From 873ddb21a73964df30d95eff59bddc94a1265f7f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 11 Aug 2026 23:39:01 +0200 Subject: [PATCH 077/152] Extracted: shared clipboard action labels --- .../categories/context.py | 21 +++++++++++++++++++ .../categories/elements/global_.py | 4 ++++ .../categories/elements/sequencer.py | 8 ------- src/sampletones_application/ui/menu.py | 9 ++------ .../ui/panels/sequencer/order.py | 10 +++++---- .../ui/panels/sequencer/tracker.py | 10 +++++---- src/sampletones_config/lang/en.yaml | 12 ++++------- 7 files changed, 43 insertions(+), 31 deletions(-) create mode 100644 src/sampletones_application/categories/context.py diff --git a/src/sampletones_application/categories/context.py b/src/sampletones_application/categories/context.py new file mode 100644 index 000000000..69d18a9b7 --- /dev/null +++ b/src/sampletones_application/categories/context.py @@ -0,0 +1,21 @@ +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType +from sampletones_application.categories.manager import LanguageManager + + +def context_label( + language_manager: LanguageManager, + element: ContextElements, +) -> str: + """Resolves a context-action label, the words every menu offering that action prints. + + Cut, Copy and Play name one gesture wherever they are offered, so the cell menus of the + sequencer grids, the file trees and the menu bar read them from one entry. A reader then + meets the same word for the same action, and a translation reaches all of them at once. + """ + return language_manager[ + Page.GLOBAL, + Panel.CONTEXT, + TextType.LABEL, + element, + ] diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 5dfca380a..f41b3494b 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -33,6 +33,10 @@ class TreeElements(AbstractElement): class ContextElements(AbstractElement): PLAY = "play" + CUT = "cut" + COPY = "copy" + PASTE = "paste" + DELETE = "delete" MARK_AS_FAVORITE = "mark_as_favorite" UNMARK_AS_FAVORITE = "unmark_as_favorite" COPY_FILENAME = "copy_filename" diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index c5df06bfc..51db2e5d9 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -30,10 +30,6 @@ class SequencerTrackerElements(AbstractElement): HEADER_SAMPLE = "header_sample" CONTEXT_PLAY = "context_play" CONTEXT_PLAY_FROM_FRAME = "context_play_from_frame" - CONTEXT_COPY = "context_copy" - CONTEXT_CUT = "context_cut" - CONTEXT_PASTE = "context_paste" - CONTEXT_DELETE = "context_delete" CONTEXT_NOTE_OFF = "context_note_off" CONTEXT_SET_INSTRUMENT = "context_set_instrument" CONTEXT_NO_SAMPLES = "context_no_samples" @@ -66,10 +62,6 @@ class SequencerOrderElements(AbstractElement): LABEL_CHANNEL = "label_channel" LABEL_MASTER = "label_master" CONTEXT_PLAY = "context_play" - CONTEXT_COPY = "context_copy" - CONTEXT_CUT = "context_cut" - CONTEXT_PASTE = "context_paste" - CONTEXT_DELETE = "context_delete" CONTEXT_DUPLICATE = "context_duplicate" CONTEXT_CLONE = "context_clone" CONTEXT_INSERT = "context_insert" diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 6b63ed914..056e7a529 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -3,6 +3,7 @@ import dearpygui.dearpygui as dpg +from sampletones_application.categories.context import context_label from sampletones_application.categories.elements.global_ import ( ContextElements, MenuElements, @@ -152,13 +153,7 @@ def _label(self, element: MenuElements) -> str: ] def _context_label(self, element: ContextElements) -> str: - """Resolves a shared context-action label reused between the tree menus and this bar.""" - return self._language_manager[ - Page.GLOBAL, - Panel.CONTEXT, - TextType.LABEL, - element, - ] + return context_label(self._language_manager, element) def create(self, state: MenuBarViewModel) -> None: with dpg.menu_bar(): diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 69dd1ddbc..b58c53f4a 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -2,6 +2,8 @@ import dearpygui.dearpygui as dpg +from sampletones_application.categories.context import context_label +from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import SequencerOrderElements from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager @@ -211,10 +213,10 @@ def label(element: SequencerOrderElements) -> str: return self._label(language_manager, element) self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY) - self._lbl_context_copy = label(SequencerOrderElements.CONTEXT_COPY) - self._lbl_context_cut = label(SequencerOrderElements.CONTEXT_CUT) - self._lbl_context_paste = label(SequencerOrderElements.CONTEXT_PASTE) - self._lbl_context_delete = label(SequencerOrderElements.CONTEXT_DELETE) + self._lbl_context_cut = context_label(language_manager, ContextElements.CUT) + self._lbl_context_copy = context_label(language_manager, ContextElements.COPY) + self._lbl_context_paste = context_label(language_manager, ContextElements.PASTE) + self._lbl_context_delete = context_label(language_manager, ContextElements.DELETE) self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE) self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE) self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT) diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 126e4daca..8918e1ddf 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -2,6 +2,8 @@ import dearpygui.dearpygui as dpg +from sampletones_application.categories.context import context_label +from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import ( SequencerTrackerElements, ) @@ -244,10 +246,10 @@ def label(element: SequencerTrackerElements) -> str: self._lbl_context_play = label(SequencerTrackerElements.CONTEXT_PLAY) self._lbl_context_play_from_frame = label(SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME) - self._lbl_context_copy = label(SequencerTrackerElements.CONTEXT_COPY) - self._lbl_context_cut = label(SequencerTrackerElements.CONTEXT_CUT) - self._lbl_context_paste = label(SequencerTrackerElements.CONTEXT_PASTE) - self._lbl_context_delete = label(SequencerTrackerElements.CONTEXT_DELETE) + self._lbl_context_cut = context_label(language_manager, ContextElements.CUT) + self._lbl_context_copy = context_label(language_manager, ContextElements.COPY) + self._lbl_context_paste = context_label(language_manager, ContextElements.PASTE) + self._lbl_context_delete = context_label(language_manager, ContextElements.DELETE) 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) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 6f29a1148..fdffff2e0 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -134,6 +134,10 @@ global.browser.label.clear_search: "Clear" # Global — Context menu # ============================================================================= global.context.label.play: "Play" +global.context.label.cut: "Cut" +global.context.label.copy: "Copy" +global.context.label.paste: "Paste" +global.context.label.delete: "Delete" global.context.label.mark_as_favorite: "Mark as favorite" global.context.label.unmark_as_favorite: "Unmark as favorite" global.context.label.copy_filename: "Copy filename to clipboard" @@ -450,10 +454,6 @@ 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_copy: "Copy" -sequencer.tracker.label.context_cut: "Cut" -sequencer.tracker.label.context_paste: "Paste" -sequencer.tracker.label.context_delete: "Delete" sequencer.tracker.label.context_note_off: "Note off" sequencer.tracker.label.context_set_instrument: "Set instrument" sequencer.tracker.label.context_no_samples: "No samples" @@ -487,10 +487,6 @@ sequencer.order.label.row_pulse_2: "Pulse 2" sequencer.order.label.row_triangle: "Triangle" sequencer.order.label.row_noise: "Noise" sequencer.order.label.context_play: "Play from this frame" -sequencer.order.label.context_copy: "Copy" -sequencer.order.label.context_cut: "Cut" -sequencer.order.label.context_paste: "Paste" -sequencer.order.label.context_delete: "Delete" sequencer.order.label.context_duplicate: "Duplicate" sequencer.order.label.context_clone: "Clone" sequencer.order.label.context_insert: "Insert frame" From 849ccbaf040534c56989685f0c09e4d004dfdef3 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 00:07:12 +0200 Subject: [PATCH 078/152] Extracted: one action builder --- .../ui/panels/sequencer/input/order.py | 31 ++- .../ui/panels/sequencer/input/state.py | 31 ++- .../ui/panels/sequencer/input/target.py | 56 ++++ .../ui/panels/sequencer/order.py | 170 ++++++------ .../ui/panels/sequencer/tracker.py | 139 ++++------ .../ui/panels/sequencer/test_block_menu.py | 250 ++++++++++++++---- .../sequencer/test_tracker_context_menu.py | 15 +- 7 files changed, 447 insertions(+), 245 deletions(-) create mode 100644 src/sampletones_application/ui/panels/sequencer/input/target.py diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index f62bbb40c..67b58a25b 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -66,6 +66,26 @@ def region(self) -> Optional[OrderRegion]: last_position=max(self.anchor.position, self.cursor.position), ) + def region_at(self, cell: OrderCursor) -> OrderRegion: + """The block a gesture raised on ``cell`` acts on: the selection it stands in, or the cell + alone. + + A gesture raised inside a selection acts on the whole of it, which is what a reader who has + just dragged a range out expects it to reach; one raised anywhere else acts on the cell it + names, which is a block of exactly that cell. + """ + region = self.region + if region is not None and region.covers(cell.generator, cell.position): + return region + + row = CHANNEL_AXIS.index(cell.generator) + return OrderRegion( + first_row=row, + last_row=row, + first_position=cell.position, + last_position=cell.position, + ) + @property def target_region(self) -> Optional[OrderRegion]: """The region a block gesture acts on: the selection, or the cursor's own cell. @@ -73,19 +93,10 @@ def target_region(self) -> Optional[OrderRegion]: A cursor with nothing selected stands on a block of one cell, so copying reaches the cell the reader is working in and needs no selection made first. """ - if self.region is not None: - return self.region - if self.cursor is None: return None - row = CHANNEL_AXIS.index(self.cursor.generator) - return OrderRegion( - first_row=row, - last_row=row, - first_position=self.cursor.position, - last_position=self.cursor.position, - ) + return self.region_at(self.cursor) def extend_to(self, cursor: OrderCursor) -> OrderInputState: """Carries the moving end of the selection to ``cursor``, anchoring it where it began. diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index b89e11a07..d06d77167 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -104,6 +104,26 @@ def region(self) -> Optional[TrackerRegion]: last_slot=max(anchor_slot, cursor_slot), ) + def region_at(self, cell: TrackerCursor) -> TrackerRegion: + """The block a gesture raised on ``cell`` acts on: the selection it stands in, or the cell + alone. + + A gesture raised inside a selection acts on the whole of it, which is what a reader who has + just dragged a range out expects it to reach; one raised anywhere else acts on the cell it + names, which is a block of exactly that cell. + """ + slot = TrackerSlot(cell.generator, cell.subcolumn) + region = self.region + if region is not None and region.covers(cell.row, slot): + return region + + return TrackerRegion( + first_row=cell.row, + last_row=cell.row, + first_slot=slot.flat_index, + last_slot=slot.flat_index, + ) + @property def target_region(self) -> Optional[TrackerRegion]: """The region a block gesture acts on: the selection, or the cursor's own cell. @@ -111,19 +131,10 @@ def target_region(self) -> Optional[TrackerRegion]: A cursor with nothing selected stands on a block of one cell, so copying reaches the cell the reader is working in and needs no selection made first. """ - if self.region is not None: - return self.region - if self.cursor is None: return None - slot = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index - return TrackerRegion( - first_row=self.cursor.row, - last_row=self.cursor.row, - first_slot=slot, - last_slot=slot, - ) + return self.region_at(self.cursor) def extend_to(self, cursor: TrackerCursor) -> TrackerInputState: """Carries the moving end of the selection to ``cursor``, anchoring it where it began. diff --git a/src/sampletones_application/ui/panels/sequencer/input/target.py b/src/sampletones_application/ui/panels/sequencer/input/target.py new file mode 100644 index 000000000..089a812cb --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/input/target.py @@ -0,0 +1,56 @@ +from dataclasses import dataclass + +from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.order import OrderCursor +from sampletones_application.view_model.sequencer.region import ( + OrderCell, + OrderRegion, + TrackerCell, + TrackerRegion, +) + + +@dataclass(frozen=True) +class TrackerMenuTarget: + """The tracker cell a set of actions was raised on, and the block those actions act on. + + Both are needed at once: the block decides what the clipboard actions cover, while the cell + decides where a pasted block lands and which row and channel the cell-level actions reach. + A target keeps the pair together, so a builder handed one prints a whole action set. + """ + + cell: TrackerCursor + region: TrackerRegion + + @property + def anchor(self) -> TrackerCell: + """The cell a pasted block is written from, which is the target's own row and column. + + A block carries the subcolumn offsets it was read at, so the anchor names a row and a + column and leaves the rest to the block. + """ + return TrackerCell( + row=self.cell.row, + generator=self.cell.generator, + ) + + +@dataclass(frozen=True) +class OrderMenuTarget: + """The order cell a set of actions was raised on, and the block those actions act on. + + Both are needed at once: the block decides what the clipboard actions cover, while the cell + decides where a pasted block lands and which frame the frame actions reach. + """ + + cell: OrderCursor + region: OrderRegion + + @property + def anchor(self) -> OrderCell: + """The cell a pasted block is written from, which is the target's own channel row and + position.""" + return OrderCell( + generator=self.cell.generator, + position=self.cell.position, + ) diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index b58c53f4a..9d16ef0e2 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -51,6 +51,7 @@ OrderCursor, OrderInputState, ) +from sampletones_application.ui.panels.sequencer.input.target import OrderMenuTarget from sampletones_application.ui.themes.inline import ( create_header_selectable_theme, create_selectable_text_theme, @@ -1043,6 +1044,7 @@ def _show_context_menu( generator: Optional[GeneratorName], position: int, ) -> None: + target = self._menu_target(OrderCursor(generator, position)) with context_menu(): header = dpg.add_text(display_id(position)) FontRegistry.bind_to_item(header, Font.MONO_BOLD) @@ -1053,114 +1055,106 @@ def _show_context_menu( shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() - self._add_block_items(generator, position) - dpg.add_separator() - dpg.add_menu_item( - label=self._lbl_context_duplicate, - shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME), - callback=lambda: self.call(self.on_duplicate_requested, position), - ) - dpg.add_menu_item( - label=self._lbl_context_clone, - shortcut=self._shortcuts.display(ShortcutId.ORDER_CLONE_FRAME), - callback=lambda: self.call(self.on_clone_requested, position), - ) - dpg.add_menu_item( - label=self._lbl_context_insert, - shortcut=self._shortcuts.display(ShortcutId.ORDER_INSERT_FRAME), - callback=lambda: self.call(self.on_insert_requested, position), - ) - dpg.add_menu_item( - label=self._lbl_context_clear, - shortcut=self._shortcuts.display(ShortcutId.ORDER_CLEAR_FRAME), - callback=lambda: self.call(self.on_clear_requested, position), - ) - dpg.add_menu_item( - label=self._lbl_context_remove, - shortcut=self._shortcuts.display(ShortcutId.ORDER_REMOVE_FRAME), - callback=lambda: self.call(self.on_remove_requested, position), - ) - dpg.add_separator() - self._add_move_item( - self._lbl_context_move_left, - ShortcutId.ORDER_MOVE_FRAME_LEFT, - position, - ) - self._add_move_item( - self._lbl_context_move_right, - ShortcutId.ORDER_MOVE_FRAME_RIGHT, - position, - ) - self._add_move_item( - self._lbl_context_move_start, - ShortcutId.ORDER_MOVE_FRAME_TO_START, - position, - ) - self._add_move_item( - self._lbl_context_move_end, - ShortcutId.ORDER_MOVE_FRAME_TO_END, - position, - ) - - def _menu_region( - self, - generator: Optional[GeneratorName], - position: int, - ) -> OrderRegion: - """The block a menu raised on a cell acts on: the selection it stands in, or the cell alone. + self._add_action_items(target) - A menu opened inside a selection acts on the whole of it, which is what a reader who has - just dragged a range out expects the actions to reach; one opened anywhere else acts on the - cell it was raised on, the same block the cursor alone stands for. - """ - region = self._input_state.region - if region is not None and region.covers(generator, position): - return region - - row = CHANNEL_AXIS.index(generator) - return OrderRegion( - first_row=row, - last_row=row, - first_position=position, - last_position=position, + def _menu_target(self, cell: OrderCursor) -> OrderMenuTarget: + """The cell a set of actions is built for, paired with the block those actions act on.""" + return OrderMenuTarget( + cell=cell, + region=self._input_state.region_at(cell), ) - def _add_block_items( - self, - generator: Optional[GeneratorName], - position: int, - ) -> None: - """Builds the clipboard items, acting on the block the menu was raised on. + def _add_action_items(self, target: OrderMenuTarget) -> None: + """Builds every action an order cell offers, in the order each menu prints them. - Paste is offered once a block has been copied, and it anchors at the clicked cell, so the - menu lands a block where the pointer is while the keys land it under the cursor. Delete - prints no key of its own, because ``Del`` empties a selection while one stands and clears - the cell under the cursor otherwise. + The table states its actions once, and whoever asks for them decides where they are shown: + the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the + cursor stands on. An action added here reaches both. + """ + self._add_block_items(target) + dpg.add_separator() + self._add_frame_items(target.cell.position) + dpg.add_separator() + self._add_move_items(target.cell.position) + + def _add_block_items(self, target: OrderMenuTarget) -> None: + """Builds the clipboard items, acting on the block the actions were raised on. + + Paste is offered once a block has been copied, and it anchors at the target's own cell, so + the cell menu lands a block where the pointer is while the keys land it under the cursor. + Delete prints no key of its own, because ``Del`` empties a selection while one stands and + clears the cell under the cursor otherwise. """ - region = self._menu_region(generator, position) - cell = OrderCell( - generator=generator, - position=position, - ) dpg.add_menu_item( label=self._lbl_context_copy, shortcut=self._shortcuts.display(ShortcutId.ORDER_COPY_BLOCK), - callback=lambda: self.call(self.on_copy_block, region), + callback=lambda: self.call(self.on_copy_block, target.region), ) dpg.add_menu_item( label=self._lbl_context_cut, shortcut=self._shortcuts.display(ShortcutId.ORDER_CUT_BLOCK), - callback=lambda: self.call(self.on_cut_block, region), + callback=lambda: self.call(self.on_cut_block, target.region), ) dpg.add_menu_item( label=self._lbl_context_paste, shortcut=self._shortcuts.display(ShortcutId.ORDER_PASTE_BLOCK), enabled=self.query(self.can_paste_block, default=False), - callback=lambda: self.call(self.on_paste_block, cell), + callback=lambda: self.call(self.on_paste_block, target.anchor), ) dpg.add_menu_item( label=self._lbl_context_delete, - callback=lambda: self.call(self.on_delete_block, region), + callback=lambda: self.call(self.on_delete_block, target.region), + ) + + def _add_frame_items(self, position: int) -> None: + """Builds the frame operations, each acting on the whole frame the target cell sits in.""" + dpg.add_menu_item( + label=self._lbl_context_duplicate, + shortcut=self._shortcuts.display(ShortcutId.ORDER_DUPLICATE_FRAME), + callback=lambda: self.call(self.on_duplicate_requested, position), + ) + dpg.add_menu_item( + label=self._lbl_context_clone, + shortcut=self._shortcuts.display(ShortcutId.ORDER_CLONE_FRAME), + callback=lambda: self.call(self.on_clone_requested, position), + ) + dpg.add_menu_item( + label=self._lbl_context_insert, + shortcut=self._shortcuts.display(ShortcutId.ORDER_INSERT_FRAME), + callback=lambda: self.call(self.on_insert_requested, position), + ) + dpg.add_menu_item( + label=self._lbl_context_clear, + shortcut=self._shortcuts.display(ShortcutId.ORDER_CLEAR_FRAME), + callback=lambda: self.call(self.on_clear_requested, position), + ) + dpg.add_menu_item( + label=self._lbl_context_remove, + shortcut=self._shortcuts.display(ShortcutId.ORDER_REMOVE_FRAME), + callback=lambda: self.call(self.on_remove_requested, position), + ) + + def _add_move_items(self, position: int) -> None: + """Builds the four moves a frame can make, in the order they walk the song.""" + self._add_move_item( + self._lbl_context_move_left, + ShortcutId.ORDER_MOVE_FRAME_LEFT, + position, + ) + self._add_move_item( + self._lbl_context_move_right, + ShortcutId.ORDER_MOVE_FRAME_RIGHT, + position, + ) + self._add_move_item( + self._lbl_context_move_start, + ShortcutId.ORDER_MOVE_FRAME_TO_START, + position, + ) + self._add_move_item( + self._lbl_context_move_end, + ShortcutId.ORDER_MOVE_FRAME_TO_END, + position, ) def _add_move_item( diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 8918e1ddf..ab7ce277d 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -56,6 +56,7 @@ EditAction, ) from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.target import TrackerMenuTarget from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background from sampletones_application.ui.themes.inline import ( create_header_selectable_theme, @@ -1259,6 +1260,7 @@ def _show_context_menu( generator: Optional[GeneratorName], subcolumn: SubColumn, ) -> None: + target = self._menu_target(TrackerCursor(row_index, generator, subcolumn)) with context_menu(): header = dpg.add_text( tracker_display.indexed_label(row_index, self._column_labels[generator]), @@ -1276,88 +1278,66 @@ def _show_context_menu( shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() - self._add_block_items(row_index, generator, subcolumn) - dpg.add_separator() - self._add_instrument_submenu(row_index, generator) - dpg.add_menu_item( - label=self._lbl_context_note_off, - callback=lambda: self.call(self.on_set_note_off, row_index, generator), - ) - dpg.add_separator() - self._add_transpose_items(row_index, generator) - dpg.add_separator() - self._add_volume_items(row_index, generator) - dpg.add_separator() - self._add_clear_items(row_index, generator, subcolumn) - - def _menu_region( - self, - row_index: int, - generator: Optional[GeneratorName], - subcolumn: SubColumn, - ) -> TrackerRegion: - """The block a menu raised on a cell acts on: the selection it stands in, or the cell alone. + self._add_action_items(target) - A menu opened inside a selection acts on the whole of it, which is what a reader who has - just dragged a range out expects the actions to reach; one opened anywhere else acts on the - cell it was raised on, the same block the cursor alone stands for. - """ - slot = TrackerSlot(generator, subcolumn) - region = self._input_state.region - if region is not None and region.covers(row_index, slot): - return region - - return TrackerRegion( - first_row=row_index, - last_row=row_index, - first_slot=slot.flat_index, - last_slot=slot.flat_index, + def _menu_target(self, cell: TrackerCursor) -> TrackerMenuTarget: + """The cell a set of actions is built for, paired with the block those actions act on.""" + return TrackerMenuTarget( + cell=cell, + region=self._input_state.region_at(cell), ) - def _add_block_items( - self, - row_index: int, - generator: Optional[GeneratorName], - subcolumn: SubColumn, - ) -> None: - """Builds the clipboard items, acting on the block the menu was raised on. + def _add_action_items(self, target: TrackerMenuTarget) -> None: + """Builds every action a tracker cell offers, in the order each menu prints them. - Paste is offered once a block has been copied, and it anchors at the clicked cell, so the - menu lands a block where the pointer is while the keys land it under the cursor. Delete - prints no key of its own, because ``Del`` empties a selection while one stands and clears - the cell under the cursor otherwise. + The grid states its actions once, and whoever asks for them decides where they are shown: + the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the + cursor stands on. An action added here reaches both. """ - region = self._menu_region(row_index, generator, subcolumn) - cell = TrackerCell( - row=row_index, - generator=generator, + self._add_block_items(target) + dpg.add_separator() + self._add_instrument_submenu(target.cell) + dpg.add_menu_item( + label=self._lbl_context_note_off, + callback=lambda: self.call(self.on_set_note_off, target.cell.row, target.cell.generator), ) + dpg.add_separator() + self._add_transpose_items(target.cell) + dpg.add_separator() + self._add_volume_items(target.cell) + dpg.add_separator() + self._add_clear_items(target.cell) + + def _add_block_items(self, target: TrackerMenuTarget) -> None: + """Builds the clipboard items, acting on the block the actions were raised on. + + Paste is offered once a block has been copied, and it anchors at the target's own cell, so + the cell menu lands a block where the pointer is while the keys land it under the cursor. + Delete prints no key of its own, because ``Del`` empties a selection while one stands and + clears the cell under the cursor otherwise. + """ dpg.add_menu_item( label=self._lbl_context_copy, shortcut=self._shortcuts.display(ShortcutId.TRACKER_COPY_BLOCK), - callback=lambda: self.call(self.on_copy_block, region), + callback=lambda: self.call(self.on_copy_block, target.region), ) dpg.add_menu_item( label=self._lbl_context_cut, shortcut=self._shortcuts.display(ShortcutId.TRACKER_CUT_BLOCK), - callback=lambda: self.call(self.on_cut_block, region), + callback=lambda: self.call(self.on_cut_block, target.region), ) dpg.add_menu_item( label=self._lbl_context_paste, shortcut=self._shortcuts.display(ShortcutId.TRACKER_PASTE_BLOCK), enabled=self.query(self.can_paste_block, default=False), - callback=lambda: self.call(self.on_paste_block, cell), + callback=lambda: self.call(self.on_paste_block, target.anchor), ) dpg.add_menu_item( label=self._lbl_context_delete, - callback=lambda: self.call(self.on_delete_block, region), + callback=lambda: self.call(self.on_delete_block, target.region), ) - def _add_instrument_submenu( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: + def _add_instrument_submenu(self, cell: TrackerCursor) -> None: with dpg.menu(label=self._lbl_context_set_instrument): samples = self._current_samples.samples if self._current_samples is not None else () if not samples: @@ -1370,15 +1350,11 @@ def _add_instrument_submenu( for index, sample in enumerate(samples): dpg.add_menu_item( label=tracker_display.indexed_label(index, sample.name), - user_data=(row_index, generator, sample.sample_id), + user_data=(cell.row, cell.generator, sample.sample_id), callback=self._on_set_instrument_menu, ) - def _add_transpose_items( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: + def _add_transpose_items(self, cell: TrackerCursor) -> None: for label, delta in ( (self._lbl_context_transpose_up, SEMITONE_STEP), (self._lbl_context_transpose_down, -SEMITONE_STEP), @@ -1387,15 +1363,11 @@ def _add_transpose_items( ): dpg.add_menu_item( label=label, - user_data=(row_index, generator, delta), + user_data=(cell.row, cell.generator, delta), callback=self._on_transpose_menu, ) - def _add_volume_items( - self, - row_index: int, - generator: Optional[GeneratorName], - ) -> None: + def _add_volume_items(self, cell: TrackerCursor) -> None: for label, delta in ( (self._lbl_context_volume_up, VOLUME_FINE_STEP), (self._lbl_context_volume_down, -VOLUME_FINE_STEP), @@ -1404,7 +1376,7 @@ def _add_volume_items( ): dpg.add_menu_item( label=label, - user_data=(row_index, generator, delta), + user_data=(cell.row, cell.generator, delta), callback=self._on_volume_menu, ) @@ -1435,13 +1407,8 @@ def _on_volume_menu( row_index, generator, delta = user_data self.call(self.on_adjust_volume, row_index, generator, delta) - def _add_clear_items( - self, - row_index: int, - generator: Optional[GeneratorName], - subcolumn: SubColumn, - ) -> None: - """Builds the three clear levels: the clicked subcolumn, the whole channel cell, the whole row. + def _add_clear_items(self, cell: TrackerCursor) -> None: + """Builds the three clear levels: the target's subcolumn, its whole channel cell, its whole row. The cell and row levels coincide on the sample column, which already clears every channel, so the per-channel ``Clear cell`` item is offered only for an actual channel. @@ -1450,23 +1417,23 @@ def _add_clear_items( label=self._lbl_context_clear_subcolumn, callback=lambda: self.call( self.on_clear_subcolumn, - row_index, - generator, - subcolumn, + cell.row, + cell.generator, + cell.subcolumn, ), ) - if generator is not None: + if cell.generator is not None: dpg.add_menu_item( label=self._lbl_context_clear_cell, callback=lambda: self.call( self.on_clear_row, - row_index, - generator, + cell.row, + cell.generator, ), ) dpg.add_menu_item( label=self._lbl_context_clear_row, - callback=lambda: self.call(self.on_clear_row, row_index, None), + callback=lambda: self.call(self.on_clear_row, cell.row, None), ) def _keys_active(self) -> bool: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 130d1f060..7776dc1d3 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -1,5 +1,7 @@ +import contextlib from dataclasses import dataclass, field -from typing import Any, Callable, List, Optional +from types import ModuleType +from typing import Any, Callable, Iterator, List, Optional, Tuple import pytest @@ -55,6 +57,10 @@ class Gestures: pasted: List[Any] = field(default_factory=list) +def _prints_only() -> None: + """Stands in for the callback of an item that only states something, such as an empty list.""" + + class _MenuRecorder: """Captures the items a builder registers, in the order it registers them.""" @@ -66,17 +72,56 @@ def add_menu_item(self, **kwargs: Any) -> int: MenuItem( label=kwargs["label"], enabled=kwargs.get("enabled", True), - callback=kwargs["callback"], + callback=kwargs.get("callback", _prints_only), ) ) return 0 -def _labels(panel: Any) -> None: - panel._lbl_context_copy = "Copy" - panel._lbl_context_cut = "Cut" - panel._lbl_context_paste = "Paste" - panel._lbl_context_delete = "Delete" +CLIPBOARD_LABELS = { + "copy": "Copy", + "cut": "Cut", + "paste": "Paste", + "delete": "Delete", +} + +TRACKER_LABELS = ( + "note_off", + "set_instrument", + "no_samples", + "clear_subcolumn", + "clear_cell", + "clear_row", + "transpose_up", + "transpose_down", + "transpose_octave_up", + "transpose_octave_down", + "volume_up", + "volume_down", + "volume_up_coarse", + "volume_down_coarse", +) + +ORDER_LABELS = ( + "duplicate", + "clone", + "insert", + "clear", + "remove", + "move_left", + "move_right", + "move_start", + "move_end", +) + + +def _labels(panel: Any, names: Tuple[str, ...]) -> None: + """Gives the panel the words its builders print, the clipboard four reading as they ship.""" + for name, text in CLIPBOARD_LABELS.items(): + setattr(panel, f"_lbl_context_{name}", text) + + for name in names: + setattr(panel, f"_lbl_context_{name}", name) def _tracker_panel( @@ -86,9 +131,10 @@ def _tracker_panel( ) -> tracker_module.GUISequencerTrackerPanel: """A tracker panel whose menu builder can run with no DearPyGui context behind it.""" panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) - _labels(panel) + _labels(panel, TRACKER_LABELS) panel._shortcuts = shipped_source() panel._input_state = TrackerInputState() + panel._current_samples = None panel.on_copy_block = gestures.copied.append panel.on_cut_block = gestures.cut.append panel.on_delete_block = gestures.deleted.append @@ -104,9 +150,10 @@ def _order_panel( ) -> order_module.GUISequencerOrderPanel: """An order panel whose menu builder can run with no DearPyGui context behind it.""" panel = order_module.GUISequencerOrderPanel.__new__(order_module.GUISequencerOrderPanel) - _labels(panel) + _labels(panel, ORDER_LABELS) panel._shortcuts = shipped_source() panel._input_state = OrderInputState() + panel._position_count = POSITION_COUNT panel.on_copy_block = gestures.copied.append panel.on_cut_block = gestures.cut.append panel.on_delete_block = gestures.deleted.append @@ -115,18 +162,41 @@ def _order_panel( return panel -@pytest.fixture -def tracker_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: +@contextlib.contextmanager +def _submenu(**_kwargs: Any) -> Iterator[None]: + """Stands in for a submenu, whose items land in the same recording as the rest.""" + yield + + +def _record_into( + monkeypatch: pytest.MonkeyPatch, + module: ModuleType, +) -> _MenuRecorder: recorder = _MenuRecorder() - monkeypatch.setattr(tracker_module.dpg, "add_menu_item", recorder.add_menu_item) + monkeypatch.setattr(module.dpg, "add_menu_item", recorder.add_menu_item) + monkeypatch.setattr(module.dpg, "add_separator", lambda **_kwargs: 0) + monkeypatch.setattr(module.dpg, "menu", _submenu) return recorder +@pytest.fixture +def tracker_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: + return _record_into(monkeypatch, tracker_module) + + @pytest.fixture def order_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: - recorder = _MenuRecorder() - monkeypatch.setattr(order_module.dpg, "add_menu_item", recorder.add_menu_item) - return recorder + return _record_into(monkeypatch, order_module) + + +def _tracker_cell(generator: Optional[GeneratorName]) -> TrackerCursor: + """The clicked cell the tracker item tests raise their menu on.""" + return TrackerCursor(CLICKED_ROW, generator, SubColumn.INSTRUMENT) + + +def _order_cell(generator: Optional[GeneratorName]) -> OrderCursor: + """The clicked cell the order item tests raise their menu on.""" + return OrderCursor(generator, CLICKED_POSITION) def _selected_tracker_state() -> TrackerInputState: @@ -146,25 +216,29 @@ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - region = panel._menu_region( - CLICKED_ROW + 1, - GeneratorName.PULSE1, - SubColumn.TRANSPOSE, + target = panel._menu_target( + TrackerCursor( + CLICKED_ROW + 1, + GeneratorName.PULSE1, + SubColumn.TRANSPOSE, + ) ) - assert region == panel._input_state.region + assert target.region == panel._input_state.region def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> None: panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - region = panel._menu_region( - CLICKED_ROW, - GeneratorName.TRIANGLE, - SubColumn.VOLUME, + target = panel._menu_target( + TrackerCursor( + CLICKED_ROW, + GeneratorName.TRIANGLE, + SubColumn.VOLUME, + ) ) - assert region == TrackerRegion( + assert target.region == TrackerRegion( first_row=CLICKED_ROW, last_row=CLICKED_ROW, first_slot=TrackerSlot(GeneratorName.TRIANGLE, SubColumn.VOLUME).flat_index, @@ -174,14 +248,35 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _tracker_panel(Gestures()) - region = panel._menu_region( - CLICKED_ROW, - None, - SubColumn.INSTRUMENT, - ) + target = panel._menu_target(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT)) - assert region.rows == range(CLICKED_ROW, CLICKED_ROW + 1) - assert region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),) + assert target.region.rows == range(CLICKED_ROW, CLICKED_ROW + 1) + assert target.region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),) + + def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: + """The menu bar asks for the cursor's own target, which is the standing selection.""" + panel = _tracker_panel(Gestures()) + panel._input_state = _selected_tracker_state() + cursor = TrackerCursor(CLICKED_ROW + 2, GeneratorName.PULSE1, SubColumn.VOLUME) + + target = panel._menu_target(cursor) + + assert target.region == panel._input_state.region + + def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: + panel = _tracker_panel(Gestures()) + cursor = TrackerCursor(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME) + panel._input_state = TrackerInputState(cursor=cursor) + + target = panel._menu_target(cursor) + + assert target.region == TrackerRegion( + first_row=CLICKED_ROW, + last_row=CLICKED_ROW, + first_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index, + last_slot=TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME).flat_index, + ) + assert target.anchor == TrackerCell(row=CLICKED_ROW, generator=GeneratorName.NOISE) class TestTrackerMenuItems: @@ -194,7 +289,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_tracker_state() selection = panel._input_state.region - panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) for item in tracker_recorder.items: item.callback() @@ -207,7 +302,15 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord gestures = Gestures() panel = _tracker_panel(gestures) - panel._add_block_items(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME) + panel._add_block_items( + panel._menu_target( + TrackerCursor( + CLICKED_ROW, + GeneratorName.NOISE, + SubColumn.VOLUME, + ) + ) + ) tracker_recorder.items[PASTE_ITEM].callback() assert gestures.pasted == [TrackerCell(row=CLICKED_ROW, generator=GeneratorName.NOISE)] @@ -215,7 +318,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord def test_paste_awaits_a_copy(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures(), can_paste=False) - panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) assert tracker_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in tracker_recorder.items] == [True, True, False, True] @@ -226,7 +329,7 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _tracker_panel(Gestures()) - panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) assert [item.label for item in tracker_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] @@ -236,17 +339,17 @@ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - region = panel._menu_region(GeneratorName.PULSE1, CLICKED_POSITION + 1) + target = panel._menu_target(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1)) - assert region == panel._input_state.region + assert target.region == panel._input_state.region def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> None: panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - region = panel._menu_region(None, CLICKED_POSITION) + target = panel._menu_target(_order_cell(None)) - assert region == OrderRegion( + assert target.region == OrderRegion( first_row=CHANNEL_AXIS.index(None), last_row=CHANNEL_AXIS.index(None), first_position=CLICKED_POSITION, @@ -256,14 +359,39 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _order_panel(Gestures()) - region = panel._menu_region(GeneratorName.PULSE1, CLICKED_POSITION) + target = panel._menu_target(_order_cell(GeneratorName.PULSE1)) + + assert target.region == OrderRegion( + first_row=PULSE1_ROW, + last_row=PULSE1_ROW, + first_position=CLICKED_POSITION, + last_position=CLICKED_POSITION, + ) + + def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: + """The menu bar asks for the cursor's own target, which is the standing selection.""" + panel = _order_panel(Gestures()) + panel._input_state = _selected_order_state() + cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 2) + + target = panel._menu_target(cursor) + + assert target.region == panel._input_state.region + + def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: + panel = _order_panel(Gestures()) + cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION) + panel._input_state = OrderInputState(cursor=cursor) + + target = panel._menu_target(cursor) - assert region == OrderRegion( + assert target.region == OrderRegion( first_row=PULSE1_ROW, last_row=PULSE1_ROW, first_position=CLICKED_POSITION, last_position=CLICKED_POSITION, ) + assert target.anchor == OrderCell(generator=GeneratorName.PULSE1, position=CLICKED_POSITION) class TestOrderMenuItems: @@ -276,7 +404,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_order_state() selection = panel._input_state.region - panel._add_block_items(GeneratorName.PULSE1, CLICKED_POSITION) + panel._add_block_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) for item in order_recorder.items: item.callback() @@ -288,7 +416,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder gestures = Gestures() panel = _order_panel(gestures) - panel._add_block_items(None, CLICKED_POSITION) + panel._add_block_items(panel._menu_target(_order_cell(None))) order_recorder.items[PASTE_ITEM].callback() assert gestures.pasted == [OrderCell(generator=None, position=CLICKED_POSITION)] @@ -296,7 +424,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder def test_paste_awaits_a_copy(self, order_recorder: _MenuRecorder) -> None: panel = _order_panel(Gestures(), can_paste=False) - panel._add_block_items(GeneratorName.PULSE1, CLICKED_POSITION) + panel._add_block_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) assert order_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in order_recorder.items] == [True, True, False, True] @@ -307,18 +435,46 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _order_panel(Gestures()) - panel._add_block_items(GeneratorName.PULSE1, CLICKED_POSITION) + panel._add_block_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) assert [item.label for item in order_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] +class TestActionSet: + """One builder states each grid's actions, so every menu offering them prints the same set.""" + + def test_the_tracker_action_set_opens_with_the_clipboard_items( + self, + tracker_recorder: _MenuRecorder, + ) -> None: + panel = _tracker_panel(Gestures()) + + panel._add_action_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) + + labels = [item.label for item in tracker_recorder.items] + assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] + assert panel._lbl_context_clear_row in labels + + def test_the_order_action_set_opens_with_the_clipboard_items( + self, + order_recorder: _MenuRecorder, + ) -> None: + panel = _order_panel(Gestures()) + + panel._add_action_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) + + labels = [item.label for item in order_recorder.items] + assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] + assert panel._lbl_context_move_end in labels + + class TestMenuItemOrder: """The four items keep the order the indices name, which is what the item tests read them by.""" def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures()) - panel._add_block_items(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) labels = [item.label for item in tracker_recorder.items] assert labels[COPY_ITEM] == "Copy" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 0eb8a719e..3e413f279 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -4,10 +4,12 @@ import pytest from sampletones_application.ui.panels.sequencer import tracker as tracker_module +from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.view_model.sequencer.samples import ( SampleEntryViewModel, SequencerSamplesViewModel, ) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP @@ -74,13 +76,18 @@ def _menu(**kwargs: Any) -> Iterator[None]: return instance +def _cell(row: int, generator: GeneratorName) -> TrackerCursor: + """The cell a menu was raised on, which the items carry as their payload.""" + return TrackerCursor(row, generator, SubColumn.INSTRUMENT) + + class TestMenuDispatchPreservesPayload: def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None: panel = _panel() deltas: List[int] = [] panel.on_adjust_transpose = lambda row, generator, delta: deltas.append(delta) - panel._add_transpose_items(2, GeneratorName.PULSE1) + panel._add_transpose_items(_cell(2, GeneratorName.PULSE1)) recorder.dispatch_as_dpg() assert deltas == [ @@ -95,7 +102,7 @@ def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder deltas: List[int] = [] panel.on_adjust_volume = lambda row, generator, delta: deltas.append(delta) - panel._add_volume_items(2, GeneratorName.PULSE1) + panel._add_volume_items(_cell(2, GeneratorName.PULSE1)) recorder.dispatch_as_dpg() assert deltas == [ @@ -110,7 +117,7 @@ def test_adjust_carries_the_clicked_row_and_channel(self, recorder: _MenuItemRec calls: List[Tuple[int, GeneratorName, int]] = [] panel.on_adjust_transpose = lambda row, generator, delta: calls.append((row, generator, delta)) - panel._add_transpose_items(7, GeneratorName.TRIANGLE) + panel._add_transpose_items(_cell(7, GeneratorName.TRIANGLE)) recorder.dispatch_as_dpg() assert calls[0] == (7, GeneratorName.TRIANGLE, SEMITONE_STEP) @@ -129,7 +136,7 @@ def test_instrument_items_pass_the_sample_id(self, recorder: _MenuItemRecorder) chosen: List[str] = [] panel.on_set_row = lambda row, generator, sample_id, transpose, volume: chosen.append(sample_id) - panel._add_instrument_submenu(0, GeneratorName.PULSE2) + panel._add_instrument_submenu(_cell(0, GeneratorName.PULSE2)) recorder.dispatch_as_dpg() assert chosen == ["lead-id"] From 4fc2f427f4643b3f21c66bed3d0a307cf58682e9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 00:53:55 +0200 Subject: [PATCH 079/152] Added: focus-aware actions in the Edit menu --- src/sampletones_application/application.py | 8 ++ .../coordinators/edit/__init__.py | 0 .../coordinators/edit/protocol.py | 7 ++ .../coordinators/edit/router.py | 43 ++++++++ .../coordinators/tabs/sequencer.py | 21 +++- src/sampletones_application/tags/general.py | 6 + src/sampletones_application/ui/menu.py | 75 ++++++++++++- .../ui/panels/sequencer/order.py | 19 ++++ .../ui/panels/sequencer/tracker.py | 19 ++++ src/sampletones_application/utils/gui/dpg.py | 16 +++ .../utils/gui/staging.py | 6 +- .../coordinators/edit/__init__.py | 0 .../coordinators/edit/test_router.py | 66 +++++++++++ .../sampletones_application/ui/test_menu.py | 103 +++++++++++++++++- 14 files changed, 381 insertions(+), 8 deletions(-) create mode 100644 src/sampletones_application/coordinators/edit/__init__.py create mode 100644 src/sampletones_application/coordinators/edit/protocol.py create mode 100644 src/sampletones_application/coordinators/edit/router.py create mode 100644 tests/unit/sampletones_application/coordinators/edit/__init__.py create mode 100644 tests/unit/sampletones_application/coordinators/edit/test_router.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index f1b0653dd..9f7ce0acb 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -15,6 +15,7 @@ from sampletones_application.constants.playback import FollowMode from sampletones_application.coordinators.config import ConfigCoordinator from sampletones_application.coordinators.display import DisplayCoordinator +from sampletones_application.coordinators.edit.router import EditRouter from sampletones_application.coordinators.keybindings import KeybindingsCoordinator from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol @@ -312,6 +313,7 @@ def __init__( player_glyphs=self.layout.glyphs.player, player_layout=self.layout.player, language_manager=self.language_manager, + build_edit_actions=self._build_edit_actions, on_play_from_start=self._play_from_start, on_pause_or_resume=self._play, on_stop=self._stop, @@ -461,6 +463,8 @@ def __init__( on_channels_changed=self._update_menu, ) + self._edit_router = EditRouter(surfaces=self._sequencer_tab.edit_surfaces) + self._playback_router = PlaybackRouter( sources=( self._reconstructions_tab.player, @@ -1325,6 +1329,10 @@ def _persist_application_state(self) -> None: self.session_manager.set_current_tab(current_tab) self.session_manager.save_config() + def _build_edit_actions(self) -> bool: + """States the actions of the grid holding the cursor into the Edit menu being built.""" + return self._edit_router.build_menu_actions() + def _play_from_start(self) -> None: self._playback_router.play_from_start() self._update_menu() diff --git a/src/sampletones_application/coordinators/edit/__init__.py b/src/sampletones_application/coordinators/edit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/coordinators/edit/protocol.py b/src/sampletones_application/coordinators/edit/protocol.py new file mode 100644 index 000000000..3c5c57fe6 --- /dev/null +++ b/src/sampletones_application/coordinators/edit/protocol.py @@ -0,0 +1,7 @@ +from typing import Protocol + + +class EditSurfaceProtocol(Protocol): + def owns_edit_actions(self) -> bool: ... + + def build_edit_actions(self) -> None: ... diff --git a/src/sampletones_application/coordinators/edit/router.py b/src/sampletones_application/coordinators/edit/router.py new file mode 100644 index 000000000..edd6c3c24 --- /dev/null +++ b/src/sampletones_application/coordinators/edit/router.py @@ -0,0 +1,43 @@ +from typing import Optional, Sequence + +from sampletones_application.coordinators.edit.protocol import EditSurfaceProtocol + + +class EditRouter: + """The single editing surface behind the menu bar's Edit menu. + + A surface is a grid that offers editing gestures on the cell it holds a cursor in. Each one + states whether it owns those gestures at this moment, and the router asks the one that does to + build its actions into the menu being built. Surfaces are mutually exclusive, since taking a + cursor in one drops the cursor of the others, so at most one answers. + + It is stateless: the surface is resolved on each call, so the menu states the actions of + whoever holds the cursor when it is opened, and needs no notice of cursors moving. + + The router itself draws nothing. It calls the surface, which builds its items into the + container the menu bar has opened, the way :class:`PlaybackRouter` calls a source to play. + """ + + def __init__(self, *, surfaces: Sequence[EditSurfaceProtocol]) -> None: + self._surfaces = tuple(surfaces) + + def build_menu_actions(self) -> bool: + """Builds the focused surface's actions, reporting whether a surface stated any. + + Returns: + bool: Whether a surface owned the editing gestures and built its actions. + """ + surface = self._focused_surface() + if surface is None: + return False + + surface.build_edit_actions() + return True + + def _focused_surface(self) -> Optional[EditSurfaceProtocol]: + """The surface owning the editing gestures, or ``None`` while a reader edits elsewhere.""" + for surface in self._surfaces: + if surface.owns_edit_actions(): + return surface + + return None diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 20486780b..2ed05068f 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Optional, ParamSpec, Union +from typing import Callable, Optional, ParamSpec, Tuple, Union import dearpygui.dearpygui as dpg @@ -11,6 +11,7 @@ from sampletones_application.config.managers.config import ConfigManager from sampletones_application.config.managers.session import SessionManager from sampletones_application.constants.playback import FollowMode +from sampletones_application.coordinators.edit.protocol import EditSurfaceProtocol from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol @@ -1277,7 +1278,12 @@ def _settle_inserted_frame(self, position: int) -> None: standing on one of them follows it, and the grid moves to the new frame for the reader to work on. """ - self._relocate_playhead(lambda playhead: remap_after_insert(playhead, position)) + self._relocate_playhead( + lambda playhead: remap_after_insert( + playhead, + position, + ) + ) self._select_frame_when_idle(position) def _on_order_insert(self, position: int) -> None: @@ -1411,3 +1417,14 @@ def _build_right_column(self, parent: str) -> None: @property def player(self) -> AudioPlayerProtocol: return self._guarded_player + + @property + def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]: + """The grids offering editing gestures on the cell they hold a cursor in. + + The two hold one cursor between them, so the menu bar reaches whichever one has it. + """ + return ( + self._sequencer_tracker_panel, + self._sequencer_order_panel, + ) diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index d1814485f..ecdfc8f74 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -440,6 +440,12 @@ Widget.MENU, "item_edit_redo", ) +TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "group_edit_actions", +) TAG_GLOBAL_DIALOG_PROJECT_SAVED = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 056e7a529..b9b21d342 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -1,5 +1,5 @@ from functools import partial -from typing import Callable, Dict, Final, Tuple +from typing import Callable, Dict, Final, Optional, Tuple import dearpygui.dearpygui as dpg @@ -19,6 +19,8 @@ from sampletones_application.layout.player import PlayerLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( + SUF_HANDLER_REGISTRY, + TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, TAG_GLOBAL_MENU_ITEM_EDIT_REDO, TAG_GLOBAL_MENU_ITEM_EDIT_UNDO, TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT, @@ -68,6 +70,8 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.gui.dpg import ( dpg_configure_item, + dpg_container, + dpg_delete_children, dpg_set_item_label, dpg_set_value, ) @@ -81,6 +85,7 @@ from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager from sampletones_application.view_model.shared.menu import MenuBarViewModel from sampletones_core.constants.enums import GeneratorName +from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import VoidCallback PROJECT_ITEM_TAGS: Final[Tuple[str, ...]] = ( @@ -101,6 +106,12 @@ FollowMode.PATTERNS: MenuElements.ITEM_PLAYBACK_FOLLOW_PATTERNS, FollowMode.OFF: MenuElements.ITEM_PLAYBACK_FOLLOW_OFF, } +UNFOCUSED_CLIPBOARD_ELEMENTS: Final[Tuple[ContextElements, ...]] = ( + ContextElements.COPY, + ContextElements.CUT, + ContextElements.PASTE, + ContextElements.DELETE, +) CHANNEL_LABELS: Final[Dict[GeneratorName, ContextElements]] = { GeneratorName.PULSE1: ContextElements.PULSE_1, GeneratorName.PULSE2: ContextElements.PULSE_2, @@ -120,6 +131,7 @@ def __init__( player_glyphs: PlayerGlyphs, player_layout: PlayerLayout, language_manager: LanguageManager, + build_edit_actions: Callable[[], bool], on_play_from_start: VoidCallback, on_pause_or_resume: VoidCallback, on_stop: VoidCallback, @@ -132,6 +144,7 @@ def __init__( self._player_glyphs = player_glyphs self._player_layout = player_layout self._language_manager = language_manager + self._build_edit_actions = build_edit_actions self._on_play_from_start = on_play_from_start self._on_pause_or_resume = on_pause_or_resume self._on_stop = on_stop @@ -144,6 +157,12 @@ def __init__( self._pause_tooltip_tag = compose_tag(self._pause_button_tag, SUF_PLAYER_TOOLTIP) self._lbl_pause = language_manager["global.player.label.pause"] + self._edit_actions_handler_tag = compose_tag( + TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, + SUF_HANDLER_REGISTRY, + ) + self._edit_actions_frame: Optional[int] = None + def _label(self, element: MenuElements) -> str: return self._language_manager[ Page.GLOBAL, @@ -237,6 +256,11 @@ def _create_project_export_menu(self, state: MenuBarViewModel) -> None: ) def _create_edit_menu(self, state: MenuBarViewModel) -> None: + """Builds the Edit menu: the history steps, then the actions of whoever holds the cursor. + + The trailing section is a container of its own, so the actions it holds are stated afresh + each time the menu is opened while Undo and Redo stand where they are. + """ with dpg.menu(label=self._label(MenuElements.GROUP_EDIT)): self._shortcut_manager.add_menu_item( ShortcutId.UNDO, @@ -250,6 +274,55 @@ def _create_edit_menu(self, state: MenuBarViewModel) -> None: label=self._label(MenuElements.ITEM_EDIT_REDO), enabled=state.redo_enabled, ) + dpg.add_separator() + dpg.add_group(tag=TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS) + + with dpg.item_handler_registry(tag=self._edit_actions_handler_tag): + dpg.add_item_visible_handler(callback=self._on_edit_actions_drawn) + + dpg.bind_item_handler_registry( + TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, + self._edit_actions_handler_tag, + ) + self._refresh_edit_actions() + + def _on_edit_actions_drawn( + self, + _sender: Sender, + _app_data: Sender, + ) -> None: + """States the actions afresh each time the Edit menu is opened. + + DearPyGui reports the section drawn once a frame while the menu stands open, so a gap in + those reports marks a fresh opening. The section stays built between openings, which gives + the popup its full height on the frame it appears, and the rebuilt one takes over a frame + later — long before an item can be reached and chosen. + """ + frame = dpg.get_frame_count() + reopened = self._edit_actions_frame is None or frame - self._edit_actions_frame > 1 + self._edit_actions_frame = frame + if reopened: + self._refresh_edit_actions() + + def _refresh_edit_actions(self) -> None: + """Empties the Edit menu's trailing section and asks the focused surface to state it. + + A surface builds the same actions its own cell menu offers, so the two doors print one set + with the keys and the enablement each action carries. With no surface holding a cursor, the + clipboard actions are named greyed out, so a reader still meets the commands. + """ + dpg_delete_children(TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS) + with dpg_container(TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS): + if not self._build_edit_actions(): + self._add_unfocused_clipboard_items() + + def _add_unfocused_clipboard_items(self) -> None: + """Names the clipboard actions greyed out, the Edit menu with no grid holding a cursor.""" + for element in UNFOCUSED_CLIPBOARD_ELEMENTS: + dpg.add_menu_item( + label=self._context_label(element), + enabled=False, + ) def _create_reconstruction_menu(self, state: MenuBarViewModel) -> None: with dpg.menu(label=self._label(MenuElements.GROUP_RECONSTRUCTION)): diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 9d16ef0e2..080fa18b5 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -1064,6 +1064,25 @@ def _menu_target(self, cell: OrderCursor) -> OrderMenuTarget: region=self._input_state.region_at(cell), ) + def owns_edit_actions(self) -> bool: + """Whether the Edit menu states this table's actions, which it does while it owns keys. + + The menu offers what the next press would reach, so one question decides both. + """ + return self._keys_active() + + def build_edit_actions(self) -> None: + """Builds this table's whole action set for the cell the cursor stands on. + + The menu bar asks while the table owns the editing gestures, so the cursor names the target + the same way a pointer names it on the cell menu. + """ + cursor = self._input_state.cursor + if cursor is None: + return + + self._add_action_items(self._menu_target(cursor)) + def _add_action_items(self, target: OrderMenuTarget) -> None: """Builds every action an order cell offers, in the order each menu prints them. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index ab7ce277d..249f0e21c 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -1287,6 +1287,25 @@ def _menu_target(self, cell: TrackerCursor) -> TrackerMenuTarget: region=self._input_state.region_at(cell), ) + def owns_edit_actions(self) -> bool: + """Whether the Edit menu states this grid's actions, which it does while the grid owns keys. + + The menu offers what the next press would reach, so one question decides both. + """ + return self._keys_active() + + def build_edit_actions(self) -> None: + """Builds this grid's whole action set for the cell the cursor stands on. + + The menu bar asks while the grid owns the editing gestures, so the cursor names the target + the same way a pointer names it on the cell menu. + """ + cursor = self._input_state.cursor + if cursor is None: + return + + self._add_action_items(self._menu_target(cursor)) + def _add_action_items(self, target: TrackerMenuTarget) -> None: """Builds every action a tracker cell offers, in the order each menu prints them. diff --git a/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py index 8e0936774..42bd49c7b 100644 --- a/src/sampletones_application/utils/gui/dpg.py +++ b/src/sampletones_application/utils/gui/dpg.py @@ -1,8 +1,10 @@ import functools +from contextlib import contextmanager from typing import ( Any, Callable, Concatenate, + Iterator, Optional, ParamSpec, TypeVar, @@ -55,6 +57,20 @@ def dpg_delete_item(tag: Sender, /, *args: Any, **kwargs: Any) -> None: dpg.delete_item(tag, *args, **kwargs) +@contextmanager +def dpg_container(tag: Sender) -> Iterator[None]: + """Makes ``tag`` the container parentless items land in for the length of the block. + + A builder that states its items without naming a parent can then be pointed at any container, + which is how one set of items is built into the menu, popup or panel that asked for it. + """ + dpg.push_container_stack(tag) + try: + yield + finally: + dpg.pop_container_stack() + + def dpg_delete_children(tag: Sender, /, *_args: Any, **kwargs: Any) -> None: dpg_delete_item(tag, children_only=True, **kwargs) diff --git a/src/sampletones_application/utils/gui/staging.py b/src/sampletones_application/utils/gui/staging.py index e3b872f79..f4f31af1f 100644 --- a/src/sampletones_application/utils/gui/staging.py +++ b/src/sampletones_application/utils/gui/staging.py @@ -3,6 +3,7 @@ import dearpygui.dearpygui as dpg +from sampletones_application.utils.gui.dpg import dpg_container from sampletones_shared.types.application import Sender @@ -25,11 +26,8 @@ def staged_container(stage: Sender) -> Iterator[None]: Items created with an explicit parent still honour that parent; the stage captures the parentless ones. """ - dpg.push_container_stack(stage) - try: + with dpg_container(stage): yield - finally: - dpg.pop_container_stack() def attach_staged_item(item: Sender, parent: Sender) -> None: diff --git a/tests/unit/sampletones_application/coordinators/edit/__init__.py b/tests/unit/sampletones_application/coordinators/edit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/coordinators/edit/test_router.py b/tests/unit/sampletones_application/coordinators/edit/test_router.py new file mode 100644 index 000000000..61d52fc84 --- /dev/null +++ b/tests/unit/sampletones_application/coordinators/edit/test_router.py @@ -0,0 +1,66 @@ +from typing import List, Sequence + +from sampletones_application.coordinators.edit.router import EditRouter + + +class FakeSurface: + """A test double for a grid offering editing gestures on the cell it holds a cursor in.""" + + def __init__(self, name: str, *, focused: bool) -> None: + self.name = name + self.focused = focused + self.builds = 0 + + def owns_edit_actions(self) -> bool: + return self.focused + + def build_edit_actions(self) -> None: + self.builds += 1 + + +def _router(surfaces: Sequence[FakeSurface]) -> EditRouter: + return EditRouter(surfaces=surfaces) + + +class TestFocusedSurface: + """The menu states the actions of whoever holds the cursor when it is opened.""" + + def test_the_focused_surface_states_its_actions(self) -> None: + tracker = FakeSurface("tracker", focused=True) + order = FakeSurface("order", focused=False) + router = _router([tracker, order]) + + assert router.build_menu_actions() is True + assert (tracker.builds, order.builds) == (1, 0) + + def test_a_surface_left_behind_states_nothing(self) -> None: + tracker = FakeSurface("tracker", focused=False) + order = FakeSurface("order", focused=True) + router = _router([tracker, order]) + + router.build_menu_actions() + + assert (tracker.builds, order.builds) == (0, 1) + + def test_nothing_is_built_with_no_surface_focused(self) -> None: + """A tab switch leaves both grids holding their cursors while neither owns the keys.""" + surfaces = [FakeSurface("tracker", focused=False), FakeSurface("order", focused=False)] + router = _router(surfaces) + + assert router.build_menu_actions() is False + assert [surface.builds for surface in surfaces] == [0, 0] + + def test_a_router_with_no_surface_reports_nothing_built(self) -> None: + assert _router([]).build_menu_actions() is False + + def test_the_surface_is_resolved_on_each_call(self) -> None: + """The router holds no target, so a cursor taken after it was built reaches the menu.""" + tracker = FakeSurface("tracker", focused=False) + router = _router([tracker]) + + first: List[bool] = [router.build_menu_actions()] + tracker.focused = True + first.append(router.build_menu_actions()) + + assert first == [False, True] + assert tracker.builds == 1 diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index 1f13cf653..d4474195e 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -1,5 +1,5 @@ from contextlib import contextmanager -from typing import Any, Dict, FrozenSet, Iterator, List +from typing import Any, Callable, Dict, FrozenSet, Iterator, List import pytest @@ -7,6 +7,7 @@ from sampletones_application.constants.playback import FollowMode from sampletones_application.paths import LANG_EN from sampletones_application.tags.general import ( + TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, ) @@ -57,6 +58,9 @@ def __init__(self) -> None: self.values: Dict[str, bool] = {} self.enabled: Dict[str, bool] = {} self.menus: List[Dict[str, Any]] = [] + self.items: List[Dict[str, Any]] = [] + self.containers: List[str] = [] + self.emptied: List[str] = [] @contextmanager def menu(self, **kwargs: Any) -> Iterator[int]: @@ -69,6 +73,18 @@ def submenu(self, tag: str) -> Dict[str, Any]: def add_separator(self, **kwargs: Any) -> int: return 0 + def add_menu_item(self, **kwargs: Any) -> int: + self.items.append(kwargs) + return 0 + + @contextmanager + def container(self, tag: str) -> Iterator[None]: + self.containers.append(tag) + yield + + def delete_children(self, tag: str) -> None: + self.emptied.append(tag) + def set_value(self, item: str, value: bool) -> None: self.values[item] = value @@ -113,8 +129,11 @@ def framework(monkeypatch: pytest.MonkeyPatch) -> _DearPyGuiRecorder: instance = _DearPyGuiRecorder() monkeypatch.setattr(menu_module.dpg, "menu", instance.menu) monkeypatch.setattr(menu_module.dpg, "add_separator", instance.add_separator) + monkeypatch.setattr(menu_module.dpg, "add_menu_item", instance.add_menu_item) monkeypatch.setattr(menu_module, "dpg_set_value", instance.set_value) monkeypatch.setattr(menu_module, "dpg_configure_item", instance.configure_item) + monkeypatch.setattr(menu_module, "dpg_container", instance.container) + monkeypatch.setattr(menu_module, "dpg_delete_children", instance.delete_children) return instance @@ -359,3 +378,85 @@ def test_a_full_mix_withholds_the_restore( menu_bar._update_channels(_state(frozenset())) assert framework.enabled == {TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS: False} + + +def _edit_bar(build_edit_actions: Callable[[], bool]) -> MenuBar: + """A bar holding what the Edit menu's trailing section reads, and nothing else.""" + instance = MenuBar.__new__(MenuBar) + instance._language_manager = LanguageManager(LANG_EN) + instance._build_edit_actions = build_edit_actions + instance._edit_actions_frame = None + return instance + + +class TestEditActionsSection: + """The Edit menu carries the actions of the grid holding the cursor, and names them itself + while no grid holds one.""" + + def test_the_clipboard_actions_are_named_greyed_out_with_no_grid_focused( + self, + framework: _DearPyGuiRecorder, + ) -> None: + _edit_bar(lambda: False)._refresh_edit_actions() + + assert [item["label"] for item in framework.items] == ["Copy", "Cut", "Paste", "Delete"] + assert [item["enabled"] for item in framework.items] == [False] * 4 + + def test_a_focused_grid_states_its_own_actions( + self, + framework: _DearPyGuiRecorder, + ) -> None: + requests: List[bool] = [] + + def build() -> bool: + requests.append(True) + return True + + _edit_bar(build)._refresh_edit_actions() + + assert requests == [True] + assert framework.items == [] + + def test_the_section_is_emptied_before_the_actions_are_stated( + self, + framework: _DearPyGuiRecorder, + ) -> None: + _edit_bar(lambda: False)._refresh_edit_actions() + + assert framework.emptied == [TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS] + assert framework.containers == [TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS] + + +class TestEditActionsRefresh: + """DearPyGui reports the section drawn once a frame while the menu stands open, so a gap in + those reports is what marks a fresh opening.""" + + def test_the_actions_are_stated_once_while_the_menu_stays_open( + self, + framework: _DearPyGuiRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + frames = iter([10, 11, 12, 13]) + monkeypatch.setattr(menu_module.dpg, "get_frame_count", lambda: next(frames)) + requests: List[int] = [] + menu_bar = _edit_bar(lambda: bool(requests.append(1))) + + for _ in range(4): + menu_bar._on_edit_actions_drawn(0, 0) + + assert len(requests) == 1 + + def test_the_actions_are_stated_afresh_each_time_the_menu_is_opened( + self, + framework: _DearPyGuiRecorder, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + frames = iter([10, 11, 40, 41]) + monkeypatch.setattr(menu_module.dpg, "get_frame_count", lambda: next(frames)) + requests: List[int] = [] + menu_bar = _edit_bar(lambda: bool(requests.append(1))) + + for _ in range(4): + menu_bar._on_edit_actions_drawn(0, 0) + + assert len(requests) == 2 From 78f38cf48c9f969935ebd088c4ffd10539f29ea1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 01:21:15 +0200 Subject: [PATCH 080/152] Fixed: the Edit menu growing --- src/sampletones_application/tags/general.py | 10 +- src/sampletones_application/ui/menu.py | 55 +++++++---- src/sampletones_application/utils/gui/dpg.py | 26 ++++- .../sampletones_application/ui/test_menu.py | 99 +++++++++++++++---- 4 files changed, 150 insertions(+), 40 deletions(-) diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index ecdfc8f74..34749614d 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -440,11 +440,17 @@ Widget.MENU, "item_edit_redo", ) -TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS = TagName( +TAG_GLOBAL_MENU_GROUP_EDIT = TagName( Page.GLOBAL, Panel.IMPLICIT, Widget.MENU, - "group_edit_actions", + "group_edit", +) +TAG_GLOBAL_MENU_GROUP_EDIT_MARKER = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "group_edit_marker", ) TAG_GLOBAL_DIALOG_PROJECT_SAVED = TagName( Page.GLOBAL, diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index b9b21d342..377429b8d 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -20,7 +20,8 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_HANDLER_REGISTRY, - TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, + TAG_GLOBAL_MENU_GROUP_EDIT, + TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, TAG_GLOBAL_MENU_ITEM_EDIT_REDO, TAG_GLOBAL_MENU_ITEM_EDIT_UNDO, TAG_GLOBAL_MENU_ITEM_FILE_CLOSE_PROJECT, @@ -69,9 +70,9 @@ ) from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.gui.dpg import ( + dpg_append_items, dpg_configure_item, - dpg_container, - dpg_delete_children, + dpg_delete_item, dpg_set_item_label, dpg_set_value, ) @@ -158,10 +159,11 @@ def __init__( self._lbl_pause = language_manager["global.player.label.pause"] self._edit_actions_handler_tag = compose_tag( - TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, + TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, SUF_HANDLER_REGISTRY, ) self._edit_actions_frame: Optional[int] = None + self._edit_action_items: Tuple[Sender, ...] = () def _label(self, element: MenuElements) -> str: return self._language_manager[ @@ -258,10 +260,16 @@ def _create_project_export_menu(self, state: MenuBarViewModel) -> None: def _create_edit_menu(self, state: MenuBarViewModel) -> None: """Builds the Edit menu: the history steps, then the actions of whoever holds the cursor. - The trailing section is a container of its own, so the actions it holds are stated afresh - each time the menu is opened while Undo and Redo stand where they are. + The actions are stated into the menu itself and taken away again on each opening, so they + follow the cursor. A marker leads the menu, holding nothing and reporting the popup drawn: + a container standing below a menu item takes the width those items span as its own, which + the popup then grows to fit on every frame it stays open. """ - with dpg.menu(label=self._label(MenuElements.GROUP_EDIT)): + with dpg.menu( + label=self._label(MenuElements.GROUP_EDIT), + tag=TAG_GLOBAL_MENU_GROUP_EDIT, + ): + dpg.add_group(tag=TAG_GLOBAL_MENU_GROUP_EDIT_MARKER) self._shortcut_manager.add_menu_item( ShortcutId.UNDO, tag=TAG_GLOBAL_MENU_ITEM_EDIT_UNDO, @@ -275,13 +283,12 @@ def _create_edit_menu(self, state: MenuBarViewModel) -> None: enabled=state.redo_enabled, ) dpg.add_separator() - dpg.add_group(tag=TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS) with dpg.item_handler_registry(tag=self._edit_actions_handler_tag): dpg.add_item_visible_handler(callback=self._on_edit_actions_drawn) dpg.bind_item_handler_registry( - TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, + TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, self._edit_actions_handler_tag, ) self._refresh_edit_actions() @@ -293,10 +300,10 @@ def _on_edit_actions_drawn( ) -> None: """States the actions afresh each time the Edit menu is opened. - DearPyGui reports the section drawn once a frame while the menu stands open, so a gap in - those reports marks a fresh opening. The section stays built between openings, which gives - the popup its full height on the frame it appears, and the rebuilt one takes over a frame - later — long before an item can be reached and chosen. + DearPyGui reports the marker drawn once a frame while the menu stands open, so a gap in + those reports marks a fresh opening. The actions stay standing between openings, which + gives the popup its full height on the frame it appears, and the rebuilt ones take over a + frame later — long before an item can be reached and chosen. """ frame = dpg.get_frame_count() reopened = self._edit_actions_frame is None or frame - self._edit_actions_frame > 1 @@ -305,16 +312,24 @@ def _on_edit_actions_drawn( self._refresh_edit_actions() def _refresh_edit_actions(self) -> None: - """Empties the Edit menu's trailing section and asks the focused surface to state it. + """Takes the standing actions out of the Edit menu and asks the focused surface for its own. A surface builds the same actions its own cell menu offers, so the two doors print one set - with the keys and the enablement each action carries. With no surface holding a cursor, the - clipboard actions are named greyed out, so a reader still meets the commands. + with the keys and the enablement each action carries. The history steps above them stand + where they are, since only what the last build stated is taken away. """ - dpg_delete_children(TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS) - with dpg_container(TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS): - if not self._build_edit_actions(): - self._add_unfocused_clipboard_items() + for item in self._edit_action_items: + dpg_delete_item(item) + + self._edit_action_items = dpg_append_items( + TAG_GLOBAL_MENU_GROUP_EDIT, + self._add_edit_action_items, + ) + + def _add_edit_action_items(self) -> None: + """States the focused surface's actions, or the clipboard four greyed out while none is.""" + if not self._build_edit_actions(): + self._add_unfocused_clipboard_items() def _add_unfocused_clipboard_items(self) -> None: """Names the clipboard actions greyed out, the Edit menu with no grid holding a cursor.""" diff --git a/src/sampletones_application/utils/gui/dpg.py b/src/sampletones_application/utils/gui/dpg.py index 42bd49c7b..4f1c9a31b 100644 --- a/src/sampletones_application/utils/gui/dpg.py +++ b/src/sampletones_application/utils/gui/dpg.py @@ -4,9 +4,12 @@ Any, Callable, Concatenate, + Final, Iterator, + List, Optional, ParamSpec, + Tuple, TypeVar, cast, ) @@ -15,11 +18,13 @@ from sampletones_application.ui.elements.button import GUIButton from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import Callback +from sampletones_shared.types.callback import Callback, VoidCallback P = ParamSpec("P") R = TypeVar("R") +SLOT_ITEMS: Final[int] = 1 + def dpg_wrapper( button_function: Optional[Callback] = None, @@ -75,6 +80,25 @@ def dpg_delete_children(tag: Sender, /, *_args: Any, **kwargs: Any) -> None: dpg_delete_item(tag, children_only=True, **kwargs) +def dpg_item_children(tag: Sender) -> Tuple[Sender, ...]: + """The items the container holds, in the order they are drawn.""" + children = cast(List[Sender], dpg.get_item_children(tag, SLOT_ITEMS)) + return tuple(children) + + +def dpg_append_items(tag: Sender, build: VoidCallback) -> Tuple[Sender, ...]: + """Runs ``build`` with ``tag`` open as the container, reporting the items it left there. + + What one build stated is known by what the container gained, so a caller that rebuilds a + section takes exactly those items away again and leaves the rest of the container standing. + """ + standing = len(dpg_item_children(tag)) + with dpg_container(tag): + build() + + return dpg_item_children(tag)[standing:] + + def dpg_bind_item_theme( tag: Sender, theme_tag: Sender, diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index d4474195e..e63f59d3f 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -1,5 +1,5 @@ from contextlib import contextmanager -from typing import Any, Callable, Dict, FrozenSet, Iterator, List +from typing import Any, Callable, Dict, FrozenSet, Iterator, List, Tuple import pytest @@ -7,7 +7,8 @@ from sampletones_application.constants.playback import FollowMode from sampletones_application.paths import LANG_EN from sampletones_application.tags.general import ( - TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS, + TAG_GLOBAL_MENU_GROUP_EDIT, + TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, ) @@ -37,11 +38,13 @@ class _ShortcutManagerRecorder: """Records the items a menu asks for, in place of the manager that would create them.""" - def __init__(self) -> None: + def __init__(self, built: List[str]) -> None: self.items: List[Dict[str, Any]] = [] + self._built = built def add_menu_item(self, shortcut_id: ShortcutId, **kwargs: Any) -> None: self.items.append({"shortcut_id": shortcut_id, **kwargs}) + self._built.append(f"item:{kwargs['label']}") @property def labels(self) -> List[str]: @@ -59,8 +62,9 @@ def __init__(self) -> None: self.enabled: Dict[str, bool] = {} self.menus: List[Dict[str, Any]] = [] self.items: List[Dict[str, Any]] = [] + self.built: List[str] = [] self.containers: List[str] = [] - self.emptied: List[str] = [] + self.deleted: List[int] = [] @contextmanager def menu(self, **kwargs: Any) -> Iterator[int]: @@ -71,19 +75,37 @@ def submenu(self, tag: str) -> Dict[str, Any]: return next(entry for entry in self.menus if entry.get("tag") == tag) def add_separator(self, **kwargs: Any) -> int: + self.built.append("separator") + return 0 + + def add_group(self, *, tag: str) -> int: + self.built.append(f"group:{tag}") return 0 def add_menu_item(self, **kwargs: Any) -> int: self.items.append(kwargs) + self.built.append(f"item:{kwargs['label']}") return 0 @contextmanager - def container(self, tag: str) -> Iterator[None]: + def item_handler_registry(self, **kwargs: Any) -> Iterator[int]: + yield 0 + + def add_item_visible_handler(self, **kwargs: Any) -> int: + return 0 + + def bind_item_handler_registry(self, item: str, registry: str) -> None: + return None + + def append_items(self, tag: str, build: Callable[[], None]) -> Tuple[int, ...]: + """Stands in for the helper that reports what one build left in the container.""" self.containers.append(tag) - yield + standing = len(self.items) + build() + return tuple(range(standing, len(self.items))) - def delete_children(self, tag: str) -> None: - self.emptied.append(tag) + def delete_item(self, item: int) -> None: + self.deleted.append(item) def set_value(self, item: str, value: bool) -> None: self.values[item] = value @@ -130,16 +152,20 @@ def framework(monkeypatch: pytest.MonkeyPatch) -> _DearPyGuiRecorder: monkeypatch.setattr(menu_module.dpg, "menu", instance.menu) monkeypatch.setattr(menu_module.dpg, "add_separator", instance.add_separator) monkeypatch.setattr(menu_module.dpg, "add_menu_item", instance.add_menu_item) + monkeypatch.setattr(menu_module.dpg, "add_group", instance.add_group) + monkeypatch.setattr(menu_module.dpg, "item_handler_registry", instance.item_handler_registry) + monkeypatch.setattr(menu_module.dpg, "add_item_visible_handler", instance.add_item_visible_handler) + monkeypatch.setattr(menu_module.dpg, "bind_item_handler_registry", instance.bind_item_handler_registry) monkeypatch.setattr(menu_module, "dpg_set_value", instance.set_value) monkeypatch.setattr(menu_module, "dpg_configure_item", instance.configure_item) - monkeypatch.setattr(menu_module, "dpg_container", instance.container) - monkeypatch.setattr(menu_module, "dpg_delete_children", instance.delete_children) + monkeypatch.setattr(menu_module, "dpg_append_items", instance.append_items) + monkeypatch.setattr(menu_module, "dpg_delete_item", instance.delete_item) return instance @pytest.fixture -def shortcuts() -> _ShortcutManagerRecorder: - return _ShortcutManagerRecorder() +def shortcuts(framework: _DearPyGuiRecorder) -> _ShortcutManagerRecorder: + return _ShortcutManagerRecorder(framework.built) @pytest.fixture @@ -153,11 +179,15 @@ def menu_bar( shortcuts: _ShortcutManagerRecorder, switched: List[GeneratorName], ) -> MenuBar: - """A bar with the collaborators its Channels submenu reads, from the real language file.""" + """A bar with the collaborators its submenus read, from the real language file.""" instance = MenuBar.__new__(MenuBar) instance._shortcut_manager = shortcuts instance._language_manager = LanguageManager(LANG_EN) instance._on_channel_muted = switched.append + instance._build_edit_actions = lambda: False + instance._edit_actions_handler_tag = "handlers" + instance._edit_actions_frame = None + instance._edit_action_items = () return instance @@ -381,11 +411,12 @@ def test_a_full_mix_withholds_the_restore( def _edit_bar(build_edit_actions: Callable[[], bool]) -> MenuBar: - """A bar holding what the Edit menu's trailing section reads, and nothing else.""" + """A bar holding what the Edit menu's action section reads, and nothing else.""" instance = MenuBar.__new__(MenuBar) instance._language_manager = LanguageManager(LANG_EN) instance._build_edit_actions = build_edit_actions instance._edit_actions_frame = None + instance._edit_action_items = () return instance @@ -417,14 +448,48 @@ def build() -> bool: assert requests == [True] assert framework.items == [] - def test_the_section_is_emptied_before_the_actions_are_stated( + def test_the_actions_are_stated_into_the_menu_itself( self, framework: _DearPyGuiRecorder, ) -> None: _edit_bar(lambda: False)._refresh_edit_actions() - assert framework.emptied == [TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS] - assert framework.containers == [TAG_GLOBAL_MENU_GROUP_EDIT_ACTIONS] + assert framework.containers == [TAG_GLOBAL_MENU_GROUP_EDIT] + + def test_a_build_takes_away_only_what_the_one_before_it_stated( + self, + framework: _DearPyGuiRecorder, + ) -> None: + menu_bar = _edit_bar(lambda: False) + + menu_bar._refresh_edit_actions() + menu_bar._refresh_edit_actions() + + assert framework.deleted == [0, 1, 2, 3] + + +class TestEditMenuOrder: + """The marker leads the Edit menu. A container standing below a menu item takes the width the + items span as its own, and the popup grows to fit it on every frame it stays open.""" + + def test_the_marker_stands_before_every_item( + self, + menu_bar: MenuBar, + framework: _DearPyGuiRecorder, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_edit_menu(_state(frozenset())) + + assert framework.built == [ + f"group:{TAG_GLOBAL_MENU_GROUP_EDIT_MARKER}", + "item:Undo", + "item:Redo", + "separator", + "item:Copy", + "item:Cut", + "item:Paste", + "item:Delete", + ] class TestEditActionsRefresh: From 8391b4a345e65590686cf2133222358de72f771c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 01:43:52 +0200 Subject: [PATCH 081/152] Extracted: the grid drag-selection gesture --- .../ui/elements/table/drag.py | 86 +++++++++- .../ui/panels/sequencer/order.py | 42 ++--- .../ui/panels/sequencer/tracker.py | 42 ++--- .../ui/elements/table/test_cells.py | 21 +++ .../ui/elements/table/test_drag.py | 147 ++++++++++++++++++ .../panels/sequencer/test_selection_drag.py | 102 ++++++------ 6 files changed, 332 insertions(+), 108 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/table/test_drag.py diff --git a/src/sampletones_application/ui/elements/table/drag.py b/src/sampletones_application/ui/elements/table/drag.py index bde55c5e4..41f12172e 100644 --- a/src/sampletones_application/ui/elements/table/drag.py +++ b/src/sampletones_application/ui/elements/table/drag.py @@ -1,6 +1,10 @@ from collections.abc import Hashable from dataclasses import dataclass -from typing import Generic, TypeVar +from typing import Callable, Generic, Optional, TypeVar + +from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers +from sampletones_shared.types.application import Sender KeyT = TypeVar("KeyT", bound=Hashable) @@ -19,3 +23,83 @@ class DragGesture(Generic[KeyT]): origin: KeyT extends: bool moved: bool = False + + +@dataclass(frozen=True) +class DragReach(Generic[KeyT]): + """How far a drag has carried the pointer, and which end it grew from. + + A plain drag anchors a fresh selection at ``origin`` and runs it out to ``reached``; a drag + whose press held Shift reports ``extends``, and carries the selection already on the grid + out to ``reached`` instead. + """ + + origin: KeyT + reached: KeyT + extends: bool + + +class DragSelection(Generic[KeyT]): + """The gesture a grid selection is dragged out with. + + A grid hands its pointer reports here — the cell a press holds, the click that follows, the + press that starts the next gesture — and states the reach that comes back as a selection in + its own coordinates. The cell cache names the widget a press landed on, and ``cell_at`` reads + the cell the pointer stands on now off the grid's geometry. + """ + + def __init__( + self, + *, + cells: EditableCells[KeyT], + cell_at: Callable[[], Optional[KeyT]], + ) -> None: + self._cells = cells + self._cell_at = cell_at + self._gesture: Optional[DragGesture[KeyT]] = None + + def hold(self, widget: Sender) -> Optional[DragReach[KeyT]]: + """How far a held pointer has carried, once it has left the cell the press landed on. + + DearPyGui reports no hover for the cells a held pointer passes over, so the cell the drag + has reached is read off the grid's own geometry while the held widget names where the press + landed. A press that stays on its own cell is still a click, and the click itself is what + places the cursor there. + """ + if self._gesture is None: + origin = self._cells.key(widget) + if origin is not None: + self._gesture = DragGesture( + origin=origin, + extends=Modifier.SHIFT in capture_modifiers(), + ) + + return None + + reached = self._cell_at() + if reached is None or (reached == self._gesture.origin and not self._gesture.moved): + return None + + self._gesture.moved = True + return DragReach( + origin=self._gesture.origin, + reached=reached, + extends=self._gesture.extends, + ) + + def claims_click(self) -> bool: + """Whether the click reaching the grid ends a drag, which the drag then takes as its own. + + A drag that comes back to the cell it started from releases there, and the release reports + a click; that click belongs to the drag, so the range dragged out stands and the gesture + ends here. + """ + claimed = self._gesture is not None and self._gesture.moved + if claimed: + self._gesture = None + + return claimed + + def clear(self) -> None: + """Drops the gesture in hand, so the next press drags a selection out on its own.""" + self._gesture = None diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 080fa18b5..1c2da3e84 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -39,7 +39,7 @@ ) from sampletones_application.ui.elements.table.caret import CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells, pending_label -from sampletones_application.ui.elements.table.drag import DragGesture +from sampletones_application.ui.elements.table.drag import DragSelection from sampletones_application.ui.panels.sequencer.channels import ( ChannelMenuLabels, ChannelSwitch, @@ -142,7 +142,10 @@ def __init__( self._order: EditableCells[OrderKey] = EditableCells() self._input_state: OrderInputState = OrderInputState() self._selection: FrozenSet[OrderKey] = frozenset() - self._drag: Optional[DragGesture[OrderKey]] = None + self._drag: DragSelection[OrderKey] = DragSelection( + cells=self._order, + cell_at=self._cell_at, + ) self._highlighted: Optional[OrderCursor] = None self._highlighted_column: Optional[int] = None self._current_position: Optional[int] = None @@ -483,7 +486,7 @@ def _rebuild_table( self._highlighted = None self._highlighted_column = None self._selection = frozenset() - self._drag = None + self._drag.clear() self._order.reset(cell_values) self._position_count = view_model.position_count self._build_table(view_model.position_count) @@ -873,8 +876,7 @@ def _on_cell_clicked( """ dpg.set_value(sender, False) self._selection -= {user_data} - if self._drag is not None and self._drag.moved: - self._drag = None + if self._drag.claims_click(): self._repaint_selection() return @@ -890,32 +892,18 @@ def _on_cell_clicked( def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: """Carries the selection to the cell under a held pointer, which is what drags a range out. - DearPyGui reports no hover for the cells a held pointer passes over, so the cell the drag - has reached is read off the table's own geometry while the held cell names where the press - landed. A press that stays on its own cell is still a click, and the click itself is what - places the cursor there. + The gesture states how far the pointer has carried: a plain drag anchors at the cell the + press landed on, and one whose press held Shift carries the selection already standing. """ - if self._drag is None: - origin = self._order.key(app_data) - if origin is None: - return - - self._drag = DragGesture( - origin=origin, - extends=Modifier.SHIFT in capture_modifiers(), - ) - return - - reached = self._cell_at() - if reached is None or (reached == self._drag.origin and not self._drag.moved): + reach = self._drag.hold(app_data) + if reach is None: return - self._drag.moved = True state = self._committed_state() - if not self._drag.extends: - state = OrderInputState(cursor=OrderCursor(*self._drag.origin)) + if not reach.extends: + state = OrderInputState(cursor=OrderCursor(*reach.origin)) - self._apply_state(state.extend_to(OrderCursor(*reached))) + self._apply_state(state.extend_to(OrderCursor(*reach.reached))) def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: """Drops the gesture a finished drag left behind, so this press selects on its own. @@ -924,7 +912,7 @@ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: reaches this panel ahead of the click the cell itself reports: a drag that comes back to the cell it started from would otherwise have its selection taken down by its own click. """ - self._drag = None + self._drag.clear() def _cell_at(self) -> Optional[OrderKey]: """The cell the pointer stands on, clamped to the table the order lays out. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 249f0e21c..bfa47dde4 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -32,7 +32,7 @@ from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.table.caret import CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells -from sampletones_application.ui.elements.table.drag import DragGesture +from sampletones_application.ui.elements.table.drag import DragSelection from sampletones_application.ui.panels.sequencer import display as tracker_display from sampletones_application.ui.panels.sequencer.channels import ( ChannelMenuLabels, @@ -171,7 +171,10 @@ def __init__( self._follows_playing_row: bool = False self._input_state: TrackerInputState = TrackerInputState() self._selection: FrozenSet[CellKey] = frozenset() - self._drag: Optional[DragGesture[CellKey]] = None + self._drag: DragSelection[CellKey] = DragSelection( + cells=self._editable_cells, + cell_at=self._cell_at, + ) self._subcolumn_themes: Dict[SubColumn, int] = {} self._muted_subcolumn_themes: Dict[SubColumn, int] = {} self._row_number_theme: int = 0 @@ -488,7 +491,7 @@ def _rebuild_table( dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) self._input_state = self._input_state.collapse() self._selection = frozenset() - self._drag = None + self._drag.clear() self._editable_cells.reset(cell_values) self._build_table(view_model) self.repaint() @@ -1093,8 +1096,7 @@ def _on_cell_clicked( """ dpg.set_value(sender, False) self._selection -= {user_data} - if self._drag is not None and self._drag.moved: - self._drag = None + if self._drag.claims_click(): self._repaint_selection() return @@ -1110,32 +1112,18 @@ def _on_cell_clicked( def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: """Carries the selection to the cell under a held pointer, which is what drags a range out. - DearPyGui reports no hover for the cells a held pointer passes over, so the cell the drag - has reached is read off the grid's own geometry while the held cell names where the press - landed. A press that stays on its own cell is still a click, and the click itself is what - places the cursor there. + The gesture states how far the pointer has carried: a plain drag anchors at the cell the + press landed on, and one whose press held Shift carries the selection already standing. """ - if self._drag is None: - origin = self._editable_cells.key(app_data) - if origin is None: - return - - self._drag = DragGesture( - origin=origin, - extends=Modifier.SHIFT in capture_modifiers(), - ) - return - - reached = self._cell_at() - if reached is None or (reached == self._drag.origin and not self._drag.moved): + reach = self._drag.hold(app_data) + if reach is None: return - self._drag.moved = True state = self._committed_state() - if not self._drag.extends: - state = TrackerInputState(cursor=TrackerCursor(*self._drag.origin)) + if not reach.extends: + state = TrackerInputState(cursor=TrackerCursor(*reach.origin)) - self._apply_state(state.extend_to(TrackerCursor(*reached))) + self._apply_state(state.extend_to(TrackerCursor(*reach.reached))) def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: """Drops the gesture a finished drag left behind, so this press selects on its own. @@ -1144,7 +1132,7 @@ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: reaches this panel ahead of the click the cell itself reports: a drag that comes back to the cell it started from would otherwise have its selection taken down by its own click. """ - self._drag = None + self._drag.clear() def _cell_at(self) -> Optional[CellKey]: """The cell the pointer stands on, clamped to the grid the shown frame lays out. diff --git a/tests/unit/sampletones_application/ui/elements/table/test_cells.py b/tests/unit/sampletones_application/ui/elements/table/test_cells.py index 2a61713c0..3553b52d4 100644 --- a/tests/unit/sampletones_application/ui/elements/table/test_cells.py +++ b/tests/unit/sampletones_application/ui/elements/table/test_cells.py @@ -47,6 +47,27 @@ def test_reconcile_updates_only_changed_registered_cells(self) -> None: configure.assert_called_once_with(20, label="label-b") assert cells.values["b"] == "z" + def test_a_registered_widget_reads_back_as_its_key(self) -> None: + """A cell cache answers from both sides, because a handler reports the widget it fired for.""" + cells: EditableCells[str] = EditableCells() + cells.register("a", 1) + + assert cells.key(1) == "a" + assert cells.widget("a") == 1 + + def test_a_rebuild_drops_both_directions(self) -> None: + cells: EditableCells[str] = EditableCells() + cells.register("a", 1) + cells.reset({}) + + assert cells.key(1) is None + assert cells.widget("a") is None + + def test_an_unknown_widget_names_no_cell(self) -> None: + cells: EditableCells[str] = EditableCells() + + assert cells.key(1) is None + def test_reconcile_caches_value_even_without_a_widget(self) -> None: cells: EditableCells[str] = EditableCells() cells.reset({"a": "x"}) diff --git a/tests/unit/sampletones_application/ui/elements/table/test_drag.py b/tests/unit/sampletones_application/ui/elements/table/test_drag.py new file mode 100644 index 000000000..220075c65 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/table/test_drag.py @@ -0,0 +1,147 @@ +from typing import Optional, Tuple + +import pytest + +from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.elements.table.drag import DragSelection +from sampletones_application.utils.gui.keyboard.modifiers import Modifier + +Key = Tuple[int, int] + +ORIGIN_WIDGET = 101 +OTHER_WIDGET = 202 +ORIGIN: Key = (2, 1) +REACHED: Key = (5, 3) + + +class _Pointer: + """Where the pointer stands, which a drag reads off the grid between holds.""" + + def __init__(self, cell: Optional[Key]) -> None: + self.cell = cell + + +def _hold_modifiers(monkeypatch: pytest.MonkeyPatch, shift: bool) -> None: + monkeypatch.setattr( + "sampletones_application.ui.elements.table.drag.capture_modifiers", + lambda: {Modifier.SHIFT} if shift else set(), + ) + + +def _drag( + monkeypatch: pytest.MonkeyPatch, + reached: Optional[Key], + shift: bool = False, +) -> Tuple[DragSelection[Key], _Pointer]: + cells: EditableCells[Key] = EditableCells() + cells.register(ORIGIN, ORIGIN_WIDGET) + pointer = _Pointer(reached) + _hold_modifiers(monkeypatch, shift) + return ( + DragSelection(cells=cells, cell_at=lambda: pointer.cell), + pointer, + ) + + +class TestDragReach: + """A press grows into a drag only once the pointer has left the cell it landed on.""" + + def test_a_press_alone_reaches_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + assert drag.hold(ORIGIN_WIDGET) is None + + def test_a_press_held_on_its_own_cell_stays_a_click(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=ORIGIN) + + drag.hold(ORIGIN_WIDGET) + + assert drag.hold(ORIGIN_WIDGET) is None + + def test_a_drag_reports_the_cell_it_grew_from(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + drag.hold(ORIGIN_WIDGET) + reach = drag.hold(ORIGIN_WIDGET) + + assert reach is not None + assert reach.origin == ORIGIN + assert reach.reached == REACHED + assert reach.extends is False + + def test_a_shift_press_reports_a_carried_selection(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED, shift=True) + + drag.hold(ORIGIN_WIDGET) + reach = drag.hold(ORIGIN_WIDGET) + + assert reach is not None + assert reach.extends is True + + def test_a_drag_returning_to_its_origin_reaches_that_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, pointer = _drag(monkeypatch, reached=REACHED) + + drag.hold(ORIGIN_WIDGET) + drag.hold(ORIGIN_WIDGET) + pointer.cell = ORIGIN + reach = drag.hold(ORIGIN_WIDGET) + + assert reach is not None + assert reach.reached == ORIGIN + + def test_a_pointer_off_the_grid_reaches_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=None) + + drag.hold(ORIGIN_WIDGET) + + assert drag.hold(ORIGIN_WIDGET) is None + + def test_a_press_on_a_cell_the_cache_forgot_starts_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + drag.hold(OTHER_WIDGET) + + assert drag.hold(OTHER_WIDGET) is None + + +class TestDragClick: + """The click a drag ends on belongs to the drag; every other click is a gesture of its own.""" + + def test_a_click_without_a_press_stands_on_its_own(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + assert drag.claims_click() is False + + def test_a_press_that_never_moved_leaves_its_click_alone(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=ORIGIN) + + drag.hold(ORIGIN_WIDGET) + + assert drag.claims_click() is False + + def test_a_drag_takes_the_click_that_ends_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + drag.hold(ORIGIN_WIDGET) + drag.hold(ORIGIN_WIDGET) + + assert drag.claims_click() is True + + def test_the_claimed_click_ends_the_gesture(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + drag.hold(ORIGIN_WIDGET) + drag.hold(ORIGIN_WIDGET) + drag.claims_click() + + assert drag.claims_click() is False + + def test_a_cleared_gesture_starts_afresh(self, monkeypatch: pytest.MonkeyPatch) -> None: + drag, _ = _drag(monkeypatch, reached=REACHED) + + drag.hold(ORIGIN_WIDGET) + drag.hold(ORIGIN_WIDGET) + drag.clear() + + assert drag.claims_click() is False + assert drag.hold(ORIGIN_WIDGET) is None diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py index b776b479b..d133e4659 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple +from typing import List, Optional, Tuple, Union import pytest @@ -11,6 +11,7 @@ PALETTES_DIRECTORY, ) from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.elements.table.drag import DragSelection from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, @@ -49,9 +50,15 @@ def _hold_modifiers( module: str, shift: bool, ) -> None: + """Holds Shift down for both readers of it: the drag reads the press, the panel the click.""" + modifiers = {Modifier.SHIFT} if shift else set() monkeypatch.setattr( f"sampletones_application.ui.panels.sequencer.{module}.capture_modifiers", - lambda: {Modifier.SHIFT} if shift else set(), + lambda: modifiers, + ) + monkeypatch.setattr( + "sampletones_application.ui.elements.table.drag.capture_modifiers", + lambda: modifiers, ) @@ -63,9 +70,12 @@ def _tracker( panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) panel._input_state = TrackerInputState() panel._current_row_count = ROW_COUNT - panel._drag = None panel._editable_cells = EditableCells() panel._editable_cells.register(ORIGIN_CELL, ORIGIN_WIDGET) + panel._drag = DragSelection( + cells=panel._editable_cells, + cell_at=lambda: panel._cell_at(), + ) states: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) @@ -82,9 +92,12 @@ def _order( panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) panel._input_state = OrderInputState() panel._position_count = POSITION_COUNT - panel._drag = None panel._order = EditableCells() panel._order.register(ORIGIN_ENTRY, ORIGIN_WIDGET) + panel._drag = DragSelection( + cells=panel._order, + cell_at=lambda: panel._cell_at(), + ) states: List[OrderInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) @@ -93,28 +106,18 @@ def _order( return panel, states -class TestEditableCellKeys: - """A cell cache answers from both sides, because a handler reports the widget it fired for.""" - - def test_a_registered_widget_reads_back_as_its_key(self) -> None: - cells: EditableCells[CellKey] = EditableCells() - cells.register(ORIGIN_CELL, ORIGIN_WIDGET) - - assert cells.key(ORIGIN_WIDGET) == ORIGIN_CELL - assert cells.widget(ORIGIN_CELL) == ORIGIN_WIDGET - - def test_a_rebuild_drops_both_directions(self) -> None: - cells: EditableCells[CellKey] = EditableCells() - cells.register(ORIGIN_CELL, ORIGIN_WIDGET) - cells.reset({}) - - assert cells.key(ORIGIN_WIDGET) is None - assert cells.widget(ORIGIN_CELL) is None - - def test_an_unknown_widget_names_no_cell(self) -> None: - cells: EditableCells[CellKey] = EditableCells() - - assert cells.key(ORIGIN_WIDGET) is None +def _silence_click( + monkeypatch: pytest.MonkeyPatch, + panel: Union[GUISequencerTrackerPanel, GUISequencerOrderPanel], + module: str, +) -> None: + """Lets a click run over a grid that was never drawn: the cell releases, the repaint stands in.""" + panel._selection = frozenset() + monkeypatch.setattr(panel, "_repaint_selection", lambda: None) + monkeypatch.setattr( + f"sampletones_application.ui.panels.sequencer.{module}.dpg.set_value", + lambda widget, value: None, + ) class TestTrackerDrag: @@ -125,9 +128,6 @@ def test_a_press_alone_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> panel._on_cell_held(0, ORIGIN_WIDGET) - assert panel._drag is not None - assert panel._drag.origin == ORIGIN_CELL - assert panel._drag.moved is False assert states == [] def test_a_press_held_on_its_own_cell_stays_a_click(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -145,8 +145,6 @@ def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatc panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) - assert panel._drag is not None - assert panel._drag.moved is True assert states[-1].region == TrackerRegion( first_row=2, last_row=5, @@ -202,21 +200,27 @@ def test_a_drag_back_to_its_origin_selects_that_cell(self, monkeypatch: pytest.M last_slot=4, ) - def test_a_press_on_a_cell_the_cache_forgot_starts_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + def test_a_press_on_a_cell_the_cache_forgot_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: panel, states = _tracker(monkeypatch, reached=ORIGIN_CELL) + panel._on_cell_held(0, ORIGIN_WIDGET + 1) panel._on_cell_held(0, ORIGIN_WIDGET + 1) - assert panel._drag is None assert states == [] def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None: - panel, _ = _tracker(monkeypatch, reached=ORIGIN_CELL) + """The press starting a gesture ends the one before it, so its click places the cursor.""" + reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + panel, states = _tracker(monkeypatch, reached=reached) + _silence_click(monkeypatch, panel, "tracker") + panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_pointer_pressed(0, 0) + panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_CELL) - assert panel._drag is None + assert states[-1].cursor == TrackerCursor(*ORIGIN_CELL) + assert states[-1].region is None def test_the_click_ending_a_drag_leaves_the_selection_alone( self, @@ -225,12 +229,7 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( """A drag returning to its own cell releases there, and that release reports a click.""" reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) panel, states = _tracker(monkeypatch, reached=reached) - panel._selection = frozenset({ORIGIN_CELL}) - monkeypatch.setattr(panel, "_repaint_selection", lambda: None) - monkeypatch.setattr( - "sampletones_application.ui.panels.sequencer.tracker.dpg.set_value", - lambda widget, value: None, - ) + _silence_click(monkeypatch, panel, "tracker") panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -238,7 +237,6 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_CELL) assert len(states) == applied - assert panel._drag is None class TestTrackerDragHitTest: @@ -294,8 +292,6 @@ def test_a_press_alone_selects_nothing(self, monkeypatch: pytest.MonkeyPatch) -> panel._on_cell_held(0, ORIGIN_WIDGET) - assert panel._drag is not None - assert panel._drag.origin == ORIGIN_ENTRY assert states == [] def test_a_drag_anchors_at_the_pressed_cell(self, monkeypatch: pytest.MonkeyPatch) -> None: @@ -326,12 +322,18 @@ def test_a_shift_press_carries_the_selection_already_held(self, monkeypatch: pyt assert states[-1].anchor == OrderCursor(GeneratorName.NOISE, 6) def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.MonkeyPatch) -> None: - panel, _ = _order(monkeypatch, reached=ORIGIN_ENTRY) + """The press starting a gesture ends the one before it, so its click places the cursor.""" + reached: OrderKey = (GeneratorName.PULSE2, 4) + panel, states = _order(monkeypatch, reached=reached) + _silence_click(monkeypatch, panel, "order") + panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_pointer_pressed(0, 0) + panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_ENTRY) - assert panel._drag is None + assert states[-1].cursor == OrderCursor(*ORIGIN_ENTRY) + assert states[-1].region is None def test_the_click_ending_a_drag_leaves_the_selection_alone( self, @@ -339,12 +341,7 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( ) -> None: reached: OrderKey = (GeneratorName.PULSE2, 4) panel, states = _order(monkeypatch, reached=reached) - panel._selection = frozenset({ORIGIN_ENTRY}) - monkeypatch.setattr(panel, "_repaint_selection", lambda: None) - monkeypatch.setattr( - "sampletones_application.ui.panels.sequencer.order.dpg.set_value", - lambda widget, value: None, - ) + _silence_click(monkeypatch, panel, "order") panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -352,4 +349,3 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( panel._on_cell_clicked(ORIGIN_WIDGET, True, ORIGIN_ENTRY) assert len(states) == applied - assert panel._drag is None From 5dd82847058dd8e3ac36cf342c666a951358793e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 02:09:09 +0200 Subject: [PATCH 082/152] Extracted: shared grid selection state --- .../ui/panels/sequencer/display.py | 2 +- .../ui/panels/sequencer/input/cursor.py | 12 - .../ui/panels/sequencer/input/order.py | 109 +----- .../ui/panels/sequencer/input/state.py | 329 ++---------------- .../ui/panels/sequencer/input/target.py | 2 +- .../ui/panels/sequencer/input/tracker.py | 309 ++++++++++++++++ .../ui/panels/sequencer/tracker.py | 3 +- .../panels/sequencer/input/test_grid_input.py | 137 ++++++++ .../sequencer/input/test_tracker_input.py | 3 +- .../ui/panels/sequencer/test_block_keys.py | 3 +- .../ui/panels/sequencer/test_block_menu.py | 3 +- .../ui/panels/sequencer/test_panel_escape.py | 3 +- .../panels/sequencer/test_panel_tab_gate.py | 3 +- .../panels/sequencer/test_selection_drag.py | 3 +- .../panels/sequencer/test_selection_keys.py | 3 +- .../sequencer/test_tracker_context_menu.py | 2 +- .../sequencer/test_tracker_navigation.py | 3 +- .../sequencer/test_tracker_play_shortcut.py | 3 +- .../ui/panels/sequencer/test_tracker_rows.py | 3 +- 19 files changed, 515 insertions(+), 420 deletions(-) delete mode 100644 src/sampletones_application/ui/panels/sequencer/input/cursor.py create mode 100644 src/sampletones_application/ui/panels/sequencer/input/tracker.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py diff --git a/src/sampletones_application/ui/panels/sequencer/display.py b/src/sampletones_application/ui/panels/sequencer/display.py index a35151cae..03d10e471 100644 --- a/src/sampletones_application/ui/panels/sequencer/display.py +++ b/src/sampletones_application/ui/panels/sequencer/display.py @@ -1,7 +1,7 @@ from typing import Dict, Final, Optional, Tuple from sampletones_application.ui.elements.table.cells import pending_label -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_application.view_model.sequencer.tracker import SequencerCellViewModel from sampletones_core.constants.enums import GeneratorName diff --git a/src/sampletones_application/ui/panels/sequencer/input/cursor.py b/src/sampletones_application/ui/panels/sequencer/input/cursor.py deleted file mode 100644 index 926e77866..000000000 --- a/src/sampletones_application/ui/panels/sequencer/input/cursor.py +++ /dev/null @@ -1,12 +0,0 @@ -from dataclasses import dataclass -from typing import Optional - -from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.enums import GeneratorName - - -@dataclass(frozen=True) -class TrackerCursor: - row: int - generator: Optional[GeneratorName] - subcolumn: SubColumn diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index 67b58a25b..8f702e60e 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -1,10 +1,10 @@ from __future__ import annotations +from dataclasses import dataclass from typing import Final, Optional, Tuple -from pydantic.dataclasses import dataclass - from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.ui.panels.sequencer.input.state import GridInputState from sampletones_application.view_model.sequencer.region import OrderRegion from sampletones_core.constants.enums import GeneratorName @@ -24,92 +24,31 @@ def _parse(pending: str) -> Optional[int]: return None -@dataclass -class OrderInputState: +@dataclass(frozen=True) +class OrderInputState(GridInputState[OrderCursor, OrderRegion]): """Edit cursor, pending hex entry and selection anchor for the order table. The order has no subcolumns, so a cell holds a single pattern index; typing accumulates :data:`INDEX_DIGITS` hex digits and then commits the parsed index. - Navigation moves along positions (columns) or channels/master (rows). The anchor is where a - range selection was started and the cursor is its other end, so the two together are the - block a copy or a paste acts on. + Navigation moves along positions (columns) or channels/master (rows). """ - cursor: Optional[OrderCursor] = None - pending: str = "" - anchor: Optional[OrderCursor] = None - - def reset_pending(self) -> OrderInputState: - """Drops a partial entry, leaving the cursor and any selection where they stand. - - The anchor survives because this runs before every move, the extending ones included: - each gesture then decides whether to hold the selection or collapse it. - """ - return OrderInputState(cursor=self.cursor, pending="", anchor=self.anchor) - - def collapse(self) -> OrderInputState: - """Drops the selection, leaving the cursor's own cell as the whole target.""" - return OrderInputState(cursor=self.cursor, pending=self.pending) - - @property - def region(self) -> Optional[OrderRegion]: - """The block a selection covers, once one has been started.""" - if self.cursor is None or self.anchor is None: - return None - - anchor_row = CHANNEL_AXIS.index(self.anchor.generator) - cursor_row = CHANNEL_AXIS.index(self.cursor.generator) - return OrderRegion( - first_row=min(anchor_row, cursor_row), - last_row=max(anchor_row, cursor_row), - first_position=min(self.anchor.position, self.cursor.position), - last_position=max(self.anchor.position, self.cursor.position), - ) - - def region_at(self, cell: OrderCursor) -> OrderRegion: - """The block a gesture raised on ``cell`` acts on: the selection it stands in, or the cell - alone. - - A gesture raised inside a selection acts on the whole of it, which is what a reader who has - just dragged a range out expects it to reach; one raised anywhere else acts on the cell it - names, which is a block of exactly that cell. - """ - region = self.region - if region is not None and region.covers(cell.generator, cell.position): - return region - - row = CHANNEL_AXIS.index(cell.generator) + def _region_between( + self, + first: OrderCursor, + second: OrderCursor, + ) -> OrderRegion: + first_row = CHANNEL_AXIS.index(first.generator) + second_row = CHANNEL_AXIS.index(second.generator) return OrderRegion( - first_row=row, - last_row=row, - first_position=cell.position, - last_position=cell.position, + first_row=min(first_row, second_row), + last_row=max(first_row, second_row), + first_position=min(first.position, second.position), + last_position=max(first.position, second.position), ) - @property - def target_region(self) -> Optional[OrderRegion]: - """The region a block gesture acts on: the selection, or the cursor's own cell. - - A cursor with nothing selected stands on a block of one cell, so copying reaches the cell - the reader is working in and needs no selection made first. - """ - if self.cursor is None: - return None - - return self.region_at(self.cursor) - - def extend_to(self, cursor: OrderCursor) -> OrderInputState: - """Carries the moving end of the selection to ``cursor``, anchoring it where it began. - - A selection that has not been started yet takes the cell the cursor stands on as its - anchor, so the first extending gesture selects the cell it came from as well as the one - it reaches. - """ - return OrderInputState( - cursor=cursor, - pending="", - anchor=self.anchor if self.anchor is not None else self.cursor, - ) + def _covers(self, region: OrderRegion, cell: OrderCursor) -> bool: + return region.covers(cell.generator, cell.position) def extend_position( self, @@ -175,20 +114,8 @@ def type_char(self, char: str) -> Tuple[OrderInputState, Optional[int]]: return self._after_entry(), _parse(pending) - def _after_entry(self) -> OrderInputState: - """The state a committed entry leaves: the cursor alone, nothing pending and nothing selected. - - Typing writes the one cell the cursor stands on, so it takes the selection down to that - cell instead of leaving a range for the next gesture to act on. - """ - return self.collapse().reset_pending() - def commit_partial(self) -> Tuple[OrderInputState, Optional[int]]: if not self.pending or self.cursor is None: return self, None return self.reset_pending(), _parse(self.pending.zfill(INDEX_DIGITS)) - - def cancel(self) -> OrderInputState: - """Drops a partial entry and any selection, which is what Escape asks of the table.""" - return self.collapse().reset_pending() diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index d06d77167..864edb0c9 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -1,110 +1,56 @@ -from __future__ import annotations +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Generic, Optional, Self, TypeVar -from typing import Dict, Final, Optional, Tuple +CursorT = TypeVar("CursorT") +RegionT = TypeVar("RegionT") -from pydantic.dataclasses import dataclass -from sampletones_application.constants.sequencer import CHANNEL_AXIS -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor -from sampletones_application.ui.panels.sequencer.input.edit import ( - ClearAction, - EditAction, -) -from sampletones_application.view_model.sequencer.region import TrackerRegion -from sampletones_application.view_model.sequencer.slot import ( - SLOT_COUNT, - SUBCOLUMNS, - TrackerSlot, - slot_from_flat, -) -from sampletones_application.view_model.sequencer.subcolumn import SubColumn -from sampletones_core.constants.general import MAX_VOLUME -from sampletones_shared.constants.symbols import MINUS, PLUS, PLUS_MINUS, SIGNS - -DIGIT_COUNT: Final[Dict[SubColumn, int]] = { - SubColumn.INSTRUMENT: 2, - SubColumn.TRANSPOSE: 2, - SubColumn.VOLUME: 1, -} - - -def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: - try: - match cursor.subcolumn: - case SubColumn.INSTRUMENT: - return EditAction( - row=cursor.row, - generator=cursor.generator, - sample_index=int(pending, 16), - transpose=None, - volume=None, - ) - case SubColumn.VOLUME: - return EditAction( - row=cursor.row, - generator=cursor.generator, - sample_index=None, - transpose=None, - volume=min(int(pending, 16), MAX_VOLUME), - ) - case SubColumn.TRANSPOSE: - sign = -1 if pending.startswith(MINUS) else 1 - magnitude = pending.lstrip(PLUS_MINUS) - if not magnitude: - return None - - return EditAction( - row=cursor.row, - generator=cursor.generator, - sample_index=None, - transpose=sign * int(magnitude, 16), - volume=None, - ) - except ValueError: - return None - - -@dataclass -class TrackerInputState: - """Edit cursor, pending entry and selection anchor for the tracker grid. +@dataclass(frozen=True) +class GridInputState(ABC, Generic[CursorT, RegionT]): + """Edit cursor, pending entry and selection anchor of a sequencer grid. The anchor is where a range selection was started; the cursor is its other end, so the two together are the region a block operation acts on. Every plain move builds a state without one, which is what makes a move collapse a selection to the cell it lands in. + + A grid states how a pair of its own cells bounds a block and how a block reaches a cell; the + selection rules that follow from those two are stated here and serve every grid. """ - cursor: Optional[TrackerCursor] = None + cursor: Optional[CursorT] = None pending: str = "" - anchor: Optional[TrackerCursor] = None + anchor: Optional[CursorT] = None + + @abstractmethod + def _region_between(self, first: CursorT, second: CursorT) -> RegionT: + """The block a pair of cells bounds, whichever way round the pair stands.""" - def reset_pending(self) -> TrackerInputState: + @abstractmethod + def _covers(self, region: RegionT, cell: CursorT) -> bool: + """Whether ``region`` reaches ``cell``.""" + + def reset_pending(self) -> Self: """Drops a partial entry, leaving the cursor and any selection where they stand. The anchor survives because this runs before every move, the extending ones included: each gesture then decides whether to hold the selection or collapse it. """ - return TrackerInputState(cursor=self.cursor, pending="", anchor=self.anchor) + return type(self)(cursor=self.cursor, pending="", anchor=self.anchor) - def collapse(self) -> TrackerInputState: + def collapse(self) -> Self: """Drops the selection, leaving the cursor's own cell as the whole target.""" - return TrackerInputState(cursor=self.cursor, pending=self.pending) + return type(self)(cursor=self.cursor, pending=self.pending) @property - def region(self) -> Optional[TrackerRegion]: + def region(self) -> Optional[RegionT]: """The block a selection covers, once one has been started.""" if self.cursor is None or self.anchor is None: return None - anchor_slot = TrackerSlot(self.anchor.generator, self.anchor.subcolumn).flat_index - cursor_slot = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index - return TrackerRegion( - first_row=min(self.anchor.row, self.cursor.row), - last_row=max(self.anchor.row, self.cursor.row), - first_slot=min(anchor_slot, cursor_slot), - last_slot=max(anchor_slot, cursor_slot), - ) + return self._region_between(self.anchor, self.cursor) - def region_at(self, cell: TrackerCursor) -> TrackerRegion: + def region_at(self, cell: CursorT) -> RegionT: """The block a gesture raised on ``cell`` acts on: the selection it stands in, or the cell alone. @@ -112,20 +58,14 @@ def region_at(self, cell: TrackerCursor) -> TrackerRegion: just dragged a range out expects it to reach; one raised anywhere else acts on the cell it names, which is a block of exactly that cell. """ - slot = TrackerSlot(cell.generator, cell.subcolumn) region = self.region - if region is not None and region.covers(cell.row, slot): + if region is not None and self._covers(region, cell): return region - return TrackerRegion( - first_row=cell.row, - last_row=cell.row, - first_slot=slot.flat_index, - last_slot=slot.flat_index, - ) + return self._region_between(cell, cell) @property - def target_region(self) -> Optional[TrackerRegion]: + def target_region(self) -> Optional[RegionT]: """The region a block gesture acts on: the selection, or the cursor's own cell. A cursor with nothing selected stands on a block of one cell, so copying reaches the cell @@ -136,222 +76,27 @@ def target_region(self) -> Optional[TrackerRegion]: return self.region_at(self.cursor) - def extend_to(self, cursor: TrackerCursor) -> TrackerInputState: + def extend_to(self, cursor: CursorT) -> Self: """Carries the moving end of the selection to ``cursor``, anchoring it where it began. A selection that has not been started yet takes the cell the cursor stands on as its anchor, so the first extending gesture selects the cell it came from as well as the one it reaches. """ - return TrackerInputState( + return type(self)( cursor=cursor, pending="", anchor=self.anchor if self.anchor is not None else self.cursor, ) - def extend_row( - self, - value: int, - row_count: int, - absolute: bool = False, - ) -> TrackerInputState: - """Carries the selection's moving end to another row of the same slot.""" - if self.cursor is None or row_count == 0: - return self - - new_row = value if absolute else self.cursor.row + value - new_row = max(0, min(new_row, row_count - 1)) - return self.extend_to( - TrackerCursor( - new_row, - self.cursor.generator, - self.cursor.subcolumn, - ) - ) - - def extend_slot(self, value: int) -> TrackerInputState: - """Carries the selection's moving end along the flat slot axis, stopping at either end. - - A selection covers a run of the grid, so the walk stops at the first and the last slot - rather than wrapping around the way plain navigation does. - """ - if self.cursor is None: - return self - - current = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index - slot = slot_from_flat(max(0, min(current + value, SLOT_COUNT - 1))) - return self.extend_to(TrackerCursor(self.cursor.row, slot.generator, slot.subcolumn)) - - def navigate_row( - self, - value: int, - row_count: int, - absolute: bool = False, - ) -> TrackerInputState: - if self.cursor is None or row_count == 0: - return self - - new_row = value if absolute else self.cursor.row + value - new_row = max(0, min(new_row, row_count - 1)) - return TrackerInputState( - cursor=TrackerCursor( - new_row, - self.cursor.generator, - self.cursor.subcolumn, - ), - pending="", - ) - - def navigate_subcolumn( - self, - value: int, - absolute: bool = False, - ) -> TrackerInputState: - """Steps the cursor along the flattened slot axis, wrapping at either end. - - Wrapping is a navigation policy the cursor owns: walking right off the last - volume slot lands on the sample column's instrument, so a held arrow key - tours the whole row. - """ - if self.cursor is None: - return self - - if absolute: - new_sub = SUBCOLUMNS[value % len(SUBCOLUMNS)] - return TrackerInputState( - cursor=TrackerCursor( - self.cursor.row, - self.cursor.generator, - new_sub, - ), - pending="", - ) - - current = TrackerSlot(self.cursor.generator, self.cursor.subcolumn).flat_index - slot = slot_from_flat((current + value) % SLOT_COUNT) - return TrackerInputState( - cursor=TrackerCursor(self.cursor.row, slot.generator, slot.subcolumn), - pending="", - ) - - def navigate_column_by(self, delta: int) -> TrackerInputState: - if self.cursor is None: - return self - - current_idx = CHANNEL_AXIS.index(self.cursor.generator) - next_idx = (current_idx + delta) % len(CHANNEL_AXIS) - return TrackerInputState( - cursor=TrackerCursor( - self.cursor.row, - CHANNEL_AXIS[next_idx], - self.cursor.subcolumn, - ), - pending="", - ) - - def type_char( - self, - char: str, - ) -> Tuple[TrackerInputState, Optional[EditAction]]: - if self.cursor is None: - return self, None - - if self.cursor.subcolumn is SubColumn.INSTRUMENT and char == MINUS: - return self._after_entry(), self._note_off_action(self.cursor) - - if self.cursor.subcolumn is SubColumn.TRANSPOSE: - return self._type_transpose_char(char) - - if char in SIGNS: - return self, None - - pending = self.pending + char - expected = DIGIT_COUNT[self.cursor.subcolumn] - if len(pending) < expected: - return TrackerInputState(cursor=self.cursor, pending=pending), None - - action = _parse(self.cursor, pending) - return self._after_entry(), action + def cancel(self) -> Self: + """Drops a partial entry and any selection, which is what Escape asks of a grid.""" + return self.collapse().reset_pending() - def _after_entry(self) -> TrackerInputState: + def _after_entry(self) -> Self: """The state a committed entry leaves: the cursor alone, nothing pending and nothing selected. Typing writes the one cell the cursor stands on, so it takes the selection down to that cell instead of leaving a range for the next gesture to act on. """ return self.collapse().reset_pending() - - def _note_off_action(self, cursor: TrackerCursor) -> EditAction: - return EditAction( - row=cursor.row, - generator=cursor.generator, - sample_index=None, - transpose=None, - volume=None, - note_off=True, - ) - - def _type_transpose_char( - self, - char: str, - ) -> Tuple[TrackerInputState, Optional[EditAction]]: - """Drives the signed transpose field: ``[±][H][H]``. - - The first slot is reserved for the sign. A leading sign sets it; a leading - digit implies ``+``. A sign key pressed later flips the sign in place, - keeping any digits already entered. The field commits once both magnitude - digits are in. - """ - if self.cursor is None: - return self, None - - is_sign = char in SIGNS - if not self.pending: - pending = char if is_sign else f"{PLUS}{char}" - elif is_sign: - pending = char + self.pending[1:] - return ( - TrackerInputState(cursor=self.cursor, pending=pending), - None, - ) - else: - pending = self.pending + char - - digits = len(pending) - 1 - if digits < DIGIT_COUNT[SubColumn.TRANSPOSE]: - return TrackerInputState(cursor=self.cursor, pending=pending), None - - action = _parse(self.cursor, pending) - return self._after_entry(), action - - def commit_partial(self) -> Tuple[TrackerInputState, Optional[EditAction]]: - if not self.pending or self.cursor is None: - return self, None - - if self.cursor.subcolumn is SubColumn.TRANSPOSE: - action = _parse(self.cursor, self.pending) - return self.reset_pending(), action - - expected = DIGIT_COUNT[self.cursor.subcolumn] - padded = self.pending.zfill(expected) - action = _parse(self.cursor, padded) - return self.reset_pending(), action - - def clear(self) -> Tuple[TrackerInputState, ClearAction]: - action = ClearAction( - row=self.cursor.row if self.cursor else 0, - generator=self.cursor.generator if self.cursor else None, - ) - return self.reset_pending(), action - - def clear_subcolumn(self) -> Tuple[TrackerInputState, ClearAction]: - action = ClearAction( - row=self.cursor.row if self.cursor else 0, - generator=self.cursor.generator if self.cursor else None, - subcolumn=self.cursor.subcolumn if self.cursor else None, - ) - return self.reset_pending(), action - - def cancel(self) -> TrackerInputState: - """Drops a partial entry and any selection, which is what Escape asks of the grid.""" - return self.collapse().reset_pending() diff --git a/src/sampletones_application/ui/panels/sequencer/input/target.py b/src/sampletones_application/ui/panels/sequencer/input/target.py index 089a812cb..664c7c0d9 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/target.py +++ b/src/sampletones_application/ui/panels/sequencer/input/target.py @@ -1,7 +1,7 @@ from dataclasses import dataclass -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.order import OrderCursor +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor from sampletones_application.view_model.sequencer.region import ( OrderCell, OrderRegion, diff --git a/src/sampletones_application/ui/panels/sequencer/input/tracker.py b/src/sampletones_application/ui/panels/sequencer/input/tracker.py new file mode 100644 index 000000000..08f5a528e --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/input/tracker.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Final, Optional, Tuple + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.ui.panels.sequencer.input.edit import ( + ClearAction, + EditAction, +) +from sampletones_application.ui.panels.sequencer.input.state import GridInputState +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + SUBCOLUMNS, + TrackerSlot, + slot_from_flat, +) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_shared.constants.symbols import MINUS, PLUS, PLUS_MINUS, SIGNS + +DIGIT_COUNT: Final[Dict[SubColumn, int]] = { + SubColumn.INSTRUMENT: 2, + SubColumn.TRANSPOSE: 2, + SubColumn.VOLUME: 1, +} + + +@dataclass(frozen=True) +class TrackerCursor: + row: int + generator: Optional[GeneratorName] + subcolumn: SubColumn + + +def _parse(cursor: TrackerCursor, pending: str) -> Optional[EditAction]: + try: + match cursor.subcolumn: + case SubColumn.INSTRUMENT: + return EditAction( + row=cursor.row, + generator=cursor.generator, + sample_index=int(pending, 16), + transpose=None, + volume=None, + ) + case SubColumn.VOLUME: + return EditAction( + row=cursor.row, + generator=cursor.generator, + sample_index=None, + transpose=None, + volume=min(int(pending, 16), MAX_VOLUME), + ) + case SubColumn.TRANSPOSE: + sign = -1 if pending.startswith(MINUS) else 1 + magnitude = pending.lstrip(PLUS_MINUS) + if not magnitude: + return None + + return EditAction( + row=cursor.row, + generator=cursor.generator, + sample_index=None, + transpose=sign * int(magnitude, 16), + volume=None, + ) + except ValueError: + return None + + +@dataclass(frozen=True) +class TrackerInputState(GridInputState[TrackerCursor, TrackerRegion]): + """Edit cursor, pending entry and selection anchor for the tracker grid. + + A cell of the grid is a row crossed with a slot — a channel and one of its subcolumns — + so a selection reaches across the sample column and the channels alike, and typing drives + the subcolumn the cursor stands on. + """ + + def _region_between( + self, + first: TrackerCursor, + second: TrackerCursor, + ) -> TrackerRegion: + first_slot = TrackerSlot(first.generator, first.subcolumn).flat_index + second_slot = TrackerSlot(second.generator, second.subcolumn).flat_index + return TrackerRegion( + first_row=min(first.row, second.row), + last_row=max(first.row, second.row), + first_slot=min(first_slot, second_slot), + last_slot=max(first_slot, second_slot), + ) + + def _covers(self, region: TrackerRegion, cell: TrackerCursor) -> bool: + return region.covers(cell.row, TrackerSlot(cell.generator, cell.subcolumn)) + + def extend_row( + self, + value: int, + row_count: int, + absolute: bool = False, + ) -> TrackerInputState: + """Carries the selection's moving end to another row of the same slot.""" + if self.cursor is None or row_count == 0: + return self + + new_row = value if absolute else self.cursor.row + value + new_row = max(0, min(new_row, row_count - 1)) + return self.extend_to( + TrackerCursor( + new_row, + self.cursor.generator, + self.cursor.subcolumn, + ) + ) + + def extend_slot(self, value: int) -> TrackerInputState: + """Carries the selection's moving end along the flat slot axis, stopping at either end. + + A selection covers a run of the grid, so the walk stops at the first and the last slot + rather than wrapping around the way plain navigation does. + """ + if self.cursor is None: + return self + + current = TrackerSlot( + self.cursor.generator, + self.cursor.subcolumn, + ).flat_index + slot = slot_from_flat(max(0, min(current + value, SLOT_COUNT - 1))) + return self.extend_to( + TrackerCursor( + self.cursor.row, + slot.generator, + slot.subcolumn, + ) + ) + + def navigate_row( + self, + value: int, + row_count: int, + absolute: bool = False, + ) -> TrackerInputState: + if self.cursor is None or row_count == 0: + return self + + new_row = value if absolute else self.cursor.row + value + new_row = max(0, min(new_row, row_count - 1)) + return TrackerInputState( + cursor=TrackerCursor( + new_row, + self.cursor.generator, + self.cursor.subcolumn, + ), + pending="", + ) + + def navigate_subcolumn( + self, + value: int, + absolute: bool = False, + ) -> TrackerInputState: + """Steps the cursor along the flattened slot axis, wrapping at either end. + + Wrapping is a navigation policy the cursor owns: walking right off the last + volume slot lands on the sample column's instrument, so a held arrow key + tours the whole row. + """ + if self.cursor is None: + return self + + if absolute: + new_sub = SUBCOLUMNS[value % len(SUBCOLUMNS)] + return TrackerInputState( + cursor=TrackerCursor( + self.cursor.row, + self.cursor.generator, + new_sub, + ), + pending="", + ) + + current = TrackerSlot( + self.cursor.generator, + self.cursor.subcolumn, + ).flat_index + slot = slot_from_flat((current + value) % SLOT_COUNT) + return TrackerInputState( + cursor=TrackerCursor( + self.cursor.row, + slot.generator, + slot.subcolumn, + ), + pending="", + ) + + def navigate_column_by(self, delta: int) -> TrackerInputState: + if self.cursor is None: + return self + + current_idx = CHANNEL_AXIS.index(self.cursor.generator) + next_idx = (current_idx + delta) % len(CHANNEL_AXIS) + return TrackerInputState( + cursor=TrackerCursor( + self.cursor.row, + CHANNEL_AXIS[next_idx], + self.cursor.subcolumn, + ), + pending="", + ) + + def type_char( + self, + char: str, + ) -> Tuple[TrackerInputState, Optional[EditAction]]: + if self.cursor is None: + return self, None + + if self.cursor.subcolumn is SubColumn.INSTRUMENT and char == MINUS: + return self._after_entry(), self._note_off_action(self.cursor) + + if self.cursor.subcolumn is SubColumn.TRANSPOSE: + return self._type_transpose_char(char) + + if char in SIGNS: + return self, None + + pending = self.pending + char + expected = DIGIT_COUNT[self.cursor.subcolumn] + if len(pending) < expected: + return TrackerInputState(cursor=self.cursor, pending=pending), None + + action = _parse(self.cursor, pending) + return self._after_entry(), action + + def _note_off_action(self, cursor: TrackerCursor) -> EditAction: + return EditAction( + row=cursor.row, + generator=cursor.generator, + sample_index=None, + transpose=None, + volume=None, + note_off=True, + ) + + def _type_transpose_char( + self, + char: str, + ) -> Tuple[TrackerInputState, Optional[EditAction]]: + """Drives the signed transpose field: ``[±][H][H]``. + + The first slot is reserved for the sign. A leading sign sets it; a leading + digit implies ``+``. A sign key pressed later flips the sign in place, + keeping any digits already entered. The field commits once both magnitude + digits are in. + """ + if self.cursor is None: + return self, None + + is_sign = char in SIGNS + if not self.pending: + pending = char if is_sign else f"{PLUS}{char}" + elif is_sign: + pending = char + self.pending[1:] + return ( + TrackerInputState(cursor=self.cursor, pending=pending), + None, + ) + else: + pending = self.pending + char + + digits = len(pending) - 1 + if digits < DIGIT_COUNT[SubColumn.TRANSPOSE]: + return TrackerInputState(cursor=self.cursor, pending=pending), None + + action = _parse(self.cursor, pending) + return self._after_entry(), action + + def commit_partial(self) -> Tuple[TrackerInputState, Optional[EditAction]]: + if not self.pending or self.cursor is None: + return self, None + + if self.cursor.subcolumn is SubColumn.TRANSPOSE: + action = _parse(self.cursor, self.pending) + return self.reset_pending(), action + + expected = DIGIT_COUNT[self.cursor.subcolumn] + padded = self.pending.zfill(expected) + action = _parse(self.cursor, padded) + return self.reset_pending(), action + + def clear(self) -> Tuple[TrackerInputState, ClearAction]: + action = ClearAction( + row=self.cursor.row if self.cursor else 0, + generator=self.cursor.generator if self.cursor else None, + ) + return self.reset_pending(), action + + def clear_subcolumn(self) -> Tuple[TrackerInputState, ClearAction]: + action = ClearAction( + row=self.cursor.row if self.cursor else 0, + generator=self.cursor.generator if self.cursor else None, + subcolumn=self.cursor.subcolumn if self.cursor else None, + ) + return self.reset_pending(), action diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index bfa47dde4..6538b5783 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -50,13 +50,12 @@ tracker_table_row, ) from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor from sampletones_application.ui.panels.sequencer.input.edit import ( ClearAction, EditAction, ) -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState from sampletones_application.ui.panels.sequencer.input.target import TrackerMenuTarget +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background from sampletones_application.ui.themes.inline import ( create_header_selectable_theme, diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py new file mode 100644 index 000000000..be408fee8 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py @@ -0,0 +1,137 @@ +from dataclasses import dataclass + +from sampletones_application.ui.panels.sequencer.input.state import GridInputState + + +@dataclass(frozen=True) +class _Cell: + row: int + column: int + + +@dataclass(frozen=True) +class _Block: + first_row: int + last_row: int + first_column: int + last_column: int + + +@dataclass(frozen=True) +class _GridState(GridInputState[_Cell, _Block]): + """A grid of plain rows and columns, which is the coordinate space the shared rules are read in.""" + + def _region_between(self, first: _Cell, second: _Cell) -> _Block: + return _Block( + first_row=min(first.row, second.row), + last_row=max(first.row, second.row), + first_column=min(first.column, second.column), + last_column=max(first.column, second.column), + ) + + def _covers(self, region: _Block, cell: _Cell) -> bool: + return ( + region.first_row <= cell.row <= region.last_row and region.first_column <= cell.column <= region.last_column + ) + + +def _state( + row: int = 2, + column: int = 1, + pending: str = "", +) -> _GridState: + return _GridState(cursor=_Cell(row, column), pending=pending) + + +class TestSelection: + """A selection stands between the anchor a gesture started on and the cursor it carried to.""" + + def test_a_cursor_alone_covers_no_region(self) -> None: + assert _state().region is None + + def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: + extended = _state(row=2, column=1).extend_to(_Cell(4, 3)) + + assert extended.anchor == _Cell(2, 1) + assert extended.region == _Block(first_row=2, last_row=4, first_column=1, last_column=3) + + def test_a_later_extend_keeps_the_anchor_it_began_on(self) -> None: + extended = _state(row=2, column=1).extend_to(_Cell(4, 3)).extend_to(_Cell(6, 5)) + + assert extended.anchor == _Cell(2, 1) + assert extended.region == _Block(first_row=2, last_row=6, first_column=1, last_column=5) + + def test_extending_backwards_names_the_same_region_as_forwards(self) -> None: + backwards = _state(row=4, column=3).extend_to(_Cell(2, 1)).region + forwards = _state(row=2, column=1).extend_to(_Cell(4, 3)).region + + assert backwards == forwards + + def test_extending_leaves_nothing_pending(self) -> None: + assert _state(pending="5").extend_to(_Cell(4, 3)).pending == "" + + def test_collapsing_drops_the_selection_and_holds_the_entry(self) -> None: + collapsed = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3)).collapse() + + assert collapsed.region is None + assert collapsed.pending == "5" + + def test_dropping_a_partial_entry_holds_the_selection(self) -> None: + held = _state(pending="5").extend_to(_Cell(4, 3)).reset_pending() + + assert held.region is not None + assert held.pending == "" + + def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: + cancelled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3)).cancel() + + assert cancelled.region is None + assert cancelled.pending == "" + assert cancelled.cursor == _Cell(2, 1) + + def test_a_committed_entry_leaves_the_cursor_alone(self) -> None: + settled = _GridState(cursor=_Cell(2, 1), pending="5", anchor=_Cell(4, 3))._after_entry() + + assert settled.region is None + assert settled.pending == "" + + def test_a_transition_answers_as_the_grid_it_came_from(self) -> None: + """A grid state states its own rules, so what a shared rule builds is the grid's own state.""" + assert isinstance(_state().extend_to(_Cell(4, 3)), _GridState) + assert isinstance(_state().reset_pending(), _GridState) + assert isinstance(_state().collapse(), _GridState) + + +class TestTarget: + """The region a block gesture acts on, which is the selection wherever one has been made.""" + + def test_a_cursor_alone_targets_its_own_cell(self) -> None: + assert _state(row=2, column=1).target_region == _Block( + first_row=2, + last_row=2, + first_column=1, + last_column=1, + ) + + def test_a_selection_is_targeted_whole(self) -> None: + selected = _state().extend_to(_Cell(4, 3)) + + assert selected.target_region == selected.region + + def test_a_grid_with_no_cursor_targets_nothing(self) -> None: + assert _GridState().target_region is None + + def test_a_cell_inside_the_selection_is_raised_on_the_whole_of_it(self) -> None: + selected = _state(row=2, column=1).extend_to(_Cell(6, 5)) + + assert selected.region_at(_Cell(4, 3)) == selected.region + + def test_a_cell_outside_the_selection_is_raised_on_itself(self) -> None: + selected = _state(row=2, column=1).extend_to(_Cell(4, 3)) + + assert selected.region_at(_Cell(8, 7)) == _Block( + first_row=8, + last_row=8, + first_column=7, + last_column=7, + ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index 48289f08e..dd365c74f 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -1,7 +1,6 @@ from typing import Optional -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 3229aae65..6e21cb2dd 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -5,12 +5,11 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.elements.table.cells import EditableCells -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.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 7776dc1d3..522f6ab6c 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -8,12 +8,11 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer import order as order_module from sampletones_application.ui.panels.sequencer import tracker as tracker_module -from sampletones_application.ui.panels.sequencer.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.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.view_model.sequencer.region import ( OrderCell, OrderRegion, 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 20e499de5..0557c7b7a 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,12 +3,11 @@ import dearpygui.dearpygui as dpg 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.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard import KeyEvent 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 index 1dcd8ac5a..08b1610a8 100644 --- 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 @@ -3,12 +3,11 @@ 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.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py index d133e4659..700e17a4f 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -12,12 +12,11 @@ ) from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.elements.table.drag import DragSelection -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.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel, OrderKey from sampletones_application.ui.panels.sequencer.tracker import CellKey, GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.modifiers import Modifier diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py index 22a7af00e..a5308d619 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py @@ -2,12 +2,11 @@ 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.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard.combination import KeyCombination diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index 3e413f279..e08c2c58d 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -4,7 +4,7 @@ import pytest from sampletones_application.ui.panels.sequencer import tracker as tracker_module -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor from sampletones_application.view_model.sequencer.samples import ( SampleEntryViewModel, SequencerSamplesViewModel, 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 index 31af3d6fa..2bfec6503 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_navigation.py @@ -5,8 +5,7 @@ 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.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard import KeyEvent from sampletones_application.utils.gui.keyboard.combination import KeyCombination diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py index e5d43ea89..9a6c38c97 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_play_shortcut.py @@ -2,8 +2,7 @@ import dearpygui.dearpygui as dpg -from sampletones_application.ui.panels.sequencer.input.cursor import TrackerCursor -from sampletones_application.ui.panels.sequencer.input.state import TrackerInputState +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.gui.keyboard import KeyEvent from sampletones_application.utils.gui.keyboard.modifiers import CTRL, CTRL_SHIFT 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 index fa7f45175..6ae66a05d 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_rows.py @@ -9,8 +9,7 @@ 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.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.utils.palette.colors.written import LiteralColor from sampletones_application.view_model.sequencer.settings import ( From 3f1bfcff299794a12a790cc9ea5d861d083796e2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 10:11:00 +0200 Subject: [PATCH 083/152] Extracted: shared grid block gestures --- docs/development/sequencer-blocks.md | 6 +- .../logic/sequencer/order/block.py | 2 - .../logic/sequencer/order/reader.py | 6 +- .../logic/sequencer/tracker/block.py | 15 -- .../logic/sequencer/tracker/reader.py | 3 - .../logic/sequencer/tracker/writer.py | 14 +- .../ui/panels/sequencer/grid/__init__.py | 0 .../ui/panels/sequencer/grid/gestures.py | 103 +++++++++++++ .../ui/panels/sequencer/input/state.py | 16 +- .../ui/panels/sequencer/input/target.py | 7 +- .../ui/panels/sequencer/order.py | 84 +++++------ .../ui/panels/sequencer/tracker.py | 84 +++++------ tests/suite/sequencer.py | 9 +- .../logic/sequencer/order/test_reader.py | 30 +--- .../logic/sequencer/tracker/test_reader.py | 14 +- .../ui/panels/sequencer/grid/__init__.py | 0 .../ui/panels/sequencer/grid/test_gestures.py | 138 ++++++++++++++++++ .../panels/sequencer/input/test_grid_input.py | 12 +- .../sequencer/input/test_order_input.py | 15 +- .../sequencer/input/test_tracker_input.py | 15 +- .../ui/panels/sequencer/test_block_keys.py | 5 + .../ui/panels/sequencer/test_block_menu.py | 61 ++++---- 22 files changed, 397 insertions(+), 242 deletions(-) create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/__init__.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/gestures.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/__init__.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index d88b35294..b32794318 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -90,11 +90,11 @@ emptied trailing frames stand as silent ones. ## What a gesture acts on - **From the keyboard**: the selection, or — with none up — the cursor's own cell. - `target_region` on each input state is where that fallback lives, so copying one cell + `region_at` on the shared input state is where that fallback lives, so copying one cell needs no selection made first. - **From a context menu**: the selection when the menu was raised inside it, and the - clicked cell otherwise (`_menu_region` on each panel, over `Region.covers`). A paste - from a menu anchors at the clicked cell; a paste from the keyboard anchors at the cursor. + clicked cell otherwise (the same `region_at`, over `Region.covers`). A paste from a menu + anchors at the clicked cell; a paste from the keyboard anchors at the cursor. - **`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot share a combination inside a shortcut category, so this branch is the route; it also matches tracker convention. diff --git a/src/sampletones_application/logic/sequencer/order/block.py b/src/sampletones_application/logic/sequencer/order/block.py index f217848fd..3ba479ead 100644 --- a/src/sampletones_application/logic/sequencer/order/block.py +++ b/src/sampletones_application/logic/sequencer/order/block.py @@ -20,6 +20,4 @@ class OrderBlock: reaches nothing, so the order ends where the last written column does. """ - row_count: int - position_count: int entries: Dict[BlockKey, Optional[int]] diff --git a/src/sampletones_application/logic/sequencer/order/reader.py b/src/sampletones_application/logic/sequencer/order/reader.py index a81f5d847..c14819701 100644 --- a/src/sampletones_application/logic/sequencer/order/reader.py +++ b/src/sampletones_application/logic/sequencer/order/reader.py @@ -32,11 +32,7 @@ def read(self, region: OrderRegion) -> OrderBlock: if agreement.is_unanimous: entries[(row_offset, position_offset)] = agreement.value - return OrderBlock( - row_count=region.row_count, - position_count=region.position_count, - entries=entries, - ) + return OrderBlock(entries=entries) def _agree( self, diff --git a/src/sampletones_application/logic/sequencer/tracker/block.py b/src/sampletones_application/logic/sequencer/tracker/block.py index 899b28374..a55019326 100644 --- a/src/sampletones_application/logic/sequencer/tracker/block.py +++ b/src/sampletones_application/logic/sequencer/tracker/block.py @@ -30,21 +30,6 @@ class TrackerBlock: channel to whichever column it is written into. """ - row_count: int - first_slot: int - last_slot: int notes: Dict[BlockKey, Optional[BlockNote]] transposes: Dict[BlockKey, Optional[int]] volumes: Dict[BlockKey, Optional[int]] - - @property - def slot_count(self) -> int: - return self.last_slot - self.first_slot + 1 - - @property - def slots(self) -> range: - return range(self.first_slot, self.last_slot + 1) - - @property - def rows(self) -> range: - return range(self.row_count) diff --git a/src/sampletones_application/logic/sequencer/tracker/reader.py b/src/sampletones_application/logic/sequencer/tracker/reader.py index 8bac8910b..c0ef980dc 100644 --- a/src/sampletones_application/logic/sequencer/tracker/reader.py +++ b/src/sampletones_application/logic/sequencer/tracker/reader.py @@ -33,9 +33,6 @@ def read(self, region: TrackerRegion) -> TrackerBlock: """Takes the values a region covers, keeping each kind of subcolumn in a map of its own.""" base = column_slot_base(slot_from_flat(region.first_slot).generator) return TrackerBlock( - row_count=region.row_count, - first_slot=region.first_slot - base, - last_slot=region.last_slot - base, notes=self._read_subcolumn(region, base, SubColumn.INSTRUMENT, self._note_of), transposes=self._read_subcolumn(region, base, SubColumn.TRANSPOSE, self._transpose_of), volumes=self._read_subcolumn(region, base, SubColumn.VOLUME, self._volume_of), diff --git a/src/sampletones_application/logic/sequencer/tracker/writer.py b/src/sampletones_application/logic/sequencer/tracker/writer.py index 5849cc7a0..22a984380 100644 --- a/src/sampletones_application/logic/sequencer/tracker/writer.py +++ b/src/sampletones_application/logic/sequencer/tracker/writer.py @@ -1,13 +1,6 @@ +from collections.abc import Hashable from typing import Callable, Dict, Optional, TypeVar -from sampletones_application.logic.sequencer.tracker.block import ( - BlockKey, - BlockNote, - TrackerBlock, -) -from sampletones_application.logic.sequencer.tracker.tracker import ( - SequencerTrackerLogic, -) from sampletones_application.view_model.sequencer.region import ( TrackerCell, TrackerRegion, @@ -21,7 +14,10 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.project.instruments.note_off import NoteOff -ValueT = TypeVar("ValueT") +from .block import BlockKey, BlockNote, TrackerBlock +from .tracker import SequencerTrackerLogic + +ValueT = TypeVar("ValueT", bound=Hashable) class TrackerBlockWriter: diff --git a/src/sampletones_application/ui/panels/sequencer/grid/__init__.py b/src/sampletones_application/ui/panels/sequencer/grid/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/sequencer/grid/gestures.py b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py new file mode 100644 index 000000000..3b1edf448 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py @@ -0,0 +1,103 @@ +from typing import Callable, Generic, Optional, Protocol, TypeVar + +from sampletones_shared.utils.callbacks import CallbackMixin + +RegionT = TypeVar("RegionT") +CellT = TypeVar("CellT") +RegionT_co = TypeVar("RegionT_co", covariant=True) +CellT_co = TypeVar("CellT_co", covariant=True) + + +class BlockTarget(Protocol[RegionT_co, CellT_co]): + """What a block gesture acts on: the block it covers, and the cell a pasted block lands at. + + A grid resolves one from whichever cell raised the gesture, so the pair travels together and + each gesture reads the half it acts on. + """ + + @property + def region(self) -> RegionT_co: ... + + @property + def anchor(self) -> CellT_co: ... + + +class BlockGrid(Protocol[RegionT, CellT]): + """What a grid states to the block gestures raised over it. + + The hooks are the grid's own, so the coordinator keeps wiring them where it already does; the + two methods are what a key press needs, since it names its target through the cursor. + """ + + on_copy_block: Optional[Callable[[RegionT], None]] + on_cut_block: Optional[Callable[[RegionT], None]] + on_delete_block: Optional[Callable[[RegionT], None]] + on_paste_block: Optional[Callable[[CellT], None]] + can_paste_block: Optional[Callable[[], bool]] + + def commit_entry(self) -> None: + """Writes the entry being typed into the cell the cursor stands on.""" + + def cursor_target(self) -> Optional[BlockTarget[RegionT, CellT]]: + """The target the cursor names, once the grid holds a cursor.""" + + +class BlockGestures(CallbackMixin, Generic[RegionT, CellT]): + """The four gestures a grid's blocks answer to: copy, cut, paste and delete. + + Three doors raise the same four. A key press acts at the cursor, and takes its target once the + entry being typed has landed, so a gesture carries the value the reader has just finished. A + cell menu and the menu bar's Edit menu each name the target they were built for and act on it + where it stands. Holding the four here is what has every door fire one implementation. + + The plain gestures act at the cursor; the ``_at`` gestures act on a target already named. + """ + + def __init__(self, *, grid: BlockGrid[RegionT, CellT]) -> None: + self._grid = grid + + def can_paste(self) -> bool: + """Whether a block stands ready for a paste to write.""" + return self.query(self._grid.can_paste_block, default=False) + + def copy(self) -> None: + self._at_cursor(self.copy_at) + + def cut(self) -> None: + self._at_cursor(self.cut_at) + + def delete(self) -> None: + self._at_cursor(self.delete_at) + + def paste(self) -> None: + self._at_cursor(self.paste_at) + + def copy_at(self, target: BlockTarget[RegionT, CellT]) -> None: + """Takes what a target covers, leaving the grid as it stands.""" + self.call(self._grid.on_copy_block, target.region) + + def cut_at(self, target: BlockTarget[RegionT, CellT]) -> None: + """Takes what a target covers, and empties it.""" + self.call(self._grid.on_cut_block, target.region) + + def delete_at(self, target: BlockTarget[RegionT, CellT]) -> None: + """Empties what a target covers, the block in hand standing as it is.""" + self.call(self._grid.on_delete_block, target.region) + + def paste_at(self, target: BlockTarget[RegionT, CellT]) -> None: + """Writes the block in hand from a target's own cell, which is where it lands.""" + self.call(self._grid.on_paste_block, target.anchor) + + def _at_cursor( + self, + gesture: Callable[[BlockTarget[RegionT, CellT]], None], + ) -> None: + """Raises a gesture on the cell the cursor stands on, the entry being typed landing first. + + Committing ahead of the gesture is what lets a block carry the value the reader has just + finished typing. + """ + self._grid.commit_entry() + target = self._grid.cursor_target() + if target is not None: + gesture(target) diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index 864edb0c9..cb5f3373d 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -56,7 +56,9 @@ def region_at(self, cell: CursorT) -> RegionT: A gesture raised inside a selection acts on the whole of it, which is what a reader who has just dragged a range out expects it to reach; one raised anywhere else acts on the cell it - names, which is a block of exactly that cell. + names, which is a block of exactly that cell. A cursor with nothing selected therefore + stands on a block of one cell, so copying reaches the cell the reader is working in and + needs no selection made first. """ region = self.region if region is not None and self._covers(region, cell): @@ -64,18 +66,6 @@ def region_at(self, cell: CursorT) -> RegionT: return self._region_between(cell, cell) - @property - def target_region(self) -> Optional[RegionT]: - """The region a block gesture acts on: the selection, or the cursor's own cell. - - A cursor with nothing selected stands on a block of one cell, so copying reaches the cell - the reader is working in and needs no selection made first. - """ - if self.cursor is None: - return None - - return self.region_at(self.cursor) - def extend_to(self, cursor: CursorT) -> Self: """Carries the moving end of the selection to ``cursor``, anchoring it where it began. diff --git a/src/sampletones_application/ui/panels/sequencer/input/target.py b/src/sampletones_application/ui/panels/sequencer/input/target.py index 664c7c0d9..20570b4c2 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/target.py +++ b/src/sampletones_application/ui/panels/sequencer/input/target.py @@ -11,12 +11,13 @@ @dataclass(frozen=True) -class TrackerMenuTarget: +class TrackerTarget: """The tracker cell a set of actions was raised on, and the block those actions act on. Both are needed at once: the block decides what the clipboard actions cover, while the cell decides where a pasted block lands and which row and channel the cell-level actions reach. - A target keeps the pair together, so a builder handed one prints a whole action set. + A target keeps the pair together, so a builder handed one prints a whole action set and a key + press handed one reaches the same block the menus would. """ cell: TrackerCursor @@ -36,7 +37,7 @@ def anchor(self) -> TrackerCell: @dataclass(frozen=True) -class OrderMenuTarget: +class OrderTarget: """The order cell a set of actions was raised on, and the block those actions act on. Both are needed at once: the block decides what the clipboard actions cover, while the cell diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 1c2da3e84..23d54995e 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -46,12 +46,13 @@ channel_tooltip, ) from sampletones_application.ui.panels.sequencer.columns import channel_color +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.order import ( INDEX_DIGITS, OrderCursor, OrderInputState, ) -from sampletones_application.ui.panels.sequencer.input.target import OrderMenuTarget +from sampletones_application.ui.panels.sequencer.input.target import OrderTarget from sampletones_application.ui.themes.inline import ( create_header_selectable_theme, create_selectable_text_theme, @@ -183,6 +184,7 @@ def __init__( self.on_channels_muted: Optional[VoidCallback] = None self.on_channels_unmuted: Optional[VoidCallback] = None + self._blocks: BlockGestures[OrderRegion, OrderCell] = BlockGestures(grid=self) self._lbl_order = self._label(language_manager, SequencerOrderElements.ORDER_TEXT) self._load_row_labels(language_manager) self._load_context_labels(language_manager) @@ -1032,7 +1034,7 @@ def _show_context_menu( generator: Optional[GeneratorName], position: int, ) -> None: - target = self._menu_target(OrderCursor(generator, position)) + target = self._target_at(OrderCursor(generator, position)) with context_menu(): header = dpg.add_text(display_id(position)) FontRegistry.bind_to_item(header, Font.MONO_BOLD) @@ -1045,13 +1047,21 @@ def _show_context_menu( dpg.add_separator() self._add_action_items(target) - def _menu_target(self, cell: OrderCursor) -> OrderMenuTarget: - """The cell a set of actions is built for, paired with the block those actions act on.""" - return OrderMenuTarget( + def _target_at(self, cell: OrderCursor) -> OrderTarget: + """The cell a set of actions is raised on, paired with the block those actions act on.""" + return OrderTarget( cell=cell, region=self._input_state.region_at(cell), ) + def cursor_target(self) -> Optional[OrderTarget]: + """The target the cursor names, which is what a key press and the Edit menu act on.""" + cursor = self._input_state.cursor + if cursor is None: + return None + + return self._target_at(cursor) + def owns_edit_actions(self) -> bool: """Whether the Edit menu states this table's actions, which it does while it owns keys. @@ -1065,13 +1075,11 @@ def build_edit_actions(self) -> None: The menu bar asks while the table owns the editing gestures, so the cursor names the target the same way a pointer names it on the cell menu. """ - cursor = self._input_state.cursor - if cursor is None: - return - - self._add_action_items(self._menu_target(cursor)) + target = self.cursor_target() + if target is not None: + self._add_action_items(target) - def _add_action_items(self, target: OrderMenuTarget) -> None: + def _add_action_items(self, target: OrderTarget) -> None: """Builds every action an order cell offers, in the order each menu prints them. The table states its actions once, and whoever asks for them decides where they are shown: @@ -1084,7 +1092,7 @@ def _add_action_items(self, target: OrderMenuTarget) -> None: dpg.add_separator() self._add_move_items(target.cell.position) - def _add_block_items(self, target: OrderMenuTarget) -> None: + def _add_block_items(self, target: OrderTarget) -> None: """Builds the clipboard items, acting on the block the actions were raised on. Paste is offered once a block has been copied, and it anchors at the target's own cell, so @@ -1095,22 +1103,22 @@ def _add_block_items(self, target: OrderMenuTarget) -> None: dpg.add_menu_item( label=self._lbl_context_copy, shortcut=self._shortcuts.display(ShortcutId.ORDER_COPY_BLOCK), - callback=lambda: self.call(self.on_copy_block, target.region), + callback=lambda: self._blocks.copy_at(target), ) dpg.add_menu_item( label=self._lbl_context_cut, shortcut=self._shortcuts.display(ShortcutId.ORDER_CUT_BLOCK), - callback=lambda: self.call(self.on_cut_block, target.region), + callback=lambda: self._blocks.cut_at(target), ) dpg.add_menu_item( label=self._lbl_context_paste, shortcut=self._shortcuts.display(ShortcutId.ORDER_PASTE_BLOCK), - enabled=self.query(self.can_paste_block, default=False), - callback=lambda: self.call(self.on_paste_block, target.anchor), + enabled=self._blocks.can_paste(), + callback=lambda: self._blocks.paste_at(target), ) dpg.add_menu_item( label=self._lbl_context_delete, - callback=lambda: self.call(self.on_delete_block, target.region), + callback=lambda: self._blocks.delete_at(target), ) def _add_frame_items(self, position: int) -> None: @@ -1276,44 +1284,18 @@ def _block_action(self, shortcut_id: ShortcutId) -> bool: """ match shortcut_id: case ShortcutId.ORDER_COPY_BLOCK: - self._region_gesture(self.on_copy_block) + self._blocks.copy() case ShortcutId.ORDER_CUT_BLOCK: - self._region_gesture(self.on_cut_block) + self._blocks.cut() case ShortcutId.ORDER_CLEAR_CELL if self._input_state.region is not None: - self._region_gesture(self.on_delete_block) + self._blocks.delete() case ShortcutId.ORDER_PASTE_BLOCK: - self._paste_block() + self._blocks.paste() case _: return False return True - def _region_gesture(self, callback: Optional[OnBlockRegionCallback]) -> None: - """Hands the selected block out to a gesture, the cell under the cursor standing for itself. - - A partial entry is committed first, so the block carries the index the reader has just - finished typing. - """ - state = self._committed_state() - self._apply_state(state) - region = state.target_region - if region is not None: - self.call(callback, region) - - def _paste_block(self) -> None: - """Names the cell a block is written from, which is wherever the cursor stands.""" - state = self._committed_state() - self._apply_state(state) - cursor = state.cursor - if cursor is not None: - self.call( - self.on_paste_block, - OrderCell( - generator=cursor.generator, - position=cursor.position, - ), - ) - 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. @@ -1417,6 +1399,14 @@ def _committed_state(self) -> OrderInputState: return state + def commit_entry(self) -> None: + """Writes the entry being typed into the cell the cursor stands on. + + A block gesture takes this first, so what it lifts out carries the index the reader has + just finished typing. + """ + self._apply_state(self._committed_state()) + def _type_character(self, event: KeyEvent) -> bool: """Types a hex digit into the cell under the cursor, reporting whether the press was one. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 6538b5783..9ab41c57a 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -50,11 +50,12 @@ tracker_table_row, ) from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.edit import ( ClearAction, EditAction, ) -from sampletones_application.ui.panels.sequencer.input.target import TrackerMenuTarget +from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.rows import RowCues, row_background from sampletones_application.ui.themes.inline import ( @@ -203,6 +204,7 @@ def __init__( self.on_channels_muted: Optional[VoidCallback] = None self.on_channels_unmuted: Optional[VoidCallback] = None + self._blocks: BlockGestures[TrackerRegion, TrackerCell] = BlockGestures(grid=self) self.pattern_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_PATTERN) self._lbl_tracker = self._label( @@ -1247,7 +1249,7 @@ def _show_context_menu( generator: Optional[GeneratorName], subcolumn: SubColumn, ) -> None: - target = self._menu_target(TrackerCursor(row_index, generator, subcolumn)) + target = self._target_at(TrackerCursor(row_index, generator, subcolumn)) with context_menu(): header = dpg.add_text( tracker_display.indexed_label(row_index, self._column_labels[generator]), @@ -1267,13 +1269,21 @@ def _show_context_menu( dpg.add_separator() self._add_action_items(target) - def _menu_target(self, cell: TrackerCursor) -> TrackerMenuTarget: - """The cell a set of actions is built for, paired with the block those actions act on.""" - return TrackerMenuTarget( + def _target_at(self, cell: TrackerCursor) -> TrackerTarget: + """The cell a set of actions is raised on, paired with the block those actions act on.""" + return TrackerTarget( cell=cell, region=self._input_state.region_at(cell), ) + def cursor_target(self) -> Optional[TrackerTarget]: + """The target the cursor names, which is what a key press and the Edit menu act on.""" + cursor = self._input_state.cursor + if cursor is None: + return None + + return self._target_at(cursor) + def owns_edit_actions(self) -> bool: """Whether the Edit menu states this grid's actions, which it does while the grid owns keys. @@ -1287,13 +1297,11 @@ def build_edit_actions(self) -> None: The menu bar asks while the grid owns the editing gestures, so the cursor names the target the same way a pointer names it on the cell menu. """ - cursor = self._input_state.cursor - if cursor is None: - return - - self._add_action_items(self._menu_target(cursor)) + target = self.cursor_target() + if target is not None: + self._add_action_items(target) - def _add_action_items(self, target: TrackerMenuTarget) -> None: + def _add_action_items(self, target: TrackerTarget) -> None: """Builds every action a tracker cell offers, in the order each menu prints them. The grid states its actions once, and whoever asks for them decides where they are shown: @@ -1314,7 +1322,7 @@ def _add_action_items(self, target: TrackerMenuTarget) -> None: dpg.add_separator() self._add_clear_items(target.cell) - def _add_block_items(self, target: TrackerMenuTarget) -> None: + def _add_block_items(self, target: TrackerTarget) -> None: """Builds the clipboard items, acting on the block the actions were raised on. Paste is offered once a block has been copied, and it anchors at the target's own cell, so @@ -1325,22 +1333,22 @@ def _add_block_items(self, target: TrackerMenuTarget) -> None: dpg.add_menu_item( label=self._lbl_context_copy, shortcut=self._shortcuts.display(ShortcutId.TRACKER_COPY_BLOCK), - callback=lambda: self.call(self.on_copy_block, target.region), + callback=lambda: self._blocks.copy_at(target), ) dpg.add_menu_item( label=self._lbl_context_cut, shortcut=self._shortcuts.display(ShortcutId.TRACKER_CUT_BLOCK), - callback=lambda: self.call(self.on_cut_block, target.region), + callback=lambda: self._blocks.cut_at(target), ) dpg.add_menu_item( label=self._lbl_context_paste, shortcut=self._shortcuts.display(ShortcutId.TRACKER_PASTE_BLOCK), - enabled=self.query(self.can_paste_block, default=False), - callback=lambda: self.call(self.on_paste_block, target.anchor), + enabled=self._blocks.can_paste(), + callback=lambda: self._blocks.paste_at(target), ) dpg.add_menu_item( label=self._lbl_context_delete, - callback=lambda: self.call(self.on_delete_block, target.region), + callback=lambda: self._blocks.delete_at(target), ) def _add_instrument_submenu(self, cell: TrackerCursor) -> None: @@ -1544,44 +1552,18 @@ def _block_action(self, shortcut_id: ShortcutId) -> bool: """ match shortcut_id: case ShortcutId.TRACKER_COPY_BLOCK: - self._region_gesture(self.on_copy_block) + self._blocks.copy() case ShortcutId.TRACKER_CUT_BLOCK: - self._region_gesture(self.on_cut_block) + self._blocks.cut() case ShortcutId.TRACKER_CLEAR_ROW if self._input_state.region is not None: - self._region_gesture(self.on_delete_block) + self._blocks.delete() case ShortcutId.TRACKER_PASTE_BLOCK: - self._paste_block() + self._blocks.paste() case _: return False return True - def _region_gesture(self, callback: Optional[OnBlockRegionCallback]) -> None: - """Hands the selected block out to a gesture, the cell under the cursor standing for itself. - - A partial entry is committed first, so the block carries the value the reader has just - finished typing. - """ - state = self._committed_state() - self._apply_state(state) - region = state.target_region - if region is not None: - self.call(callback, region) - - def _paste_block(self) -> None: - """Names the cell a block is written from, which is wherever the cursor stands.""" - state = self._committed_state() - self._apply_state(state) - cursor = state.cursor - if cursor is not None: - self.call( - self.on_paste_block, - TrackerCell( - row=cursor.row, - generator=cursor.generator, - ), - ) - 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. @@ -1742,6 +1724,14 @@ def _committed_state(self) -> TrackerInputState: return state + def commit_entry(self) -> None: + """Writes the entry being typed into the cell the cursor stands on. + + A block gesture takes this first, so what it lifts out carries the value the reader has + just finished typing. + """ + self._apply_state(self._committed_state()) + def _type_character(self, event: KeyEvent) -> bool: """Types a note, digit or sign into the cell under the cursor, reporting whether the press was one. diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index 5ed85b863..365514001 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -121,11 +121,7 @@ def parse_order_block(rows: Sequence[str]) -> OrderBlock: if token != MIXED: entries[(row_offset, position_offset)] = parse_index(token) - return OrderBlock( - row_count=len(rows), - position_count=widths.pop(), - entries=entries, - ) + return OrderBlock(entries=entries) def fill_order( @@ -200,9 +196,6 @@ def parse_block( volumes[key] = parse_volume(token) return TrackerBlock( - row_count=len(rows), - first_slot=first_slot, - last_slot=first_slot + widths.pop() - 1, notes=notes, transposes=transposes, volumes=volumes, diff --git a/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py index bc4fde3cb..2c49e3cc2 100644 --- a/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py +++ b/tests/unit/sampletones_application/logic/sequencer/order/test_reader.py @@ -149,34 +149,8 @@ def test_a_position_its_channels_disagree_over_is_left_out( assert block.entries == {(0, 0): 0} -class TestExtent: - """A block states the rectangle it was read at, which a mixed edge column cannot take away.""" - - def test_a_region_carries_the_shape_it_covers( - self, - logic: SequencerOrderLogic, - reader: OrderBlockReader, - ) -> None: - fill_order( - logic, - ( - "00 01 02", - "00 01 02", - "00 01 02", - "00 01 02", - ), - ) - - block = reader.read( - OrderRegion( - first_row=MASTER_ROW, - last_row=NOISE_ROW, - first_position=1, - last_position=2, - ) - ) - - assert (block.row_count, block.position_count) == (5, 2) +class TestOffsets: + """A block addresses its entries by the offsets they stand at, counted from where it begins.""" def test_offsets_run_from_the_cell_the_region_begins_at( self, diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py index bcf080ba0..f1518560b 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_reader.py @@ -142,7 +142,6 @@ def test_rows_past_the_pattern_read_empty( block = reader.read(_column(GeneratorName.PULSE1, last_row=3)) - assert block.row_count == 4 assert block.volumes[_key(SubColumn.VOLUME)] == 4 assert block.volumes[_key(SubColumn.VOLUME, 2)] is None assert block.volumes[_key(SubColumn.VOLUME, 3)] is None @@ -232,15 +231,15 @@ def test_an_untouched_row_carries_its_emptiness( assert block.volumes[_key(SubColumn.VOLUME)] is None -class TestExtent: - """A block states the rectangle it was read from, whatever the cells in it turned out to hold.""" +class TestOffsets: + """A block addresses its values by the offsets it was read at, whatever the cells hold.""" - def test_a_mixed_edge_column_keeps_its_place_in_the_block( + def test_a_mixed_edge_column_leaves_only_itself_out( self, logic: SequencerTrackerLogic, reader: TrackerBlockReader, ) -> None: - """The last slot reads as nothing, and the extent is what still states the block reaches it.""" + """The last slot reads as nothing, and the cells beside it keep the offsets they stand at.""" logic.set_cell_subcolumn(0, GeneratorName.PULSE1, volume=2) block = reader.read( @@ -252,8 +251,8 @@ def test_a_mixed_edge_column_keeps_its_place_in_the_block( ) ) - assert (block.first_slot, block.last_slot) == (0, 2) - assert block.slot_count == 3 + assert set(block.notes) == {_key(SubColumn.INSTRUMENT)} + assert set(block.transposes) == {_key(SubColumn.TRANSPOSE)} assert _key(SubColumn.VOLUME) not in block.volumes def test_the_offsets_are_measured_from_the_column_the_block_begins_in( @@ -274,7 +273,6 @@ def test_the_offsets_are_measured_from_the_column_the_block_begins_in( ) ) - assert (block.first_slot, block.last_slot) == (1, 3) assert set(block.transposes) == {(0, 1)} assert set(block.volumes) == {(0, 2)} assert set(block.notes) == {(0, 3)} diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py new file mode 100644 index 000000000..e41bad8f4 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py @@ -0,0 +1,138 @@ +from dataclasses import dataclass +from typing import Callable, Final, List, Optional, Tuple + +import pytest + +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures + +Gestures = BlockGestures[str, str] + + +@dataclass(frozen=True) +class _Target: + """A block and the cell a paste lands at, written as the words each hook reports.""" + + region: str + anchor: str + + +CURSOR_TARGET: Final[_Target] = _Target(region="cursor block", anchor="cursor cell") +NAMED_TARGET: Final[_Target] = _Target(region="named block", anchor="named cell") + + +class _Grid: + """A grid recording what it was asked to do, in the order it was asked. + + The entry it settles and the hooks it announces through land in one list, so a test reads + both what a gesture reached and when the grid committed what was being typed. + """ + + def __init__( + self, + *, + target: Optional[_Target] = None, + can_paste: bool = True, + ) -> None: + self.events: List[str] = [] + self._target = target + self.on_copy_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"copy {region}") + self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}") + self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") + self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") + self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste + + def commit_entry(self) -> None: + self.events.append("commit") + + def cursor_target(self) -> Optional[_Target]: + return self._target + + +@dataclass(frozen=True) +class GestureCase: + """One of the four gestures, raised at the cursor and on a target a menu named.""" + + name: str + at_cursor: Callable[[Gestures], None] + at_target: Callable[[Gestures, _Target], None] + from_cursor: str + from_target: str + + +CASES: Final[Tuple[GestureCase, ...]] = ( + GestureCase( + name="copy", + at_cursor=lambda gestures: gestures.copy(), + at_target=lambda gestures, target: gestures.copy_at(target), + from_cursor="copy cursor block", + from_target="copy named block", + ), + GestureCase( + name="cut", + at_cursor=lambda gestures: gestures.cut(), + at_target=lambda gestures, target: gestures.cut_at(target), + from_cursor="cut cursor block", + from_target="cut named block", + ), + GestureCase( + name="delete", + at_cursor=lambda gestures: gestures.delete(), + at_target=lambda gestures, target: gestures.delete_at(target), + from_cursor="delete cursor block", + from_target="delete named block", + ), + GestureCase( + name="paste", + at_cursor=lambda gestures: gestures.paste(), + at_target=lambda gestures, target: gestures.paste_at(target), + from_cursor="paste cursor cell", + from_target="paste named cell", + ), +) + + +@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) +class TestAtTheCursor: + """A key press acts on the target the cursor names, once the entry being typed has landed.""" + + def test_a_gesture_reaches_the_cursor_s_own_target(self, case: GestureCase) -> None: + grid = _Grid(target=CURSOR_TARGET) + + case.at_cursor(BlockGestures(grid=grid)) + + assert grid.events == ["commit", case.from_cursor] + + def test_a_grid_holding_no_cursor_settles_its_entry_and_stands(self, case: GestureCase) -> None: + grid = _Grid(target=None) + + case.at_cursor(BlockGestures(grid=grid)) + + assert grid.events == ["commit"] + + +@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) +class TestOnANamedTarget: + """A menu item acts on the target it was built for, wherever the cursor happens to stand.""" + + def test_a_gesture_reaches_the_target_it_was_handed(self, case: GestureCase) -> None: + grid = _Grid(target=CURSOR_TARGET) + + case.at_target(BlockGestures(grid=grid), NAMED_TARGET) + + assert grid.events == [case.from_target] + + +class TestPasteEnablement: + """Paste is offered while a block stands ready for it to write.""" + + def test_a_grid_holding_a_block_offers_the_paste(self) -> None: + assert BlockGestures(grid=_Grid(can_paste=True)).can_paste() is True + + def test_a_grid_holding_none_offers_no_paste(self) -> None: + assert BlockGestures(grid=_Grid(can_paste=False)).can_paste() is False + + def test_a_grid_awaiting_its_wiring_offers_no_paste(self) -> None: + grid = _Grid() + grid.can_paste_block = None + + assert BlockGestures(grid=grid).can_paste() is False diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py index be408fee8..e508fc396 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py @@ -105,22 +105,14 @@ def test_a_transition_answers_as_the_grid_it_came_from(self) -> None: class TestTarget: """The region a block gesture acts on, which is the selection wherever one has been made.""" - def test_a_cursor_alone_targets_its_own_cell(self) -> None: - assert _state(row=2, column=1).target_region == _Block( + def test_a_cell_of_a_grid_with_nothing_selected_is_raised_on_itself(self) -> None: + assert _state(row=2, column=1).region_at(_Cell(2, 1)) == _Block( first_row=2, last_row=2, first_column=1, last_column=1, ) - def test_a_selection_is_targeted_whole(self) -> None: - selected = _state().extend_to(_Cell(4, 3)) - - assert selected.target_region == selected.region - - def test_a_grid_with_no_cursor_targets_nothing(self) -> None: - assert _GridState().target_region is None - def test_a_cell_inside_the_selection_is_raised_on_the_whole_of_it(self) -> None: selected = _state(row=2, column=1).extend_to(_Cell(6, 5)) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index 1f36dc476..93a43a4b7 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -113,20 +113,19 @@ def test_cancel_drops_the_selection_and_the_partial_entry(self) -> None: class TestTarget: """The region a block gesture acts on, which is the selection wherever one has been made.""" - def test_a_cursor_alone_targets_its_own_cell(self) -> None: - region = _state(GeneratorName.PULSE2, position=4).target_region + def test_a_cell_of_a_table_with_nothing_selected_is_raised_on_itself(self) -> None: + cell = OrderCursor(GeneratorName.PULSE2, 4) + + region = _state(GeneratorName.PULSE2, position=4).region_at(cell) - assert region is not None assert (region.first_position, region.last_position) == (4, 4) assert region.generators == (GeneratorName.PULSE2,) - def test_a_selection_is_targeted_whole(self) -> None: + def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None: selected = _state(position=4).extend_position(2, POSITION_COUNT) + cell = OrderCursor(GeneratorName.PULSE1, 5) - assert selected.target_region == selected.region - - def test_a_table_with_no_cursor_targets_nothing(self) -> None: - assert OrderInputState().target_region is None + assert selected.region_at(cell) == selected.region class TestEntry: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index dd365c74f..e2c0dbb49 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -137,20 +137,19 @@ def test_collapse_keeps_the_cursor_where_it_stands(self) -> None: class TestTargetRegion: """The region a block gesture acts on, which is the selection wherever one has been made.""" - def test_a_cursor_alone_targets_its_own_cell(self) -> None: - region = _state(SubColumn.TRANSPOSE, row=4).target_region + def test_a_cell_of_a_grid_with_nothing_selected_is_raised_on_itself(self) -> None: + cell = TrackerCursor(4, GeneratorName.PULSE1, SubColumn.TRANSPOSE) + + region = _state(SubColumn.TRANSPOSE, row=4).region_at(cell) - assert region is not None assert (region.first_row, region.last_row) == (4, 4) assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),) - def test_a_selection_is_targeted_whole(self) -> None: + def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None: selected = _state(SubColumn.INSTRUMENT, row=4).extend_row(2, ROW_COUNT) + cell = TrackerCursor(5, GeneratorName.PULSE1, SubColumn.INSTRUMENT) - assert selected.target_region == selected.region - - def test_a_grid_with_no_cursor_targets_nothing(self) -> None: - assert TrackerInputState().target_region is None + assert selected.region_at(cell) == selected.region class TestColumnNavigation: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 6e21cb2dd..2186b19ad 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -5,6 +5,7 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, @@ -83,6 +84,8 @@ def _panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.on_clear_row = lambda row, generator_name: gestures.cleared.append((row, generator_name)) + panel.can_paste_block = lambda: True + panel._blocks = BlockGestures(grid=panel) monkeypatch.setattr(panel, "_apply_state", lambda state: None) return panel @@ -103,6 +106,8 @@ def _order_panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.on_set_order_entry = lambda channel, position, index: gestures.cleared.append((channel, position, index)) + panel.can_paste_block = lambda: True + panel._blocks = BlockGestures(grid=panel) monkeypatch.setattr(panel, "_apply_state", lambda state, notify=True: None) return panel diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 522f6ab6c..ceabdd53a 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -8,6 +8,7 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer import order as order_module from sampletones_application.ui.panels.sequencer import tracker as tracker_module +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, @@ -139,6 +140,7 @@ def _tracker_panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.can_paste_block = lambda: can_paste + panel._blocks = BlockGestures(grid=panel) return panel @@ -158,6 +160,7 @@ def _order_panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.can_paste_block = lambda: can_paste + panel._blocks = BlockGestures(grid=panel) return panel @@ -210,12 +213,12 @@ def _selected_order_state() -> OrderInputState: return state.extend_position(2, POSITION_COUNT) -class TestTrackerMenuTarget: +class TestTrackerTarget: def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - target = panel._menu_target( + target = panel._target_at( TrackerCursor( CLICKED_ROW + 1, GeneratorName.PULSE1, @@ -229,7 +232,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - target = panel._menu_target( + target = panel._target_at( TrackerCursor( CLICKED_ROW, GeneratorName.TRIANGLE, @@ -247,7 +250,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _tracker_panel(Gestures()) - target = panel._menu_target(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT)) + target = panel._target_at(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT)) assert target.region.rows == range(CLICKED_ROW, CLICKED_ROW + 1) assert target.region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),) @@ -256,19 +259,23 @@ def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: """The menu bar asks for the cursor's own target, which is the standing selection.""" panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - cursor = TrackerCursor(CLICKED_ROW + 2, GeneratorName.PULSE1, SubColumn.VOLUME) - target = panel._menu_target(cursor) + target = panel.cursor_target() + assert target is not None assert target.region == panel._input_state.region + def test_a_grid_holding_no_cursor_names_no_target(self) -> None: + assert _tracker_panel(Gestures()).cursor_target() is None + def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: panel = _tracker_panel(Gestures()) cursor = TrackerCursor(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME) panel._input_state = TrackerInputState(cursor=cursor) - target = panel._menu_target(cursor) + target = panel.cursor_target() + assert target is not None assert target.region == TrackerRegion( first_row=CLICKED_ROW, last_row=CLICKED_ROW, @@ -288,7 +295,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_tracker_state() selection = panel._input_state.region - panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) for item in tracker_recorder.items: item.callback() @@ -302,7 +309,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord panel = _tracker_panel(gestures) panel._add_block_items( - panel._menu_target( + panel._target_at( TrackerCursor( CLICKED_ROW, GeneratorName.NOISE, @@ -317,7 +324,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord def test_paste_awaits_a_copy(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures(), can_paste=False) - panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) assert tracker_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in tracker_recorder.items] == [True, True, False, True] @@ -328,17 +335,17 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _tracker_panel(Gestures()) - panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) assert [item.label for item in tracker_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] -class TestOrderMenuTarget: +class TestOrderTarget: def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - target = panel._menu_target(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1)) + target = panel._target_at(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1)) assert target.region == panel._input_state.region @@ -346,7 +353,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - target = panel._menu_target(_order_cell(None)) + target = panel._target_at(_order_cell(None)) assert target.region == OrderRegion( first_row=CHANNEL_AXIS.index(None), @@ -358,7 +365,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _order_panel(Gestures()) - target = panel._menu_target(_order_cell(GeneratorName.PULSE1)) + target = panel._target_at(_order_cell(GeneratorName.PULSE1)) assert target.region == OrderRegion( first_row=PULSE1_ROW, @@ -371,19 +378,23 @@ def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: """The menu bar asks for the cursor's own target, which is the standing selection.""" panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 2) - target = panel._menu_target(cursor) + target = panel.cursor_target() + assert target is not None assert target.region == panel._input_state.region + def test_a_table_holding_no_cursor_names_no_target(self) -> None: + assert _order_panel(Gestures()).cursor_target() is None + def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: panel = _order_panel(Gestures()) cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION) panel._input_state = OrderInputState(cursor=cursor) - target = panel._menu_target(cursor) + target = panel.cursor_target() + assert target is not None assert target.region == OrderRegion( first_row=PULSE1_ROW, last_row=PULSE1_ROW, @@ -403,7 +414,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_order_state() selection = panel._input_state.region - panel._add_block_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) for item in order_recorder.items: item.callback() @@ -415,7 +426,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder gestures = Gestures() panel = _order_panel(gestures) - panel._add_block_items(panel._menu_target(_order_cell(None))) + panel._add_block_items(panel._target_at(_order_cell(None))) order_recorder.items[PASTE_ITEM].callback() assert gestures.pasted == [OrderCell(generator=None, position=CLICKED_POSITION)] @@ -423,7 +434,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder def test_paste_awaits_a_copy(self, order_recorder: _MenuRecorder) -> None: panel = _order_panel(Gestures(), can_paste=False) - panel._add_block_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) assert order_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in order_recorder.items] == [True, True, False, True] @@ -434,7 +445,7 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _order_panel(Gestures()) - panel._add_block_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) assert [item.label for item in order_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] @@ -448,7 +459,7 @@ def test_the_tracker_action_set_opens_with_the_clipboard_items( ) -> None: panel = _tracker_panel(Gestures()) - panel._add_action_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) + panel._add_action_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) labels = [item.label for item in tracker_recorder.items] assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] @@ -460,7 +471,7 @@ def test_the_order_action_set_opens_with_the_clipboard_items( ) -> None: panel = _order_panel(Gestures()) - panel._add_action_items(panel._menu_target(_order_cell(GeneratorName.PULSE1))) + panel._add_action_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) labels = [item.label for item in order_recorder.items] assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] @@ -473,7 +484,7 @@ class TestMenuItemOrder: def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures()) - panel._add_block_items(panel._menu_target(_tracker_cell(GeneratorName.PULSE1))) + panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) labels = [item.label for item in tracker_recorder.items] assert labels[COPY_ITEM] == "Copy" From 3fe19bfe03577036177a80d9c419429b6564ed06 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 10:39:39 +0200 Subject: [PATCH 084/152] Extracted: shared grid selection painting --- .../ui/elements/table/selection.py | 74 +++++++ .../ui/panels/sequencer/order.py | 44 ++--- .../ui/panels/sequencer/tracker.py | 46 ++--- .../view_model/sequencer/region.py | 12 -- .../ui/elements/table/test_selection.py | 184 ++++++++++++++++++ .../sequencer/input/test_order_input.py | 1 - .../sequencer/input/test_tracker_input.py | 1 - .../panels/sequencer/test_selection_drag.py | 30 ++- .../view_model/sequencer/test_region.py | 10 +- 9 files changed, 297 insertions(+), 105 deletions(-) create mode 100644 src/sampletones_application/ui/elements/table/selection.py create mode 100644 tests/unit/sampletones_application/ui/elements/table/test_selection.py diff --git a/src/sampletones_application/ui/elements/table/selection.py b/src/sampletones_application/ui/elements/table/selection.py new file mode 100644 index 000000000..14729cabf --- /dev/null +++ b/src/sampletones_application/ui/elements/table/selection.py @@ -0,0 +1,74 @@ +from collections.abc import Hashable +from typing import Callable, FrozenSet, Generic, Optional, TypeVar + +import dearpygui.dearpygui as dpg + +from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.elements.table.drag import DragReach, DragSelection +from sampletones_shared.types.application import Sender + +KeyT = TypeVar("KeyT", bound=Hashable) + + +class TableSelection(Generic[KeyT]): + """The selection a table shows, and the pointer gesture that draws it. + + A grid states which of its cells the selection covers, in whatever coordinates it selects in; + which of them stand painted, and how far a held pointer has carried, are held here. A selected + cell is drawn by the selectable's own selected state, which the table's theme colours, so a + repaint reaches only the cells whose membership changed. + """ + + def __init__( + self, + *, + cells: EditableCells[KeyT], + cell_at: Callable[[], Optional[KeyT]], + covered: Callable[[], FrozenSet[KeyT]], + ) -> None: + self._cells = cells + self._covered = covered + self._drag: DragSelection[KeyT] = DragSelection(cells=cells, cell_at=cell_at) + self._painted: FrozenSet[KeyT] = frozenset() + + def hold(self, widget: Sender) -> Optional[DragReach[KeyT]]: + """How far a held pointer has carried, which is what a drag grows the selection out to.""" + return self._drag.hold(widget) + + def claims_click(self, sender: Sender, key: KeyT) -> bool: + """Whether the click on a cell ends a drag, which the drag then takes as its own. + + DearPyGui toggles a selectable as it reports the click, so the cell is released here and + dropped from what stands painted: the repaint that follows is what states whether the cell + belongs to the selection. + """ + dpg.set_value(sender, False) + self._painted -= {key} + if not self._drag.claims_click(): + return False + + self.repaint() + return True + + def drop_gesture(self) -> None: + """Drops the gesture in hand, the selection standing as it is.""" + self._drag.clear() + + def repaint(self) -> None: + """Marks the cells the selection now covers and releases the ones it has left.""" + covered = self._covered() + for key in self._painted ^ covered: + widget = self._cells.widget(key) + if widget is not None: + dpg.set_value(widget, key in covered) + + self._painted = covered + + def reset(self) -> None: + """Forgets the selection and the gesture, which is what a rebuilt table asks for. + + The cells a selection stood on belong to the body being replaced, so the paint is forgotten + with them and the grid states its selection onto the new cells afresh. + """ + self._drag.clear() + self._painted = frozenset() diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 23d54995e..f651783f4 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -39,7 +39,7 @@ ) from sampletones_application.ui.elements.table.caret import CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells, pending_label -from sampletones_application.ui.elements.table.drag import DragSelection +from sampletones_application.ui.elements.table.selection import TableSelection from sampletones_application.ui.panels.sequencer.channels import ( ChannelMenuLabels, ChannelSwitch, @@ -142,10 +142,10 @@ def __init__( self._position_count: int = 0 self._order: EditableCells[OrderKey] = EditableCells() self._input_state: OrderInputState = OrderInputState() - self._selection: FrozenSet[OrderKey] = frozenset() - self._drag: DragSelection[OrderKey] = DragSelection( + self._selection: TableSelection[OrderKey] = TableSelection( cells=self._order, cell_at=self._cell_at, + covered=self._selected_cells, ) self._highlighted: Optional[OrderCursor] = None self._highlighted_column: Optional[int] = None @@ -398,7 +398,7 @@ def deselect_cell(self) -> None: self._clear_cursor_highlight() self._clear_column_highlight() self._input_state = OrderInputState() - self._repaint_selection() + self._selection.repaint() self._update_caret() if cursor is not None: @@ -487,8 +487,7 @@ def _rebuild_table( dpg_delete_item(TAG_SEQUENCER_ORDER_TABLE) self._highlighted = None self._highlighted_column = None - self._selection = frozenset() - self._drag.clear() + self._selection.reset() self._order.reset(cell_values) self._position_count = view_model.position_count self._build_table(view_model.position_count) @@ -754,20 +753,6 @@ def _selected_cells(self) -> FrozenSet[OrderKey]: return frozenset(keys) - def _repaint_selection(self) -> None: - """Marks the cells the selection now covers and releases the ones it has left. - - A selected cell is drawn by the selectable's own selected state, which the order table's - theme colours, so a repaint reaches only the cells whose membership actually changed. - """ - selected = self._selected_cells() - for key in self._selection ^ selected: - widget = self._order.widget(key) - if widget is not None: - dpg.set_value(widget, key in selected) - - self._selection = selected - def _clear_cursor_highlight(self) -> None: if self._highlighted is None: return @@ -834,7 +819,7 @@ def _apply_state( if old is None or old.position != new.position: self.call(self.on_frame_selected, new.position) - self._repaint_selection() + self._selection.repaint() self._update_caret() self._refresh_remove_enabled() @@ -869,17 +854,10 @@ def _on_cell_clicked( ) -> None: """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held. - The click leaves the selectable holding whatever DearPyGui toggled it to, so the cell is - released here and its membership dropped: the repaint that follows is what states whether - the cell the user clicked belongs to the selection. - - A drag that comes back to the cell it started from ends on a click, and that click is the - end of the drag rather than a gesture of its own, so it leaves the selection standing. + A drag that comes back to the cell it started from ends on a click, and the selection takes + that click as the end of the drag, so the range dragged out stands and the cursor with it. """ - dpg.set_value(sender, False) - self._selection -= {user_data} - if self._drag.claims_click(): - self._repaint_selection() + if self._selection.claims_click(sender, user_data): return state = self._committed_state() @@ -897,7 +875,7 @@ def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: The gesture states how far the pointer has carried: a plain drag anchors at the cell the press landed on, and one whose press held Shift carries the selection already standing. """ - reach = self._drag.hold(app_data) + reach = self._selection.hold(app_data) if reach is None: return @@ -914,7 +892,7 @@ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: reaches this panel ahead of the click the cell itself reports: a drag that comes back to the cell it started from would otherwise have its selection taken down by its own click. """ - self._drag.clear() + self._selection.drop_gesture() def _cell_at(self) -> Optional[OrderKey]: """The cell the pointer stands on, clamped to the table the order lays out. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index 9ab41c57a..b9f3319f2 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -32,7 +32,7 @@ from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.table.caret import CaretOverlay from sampletones_application.ui.elements.table.cells import EditableCells -from sampletones_application.ui.elements.table.drag import DragSelection +from sampletones_application.ui.elements.table.selection import TableSelection from sampletones_application.ui.panels.sequencer import display as tracker_display from sampletones_application.ui.panels.sequencer.channels import ( ChannelMenuLabels, @@ -170,10 +170,10 @@ def __init__( self._painted_row: Optional[int] = None self._follows_playing_row: bool = False self._input_state: TrackerInputState = TrackerInputState() - self._selection: FrozenSet[CellKey] = frozenset() - self._drag: DragSelection[CellKey] = DragSelection( + self._selection: TableSelection[CellKey] = TableSelection( cells=self._editable_cells, cell_at=self._cell_at, + covered=self._selected_cells, ) self._subcolumn_themes: Dict[SubColumn, int] = {} self._muted_subcolumn_themes: Dict[SubColumn, int] = {} @@ -491,8 +491,7 @@ def _rebuild_table( """ dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) self._input_state = self._input_state.collapse() - self._selection = frozenset() - self._drag.clear() + self._selection.reset() self._editable_cells.reset(cell_values) self._build_table(view_model) self.repaint() @@ -823,7 +822,7 @@ def _update_cursor(self) -> None: else: self._input_state = TrackerInputState() - self._repaint_selection() + self._selection.repaint() self._update_caret() def deselect_cell(self) -> None: @@ -831,7 +830,7 @@ def deselect_cell(self) -> None: if cursor is not None: self._input_state = TrackerInputState() self._remove_cell_highlight(cursor.row, cursor.generator) - self._repaint_selection() + self._selection.repaint() self._update_caret() @@ -858,7 +857,7 @@ def _apply_state(self, new_state: TrackerInputState) -> None: if new_pos != old_pos and new_cursor is not None: self.call(self.on_cell_selected) - self._repaint_selection() + self._selection.repaint() self._update_caret() def update_samples(self, view_model: SequencerSamplesViewModel) -> None: @@ -1049,20 +1048,6 @@ def _selected_cells(self) -> FrozenSet[CellKey]: return frozenset(keys) - def _repaint_selection(self) -> None: - """Marks the cells the selection now covers and releases the ones it has left. - - A selected cell is drawn by the selectable's own selected state, which the pattern table's - theme colours, so a repaint reaches only the cells whose membership actually changed. - """ - selected = self._selected_cells() - for key in self._selection ^ selected: - widget = self._editable_cells.widget(key) - if widget is not None: - dpg.set_value(widget, key in selected) - - self._selection = selected - def _remove_cell_highlight( self, row_index: int, @@ -1088,17 +1073,10 @@ def _on_cell_clicked( ) -> None: """Places the cursor on the clicked cell, or carries a selection out to it while Shift is held. - The click leaves the selectable holding whatever DearPyGui toggled it to, so the cell is - released here and its membership dropped: the repaint that follows is what states whether - the cell the user clicked belongs to the selection. - - A drag that comes back to the cell it started from ends on a click, and that click is the - end of the drag rather than a gesture of its own, so it leaves the selection standing. + A drag that comes back to the cell it started from ends on a click, and the selection takes + that click as the end of the drag, so the range dragged out stands and the cursor with it. """ - dpg.set_value(sender, False) - self._selection -= {user_data} - if self._drag.claims_click(): - self._repaint_selection() + if self._selection.claims_click(sender, user_data): return state = self._committed_state() @@ -1116,7 +1094,7 @@ def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: The gesture states how far the pointer has carried: a plain drag anchors at the cell the press landed on, and one whose press held Shift carries the selection already standing. """ - reach = self._drag.hold(app_data) + reach = self._selection.hold(app_data) if reach is None: return @@ -1133,7 +1111,7 @@ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: reaches this panel ahead of the click the cell itself reports: a drag that comes back to the cell it started from would otherwise have its selection taken down by its own click. """ - self._drag.clear() + self._selection.drop_gesture() def _cell_at(self) -> Optional[CellKey]: """The cell the pointer stands on, clamped to the grid the shown frame lays out. diff --git a/src/sampletones_application/view_model/sequencer/region.py b/src/sampletones_application/view_model/sequencer/region.py index 25dbccd41..c4f8de5b7 100644 --- a/src/sampletones_application/view_model/sequencer/region.py +++ b/src/sampletones_application/view_model/sequencer/region.py @@ -49,10 +49,6 @@ def _validate_rows(self) -> Self: return self - @property - def row_count(self) -> int: - return self.last_row - self.first_row + 1 - @property def rows(self) -> range: return range(self.first_row, self.last_row + 1) @@ -79,10 +75,6 @@ def _validate_slots(self) -> Self: return self - @property - def slot_count(self) -> int: - return self.last_slot - self.first_slot + 1 - @property def slots(self) -> Tuple[TrackerSlot, ...]: """The slots the region covers, each as the column and subcolumn it addresses.""" @@ -118,10 +110,6 @@ def _validate_positions(self) -> Self: return self - @property - def position_count(self) -> int: - return self.last_position - self.first_position + 1 - @property def positions(self) -> range: return range(self.first_position, self.last_position + 1) diff --git a/tests/unit/sampletones_application/ui/elements/table/test_selection.py b/tests/unit/sampletones_application/ui/elements/table/test_selection.py new file mode 100644 index 000000000..6a19fb8a1 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/table/test_selection.py @@ -0,0 +1,184 @@ +from typing import Dict, FrozenSet, List, Optional, Set, Tuple + +import pytest + +from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.elements.table.selection import TableSelection +from sampletones_application.utils.gui.keyboard.modifiers import Modifier + +Key = Tuple[int, int] + +ORIGIN: Key = (2, 1) +REACHED: Key = (5, 3) +FORGOTTEN: Key = (9, 9) +WIDGETS: Dict[Key, int] = {ORIGIN: 101, REACHED: 202} + + +class _Grid: + """A grid stating what its selection covers, and recording what was painted on it.""" + + def __init__(self, covered: Set[Key]) -> None: + self.covered = covered + self.painted: List[Tuple[int, bool]] = [] + self.cell: Optional[Key] = REACHED + + def covers(self) -> FrozenSet[Key]: + return frozenset(self.covered) + + +def _selection( + monkeypatch: pytest.MonkeyPatch, + covered: Set[Key], +) -> Tuple[TableSelection[Key], _Grid]: + cells: EditableCells[Key] = EditableCells() + for key, widget in WIDGETS.items(): + cells.register(key, widget) + + grid = _Grid(covered) + monkeypatch.setattr( + "sampletones_application.ui.elements.table.selection.dpg.set_value", + lambda widget, value: grid.painted.append((widget, value)), + ) + monkeypatch.setattr( + "sampletones_application.ui.elements.table.drag.capture_modifiers", + lambda: set(), + ) + return ( + TableSelection(cells=cells, cell_at=lambda: grid.cell, covered=grid.covers), + grid, + ) + + +def _drag_out(selection: TableSelection[Key], widget: int) -> None: + """Carries a press out to another cell, which is what turns it into a drag.""" + selection.hold(widget) + selection.hold(widget) + + +class TestRepaint: + """A repaint reaches the cells whose membership changed, and leaves the rest standing.""" + + def test_the_cells_now_covered_are_marked(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + + selection.repaint() + + assert grid.painted == [(WIDGETS[ORIGIN], True)] + + def test_a_cell_the_selection_has_left_is_released(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + selection.repaint() + grid.painted.clear() + + grid.covered = {REACHED} + selection.repaint() + + assert sorted(grid.painted) == [(WIDGETS[ORIGIN], False), (WIDGETS[REACHED], True)] + + def test_a_cell_standing_as_it_was_is_left_alone(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + selection.repaint() + grid.painted.clear() + + selection.repaint() + + assert grid.painted == [] + + def test_a_cell_the_cache_forgot_is_passed_over(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A region names cells of the grid, so a repaint reaches those the cache holds a widget for.""" + selection, grid = _selection(monkeypatch, covered={FORGOTTEN}) + + selection.repaint() + + assert grid.painted == [] + + +class TestClick: + """A click releases the cell DearPyGui toggled, and a drag takes the click that ends it.""" + + def test_a_click_releases_the_selectable(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, grid = _selection(monkeypatch, covered=set()) + + claimed = selection.claims_click(WIDGETS[ORIGIN], ORIGIN) + + assert claimed is False + assert grid.painted == [(WIDGETS[ORIGIN], False)] + + def test_a_clicked_cell_the_selection_covers_is_marked_again(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The click leaves the cell released, so the repaint after it states the membership again.""" + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + selection.repaint() + grid.painted.clear() + + selection.claims_click(WIDGETS[ORIGIN], ORIGIN) + selection.repaint() + + assert grid.painted == [(WIDGETS[ORIGIN], False), (WIDGETS[ORIGIN], True)] + + def test_a_drag_takes_the_click_that_ends_it(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + _drag_out(selection, WIDGETS[ORIGIN]) + grid.painted.clear() + + claimed = selection.claims_click(WIDGETS[ORIGIN], ORIGIN) + + assert claimed is True + assert grid.painted == [(WIDGETS[ORIGIN], False), (WIDGETS[ORIGIN], True)] + + def test_a_second_click_stands_on_its_own(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The drag ends with the click it was claimed by, so the click after it places a cursor.""" + selection, _ = _selection(monkeypatch, covered={ORIGIN}) + _drag_out(selection, WIDGETS[ORIGIN]) + selection.claims_click(WIDGETS[ORIGIN], ORIGIN) + + assert selection.claims_click(WIDGETS[ORIGIN], ORIGIN) is False + + +class TestGestureAndReset: + """The gesture in hand and the selection painted are dropped by different callers.""" + + def test_dropping_the_gesture_leaves_the_selection_painted(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + _drag_out(selection, WIDGETS[ORIGIN]) + selection.repaint() + grid.painted.clear() + + selection.drop_gesture() + selection.repaint() + + assert selection.claims_click(WIDGETS[ORIGIN], ORIGIN) is False + assert grid.painted == [(WIDGETS[ORIGIN], False)] + + def test_a_reset_forgets_what_stood_painted(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A rebuilt table holds cells of its own, so the selection is marked onto them afresh.""" + selection, grid = _selection(monkeypatch, covered={ORIGIN}) + selection.repaint() + grid.painted.clear() + + selection.reset() + selection.repaint() + + assert grid.painted == [(WIDGETS[ORIGIN], True)] + + def test_a_reset_drops_the_gesture_in_hand(self, monkeypatch: pytest.MonkeyPatch) -> None: + selection, _ = _selection(monkeypatch, covered=set()) + _drag_out(selection, WIDGETS[ORIGIN]) + + selection.reset() + + assert selection.claims_click(WIDGETS[ORIGIN], ORIGIN) is False + + +def test_a_shift_press_carries_the_selection_out(monkeypatch: pytest.MonkeyPatch) -> None: + """The reach a hold reports is the drag's own, which the grid turns into its selection.""" + selection, grid = _selection(monkeypatch, covered=set()) + monkeypatch.setattr( + "sampletones_application.ui.elements.table.drag.capture_modifiers", + lambda: {Modifier.SHIFT}, + ) + + selection.hold(WIDGETS[ORIGIN]) + reach = selection.hold(WIDGETS[ORIGIN]) + + assert reach is not None + assert (reach.origin, reach.reached, reach.extends) == (ORIGIN, grid.cell, True) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index 93a43a4b7..f41409204 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -54,7 +54,6 @@ def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: region = extended.region assert region is not None assert (region.first_position, region.last_position) == (2, 3) - assert region.position_count == 2 def test_extending_leftwards_names_the_same_region_as_rightwards(self) -> None: leftwards = _state(position=3).extend_position(-1, POSITION_COUNT).region diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index e2c0dbb49..27bec8f2c 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -51,7 +51,6 @@ def test_the_first_extend_anchors_the_cell_it_came_from(self) -> None: region = extended.region assert region is not None assert (region.first_row, region.last_row) == (4, 5) - assert region.row_count == 2 def test_extending_upwards_names_the_same_region_as_downwards(self) -> None: """The bounds are ordered by the region, so the direction of the drag leaves no trace.""" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py index 700e17a4f..ca00eb80c 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -1,4 +1,4 @@ -from typing import List, Optional, Tuple, Union +from typing import List, Optional, Tuple import pytest @@ -11,7 +11,7 @@ PALETTES_DIRECTORY, ) from sampletones_application.ui.elements.table.cells import EditableCells -from sampletones_application.ui.elements.table.drag import DragSelection +from sampletones_application.ui.elements.table.selection import TableSelection from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, @@ -71,9 +71,10 @@ def _tracker( panel._current_row_count = ROW_COUNT panel._editable_cells = EditableCells() panel._editable_cells.register(ORIGIN_CELL, ORIGIN_WIDGET) - panel._drag = DragSelection( + panel._selection = TableSelection( cells=panel._editable_cells, cell_at=lambda: panel._cell_at(), + covered=panel._selected_cells, ) states: List[TrackerInputState] = [] @@ -93,9 +94,10 @@ def _order( panel._position_count = POSITION_COUNT panel._order = EditableCells() panel._order.register(ORIGIN_ENTRY, ORIGIN_WIDGET) - panel._drag = DragSelection( + panel._selection = TableSelection( cells=panel._order, cell_at=lambda: panel._cell_at(), + covered=panel._selected_cells, ) states: List[OrderInputState] = [] @@ -105,16 +107,10 @@ def _order( return panel, states -def _silence_click( - monkeypatch: pytest.MonkeyPatch, - panel: Union[GUISequencerTrackerPanel, GUISequencerOrderPanel], - module: str, -) -> None: - """Lets a click run over a grid that was never drawn: the cell releases, the repaint stands in.""" - panel._selection = frozenset() - monkeypatch.setattr(panel, "_repaint_selection", lambda: None) +def _silence_click(monkeypatch: pytest.MonkeyPatch) -> None: + """Lets a click run over a grid that was never drawn: the cells it releases hold no widget.""" monkeypatch.setattr( - f"sampletones_application.ui.panels.sequencer.{module}.dpg.set_value", + "sampletones_application.ui.elements.table.selection.dpg.set_value", lambda widget, value: None, ) @@ -211,7 +207,7 @@ def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.Monkey """The press starting a gesture ends the one before it, so its click places the cursor.""" reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) panel, states = _tracker(monkeypatch, reached=reached) - _silence_click(monkeypatch, panel, "tracker") + _silence_click(monkeypatch) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -228,7 +224,7 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( """A drag returning to its own cell releases there, and that release reports a click.""" reached: CellKey = (5, GeneratorName.PULSE1, SubColumn.TRANSPOSE) panel, states = _tracker(monkeypatch, reached=reached) - _silence_click(monkeypatch, panel, "tracker") + _silence_click(monkeypatch) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -324,7 +320,7 @@ def test_a_new_press_ends_the_gesture_before_it(self, monkeypatch: pytest.Monkey """The press starting a gesture ends the one before it, so its click places the cursor.""" reached: OrderKey = (GeneratorName.PULSE2, 4) panel, states = _order(monkeypatch, reached=reached) - _silence_click(monkeypatch, panel, "order") + _silence_click(monkeypatch) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) @@ -340,7 +336,7 @@ def test_the_click_ending_a_drag_leaves_the_selection_alone( ) -> None: reached: OrderKey = (GeneratorName.PULSE2, 4) panel, states = _order(monkeypatch, reached=reached) - _silence_click(monkeypatch, panel, "order") + _silence_click(monkeypatch) panel._on_cell_held(0, ORIGIN_WIDGET) panel._on_cell_held(0, ORIGIN_WIDGET) diff --git a/tests/unit/sampletones_application/view_model/sequencer/test_region.py b/tests/unit/sampletones_application/view_model/sequencer/test_region.py index fdf7f284d..ebd29edab 100644 --- a/tests/unit/sampletones_application/view_model/sequencer/test_region.py +++ b/tests/unit/sampletones_application/view_model/sequencer/test_region.py @@ -21,8 +21,6 @@ class TestTrackerRegion: def test_a_single_cell_region_covers_that_cell(self) -> None: region = TrackerRegion(first_row=3, last_row=3, first_slot=4, last_slot=4) - assert region.row_count == 1 - assert region.slot_count == 1 assert tuple(region.rows) == (3,) assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.TRANSPOSE),) @@ -38,8 +36,8 @@ def test_the_slots_read_as_the_columns_and_subcolumns_they_address(self) -> None def test_a_region_spans_the_whole_axis(self) -> None: region = TrackerRegion(first_row=0, last_row=63, first_slot=0, last_slot=SLOT_COUNT - 1) - assert region.row_count == 64 - assert region.slot_count == SLOT_COUNT + assert tuple(region.rows) == tuple(range(64)) + assert region.slots == tuple(slot_from_flat(index) for index in range(SLOT_COUNT)) def test_inverted_rows_are_rejected(self) -> None: with pytest.raises(ValidationError): @@ -63,8 +61,6 @@ class TestOrderRegion: def test_a_single_cell_region_covers_that_cell(self) -> None: region = OrderRegion(first_row=0, last_row=0, first_position=2, last_position=2) - assert region.row_count == 1 - assert region.position_count == 1 assert region.generators == (None,) assert tuple(region.positions) == (2,) @@ -82,7 +78,7 @@ def test_a_region_spans_the_whole_channel_axis(self) -> None: ) assert region.generators == CHANNEL_AXIS - assert region.position_count == 8 + assert tuple(region.positions) == tuple(range(8)) def test_inverted_positions_are_rejected(self) -> None: with pytest.raises(ValidationError): From 47b798a0b4fd0e592cbda533c31cf5f572e0aa64 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 11:02:29 +0200 Subject: [PATCH 085/152] Documented: the focus-aware Edit menu --- docs/development/architecture.md | 2 +- docs/development/sequencer-blocks.md | 62 +++++++++++++++++++++------- docs/guide/interface.md | 9 ++-- 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 8aa730c5b..68ecab450 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -292,7 +292,7 @@ They read the source as an AST through the shared layer in `sampletones_shared/m There are two coordinator kinds: -*Domain coordinators* manage a cross-cutting concern that spans the whole application lifecycle — e.g. `ProjectCoordinator` (project file I/O, save confirmations) or `PlaybackRouter` (the single transport over the shared output device, acting on the active tab's source or the engaged one — see `docs/development/playback.md`). +*Domain coordinators* manage a cross-cutting concern that spans the whole application lifecycle — e.g. `ProjectCoordinator` (project file I/O, save confirmations), `PlaybackRouter` (the single transport over the shared output device, acting on the active tab's source or the engaged one — see `docs/development/playback.md`), or `EditRouter` (the single edit surface behind the menu bar's Edit menu, which shows the actions of the grid holding the cursor — see `docs/development/sequencer-blocks.md`). *Tab coordinators* own everything for one tab: they instantiate its panels, logic objects, and tab-scoped services, wire their callbacks together, and provide `create_tab()` — the single method that builds the DPG widget tree for that tab. Tab coordinators present a narrow public API of intent-level methods (`set_input_path`, `display_reconstruction`, …) and keep their panels and logic objects private. diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index b32794318..2f053e55e 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -4,7 +4,8 @@ A **block** is a rectangle of one sequencer grid, lifted out of the song so it c written back somewhere else. Copy, cut, paste and delete are the four gestures over it, and both grids — the tracker's pattern rows and the order's frames — carry the same set. -This document states the rules those gestures follow. The layering they sit in is +This document states the rules those gestures follow, and how a grid's actions reach the +menus and the keyboard that fire them. The layering they sit in is [Architecture](architecture.md); the conventions the code is held to are the [coding guidelines](guidelines.md). @@ -87,26 +88,55 @@ Growth runs before the first write, so one history entry covers the appended fra the values in them, and a single undo takes both back. Delete keeps the order's length: emptied trailing frames stand as silent ones. -## What a gesture acts on +## A grid declares its actions once -- **From the keyboard**: the selection, or — with none up — the cursor's own cell. - `region_at` on the shared input state is where that fallback lives, so copying one cell - needs no selection made first. -- **From a context menu**: the selection when the menu was raised inside it, and the - clicked cell otherwise (the same `region_at`, over `Region.covers`). A paste from a menu - anchors at the clicked cell; a paste from the keyboard anchors at the cursor. -- **`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot - share a combination inside a shortcut category, so this branch is the route; it also - matches tracker convention. +Where they are shown is decided by whoever asks for them. Each grid builds its whole +action set from one **target** — the cell a gesture is aimed at, paired with the region +that gesture acts on — and three doors resolve that target their own way: -Copy is wired straight through rather than through `_undoable` — it mutates nothing, so a -transaction over it would record an entry with nothing to restore. Cut, delete and paste -each record exactly one entry, and none of them coalesces: a block gesture is already a -whole gesture, and folding two consecutive pastes would hide a repeat the reader performed -on purpose. +| Door | Aims at | Anchors a paste at | +|------|---------|--------------------| +| The keyboard | the cursor's cell | the cursor | +| A context menu | the cell it was raised on | the clicked cell | +| The menu bar's **Edit** menu | the cursor's cell | the cursor | + +The region behind a target is `region_at` on the shared input state: the selection when the +cell falls inside it (`Region.covers`), and the cell alone otherwise. So copying one cell +needs no selection made first, and a menu raised inside a selection acts on the whole of it. + +One builder means an action added to a grid appears at every door, and the accelerator +**Edit** prints is the one that grid answers to, since a binding is declared once and every +reader of it reads that entry ([Architecture](architecture.md), principle 12). + +`EditRouter` (`coordinators/edit/`) is the menu-side counterpart of the `KeyRouter` the +keyboard runs through. Each surface states whether it owns the editing gestures at this +moment — the same predicate its key scope answers with, so the menu offers what the next +press would reach — and the router asks the one that does to build its items into the menu +the bar has opened. It holds no state, resolving the surface on each call, so the menu +states the actions of whoever holds the cursor at the moment it is opened. The bar names the +clipboard four greyed out when no grid answers, which is how a reader working from the menus +learns the commands exist. + +**`Del`** carries two meanings, resolved by whether a selection stands. Two ids cannot share +a combination inside a shortcut category, so this branch is the route; it also matches +tracker convention. + +## One gesture, one history entry + +Cut, delete and paste each record exactly one entry, whichever door fired them, and none of +them coalesces: a block gesture is already a whole gesture, and folding two consecutive +pastes would hide a repeat the reader performed on purpose. Copy runs outside a transaction, +since it mutates nothing. ## Dragging a range out +Both grids compose one `TableSelection` (`ui/elements/table/selection.py`), which holds what +stands painted and the drag gesture that draws it. The grid states which of its cells the +selection covers, in its own coordinates; the repaint that follows reaches the cells whose +membership changed, marking each through the selectable's own selected state, which the +table's theme colours. A rebuilt table asks for a reset, since the cells a selection stood on +belong to the body that was replaced. + Both panels read the cell under a held pointer off their own geometry, because DearPyGui reports no hover for the cells a held pointer passes over. A drag carried past an edge reads as the edge, so it selects up to it. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 275784de9..12bc78057 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -75,10 +75,11 @@ instructions data** to re-read the catalogue; selecting an entry in the The menu bar and status bar sit outside the tabs. -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**. +Each menu covers one kind of work: **File** for projects, **Edit** for undo, redo, +and what you can do where your cursor stands, **Reconstruction** for the current +reconstruction and its exports, **Playback** for playing and for muting the +sequencer's channels, **View** for settings and the window, and **Help** for +**About**. Two items write audio you can play anywhere: **Reconstruction ▸ Export to WAV...** for the reconstruction on show, and **File ▸ Render song...** (`Ctrl+Shift+E`) for From 1dd316741d0aabc1520387b6f33cc543ea168062 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 11:38:57 +0200 Subject: [PATCH 086/152] Added: transpose and volume over a selection --- docs/development/sequencer-blocks.md | 18 + docs/guide/sequencer.md | 16 + .../categories/elements/settings.py | 8 + .../coordinators/tabs/sequencer.py | 23 +- .../logic/sequencer/history_detail.py | 48 ++- .../logic/sequencer/tracker/__init__.py | 2 + .../logic/sequencer/tracker/adjuster.py | 57 ++++ .../logic/sequencer/tracker/tracker.py | 32 -- .../ui/panels/sequencer/tracker.py | 169 +++++++--- .../utils/gui/shortcuts/ids.py | 8 + .../view_model/sequencer/region.py | 10 + .../keybindings/default.yaml | 8 + src/sampletones_config/keybindings/macos.yaml | 8 + src/sampletones_config/lang/en.yaml | 8 + .../logic/sequencer/test_history_detail.py | 32 +- .../logic/sequencer/tracker/test_adjuster.py | 312 ++++++++++++++++++ .../logic/sequencer/tracker/test_tracker.py | 61 ---- .../ui/panels/sequencer/test_block_keys.py | 58 +++- .../ui/panels/sequencer/test_block_menu.py | 16 +- .../sequencer/test_tracker_context_menu.py | 51 +-- .../utils/gui/shortcuts/test_shipped.py | 117 +++++++ 21 files changed, 870 insertions(+), 192 deletions(-) create mode 100644 src/sampletones_application/logic/sequencer/tracker/adjuster.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index 2f053e55e..78479db75 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -88,6 +88,19 @@ Growth runs before the first write, so one history entry covers the appended fra the values in them, and a single undo takes both back. Delete keeps the order's length: emptied trailing frames stand as silent ones. +## A shift reads the columns behind a region + +Transpose and volume move whole cells, while a region names its edges as subcolumns. A shift +therefore reads the columns a region covers (`TrackerRegion.columns`) and reaches each of their +channels once, at every row the region spans. Two consequences follow: a nudge raised with the +cursor on a volume subcolumn still moves that cell's transpose, and a region covering the sample +column together with a channel beneath it moves that channel a single step, since the sample column +stands for the channels a value typed in it writes to. + +Each cell reaches the grid through the single-cell adjustment that already governs it, the way a +pasted cell does, so a shift lands exactly the writes the same nudge repeated by hand would make — +the transpose and volume ranges included. + ## A grid declares its actions once Where they are shown is decided by whoever asks for them. Each grid builds its whole @@ -128,6 +141,11 @@ them coalesces: a block gesture is already a whole gesture, and folding two cons pastes would hide a repeat the reader performed on purpose. Copy runs outside a transaction, since it mutates nothing. +A shift coalesces, because a nudge is a step of one gesture rather than a whole one. The block it +covers is its coalescing target, so a streak over one selection leaves a single step to undo and a +shift after the cursor moves or the selection is reached out starts the next entry. Transpose and +volume count separately, each carrying its own action. + ## Dragging a range out Both grids compose one `TableSelection` (`ui/elements/table/selection.py`), which holds what diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 28a5287d4..de22cb8db 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -80,6 +80,22 @@ as it was. Emptying cells keeps the rows and frames they sit in, and every block action is one step in the history, so a single **Undo** takes it all back. +## Transposing and shading + +In the **Tracker**, transpose and volume move whatever the selection covers, so a +run of rows nudges together. + +| Key | Action | +|-----|--------| +| `Ctrl+Up` / `Ctrl+Down` | Transpose a semitone | +| `Ctrl+Shift+Up` / `Ctrl+Shift+Down` | Transpose an octave | +| `Alt+Up` / `Alt+Down` | Volume a step | +| `Alt+Shift+Up` / `Alt+Shift+Down` | Volume four steps | + +Control carries pitch, Alt carries volume, and Shift makes the step the bigger one. +With nothing selected they act on the cell the cursor stands on, and the same +commands sit on the right-click menu with these keys beside them. + ## Playing the song The transport below the grid plays the song, and the keyboard drives playback diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index b4a04dac5..7b96b8e02 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -129,6 +129,14 @@ class KeybindingActionElements(AbstractElement): TRACKER_COPY_BLOCK = "tracker_copy_block" TRACKER_CUT_BLOCK = "tracker_cut_block" TRACKER_PASTE_BLOCK = "tracker_paste_block" + TRACKER_TRANSPOSE_UP = "tracker_transpose_up" + TRACKER_TRANSPOSE_DOWN = "tracker_transpose_down" + TRACKER_TRANSPOSE_OCTAVE_UP = "tracker_transpose_octave_up" + TRACKER_TRANSPOSE_OCTAVE_DOWN = "tracker_transpose_octave_down" + TRACKER_VOLUME_UP = "tracker_volume_up" + TRACKER_VOLUME_DOWN = "tracker_volume_down" + TRACKER_VOLUME_UP_COARSE = "tracker_volume_up_coarse" + TRACKER_VOLUME_DOWN_COARSE = "tracker_volume_down_coarse" TRACKER_PAGE_UP = "tracker_page_up" TRACKER_PAGE_DOWN = "tracker_page_down" TRACKER_CLEAR_ROW = "tracker_clear_row" diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 2ed05068f..70df6d853 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -43,6 +43,7 @@ SequencerTrackerLogic, TrackerBlockReader, TrackerBlockWriter, + TrackerRegionAdjuster, ) from sampletones_application.logic.shared.tree import TreeLogic from sampletones_application.parameters.sequencer import SequencerTabParameters @@ -197,6 +198,7 @@ def __init__( self._clipboard: SequencerClipboard = SequencerClipboard() self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic) self._tracker_block_writer: TrackerBlockWriter = TrackerBlockWriter(self._sequencer_tracker_logic) + self._tracker_region_adjuster: TrackerRegionAdjuster = TrackerRegionAdjuster(self._sequencer_tracker_logic) self._order_block_reader: OrderBlockReader = OrderBlockReader(self._sequencer_order_logic) self._order_block_writer: OrderBlockWriter = OrderBlockWriter(self._sequencer_order_logic) self._sequencer_samples_logic: SequencerSamplesLogic = SequencerSamplesLogic( @@ -348,13 +350,13 @@ def _wire_tracker_callbacks(self) -> None: 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._sequencer_tracker_logic.adjust_cell_transpose, + self._tracker_region_adjuster.adjust_transpose, detail=self._history_detail.adjust_transpose, coalesce=self._adjustment_key, ) self._sequencer_tracker_panel.on_adjust_volume = self._undoable( HistoryAction.ADJUST_VOLUME, - self._sequencer_tracker_logic.adjust_cell_volume, + self._tracker_region_adjuster.adjust_volume, detail=self._history_detail.adjust_volume, coalesce=self._adjustment_key, ) @@ -711,11 +713,22 @@ def _cell_key( def _adjustment_key( self, - row_index: int, - generator: Optional[GeneratorName], + region: TrackerRegion, _delta: int, ) -> CoalesceKey: - return self._cell_key(row_index, generator) + """Identifies the cells an adjustment covers as one coalescing target. + + A streak of nudges over the same block reads as one entry, so holding a transpose key steps + the selection and leaves a single step to undo; moving the cursor or reaching the selection + out starts the next one. + """ + return ( + self._sequencer_tracker_logic.frame_index, + region.first_row, + region.last_row, + region.first_slot, + region.last_slot, + ) def _edit_row_key( self, diff --git a/src/sampletones_application/logic/sequencer/history_detail.py b/src/sampletones_application/logic/sequencer/history_detail.py index 125cc1816..32f1672b8 100644 --- a/src/sampletones_application/logic/sequencer/history_detail.py +++ b/src/sampletones_application/logic/sequencer/history_detail.py @@ -144,37 +144,23 @@ def clear_subcolumn( segments.append(self._subcolumn(subcolumn)) return tuple(segments) - def adjust_transpose( - self, - row_index: int, - generator: Optional[GeneratorName], - delta: int, - ) -> Segments: - affected = self._tracker_logic.relevant_generators(row_index) - segments = list(self._location(row_index, generator, affected)) - segments.append( + def adjust_transpose(self, region: TrackerRegion, delta: int) -> Segments: + """Reads as the cells a shift covers, followed by the semitones it moves them.""" + return ( + *self._tracker_region(region), self._segment(display_transpose(delta), HistoryDetailRole.TRANSPOSE), ) - return tuple(segments) - def adjust_volume( - self, - row_index: int, - generator: Optional[GeneratorName], - delta: int, - ) -> Segments: - affected = self._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) + def adjust_volume(self, region: TrackerRegion, delta: int) -> Segments: + """Reads as the cells a shift covers, followed by the steps it moves them.""" + return ( + *self._tracker_region(region), + self._segment(f"{delta:+d}", HistoryDetailRole.VOLUME), + ) def tracker_block(self, region: TrackerRegion) -> Segments: """Reads as the frame, the channels a block spans and the rows it covers.""" - return ( - self._frame(self._tracker_logic.frame_index), - self._channel(self._covered_channels({slot.generator for slot in region.slots})), - self._row_range(region.first_row, region.last_row), - ) + return self._tracker_region(region) def tracker_paste(self, cell: TrackerCell) -> Segments: """Reads as the cell a block was written from, the one place a paste chooses.""" @@ -319,6 +305,18 @@ def _edit_row_generators( return self._tracker_logic.relevant_generators(row_index) + def _tracker_region(self, region: TrackerRegion) -> Segments: + """Reads a rectangle of the tracker as its frame, the channels it spans and the rows it covers. + + Every gesture over a region reads the same way, so a block and a shift describe the cells + they reach in one form. + """ + return ( + self._frame(self._tracker_logic.frame_index), + self._channel(self._covered_channels(set(region.columns))), + self._row_range(region.first_row, region.last_row), + ) + def _location( self, row_index: int, diff --git a/src/sampletones_application/logic/sequencer/tracker/__init__.py b/src/sampletones_application/logic/sequencer/tracker/__init__.py index 5e3c9ecc3..1d2e89c82 100644 --- a/src/sampletones_application/logic/sequencer/tracker/__init__.py +++ b/src/sampletones_application/logic/sequencer/tracker/__init__.py @@ -1,3 +1,4 @@ +from .adjuster import TrackerRegionAdjuster from .block import BlockNote, TrackerBlock from .reader import TrackerBlockReader from .tracker import SequencerTrackerLogic @@ -9,4 +10,5 @@ "TrackerBlock", "TrackerBlockReader", "TrackerBlockWriter", + "TrackerRegionAdjuster", ] diff --git a/src/sampletones_application/logic/sequencer/tracker/adjuster.py b/src/sampletones_application/logic/sequencer/tracker/adjuster.py new file mode 100644 index 000000000..3e77e4b5f --- /dev/null +++ b/src/sampletones_application/logic/sequencer/tracker/adjuster.py @@ -0,0 +1,57 @@ +from typing import Iterator, List, Optional, Tuple + +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_core.constants.enums import GeneratorName + +from .tracker import SequencerTrackerLogic + + +class TrackerRegionAdjuster: + """Shifts transpose and volume across the cells a region covers. + + A region names its edges as subcolumns while these two gestures act on whole cells, so an + adjustment reads the columns behind the region and reaches each of their channels once. The + sample column stands for the channels a value typed in it writes to, which is what keeps a + region covering it and a channel beneath it moving that channel a single step. + + Each cell reaches the grid through the single-cell adjustment that already governs it, so a + shift over a region lands exactly the writes the same nudge repeated by hand would make. + """ + + def __init__(self, tracker_logic: SequencerTrackerLogic) -> None: + self._tracker = tracker_logic + + def adjust_transpose(self, region: TrackerRegion, delta: int) -> None: + """Shifts every covered cell's transpose by ``delta`` semitones.""" + for row_index, generator in self._cells(region): + self._tracker.adjust_transpose(generator, row_index, delta) + + def adjust_volume(self, region: TrackerRegion, delta: int) -> None: + """Shifts every covered cell's volume by ``delta``.""" + for row_index, generator in self._cells(region): + self._tracker.adjust_volume(generator, row_index, delta) + + def _cells( + self, + region: TrackerRegion, + ) -> Iterator[Tuple[int, GeneratorName]]: + """The channel cells a region reaches, row by row and each named once.""" + columns = region.columns + for row_index in region.rows: + for generator in self._channels(columns, row_index): + yield row_index, generator + + def _channels( + self, + columns: Tuple[Optional[GeneratorName], ...], + row_index: int, + ) -> List[GeneratorName]: + """The channels a row's columns reach, the sample column standing for the ones it governs.""" + channels: List[GeneratorName] = [] + for column in columns: + if column is None: + channels.extend(self._tracker.relevant_generators(row_index)) + else: + channels.append(column) + + return list(dict.fromkeys(channels)) diff --git a/src/sampletones_application/logic/sequencer/tracker/tracker.py b/src/sampletones_application/logic/sequencer/tracker/tracker.py index 9cf7c00ae..b9ad587ff 100644 --- a/src/sampletones_application/logic/sequencer/tracker/tracker.py +++ b/src/sampletones_application/logic/sequencer/tracker/tracker.py @@ -256,28 +256,6 @@ def set_cell_subcolumn( volume=volume, ) - def adjust_cell_transpose( - self, - row_index: int, - generator: Optional[GeneratorName], - delta: int, - ) -> None: - if generator is None: - self.adjust_sample_transpose(row_index, delta) - else: - self.adjust_transpose(generator, row_index, delta) - - def adjust_cell_volume( - self, - row_index: int, - generator: Optional[GeneratorName], - delta: int, - ) -> None: - if generator is None: - self.adjust_sample_volume(row_index, delta) - else: - self.adjust_volume(generator, row_index, delta) - def set_row( self, generator: GeneratorName, @@ -466,16 +444,6 @@ def adjust_volume( volume=self._current_volume(generator, row_index) + delta, ) - def adjust_sample_transpose(self, row_index: int, delta: int) -> None: - """Shifts transpose by ``delta`` across the sample column's channels.""" - for generator in self._subcolumn_generators(row_index): - self.adjust_transpose(generator, row_index, delta) - - def adjust_sample_volume(self, row_index: int, delta: int) -> None: - """Shifts volume by ``delta`` across the sample column's channels.""" - for generator in self._subcolumn_generators(row_index): - self.adjust_volume(generator, row_index, delta) - def row( self, generator: GeneratorName, diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index b9f3319f2..f960c4dd2 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -115,7 +115,7 @@ OnCellSelectedCallback = VoidCallback OnPlayFromRowCallback = Callable[[int], None] OnPlayFromFrameCallback = VoidCallback -OnAdjustCallback = Callable[[int, Optional[GeneratorName], int], None] +OnAdjustCallback = Callable[[TrackerRegion, int], None] OnChannelMuteToggledCallback = Callable[[GeneratorName], None] OnChannelSoloedCallback = Callable[[GeneratorName], None] OnBlockRegionCallback = Callable[[TrackerRegion], None] @@ -127,6 +127,63 @@ VOLUME_COARSE_STEP: Final[int] = (MAX_VOLUME + 1) // 4 PLAYHEAD_PAINT_FRAMES: Final[int] = 1 +AdjustAction = Tuple[SequencerTrackerElements, ShortcutId, int] +AdjustMenuCallback = Callable[[Sender, None, Tuple[TrackerRegion, int]], None] + +TRANSPOSE_ACTIONS: Final[Tuple[AdjustAction, ...]] = ( + ( + SequencerTrackerElements.CONTEXT_TRANSPOSE_UP, + ShortcutId.TRACKER_TRANSPOSE_UP, + SEMITONE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_TRANSPOSE_DOWN, + ShortcutId.TRACKER_TRANSPOSE_DOWN, + -SEMITONE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_UP, + ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP, + OCTAVE_SEMITONES, + ), + ( + SequencerTrackerElements.CONTEXT_TRANSPOSE_OCTAVE_DOWN, + ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN, + -OCTAVE_SEMITONES, + ), +) + +VOLUME_ACTIONS: Final[Tuple[AdjustAction, ...]] = ( + ( + SequencerTrackerElements.CONTEXT_VOLUME_UP, + ShortcutId.TRACKER_VOLUME_UP, + VOLUME_FINE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_VOLUME_DOWN, + ShortcutId.TRACKER_VOLUME_DOWN, + -VOLUME_FINE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_VOLUME_UP_COARSE, + ShortcutId.TRACKER_VOLUME_UP_COARSE, + VOLUME_COARSE_STEP, + ), + ( + SequencerTrackerElements.CONTEXT_VOLUME_DOWN_COARSE, + ShortcutId.TRACKER_VOLUME_DOWN_COARSE, + -VOLUME_COARSE_STEP, + ), +) + + +def _steps(actions: Tuple[AdjustAction, ...]) -> Dict[ShortcutId, int]: + return {shortcut_id: delta for _, shortcut_id, delta in actions} + + +TRANSPOSE_STEPS: Final[Dict[ShortcutId, int]] = _steps(TRANSPOSE_ACTIONS) +VOLUME_STEPS: Final[Dict[ShortcutId, int]] = _steps(VOLUME_ACTIONS) + class GUISequencerTrackerPanel(GUIPanel): def __init__( @@ -261,14 +318,9 @@ def label(element: SequencerTrackerElements) -> str: 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) + self._lbl_adjust: Dict[SequencerTrackerElements, str] = { + element: label(element) for element, _, _ in (*TRANSPOSE_ACTIONS, *VOLUME_ACTIONS) + } def _load_header_tooltips(self, language_manager: LanguageManager) -> None: """Reads the header tooltips, which name the click gestures the labels carry.""" @@ -1294,9 +1346,9 @@ def _add_action_items(self, target: TrackerTarget) -> None: callback=lambda: self.call(self.on_set_note_off, target.cell.row, target.cell.generator), ) dpg.add_separator() - self._add_transpose_items(target.cell) + self._add_transpose_items(target) dpg.add_separator() - self._add_volume_items(target.cell) + self._add_volume_items(target) dpg.add_separator() self._add_clear_items(target.cell) @@ -1346,30 +1398,31 @@ def _add_instrument_submenu(self, cell: TrackerCursor) -> None: callback=self._on_set_instrument_menu, ) - def _add_transpose_items(self, cell: TrackerCursor) -> None: - for label, delta in ( - (self._lbl_context_transpose_up, SEMITONE_STEP), - (self._lbl_context_transpose_down, -SEMITONE_STEP), - (self._lbl_context_transpose_octave_up, OCTAVE_SEMITONES), - (self._lbl_context_transpose_octave_down, -OCTAVE_SEMITONES), - ): - dpg.add_menu_item( - label=label, - user_data=(cell.row, cell.generator, delta), - callback=self._on_transpose_menu, - ) + def _add_transpose_items(self, target: TrackerTarget) -> None: + self._add_adjust_items(target, TRANSPOSE_ACTIONS, self._on_transpose_menu) - def _add_volume_items(self, cell: TrackerCursor) -> None: - for label, delta in ( - (self._lbl_context_volume_up, VOLUME_FINE_STEP), - (self._lbl_context_volume_down, -VOLUME_FINE_STEP), - (self._lbl_context_volume_up_coarse, VOLUME_COARSE_STEP), - (self._lbl_context_volume_down_coarse, -VOLUME_COARSE_STEP), - ): + def _add_volume_items(self, target: TrackerTarget) -> None: + self._add_adjust_items(target, VOLUME_ACTIONS, self._on_volume_menu) + + def _add_adjust_items( + self, + target: TrackerTarget, + actions: Tuple[AdjustAction, ...], + callback: AdjustMenuCallback, + ) -> None: + """Builds one axis of adjustment items, each shifting the cells its target covers. + + An adjustment acts on whole cells, so it reaches the columns the target's block covers and + the rows it spans: a nudge with a selection standing moves all of it, and one on a cell + alone moves that cell. Each item prints the key it answers to, since the action states its + label, its binding and its step in one entry. + """ + for element, shortcut_id, delta in actions: dpg.add_menu_item( - label=label, - user_data=(cell.row, cell.generator, delta), - callback=self._on_volume_menu, + label=self._lbl_adjust[element], + shortcut=self._shortcuts.display(shortcut_id), + user_data=(target.region, delta), + callback=callback, ) def _on_set_instrument_menu( @@ -1385,19 +1438,19 @@ def _on_transpose_menu( self, _sender: Sender, _app_data: None, - user_data: Tuple[int, Optional[GeneratorName], int], + user_data: Tuple[TrackerRegion, int], ) -> None: - row_index, generator, delta = user_data - self.call(self.on_adjust_transpose, row_index, generator, delta) + region, delta = user_data + self.call(self.on_adjust_transpose, region, delta) def _on_volume_menu( self, _sender: Sender, _app_data: None, - user_data: Tuple[int, Optional[GeneratorName], int], + user_data: Tuple[TrackerRegion, int], ) -> None: - row_index, generator, delta = user_data - self.call(self.on_adjust_volume, row_index, generator, delta) + region, delta = user_data + self.call(self.on_adjust_volume, region, delta) def _add_clear_items(self, cell: TrackerCursor) -> None: """Builds the three clear levels: the target's subcolumn, its whole channel cell, its whole row. @@ -1467,6 +1520,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._block_action(shortcut_id): return True + if self._adjust_action(shortcut_id): + return True + return self._edit_row(shortcut_id) def _move_cursor(self, shortcut_id: ShortcutId) -> bool: @@ -1542,6 +1598,41 @@ def _block_action(self, shortcut_id: ShortcutId) -> bool: return True + def _adjust_action(self, shortcut_id: ShortcutId) -> bool: + """Shifts the covered cells' transpose or volume, reporting whether the action was one of + the two axes. + + A press acts on the block the cursor stands in, which is the selection while one covers it + and the cursor's own cell otherwise — the target the menus resolve as well, so a key and a + menu item reach the same cells. + """ + transpose_step = TRANSPOSE_STEPS.get(shortcut_id) + if transpose_step is not None: + self._adjust_at_cursor(self.on_adjust_transpose, transpose_step) + return True + + volume_step = VOLUME_STEPS.get(shortcut_id) + if volume_step is not None: + self._adjust_at_cursor(self.on_adjust_volume, volume_step) + return True + + return False + + def _adjust_at_cursor( + self, + hook: Optional[OnAdjustCallback], + delta: int, + ) -> None: + """Raises an adjustment on the block the cursor stands in, the entry being typed landing first. + + Committing ahead of the shift is what lets a nudge carry the value the reader has just + finished typing, the rule the block gestures follow as well. + """ + self.commit_entry() + target = self.cursor_target() + if target is not None: + self.call(hook, target.region, delta) + def _edit_row(self, shortcut_id: ShortcutId) -> bool: """Empties the cell under the cursor or drops a partial entry, reporting whether the action was one of the cell edits. diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index a14de3b20..bfd7b9ef5 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -147,6 +147,14 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: TRACKER_COPY_BLOCK = ("TrackerCopyBlock", ShortcutCategory.TRACKER) TRACKER_CUT_BLOCK = ("TrackerCutBlock", ShortcutCategory.TRACKER) TRACKER_PASTE_BLOCK = ("TrackerPasteBlock", ShortcutCategory.TRACKER) + TRACKER_TRANSPOSE_UP = ("TrackerTransposeUp", ShortcutCategory.TRACKER) + TRACKER_TRANSPOSE_DOWN = ("TrackerTransposeDown", ShortcutCategory.TRACKER) + TRACKER_TRANSPOSE_OCTAVE_UP = ("TrackerTransposeOctaveUp", ShortcutCategory.TRACKER) + TRACKER_TRANSPOSE_OCTAVE_DOWN = ("TrackerTransposeOctaveDown", ShortcutCategory.TRACKER) + TRACKER_VOLUME_UP = ("TrackerVolumeUp", ShortcutCategory.TRACKER) + TRACKER_VOLUME_DOWN = ("TrackerVolumeDown", ShortcutCategory.TRACKER) + TRACKER_VOLUME_UP_COARSE = ("TrackerVolumeUpCoarse", ShortcutCategory.TRACKER) + TRACKER_VOLUME_DOWN_COARSE = ("TrackerVolumeDownCoarse", ShortcutCategory.TRACKER) TRACKER_PAGE_UP = ("TrackerPageUp", ShortcutCategory.TRACKER) TRACKER_PAGE_DOWN = ("TrackerPageDown", ShortcutCategory.TRACKER) TRACKER_CLEAR_ROW = ("TrackerClearRow", ShortcutCategory.TRACKER) diff --git a/src/sampletones_application/view_model/sequencer/region.py b/src/sampletones_application/view_model/sequencer/region.py index c4f8de5b7..04249d7b8 100644 --- a/src/sampletones_application/view_model/sequencer/region.py +++ b/src/sampletones_application/view_model/sequencer/region.py @@ -80,6 +80,16 @@ def slots(self) -> Tuple[TrackerSlot, ...]: """The slots the region covers, each as the column and subcolumn it addresses.""" return tuple(slot_from_flat(index) for index in range(self.first_slot, self.last_slot + 1)) + @property + def columns(self) -> Tuple[Optional[GeneratorName], ...]: + """The columns the region reaches, each named once and in the order the axis lays them out. + + A region names its edges as subcolumns, while a gesture acting on whole cells — a transpose + or a volume shift — reaches the columns behind them. The sample column reads ``None``, as it + does everywhere the axis is read. + """ + return tuple(dict.fromkeys(slot.generator for slot in self.slots)) + def covers(self, row: int, slot: TrackerSlot) -> bool: """Whether a cell of the grid falls inside the rectangle. diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index c0a88d7a8..6da80187d 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -107,6 +107,14 @@ bindings: TrackerCopyBlock: {combination: "Ctrl+C"} TrackerCutBlock: {combination: "Ctrl+X"} TrackerPasteBlock: {combination: "Ctrl+V"} + TrackerTransposeUp: {combination: "Ctrl+Up"} + TrackerTransposeDown: {combination: "Ctrl+Down"} + TrackerTransposeOctaveUp: {combination: "Ctrl+Shift+Up"} + TrackerTransposeOctaveDown: {combination: "Ctrl+Shift+Down"} + TrackerVolumeUp: {combination: "Alt+Up"} + TrackerVolumeDown: {combination: "Alt+Down"} + TrackerVolumeUpCoarse: {combination: "Alt+Shift+Up"} + TrackerVolumeDownCoarse: {combination: "Alt+Shift+Down"} TrackerPageUp: {combination: "PgUp"} TrackerPageDown: {combination: "PgDn"} TrackerClearRow: {combination: "Del"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 0b70e7293..1e127e281 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -107,6 +107,14 @@ bindings: TrackerCopyBlock: {combination: "Cmd+C"} TrackerCutBlock: {combination: "Cmd+X"} TrackerPasteBlock: {combination: "Cmd+V"} + TrackerTransposeUp: {combination: "Cmd+Alt+Up"} + TrackerTransposeDown: {combination: "Cmd+Alt+Down"} + TrackerTransposeOctaveUp: {combination: "Cmd+Alt+Shift+Up"} + TrackerTransposeOctaveDown: {combination: "Cmd+Alt+Shift+Down"} + TrackerVolumeUp: {combination: "Cmd+Alt+Right"} + TrackerVolumeDown: {combination: "Cmd+Alt+Left"} + TrackerVolumeUpCoarse: {combination: "Cmd+Alt+Shift+Right"} + TrackerVolumeDownCoarse: {combination: "Cmd+Alt+Shift+Left"} TrackerPageUp: {combination: "PgUp", aliases: ["Alt+Up"]} TrackerPageDown: {combination: "PgDn", aliases: ["Alt+Down"]} TrackerClearRow: {combination: "Del", aliases: ["Cmd+Backspace"]} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index fdffff2e0..8c9f5d699 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -818,6 +818,14 @@ settings.keybindings.label.tracker_extend_selection_to_last_row: "Extend selecti settings.keybindings.label.tracker_copy_block: "Copy selection" settings.keybindings.label.tracker_cut_block: "Cut selection" settings.keybindings.label.tracker_paste_block: "Paste selection" +settings.keybindings.label.tracker_transpose_up: "Transpose up" +settings.keybindings.label.tracker_transpose_down: "Transpose down" +settings.keybindings.label.tracker_transpose_octave_up: "Transpose octave up" +settings.keybindings.label.tracker_transpose_octave_down: "Transpose octave down" +settings.keybindings.label.tracker_volume_up: "Volume up" +settings.keybindings.label.tracker_volume_down: "Volume down" +settings.keybindings.label.tracker_volume_up_coarse: "Volume up (coarse)" +settings.keybindings.label.tracker_volume_down_coarse: "Volume down (coarse)" settings.keybindings.label.tracker_page_up: "Page up" settings.keybindings.label.tracker_page_down: "Page down" settings.keybindings.label.tracker_clear_row: "Clear row" 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 835fe41e0..1baec8650 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_history_detail.py @@ -175,7 +175,15 @@ def test_adjust_transpose_shows_signed_delta(self) -> None: controller = _controller() formatter = _formatter(controller) - segments = formatter.adjust_transpose(0, GeneratorName.PULSE2, -3) + segments = formatter.adjust_transpose( + TrackerRegion( + first_row=0, + last_row=0, + first_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.INSTRUMENT).flat_index, + last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.VOLUME).flat_index, + ), + -3, + ) assert _pairs(segments) == [ ("00", HistoryDetailRole.FRAME), @@ -184,6 +192,28 @@ def test_adjust_transpose_shows_signed_delta(self) -> None: ("-03", HistoryDetailRole.TRANSPOSE), ] + def test_adjust_volume_reads_the_rows_it_covers(self) -> None: + """A shift over a selection names the span it reached, the way a block gesture does.""" + controller = _controller() + formatter = _formatter(controller) + + segments = formatter.adjust_volume( + TrackerRegion( + first_row=0, + last_row=3, + first_slot=TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME).flat_index, + last_slot=TrackerSlot(GeneratorName.PULSE2, SubColumn.VOLUME).flat_index, + ), + -1, + ) + + assert _pairs(segments) == [ + ("00", HistoryDetailRole.FRAME), + ("Pp", HistoryDetailRole.CHANNEL), + ("00-03", HistoryDetailRole.ROW), + ("-1", HistoryDetailRole.VOLUME), + ] + class TestOrderDetails: def test_add_frame_reports_the_landing_index(self) -> None: diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py new file mode 100644 index 000000000..20da9fe32 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_adjuster.py @@ -0,0 +1,312 @@ +from dataclasses import dataclass +from typing import Final, Optional, Tuple + +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.tracker import ( + SequencerTrackerLogic, + TrackerRegionAdjuster, +) +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase +from tests.suite.sequencer import fill_frame, render_frame, sample_reconstruction + +FRAME_ROWS: Final[int] = 3 +EMPTY: Final[str] = ".. ... . | .. ... . | .. ... . | .. ... ." +LEAD: Final[str] = "00" + + +@dataclass(frozen=True, kw_only=True) +class Grid: + """A three-row frame with a sample over two channels, the state every case starts from.""" + + controller: ProjectController + logic: SequencerTrackerLogic + adjuster: TrackerRegionAdjuster + sample_ids: Tuple[str, ...] + + +@pytest.fixture +def grid() -> Grid: + """A frame short enough for a case to state whole, holding a sample over two of the channels. + + Which channels a sample governs is what the sample column fans a shift out over, so a governed + row and an ungoverned one both stand available to a case. + """ + controller = ProjectController(ProjectManager()) + logic = SequencerTrackerLogic(controller) + logic.set_rows_per_pattern(FRAME_ROWS) + lead = controller.add_sample( + sample_reconstruction([GeneratorName.PULSE1, GeneratorName.PULSE2]), + name="lead", + ) + return Grid( + controller=controller, + logic=logic, + adjuster=TrackerRegionAdjuster(logic), + sample_ids=(lead.id,), + ) + + +def _region( + first: Tuple[Optional[GeneratorName], SubColumn], + last: Tuple[Optional[GeneratorName], SubColumn], + *, + first_row: int = 0, + last_row: int = 0, +) -> TrackerRegion: + """The rectangle a pair of slots bounds, each stated as the column and subcolumn it addresses.""" + return TrackerRegion( + first_row=first_row, + last_row=last_row, + first_slot=TrackerSlot(*first).flat_index, + last_slot=TrackerSlot(*last).flat_index, + ) + + +class TestAdjustTranspose(BaseTestSuite): + """Which cells a transpose shift reaches, stated as the whole frame it leaves behind. + + A shift acts on whole cells while a region names its edges as subcolumns, so each case states + the subcolumns its region begins and ends on and reads the columns behind them in the result. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + region: TrackerRegion + delta: int + expected: Tuple[str, ...] + frame: Tuple[str, ...] = () + + test_cases = ( + TestCase( + label="a cell alone shifts its own channel", + region=_region( + (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + ), + delta=1, + expected=( + ".. +01 . | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a region standing on another subcolumn still shifts the transpose", + region=_region( + (GeneratorName.TRIANGLE, SubColumn.VOLUME), + (GeneratorName.TRIANGLE, SubColumn.VOLUME), + ), + delta=-1, + expected=( + ".. ... . | .. ... . | .. -01 . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a shift adds to the transpose a cell already holds", + frame=(".. +02 . | .. ... . | .. ... . | .. ... .",), + region=_region( + (GeneratorName.PULSE1, SubColumn.TRANSPOSE), + (GeneratorName.PULSE1, SubColumn.TRANSPOSE), + ), + delta=12, + expected=( + ".. +0E . | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a region across columns shifts each of them", + region=_region( + (GeneratorName.PULSE2, SubColumn.VOLUME), + (GeneratorName.NOISE, SubColumn.INSTRUMENT), + ), + delta=1, + expected=( + ".. ... . | .. +01 . | .. +01 . | .. +01 .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a region across rows shifts each of them", + region=_region( + (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + first_row=1, + last_row=2, + ), + delta=2, + expected=( + EMPTY, + ".. +02 . | .. ... . | .. ... . | .. ... .", + ".. +02 . | .. ... . | .. ... . | .. ... .", + ), + ), + TestCase( + label="an ungoverned sample column reaches every channel", + region=_region( + (None, SubColumn.INSTRUMENT), + (None, SubColumn.VOLUME), + ), + delta=3, + expected=( + ".. +03 . | .. +03 . | .. +03 . | .. +03 .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a governed sample column reaches the channels its sample uses", + frame=(f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .",), + region=_region( + (None, SubColumn.INSTRUMENT), + (None, SubColumn.VOLUME), + ), + delta=3, + expected=( + "00 +03 . | 00 +03 . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a channel covered beside the sample column moves a single step", + frame=(f"{LEAD} ... . | {LEAD} ... . | .. ... . | .. ... .",), + region=_region( + (None, SubColumn.INSTRUMENT), + (GeneratorName.PULSE1, SubColumn.VOLUME), + ), + delta=1, + expected=( + "00 +01 . | 00 +01 . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a shift stops at the transpose range", + frame=(".. +20 . | .. ... . | .. ... . | .. ... .",), + region=_region( + (GeneratorName.PULSE1, SubColumn.TRANSPOSE), + (GeneratorName.PULSE1, SubColumn.TRANSPOSE), + ), + delta=12, + expected=( + ".. +24 . | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_frame_after_a_shift( + self, + grid: Grid, + test_case: TestCase, + ) -> None: + fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids) + + grid.adjuster.adjust_transpose(test_case.region, test_case.delta) + + assert render_frame(grid.logic) == test_case.expected + + +class TestAdjustVolume(BaseTestSuite): + """Which cells a volume shift reaches, read the same way a transpose shift is.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + region: TrackerRegion + delta: int + expected: Tuple[str, ...] + frame: Tuple[str, ...] = () + + test_cases = ( + TestCase( + label="an unset cell steps down from full", + region=_region( + (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + (GeneratorName.PULSE1, SubColumn.INSTRUMENT), + ), + delta=-1, + expected=( + ".. ... E | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a coarse step moves the whole region", + frame=(".. ... 8 | .. ... 8 | .. ... . | .. ... .",), + region=_region( + (GeneratorName.PULSE1, SubColumn.VOLUME), + (GeneratorName.PULSE2, SubColumn.VOLUME), + ), + delta=-4, + expected=( + ".. ... 4 | .. ... 4 | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a shift stops at silence", + frame=(".. ... 1 | .. ... . | .. ... . | .. ... .",), + region=_region( + (GeneratorName.PULSE1, SubColumn.VOLUME), + (GeneratorName.PULSE1, SubColumn.VOLUME), + ), + delta=-4, + expected=( + ".. ... 0 | .. ... . | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + TestCase( + label="a channel covered beside the sample column moves a single step", + frame=(f"{LEAD} ... 8 | {LEAD} ... 8 | .. ... . | .. ... .",), + region=_region( + (None, SubColumn.INSTRUMENT), + (GeneratorName.PULSE1, SubColumn.VOLUME), + ), + delta=-1, + expected=( + "00 ... 7 | 00 ... 7 | .. ... . | .. ... .", + EMPTY, + EMPTY, + ), + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_frame_after_a_shift( + self, + grid: Grid, + test_case: TestCase, + ) -> None: + fill_frame(grid.logic, test_case.frame, sample_ids=grid.sample_ids) + + grid.adjuster.adjust_volume(test_case.region, test_case.delta) + + assert render_frame(grid.logic) == test_case.expected diff --git a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py index 52987c990..bee5646f1 100644 --- a/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py +++ b/tests/unit/sampletones_application/logic/sequencer/tracker/test_tracker.py @@ -195,34 +195,6 @@ def test_the_sample_column_cuts_every_channel(self) -> None: assert isinstance(_row(controller, generator).command, NoteOff) -class TestAdjustCell: - def test_a_channel_cell_shifts_only_that_channel(self) -> None: - controller = _controller() - logic = SequencerTrackerLogic(controller) - - logic.adjust_cell_volume(0, GeneratorName.PULSE1, -1) - - assert _row(controller, GeneratorName.PULSE1).volume == MAX_VOLUME - 1 - assert _row(controller, GeneratorName.PULSE2).volume is None - - def test_the_sample_column_shifts_the_sample_channels(self) -> None: - controller = _controller() - logic = SequencerTrackerLogic(controller) - sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), - name="lead", - ) - logic.set_sample_instrument(0, sample.id) - - logic.adjust_cell_transpose(0, None, 3) - - for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): - assert _row(controller, generator).transpose == 3 - - for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): - assert _row(controller, generator).transpose is None - - class TestFrameRowCount: def test_counts_the_rows_the_grid_builds(self) -> None: controller = _controller() @@ -512,39 +484,6 @@ def test_clamps_to_zero(self) -> None: assert _row(controller, GeneratorName.PULSE1).volume == 0 -class TestAdjustSampleColumn: - def test_sample_transpose_shifts_only_relevant_channels(self) -> None: - controller = _controller() - logic = SequencerTrackerLogic(controller) - sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), - name="lead", - ) - logic.set_sample_instrument(0, sample.id) - - logic.adjust_sample_transpose(0, 3) - - for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): - assert _row(controller, generator).transpose == 3 - - for generator in (GeneratorName.PULSE2, GeneratorName.NOISE): - assert _row(controller, generator).transpose is None - - def test_sample_volume_steps_relevant_channels_down_from_full(self) -> None: - controller = _controller() - logic = SequencerTrackerLogic(controller) - sample = controller.add_sample( - sample_reconstruction([GeneratorName.PULSE1, GeneratorName.TRIANGLE]), - name="lead", - ) - logic.set_sample_instrument(0, sample.id) - - logic.adjust_sample_volume(0, -1) - - for generator in (GeneratorName.PULSE1, GeneratorName.TRIANGLE): - assert _row(controller, generator).volume == MAX_VOLUME - 1 - - class TestBuildTrackerAggregation: def test_single_channel_of_a_multi_channel_sample_reads_mixed(self) -> None: controller = _controller() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 2186b19ad..3dd19e0c6 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -5,6 +5,7 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.elements.table.cells import EditableCells +from sampletones_application.ui.panels.sequencer import tracker as tracker_module from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, @@ -24,6 +25,7 @@ from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName +from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP from tests.suite.shortcuts import shipped_source ROW_COUNT = 64 @@ -36,13 +38,16 @@ @dataclass class Gestures: - """What each block hook was handed, which is the whole of what a press reaches the grid with.""" + """What each of the tracker's hooks was handed, which is the whole of what a press reaches the + grid with.""" copied: List[TrackerRegion] = field(default_factory=list) cut: List[TrackerRegion] = field(default_factory=list) deleted: List[TrackerRegion] = field(default_factory=list) pasted: List[TrackerCell] = field(default_factory=list) cleared: List[Tuple[int, Optional[GeneratorName]]] = field(default_factory=list) + transposed: List[Tuple[TrackerRegion, int]] = field(default_factory=list) + volume_shifted: List[Tuple[TrackerRegion, int]] = field(default_factory=list) @dataclass @@ -84,6 +89,8 @@ def _panel( panel.on_delete_block = gestures.deleted.append panel.on_paste_block = gestures.pasted.append panel.on_clear_row = lambda row, generator_name: gestures.cleared.append((row, generator_name)) + panel.on_adjust_transpose = lambda region, delta: gestures.transposed.append((region, delta)) + panel.on_adjust_volume = lambda region, delta: gestures.volume_shifted.append((region, delta)) panel.can_paste_block = lambda: True panel._blocks = BlockGestures(grid=panel) monkeypatch.setattr(panel, "_apply_state", lambda state: None) @@ -220,6 +227,55 @@ def test_a_cursor_alone_keeps_clearing_the_cell_it_stands_on( assert gestures.cleared == [(CURSOR_ROW, GeneratorName.PULSE1)] +class TestTrackerAdjustKeys: + """The shifts reach the same block the clipboard keys do, so a selection moves whole.""" + + def test_a_selection_is_transposed_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures) + panel._input_state = panel._input_state.extend_row(2, ROW_COUNT) + + assert panel._on_key_pressed(_press("Ctrl+Up")) is True + assert gestures.transposed == [ + ( + TrackerRegion( + first_row=CURSOR_ROW, + last_row=CURSOR_ROW + 2, + first_slot=3, + last_slot=3, + ), + SEMITONE_STEP, + ) + ] + + def test_a_cursor_alone_shifts_the_cell_it_stands_on(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures, subcolumn=SubColumn.VOLUME) + + assert panel._on_key_pressed(_press("Alt+Down")) is True + region, delta = gestures.volume_shifted[-1] + assert region.rows == range(CURSOR_ROW, CURSOR_ROW + 1) + assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) + assert delta == -tracker_module.VOLUME_FINE_STEP + + def test_shift_makes_the_step_the_bigger_one(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures) + + assert panel._on_key_pressed(_press("Ctrl+Shift+Up")) is True + assert panel._on_key_pressed(_press("Alt+Shift+Up")) is True + assert gestures.transposed[-1][1] == OCTAVE_SEMITONES + assert gestures.volume_shifted[-1][1] == tracker_module.VOLUME_COARSE_STEP + + def test_a_grid_with_no_cursor_shifts_nothing(self, monkeypatch: pytest.MonkeyPatch) -> None: + gestures = Gestures() + panel = _panel(monkeypatch, gestures) + panel._input_state = TrackerInputState() + + assert panel._on_key_pressed(_press("Ctrl+Up")) is False + assert gestures.transposed == [] + + class TestOrderCopyKey: def test_a_selection_is_copied_whole(self, monkeypatch: pytest.MonkeyPatch) -> None: gestures = OrderGestures() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index ceabdd53a..fa7060d13 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -92,14 +92,6 @@ def add_menu_item(self, **kwargs: Any) -> int: "clear_subcolumn", "clear_cell", "clear_row", - "transpose_up", - "transpose_down", - "transpose_octave_up", - "transpose_octave_down", - "volume_up", - "volume_down", - "volume_up_coarse", - "volume_down_coarse", ) ORDER_LABELS = ( @@ -124,6 +116,13 @@ def _labels(panel: Any, names: Tuple[str, ...]) -> None: setattr(panel, f"_lbl_context_{name}", name) +def _adjust_labels(panel: Any) -> None: + """Gives the panel the words its transpose and volume items print, each reading as its element.""" + panel._lbl_adjust = { + element: element.value for element, _, _ in (*tracker_module.TRANSPOSE_ACTIONS, *tracker_module.VOLUME_ACTIONS) + } + + def _tracker_panel( gestures: Gestures, *, @@ -132,6 +131,7 @@ def _tracker_panel( """A tracker panel whose menu builder can run with no DearPyGui context behind it.""" panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) _labels(panel, TRACKER_LABELS) + _adjust_labels(panel) panel._shortcuts = shipped_source() panel._input_state = TrackerInputState() panel._current_samples = None diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py index e08c2c58d..582850156 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_tracker_context_menu.py @@ -4,7 +4,9 @@ import pytest from sampletones_application.ui.panels.sequencer import tracker as tracker_module -from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor +from sampletones_application.ui.panels.sequencer.input.target import TrackerTarget +from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState +from sampletones_application.view_model.sequencer.region import TrackerRegion from sampletones_application.view_model.sequencer.samples import ( SampleEntryViewModel, SequencerSamplesViewModel, @@ -12,6 +14,7 @@ from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP +from tests.suite.shortcuts import shipped_source SENDER_WIDGET_ID = 6099 """A stand-in for the menu-item widget id DearPyGui passes as the callback's first @@ -21,14 +24,6 @@ _CONTEXT_LABELS = ( "_lbl_context_set_instrument", "_lbl_context_no_samples", - "_lbl_context_transpose_up", - "_lbl_context_transpose_down", - "_lbl_context_transpose_octave_up", - "_lbl_context_transpose_octave_down", - "_lbl_context_volume_up", - "_lbl_context_volume_down", - "_lbl_context_volume_up_coarse", - "_lbl_context_volume_down_coarse", ) @@ -36,13 +31,22 @@ 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. + labels, the keys each item prints, and ``CallbackMixin.call``, so a fully + wired GUI context is unnecessary here. Labels carry no behaviour, so any + placeholder text serves. """ panel = tracker_module.GUISequencerTrackerPanel.__new__(tracker_module.GUISequencerTrackerPanel) for label in _CONTEXT_LABELS: setattr(panel, label, "") + panel._lbl_adjust = { + element: "" + for element, _, _ in ( + *tracker_module.TRANSPOSE_ACTIONS, + *tracker_module.VOLUME_ACTIONS, + ) + } + panel._shortcuts = shipped_source() return panel @@ -81,13 +85,19 @@ def _cell(row: int, generator: GeneratorName) -> TrackerCursor: return TrackerCursor(row, generator, SubColumn.INSTRUMENT) +def _target(row: int, generator: GeneratorName) -> TrackerTarget: + """The cell a menu was raised on, paired with the block of that cell alone.""" + cell = _cell(row, generator) + return TrackerTarget(cell=cell, region=TrackerInputState().region_at(cell)) + + class TestMenuDispatchPreservesPayload: def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None: panel = _panel() deltas: List[int] = [] - panel.on_adjust_transpose = lambda row, generator, delta: deltas.append(delta) + panel.on_adjust_transpose = lambda region, delta: deltas.append(delta) - panel._add_transpose_items(_cell(2, GeneratorName.PULSE1)) + panel._add_transpose_items(_target(2, GeneratorName.PULSE1)) recorder.dispatch_as_dpg() assert deltas == [ @@ -100,9 +110,9 @@ def test_transpose_items_pass_the_configured_step(self, recorder: _MenuItemRecor def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder) -> None: panel = _panel() deltas: List[int] = [] - panel.on_adjust_volume = lambda row, generator, delta: deltas.append(delta) + panel.on_adjust_volume = lambda region, delta: deltas.append(delta) - panel._add_volume_items(_cell(2, GeneratorName.PULSE1)) + panel._add_volume_items(_target(2, GeneratorName.PULSE1)) recorder.dispatch_as_dpg() assert deltas == [ @@ -112,15 +122,16 @@ def test_volume_items_pass_the_configured_step(self, recorder: _MenuItemRecorder -tracker_module.VOLUME_COARSE_STEP, ] - def test_adjust_carries_the_clicked_row_and_channel(self, recorder: _MenuItemRecorder) -> None: + def test_adjust_carries_the_block_the_menu_was_raised_on(self, recorder: _MenuItemRecorder) -> None: panel = _panel() - calls: List[Tuple[int, GeneratorName, int]] = [] - panel.on_adjust_transpose = lambda row, generator, delta: calls.append((row, generator, delta)) + calls: List[Tuple[TrackerRegion, int]] = [] + panel.on_adjust_transpose = lambda region, delta: calls.append((region, delta)) + target = _target(7, GeneratorName.TRIANGLE) - panel._add_transpose_items(_cell(7, GeneratorName.TRIANGLE)) + panel._add_transpose_items(target) recorder.dispatch_as_dpg() - assert calls[0] == (7, GeneratorName.TRIANGLE, SEMITONE_STEP) + assert calls[0] == (target.region, SEMITONE_STEP) def test_instrument_items_pass_the_sample_id(self, recorder: _MenuItemRecorder) -> None: panel = _panel() diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py index be485188e..8f480a3b1 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py @@ -119,6 +119,123 @@ def test_the_samples_panel_keeps_rename_on_its_function_key(self, shipped: Short assert shipped.action(ShortcutCategory.SAMPLES, _press("F2")) is ShortcutId.SAMPLES_RENAME_SAMPLE +class TestTrackerAdjustKeys(BaseTestSuite): + """The keys the tracker's shifts answer to: Ctrl carries pitch, Alt carries volume, and Shift + makes the step the bigger one.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + shortcut_id: ShortcutId + expected: str + + test_cases = ( + TestCase(label="transpose up", shortcut_id=ShortcutId.TRACKER_TRANSPOSE_UP, expected="Ctrl+Up"), + TestCase(label="transpose down", shortcut_id=ShortcutId.TRACKER_TRANSPOSE_DOWN, expected="Ctrl+Down"), + TestCase( + label="an octave up", + shortcut_id=ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP, + expected="Ctrl+Shift+Up", + ), + TestCase( + label="an octave down", + shortcut_id=ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN, + expected="Ctrl+Shift+Down", + ), + TestCase(label="volume up", shortcut_id=ShortcutId.TRACKER_VOLUME_UP, expected="Alt+Up"), + TestCase(label="volume down", shortcut_id=ShortcutId.TRACKER_VOLUME_DOWN, expected="Alt+Down"), + TestCase( + label="a coarse volume up", + shortcut_id=ShortcutId.TRACKER_VOLUME_UP_COARSE, + expected="Alt+Shift+Up", + ), + TestCase( + label="a coarse volume down", + shortcut_id=ShortcutId.TRACKER_VOLUME_DOWN_COARSE, + expected="Alt+Shift+Down", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_shift_reads_under_the_combination_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_shift_answers_its_press_in_the_tracker( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + action = shipped.action(ShortcutCategory.TRACKER, _press(test_case.expected)) + + assert action is test_case.shortcut_id + + +class TestMacosAdjustKeys(BaseTestSuite): + """What a Mac reaches the tracker's shifts through. + + The alternatives a Mac keyboard needs already answer on Cmd and Alt with the arrows, so the + shifts take Cmd+Alt there and read their axis from the direction: the arrows up and down carry + pitch, those left and right carry volume, and Shift makes the step the bigger one. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + shortcut_id: ShortcutId + expected: str + + test_cases = ( + TestCase(label="transpose up", shortcut_id=ShortcutId.TRACKER_TRANSPOSE_UP, expected="Cmd+Alt+Up"), + TestCase(label="transpose down", shortcut_id=ShortcutId.TRACKER_TRANSPOSE_DOWN, expected="Cmd+Alt+Down"), + TestCase( + label="an octave up", + shortcut_id=ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP, + expected="Cmd+Alt+Shift+Up", + ), + TestCase( + label="an octave down", + shortcut_id=ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN, + expected="Cmd+Alt+Shift+Down", + ), + TestCase(label="volume up", shortcut_id=ShortcutId.TRACKER_VOLUME_UP, expected="Cmd+Alt+Right"), + TestCase(label="volume down", shortcut_id=ShortcutId.TRACKER_VOLUME_DOWN, expected="Cmd+Alt+Left"), + TestCase( + label="a coarse volume up", + shortcut_id=ShortcutId.TRACKER_VOLUME_UP_COARSE, + expected="Cmd+Alt+Shift+Right", + ), + TestCase( + label="a coarse volume down", + shortcut_id=ShortcutId.TRACKER_VOLUME_DOWN_COARSE, + expected="Cmd+Alt+Shift+Left", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_shift_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 TestMacosKeys(BaseTestSuite): """What a Mac reads its keys as, spelled the way that keyboard is labelled.""" From 36ed2755a61961b52e2b67d4c48561fc4db018e8 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 13:21:38 +0200 Subject: [PATCH 087/152] Extracted: the grid edit surface --- .../coordinators/tabs/sequencer.py | 4 +- .../ui/panels/sequencer/grid/gestures.py | 44 +-- .../ui/panels/sequencer/grid/surface.py | 195 +++++++++++++ .../ui/panels/sequencer/order.py | 106 +++---- .../ui/panels/sequencer/tracker.py | 108 +++----- tests/suite/grid.py | 44 +++ .../ui/panels/sequencer/grid/test_gestures.py | 70 +---- .../ui/panels/sequencer/grid/test_surface.py | 261 ++++++++++++++++++ .../ui/panels/sequencer/test_block_keys.py | 8 + .../ui/panels/sequencer/test_block_menu.py | 77 +++--- 10 files changed, 636 insertions(+), 281 deletions(-) create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface.py create mode 100644 tests/suite/grid.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 70df6d853..f28501682 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1438,6 +1438,6 @@ def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]: The two hold one cursor between them, so the menu bar reaches whichever one has it. """ return ( - self._sequencer_tracker_panel, - self._sequencer_order_panel, + self._sequencer_tracker_panel.edit_surface, + self._sequencer_order_panel.edit_surface, ) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/gestures.py b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py index 3b1edf448..19d5c84d4 100644 --- a/src/sampletones_application/ui/panels/sequencer/grid/gestures.py +++ b/src/sampletones_application/ui/panels/sequencer/grid/gestures.py @@ -25,8 +25,7 @@ def anchor(self) -> CellT_co: ... class BlockGrid(Protocol[RegionT, CellT]): """What a grid states to the block gestures raised over it. - The hooks are the grid's own, so the coordinator keeps wiring them where it already does; the - two methods are what a key press needs, since it names its target through the cursor. + The hooks are the grid's own, so the coordinator keeps wiring them where it already does. """ on_copy_block: Optional[Callable[[RegionT], None]] @@ -35,22 +34,13 @@ class BlockGrid(Protocol[RegionT, CellT]): on_paste_block: Optional[Callable[[CellT], None]] can_paste_block: Optional[Callable[[], bool]] - def commit_entry(self) -> None: - """Writes the entry being typed into the cell the cursor stands on.""" - - def cursor_target(self) -> Optional[BlockTarget[RegionT, CellT]]: - """The target the cursor names, once the grid holds a cursor.""" - class BlockGestures(CallbackMixin, Generic[RegionT, CellT]): """The four gestures a grid's blocks answer to: copy, cut, paste and delete. - Three doors raise the same four. A key press acts at the cursor, and takes its target once the - entry being typed has landed, so a gesture carries the value the reader has just finished. A - cell menu and the menu bar's Edit menu each name the target they were built for and act on it - where it stands. Holding the four here is what has every door fire one implementation. - - The plain gestures act at the cursor; the ``_at`` gestures act on a target already named. + Three doors raise the same four, and each names the target it acts on: a cell menu and the menu + bar's Edit menu name the target they were built for, and a key press names the cursor's. + Holding the four here is what has every door fire one implementation. """ def __init__(self, *, grid: BlockGrid[RegionT, CellT]) -> None: @@ -60,18 +50,6 @@ def can_paste(self) -> bool: """Whether a block stands ready for a paste to write.""" return self.query(self._grid.can_paste_block, default=False) - def copy(self) -> None: - self._at_cursor(self.copy_at) - - def cut(self) -> None: - self._at_cursor(self.cut_at) - - def delete(self) -> None: - self._at_cursor(self.delete_at) - - def paste(self) -> None: - self._at_cursor(self.paste_at) - def copy_at(self, target: BlockTarget[RegionT, CellT]) -> None: """Takes what a target covers, leaving the grid as it stands.""" self.call(self._grid.on_copy_block, target.region) @@ -87,17 +65,3 @@ def delete_at(self, target: BlockTarget[RegionT, CellT]) -> None: def paste_at(self, target: BlockTarget[RegionT, CellT]) -> None: """Writes the block in hand from a target's own cell, which is where it lands.""" self.call(self._grid.on_paste_block, target.anchor) - - def _at_cursor( - self, - gesture: Callable[[BlockTarget[RegionT, CellT]], None], - ) -> None: - """Raises a gesture on the cell the cursor stands on, the entry being typed landing first. - - Committing ahead of the gesture is what lets a block carry the value the reader has just - finished typing. - """ - self._grid.commit_entry() - target = self._grid.cursor_target() - if target is not None: - gesture(target) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface.py b/src/sampletones_application/ui/panels/sequencer/grid/surface.py new file mode 100644 index 000000000..24d25246b --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/surface.py @@ -0,0 +1,195 @@ +from dataclasses import dataclass +from typing import Any, Callable, Dict, Final, Generic, Mapping, Optional, Protocol, Tuple, TypeVar + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.context import context_label +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures, BlockTarget +from sampletones_application.ui.panels.sequencer.input.state import GridInputState +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource + +CursorT = TypeVar("CursorT") +RegionT = TypeVar("RegionT") +CellT = TypeVar("CellT") +TargetT = TypeVar("TargetT", bound=BlockTarget[Any, Any]) +TargetT_co = TypeVar("TargetT_co", covariant=True) +TargetT_contra = TypeVar("TargetT_contra", contravariant=True) +CursorT_contra = TypeVar("CursorT_contra", contravariant=True) +RegionT_contra = TypeVar("RegionT_contra", contravariant=True) + +CLIPBOARD_ACTIONS: Final[Tuple[ContextElements, ...]] = ( + ContextElements.COPY, + ContextElements.CUT, + ContextElements.PASTE, + ContextElements.DELETE, +) + + +def clipboard_labels(language_manager: LanguageManager) -> Dict[ContextElements, str]: + """The words every clipboard item prints, read from the vocabulary each grid shares.""" + return {element: context_label(language_manager, element) for element in CLIPBOARD_ACTIONS} + + +@dataclass(frozen=True) +class BlockShortcuts: + """The keys one grid answers the clipboard gestures with. + + Delete stands apart from the three: ``Del`` empties a selection while one stands and clears the + cell under the cursor otherwise, so the grid resolves it from the selection and its item prints + no key. + """ + + copy: ShortcutId + cut: ShortcutId + paste: ShortcutId + + +class TargetFactory(Protocol[CursorT_contra, RegionT_contra, TargetT_co]): + """How a grid's own target is built from the pair every target carries.""" + + def __call__(self, *, cell: CursorT_contra, region: RegionT_contra) -> TargetT_co: ... + + +class EditGrid(Protocol[CursorT, RegionT, TargetT_contra]): + """What a grid states to the edit surface built over it. + + The state carries the cursor and the selection a target is resolved from, and the grid states + its own actions for a target the surface hands back. Whether the grid owns those gestures at + this moment is the question its key scope already answers, so one predicate serves the keyboard + and the menu alike. + """ + + def owns_keys(self) -> bool: ... + + def input_state(self) -> GridInputState[CursorT, RegionT]: ... + + def add_action_items(self, target: TargetT_contra) -> None: ... + + def commit_entry(self) -> None: + """Writes the entry being typed into the cell the cursor stands on.""" + + +class GridEditSurface(Generic[CursorT, RegionT, CellT, TargetT]): + """A sequencer grid as the menu bar's Edit menu reaches it. + + The menu bar asks the surface for the actions of whichever grid holds the cursor, and the + surface asks that grid to build them for the target the cursor names. Both grids reach the + Edit menu through one implementation, so the menu states what the next key press would. + + It also prints the clipboard four, which are the actions every grid carries: the words come + from the shared context vocabulary and the accelerators from the grid's own three bindings, so + a grid states only which keys it answers to and the items read the same in either. + """ + + def __init__( + self, + *, + grid: EditGrid[CursorT, RegionT, TargetT], + blocks: BlockGestures[RegionT, CellT], + target: TargetFactory[CursorT, RegionT, TargetT], + shortcuts: ShortcutSource, + block_shortcuts: BlockShortcuts, + labels: Mapping[ContextElements, str], + ) -> None: + self._grid = grid + self._blocks = blocks + self._target = target + self._shortcuts = shortcuts + self._block_shortcuts = block_shortcuts + self._labels = labels + + def owns_edit_actions(self) -> bool: + """Whether the Edit menu states this grid's actions, which it does while the grid owns keys. + + The menu offers what the next press would reach, so one question decides both. + """ + return self._grid.owns_keys() + + def build_edit_actions(self) -> None: + """Builds the grid's whole action set for the cell the cursor stands on. + + The menu bar asks while the grid owns the editing gestures, so the cursor names the target + the same way a pointer names it on the cell menu. + """ + target = self.cursor_target() + if target is not None: + self._grid.add_action_items(target) + + def target_at(self, cell: CursorT) -> TargetT: + """The cell a set of actions is raised on, paired with the block those actions act on. + + The block is the selection the cell falls inside, or the cell alone, so a menu raised + within a selection reaches the whole of it and one raised elsewhere reaches what it names. + """ + return self._target( + cell=cell, + region=self._grid.input_state().region_at(cell), + ) + + def cursor_target(self) -> Optional[TargetT]: + """The target the cursor names, which is what a key press and the Edit menu act on.""" + cursor = self._grid.input_state().cursor + if cursor is None: + return None + + return self.target_at(cursor) + + def copy(self) -> None: + self._at_cursor(self._blocks.copy_at) + + def cut(self) -> None: + self._at_cursor(self._blocks.cut_at) + + def delete(self) -> None: + self._at_cursor(self._blocks.delete_at) + + def paste(self) -> None: + self._at_cursor(self._blocks.paste_at) + + def can_paste(self) -> bool: + """Whether a block stands ready for a paste to write.""" + return self._blocks.can_paste() + + def _at_cursor( + self, + gesture: Callable[[BlockTarget[RegionT, CellT]], None], + ) -> None: + """Raises a gesture on the cell the cursor stands on, the entry being typed landing first. + + Committing ahead of the gesture is what lets a block carry the value the reader has just + finished typing. + """ + self._grid.commit_entry() + target = self.cursor_target() + if target is not None: + gesture(target) + + def add_block_items(self, target: BlockTarget[RegionT, CellT]) -> None: + """Builds the clipboard items, acting on the block the actions were raised on. + + Paste is offered once a block has been copied, and it anchors at the target's own cell, so + the cell menu lands a block where the pointer is while the keys land it under the cursor. + """ + dpg.add_menu_item( + label=self._labels[ContextElements.COPY], + shortcut=self._shortcuts.display(self._block_shortcuts.copy), + callback=lambda: self._blocks.copy_at(target), + ) + dpg.add_menu_item( + label=self._labels[ContextElements.CUT], + shortcut=self._shortcuts.display(self._block_shortcuts.cut), + callback=lambda: self._blocks.cut_at(target), + ) + dpg.add_menu_item( + label=self._labels[ContextElements.PASTE], + shortcut=self._shortcuts.display(self._block_shortcuts.paste), + enabled=self._blocks.can_paste(), + callback=lambda: self._blocks.paste_at(target), + ) + dpg.add_menu_item( + label=self._labels[ContextElements.DELETE], + callback=lambda: self._blocks.delete_at(target), + ) diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index f651783f4..a465b85d5 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -2,8 +2,6 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import context_label -from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import SequencerOrderElements from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager @@ -47,6 +45,11 @@ ) from sampletones_application.ui.panels.sequencer.columns import channel_color from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.surface import ( + BlockShortcuts, + GridEditSurface, + clipboard_labels, +) from sampletones_application.ui.panels.sequencer.input.order import ( INDEX_DIGITS, OrderCursor, @@ -97,6 +100,7 @@ OnBlockRegionCallback = Callable[[OrderRegion], None] OnPasteBlockCallback = Callable[[OrderCell], None] CanPasteBlockQuery = Callable[[], bool] +OrderEditSurface = GridEditSurface[OrderCursor, OrderRegion, OrderCell, OrderTarget] MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = { ShortcutId.ORDER_MOVE_FRAME_LEFT: MoveDirection.PREVIOUS, @@ -185,6 +189,18 @@ def __init__( self.on_channels_unmuted: Optional[VoidCallback] = None self._blocks: BlockGestures[OrderRegion, OrderCell] = BlockGestures(grid=self) + self._surface: OrderEditSurface = GridEditSurface( + grid=self, + blocks=self._blocks, + target=OrderTarget, + shortcuts=shortcut_source, + block_shortcuts=BlockShortcuts( + copy=ShortcutId.ORDER_COPY_BLOCK, + cut=ShortcutId.ORDER_CUT_BLOCK, + paste=ShortcutId.ORDER_PASTE_BLOCK, + ), + labels=clipboard_labels(language_manager), + ) self._lbl_order = self._label(language_manager, SequencerOrderElements.ORDER_TEXT) self._load_row_labels(language_manager) self._load_context_labels(language_manager) @@ -219,10 +235,6 @@ def label(element: SequencerOrderElements) -> str: return self._label(language_manager, element) self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY) - self._lbl_context_cut = context_label(language_manager, ContextElements.CUT) - self._lbl_context_copy = context_label(language_manager, ContextElements.COPY) - self._lbl_context_paste = context_label(language_manager, ContextElements.PASTE) - self._lbl_context_delete = context_label(language_manager, ContextElements.DELETE) self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE) self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE) self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT) @@ -1012,7 +1024,7 @@ def _show_context_menu( generator: Optional[GeneratorName], position: int, ) -> None: - target = self._target_at(OrderCursor(generator, position)) + target = self._surface.target_at(OrderCursor(generator, position)) with context_menu(): header = dpg.add_text(display_id(position)) FontRegistry.bind_to_item(header, Font.MONO_BOLD) @@ -1023,82 +1035,34 @@ def _show_context_menu( shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() - self._add_action_items(target) - - def _target_at(self, cell: OrderCursor) -> OrderTarget: - """The cell a set of actions is raised on, paired with the block those actions act on.""" - return OrderTarget( - cell=cell, - region=self._input_state.region_at(cell), - ) - - def cursor_target(self) -> Optional[OrderTarget]: - """The target the cursor names, which is what a key press and the Edit menu act on.""" - cursor = self._input_state.cursor - if cursor is None: - return None + self.add_action_items(target) - return self._target_at(cursor) + @property + def edit_surface(self) -> OrderEditSurface: + """This table as the menu bar's Edit menu reaches it.""" + return self._surface - def owns_edit_actions(self) -> bool: - """Whether the Edit menu states this table's actions, which it does while it owns keys. + def input_state(self) -> OrderInputState: + """Where the cursor stands and what it has selected, which a target is resolved from.""" + return self._input_state - The menu offers what the next press would reach, so one question decides both. - """ + def owns_keys(self) -> bool: + """Whether the table owns the next key, which is also what the Edit menu asks.""" return self._keys_active() - def build_edit_actions(self) -> None: - """Builds this table's whole action set for the cell the cursor stands on. - - The menu bar asks while the table owns the editing gestures, so the cursor names the target - the same way a pointer names it on the cell menu. - """ - target = self.cursor_target() - if target is not None: - self._add_action_items(target) - - def _add_action_items(self, target: OrderTarget) -> None: + def add_action_items(self, target: OrderTarget) -> None: """Builds every action an order cell offers, in the order each menu prints them. The table states its actions once, and whoever asks for them decides where they are shown: the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the cursor stands on. An action added here reaches both. """ - self._add_block_items(target) + self._surface.add_block_items(target) dpg.add_separator() self._add_frame_items(target.cell.position) dpg.add_separator() self._add_move_items(target.cell.position) - def _add_block_items(self, target: OrderTarget) -> None: - """Builds the clipboard items, acting on the block the actions were raised on. - - Paste is offered once a block has been copied, and it anchors at the target's own cell, so - the cell menu lands a block where the pointer is while the keys land it under the cursor. - Delete prints no key of its own, because ``Del`` empties a selection while one stands and - clears the cell under the cursor otherwise. - """ - dpg.add_menu_item( - label=self._lbl_context_copy, - shortcut=self._shortcuts.display(ShortcutId.ORDER_COPY_BLOCK), - callback=lambda: self._blocks.copy_at(target), - ) - dpg.add_menu_item( - label=self._lbl_context_cut, - shortcut=self._shortcuts.display(ShortcutId.ORDER_CUT_BLOCK), - callback=lambda: self._blocks.cut_at(target), - ) - dpg.add_menu_item( - label=self._lbl_context_paste, - shortcut=self._shortcuts.display(ShortcutId.ORDER_PASTE_BLOCK), - enabled=self._blocks.can_paste(), - callback=lambda: self._blocks.paste_at(target), - ) - dpg.add_menu_item( - label=self._lbl_context_delete, - callback=lambda: self._blocks.delete_at(target), - ) - def _add_frame_items(self, position: int) -> None: """Builds the frame operations, each acting on the whole frame the target cell sits in.""" dpg.add_menu_item( @@ -1262,13 +1226,13 @@ def _block_action(self, shortcut_id: ShortcutId) -> bool: """ match shortcut_id: case ShortcutId.ORDER_COPY_BLOCK: - self._blocks.copy() + self._surface.copy() case ShortcutId.ORDER_CUT_BLOCK: - self._blocks.cut() + self._surface.cut() case ShortcutId.ORDER_CLEAR_CELL if self._input_state.region is not None: - self._blocks.delete() + self._surface.delete() case ShortcutId.ORDER_PASTE_BLOCK: - self._blocks.paste() + self._surface.paste() case _: return False diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index f960c4dd2..c5b34c2bc 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -2,8 +2,6 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import context_label -from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import ( SequencerTrackerElements, ) @@ -51,6 +49,11 @@ ) from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.surface import ( + BlockShortcuts, + GridEditSurface, + clipboard_labels, +) from sampletones_application.ui.panels.sequencer.input.edit import ( ClearAction, EditAction, @@ -121,6 +124,7 @@ OnBlockRegionCallback = Callable[[TrackerRegion], None] OnPasteBlockCallback = Callable[[TrackerCell], None] CanPasteBlockQuery = Callable[[], bool] +TrackerEditSurface = GridEditSurface[TrackerCursor, TrackerRegion, TrackerCell, TrackerTarget] VOLUME_FINE_STEP: Final[int] = 1 @@ -262,6 +266,18 @@ def __init__( self.on_channels_unmuted: Optional[VoidCallback] = None self._blocks: BlockGestures[TrackerRegion, TrackerCell] = BlockGestures(grid=self) + self._surface: TrackerEditSurface = GridEditSurface( + grid=self, + blocks=self._blocks, + target=TrackerTarget, + shortcuts=shortcut_source, + block_shortcuts=BlockShortcuts( + copy=ShortcutId.TRACKER_COPY_BLOCK, + cut=ShortcutId.TRACKER_CUT_BLOCK, + paste=ShortcutId.TRACKER_PASTE_BLOCK, + ), + labels=clipboard_labels(language_manager), + ) self.pattern_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_PATTERN) self._lbl_tracker = self._label( @@ -308,10 +324,6 @@ def label(element: SequencerTrackerElements) -> str: self._lbl_context_play = label(SequencerTrackerElements.CONTEXT_PLAY) self._lbl_context_play_from_frame = label(SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME) - self._lbl_context_cut = context_label(language_manager, ContextElements.CUT) - self._lbl_context_copy = context_label(language_manager, ContextElements.COPY) - self._lbl_context_paste = context_label(language_manager, ContextElements.PASTE) - self._lbl_context_delete = context_label(language_manager, ContextElements.DELETE) 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) @@ -1279,7 +1291,7 @@ def _show_context_menu( generator: Optional[GeneratorName], subcolumn: SubColumn, ) -> None: - target = self._target_at(TrackerCursor(row_index, generator, subcolumn)) + target = self._surface.target_at(TrackerCursor(row_index, generator, subcolumn)) with context_menu(): header = dpg.add_text( tracker_display.indexed_label(row_index, self._column_labels[generator]), @@ -1297,48 +1309,29 @@ def _show_context_menu( shortcut=self._shortcuts.display(ShortcutId.PLAY_FROM_FRAME), ) dpg.add_separator() - self._add_action_items(target) + self.add_action_items(target) - def _target_at(self, cell: TrackerCursor) -> TrackerTarget: - """The cell a set of actions is raised on, paired with the block those actions act on.""" - return TrackerTarget( - cell=cell, - region=self._input_state.region_at(cell), - ) - - def cursor_target(self) -> Optional[TrackerTarget]: - """The target the cursor names, which is what a key press and the Edit menu act on.""" - cursor = self._input_state.cursor - if cursor is None: - return None - - return self._target_at(cursor) + @property + def edit_surface(self) -> TrackerEditSurface: + """This grid as the menu bar's Edit menu reaches it.""" + return self._surface - def owns_edit_actions(self) -> bool: - """Whether the Edit menu states this grid's actions, which it does while the grid owns keys. + def input_state(self) -> TrackerInputState: + """Where the cursor stands and what it has selected, which a target is resolved from.""" + return self._input_state - The menu offers what the next press would reach, so one question decides both. - """ + def owns_keys(self) -> bool: + """Whether the grid owns the next key, which is also what the Edit menu asks.""" return self._keys_active() - def build_edit_actions(self) -> None: - """Builds this grid's whole action set for the cell the cursor stands on. - - The menu bar asks while the grid owns the editing gestures, so the cursor names the target - the same way a pointer names it on the cell menu. - """ - target = self.cursor_target() - if target is not None: - self._add_action_items(target) - - def _add_action_items(self, target: TrackerTarget) -> None: + def add_action_items(self, target: TrackerTarget) -> None: """Builds every action a tracker cell offers, in the order each menu prints them. The grid states its actions once, and whoever asks for them decides where they are shown: the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the cursor stands on. An action added here reaches both. """ - self._add_block_items(target) + self._surface.add_block_items(target) dpg.add_separator() self._add_instrument_submenu(target.cell) dpg.add_menu_item( @@ -1352,35 +1345,6 @@ def _add_action_items(self, target: TrackerTarget) -> None: dpg.add_separator() self._add_clear_items(target.cell) - def _add_block_items(self, target: TrackerTarget) -> None: - """Builds the clipboard items, acting on the block the actions were raised on. - - Paste is offered once a block has been copied, and it anchors at the target's own cell, so - the cell menu lands a block where the pointer is while the keys land it under the cursor. - Delete prints no key of its own, because ``Del`` empties a selection while one stands and - clears the cell under the cursor otherwise. - """ - dpg.add_menu_item( - label=self._lbl_context_copy, - shortcut=self._shortcuts.display(ShortcutId.TRACKER_COPY_BLOCK), - callback=lambda: self._blocks.copy_at(target), - ) - dpg.add_menu_item( - label=self._lbl_context_cut, - shortcut=self._shortcuts.display(ShortcutId.TRACKER_CUT_BLOCK), - callback=lambda: self._blocks.cut_at(target), - ) - dpg.add_menu_item( - label=self._lbl_context_paste, - shortcut=self._shortcuts.display(ShortcutId.TRACKER_PASTE_BLOCK), - enabled=self._blocks.can_paste(), - callback=lambda: self._blocks.paste_at(target), - ) - dpg.add_menu_item( - label=self._lbl_context_delete, - callback=lambda: self._blocks.delete_at(target), - ) - def _add_instrument_submenu(self, cell: TrackerCursor) -> None: with dpg.menu(label=self._lbl_context_set_instrument): samples = self._current_samples.samples if self._current_samples is not None else () @@ -1586,13 +1550,13 @@ def _block_action(self, shortcut_id: ShortcutId) -> bool: """ match shortcut_id: case ShortcutId.TRACKER_COPY_BLOCK: - self._blocks.copy() + self._surface.copy() case ShortcutId.TRACKER_CUT_BLOCK: - self._blocks.cut() + self._surface.cut() case ShortcutId.TRACKER_CLEAR_ROW if self._input_state.region is not None: - self._blocks.delete() + self._surface.delete() case ShortcutId.TRACKER_PASTE_BLOCK: - self._blocks.paste() + self._surface.paste() case _: return False @@ -1629,7 +1593,7 @@ def _adjust_at_cursor( finished typing, the rule the block gestures follow as well. """ self.commit_entry() - target = self.cursor_target() + target = self._surface.cursor_target() if target is not None: self.call(hook, target.region, delta) diff --git a/tests/suite/grid.py b/tests/suite/grid.py new file mode 100644 index 000000000..17737c2d4 --- /dev/null +++ b/tests/suite/grid.py @@ -0,0 +1,44 @@ +from typing import Any, Dict, Final + +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.ui.panels.sequencer.grid.surface import BlockShortcuts, GridEditSurface +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId + +CLIPBOARD_LABELS: Final[Dict[ContextElements, str]] = { + ContextElements.COPY: "Copy", + ContextElements.CUT: "Cut", + ContextElements.PASTE: "Paste", + ContextElements.DELETE: "Delete", +} + +TRACKER_BLOCK_SHORTCUTS: Final[BlockShortcuts] = BlockShortcuts( + copy=ShortcutId.TRACKER_COPY_BLOCK, + cut=ShortcutId.TRACKER_CUT_BLOCK, + paste=ShortcutId.TRACKER_PASTE_BLOCK, +) + +ORDER_BLOCK_SHORTCUTS: Final[BlockShortcuts] = BlockShortcuts( + copy=ShortcutId.ORDER_COPY_BLOCK, + cut=ShortcutId.ORDER_CUT_BLOCK, + paste=ShortcutId.ORDER_PASTE_BLOCK, +) + + +def attach_edit_surface( + panel: Any, + block_shortcuts: BlockShortcuts, + target: Any, +) -> None: + """Gives a hand-built grid panel the edit surface its menus and its cursor's target run through. + + A case that builds a panel without its constructor supplies the collaborators the panel would + have composed, and this is the one that resolves a target and prints the clipboard items. + """ + panel._surface = GridEditSurface( + grid=panel, + blocks=panel._blocks, + target=target, + shortcuts=panel._shortcuts, + block_shortcuts=block_shortcuts, + labels=CLIPBOARD_LABELS, + ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py index e41bad8f4..348eaeefa 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_gestures.py @@ -16,110 +16,64 @@ class _Target: anchor: str -CURSOR_TARGET: Final[_Target] = _Target(region="cursor block", anchor="cursor cell") NAMED_TARGET: Final[_Target] = _Target(region="named block", anchor="named cell") class _Grid: - """A grid recording what it was asked to do, in the order it was asked. - - The entry it settles and the hooks it announces through land in one list, so a test reads - both what a gesture reached and when the grid committed what was being typed. - """ - - def __init__( - self, - *, - target: Optional[_Target] = None, - can_paste: bool = True, - ) -> None: + """A grid recording the hooks it announced through, in the order it announced them.""" + + def __init__(self, *, can_paste: bool = True) -> None: self.events: List[str] = [] - self._target = target self.on_copy_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"copy {region}") self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}") self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste - def commit_entry(self) -> None: - self.events.append("commit") - - def cursor_target(self) -> Optional[_Target]: - return self._target - @dataclass(frozen=True) class GestureCase: - """One of the four gestures, raised at the cursor and on a target a menu named.""" + """One of the four gestures, raised on the target its door named.""" name: str - at_cursor: Callable[[Gestures], None] at_target: Callable[[Gestures, _Target], None] - from_cursor: str - from_target: str + reaches: str CASES: Final[Tuple[GestureCase, ...]] = ( GestureCase( name="copy", - at_cursor=lambda gestures: gestures.copy(), at_target=lambda gestures, target: gestures.copy_at(target), - from_cursor="copy cursor block", - from_target="copy named block", + reaches="copy named block", ), GestureCase( name="cut", - at_cursor=lambda gestures: gestures.cut(), at_target=lambda gestures, target: gestures.cut_at(target), - from_cursor="cut cursor block", - from_target="cut named block", + reaches="cut named block", ), GestureCase( name="delete", - at_cursor=lambda gestures: gestures.delete(), at_target=lambda gestures, target: gestures.delete_at(target), - from_cursor="delete cursor block", - from_target="delete named block", + reaches="delete named block", ), GestureCase( name="paste", - at_cursor=lambda gestures: gestures.paste(), at_target=lambda gestures, target: gestures.paste_at(target), - from_cursor="paste cursor cell", - from_target="paste named cell", + reaches="paste named cell", ), ) -@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) -class TestAtTheCursor: - """A key press acts on the target the cursor names, once the entry being typed has landed.""" - - def test_a_gesture_reaches_the_cursor_s_own_target(self, case: GestureCase) -> None: - grid = _Grid(target=CURSOR_TARGET) - - case.at_cursor(BlockGestures(grid=grid)) - - assert grid.events == ["commit", case.from_cursor] - - def test_a_grid_holding_no_cursor_settles_its_entry_and_stands(self, case: GestureCase) -> None: - grid = _Grid(target=None) - - case.at_cursor(BlockGestures(grid=grid)) - - assert grid.events == ["commit"] - - @pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) class TestOnANamedTarget: - """A menu item acts on the target it was built for, wherever the cursor happens to stand.""" + """Each door names the target it acts on, and the gesture reaches exactly that block.""" def test_a_gesture_reaches_the_target_it_was_handed(self, case: GestureCase) -> None: - grid = _Grid(target=CURSOR_TARGET) + grid = _Grid() case.at_target(BlockGestures(grid=grid), NAMED_TARGET) - assert grid.events == [case.from_target] + assert grid.events == [case.reaches] class TestPasteEnablement: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py new file mode 100644 index 000000000..9aacdf1d1 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py @@ -0,0 +1,261 @@ +from dataclasses import dataclass +from typing import Any, Callable, Final, List, Optional, Tuple + +import pytest + +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.ui.panels.sequencer.grid import surface as surface_module +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.surface import GridEditSurface +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS +from tests.suite.shortcuts import shipped_source + +CURSOR_CELL: Final[str] = "cursor cell" +CLICKED_CELL: Final[str] = "clicked cell" + +COPY_ITEM = 0 +CUT_ITEM = 1 +PASTE_ITEM = 2 +DELETE_ITEM = 3 + + +@dataclass(frozen=True) +class _Target: + """The cell a set of actions was raised on, and the block those actions act on.""" + + cell: str + region: str + + @property + def anchor(self) -> str: + return f"{self.cell} anchor" + + +@dataclass(frozen=True) +class _State: + """A grid's state as the surface reads it: where the cursor stands, and what a cell falls in.""" + + cursor: Optional[str] + + def region_at(self, cell: str) -> str: + return f"{cell} block" + + +def _target_for(cell: str) -> _Target: + return _Target(cell=cell, region=f"{cell} block") + + +CURSOR_TARGET: Final[_Target] = _target_for(CURSOR_CELL) +CLICKED_TARGET: Final[_Target] = _target_for(CLICKED_CELL) + + +class _Grid: + """A grid recording what it was asked to do, in the order it was asked. + + The entry it settles, the hooks it announces through and the action sets it was asked to build + land in one list, so a case reads both what a gesture reached and when the grid committed what + was being typed. + """ + + def __init__( + self, + *, + cursor: Optional[str] = CURSOR_CELL, + owns: bool = True, + can_paste: bool = True, + ) -> None: + self.events: List[str] = [] + self._cursor = cursor + self._owns = owns + self.on_copy_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"copy {region}") + self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}") + self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") + self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") + self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste + + def owns_keys(self) -> bool: + return self._owns + + def input_state(self) -> _State: + return _State(cursor=self._cursor) + + def add_action_items(self, target: _Target) -> None: + self.events.append(f"actions {target.cell}") + + def commit_entry(self) -> None: + self.events.append("commit") + + +def _surface(grid: _Grid) -> GridEditSurface[str, str, str, _Target]: + return GridEditSurface( + grid=grid, + blocks=BlockGestures(grid=grid), + target=_Target, + shortcuts=shipped_source(), + block_shortcuts=TRACKER_BLOCK_SHORTCUTS, + labels=CLIPBOARD_LABELS, + ) + + +@dataclass(frozen=True) +class GestureCase: + """One of the four gestures as a key press raises it, at the cursor's own target.""" + + name: str + at_cursor: Callable[[GridEditSurface[str, str, str, _Target]], None] + reaches: str + + +CASES: Final[Tuple[GestureCase, ...]] = ( + GestureCase( + name="copy", + at_cursor=lambda surface: surface.copy(), + reaches=f"copy {CURSOR_TARGET.region}", + ), + GestureCase( + name="cut", + at_cursor=lambda surface: surface.cut(), + reaches=f"cut {CURSOR_TARGET.region}", + ), + GestureCase( + name="delete", + at_cursor=lambda surface: surface.delete(), + reaches=f"delete {CURSOR_TARGET.region}", + ), + GestureCase( + name="paste", + at_cursor=lambda surface: surface.paste(), + reaches=f"paste {CURSOR_TARGET.anchor}", + ), +) + + +@dataclass +class RecordedItem: + """One item as it was registered, which is the whole of what a reader sees and clicks.""" + + label: str + shortcut: str + enabled: bool + callback: Callable[[], None] + + +class _MenuRecorder: + def __init__(self) -> None: + self.items: List[RecordedItem] = [] + + def add_menu_item(self, **kwargs: Any) -> int: + self.items.append( + RecordedItem( + label=kwargs["label"], + shortcut=kwargs.get("shortcut", ""), + enabled=kwargs.get("enabled", True), + callback=kwargs["callback"], + ) + ) + return 0 + + +@pytest.fixture +def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: + recorded = _MenuRecorder() + monkeypatch.setattr(surface_module.dpg, "add_menu_item", recorded.add_menu_item) + return recorded + + +@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) +class TestAtTheCursor: + """A key press acts on the target the cursor names, once the entry being typed has landed.""" + + def test_a_gesture_reaches_the_cursor_s_own_target(self, case: GestureCase) -> None: + grid = _Grid() + + case.at_cursor(_surface(grid)) + + assert grid.events == ["commit", case.reaches] + + def test_a_grid_holding_no_cursor_settles_its_entry_and_stands(self, case: GestureCase) -> None: + grid = _Grid(cursor=None) + + case.at_cursor(_surface(grid)) + + assert grid.events == ["commit"] + + +class TestEditActions: + def test_the_cursor_names_the_target_the_actions_are_built_for(self) -> None: + grid = _Grid() + + _surface(grid).build_edit_actions() + + assert grid.events == [f"actions {CURSOR_CELL}"] + + def test_a_grid_holding_no_cursor_builds_nothing(self) -> None: + """The menu bar asks whichever grid answers, and one without a cursor states no actions.""" + grid = _Grid(cursor=None) + + _surface(grid).build_edit_actions() + + assert grid.events == [] + + def test_a_grid_holding_no_cursor_names_no_target(self) -> None: + assert _surface(_Grid(cursor=None)).cursor_target() is None + + def test_the_cursor_names_its_own_target(self) -> None: + assert _surface(_Grid()).cursor_target() == CURSOR_TARGET + + def test_the_surface_answers_while_the_grid_owns_its_keys(self) -> None: + """The menu offers what the next press would reach, so one question decides both.""" + assert _surface(_Grid(owns=True)).owns_edit_actions() + assert not _surface(_Grid(owns=False)).owns_edit_actions() + + +class TestBlockItems: + def test_the_section_reads_as_the_four_clipboard_actions(self, recorder: _MenuRecorder) -> None: + _surface(_Grid()).add_block_items(CLICKED_TARGET) + + assert [item.label for item in recorder.items] == [ + CLIPBOARD_LABELS[ContextElements.COPY], + CLIPBOARD_LABELS[ContextElements.CUT], + CLIPBOARD_LABELS[ContextElements.PASTE], + CLIPBOARD_LABELS[ContextElements.DELETE], + ] + + def test_the_items_print_the_keys_the_grid_answers_to(self, recorder: _MenuRecorder) -> None: + """Each grid states its own three bindings, and an item prints exactly the one it fires.""" + shortcuts = shipped_source() + _surface(_Grid()).add_block_items(CLICKED_TARGET) + + assert recorder.items[COPY_ITEM].shortcut == shortcuts.display(ShortcutId.TRACKER_COPY_BLOCK) + assert recorder.items[CUT_ITEM].shortcut == shortcuts.display(ShortcutId.TRACKER_CUT_BLOCK) + assert recorder.items[PASTE_ITEM].shortcut == shortcuts.display(ShortcutId.TRACKER_PASTE_BLOCK) + + def test_delete_prints_no_key_of_its_own(self, recorder: _MenuRecorder) -> None: + """``Del`` empties a selection while one stands and clears the cell under the cursor + otherwise, so the grid resolves it from the selection rather than from one binding.""" + _surface(_Grid()).add_block_items(CLICKED_TARGET) + + assert recorder.items[DELETE_ITEM].shortcut == "" + + def test_the_items_act_on_the_block_they_were_raised_on(self, recorder: _MenuRecorder) -> None: + """A menu item names its target when it is built, so it reaches that block wherever the + cursor happens to stand.""" + grid = _Grid() + _surface(grid).add_block_items(CLICKED_TARGET) + + for item in recorder.items: + item.callback() + + assert grid.events == [ + f"copy {CLICKED_TARGET.region}", + f"cut {CLICKED_TARGET.region}", + f"paste {CLICKED_TARGET.anchor}", + f"delete {CLICKED_TARGET.region}", + ] + + def test_paste_awaits_a_copy(self, recorder: _MenuRecorder) -> None: + _surface(_Grid(can_paste=False)).add_block_items(CLICKED_TARGET) + + assert not recorder.items[PASTE_ITEM].enabled + assert all(item.enabled for index, item in enumerate(recorder.items) if index != PASTE_ITEM) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py index 3dd19e0c6..b82dcdae0 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_keys.py @@ -11,6 +11,7 @@ OrderCursor, OrderInputState, ) +from sampletones_application.ui.panels.sequencer.input.target import OrderTarget, TrackerTarget from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.ui.panels.sequencer.order import GUISequencerOrderPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel @@ -26,6 +27,11 @@ from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName from sampletones_shared.constants.music import OCTAVE_SEMITONES, SEMITONE_STEP +from tests.suite.grid import ( + ORDER_BLOCK_SHORTCUTS, + TRACKER_BLOCK_SHORTCUTS, + attach_edit_surface, +) from tests.suite.shortcuts import shipped_source ROW_COUNT = 64 @@ -93,6 +99,7 @@ def _panel( panel.on_adjust_volume = lambda region, delta: gestures.volume_shifted.append((region, delta)) panel.can_paste_block = lambda: True panel._blocks = BlockGestures(grid=panel) + attach_edit_surface(panel, TRACKER_BLOCK_SHORTCUTS, TrackerTarget) monkeypatch.setattr(panel, "_apply_state", lambda state: None) return panel @@ -115,6 +122,7 @@ def _order_panel( panel.on_set_order_entry = lambda channel, position, index: gestures.cleared.append((channel, position, index)) panel.can_paste_block = lambda: True panel._blocks = BlockGestures(grid=panel) + attach_edit_surface(panel, ORDER_BLOCK_SHORTCUTS, OrderTarget) monkeypatch.setattr(panel, "_apply_state", lambda state, notify=True: None) return panel diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index fa7060d13..2d856a023 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -8,11 +8,13 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer import order as order_module from sampletones_application.ui.panels.sequencer import tracker as tracker_module +from sampletones_application.ui.panels.sequencer.grid import surface as surface_module from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, ) +from sampletones_application.ui.panels.sequencer.input.target import OrderTarget, TrackerTarget from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState from sampletones_application.view_model.sequencer.region import ( OrderCell, @@ -23,6 +25,11 @@ from sampletones_application.view_model.sequencer.slot import TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName +from tests.suite.grid import ( + ORDER_BLOCK_SHORTCUTS, + TRACKER_BLOCK_SHORTCUTS, + attach_edit_surface, +) from tests.suite.shortcuts import shipped_source CLICKED_ROW = 4 @@ -78,13 +85,6 @@ def add_menu_item(self, **kwargs: Any) -> int: return 0 -CLIPBOARD_LABELS = { - "copy": "Copy", - "cut": "Cut", - "paste": "Paste", - "delete": "Delete", -} - TRACKER_LABELS = ( "note_off", "set_instrument", @@ -108,10 +108,7 @@ def add_menu_item(self, **kwargs: Any) -> int: def _labels(panel: Any, names: Tuple[str, ...]) -> None: - """Gives the panel the words its builders print, the clipboard four reading as they ship.""" - for name, text in CLIPBOARD_LABELS.items(): - setattr(panel, f"_lbl_context_{name}", text) - + """Gives the panel the words its own builders print, each reading as the action it names.""" for name in names: setattr(panel, f"_lbl_context_{name}", name) @@ -141,6 +138,7 @@ def _tracker_panel( panel.on_paste_block = gestures.pasted.append panel.can_paste_block = lambda: can_paste panel._blocks = BlockGestures(grid=panel) + attach_edit_surface(panel, TRACKER_BLOCK_SHORTCUTS, TrackerTarget) return panel @@ -161,6 +159,7 @@ def _order_panel( panel.on_paste_block = gestures.pasted.append panel.can_paste_block = lambda: can_paste panel._blocks = BlockGestures(grid=panel) + attach_edit_surface(panel, ORDER_BLOCK_SHORTCUTS, OrderTarget) return panel @@ -175,9 +174,11 @@ def _record_into( module: ModuleType, ) -> _MenuRecorder: recorder = _MenuRecorder() - monkeypatch.setattr(module.dpg, "add_menu_item", recorder.add_menu_item) - monkeypatch.setattr(module.dpg, "add_separator", lambda **_kwargs: 0) - monkeypatch.setattr(module.dpg, "menu", _submenu) + for target in (module, surface_module): + monkeypatch.setattr(target.dpg, "add_menu_item", recorder.add_menu_item) + monkeypatch.setattr(target.dpg, "add_separator", lambda **_kwargs: 0) + monkeypatch.setattr(target.dpg, "menu", _submenu) + return recorder @@ -218,7 +219,7 @@ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - target = panel._target_at( + target = panel._surface.target_at( TrackerCursor( CLICKED_ROW + 1, GeneratorName.PULSE1, @@ -232,7 +233,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - target = panel._target_at( + target = panel._surface.target_at( TrackerCursor( CLICKED_ROW, GeneratorName.TRIANGLE, @@ -250,7 +251,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _tracker_panel(Gestures()) - target = panel._target_at(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT)) + target = panel._surface.target_at(TrackerCursor(CLICKED_ROW, None, SubColumn.INSTRUMENT)) assert target.region.rows == range(CLICKED_ROW, CLICKED_ROW + 1) assert target.region.slots == (TrackerSlot(None, SubColumn.INSTRUMENT),) @@ -260,20 +261,20 @@ def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: panel = _tracker_panel(Gestures()) panel._input_state = _selected_tracker_state() - target = panel.cursor_target() + target = panel._surface.cursor_target() assert target is not None assert target.region == panel._input_state.region def test_a_grid_holding_no_cursor_names_no_target(self) -> None: - assert _tracker_panel(Gestures()).cursor_target() is None + assert _tracker_panel(Gestures())._surface.cursor_target() is None def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: panel = _tracker_panel(Gestures()) cursor = TrackerCursor(CLICKED_ROW, GeneratorName.NOISE, SubColumn.VOLUME) panel._input_state = TrackerInputState(cursor=cursor) - target = panel.cursor_target() + target = panel._surface.cursor_target() assert target is not None assert target.region == TrackerRegion( @@ -295,7 +296,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_tracker_state() selection = panel._input_state.region - panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) for item in tracker_recorder.items: item.callback() @@ -308,8 +309,8 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord gestures = Gestures() panel = _tracker_panel(gestures) - panel._add_block_items( - panel._target_at( + panel._surface.add_block_items( + panel._surface.target_at( TrackerCursor( CLICKED_ROW, GeneratorName.NOISE, @@ -324,7 +325,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, tracker_recorder: _MenuRecord def test_paste_awaits_a_copy(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures(), can_paste=False) - panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) assert tracker_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in tracker_recorder.items] == [True, True, False, True] @@ -335,7 +336,7 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _tracker_panel(Gestures()) - panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) assert [item.label for item in tracker_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] @@ -345,7 +346,7 @@ def test_a_menu_raised_inside_a_selection_acts_on_the_whole_of_it(self) -> None: panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - target = panel._target_at(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1)) + target = panel._surface.target_at(OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION + 1)) assert target.region == panel._input_state.region @@ -353,7 +354,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - target = panel._target_at(_order_cell(None)) + target = panel._surface.target_at(_order_cell(None)) assert target.region == OrderRegion( first_row=CHANNEL_AXIS.index(None), @@ -365,7 +366,7 @@ def test_a_menu_raised_outside_a_selection_acts_on_the_clicked_cell(self) -> Non def test_a_menu_raised_with_nothing_selected_acts_on_the_clicked_cell(self) -> None: panel = _order_panel(Gestures()) - target = panel._target_at(_order_cell(GeneratorName.PULSE1)) + target = panel._surface.target_at(_order_cell(GeneratorName.PULSE1)) assert target.region == OrderRegion( first_row=PULSE1_ROW, @@ -379,20 +380,20 @@ def test_the_cursor_resolves_to_the_selection_it_ends(self) -> None: panel = _order_panel(Gestures()) panel._input_state = _selected_order_state() - target = panel.cursor_target() + target = panel._surface.cursor_target() assert target is not None assert target.region == panel._input_state.region def test_a_table_holding_no_cursor_names_no_target(self) -> None: - assert _order_panel(Gestures()).cursor_target() is None + assert _order_panel(Gestures())._surface.cursor_target() is None def test_the_cursor_resolves_to_its_own_cell_with_nothing_selected(self) -> None: panel = _order_panel(Gestures()) cursor = OrderCursor(GeneratorName.PULSE1, CLICKED_POSITION) panel._input_state = OrderInputState(cursor=cursor) - target = panel.cursor_target() + target = panel._surface.cursor_target() assert target is not None assert target.region == OrderRegion( @@ -414,7 +415,7 @@ def test_the_items_hand_out_the_block_the_menu_was_raised_on( panel._input_state = _selected_order_state() selection = panel._input_state.region - panel._add_block_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) for item in order_recorder.items: item.callback() @@ -426,7 +427,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder gestures = Gestures() panel = _order_panel(gestures) - panel._add_block_items(panel._target_at(_order_cell(None))) + panel._surface.add_block_items(panel._surface.target_at(_order_cell(None))) order_recorder.items[PASTE_ITEM].callback() assert gestures.pasted == [OrderCell(generator=None, position=CLICKED_POSITION)] @@ -434,7 +435,7 @@ def test_a_paste_anchors_at_the_clicked_cell(self, order_recorder: _MenuRecorder def test_paste_awaits_a_copy(self, order_recorder: _MenuRecorder) -> None: panel = _order_panel(Gestures(), can_paste=False) - panel._add_block_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) assert order_recorder.items[PASTE_ITEM].enabled is False assert [item.enabled for item in order_recorder.items] == [True, True, False, True] @@ -445,7 +446,7 @@ def test_the_section_reads_as_the_four_clipboard_actions( ) -> None: panel = _order_panel(Gestures()) - panel._add_block_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) assert [item.label for item in order_recorder.items] == ["Copy", "Cut", "Paste", "Delete"] @@ -459,7 +460,7 @@ def test_the_tracker_action_set_opens_with_the_clipboard_items( ) -> None: panel = _tracker_panel(Gestures()) - panel._add_action_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) + panel.add_action_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) labels = [item.label for item in tracker_recorder.items] assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] @@ -471,7 +472,7 @@ def test_the_order_action_set_opens_with_the_clipboard_items( ) -> None: panel = _order_panel(Gestures()) - panel._add_action_items(panel._target_at(_order_cell(GeneratorName.PULSE1))) + panel.add_action_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) labels = [item.label for item in order_recorder.items] assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] @@ -484,7 +485,7 @@ class TestMenuItemOrder: def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _MenuRecorder) -> None: panel = _tracker_panel(Gestures()) - panel._add_block_items(panel._target_at(_tracker_cell(GeneratorName.PULSE1))) + panel._surface.add_block_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) labels = [item.label for item in tracker_recorder.items] assert labels[COPY_ITEM] == "Copy" From 193d60273bcac837faabc4db23a7a995d1ca6c1e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 13:46:57 +0200 Subject: [PATCH 088/152] Added: the samples panel to the Edit menu --- .../coordinators/tabs/sequencer.py | 6 +- .../ui/panels/sequencer/grid/surface.py | 195 ------------- .../panels/sequencer/grid/surface/__init__.py | 0 .../sequencer/grid/surface/clipboard.py | 89 ++++++ .../ui/panels/sequencer/grid/surface/edit.py | 126 +++++++++ .../panels/sequencer/grid/surface/protocol.py | 26 ++ .../panels/sequencer/grid/surface/targets.py | 54 ++++ .../ui/panels/sequencer/order.py | 10 +- .../ui/panels/sequencer/samples.py | 183 +++++++----- .../ui/panels/sequencer/tracker.py | 10 +- tests/suite/grid.py | 5 +- tests/suite/surface.py | 108 ++++++++ .../panels/sequencer/grid/surface/__init__.py | 0 .../sequencer/grid/surface/test_clipboard.py | 98 +++++++ .../sequencer/grid/surface/test_edit.py | 81 ++++++ .../sequencer/grid/surface/test_targets.py | 29 ++ .../ui/panels/sequencer/grid/test_surface.py | 261 ------------------ .../ui/panels/sequencer/test_block_menu.py | 4 +- .../ui/panels/sequencer/test_samples_menu.py | 219 +++++++++++++++ 19 files changed, 964 insertions(+), 540 deletions(-) delete mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface/edit.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface/protocol.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/surface/targets.py create mode 100644 tests/suite/surface.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_edit.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_targets.py delete mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index f28501682..a28525e0c 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1433,11 +1433,13 @@ def player(self) -> AudioPlayerProtocol: @property def edit_surfaces(self) -> Tuple[EditSurfaceProtocol, ...]: - """The grids offering editing gestures on the cell they hold a cursor in. + """The panels offering editing gestures on what they hold selected. - The two hold one cursor between them, so the menu bar reaches whichever one has it. + The three hold one selection between them — a cursor in either grid, a row in the samples + list — so the menu bar reaches whichever one has it. """ return ( self._sequencer_tracker_panel.edit_surface, self._sequencer_order_panel.edit_surface, + self._sequencer_samples_panel, ) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface.py b/src/sampletones_application/ui/panels/sequencer/grid/surface.py deleted file mode 100644 index 24d25246b..000000000 --- a/src/sampletones_application/ui/panels/sequencer/grid/surface.py +++ /dev/null @@ -1,195 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Callable, Dict, Final, Generic, Mapping, Optional, Protocol, Tuple, TypeVar - -import dearpygui.dearpygui as dpg - -from sampletones_application.categories.context import context_label -from sampletones_application.categories.elements.global_ import ContextElements -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures, BlockTarget -from sampletones_application.ui.panels.sequencer.input.state import GridInputState -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_application.utils.gui.shortcuts.source import ShortcutSource - -CursorT = TypeVar("CursorT") -RegionT = TypeVar("RegionT") -CellT = TypeVar("CellT") -TargetT = TypeVar("TargetT", bound=BlockTarget[Any, Any]) -TargetT_co = TypeVar("TargetT_co", covariant=True) -TargetT_contra = TypeVar("TargetT_contra", contravariant=True) -CursorT_contra = TypeVar("CursorT_contra", contravariant=True) -RegionT_contra = TypeVar("RegionT_contra", contravariant=True) - -CLIPBOARD_ACTIONS: Final[Tuple[ContextElements, ...]] = ( - ContextElements.COPY, - ContextElements.CUT, - ContextElements.PASTE, - ContextElements.DELETE, -) - - -def clipboard_labels(language_manager: LanguageManager) -> Dict[ContextElements, str]: - """The words every clipboard item prints, read from the vocabulary each grid shares.""" - return {element: context_label(language_manager, element) for element in CLIPBOARD_ACTIONS} - - -@dataclass(frozen=True) -class BlockShortcuts: - """The keys one grid answers the clipboard gestures with. - - Delete stands apart from the three: ``Del`` empties a selection while one stands and clears the - cell under the cursor otherwise, so the grid resolves it from the selection and its item prints - no key. - """ - - copy: ShortcutId - cut: ShortcutId - paste: ShortcutId - - -class TargetFactory(Protocol[CursorT_contra, RegionT_contra, TargetT_co]): - """How a grid's own target is built from the pair every target carries.""" - - def __call__(self, *, cell: CursorT_contra, region: RegionT_contra) -> TargetT_co: ... - - -class EditGrid(Protocol[CursorT, RegionT, TargetT_contra]): - """What a grid states to the edit surface built over it. - - The state carries the cursor and the selection a target is resolved from, and the grid states - its own actions for a target the surface hands back. Whether the grid owns those gestures at - this moment is the question its key scope already answers, so one predicate serves the keyboard - and the menu alike. - """ - - def owns_keys(self) -> bool: ... - - def input_state(self) -> GridInputState[CursorT, RegionT]: ... - - def add_action_items(self, target: TargetT_contra) -> None: ... - - def commit_entry(self) -> None: - """Writes the entry being typed into the cell the cursor stands on.""" - - -class GridEditSurface(Generic[CursorT, RegionT, CellT, TargetT]): - """A sequencer grid as the menu bar's Edit menu reaches it. - - The menu bar asks the surface for the actions of whichever grid holds the cursor, and the - surface asks that grid to build them for the target the cursor names. Both grids reach the - Edit menu through one implementation, so the menu states what the next key press would. - - It also prints the clipboard four, which are the actions every grid carries: the words come - from the shared context vocabulary and the accelerators from the grid's own three bindings, so - a grid states only which keys it answers to and the items read the same in either. - """ - - def __init__( - self, - *, - grid: EditGrid[CursorT, RegionT, TargetT], - blocks: BlockGestures[RegionT, CellT], - target: TargetFactory[CursorT, RegionT, TargetT], - shortcuts: ShortcutSource, - block_shortcuts: BlockShortcuts, - labels: Mapping[ContextElements, str], - ) -> None: - self._grid = grid - self._blocks = blocks - self._target = target - self._shortcuts = shortcuts - self._block_shortcuts = block_shortcuts - self._labels = labels - - def owns_edit_actions(self) -> bool: - """Whether the Edit menu states this grid's actions, which it does while the grid owns keys. - - The menu offers what the next press would reach, so one question decides both. - """ - return self._grid.owns_keys() - - def build_edit_actions(self) -> None: - """Builds the grid's whole action set for the cell the cursor stands on. - - The menu bar asks while the grid owns the editing gestures, so the cursor names the target - the same way a pointer names it on the cell menu. - """ - target = self.cursor_target() - if target is not None: - self._grid.add_action_items(target) - - def target_at(self, cell: CursorT) -> TargetT: - """The cell a set of actions is raised on, paired with the block those actions act on. - - The block is the selection the cell falls inside, or the cell alone, so a menu raised - within a selection reaches the whole of it and one raised elsewhere reaches what it names. - """ - return self._target( - cell=cell, - region=self._grid.input_state().region_at(cell), - ) - - def cursor_target(self) -> Optional[TargetT]: - """The target the cursor names, which is what a key press and the Edit menu act on.""" - cursor = self._grid.input_state().cursor - if cursor is None: - return None - - return self.target_at(cursor) - - def copy(self) -> None: - self._at_cursor(self._blocks.copy_at) - - def cut(self) -> None: - self._at_cursor(self._blocks.cut_at) - - def delete(self) -> None: - self._at_cursor(self._blocks.delete_at) - - def paste(self) -> None: - self._at_cursor(self._blocks.paste_at) - - def can_paste(self) -> bool: - """Whether a block stands ready for a paste to write.""" - return self._blocks.can_paste() - - def _at_cursor( - self, - gesture: Callable[[BlockTarget[RegionT, CellT]], None], - ) -> None: - """Raises a gesture on the cell the cursor stands on, the entry being typed landing first. - - Committing ahead of the gesture is what lets a block carry the value the reader has just - finished typing. - """ - self._grid.commit_entry() - target = self.cursor_target() - if target is not None: - gesture(target) - - def add_block_items(self, target: BlockTarget[RegionT, CellT]) -> None: - """Builds the clipboard items, acting on the block the actions were raised on. - - Paste is offered once a block has been copied, and it anchors at the target's own cell, so - the cell menu lands a block where the pointer is while the keys land it under the cursor. - """ - dpg.add_menu_item( - label=self._labels[ContextElements.COPY], - shortcut=self._shortcuts.display(self._block_shortcuts.copy), - callback=lambda: self._blocks.copy_at(target), - ) - dpg.add_menu_item( - label=self._labels[ContextElements.CUT], - shortcut=self._shortcuts.display(self._block_shortcuts.cut), - callback=lambda: self._blocks.cut_at(target), - ) - dpg.add_menu_item( - label=self._labels[ContextElements.PASTE], - shortcut=self._shortcuts.display(self._block_shortcuts.paste), - enabled=self._blocks.can_paste(), - callback=lambda: self._blocks.paste_at(target), - ) - dpg.add_menu_item( - label=self._labels[ContextElements.DELETE], - callback=lambda: self._blocks.delete_at(target), - ) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py new file mode 100644 index 000000000..56aeb8386 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/clipboard.py @@ -0,0 +1,89 @@ +from dataclasses import dataclass +from typing import Dict, Final, Generic, Mapping, Tuple, TypeVar + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.context import context_label +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures, BlockTarget +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource + +RegionT = TypeVar("RegionT") +CellT = TypeVar("CellT") + +CLIPBOARD_ACTIONS: Final[Tuple[ContextElements, ...]] = ( + ContextElements.COPY, + ContextElements.CUT, + ContextElements.PASTE, + ContextElements.DELETE, +) + + +@dataclass(frozen=True) +class BlockShortcuts: + """The keys one grid answers the clipboard gestures with. + + Delete stands apart from the three: ``Del`` empties a selection while one stands and clears the + cell under the cursor otherwise, so the grid resolves it from the selection and its item prints + no key. + """ + + copy: ShortcutId + cut: ShortcutId + paste: ShortcutId + + +class ClipboardItems(Generic[RegionT, CellT]): + """The four items every grid's menus print: copy, cut, paste and delete. + + The words come from the shared context vocabulary and the accelerators from the grid's own + three bindings, so a grid states only which keys it answers to and the items read the same in + either grid. + """ + + def __init__( + self, + *, + blocks: BlockGestures[RegionT, CellT], + shortcuts: ShortcutSource, + block_shortcuts: BlockShortcuts, + labels: Mapping[ContextElements, str], + ) -> None: + self._blocks = blocks + self._shortcuts = shortcuts + self._block_shortcuts = block_shortcuts + self._labels = labels + + @staticmethod + def labels(language_manager: LanguageManager) -> Dict[ContextElements, str]: + """The words every clipboard item prints, read from the vocabulary each grid shares.""" + return {element: context_label(language_manager, element) for element in CLIPBOARD_ACTIONS} + + def add_items(self, target: BlockTarget[RegionT, CellT]) -> None: + """Builds the four items, acting on the block the actions were raised on. + + Paste is offered once a block has been copied, and it anchors at the target's own cell, so + the cell menu lands a block where the pointer is while the keys land it under the cursor. + """ + dpg.add_menu_item( + label=self._labels[ContextElements.COPY], + shortcut=self._shortcuts.display(self._block_shortcuts.copy), + callback=lambda: self._blocks.copy_at(target), + ) + dpg.add_menu_item( + label=self._labels[ContextElements.CUT], + shortcut=self._shortcuts.display(self._block_shortcuts.cut), + callback=lambda: self._blocks.cut_at(target), + ) + dpg.add_menu_item( + label=self._labels[ContextElements.PASTE], + shortcut=self._shortcuts.display(self._block_shortcuts.paste), + enabled=self._blocks.can_paste(), + callback=lambda: self._blocks.paste_at(target), + ) + dpg.add_menu_item( + label=self._labels[ContextElements.DELETE], + callback=lambda: self._blocks.delete_at(target), + ) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/edit.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/edit.py new file mode 100644 index 000000000..19c429e13 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/edit.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +from typing import Any, Callable, Generic, Mapping, Optional, TypeVar + +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures, BlockTarget +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import BlockShortcuts, ClipboardItems +from sampletones_application.ui.panels.sequencer.grid.surface.protocol import EditGrid +from sampletones_application.ui.panels.sequencer.grid.surface.targets import CursorTargets, TargetFactory +from sampletones_application.utils.gui.shortcuts.source import ShortcutSource + +CursorT = TypeVar("CursorT") +RegionT = TypeVar("RegionT") +CellT = TypeVar("CellT") +TargetT = TypeVar("TargetT", bound=BlockTarget[Any, Any]) + + +class GridEditSurface(Generic[CursorT, RegionT, CellT, TargetT]): + """A sequencer grid as the menu bar's Edit menu and its own keys reach it. + + The menu bar asks the surface for the actions of whichever grid holds the cursor, and the + surface asks that grid to build them for the target the cursor names. A key press acts on that + same target, so one place resolves what the cursor stands on and every door agrees on it. + + Both grids reach the Edit menu through one implementation, so the menu states what the next key + press would. + """ + + def __init__( + self, + *, + grid: EditGrid[CursorT, RegionT, TargetT], + targets: CursorTargets[CursorT, RegionT, TargetT], + clipboard: ClipboardItems[RegionT, CellT], + blocks: BlockGestures[RegionT, CellT], + ) -> None: + self._grid = grid + self._targets = targets + self._clipboard = clipboard + self._blocks = blocks + + @classmethod + def build( + cls, + *, + grid: EditGrid[CursorT, RegionT, TargetT], + blocks: BlockGestures[RegionT, CellT], + target: TargetFactory[CursorT, RegionT, TargetT], + shortcuts: ShortcutSource, + block_shortcuts: BlockShortcuts, + labels: Mapping[ContextElements, str], + ) -> GridEditSurface[CursorT, RegionT, CellT, TargetT]: + """Composes the surface a grid states itself through, from the parts that grid supplies. + + A grid names its own target type, the three keys its clipboard items print and the gestures + its hooks answer; the collaborators built from those are the same in either grid. + """ + return cls( + grid=grid, + targets=CursorTargets( + state=grid.input_state, + target=target, + ), + clipboard=ClipboardItems( + blocks=blocks, + shortcuts=shortcuts, + block_shortcuts=block_shortcuts, + labels=labels, + ), + blocks=blocks, + ) + + def owns_edit_actions(self) -> bool: + """Whether the Edit menu states this grid's actions, which it does while the grid owns keys. + + The menu offers what the next press would reach, so one question decides both. + """ + return self._grid.owns_keys() + + def build_edit_actions(self) -> None: + """Builds the grid's whole action set for the cell the cursor stands on. + + The menu bar asks while the grid owns the editing gestures, so the cursor names the target + the same way a pointer names it on the cell menu. + """ + target = self.cursor_target() + if target is not None: + self._grid.add_action_items(target) + + def target_at(self, cell: CursorT) -> TargetT: + """The cell a set of actions is raised on, paired with the block those actions act on.""" + return self._targets.at(cell) + + def cursor_target(self) -> Optional[TargetT]: + """The target the cursor names, which is what a key press and the Edit menu act on.""" + return self._targets.at_cursor() + + def add_block_items(self, target: BlockTarget[RegionT, CellT]) -> None: + """Builds the clipboard items, acting on the block the actions were raised on.""" + self._clipboard.add_items(target) + + def copy(self) -> None: + self._at_cursor(self._blocks.copy_at) + + def cut(self) -> None: + self._at_cursor(self._blocks.cut_at) + + def delete(self) -> None: + self._at_cursor(self._blocks.delete_at) + + def paste(self) -> None: + self._at_cursor(self._blocks.paste_at) + + def _at_cursor( + self, + gesture: Callable[[BlockTarget[RegionT, CellT]], None], + ) -> None: + """Raises a gesture on the cell the cursor stands on, the entry being typed landing first. + + Committing ahead of the gesture is what lets a block carry the value the reader has just + finished typing. + """ + self._grid.commit_entry() + target = self.cursor_target() + if target is not None: + gesture(target) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/protocol.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/protocol.py new file mode 100644 index 000000000..a5e84734f --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/protocol.py @@ -0,0 +1,26 @@ +from typing import Protocol, TypeVar + +from sampletones_application.ui.panels.sequencer.input.state import GridInputState + +CursorT = TypeVar("CursorT") +RegionT = TypeVar("RegionT") +TargetT_contra = TypeVar("TargetT_contra", contravariant=True) + + +class EditGrid(Protocol[CursorT, RegionT, TargetT_contra]): + """What a grid states to the edit surface built over it. + + The state carries the cursor and the selection a target is resolved from, and the grid states + its own actions for a target the surface hands back. Whether the grid owns those gestures at + this moment is the question its key scope already answers, so one predicate serves the keyboard + and the menu alike. + """ + + def owns_keys(self) -> bool: ... + + def input_state(self) -> GridInputState[CursorT, RegionT]: ... + + def add_action_items(self, target: TargetT_contra) -> None: ... + + def commit_entry(self) -> None: + """Writes the entry being typed into the cell the cursor stands on.""" diff --git a/src/sampletones_application/ui/panels/sequencer/grid/surface/targets.py b/src/sampletones_application/ui/panels/sequencer/grid/surface/targets.py new file mode 100644 index 000000000..bd52e15fa --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/surface/targets.py @@ -0,0 +1,54 @@ +from typing import Any, Callable, Generic, Optional, Protocol, TypeVar + +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockTarget +from sampletones_application.ui.panels.sequencer.input.state import GridInputState + +CursorT = TypeVar("CursorT") +RegionT = TypeVar("RegionT") +TargetT = TypeVar("TargetT", bound=BlockTarget[Any, Any]) +TargetT_co = TypeVar("TargetT_co", covariant=True) +CursorT_contra = TypeVar("CursorT_contra", contravariant=True) +RegionT_contra = TypeVar("RegionT_contra", contravariant=True) + + +class TargetFactory(Protocol[CursorT_contra, RegionT_contra, TargetT_co]): + """How a grid's own target is built from the pair every target carries.""" + + def __call__(self, *, cell: CursorT_contra, region: RegionT_contra) -> TargetT_co: ... + + +class CursorTargets(Generic[CursorT, RegionT, TargetT]): + """Which block a cell reaches, in the grid whose state names the selection. + + Every door raises its actions on a target, and all three resolve one the same way: the cell is + paired with the block it falls inside. Reading the state afresh on each call is what keeps the + pair current, since a grid rebinds a frozen state on every edit. + """ + + def __init__( + self, + *, + state: Callable[[], GridInputState[CursorT, RegionT]], + target: TargetFactory[CursorT, RegionT, TargetT], + ) -> None: + self._state = state + self._target = target + + def at(self, cell: CursorT) -> TargetT: + """The cell a set of actions is raised on, paired with the block those actions act on. + + The block is the selection the cell falls inside, or the cell alone, so a menu raised + within a selection reaches the whole of it and one raised elsewhere reaches what it names. + """ + return self._target( + cell=cell, + region=self._state().region_at(cell), + ) + + def at_cursor(self) -> Optional[TargetT]: + """The target the cursor names, which is what a key press and the Edit menu act on.""" + cursor = self._state().cursor + if cursor is None: + return None + + return self.at(cursor) diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index a465b85d5..6453f737f 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -45,11 +45,11 @@ ) from sampletones_application.ui.panels.sequencer.columns import channel_color from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures -from sampletones_application.ui.panels.sequencer.grid.surface import ( +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( BlockShortcuts, - GridEditSurface, - clipboard_labels, + ClipboardItems, ) +from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface from sampletones_application.ui.panels.sequencer.input.order import ( INDEX_DIGITS, OrderCursor, @@ -189,7 +189,7 @@ def __init__( self.on_channels_unmuted: Optional[VoidCallback] = None self._blocks: BlockGestures[OrderRegion, OrderCell] = BlockGestures(grid=self) - self._surface: OrderEditSurface = GridEditSurface( + self._surface: OrderEditSurface = GridEditSurface.build( grid=self, blocks=self._blocks, target=OrderTarget, @@ -199,7 +199,7 @@ def __init__( cut=ShortcutId.ORDER_CUT_BLOCK, paste=ShortcutId.ORDER_PASTE_BLOCK, ), - labels=clipboard_labels(language_manager), + labels=ClipboardItems.labels(language_manager), ) self._lbl_order = self._label(language_manager, SequencerOrderElements.ORDER_TEXT) self._load_row_labels(language_manager) diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index 341b56c13..1ccc4c986 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -1,7 +1,12 @@ +from dataclasses import dataclass from typing import Callable, Dict, Final, List, Optional, Tuple import dearpygui.dearpygui as dpg +from sampletones_application.categories.context import context_label +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.categories.elements.sequencer import SequencerInstrumentsElements +from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.tags.compose import compose_tag @@ -43,12 +48,40 @@ 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, -} + +@dataclass(frozen=True) +class SampleMove: + """One of the four moves, as its key press and its menu item each name it.""" + + element: SequencerInstrumentsElements + shortcut: ShortcutId + direction: MoveDirection + + +SAMPLE_MOVES: Final[Tuple[SampleMove, ...]] = ( + SampleMove( + element=SequencerInstrumentsElements.CONTEXT_MOVE_UP, + shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_UP, + direction=MoveDirection.PREVIOUS, + ), + SampleMove( + element=SequencerInstrumentsElements.CONTEXT_MOVE_DOWN, + shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_DOWN, + direction=MoveDirection.NEXT, + ), + SampleMove( + element=SequencerInstrumentsElements.CONTEXT_MOVE_TOP, + shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_TOP, + direction=MoveDirection.FIRST, + ), + SampleMove( + element=SequencerInstrumentsElements.CONTEXT_MOVE_BOTTOM, + shortcut=ShortcutId.SAMPLES_MOVE_SAMPLE_TO_BOTTOM, + direction=MoveDirection.LAST, + ), +) + +MOVE_DIRECTIONS: Final[Dict[ShortcutId, MoveDirection]] = {move.shortcut: move.direction for move in SAMPLE_MOVES} class GUISequencerSamplesPanel(GUIPanel): @@ -92,7 +125,7 @@ def __init__( def create_panel(self, parent: str) -> None: with self._collapsible_card( parent, - self._language_manager["sequencer.instruments.label.instruments_text"], + self._label(self._language_manager, SequencerInstrumentsElements.INSTRUMENTS_TEXT), glyph=self._glyphs.headers.samples, ): self._create_samples_table() @@ -142,17 +175,17 @@ def _create_samples_table(self) -> None: ), ): dpg.add_table_column( - label=self._language_manager["sequencer.instruments.label.column_id"], + label=self._label(self._language_manager, SequencerInstrumentsElements.COLUMN_ID), width_fixed=True, init_width_or_weight=self._layout.table_cells.instrument.id, ) dpg.add_table_column( - label=self._language_manager["sequencer.instruments.label.column_name"], + label=self._label(self._language_manager, SequencerInstrumentsElements.COLUMN_NAME), width_stretch=True, init_width_or_weight=self._layout.table_cells.instrument.name, ) dpg.add_table_column( - label=self._language_manager["sequencer.instruments.label.column_loop"], + label=self._label(self._language_manager, SequencerInstrumentsElements.COLUMN_LOOP), width_fixed=True, init_width_or_weight=self._layout.table_cells.instrument.loop, ) @@ -489,77 +522,91 @@ def _show_context_menu(self, position: int, sample_id: str) -> None: if entry is None: return + target = SampleSelection( + sample_id=sample_id, + position=position, + name=entry.name, + ) with context_menu(): - header = dpg.add_text(display_sample_label(position, entry.name)) + header = dpg.add_text(target.label) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() add_play_menu_item( - self._language_manager["global.context.label.play"], + context_label(self._language_manager, ContextElements.PLAY), lambda: self.call( self.on_play_requested, sample_id, ), ) - dpg.add_menu_item( - label=self._language_manager["sequencer.instruments.label.context_edit"], - callback=lambda: self.call(self.on_sample_edit_requested, sample_id), - ) - dpg.add_menu_item( - label=self._language_manager["sequencer.instruments.label.context_rename"], - callback=lambda: self._start_rename(sample_id), - ) - dpg.add_menu_item( - label=self._language_manager["sequencer.instruments.label.context_duplicate"], - callback=lambda: self.call(self.on_duplicate_requested, sample_id), - ) dpg.add_separator() - dpg.add_menu_item( - label=self._language_manager["sequencer.instruments.label.context_remove"], - callback=lambda: self.call(self.on_remove_requested, sample_id), - ) - dpg.add_separator() - count = len(self._entries) - self._add_move_item( - self._language_manager["sequencer.instruments.label.context_move_up"], - sample_id, - position, - count, - MoveDirection.PREVIOUS, - ) - self._add_move_item( - self._language_manager["sequencer.instruments.label.context_move_down"], - sample_id, - position, - count, - MoveDirection.NEXT, - ) - self._add_move_item( - self._language_manager["sequencer.instruments.label.context_move_top"], - sample_id, - position, - count, - MoveDirection.FIRST, - ) - self._add_move_item( - self._language_manager["sequencer.instruments.label.context_move_bottom"], - sample_id, - position, - count, - MoveDirection.LAST, - ) + self.add_action_items(target) + + def owns_edit_actions(self) -> bool: + """Whether the Edit menu states this panel's actions, which it does while it holds a sample. + + The menu offers what the next press would reach, so the key scope decides it, and the + selection those keys act on is the one the actions are built for. + """ + return self._keys_active() and self.selection is not None + + def build_edit_actions(self) -> None: + """Builds the panel's whole action set for the sample the selection holds.""" + selection = self.selection + if selection is not None: + self.add_action_items(selection) + + def add_action_items(self, target: SampleSelection) -> None: + """Builds every action a sample offers, in the order each menu prints them. + + The panel states its actions once, and whoever asks for them decides where they are shown: + the row menu asks for the sample a pointer landed on, and the menu bar asks for the one the + selection holds. An action added here reaches both, printing the key it answers to. + """ + dpg.add_menu_item( + label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_EDIT), + callback=lambda: self.call(self.on_sample_edit_requested, target.sample_id), + ) + dpg.add_menu_item( + label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_RENAME), + shortcut=self._shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE), + callback=lambda: self._start_rename(target.sample_id), + ) + dpg.add_menu_item( + label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_DUPLICATE), + callback=lambda: self.call(self.on_duplicate_requested, target.sample_id), + ) + dpg.add_separator() + dpg.add_menu_item( + label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_REMOVE), + shortcut=self._shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE), + callback=lambda: self.call(self.on_remove_requested, target.sample_id), + ) + dpg.add_separator() + for move in SAMPLE_MOVES: + self._add_move_item(move, target) def _add_move_item( self, - label: str, - sample_id: str, - position: int, - count: int, - direction: MoveDirection, + move: SampleMove, + target: SampleSelection, ) -> None: - """Add a move item, greyed out (disabled) when the move would have no effect.""" - target = direction.target(position, count) + """Builds one move item, offered while the move carries the sample somewhere new.""" + position = move.direction.target(target.position, len(self._entries)) dpg.add_menu_item( - label=label, - enabled=target is not None, - callback=lambda: self.call(self.on_move_requested, sample_id, target), + label=self._label(self._language_manager, move.element), + shortcut=self._shortcuts.display(move.shortcut), + enabled=position is not None, + callback=lambda: self.call(self.on_move_requested, target.sample_id, position), ) + + @staticmethod + def _label( + language_manager: LanguageManager, + element: SequencerInstrumentsElements, + ) -> str: + return language_manager[ + Page.SEQUENCER, + Panel.INSTRUMENTS, + TextType.LABEL, + element, + ] diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index c5b34c2bc..ef623556e 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -49,11 +49,11 @@ ) from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures -from sampletones_application.ui.panels.sequencer.grid.surface import ( +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( BlockShortcuts, - GridEditSurface, - clipboard_labels, + ClipboardItems, ) +from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface from sampletones_application.ui.panels.sequencer.input.edit import ( ClearAction, EditAction, @@ -266,7 +266,7 @@ def __init__( self.on_channels_unmuted: Optional[VoidCallback] = None self._blocks: BlockGestures[TrackerRegion, TrackerCell] = BlockGestures(grid=self) - self._surface: TrackerEditSurface = GridEditSurface( + self._surface: TrackerEditSurface = GridEditSurface.build( grid=self, blocks=self._blocks, target=TrackerTarget, @@ -276,7 +276,7 @@ def __init__( cut=ShortcutId.TRACKER_CUT_BLOCK, paste=ShortcutId.TRACKER_PASTE_BLOCK, ), - labels=clipboard_labels(language_manager), + labels=ClipboardItems.labels(language_manager), ) self.pattern_theme = ThemeRegistry.get(TAG_SEQUENCER_THEME_TABLE_PATTERN) diff --git a/tests/suite/grid.py b/tests/suite/grid.py index 17737c2d4..d20ccbb4a 100644 --- a/tests/suite/grid.py +++ b/tests/suite/grid.py @@ -1,7 +1,8 @@ from typing import Any, Dict, Final from sampletones_application.categories.elements.global_ import ContextElements -from sampletones_application.ui.panels.sequencer.grid.surface import BlockShortcuts, GridEditSurface +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import BlockShortcuts +from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface from sampletones_application.utils.gui.shortcuts.ids import ShortcutId CLIPBOARD_LABELS: Final[Dict[ContextElements, str]] = { @@ -34,7 +35,7 @@ def attach_edit_surface( A case that builds a panel without its constructor supplies the collaborators the panel would have composed, and this is the one that resolves a target and prints the clipboard items. """ - panel._surface = GridEditSurface( + panel._surface = GridEditSurface.build( grid=panel, blocks=panel._blocks, target=target, diff --git a/tests/suite/surface.py b/tests/suite/surface.py new file mode 100644 index 000000000..e13a4227a --- /dev/null +++ b/tests/suite/surface.py @@ -0,0 +1,108 @@ +from dataclasses import dataclass +from typing import Callable, Final, List, Optional + +from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ClipboardItems +from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface +from sampletones_application.ui.panels.sequencer.grid.surface.targets import CursorTargets +from sampletones_application.ui.panels.sequencer.input.state import GridInputState +from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS +from tests.suite.shortcuts import shipped_source + +CURSOR_CELL: Final[str] = "cursor cell" +CLICKED_CELL: Final[str] = "clicked cell" + + +@dataclass(frozen=True) +class Target: + """The cell a set of actions was raised on, and the block those actions act on.""" + + cell: str + region: str + + @classmethod + def at(cls, cell: str) -> "Target": + """The target a cell resolves to, which is the pair the fake state states for it.""" + return cls(cell=cell, region=f"{cell} block") + + @property + def anchor(self) -> str: + return f"{self.cell} anchor" + + +@dataclass(frozen=True) +class State(GridInputState[str, str]): + """A grid's state as the surface reads it: where the cursor stands, and what a cell falls in. + + A cell's own block reads as the cell it was bounded from, so a target names the cell that + raised it and the block it resolved to in one readable pair. + """ + + def _region_between(self, first: str, _second: str) -> str: + return f"{first} block" + + def _covers(self, region: str, cell: str) -> bool: + return region == f"{cell} block" + + +CURSOR_TARGET: Final[Target] = Target.at(CURSOR_CELL) +CLICKED_TARGET: Final[Target] = Target.at(CLICKED_CELL) + + +class Grid: + """A grid recording what it was asked to do, in the order it was asked. + + The entry it settles, the hooks it announces through and the action sets it was asked to build + land in one list, so a case reads both what a gesture reached and when the grid committed what + was being typed. + """ + + def __init__( + self, + *, + cursor: Optional[str] = CURSOR_CELL, + owns: bool = True, + can_paste: bool = True, + ) -> None: + self.events: List[str] = [] + self.cursor = cursor + self._owns = owns + self.on_copy_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"copy {region}") + self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}") + self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") + self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") + self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste + + def owns_keys(self) -> bool: + return self._owns + + def input_state(self) -> State: + return State(cursor=self.cursor) + + def add_action_items(self, target: Target) -> None: + self.events.append(f"actions {target.cell}") + + def commit_entry(self) -> None: + self.events.append("commit") + + def cursor_targets(self) -> CursorTargets[str, str, Target]: + """The target resolver over this grid, which is what each door asks for a cell's block.""" + return CursorTargets(state=self.input_state, target=Target) + + def clipboard_items(self) -> ClipboardItems[str, str]: + """The four items over this grid, printing the tracker's own keys and stand-in words.""" + return ClipboardItems( + blocks=BlockGestures(grid=self), + shortcuts=shipped_source(), + block_shortcuts=TRACKER_BLOCK_SHORTCUTS, + labels=CLIPBOARD_LABELS, + ) + + def edit_surface(self) -> GridEditSurface[str, str, str, Target]: + """The surface over this grid, composed from the collaborators a real panel supplies.""" + return GridEditSurface( + grid=self, + targets=self.cursor_targets(), + clipboard=self.clipboard_items(), + blocks=BlockGestures(grid=self), + ) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py new file mode 100644 index 000000000..5d606de1f --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_clipboard.py @@ -0,0 +1,98 @@ +from dataclasses import dataclass +from typing import Any, Callable, List + +import pytest + +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.ui.panels.sequencer.grid.surface import clipboard as clipboard_module +from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS +from tests.suite.shortcuts import shipped_source +from tests.suite.surface import CLICKED_TARGET, Grid + +COPY_ITEM = 0 +CUT_ITEM = 1 +PASTE_ITEM = 2 +DELETE_ITEM = 3 + + +@dataclass +class RecordedItem: + """One item as it was registered, which is the whole of what a reader sees and clicks.""" + + label: str + shortcut: str + enabled: bool + callback: Callable[[], None] + + +class _MenuRecorder: + def __init__(self) -> None: + self.items: List[RecordedItem] = [] + + def add_menu_item(self, **kwargs: Any) -> int: + self.items.append( + RecordedItem( + label=kwargs["label"], + shortcut=kwargs.get("shortcut", ""), + enabled=kwargs.get("enabled", True), + callback=kwargs["callback"], + ) + ) + return 0 + + +@pytest.fixture +def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: + recorded = _MenuRecorder() + monkeypatch.setattr(clipboard_module.dpg, "add_menu_item", recorded.add_menu_item) + return recorded + + +class TestClipboardItems: + def test_the_section_reads_as_the_four_clipboard_actions(self, recorder: _MenuRecorder) -> None: + Grid().clipboard_items().add_items(CLICKED_TARGET) + + assert [item.label for item in recorder.items] == [ + CLIPBOARD_LABELS[ContextElements.COPY], + CLIPBOARD_LABELS[ContextElements.CUT], + CLIPBOARD_LABELS[ContextElements.PASTE], + CLIPBOARD_LABELS[ContextElements.DELETE], + ] + + def test_the_items_print_the_keys_the_grid_answers_to(self, recorder: _MenuRecorder) -> None: + """Each grid states its own three bindings, and an item prints exactly the one it fires.""" + shortcuts = shipped_source() + Grid().clipboard_items().add_items(CLICKED_TARGET) + + assert recorder.items[COPY_ITEM].shortcut == shortcuts.display(TRACKER_BLOCK_SHORTCUTS.copy) + assert recorder.items[CUT_ITEM].shortcut == shortcuts.display(TRACKER_BLOCK_SHORTCUTS.cut) + assert recorder.items[PASTE_ITEM].shortcut == shortcuts.display(TRACKER_BLOCK_SHORTCUTS.paste) + + def test_delete_prints_no_key_of_its_own(self, recorder: _MenuRecorder) -> None: + """``Del`` empties a selection while one stands and clears the cell under the cursor + otherwise, so the grid resolves it from the selection rather than from one binding.""" + Grid().clipboard_items().add_items(CLICKED_TARGET) + + assert recorder.items[DELETE_ITEM].shortcut == "" + + def test_the_items_act_on_the_block_they_were_raised_on(self, recorder: _MenuRecorder) -> None: + """A menu item names its target when it is built, so it reaches that block wherever the + cursor happens to stand.""" + grid = Grid() + grid.clipboard_items().add_items(CLICKED_TARGET) + + for item in recorder.items: + item.callback() + + assert grid.events == [ + f"copy {CLICKED_TARGET.region}", + f"cut {CLICKED_TARGET.region}", + f"paste {CLICKED_TARGET.anchor}", + f"delete {CLICKED_TARGET.region}", + ] + + def test_paste_awaits_a_copy(self, recorder: _MenuRecorder) -> None: + Grid(can_paste=False).clipboard_items().add_items(CLICKED_TARGET) + + assert not recorder.items[PASTE_ITEM].enabled + assert all(item.enabled for index, item in enumerate(recorder.items) if index != PASTE_ITEM) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_edit.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_edit.py new file mode 100644 index 000000000..fc1db3a11 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_edit.py @@ -0,0 +1,81 @@ +from dataclasses import dataclass +from typing import Callable, Final, Tuple + +import pytest + +from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface +from tests.suite.surface import CURSOR_CELL, CURSOR_TARGET, Grid, Target + + +@dataclass(frozen=True) +class GestureCase: + """One of the four gestures as a key press raises it, at the cursor's own target.""" + + name: str + at_cursor: Callable[[GridEditSurface[str, str, str, Target]], None] + reaches: str + + +CASES: Final[Tuple[GestureCase, ...]] = ( + GestureCase( + name="copy", + at_cursor=lambda surface: surface.copy(), + reaches=f"copy {CURSOR_TARGET.region}", + ), + GestureCase( + name="cut", + at_cursor=lambda surface: surface.cut(), + reaches=f"cut {CURSOR_TARGET.region}", + ), + GestureCase( + name="delete", + at_cursor=lambda surface: surface.delete(), + reaches=f"delete {CURSOR_TARGET.region}", + ), + GestureCase( + name="paste", + at_cursor=lambda surface: surface.paste(), + reaches=f"paste {CURSOR_TARGET.anchor}", + ), +) + + +@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) +class TestAtTheCursor: + """A key press acts on the target the cursor names, once the entry being typed has landed.""" + + def test_a_gesture_reaches_the_cursor_s_own_target(self, case: GestureCase) -> None: + grid = Grid() + + case.at_cursor(grid.edit_surface()) + + assert grid.events == ["commit", case.reaches] + + def test_a_grid_holding_no_cursor_settles_its_entry_and_stands(self, case: GestureCase) -> None: + grid = Grid(cursor=None) + + case.at_cursor(grid.edit_surface()) + + assert grid.events == ["commit"] + + +class TestEditActions: + def test_the_cursor_names_the_target_the_actions_are_built_for(self) -> None: + grid = Grid() + + grid.edit_surface().build_edit_actions() + + assert grid.events == [f"actions {CURSOR_CELL}"] + + def test_a_grid_holding_no_cursor_builds_nothing(self) -> None: + """The menu bar asks whichever grid answers, and one without a cursor states no actions.""" + grid = Grid(cursor=None) + + grid.edit_surface().build_edit_actions() + + assert grid.events == [] + + def test_the_surface_answers_while_the_grid_owns_its_keys(self) -> None: + """The menu offers what the next press would reach, so one question decides both.""" + assert Grid(owns=True).edit_surface().owns_edit_actions() + assert not Grid(owns=False).edit_surface().owns_edit_actions() diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_targets.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_targets.py new file mode 100644 index 000000000..51d1e47c3 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/surface/test_targets.py @@ -0,0 +1,29 @@ +from tests.suite.surface import CLICKED_CELL, CLICKED_TARGET, CURSOR_TARGET, Grid + + +class TestTargetAtACell: + def test_a_cell_is_paired_with_the_block_it_falls_in(self) -> None: + assert Grid().cursor_targets().at(CLICKED_CELL) == CLICKED_TARGET + + def test_a_cell_away_from_the_cursor_names_its_own_block(self) -> None: + """A menu raised anywhere reaches what it names, so the cursor's cell has no say in it.""" + assert Grid().cursor_targets().at(CLICKED_CELL) != CURSOR_TARGET + + +class TestTargetAtTheCursor: + def test_the_cursor_names_its_own_target(self) -> None: + assert Grid().cursor_targets().at_cursor() == CURSOR_TARGET + + def test_a_grid_holding_no_cursor_names_no_target(self) -> None: + assert Grid(cursor=None).cursor_targets().at_cursor() is None + + def test_the_state_is_read_on_each_call(self) -> None: + """A grid rebinds a frozen state on every edit, so a target resolved once would go stale.""" + grid = Grid() + targets = grid.cursor_targets() + first = targets.at_cursor() + + grid.cursor = CLICKED_CELL + + assert first == CURSOR_TARGET + assert targets.at_cursor() == CLICKED_TARGET diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py deleted file mode 100644 index 9aacdf1d1..000000000 --- a/tests/unit/sampletones_application/ui/panels/sequencer/grid/test_surface.py +++ /dev/null @@ -1,261 +0,0 @@ -from dataclasses import dataclass -from typing import Any, Callable, Final, List, Optional, Tuple - -import pytest - -from sampletones_application.categories.elements.global_ import ContextElements -from sampletones_application.ui.panels.sequencer.grid import surface as surface_module -from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures -from sampletones_application.ui.panels.sequencer.grid.surface import GridEditSurface -from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS -from tests.suite.shortcuts import shipped_source - -CURSOR_CELL: Final[str] = "cursor cell" -CLICKED_CELL: Final[str] = "clicked cell" - -COPY_ITEM = 0 -CUT_ITEM = 1 -PASTE_ITEM = 2 -DELETE_ITEM = 3 - - -@dataclass(frozen=True) -class _Target: - """The cell a set of actions was raised on, and the block those actions act on.""" - - cell: str - region: str - - @property - def anchor(self) -> str: - return f"{self.cell} anchor" - - -@dataclass(frozen=True) -class _State: - """A grid's state as the surface reads it: where the cursor stands, and what a cell falls in.""" - - cursor: Optional[str] - - def region_at(self, cell: str) -> str: - return f"{cell} block" - - -def _target_for(cell: str) -> _Target: - return _Target(cell=cell, region=f"{cell} block") - - -CURSOR_TARGET: Final[_Target] = _target_for(CURSOR_CELL) -CLICKED_TARGET: Final[_Target] = _target_for(CLICKED_CELL) - - -class _Grid: - """A grid recording what it was asked to do, in the order it was asked. - - The entry it settles, the hooks it announces through and the action sets it was asked to build - land in one list, so a case reads both what a gesture reached and when the grid committed what - was being typed. - """ - - def __init__( - self, - *, - cursor: Optional[str] = CURSOR_CELL, - owns: bool = True, - can_paste: bool = True, - ) -> None: - self.events: List[str] = [] - self._cursor = cursor - self._owns = owns - self.on_copy_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"copy {region}") - self.on_cut_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"cut {region}") - self.on_delete_block: Optional[Callable[[str], None]] = lambda region: self.events.append(f"delete {region}") - self.on_paste_block: Optional[Callable[[str], None]] = lambda cell: self.events.append(f"paste {cell}") - self.can_paste_block: Optional[Callable[[], bool]] = lambda: can_paste - - def owns_keys(self) -> bool: - return self._owns - - def input_state(self) -> _State: - return _State(cursor=self._cursor) - - def add_action_items(self, target: _Target) -> None: - self.events.append(f"actions {target.cell}") - - def commit_entry(self) -> None: - self.events.append("commit") - - -def _surface(grid: _Grid) -> GridEditSurface[str, str, str, _Target]: - return GridEditSurface( - grid=grid, - blocks=BlockGestures(grid=grid), - target=_Target, - shortcuts=shipped_source(), - block_shortcuts=TRACKER_BLOCK_SHORTCUTS, - labels=CLIPBOARD_LABELS, - ) - - -@dataclass(frozen=True) -class GestureCase: - """One of the four gestures as a key press raises it, at the cursor's own target.""" - - name: str - at_cursor: Callable[[GridEditSurface[str, str, str, _Target]], None] - reaches: str - - -CASES: Final[Tuple[GestureCase, ...]] = ( - GestureCase( - name="copy", - at_cursor=lambda surface: surface.copy(), - reaches=f"copy {CURSOR_TARGET.region}", - ), - GestureCase( - name="cut", - at_cursor=lambda surface: surface.cut(), - reaches=f"cut {CURSOR_TARGET.region}", - ), - GestureCase( - name="delete", - at_cursor=lambda surface: surface.delete(), - reaches=f"delete {CURSOR_TARGET.region}", - ), - GestureCase( - name="paste", - at_cursor=lambda surface: surface.paste(), - reaches=f"paste {CURSOR_TARGET.anchor}", - ), -) - - -@dataclass -class RecordedItem: - """One item as it was registered, which is the whole of what a reader sees and clicks.""" - - label: str - shortcut: str - enabled: bool - callback: Callable[[], None] - - -class _MenuRecorder: - def __init__(self) -> None: - self.items: List[RecordedItem] = [] - - def add_menu_item(self, **kwargs: Any) -> int: - self.items.append( - RecordedItem( - label=kwargs["label"], - shortcut=kwargs.get("shortcut", ""), - enabled=kwargs.get("enabled", True), - callback=kwargs["callback"], - ) - ) - return 0 - - -@pytest.fixture -def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: - recorded = _MenuRecorder() - monkeypatch.setattr(surface_module.dpg, "add_menu_item", recorded.add_menu_item) - return recorded - - -@pytest.mark.parametrize("case", CASES, ids=[case.name for case in CASES]) -class TestAtTheCursor: - """A key press acts on the target the cursor names, once the entry being typed has landed.""" - - def test_a_gesture_reaches_the_cursor_s_own_target(self, case: GestureCase) -> None: - grid = _Grid() - - case.at_cursor(_surface(grid)) - - assert grid.events == ["commit", case.reaches] - - def test_a_grid_holding_no_cursor_settles_its_entry_and_stands(self, case: GestureCase) -> None: - grid = _Grid(cursor=None) - - case.at_cursor(_surface(grid)) - - assert grid.events == ["commit"] - - -class TestEditActions: - def test_the_cursor_names_the_target_the_actions_are_built_for(self) -> None: - grid = _Grid() - - _surface(grid).build_edit_actions() - - assert grid.events == [f"actions {CURSOR_CELL}"] - - def test_a_grid_holding_no_cursor_builds_nothing(self) -> None: - """The menu bar asks whichever grid answers, and one without a cursor states no actions.""" - grid = _Grid(cursor=None) - - _surface(grid).build_edit_actions() - - assert grid.events == [] - - def test_a_grid_holding_no_cursor_names_no_target(self) -> None: - assert _surface(_Grid(cursor=None)).cursor_target() is None - - def test_the_cursor_names_its_own_target(self) -> None: - assert _surface(_Grid()).cursor_target() == CURSOR_TARGET - - def test_the_surface_answers_while_the_grid_owns_its_keys(self) -> None: - """The menu offers what the next press would reach, so one question decides both.""" - assert _surface(_Grid(owns=True)).owns_edit_actions() - assert not _surface(_Grid(owns=False)).owns_edit_actions() - - -class TestBlockItems: - def test_the_section_reads_as_the_four_clipboard_actions(self, recorder: _MenuRecorder) -> None: - _surface(_Grid()).add_block_items(CLICKED_TARGET) - - assert [item.label for item in recorder.items] == [ - CLIPBOARD_LABELS[ContextElements.COPY], - CLIPBOARD_LABELS[ContextElements.CUT], - CLIPBOARD_LABELS[ContextElements.PASTE], - CLIPBOARD_LABELS[ContextElements.DELETE], - ] - - def test_the_items_print_the_keys_the_grid_answers_to(self, recorder: _MenuRecorder) -> None: - """Each grid states its own three bindings, and an item prints exactly the one it fires.""" - shortcuts = shipped_source() - _surface(_Grid()).add_block_items(CLICKED_TARGET) - - assert recorder.items[COPY_ITEM].shortcut == shortcuts.display(ShortcutId.TRACKER_COPY_BLOCK) - assert recorder.items[CUT_ITEM].shortcut == shortcuts.display(ShortcutId.TRACKER_CUT_BLOCK) - assert recorder.items[PASTE_ITEM].shortcut == shortcuts.display(ShortcutId.TRACKER_PASTE_BLOCK) - - def test_delete_prints_no_key_of_its_own(self, recorder: _MenuRecorder) -> None: - """``Del`` empties a selection while one stands and clears the cell under the cursor - otherwise, so the grid resolves it from the selection rather than from one binding.""" - _surface(_Grid()).add_block_items(CLICKED_TARGET) - - assert recorder.items[DELETE_ITEM].shortcut == "" - - def test_the_items_act_on_the_block_they_were_raised_on(self, recorder: _MenuRecorder) -> None: - """A menu item names its target when it is built, so it reaches that block wherever the - cursor happens to stand.""" - grid = _Grid() - _surface(grid).add_block_items(CLICKED_TARGET) - - for item in recorder.items: - item.callback() - - assert grid.events == [ - f"copy {CLICKED_TARGET.region}", - f"cut {CLICKED_TARGET.region}", - f"paste {CLICKED_TARGET.anchor}", - f"delete {CLICKED_TARGET.region}", - ] - - def test_paste_awaits_a_copy(self, recorder: _MenuRecorder) -> None: - _surface(_Grid(can_paste=False)).add_block_items(CLICKED_TARGET) - - assert not recorder.items[PASTE_ITEM].enabled - assert all(item.enabled for index, item in enumerate(recorder.items) if index != PASTE_ITEM) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 2d856a023..9f4880ae7 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -8,8 +8,8 @@ from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer import order as order_module from sampletones_application.ui.panels.sequencer import tracker as tracker_module -from sampletones_application.ui.panels.sequencer.grid import surface as surface_module from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.surface import clipboard as clipboard_module from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, @@ -174,7 +174,7 @@ def _record_into( module: ModuleType, ) -> _MenuRecorder: recorder = _MenuRecorder() - for target in (module, surface_module): + for target in (module, clipboard_module): monkeypatch.setattr(target.dpg, "add_menu_item", recorder.add_menu_item) monkeypatch.setattr(target.dpg, "add_separator", lambda **_kwargs: 0) monkeypatch.setattr(target.dpg, "menu", _submenu) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py new file mode 100644 index 000000000..c2a64353d --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py @@ -0,0 +1,219 @@ +from dataclasses import dataclass, field +from typing import Any, Callable, List, Optional, Tuple + +import pytest + +from sampletones_application.categories.elements.sequencer import SequencerInstrumentsElements +from sampletones_application.ui.panels.sequencer import samples as samples_module +from sampletones_application.ui.panels.sequencer.samples import SAMPLE_MOVES, GUISequencerSamplesPanel +from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.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 + +EDIT_ITEM = 0 +RENAME_ITEM = 1 +DUPLICATE_ITEM = 2 +REMOVE_ITEM = 3 +MOVE_UP_ITEM = 4 +MOVE_DOWN_ITEM = 5 +MOVE_TOP_ITEM = 6 +MOVE_BOTTOM_ITEM = 7 + + +@dataclass +class MenuItem: + """One item as it was registered, which is the whole of what a reader sees and clicks.""" + + label: str + shortcut: str + enabled: bool + callback: Callable[[], None] + + +@dataclass +class Requests: + """What each sample hook was handed when its menu item fired.""" + + edited: List[str] = field(default_factory=list) + renamed: List[str] = field(default_factory=list) + duplicated: List[str] = field(default_factory=list) + removed: List[str] = field(default_factory=list) + moved: List[Tuple[str, Optional[int]]] = field(default_factory=list) + + +class _MenuRecorder: + def __init__(self) -> None: + self.items: List[MenuItem] = [] + + def add_menu_item(self, **kwargs: Any) -> int: + self.items.append( + MenuItem( + label=kwargs["label"], + shortcut=kwargs.get("shortcut", ""), + enabled=kwargs.get("enabled", True), + callback=kwargs["callback"], + ) + ) + return 0 + + +@pytest.fixture +def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuRecorder: + recorded = _MenuRecorder() + monkeypatch.setattr(samples_module.dpg, "add_menu_item", recorded.add_menu_item) + monkeypatch.setattr(samples_module.dpg, "add_separator", lambda **_kwargs: 0) + return recorded + + +@dataclass +class SamplesPanelFixture: + """A panel holding a selection, with the calls each menu item makes recorded.""" + + panel: GUISequencerSamplesPanel + requests: Requests + + +def _panel( + monkeypatch: pytest.MonkeyPatch, + *, + selected_row: Optional[int] = SELECTED_ROW, + tab_active: bool = True, + editing: Optional[str] = None, + field_focused: bool = False, +) -> SamplesPanelFixture: + """A samples panel whose menu builder can run with no DearPyGui context behind it.""" + panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel) + panel._language_manager = _Labels() + panel._shortcuts = shipped_source() + panel._entries = ENTRIES + panel._selected_sample_id = None if selected_row is None else SELECTED_ID + panel._selected_row = selected_row + panel._editing_sample_id = editing + panel._tab_active = lambda: tab_active + panel._router = _Router(field_focused=field_focused) + + requests = Requests() + panel.on_sample_edit_requested = requests.edited.append + panel.on_duplicate_requested = requests.duplicated.append + panel.on_remove_requested = requests.removed.append + panel.on_move_requested = lambda sample_id, target: requests.moved.append((sample_id, target)) + monkeypatch.setattr(panel, "_start_rename", requests.renamed.append) + return SamplesPanelFixture(panel=panel, requests=requests) + + +class _Labels: + """A language manager printing each key's own element, so an item reads as the action it names.""" + + def __getitem__(self, key: Tuple[Any, ...]) -> str: + return str(key[-1].value) + + +@dataclass(frozen=True) +class _Router: + """The key router as the panel's own scope reads it.""" + + field_focused: bool + + @property + def is_field_focused(self) -> bool: + return self.field_focused + + +class TestActionItems: + def test_the_menu_reads_as_the_sample_actions( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + _panel(monkeypatch).panel.build_edit_actions() + + assert [item.label for item in recorder.items] == [ + SequencerInstrumentsElements.CONTEXT_EDIT.value, + SequencerInstrumentsElements.CONTEXT_RENAME.value, + SequencerInstrumentsElements.CONTEXT_DUPLICATE.value, + SequencerInstrumentsElements.CONTEXT_REMOVE.value, + *(move.element.value for move in SAMPLE_MOVES), + ] + + def test_the_items_print_the_keys_the_panel_answers_to( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + """The panel has always answered these presses, and an item prints the one it fires.""" + shortcuts = shipped_source() + _panel(monkeypatch).panel.build_edit_actions() + + assert recorder.items[RENAME_ITEM].shortcut == shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE) + assert recorder.items[REMOVE_ITEM].shortcut == shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE) + assert [item.shortcut for item in recorder.items[MOVE_UP_ITEM:]] == [ + shortcuts.display(move.shortcut) for move in SAMPLE_MOVES + ] + + def test_the_items_act_on_the_sample_they_were_raised_on( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + fixture = _panel(monkeypatch) + fixture.panel.build_edit_actions() + + for item in recorder.items: + item.callback() + + assert fixture.requests.edited == [SELECTED_ID] + assert fixture.requests.renamed == [SELECTED_ID] + assert fixture.requests.duplicated == [SELECTED_ID] + assert fixture.requests.removed == [SELECTED_ID] + assert fixture.requests.moved == [ + (SELECTED_ID, SELECTED_ROW - 1), + (SELECTED_ID, SELECTED_ROW + 1), + (SELECTED_ID, 0), + (SELECTED_ID, len(ENTRIES) - 1), + ] + + def test_a_move_with_nowhere_to_go_is_greyed_out( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + _panel(monkeypatch, selected_row=0).panel.build_edit_actions() + + assert not recorder.items[MOVE_UP_ITEM].enabled + assert not recorder.items[MOVE_TOP_ITEM].enabled + assert recorder.items[MOVE_DOWN_ITEM].enabled + assert recorder.items[MOVE_BOTTOM_ITEM].enabled + + +class TestEditActions: + def test_the_panel_answers_while_it_holds_a_selection(self, monkeypatch: pytest.MonkeyPatch) -> None: + assert _panel(monkeypatch).panel.owns_edit_actions() + + def test_a_panel_holding_no_selection_stands_down(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The grids and this panel hold one selection between them, so one of them answers.""" + assert not _panel(monkeypatch, selected_row=None).panel.owns_edit_actions() + + def test_a_panel_on_a_tab_behind_stands_down(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A selection outlives a move to another tab, and the Edit menu follows the tab in front.""" + assert not _panel(monkeypatch, tab_active=False).panel.owns_edit_actions() + + def test_a_field_holding_the_keyboard_stands_the_panel_down(self, monkeypatch: pytest.MonkeyPatch) -> None: + assert not _panel(monkeypatch, field_focused=True).panel.owns_edit_actions() + + def test_a_panel_holding_no_selection_builds_nothing( + self, + monkeypatch: pytest.MonkeyPatch, + recorder: _MenuRecorder, + ) -> None: + _panel(monkeypatch, selected_row=None).panel.build_edit_actions() + + assert recorder.items == [] From 2da4f2c7594c1d5017995589b67334b964df969b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 14:30:15 +0200 Subject: [PATCH 089/152] Added: selecting a whole grid, column and subcolumn --- .../categories/elements/sequencer.py | 5 + .../categories/elements/settings.py | 5 + .../ui/panels/sequencer/input/order.py | 31 ++++ .../ui/panels/sequencer/input/state.py | 9 + .../ui/panels/sequencer/input/tracker.py | 44 +++++ .../ui/panels/sequencer/order.py | 51 ++++++ .../ui/panels/sequencer/tracker.py | 71 ++++++++ .../utils/gui/shortcuts/ids.py | 5 + .../keybindings/default.yaml | 9 +- src/sampletones_config/keybindings/macos.yaml | 9 +- src/sampletones_config/lang/en.yaml | 10 ++ .../panels/sequencer/input/test_grid_input.py | 27 +++ .../sequencer/input/test_order_input.py | 43 +++++ .../sequencer/input/test_tracker_input.py | 57 +++++- .../ui/panels/sequencer/test_block_menu.py | 118 ++++++++++++- .../ui/panels/sequencer/test_order_keys.py | 4 +- .../panels/sequencer/test_selection_keys.py | 74 +++++++- .../utils/gui/shortcuts/test_manager.py | 11 +- .../utils/gui/shortcuts/test_shipped.py | 164 ++++++++++++++++++ 19 files changed, 729 insertions(+), 18 deletions(-) diff --git a/src/sampletones_application/categories/elements/sequencer.py b/src/sampletones_application/categories/elements/sequencer.py index 51db2e5d9..448a0264b 100644 --- a/src/sampletones_application/categories/elements/sequencer.py +++ b/src/sampletones_application/categories/elements/sequencer.py @@ -30,6 +30,9 @@ class SequencerTrackerElements(AbstractElement): HEADER_SAMPLE = "header_sample" CONTEXT_PLAY = "context_play" CONTEXT_PLAY_FROM_FRAME = "context_play_from_frame" + CONTEXT_SELECT_ALL = "context_select_all" + CONTEXT_SELECT_COLUMN = "context_select_column" + CONTEXT_SELECT_SUBCOLUMN = "context_select_subcolumn" CONTEXT_NOTE_OFF = "context_note_off" CONTEXT_SET_INSTRUMENT = "context_set_instrument" CONTEXT_NO_SAMPLES = "context_no_samples" @@ -62,6 +65,8 @@ class SequencerOrderElements(AbstractElement): LABEL_CHANNEL = "label_channel" LABEL_MASTER = "label_master" CONTEXT_PLAY = "context_play" + CONTEXT_SELECT_ALL = "context_select_all" + CONTEXT_SELECT_ROW = "context_select_row" CONTEXT_DUPLICATE = "context_duplicate" CONTEXT_CLONE = "context_clone" CONTEXT_INSERT = "context_insert" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 7b96b8e02..676463a1a 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -95,6 +95,8 @@ class KeybindingActionElements(AbstractElement): ORDER_EXTEND_SELECTION_RIGHT = "order_extend_selection_right" ORDER_EXTEND_SELECTION_TO_FIRST_POSITION = "order_extend_selection_to_first_position" ORDER_EXTEND_SELECTION_TO_LAST_POSITION = "order_extend_selection_to_last_position" + ORDER_SELECT_ALL = "order_select_all" + ORDER_SELECT_ROW = "order_select_row" ORDER_COPY_BLOCK = "order_copy_block" ORDER_CUT_BLOCK = "order_cut_block" ORDER_PASTE_BLOCK = "order_paste_block" @@ -126,6 +128,9 @@ class KeybindingActionElements(AbstractElement): TRACKER_EXTEND_SELECTION_RIGHT = "tracker_extend_selection_right" TRACKER_EXTEND_SELECTION_TO_FIRST_ROW = "tracker_extend_selection_to_first_row" TRACKER_EXTEND_SELECTION_TO_LAST_ROW = "tracker_extend_selection_to_last_row" + TRACKER_SELECT_ALL = "tracker_select_all" + TRACKER_SELECT_COLUMN = "tracker_select_column" + TRACKER_SELECT_SUBCOLUMN = "tracker_select_subcolumn" TRACKER_COPY_BLOCK = "tracker_copy_block" TRACKER_CUT_BLOCK = "tracker_cut_block" TRACKER_PASTE_BLOCK = "tracker_paste_block" diff --git a/src/sampletones_application/ui/panels/sequencer/input/order.py b/src/sampletones_application/ui/panels/sequencer/input/order.py index 8f702e60e..43333b5b5 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/order.py +++ b/src/sampletones_application/ui/panels/sequencer/input/order.py @@ -50,6 +50,37 @@ def _region_between( def _covers(self, region: OrderRegion, cell: OrderCursor) -> bool: return region.covers(cell.generator, cell.position) + def select_all(self, position_count: int) -> OrderInputState: + """Selects the whole order: every channel row, across every position it holds.""" + return self._select_rows(CHANNEL_AXIS[0], CHANNEL_AXIS[-1], position_count) + + def select_row( + self, + cell: OrderCursor, + position_count: int, + ) -> OrderInputState: + """Selects the row ``cell`` stands in: that channel, across every position. + + The master row is an ordinary member of the axis here, so selecting it selects a row the + way selecting a channel does. + """ + return self._select_rows(cell.generator, cell.generator, position_count) + + def _select_rows( + self, + first_generator: Optional[GeneratorName], + last_generator: Optional[GeneratorName], + position_count: int, + ) -> OrderInputState: + """Selects a run of rows across the whole order, the cursor landing on its far corner.""" + if position_count == 0: + return self + + return self.select_between( + OrderCursor(first_generator, 0), + OrderCursor(last_generator, position_count - 1), + ) + def extend_position( self, value: int, diff --git a/src/sampletones_application/ui/panels/sequencer/input/state.py b/src/sampletones_application/ui/panels/sequencer/input/state.py index cb5f3373d..fa27781dc 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/state.py +++ b/src/sampletones_application/ui/panels/sequencer/input/state.py @@ -79,6 +79,15 @@ def extend_to(self, cursor: CursorT) -> Self: anchor=self.anchor if self.anchor is not None else self.cursor, ) + def select_between(self, anchor: CursorT, cursor: CursorT) -> Self: + """Stands a selection between two cells, the cursor landing on the second. + + A select gesture names a shape by the two corners bounding it, and leaves the cursor on the + far one: the next extending press then grows or shrinks the selection from the edge the + reader has just reached. + """ + return type(self)(cursor=cursor, pending="", anchor=anchor) + def cancel(self) -> Self: """Drops a partial entry and any selection, which is what Escape asks of a grid.""" return self.collapse().reset_pending() diff --git a/src/sampletones_application/ui/panels/sequencer/input/tracker.py b/src/sampletones_application/ui/panels/sequencer/input/tracker.py index 08f5a528e..665e0c51a 100644 --- a/src/sampletones_application/ui/panels/sequencer/input/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/input/tracker.py @@ -14,6 +14,7 @@ SLOT_COUNT, SUBCOLUMNS, TrackerSlot, + column_slot_base, slot_from_flat, ) from sampletones_application.view_model.sequencer.subcolumn import SubColumn @@ -97,6 +98,49 @@ def _region_between( def _covers(self, region: TrackerRegion, cell: TrackerCursor) -> bool: return region.covers(cell.row, TrackerSlot(cell.generator, cell.subcolumn)) + def select_all(self, row_count: int) -> TrackerInputState: + """Selects the whole frame: every row of it, across every slot the axis lays out.""" + return self._select_slots(0, SLOT_COUNT - 1, row_count) + + def select_column( + self, + cell: TrackerCursor, + row_count: int, + ) -> TrackerInputState: + """Selects the column ``cell`` stands in: every row of it, across that column's subcolumns. + + The sample column is an ordinary member of the axis here, so selecting it selects a column + the way selecting a channel does. + """ + base = column_slot_base(cell.generator) + return self._select_slots(base, base + len(SUBCOLUMNS) - 1, row_count) + + def select_subcolumn( + self, + cell: TrackerCursor, + row_count: int, + ) -> TrackerInputState: + """Selects the subcolumn ``cell`` stands in: every row of it, at that one slot.""" + slot = TrackerSlot(cell.generator, cell.subcolumn).flat_index + return self._select_slots(slot, slot, row_count) + + def _select_slots( + self, + first_slot: int, + last_slot: int, + row_count: int, + ) -> TrackerInputState: + """Selects a run of slots down the whole frame, the cursor landing on its far corner.""" + if row_count == 0: + return self + + first = slot_from_flat(first_slot) + last = slot_from_flat(last_slot) + return self.select_between( + TrackerCursor(0, first.generator, first.subcolumn), + TrackerCursor(row_count - 1, last.generator, last.subcolumn), + ) + def extend_row( self, value: int, diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 6453f737f..5e629f864 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -235,6 +235,8 @@ def label(element: SequencerOrderElements) -> str: return self._label(language_manager, element) self._lbl_context_play = label(SequencerOrderElements.CONTEXT_PLAY) + self._lbl_context_select_all = label(SequencerOrderElements.CONTEXT_SELECT_ALL) + self._lbl_context_select_row = label(SequencerOrderElements.CONTEXT_SELECT_ROW) self._lbl_context_duplicate = label(SequencerOrderElements.CONTEXT_DUPLICATE) self._lbl_context_clone = label(SequencerOrderElements.CONTEXT_CLONE) self._lbl_context_insert = label(SequencerOrderElements.CONTEXT_INSERT) @@ -1057,12 +1059,32 @@ def add_action_items(self, target: OrderTarget) -> None: the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the cursor stands on. An action added here reaches both. """ + self._add_select_items(target.cell) + dpg.add_separator() self._surface.add_block_items(target) dpg.add_separator() self._add_frame_items(target.cell.position) dpg.add_separator() self._add_move_items(target.cell.position) + def _add_select_items(self, cell: OrderCursor) -> None: + """Builds the two shapes a selection takes, the whole order and one row of it. + + Each item fires the gesture its key fires, on the cell the menu names: a row selected from + a cell menu is the row that cell stands in, and one selected from the menu bar is the row + the cursor stands in. + """ + dpg.add_menu_item( + label=self._lbl_context_select_all, + shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ALL), + callback=lambda: self._select_shape(ShortcutId.ORDER_SELECT_ALL, cell), + ) + dpg.add_menu_item( + label=self._lbl_context_select_row, + shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ROW), + callback=lambda: self._select_shape(ShortcutId.ORDER_SELECT_ROW, cell), + ) + def _add_frame_items(self, position: int) -> None: """Builds the frame operations, each acting on the whole frame the target cell sits in.""" dpg.add_menu_item( @@ -1165,6 +1187,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._extend_selection(shortcut_id): return True + if self._select_shape(shortcut_id, cursor): + return True + if self._block_action(shortcut_id): return True @@ -1217,6 +1242,32 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool: return True + def _select_shape( + self, + shortcut_id: ShortcutId, + cell: OrderCursor, + ) -> bool: + """Selects a rectangle of the table, reporting whether the action was one of its shapes. + + A press names its shape from the cell the cursor stands on, which is the cell the menu + items name as well, so a key and an item select the same block. + """ + match shortcut_id: + case ShortcutId.ORDER_SELECT_ALL: + self._select_all() + case ShortcutId.ORDER_SELECT_ROW: + self._select_row(cell) + case _: + return False + + return True + + def _select_all(self) -> None: + self._apply_state(self._committed_state().select_all(self._position_count)) + + def _select_row(self, cell: OrderCursor) -> None: + self._apply_state(self._committed_state().select_row(cell, self._position_count)) + def _block_action(self, shortcut_id: ShortcutId) -> bool: """Acts on the selected block, reporting whether the action was one of its gestures. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index ef623556e..de013cb8a 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -324,6 +324,9 @@ def label(element: SequencerTrackerElements) -> str: self._lbl_context_play = label(SequencerTrackerElements.CONTEXT_PLAY) self._lbl_context_play_from_frame = label(SequencerTrackerElements.CONTEXT_PLAY_FROM_FRAME) + self._lbl_context_select_all = label(SequencerTrackerElements.CONTEXT_SELECT_ALL) + self._lbl_context_select_column = label(SequencerTrackerElements.CONTEXT_SELECT_COLUMN) + self._lbl_context_select_subcolumn = label(SequencerTrackerElements.CONTEXT_SELECT_SUBCOLUMN) self._lbl_context_note_off = label(SequencerTrackerElements.CONTEXT_NOTE_OFF) self._lbl_context_set_instrument = label(SequencerTrackerElements.CONTEXT_SET_INSTRUMENT) self._lbl_context_no_samples = label(SequencerTrackerElements.CONTEXT_NO_SAMPLES) @@ -1331,6 +1334,8 @@ def add_action_items(self, target: TrackerTarget) -> None: the cell menu asks for the cell a pointer landed on, and the menu bar asks for the cell the cursor stands on. An action added here reaches both. """ + self._add_select_items(target.cell) + dpg.add_separator() self._surface.add_block_items(target) dpg.add_separator() self._add_instrument_submenu(target.cell) @@ -1345,6 +1350,29 @@ def add_action_items(self, target: TrackerTarget) -> None: dpg.add_separator() self._add_clear_items(target.cell) + def _add_select_items(self, cell: TrackerCursor) -> None: + """Builds the three shapes a selection takes, from the whole frame down to one subcolumn. + + Each item fires the gesture its key fires, on the cell the menu names: a column selected + from a cell menu is the column that cell stands in, and one selected from the menu bar is + the column the cursor stands in. + """ + dpg.add_menu_item( + label=self._lbl_context_select_all, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_ALL), + callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_ALL, cell), + ) + dpg.add_menu_item( + label=self._lbl_context_select_column, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_COLUMN), + callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_COLUMN, cell), + ) + dpg.add_menu_item( + label=self._lbl_context_select_subcolumn, + shortcut=self._shortcuts.display(ShortcutId.TRACKER_SELECT_SUBCOLUMN), + callback=lambda: self._select_shape(ShortcutId.TRACKER_SELECT_SUBCOLUMN, cell), + ) + def _add_instrument_submenu(self, cell: TrackerCursor) -> None: with dpg.menu(label=self._lbl_context_set_instrument): samples = self._current_samples.samples if self._current_samples is not None else () @@ -1481,6 +1509,9 @@ def _on_key_pressed(self, event: KeyEvent) -> bool: if self._extend_selection(shortcut_id): return True + if self._select_shape(shortcut_id, cursor): + return True + if self._block_action(shortcut_id): return True @@ -1541,6 +1572,46 @@ def _extend_selection(self, shortcut_id: ShortcutId) -> bool: return True + def _select_shape( + self, + shortcut_id: ShortcutId, + cell: TrackerCursor, + ) -> bool: + """Selects a rectangle of the grid, reporting whether the action was one of its shapes. + + A press names its shape from the cell the cursor stands on, which is the cell the menu + items name as well, so a key and an item select the same block. + """ + match shortcut_id: + case ShortcutId.TRACKER_SELECT_ALL: + self._select_all() + case ShortcutId.TRACKER_SELECT_COLUMN: + self._select_column(cell) + case ShortcutId.TRACKER_SELECT_SUBCOLUMN: + self._select_subcolumn(cell) + case _: + return False + + return True + + def _select_all(self) -> None: + self._select(self._committed_state().select_all(self._current_row_count)) + + def _select_column(self, cell: TrackerCursor) -> None: + self._select(self._committed_state().select_column(cell, self._current_row_count)) + + def _select_subcolumn(self, cell: TrackerCursor) -> None: + self._select(self._committed_state().select_subcolumn(cell, self._current_row_count)) + + def _select(self, new_state: TrackerInputState) -> None: + """Stands a selected shape, revealing the row its cursor landed on. + + A shape ends at the frame's last row, so the reveal carries the grid to the end the cursor + now holds — the same landing a Shift+End reach makes. + """ + self._apply_state(new_state) + self._scroll_cursor_into_view() + def _block_action(self, shortcut_id: ShortcutId) -> bool: """Acts on the selected block, reporting whether the action was one of its gestures. diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index bfd7b9ef5..8fc11fef1 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -107,6 +107,8 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: "OrderExtendSelectionToLastPosition", ShortcutCategory.ORDER, ) + ORDER_SELECT_ALL = ("OrderSelectAll", ShortcutCategory.ORDER) + ORDER_SELECT_ROW = ("OrderSelectRow", ShortcutCategory.ORDER) ORDER_COPY_BLOCK = ("OrderCopyBlock", ShortcutCategory.ORDER) ORDER_CUT_BLOCK = ("OrderCutBlock", ShortcutCategory.ORDER) ORDER_PASTE_BLOCK = ("OrderPasteBlock", ShortcutCategory.ORDER) @@ -144,6 +146,9 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: "TrackerExtendSelectionToLastRow", ShortcutCategory.TRACKER, ) + TRACKER_SELECT_ALL = ("TrackerSelectAll", ShortcutCategory.TRACKER) + TRACKER_SELECT_COLUMN = ("TrackerSelectColumn", ShortcutCategory.TRACKER) + TRACKER_SELECT_SUBCOLUMN = ("TrackerSelectSubcolumn", ShortcutCategory.TRACKER) TRACKER_COPY_BLOCK = ("TrackerCopyBlock", ShortcutCategory.TRACKER) TRACKER_CUT_BLOCK = ("TrackerCutBlock", ShortcutCategory.TRACKER) TRACKER_PASTE_BLOCK = ("TrackerPasteBlock", ShortcutCategory.TRACKER) diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 6da80187d..d1f0b3ada 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -50,10 +50,10 @@ bindings: UnmuteAllChannels: {combination: ~} # view - AudioSettings: {combination: "Ctrl+A"} + AudioSettings: {combination: "Ctrl+U"} DisplaySettings: {combination: "Ctrl+D"} KeyboardSettings: {combination: "Ctrl+K"} - ToggleAdvancedSettings: {combination: "Ctrl+Shift+A"} + ToggleAdvancedSettings: {combination: "Ctrl+Alt+T"} ToggleFullscreen: {combination: "F11"} AboutDialog: {combination: ~} NextTab: {combination: "Ctrl+PgDn", field_transparent: true} @@ -72,6 +72,8 @@ bindings: OrderExtendSelectionRight: {combination: "Shift+Right"} OrderExtendSelectionToFirstPosition: {combination: "Shift+Home"} OrderExtendSelectionToLastPosition: {combination: "Shift+End"} + OrderSelectAll: {combination: "Ctrl+A"} + OrderSelectRow: {combination: "Ctrl+Shift+A"} OrderCopyBlock: {combination: "Ctrl+C"} OrderCutBlock: {combination: "Ctrl+X"} OrderPasteBlock: {combination: "Ctrl+V"} @@ -104,6 +106,9 @@ bindings: TrackerExtendSelectionRight: {combination: "Shift+Right"} TrackerExtendSelectionToFirstRow: {combination: "Shift+Home"} TrackerExtendSelectionToLastRow: {combination: "Shift+End"} + TrackerSelectAll: {combination: "Ctrl+A"} + TrackerSelectColumn: {combination: "Ctrl+Shift+A"} + TrackerSelectSubcolumn: {combination: "Ctrl+Alt+A"} TrackerCopyBlock: {combination: "Ctrl+C"} TrackerCutBlock: {combination: "Ctrl+X"} TrackerPasteBlock: {combination: "Ctrl+V"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 1e127e281..9613c344a 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -50,10 +50,10 @@ bindings: UnmuteAllChannels: {combination: ~} # view - AudioSettings: {combination: "Cmd+A"} + AudioSettings: {combination: "Cmd+U"} DisplaySettings: {combination: "Cmd+D"} KeyboardSettings: {combination: "Cmd+K"} - ToggleAdvancedSettings: {combination: "Cmd+Shift+A"} + ToggleAdvancedSettings: {combination: "Cmd+Alt+T"} ToggleFullscreen: {combination: "Cmd+Ctrl+F"} AboutDialog: {combination: ~} NextTab: {combination: "Cmd+Alt+Right", aliases: ["Cmd+PgDn"], field_transparent: true} @@ -72,6 +72,8 @@ bindings: OrderExtendSelectionRight: {combination: "Shift+Right"} OrderExtendSelectionToFirstPosition: {combination: "Shift+Home", aliases: ["Cmd+Shift+Left"]} OrderExtendSelectionToLastPosition: {combination: "Shift+End", aliases: ["Cmd+Shift+Right"]} + OrderSelectAll: {combination: "Cmd+A"} + OrderSelectRow: {combination: "Cmd+Shift+A"} OrderCopyBlock: {combination: "Cmd+C"} OrderCutBlock: {combination: "Cmd+X"} OrderPasteBlock: {combination: "Cmd+V"} @@ -104,6 +106,9 @@ bindings: TrackerExtendSelectionRight: {combination: "Shift+Right"} TrackerExtendSelectionToFirstRow: {combination: "Shift+Home", aliases: ["Cmd+Shift+Up"]} TrackerExtendSelectionToLastRow: {combination: "Shift+End", aliases: ["Cmd+Shift+Down"]} + TrackerSelectAll: {combination: "Cmd+A"} + TrackerSelectColumn: {combination: "Cmd+Shift+A"} + TrackerSelectSubcolumn: {combination: "Cmd+Alt+A"} TrackerCopyBlock: {combination: "Cmd+C"} TrackerCutBlock: {combination: "Cmd+X"} TrackerPasteBlock: {combination: "Cmd+V"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 8c9f5d699..827d625ba 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -454,6 +454,9 @@ sequencer.tracker.label.column_triangle: "Triangle" sequencer.tracker.label.column_noise: "Noise" sequencer.tracker.label.context_play: "Play from here" sequencer.tracker.label.context_play_from_frame: "Play from this frame" +sequencer.tracker.label.context_select_all: "Select all" +sequencer.tracker.label.context_select_column: "Select column" +sequencer.tracker.label.context_select_subcolumn: "Select subcolumn" sequencer.tracker.label.context_note_off: "Note off" sequencer.tracker.label.context_set_instrument: "Set instrument" sequencer.tracker.label.context_no_samples: "No samples" @@ -487,6 +490,8 @@ sequencer.order.label.row_pulse_2: "Pulse 2" sequencer.order.label.row_triangle: "Triangle" sequencer.order.label.row_noise: "Noise" sequencer.order.label.context_play: "Play from this frame" +sequencer.order.label.context_select_all: "Select all" +sequencer.order.label.context_select_row: "Select row" sequencer.order.label.context_duplicate: "Duplicate" sequencer.order.label.context_clone: "Clone" sequencer.order.label.context_insert: "Insert frame" @@ -785,6 +790,8 @@ settings.keybindings.label.order_extend_selection_left: "Extend selection left" settings.keybindings.label.order_extend_selection_right: "Extend selection right" settings.keybindings.label.order_extend_selection_to_first_position: "Extend selection to the first position" settings.keybindings.label.order_extend_selection_to_last_position: "Extend selection to the last position" +settings.keybindings.label.order_select_all: "Select the whole order" +settings.keybindings.label.order_select_row: "Select the current row" settings.keybindings.label.order_copy_block: "Copy selection" settings.keybindings.label.order_cut_block: "Cut selection" settings.keybindings.label.order_paste_block: "Paste selection" @@ -815,6 +822,9 @@ settings.keybindings.label.tracker_extend_selection_left: "Extend selection left settings.keybindings.label.tracker_extend_selection_right: "Extend selection right" settings.keybindings.label.tracker_extend_selection_to_first_row: "Extend selection to the first row" settings.keybindings.label.tracker_extend_selection_to_last_row: "Extend selection to the last row" +settings.keybindings.label.tracker_select_all: "Select the whole frame" +settings.keybindings.label.tracker_select_column: "Select the current column" +settings.keybindings.label.tracker_select_subcolumn: "Select the current subcolumn" settings.keybindings.label.tracker_copy_block: "Copy selection" settings.keybindings.label.tracker_cut_block: "Cut selection" settings.keybindings.label.tracker_paste_block: "Paste selection" diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py index e508fc396..510e60bef 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_grid_input.py @@ -100,6 +100,33 @@ def test_a_transition_answers_as_the_grid_it_came_from(self) -> None: assert isinstance(_state().extend_to(_Cell(4, 3)), _GridState) assert isinstance(_state().reset_pending(), _GridState) assert isinstance(_state().collapse(), _GridState) + assert isinstance(_state().select_between(_Cell(0, 0), _Cell(4, 3)), _GridState) + + +class TestSelectBetween: + """A select gesture names a shape by its corners, which is how each grid states its own shapes.""" + + def test_the_selection_covers_the_rectangle_the_two_cells_bound(self) -> None: + selected = _state().select_between(_Cell(0, 0), _Cell(6, 5)) + + assert selected.region == _Block(first_row=0, last_row=6, first_column=0, last_column=5) + + def test_the_cursor_lands_on_the_far_corner(self) -> None: + """The next extending press then works from the edge the reader has just reached.""" + selected = _state().select_between(_Cell(0, 0), _Cell(6, 5)) + + assert selected.anchor == _Cell(0, 0) + assert selected.cursor == _Cell(6, 5) + + def test_a_shape_takes_over_from_the_selection_standing(self) -> None: + held = _state().extend_to(_Cell(4, 3)) + + selected = held.select_between(_Cell(0, 0), _Cell(6, 5)) + + assert selected.region == _Block(first_row=0, last_row=6, first_column=0, last_column=5) + + def test_a_shape_settles_a_partial_entry(self) -> None: + assert _state(pending="5").select_between(_Cell(0, 0), _Cell(6, 5)).pending == "" class TestTarget: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py index f41409204..00e132c8f 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_order_input.py @@ -127,6 +127,49 @@ def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None: assert selected.region_at(cell) == selected.region +class TestSelectShapes: + """The two shapes the table states, each running every position and ending at its far corner.""" + + def test_selecting_all_reaches_every_row_and_every_position(self) -> None: + selected = _state(position=2).select_all(POSITION_COUNT) + + region = selected.region + assert region is not None + assert region.generators == CHANNEL_AXIS + assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) + + def test_selecting_a_row_reaches_the_cursor_s_channel_across_the_order(self) -> None: + cell = OrderCursor(GeneratorName.TRIANGLE, 2) + + selected = _state(GeneratorName.TRIANGLE, position=2).select_row(cell, POSITION_COUNT) + + region = selected.region + assert region is not None + assert region.generators == (GeneratorName.TRIANGLE,) + assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) + + def test_the_master_row_is_a_row_like_any_other(self) -> None: + cell = OrderCursor(None, 2) + + selected = _state(None, position=2).select_row(cell, POSITION_COUNT) + + region = selected.region + assert region is not None + assert region.generators == (None,) + + def test_a_shape_stands_the_cursor_on_the_last_position_it_reaches(self) -> None: + """A shape ends where the next Shift+arrow starts, which is the far corner it covers.""" + selected = _state(position=2).select_all(POSITION_COUNT) + + assert selected.anchor == OrderCursor(CHANNEL_AXIS[0], 0) + assert selected.cursor == OrderCursor(CHANNEL_AXIS[-1], POSITION_COUNT - 1) + + def test_an_order_holding_no_positions_selects_nothing(self) -> None: + state = _state() + + assert state.select_all(0) is state + + class TestEntry: def test_type_char_commits_after_two_digits(self) -> None: partial, first = _state().type_char("A") diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py index 27bec8f2c..b4731cb43 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/input/test_tracker_input.py @@ -1,7 +1,7 @@ from typing import Optional from sampletones_application.ui.panels.sequencer.input.tracker import TrackerCursor, TrackerInputState -from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName @@ -151,6 +151,61 @@ def test_a_cell_of_a_selection_is_raised_on_the_whole_of_it(self) -> None: assert selected.region_at(cell) == selected.region +class TestSelectShapes: + """The three shapes the grid states, each running the whole frame and ending at its far corner.""" + + def test_selecting_all_reaches_every_row_and_every_slot(self) -> None: + selected = _state(SubColumn.TRANSPOSE, row=4).select_all(ROW_COUNT) + + region = selected.region + assert region is not None + assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) + assert (region.first_slot, region.last_slot) == (0, SLOT_COUNT - 1) + + def test_selecting_a_column_reaches_the_cursor_s_channel_and_its_subcolumns(self) -> None: + cell = TrackerCursor(4, GeneratorName.TRIANGLE, SubColumn.TRANSPOSE) + + selected = _state(SubColumn.TRANSPOSE, row=4).select_column(cell, ROW_COUNT) + + region = selected.region + assert region is not None + assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) + assert region.slots == tuple(TrackerSlot(GeneratorName.TRIANGLE, subcolumn) for subcolumn in SubColumn) + + def test_the_sample_column_is_a_column_like_any_other(self) -> None: + cell = TrackerCursor(4, None, SubColumn.VOLUME) + + selected = _state(SubColumn.VOLUME, row=4, generator=None).select_column(cell, ROW_COUNT) + + region = selected.region + assert region is not None + assert region.columns == (None,) + + def test_selecting_a_subcolumn_reaches_the_one_slot_the_cursor_stands_on(self) -> None: + cell = TrackerCursor(4, GeneratorName.NOISE, SubColumn.VOLUME) + + selected = _state(SubColumn.VOLUME, row=4, generator=GeneratorName.NOISE).select_subcolumn(cell, ROW_COUNT) + + region = selected.region + assert region is not None + assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) + assert region.slots == (TrackerSlot(GeneratorName.NOISE, SubColumn.VOLUME),) + + def test_a_shape_stands_the_cursor_on_the_last_row_it_reaches(self) -> None: + """A shape ends where the next Shift+arrow starts, which is the far corner it covers.""" + cell = TrackerCursor(4, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + + selected = _state(SubColumn.INSTRUMENT, row=4).select_column(cell, ROW_COUNT) + + assert selected.cursor == TrackerCursor(ROW_COUNT - 1, GeneratorName.PULSE1, SubColumn.VOLUME) + assert selected.anchor == TrackerCursor(0, GeneratorName.PULSE1, SubColumn.INSTRUMENT) + + def test_a_frame_holding_no_rows_selects_nothing(self) -> None: + state = _state(SubColumn.INSTRUMENT) + + assert state.select_all(0) is state + + class TestColumnNavigation: def test_tab_preserves_subcolumn(self) -> None: state = _state(SubColumn.VOLUME, generator=GeneratorName.PULSE1) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py index 9f4880ae7..53e5b2142 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_block_menu.py @@ -22,7 +22,7 @@ TrackerCell, TrackerRegion, ) -from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot, slot_from_flat from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName from tests.suite.grid import ( @@ -42,6 +42,11 @@ PASTE_ITEM = 2 DELETE_ITEM = 3 +SELECT_ALL_ITEM = 0 +SELECT_COLUMN_ITEM = 1 +SELECT_SUBCOLUMN_ITEM = 2 +SELECT_ROW_ITEM = 1 + PULSE1_ROW = CHANNEL_AXIS.index(GeneratorName.PULSE1) @@ -52,6 +57,7 @@ class MenuItem: label: str enabled: bool callback: Callable[[], None] + shortcut: str = "" @dataclass @@ -80,12 +86,16 @@ def add_menu_item(self, **kwargs: Any) -> int: label=kwargs["label"], enabled=kwargs.get("enabled", True), callback=kwargs.get("callback", _prints_only), + shortcut=kwargs.get("shortcut", ""), ) ) return 0 TRACKER_LABELS = ( + "select_all", + "select_column", + "select_subcolumn", "note_off", "set_instrument", "no_samples", @@ -95,6 +105,8 @@ def add_menu_item(self, **kwargs: Any) -> int: ) ORDER_LABELS = ( + "select_all", + "select_row", "duplicate", "clone", "insert", @@ -202,6 +214,30 @@ def _order_cell(generator: Optional[GeneratorName]) -> OrderCursor: return OrderCursor(generator, CLICKED_POSITION) +def _tracker_selections( + monkeypatch: pytest.MonkeyPatch, + panel: tracker_module.GUISequencerTrackerPanel, +) -> List[TrackerInputState]: + """The states a select item applies, on a grid holding a cursor and the rows to reach.""" + panel._input_state = TrackerInputState(cursor=_tracker_cell(GeneratorName.PULSE1)) + panel._current_row_count = ROW_COUNT + states: List[TrackerInputState] = [] + monkeypatch.setattr(panel, "_apply_state", states.append) + monkeypatch.setattr(panel, "_scroll_cursor_into_view", lambda: None) + return states + + +def _order_selections( + monkeypatch: pytest.MonkeyPatch, + panel: order_module.GUISequencerOrderPanel, +) -> List[OrderInputState]: + """The states a select item applies, on a table holding a cursor and the positions to reach.""" + panel._input_state = OrderInputState(cursor=_order_cell(GeneratorName.PULSE1)) + states: List[OrderInputState] = [] + monkeypatch.setattr(panel, "_apply_state", lambda state, notify=True: states.append(state)) + return states + + def _selected_tracker_state() -> TrackerInputState: """A selection running from the clicked row down two rows, over Pulse 1's whole cell.""" state = TrackerInputState(cursor=TrackerCursor(CLICKED_ROW, GeneratorName.PULSE1, SubColumn.INSTRUMENT)) @@ -454,7 +490,7 @@ def test_the_section_reads_as_the_four_clipboard_actions( class TestActionSet: """One builder states each grid's actions, so every menu offering them prints the same set.""" - def test_the_tracker_action_set_opens_with_the_clipboard_items( + def test_the_tracker_action_set_leads_with_the_shapes_a_selection_takes( self, tracker_recorder: _MenuRecorder, ) -> None: @@ -463,10 +499,11 @@ def test_the_tracker_action_set_opens_with_the_clipboard_items( panel.add_action_items(panel._surface.target_at(_tracker_cell(GeneratorName.PULSE1))) labels = [item.label for item in tracker_recorder.items] - assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] + assert labels[:3] == ["select_all", "select_column", "select_subcolumn"] + assert labels[3:7] == ["Copy", "Cut", "Paste", "Delete"] assert panel._lbl_context_clear_row in labels - def test_the_order_action_set_opens_with_the_clipboard_items( + def test_the_order_action_set_leads_with_the_shapes_a_selection_takes( self, order_recorder: _MenuRecorder, ) -> None: @@ -475,7 +512,8 @@ def test_the_order_action_set_opens_with_the_clipboard_items( panel.add_action_items(panel._surface.target_at(_order_cell(GeneratorName.PULSE1))) labels = [item.label for item in order_recorder.items] - assert labels[:4] == ["Copy", "Cut", "Paste", "Delete"] + assert labels[:2] == ["select_all", "select_row"] + assert labels[2:6] == ["Copy", "Cut", "Paste", "Delete"] assert panel._lbl_context_move_end in labels @@ -492,3 +530,73 @@ def test_the_indices_name_the_items_they_stand_for(self, tracker_recorder: _Menu assert labels[CUT_ITEM] == "Cut" assert labels[PASTE_ITEM] == "Paste" assert labels[DELETE_ITEM] == "Delete" + + +class TestSelectItems: + """The shapes each grid states, printed with their keys and firing what those keys fire.""" + + def test_the_tracker_items_print_the_keys_they_answer(self, tracker_recorder: _MenuRecorder) -> None: + panel = _tracker_panel(Gestures()) + + panel._add_select_items(_tracker_cell(GeneratorName.PULSE1)) + + assert [item.shortcut for item in tracker_recorder.items] == [ + "Ctrl+A", + "Ctrl+Shift+A", + "Ctrl+Alt+A", + ] + + def test_a_tracker_item_selects_the_column_the_menu_was_raised_on( + self, + monkeypatch: pytest.MonkeyPatch, + tracker_recorder: _MenuRecorder, + ) -> None: + """A menu names the cell it was raised on, so the shape reaches that cell's own column.""" + panel = _tracker_panel(Gestures()) + states = _tracker_selections(monkeypatch, panel) + + panel._add_select_items(_tracker_cell(GeneratorName.TRIANGLE)) + tracker_recorder.items[SELECT_COLUMN_ITEM].callback() + + region = states[-1].region + assert region is not None + assert region.columns == (GeneratorName.TRIANGLE,) + assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) + + def test_a_tracker_item_selects_the_whole_frame( + self, + monkeypatch: pytest.MonkeyPatch, + tracker_recorder: _MenuRecorder, + ) -> None: + panel = _tracker_panel(Gestures()) + states = _tracker_selections(monkeypatch, panel) + + panel._add_select_items(_tracker_cell(GeneratorName.TRIANGLE)) + tracker_recorder.items[SELECT_ALL_ITEM].callback() + + region = states[-1].region + assert region is not None + assert region.slots == tuple(slot_from_flat(index) for index in range(SLOT_COUNT)) + + def test_the_order_items_print_the_keys_they_answer(self, order_recorder: _MenuRecorder) -> None: + panel = _order_panel(Gestures()) + + panel._add_select_items(_order_cell(GeneratorName.PULSE1)) + + assert [item.shortcut for item in order_recorder.items] == ["Ctrl+A", "Ctrl+Shift+A"] + + def test_an_order_item_selects_the_row_the_menu_was_raised_on( + self, + monkeypatch: pytest.MonkeyPatch, + order_recorder: _MenuRecorder, + ) -> None: + panel = _order_panel(Gestures()) + states = _order_selections(monkeypatch, panel) + + panel._add_select_items(_order_cell(None)) + order_recorder.items[SELECT_ROW_ITEM].callback() + + region = states[-1].region + assert region is not None + assert region.generators == (None,) + assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py index 1b6c90bd5..204bfd083 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_order_keys.py @@ -136,8 +136,8 @@ def test_a_hex_key_types_into_the_cell_under_the_cursor(self, order: OrderPanelF 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 + """Ctrl+D opens the display settings, so cell entry keeps the plain key alone.""" + assert order.panel._on_key_pressed(_press("Ctrl+D")) is False assert order.states == [] def test_the_clear_cell_key_empties_the_cell_and_moves_on(self, order: OrderPanelFixture) -> None: diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py index a5308d619..2b2043bfc 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_keys.py @@ -2,6 +2,7 @@ import pytest +from sampletones_application.constants.sequencer import CHANNEL_AXIS from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, @@ -12,7 +13,7 @@ from sampletones_application.utils.gui.keyboard.combination import KeyCombination from sampletones_application.utils.gui.keyboard.event import KeyEvent from sampletones_application.view_model.sequencer.region import OrderRegion, TrackerRegion -from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.slot import SLOT_COUNT, TrackerSlot from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.constants.enums import GeneratorName from tests.suite.shortcuts import shipped_source @@ -178,3 +179,74 @@ def test_a_plain_arrow_still_moves_the_cursor(self, monkeypatch: pytest.MonkeyPa assert panel._on_key_pressed(_press("Right")) is True assert states[-1].region is None + + +class TestTrackerSelectKeys: + """The A chord selects a shape of the grid, each shape wider than the one Shift and Alt add.""" + + def test_ctrl_a_selects_the_whole_frame(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+A")) is True + region = states[-1].region + assert region is not None + assert (region.first_row, region.last_row) == (0, ROW_COUNT - 1) + assert (region.first_slot, region.last_slot) == (0, SLOT_COUNT - 1) + + def test_ctrl_shift_a_selects_the_column_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker(generator=GeneratorName.TRIANGLE, subcolumn=SubColumn.VOLUME) + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+Shift+A")) is True + region = states[-1].region + assert region is not None + assert region.slots == tuple(TrackerSlot(GeneratorName.TRIANGLE, subcolumn) for subcolumn in SubColumn) + + def test_ctrl_alt_a_selects_the_subcolumn_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _tracker(subcolumn=SubColumn.VOLUME) + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+Alt+A")) is True + region = states[-1].region + assert region is not None + assert region.slots == (TrackerSlot(GeneratorName.PULSE1, SubColumn.VOLUME),) + + def test_a_shape_stands_the_cursor_at_the_end_it_reaches(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A Shift+Up straight after shrinks the selection from the row the shape ended on.""" + panel = _tracker() + states = _tracker_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+A")) is True + assert states[-1].cursor == TrackerCursor(ROW_COUNT - 1, GeneratorName.NOISE, SubColumn.VOLUME) + + +class TestOrderSelectKeys: + """The A chord selects a shape of the table, the whole order or the row the cursor stands in.""" + + def test_ctrl_a_selects_the_whole_order(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+A")) is True + region = states[-1].region + assert region is not None + assert region.generators == CHANNEL_AXIS + assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) + + def test_ctrl_shift_a_selects_the_row_the_cursor_stands_in(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order(generator=None) + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+Shift+A")) is True + region = states[-1].region + assert region is not None + assert region.generators == (None,) + assert (region.first_position, region.last_position) == (0, POSITION_COUNT - 1) + + def test_a_shape_stands_the_cursor_at_the_end_it_reaches(self, monkeypatch: pytest.MonkeyPatch) -> None: + panel = _order() + states = _order_states(monkeypatch, panel) + + assert panel._on_key_pressed(_press("Ctrl+A")) is True + assert states[-1].cursor == OrderCursor(CHANNEL_AXIS[-1], POSITION_COUNT - 1) diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py index f40f0a709..71eb277cb 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_manager.py @@ -255,11 +255,12 @@ def test_text_field_keeps_its_editing_chord( source: ShortcutSource, field_kind: Dict[str, FieldKind], ) -> None: + """Ctrl+Z undoes the text being typed, so the application's own undo stays out of it.""" callback = Mock() - manager = _manager(source, ShortcutId.AUDIO_SETTINGS, callback) + manager = _manager(source, ShortcutId.UNDO, callback) field_kind["kind"] = FieldKind.TEXT_ENTRY - claimed = manager._dispatch(_event(dpg.mvKey_A, modifiers=CTRL)) + claimed = manager._dispatch(_event(dpg.mvKey_Z, modifiers=CTRL)) assert not claimed callback.assert_not_called() @@ -269,12 +270,12 @@ def test_text_field_yields_a_shifted_chord_it_has_no_use_for( source: ShortcutSource, field_kind: Dict[str, FieldKind], ) -> None: - """Ctrl+Shift+A carries a chord letter without being a text chord, so the shortcut fires.""" + """Ctrl+Shift+S is no text chord, so the shortcut fires while a field holds the keyboard.""" callback = Mock() - manager = _manager(source, ShortcutId.TOGGLE_ADVANCED_SETTINGS, callback) + manager = _manager(source, ShortcutId.SAVE_PROJECT_AS, callback) field_kind["kind"] = FieldKind.TEXT_ENTRY - claimed = manager._dispatch(_event(dpg.mvKey_A, modifiers=CTRL_SHIFT)) + claimed = manager._dispatch(_event(dpg.mvKey_S, modifiers=CTRL_SHIFT)) assert claimed callback.assert_called_once() diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py index 8f480a3b1..ff7bdb281 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py @@ -71,6 +71,170 @@ def test_adding_a_frame_keeps_the_unmodified_insert(self, shipped: ShortcutSchem assert shipped.action(ShortcutCategory.ORDER, _press("Ins")) is ShortcutId.ORDER_ADD_FRAME +class TestSelectKeys(BaseTestSuite): + """The A chord is selection and nothing else, each modifier narrowing the shape it names.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + category: ShortcutCategory + shortcut_id: ShortcutId + expected: str + + test_cases = ( + TestCase( + label="the whole frame", + category=ShortcutCategory.TRACKER, + shortcut_id=ShortcutId.TRACKER_SELECT_ALL, + expected="Ctrl+A", + ), + TestCase( + label="a column", + category=ShortcutCategory.TRACKER, + shortcut_id=ShortcutId.TRACKER_SELECT_COLUMN, + expected="Ctrl+Shift+A", + ), + TestCase( + label="a subcolumn", + category=ShortcutCategory.TRACKER, + shortcut_id=ShortcutId.TRACKER_SELECT_SUBCOLUMN, + expected="Ctrl+Alt+A", + ), + TestCase( + label="the whole order", + category=ShortcutCategory.ORDER, + shortcut_id=ShortcutId.ORDER_SELECT_ALL, + expected="Ctrl+A", + ), + TestCase( + label="an order row", + category=ShortcutCategory.ORDER, + shortcut_id=ShortcutId.ORDER_SELECT_ROW, + expected="Ctrl+Shift+A", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_shape_reads_under_the_combination_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_shape_answers_its_press_in_the_grid_that_states_it( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + action = shipped.action(test_case.category, _press(test_case.expected)) + + assert action is test_case.shortcut_id + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_mac_reaches_the_shape_through_its_own_modifier( + self, + test_case: TestCase, + macos: ShortcutScheme, + ) -> None: + """A Mac spells the chord with Command, so the family reads the same on either keyboard.""" + combination = test_case.expected.replace("Ctrl", "Cmd") + + assert macos.action(test_case.category, _press(combination)) is test_case.shortcut_id + + +class TestDisplacedSettingsKeys(BaseTestSuite): + """Where the two settings the A chord displaced now answer, each keeping its family's shape.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + shortcut_id: ShortcutId + expected: str + mac_expected: str + + test_cases = ( + TestCase( + label="audio settings", + shortcut_id=ShortcutId.AUDIO_SETTINGS, + expected="Ctrl+U", + mac_expected="Cmd+U", + ), + TestCase( + label="advanced settings", + shortcut_id=ShortcutId.TOGGLE_ADVANCED_SETTINGS, + expected="Ctrl+Alt+T", + mac_expected="Cmd+Alt+T", + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_setting_reads_under_the_combination_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_the_setting_answers_its_press_wherever_no_grid_claims_it( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + action = shipped.action(ShortcutCategory.APPLICATION, _press(test_case.expected)) + + assert action is test_case.shortcut_id + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_setting_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.mac_expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_grids_leave_the_settings_key_alone( + self, + test_case: TestCase, + shipped: ShortcutScheme, + ) -> None: + """A grid is asked before the application is, so a dialog opens while the cursor stands in one.""" + press = _press(test_case.expected) + + assert shipped.action(ShortcutCategory.TRACKER, press) is None + assert shipped.action(ShortcutCategory.ORDER, press) is None + + class TestChannelKeys(BaseTestSuite): """The four channels sit on the four function keys, in the order the tracker shows them.""" From 9390ecc8c1f7c5d49d7d44047f7d332b490984ff Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 15:00:42 +0200 Subject: [PATCH 090/152] Added: auto-scroll while dragging --- .../panels/sequencer/grid/scroll/__init__.py | 0 .../ui/panels/sequencer/grid/scroll/axis.py | 55 +++++ .../ui/panels/sequencer/grid/scroll/band.py | 15 ++ .../ui/panels/sequencer/grid/scroll/travel.py | 97 ++++++++ .../ui/panels/sequencer/order.py | 31 +++ .../ui/panels/sequencer/tracker.py | 26 ++ .../panels/sequencer/grid/scroll/__init__.py | 0 .../panels/sequencer/grid/scroll/test_axis.py | 62 +++++ .../sequencer/grid/scroll/test_travel.py | 231 ++++++++++++++++++ .../panels/sequencer/test_selection_drag.py | 87 +++++++ 10 files changed, 604 insertions(+) create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/scroll/axis.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/scroll/band.py create mode 100644 src/sampletones_application/ui/panels/sequencer/grid/scroll/travel.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_axis.py create mode 100644 tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_travel.py diff --git a/src/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py b/src/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/ui/panels/sequencer/grid/scroll/axis.py b/src/sampletones_application/ui/panels/sequencer/grid/scroll/axis.py new file mode 100644 index 000000000..a50db262c --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/scroll/axis.py @@ -0,0 +1,55 @@ +from typing import Protocol + +import dearpygui.dearpygui as dpg + + +class ScrollAxis(Protocol): + """The axis one table scrolls along, and the pointer coordinate that runs past its edges.""" + + def pointer(self) -> float: ... + + def scroll(self) -> float: ... + + def scroll_max(self) -> float: ... + + def set_scroll(self, offset: float) -> None: ... + + +class VerticalScroll: + """A table whose rows run down the screen, so the pointer's height names the cell it stands on.""" + + def __init__(self, *, table: str) -> None: + self._table = table + + def pointer(self) -> float: + _, top = dpg.get_mouse_pos(local=False) + return float(top) + + def scroll(self) -> float: + return float(dpg.get_y_scroll(self._table)) + + def scroll_max(self) -> float: + return float(dpg.get_y_scroll_max(self._table)) + + def set_scroll(self, offset: float) -> None: + dpg.set_y_scroll(self._table, offset) + + +class HorizontalScroll: + """A table whose columns run across the screen, so the pointer's width names the cell it stands on.""" + + def __init__(self, *, table: str) -> None: + self._table = table + + def pointer(self) -> float: + left, _ = dpg.get_mouse_pos(local=False) + return float(left) + + def scroll(self) -> float: + return float(dpg.get_x_scroll(self._table)) + + def scroll_max(self) -> float: + return float(dpg.get_x_scroll_max(self._table)) + + def set_scroll(self, offset: float) -> None: + dpg.set_x_scroll(self._table, offset) diff --git a/src/sampletones_application/ui/panels/sequencer/grid/scroll/band.py b/src/sampletones_application/ui/panels/sequencer/grid/scroll/band.py new file mode 100644 index 000000000..eaf7a34ab --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/scroll/band.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TravelBand: + """Where a grid's cells stand along the axis it scrolls, and how many of them it lays out. + + ``first_edge`` is the leading edge of the first cell in the coordinates the viewport is drawn + in, which travels with the scroll: adding the scroll back to it gives the edge the band on + screen begins at. + """ + + first_edge: float + cell_extent: float + cell_count: int diff --git a/src/sampletones_application/ui/panels/sequencer/grid/scroll/travel.py b/src/sampletones_application/ui/panels/sequencer/grid/scroll/travel.py new file mode 100644 index 000000000..99536ae58 --- /dev/null +++ b/src/sampletones_application/ui/panels/sequencer/grid/scroll/travel.py @@ -0,0 +1,97 @@ +from math import copysign +from typing import Callable, Final, Optional + +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import ScrollAxis +from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand + +TRAVEL_FLOOR_CELLS_PER_SECOND: Final[float] = 6.0 +TRAVEL_CEILING_CELLS_PER_SECOND: Final[float] = 45.0 +TRAVEL_FULL_PACE_OVERSHOOT_CELLS: Final[float] = 5.0 + + +class DragTravel: + """Carries a grid's view along while a held pointer stands past the band drawn on screen. + + A held pointer keeps reporting for as long as the button is down, wherever it has been carried + to, so the travel runs from that report and paces itself by the frame's own duration: the same + stretch of grid passes under the pointer however fast the frames arrive. Each step is added to + the offset last issued, because a table reports the scroll it was drawn with rather than the one + just set — reading it back would have the travel re-issue an offset it has already reached. + """ + + def __init__( + self, + *, + axis: ScrollAxis, + band: Callable[[], Optional[TravelBand]], + elapsed: Callable[[], float], + ) -> None: + self._axis = axis + self._band = band + self._elapsed = elapsed + self._offset: Optional[float] = None + + def advance(self) -> None: + """Travels one frame's worth toward whatever the pointer stands past, up to the grid's end. + + A pointer standing within the band leaves the grid where it is, and the drag then reaches + the cell it stands on the way it always has. A grid awaiting its first layout states no + band, and one that fits on screen has nowhere to travel to. + """ + band = self._band() + if band is None: + self.rest() + return + + scroll_max = self._axis.scroll_max() + if scroll_max <= 0.0: + self.rest() + return + + drawn = self._axis.scroll() + overshoot = self._overshoot(band, drawn, scroll_max) + if overshoot == 0.0: + self.rest() + return + + travel = self._pace(overshoot, band.cell_extent) * band.cell_extent * self._elapsed() + offset = self._offset if self._offset is not None else drawn + self._offset = min(max(offset + copysign(travel, overshoot), 0.0), scroll_max) + self._axis.set_scroll(self._offset) + + def rest(self) -> None: + """Ends the travel, so the next one sets out from the offset the grid is drawn with.""" + self._offset = None + + def _overshoot( + self, + band: TravelBand, + drawn: float, + scroll_max: float, + ) -> float: + """How far past the band the pointer stands, reading negative before its near edge. + + The band begins where the first cell's edge stands once the scroll carrying it is added + back, and it holds what the grid lays out less what it still has to scroll away. + """ + near = band.first_edge + drawn + far = near + band.cell_count * band.cell_extent - scroll_max + pointer = self._axis.pointer() + if pointer < near: + return pointer - near + + if pointer > far: + return pointer - far + + return 0.0 + + @staticmethod + def _pace(overshoot: float, cell_extent: float) -> float: + """How many cells a second the travel runs at: a floor at the edge, rising to a ceiling. + + The pace answers how far past the edge the pointer is carried, so a reader nudging the edge + creeps along and one reaching well past it covers the grid. + """ + reach = min(abs(overshoot) / (cell_extent * TRAVEL_FULL_PACE_OVERSHOOT_CELLS), 1.0) + span = TRAVEL_CEILING_CELLS_PER_SECOND - TRAVEL_FLOOR_CELLS_PER_SECOND + return TRAVEL_FLOOR_CELLS_PER_SECOND + span * reach diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 5e629f864..9dcfb3a73 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -45,6 +45,9 @@ ) from sampletones_application.ui.panels.sequencer.columns import channel_color from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import HorizontalScroll +from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand +from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( BlockShortcuts, ClipboardItems, @@ -151,6 +154,11 @@ def __init__( cell_at=self._cell_at, covered=self._selected_cells, ) + self._travel: DragTravel = DragTravel( + axis=HorizontalScroll(table=TAG_SEQUENCER_ORDER_TABLE), + band=self._travel_band, + elapsed=dpg.get_delta_time, + ) self._highlighted: Optional[OrderCursor] = None self._highlighted_column: Optional[int] = None self._current_position: Optional[int] = None @@ -502,6 +510,7 @@ def _rebuild_table( self._highlighted = None self._highlighted_column = None self._selection.reset() + self._travel.rest() self._order.reset(cell_values) self._position_count = view_model.position_count self._build_table(view_model.position_count) @@ -888,7 +897,11 @@ def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: The gesture states how far the pointer has carried: a plain drag anchors at the cell the press landed on, and one whose press held Shift carries the selection already standing. + + A pointer held past an edge travels the table first, so the reach that follows reads the + positions the travel has brought into view. """ + self._travel.advance() reach = self._selection.hold(app_data) if reach is None: return @@ -907,6 +920,24 @@ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: the cell it started from would otherwise have its selection taken down by its own click. """ self._selection.drop_gesture() + self._travel.rest() + + def _travel_band(self) -> Optional[TravelBand]: + """Where the order's positions stand, which is the band a drag held beside them travels across. + + Two positions state the pitch the columns are laid out at, so an order holding one of them + travels nowhere — there is nothing beside it to reach. + """ + first = self._cell_left(0) + following = self._cell_left(1) + if first is None or following is None: + return None + + return TravelBand( + first_edge=first, + cell_extent=following - first, + cell_count=self._position_count, + ) def _cell_at(self) -> Optional[OrderKey]: """The cell the pointer stands on, clamped to the table the order lays out. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index de013cb8a..b1734fe30 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -49,6 +49,9 @@ ) from sampletones_application.ui.panels.sequencer.display import CellKey, CellValues from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import VerticalScroll +from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand +from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( BlockShortcuts, ClipboardItems, @@ -236,6 +239,11 @@ def __init__( cell_at=self._cell_at, covered=self._selected_cells, ) + self._travel: DragTravel = DragTravel( + axis=VerticalScroll(table=TAG_SEQUENCER_TRACKER_TABLE), + band=self._travel_band, + elapsed=dpg.get_delta_time, + ) self._subcolumn_themes: Dict[SubColumn, int] = {} self._muted_subcolumn_themes: Dict[SubColumn, int] = {} self._row_number_theme: int = 0 @@ -559,6 +567,7 @@ def _rebuild_table( dpg_delete_children(TAG_SEQUENCER_TRACKER_TABLE, slot=1) self._input_state = self._input_state.collapse() self._selection.reset() + self._travel.rest() self._editable_cells.reset(cell_values) self._build_table(view_model) self.repaint() @@ -1160,7 +1169,11 @@ def _on_cell_held(self, _sender: Sender, app_data: Sender) -> None: The gesture states how far the pointer has carried: a plain drag anchors at the cell the press landed on, and one whose press held Shift carries the selection already standing. + + A pointer held past an edge travels the grid first, so the reach that follows reads the rows + the travel has brought into view. """ + self._travel.advance() reach = self._selection.hold(app_data) if reach is None: return @@ -1179,6 +1192,19 @@ def _on_pointer_pressed(self, _sender: Sender, _app_data: int) -> None: the cell it started from would otherwise have its selection taken down by its own click. """ self._selection.drop_gesture() + self._travel.rest() + + def _travel_band(self) -> Optional[TravelBand]: + """Where the frame's rows stand, which is the band a drag held below them travels across.""" + first = self._row_top(0) + if first is None or self._current_row_count == 0: + return None + + return TravelBand( + first_edge=first, + cell_extent=self._layout.tracker.row_height, + cell_count=self._current_row_count, + ) def _cell_at(self) -> Optional[CellKey]: """The cell the pointer stands on, clamped to the grid the shown frame lays out. diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_axis.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_axis.py new file mode 100644 index 000000000..ad0a73280 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_axis.py @@ -0,0 +1,62 @@ +from typing import List + +import pytest + +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import ( + HorizontalScroll, + VerticalScroll, +) + +AXIS_MODULE = "sampletones_application.ui.panels.sequencer.grid.scroll.axis.dpg" +POINTER = [12.0, 34.0] +SCROLL = 7.0 +SCROLL_MAX = 70.0 +ISSUED = 5.0 + + +def _read_pointer(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(f"{AXIS_MODULE}.get_mouse_pos", lambda local: POINTER) + + +class TestVerticalScroll: + """A table whose rows run down the screen travels by height.""" + + def test_the_pointer_reads_as_its_height(self, monkeypatch: pytest.MonkeyPatch) -> None: + _read_pointer(monkeypatch) + + assert VerticalScroll(table="tracker.table").pointer() == POINTER[1] + + def test_the_offsets_are_the_table_s_own(self, monkeypatch: pytest.MonkeyPatch) -> None: + issued: List[float] = [] + monkeypatch.setattr(f"{AXIS_MODULE}.get_y_scroll", lambda table: SCROLL) + monkeypatch.setattr(f"{AXIS_MODULE}.get_y_scroll_max", lambda table: SCROLL_MAX) + monkeypatch.setattr(f"{AXIS_MODULE}.set_y_scroll", lambda table, offset: issued.append(offset)) + axis = VerticalScroll(table="tracker.table") + + axis.set_scroll(ISSUED) + + assert axis.scroll() == SCROLL + assert axis.scroll_max() == SCROLL_MAX + assert issued == [ISSUED] + + +class TestHorizontalScroll: + """A table whose columns run across the screen travels by width.""" + + def test_the_pointer_reads_as_its_width(self, monkeypatch: pytest.MonkeyPatch) -> None: + _read_pointer(monkeypatch) + + assert HorizontalScroll(table="order.table").pointer() == POINTER[0] + + def test_the_offsets_are_the_table_s_own(self, monkeypatch: pytest.MonkeyPatch) -> None: + issued: List[float] = [] + monkeypatch.setattr(f"{AXIS_MODULE}.get_x_scroll", lambda table: SCROLL) + monkeypatch.setattr(f"{AXIS_MODULE}.get_x_scroll_max", lambda table: SCROLL_MAX) + monkeypatch.setattr(f"{AXIS_MODULE}.set_x_scroll", lambda table, offset: issued.append(offset)) + axis = HorizontalScroll(table="order.table") + + axis.set_scroll(ISSUED) + + assert axis.scroll() == SCROLL + assert axis.scroll_max() == SCROLL_MAX + assert issued == [ISSUED] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_travel.py b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_travel.py new file mode 100644 index 000000000..5589d2909 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/sequencer/grid/scroll/test_travel.py @@ -0,0 +1,231 @@ +from typing import List, Optional, Tuple + +import pytest + +from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand +from sampletones_application.ui.panels.sequencer.grid.scroll.travel import ( + TRAVEL_CEILING_CELLS_PER_SECOND, + TRAVEL_FLOOR_CELLS_PER_SECOND, + TRAVEL_FULL_PACE_OVERSHOOT_CELLS, + DragTravel, +) + +FIRST_EDGE = 100.0 +CELL_EXTENT = 20.0 +CELL_COUNT = 60 +SCROLL_MAX = 800.0 +FRAME = 1.0 / 60.0 +BAND = TravelBand(first_edge=FIRST_EDGE, cell_extent=CELL_EXTENT, cell_count=CELL_COUNT) +BAND_NEAR = FIRST_EDGE +BAND_FAR = FIRST_EDGE + CELL_COUNT * CELL_EXTENT - SCROLL_MAX + + +class FakeAxis: + """An axis that stands wherever the test puts it, and records every offset issued to it.""" + + def __init__(self, *, pointer: float, scroll: float = 0.0, scroll_max: float = SCROLL_MAX) -> None: + self._pointer = pointer + self._scroll = scroll + self._scroll_max = scroll_max + self.issued: List[float] = [] + + def pointer(self) -> float: + return self._pointer + + def scroll(self) -> float: + return self._scroll + + def scroll_max(self) -> float: + return self._scroll_max + + def set_scroll(self, offset: float) -> None: + self.issued.append(offset) + + def stand_at(self, pointer: float) -> None: + self._pointer = pointer + + +def _travel( + axis: FakeAxis, + band: Optional[TravelBand] = BAND, + frame: float = FRAME, +) -> DragTravel: + return DragTravel(axis=axis, band=lambda: band, elapsed=lambda: frame) + + +def _grid( + pointer: float, + scroll: float = 0.0, + frame: float = FRAME, +) -> Tuple[FakeAxis, DragTravel]: + """A grid drawn at ``scroll``: its first cell stands that far back, so the band holds still.""" + axis = FakeAxis(pointer=pointer, scroll=scroll) + band = TravelBand( + first_edge=FIRST_EDGE - scroll, + cell_extent=CELL_EXTENT, + cell_count=CELL_COUNT, + ) + return axis, _travel(axis, band=band, frame=frame) + + +class TestPointerWithinTheBand: + """A pointer standing on the grid leaves it where it is.""" + + def test_a_pointer_in_the_middle_travels_nowhere(self) -> None: + axis = FakeAxis(pointer=(BAND_NEAR + BAND_FAR) / 2) + + _travel(axis).advance() + + assert axis.issued == [] + + def test_a_pointer_on_either_edge_travels_nowhere(self) -> None: + for pointer in (BAND_NEAR, BAND_FAR): + axis = FakeAxis(pointer=pointer) + + _travel(axis).advance() + + assert axis.issued == [] + + def test_a_grid_awaiting_its_layout_travels_nowhere(self) -> None: + axis = FakeAxis(pointer=BAND_FAR + 500.0) + + _travel(axis, band=None).advance() + + assert axis.issued == [] + + def test_a_grid_that_fits_on_screen_travels_nowhere(self) -> None: + axis = FakeAxis(pointer=BAND_FAR + 500.0, scroll_max=0.0) + + _travel(axis).advance() + + assert axis.issued == [] + + +class TestPace: + """The travel answers how far past the edge the pointer is carried.""" + + def test_a_pointer_just_past_the_edge_travels_at_the_floor(self) -> None: + axis = FakeAxis(pointer=BAND_FAR + 0.5) + + _travel(axis).advance() + + assert axis.issued == [pytest.approx(TRAVEL_FLOOR_CELLS_PER_SECOND * CELL_EXTENT * FRAME, abs=0.5)] + + def test_a_pointer_carried_further_travels_faster(self) -> None: + near_edge = FakeAxis(pointer=BAND_FAR + CELL_EXTENT) + far_out = FakeAxis(pointer=BAND_FAR + 3 * CELL_EXTENT) + + _travel(near_edge).advance() + _travel(far_out).advance() + + assert far_out.issued[0] > near_edge.issued[0] + + def test_the_pace_stops_rising_at_the_ceiling(self) -> None: + at_full_pace = FakeAxis(pointer=BAND_FAR + TRAVEL_FULL_PACE_OVERSHOOT_CELLS * CELL_EXTENT) + far_beyond = FakeAxis(pointer=BAND_FAR + 100 * CELL_EXTENT) + + _travel(at_full_pace).advance() + _travel(far_beyond).advance() + + ceiling = TRAVEL_CEILING_CELLS_PER_SECOND * CELL_EXTENT * FRAME + assert at_full_pace.issued == [pytest.approx(ceiling)] + assert far_beyond.issued == [pytest.approx(ceiling)] + + def test_the_same_stretch_passes_however_fast_the_frames_arrive(self) -> None: + """Two frames of half the duration carry the grid exactly as far as one full one.""" + whole = FakeAxis(pointer=BAND_FAR + 200.0) + halves = FakeAxis(pointer=BAND_FAR + 200.0) + + _travel(whole).advance() + paced = _travel(halves, frame=FRAME / 2) + paced.advance() + paced.advance() + + assert halves.issued[-1] == pytest.approx(whole.issued[-1]) + + +class TestDirection: + """The travel carries the grid toward whichever edge the pointer stands past.""" + + def test_a_pointer_before_the_near_edge_travels_back(self) -> None: + axis, travel = _grid(pointer=BAND_NEAR - 100.0, scroll=400.0) + + travel.advance() + + assert axis.issued[0] < 400.0 + + def test_a_pointer_past_the_far_edge_travels_on(self) -> None: + axis, travel = _grid(pointer=BAND_FAR + 100.0, scroll=400.0) + + travel.advance() + + assert axis.issued[0] > 400.0 + + def test_the_band_travels_with_the_scroll(self) -> None: + """A scrolled grid draws its first cell further back, so the band stands where it always did.""" + axis = FakeAxis(pointer=BAND_NEAR + 10.0, scroll=300.0) + scrolled = TravelBand( + first_edge=FIRST_EDGE - 300.0, + cell_extent=CELL_EXTENT, + cell_count=CELL_COUNT, + ) + + _travel(axis, band=scrolled).advance() + + assert axis.issued == [] + + +class TestEnds: + """The travel stops where the grid does.""" + + def test_the_far_end_stops_at_the_scroll_extent(self) -> None: + axis, travel = _grid(pointer=BAND_FAR + 500.0, scroll=SCROLL_MAX - 1.0) + + travel.advance() + + assert axis.issued == [SCROLL_MAX] + + def test_the_near_end_stops_at_the_start(self) -> None: + axis, travel = _grid(pointer=BAND_NEAR - 500.0, scroll=1.0) + + travel.advance() + + assert axis.issued == [0.0] + + +class TestRunningOffset: + """Each step is added to the offset last issued, since a table reports the one it was drawn with.""" + + def test_travel_accumulates_while_the_grid_reports_the_offset_it_was_drawn_with(self) -> None: + axis = FakeAxis(pointer=BAND_FAR + 500.0) + travel = _travel(axis) + + travel.advance() + travel.advance() + travel.advance() + + step = axis.issued[0] + assert axis.issued == [ + pytest.approx(step), + pytest.approx(2 * step), + pytest.approx(3 * step), + ] + + def test_a_pointer_returning_to_the_band_ends_the_travel(self) -> None: + axis = FakeAxis(pointer=BAND_FAR + 500.0) + travel = _travel(axis) + + travel.advance() + axis.stand_at(BAND_NEAR + 10.0) + travel.advance() + + assert len(axis.issued) == 1 + + def test_a_travel_at_rest_sets_out_from_the_offset_the_grid_is_drawn_with(self) -> None: + axis, travel = _grid(pointer=BAND_FAR + 500.0, scroll=250.0) + + travel.advance() + travel.rest() + travel.advance() + + assert axis.issued[0] == pytest.approx(axis.issued[1]) diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py index ca00eb80c..97353acc9 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_selection_drag.py @@ -10,8 +10,19 @@ LAYOUT_DIRECTORY, PALETTES_DIRECTORY, ) +from sampletones_application.tags.sequencer import ( + TAG_SEQUENCER_ORDER_TABLE, + TAG_SEQUENCER_TRACKER_TABLE, +) from sampletones_application.ui.elements.table.cells import EditableCells from sampletones_application.ui.elements.table.selection import TableSelection +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import ( + HorizontalScroll, + ScrollAxis, + VerticalScroll, +) +from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand +from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel from sampletones_application.ui.panels.sequencer.input.order import ( OrderCursor, OrderInputState, @@ -44,6 +55,11 @@ def sequencer_layout(layout_config: LayoutConfig) -> SequencerLayout: return layout_config.tabs.sequencer +def _resting_travel(axis: ScrollAxis) -> DragTravel: + """A travel over a grid that was never drawn: stating no band, it carries a drag nowhere.""" + return DragTravel(axis=axis, band=lambda: None, elapsed=lambda: 1.0 / 60.0) + + def _hold_modifiers( monkeypatch: pytest.MonkeyPatch, module: str, @@ -76,6 +92,7 @@ def _tracker( cell_at=lambda: panel._cell_at(), covered=panel._selected_cells, ) + panel._travel = _resting_travel(VerticalScroll(table=TAG_SEQUENCER_TRACKER_TABLE)) states: List[TrackerInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) @@ -99,6 +116,7 @@ def _order( cell_at=lambda: panel._cell_at(), covered=panel._selected_cells, ) + panel._travel = _resting_travel(HorizontalScroll(table=TAG_SEQUENCER_ORDER_TABLE)) states: List[OrderInputState] = [] monkeypatch.setattr(panel, "_apply_state", states.append) @@ -279,6 +297,75 @@ def test_a_grid_awaiting_its_rows_answers_nothing( assert panel._row_at(100.0) is None +class TestTravelBands: + """Each grid states the band a drag held past an edge travels across, in its own axis.""" + + def test_the_tracker_band_runs_from_the_first_row( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._current_row_count = ROW_COUNT + monkeypatch.setattr(panel, "_row_top", lambda index: 100.0 if index == 0 else None) + + assert panel._travel_band() == TravelBand( + first_edge=100.0, + cell_extent=sequencer_layout.tracker.row_height, + cell_count=ROW_COUNT, + ) + + def test_a_tracker_awaiting_its_rows_states_no_band( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._current_row_count = ROW_COUNT + monkeypatch.setattr(panel, "_row_top", lambda index: None) + + assert panel._travel_band() is None + + def test_an_empty_frame_states_no_band( + self, + monkeypatch: pytest.MonkeyPatch, + sequencer_layout: SequencerLayout, + ) -> None: + panel = GUISequencerTrackerPanel.__new__(GUISequencerTrackerPanel) + panel._layout = sequencer_layout + panel._current_row_count = 0 + monkeypatch.setattr(panel, "_row_top", lambda index: 100.0) + + assert panel._travel_band() is None + + def test_the_order_band_runs_across_from_the_first_position( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + panel._position_count = POSITION_COUNT + monkeypatch.setattr(panel, "_cell_left", lambda position: 40.0 + 25.0 * position) + + assert panel._travel_band() == TravelBand( + first_edge=40.0, + cell_extent=25.0, + cell_count=POSITION_COUNT, + ) + + def test_an_order_of_one_position_states_no_band( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A single position holds every width there is, so nothing states the pitch to travel by.""" + panel = GUISequencerOrderPanel.__new__(GUISequencerOrderPanel) + panel._position_count = 1 + monkeypatch.setattr(panel, "_cell_left", lambda position: 40.0 if position == 0 else None) + + assert panel._travel_band() is None + + class TestOrderDrag: """The order table reads a drag the same way, over its channels and positions.""" From 5f8f1812228b964e9b4be5ee7b4c84bb448cc8a4 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 15:32:43 +0200 Subject: [PATCH 091/152] Added: sequencer blocks on the system clipboard --- .../coordinators/tabs/sequencer.py | 77 ++++- .../logic/sequencer/clipboard/__init__.py | 14 + .../logic/sequencer/clipboard/cache.py | 25 ++ .../logic/sequencer/clipboard/fields.py | 85 ++++++ .../logic/sequencer/clipboard/header.py | 88 ++++++ .../logic/sequencer/clipboard/order.py | 112 +++++++ .../logic/sequencer/clipboard/samples.py | 38 +++ .../{clipboard.py => clipboard/store.py} | 0 .../logic/sequencer/clipboard/tracker.py | 273 +++++++++++++++++ .../utils/gui/clipboard.py | 21 +- tests/suite/surface.py | 12 +- .../coordinators/tabs/test_sequencer.py | 136 ++++++++- .../logic/sequencer/clipboard/__init__.py | 0 .../logic/sequencer/clipboard/test_cache.py | 60 ++++ .../logic/sequencer/clipboard/test_header.py | 79 +++++ .../logic/sequencer/clipboard/test_order.py | 163 ++++++++++ .../logic/sequencer/clipboard/test_samples.py | 74 +++++ .../logic/sequencer/clipboard/test_tracker.py | 289 ++++++++++++++++++ 18 files changed, 1529 insertions(+), 17 deletions(-) create mode 100644 src/sampletones_application/logic/sequencer/clipboard/__init__.py create mode 100644 src/sampletones_application/logic/sequencer/clipboard/cache.py create mode 100644 src/sampletones_application/logic/sequencer/clipboard/fields.py create mode 100644 src/sampletones_application/logic/sequencer/clipboard/header.py create mode 100644 src/sampletones_application/logic/sequencer/clipboard/order.py create mode 100644 src/sampletones_application/logic/sequencer/clipboard/samples.py rename src/sampletones_application/logic/sequencer/{clipboard.py => clipboard/store.py} (100%) create mode 100644 src/sampletones_application/logic/sequencer/clipboard/tracker.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/clipboard/__init__.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/clipboard/test_cache.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/clipboard/test_header.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/clipboard/test_order.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index a28525e0c..c53a14e2d 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -22,11 +22,18 @@ 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.clipboard import SequencerClipboard +from sampletones_application.logic.sequencer.clipboard import ( + OrderBlockText, + ParsedBlockCache, + ProjectSampleDirectory, + SequencerClipboard, + TrackerBlockText, +) from sampletones_application.logic.sequencer.history_detail import ( SequencerHistoryDetail, ) from sampletones_application.logic.sequencer.order import ( + OrderBlock, OrderBlockReader, OrderBlockWriter, SequencerOrderLogic, @@ -41,6 +48,7 @@ from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.sequencer.tracker import ( SequencerTrackerLogic, + TrackerBlock, TrackerBlockReader, TrackerBlockWriter, TrackerRegionAdjuster, @@ -81,6 +89,10 @@ from sampletones_application.ui.panels.sequencer.samples import GUISequencerSamplesPanel from sampletones_application.ui.panels.sequencer.tracker import GUISequencerTrackerPanel from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.clipboard import ( + SystemTextClipboard, + TextClipboard, +) from sampletones_application.utils.gui.dialogs import DialogsRenderer from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.gui.frame import FrameCallbackManager @@ -196,6 +208,13 @@ def __init__( self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller) self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller) self._clipboard: SequencerClipboard = SequencerClipboard() + self._system_clipboard: TextClipboard = SystemTextClipboard() + self._tracker_block_text: TrackerBlockText = TrackerBlockText( + samples=ProjectSampleDirectory(project_controller), + ) + self._order_block_text: OrderBlockText = OrderBlockText() + self._tracker_text_cache: ParsedBlockCache[TrackerBlock] = ParsedBlockCache(self._tracker_block_text.parse) + self._order_text_cache: ParsedBlockCache[OrderBlock] = ParsedBlockCache(self._order_block_text.parse) self._tracker_block_reader: TrackerBlockReader = TrackerBlockReader(self._sequencer_tracker_logic) self._tracker_block_writer: TrackerBlockWriter = TrackerBlockWriter(self._sequencer_tracker_logic) self._tracker_region_adjuster: TrackerRegionAdjuster = TrackerRegionAdjuster(self._sequencer_tracker_logic) @@ -506,15 +525,45 @@ def _wire_block_callbacks(self) -> None: def _can_paste_tracker_block(self) -> bool: """Whether the tracker has a block to write, which is what its Paste item is offered on.""" - return self._clipboard.tracker_block is not None + return self._tracker_block_in_hand() is not None def _can_paste_order_block(self) -> bool: """Whether the order has a block to write, which is what its Paste item is offered on.""" - return self._clipboard.order_block is not None + return self._order_block_in_hand() is not None + + def _tracker_block_in_hand(self) -> Optional[TrackerBlock]: + """The block a tracker paste would write: the system clipboard's while its text is one. + + Text another instance copied reads as a block here, so it stands ahead of the slot the + tracker copied into, and text from anywhere else leaves that slot's own block in hand. + """ + parsed = self._tracker_text_cache.block(self._system_clipboard.read()) + if parsed is not None: + return parsed + + return self._clipboard.tracker_block + + def _order_block_in_hand(self) -> Optional[OrderBlock]: + """The block an order paste would write: the system clipboard's while its text is one. + + Text another instance copied reads as a block here, so it stands ahead of the slot the + order copied into, and text from anywhere else leaves that slot's own block in hand. + """ + parsed = self._order_text_cache.block(self._system_clipboard.read()) + if parsed is not None: + return parsed + + return self._clipboard.order_block def _on_tracker_copy_block(self, region: TrackerRegion) -> None: - """Puts the tracker's selected block on the clipboard, for a paste to replay.""" - self._clipboard.store_tracker_block(self._tracker_block_reader.read(region)) + """Puts the tracker's selected block on both clipboards, for a paste to replay. + + The slot keeps the block exactly, and the system clipboard keeps the text form of it, so + the same copy reaches a paste here and a paste in another instance. + """ + block = self._tracker_block_reader.read(region) + self._clipboard.store_tracker_block(block) + self._system_clipboard.write(self._tracker_block_text.state(block, region)) def _cut_tracker_block(self, region: TrackerRegion) -> None: """Takes the block a region covers onto the clipboard, then empties what it covered.""" @@ -522,14 +571,20 @@ def _cut_tracker_block(self, region: TrackerRegion) -> None: self._tracker_block_writer.clear(region) def _paste_tracker_block(self, cell: TrackerCell) -> None: - """Writes the block the tracker last copied at a cell, while a copy has been made.""" - block = self._clipboard.tracker_block + """Writes the block the tracker has in hand at a cell, while a copy has been made.""" + block = self._tracker_block_in_hand() if block is not None: self._tracker_block_writer.write(block, cell) def _on_order_copy_block(self, region: OrderRegion) -> None: - """Puts the order's selected block on the clipboard, for a paste to replay.""" - self._clipboard.store_order_block(self._order_block_reader.read(region)) + """Puts the order's selected block on both clipboards, for a paste to replay. + + The slot keeps the block exactly, and the system clipboard keeps the text form of it, so + the same copy reaches a paste here and a paste in another instance. + """ + block = self._order_block_reader.read(region) + self._clipboard.store_order_block(block) + self._system_clipboard.write(self._order_block_text.state(block, region)) def _cut_order_block(self, region: OrderRegion) -> None: """Takes the block a region covers onto the clipboard, then silences what it covered.""" @@ -537,8 +592,8 @@ def _cut_order_block(self, region: OrderRegion) -> None: self._order_block_writer.clear(region) def _paste_order_block(self, cell: OrderCell) -> None: - """Writes the block the order last copied at a cell, while a copy has been made.""" - block = self._clipboard.order_block + """Writes the block the order has in hand at a cell, while a copy has been made.""" + block = self._order_block_in_hand() if block is not None: self._order_block_writer.write(block, cell) diff --git a/src/sampletones_application/logic/sequencer/clipboard/__init__.py b/src/sampletones_application/logic/sequencer/clipboard/__init__.py new file mode 100644 index 000000000..689e06d85 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/__init__.py @@ -0,0 +1,14 @@ +from .cache import ParsedBlockCache +from .order import OrderBlockText +from .samples import ProjectSampleDirectory, SampleDirectory +from .store import SequencerClipboard +from .tracker import TrackerBlockText + +__all__ = [ + "OrderBlockText", + "ParsedBlockCache", + "ProjectSampleDirectory", + "SampleDirectory", + "SequencerClipboard", + "TrackerBlockText", +] diff --git a/src/sampletones_application/logic/sequencer/clipboard/cache.py b/src/sampletones_application/logic/sequencer/clipboard/cache.py new file mode 100644 index 000000000..df915db8f --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/cache.py @@ -0,0 +1,25 @@ +from typing import Callable, Generic, Optional, TypeVar + +BlockT = TypeVar("BlockT") + + +class ParsedBlockCache(Generic[BlockT]): + """Holds the block a text last read as, so asking again about it costs one comparison. + + A menu opening asks whether a paste has anything to write and the paste that follows asks + for the block itself, both about the text standing on the system clipboard, so one parse + serves every question put about that text. + """ + + def __init__(self, parse: Callable[[str], Optional[BlockT]]) -> None: + self._parse = parse + self._text: Optional[str] = None + self._block: Optional[BlockT] = None + + def block(self, text: str) -> Optional[BlockT]: + """The block a text reads as, parsed on its first reading and held for the rest.""" + if text != self._text: + self._text = text + self._block = self._parse(text) + + return self._block diff --git a/src/sampletones_application/logic/sequencer/clipboard/fields.py b/src/sampletones_application/logic/sequencer/clipboard/fields.py new file mode 100644 index 000000000..3e17c3aa5 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/fields.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Final, Generic, Optional, TypeVar + +from sampletones_shared.constants.symbols import DOT, HEXADECIMAL, MIXED + +KeyT = TypeVar("KeyT") +ValueT = TypeVar("ValueT") + +HEXADECIMAL_BASE: Final[int] = 16 + + +@dataclass(frozen=True) +class FieldReading(Generic[ValueT]): + """What one printed field states about the cell it stands for. + + ``stated`` separates the two readings a value of ``None`` carries: a field printing the dots + an empty cell shows states emptiness, and one printing the marks a mixed cell shows states + nothing at all, so its key stays out of the block and a paste passes that cell by. + """ + + value: Optional[ValueT] + stated: bool + + @classmethod + def of(cls, value: Optional[ValueT]) -> FieldReading[ValueT]: + return cls(value=value, stated=True) + + @classmethod + def mixed(cls) -> FieldReading[ValueT]: + return cls(value=None, stated=False) + + +def state_mixed(width: int) -> str: + """The marks a mixed cell prints, filling its field so every row line reads as a grid.""" + return MIXED * width + + +def read_placeholder(field: str) -> Optional[FieldReading[ValueT]]: + """The reading a field of one repeated mark carries: emptiness, or nothing at all. + + Returns: + The reading, present while the field is dots throughout or marks throughout. A field + carrying anything else is left to the reader of its own kind. + """ + marks = set(field) + if marks == {MIXED}: + return FieldReading.mixed() + + if marks == {DOT}: + return FieldReading.of(None) + + return None + + +def read_hexadecimal(field: str) -> Optional[int]: + """The number a field of hexadecimal digits names, present while every character is one. + + Digits are read in either case, so a field typed by hand reads as the one the grid prints. + """ + digits = field.upper() + if not digits or any(digit not in HEXADECIMAL for digit in digits): + return None + + return int(digits, HEXADECIMAL_BASE) + + +def store_reading( + values: Dict[KeyT, Optional[ValueT]], + key: KeyT, + reading: Optional[FieldReading[ValueT]], +) -> bool: + """Puts the cell a reading states into the map, answering whether the field had a reading. + + A field the form has no reading for answers ``False``, which is what refuses a whole text + rather than letting one unreadable cell reach the grid. + """ + if reading is None: + return False + + if reading.stated: + values[key] = reading.value + + return True diff --git a/src/sampletones_application/logic/sequencer/clipboard/header.py b/src/sampletones_application/logic/sequencer/clipboard/header.py new file mode 100644 index 000000000..9bd24a591 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/header.py @@ -0,0 +1,88 @@ +from dataclasses import dataclass +from typing import Final, Optional, Tuple + +BLOCK_MAGIC: Final[str] = "SampleToNES/1" +ROW_KEY: Final[str] = "rows" +LABEL_SEPARATOR: Final[str] = "=" +SPAN_SEPARATOR: Final[str] = ".." +HEADER_TOKEN_COUNT: Final[int] = 4 + + +@dataclass(frozen=True) +class BlockShape: + """How far a block reaches: the rows it holds, and the span its fields cross. + + The span is stated in the coordinates of the grid the block was read from, so a tracker + block names the slots it began and ended on and a reading of it lands on the same kinds of + subcolumn. + """ + + rows: int + first: int + last: int + + @property + def width(self) -> int: + return self.last - self.first + 1 + + +def state_header(*, grid: str, span_key: str, shape: BlockShape) -> str: + """The line a block opens with, naming the grid it came from and the shape it covers.""" + rows = f"{ROW_KEY}{LABEL_SEPARATOR}{shape.rows}" + span = f"{span_key}{LABEL_SEPARATOR}{shape.first}{SPAN_SEPARATOR}{shape.last}" + return f"{BLOCK_MAGIC} {grid} {rows} {span}" + + +def parse_header( + line: str, + *, + grid: str, + span_key: str, +) -> Optional[BlockShape]: + """The shape a header states, present while it names this grid in the form written here. + + The shape is also the declaration the body is held to, so a text whose lines state a + different count or width is refused by the reader that asked for it. + """ + tokens = line.split() + if len(tokens) != HEADER_TOKEN_COUNT or tokens[0] != BLOCK_MAGIC or tokens[1] != grid: + return None + + rows = _read_count(tokens[2], ROW_KEY) + span = _read_span(tokens[3], span_key) + if rows is None or span is None: + return None + + first, last = span + return BlockShape(rows=rows, first=first, last=last) + + +def _read_label(token: str, label: str) -> Optional[str]: + """What a ``label=value`` token states, present while it carries the label asked for.""" + name, separator, value = token.partition(LABEL_SEPARATOR) + if name != label or not separator: + return None + + return value + + +def _read_count(token: str, label: str) -> Optional[int]: + """The count a ``rows=4`` token names, present while it covers at least one row.""" + value = _read_label(token, label) + if value is None or not value.isdigit() or int(value) < 1: + return None + + return int(value) + + +def _read_span(token: str, label: str) -> Optional[Tuple[int, int]]: + """The bounds a ``slots=3..11`` token names, present while they stand in reading order.""" + value = _read_label(token, label) + if value is None: + return None + + first, separator, last = value.partition(SPAN_SEPARATOR) + if not separator or not first.isdigit() or not last.isdigit() or int(last) < int(first): + return None + + return int(first), int(last) diff --git a/src/sampletones_application/logic/sequencer/clipboard/order.py b/src/sampletones_application/logic/sequencer/clipboard/order.py new file mode 100644 index 000000000..c065e42e4 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/order.py @@ -0,0 +1,112 @@ +from typing import Dict, Final, List, Optional + +from sampletones_application.constants.sequencer import CHANNEL_AXIS +from sampletones_application.logic.sequencer.order.block import BlockKey, OrderBlock +from sampletones_application.view_model.sequencer.region import OrderRegion +from sampletones_core.utils.display import display_id + +from .fields import ( + FieldReading, + read_hexadecimal, + read_placeholder, + state_mixed, + store_reading, +) +from .header import BlockShape, parse_header, state_header + +ORDER_GRID: Final[str] = "order" +POSITION_KEY: Final[str] = "positions" +ENTRY_WIDTH: Final[int] = len(display_id(None)) + + +class OrderBlockText: + """States an order block as the lines the table prints, and reads the same form back. + + One line per channel row, positions running across it, every field carrying what the table + shows in its cell: a pattern index, the dots of a silent slot, or the marks a master cell + fills its field with where the channels beneath it disagree. + """ + + def state(self, block: OrderBlock, region: OrderRegion) -> str: + """The text a copy puts on the system clipboard, the region supplying the shape. + + The region is what states the positions the block stands on, since a mixed cell leaves + its key out and a block alone therefore names less than the rectangle it came from. + """ + shape = BlockShape( + rows=len(region.rows), + first=region.first_position, + last=region.last_position, + ) + lines = [state_header(grid=ORDER_GRID, span_key=POSITION_KEY, shape=shape)] + lines.extend(self._state_row(block, shape, row_offset) for row_offset in range(shape.rows)) + return "\n".join(lines) + + def parse(self, text: str) -> Optional[OrderBlock]: + """The block a text states, present while it is one this table writes. + + Text naming another grid, declaring a shape its lines do not fill, or carrying a field + the form has no reading for states no block, so the slot the order copied into stands. + """ + lines = text.strip().splitlines() + if not lines: + return None + + shape = parse_header(lines[0], grid=ORDER_GRID, span_key=POSITION_KEY) + if shape is None or shape.rows > len(CHANNEL_AXIS) or len(lines) != shape.rows + 1: + return None + + return self._read_rows(lines[1:], shape) + + def _state_row( + self, + block: OrderBlock, + shape: BlockShape, + row_offset: int, + ) -> str: + """One row of the block, its fields standing in the order the positions run.""" + return " ".join( + self._state_entry( + block, + (row_offset, position_offset), + ) + for position_offset in range(shape.width) + ) + + @staticmethod + def _state_entry(block: OrderBlock, key: BlockKey) -> str: + if key not in block.entries: + return state_mixed(ENTRY_WIDTH) + + return display_id(block.entries[key]) + + def _read_rows( + self, + lines: List[str], + shape: BlockShape, + ) -> Optional[OrderBlock]: + entries: Dict[BlockKey, Optional[int]] = {} + for row_offset, line in enumerate(lines): + fields = line.split() + if len(fields) != shape.width: + return None + + for position_offset, field in enumerate(fields): + key = (row_offset, position_offset) + if not store_reading(entries, key, self._read_entry(field)): + return None + + return OrderBlock(entries=entries) + + @staticmethod + def _read_entry(field: str) -> Optional[FieldReading[int]]: + """The pattern a field names, present while it states an index or one of the two marks.""" + placeholder: Optional[FieldReading[int]] = read_placeholder(field) + if placeholder is not None: + return placeholder + + pattern_index = read_hexadecimal(field) + if pattern_index is None: + return None + + return FieldReading.of(pattern_index) diff --git a/src/sampletones_application/logic/sequencer/clipboard/samples.py b/src/sampletones_application/logic/sequencer/clipboard/samples.py new file mode 100644 index 000000000..2e4099521 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/samples.py @@ -0,0 +1,38 @@ +from typing import Optional, Protocol + +from sampletones_application.logic.project.controller import ProjectController + + +class SampleDirectory(Protocol): + """The samples a note can name, read the way a grid prints them: by list position.""" + + def position_of(self, sample_id: str) -> Optional[int]: ... + + def sample_at(self, position: int) -> Optional[str]: ... + + +class ProjectSampleDirectory: + """The samples the open project holds, in the order the samples panel lists them. + + The project is read on each lookup, because opening a document and every undo put another + one in place, so a block stated as text names whichever sample stands at that position now. + """ + + def __init__(self, project_controller: ProjectController) -> None: + self._controller = project_controller + + def position_of(self, sample_id: str) -> Optional[int]: + """Where a sample stands in the list, present while the project holds it.""" + samples = self._controller.project.samples + if samples.get(sample_id) is None: + return None + + return samples.get_index(sample_id) + + def sample_at(self, position: int) -> Optional[str]: + """The sample a position names, present while the list reaches that far.""" + samples = self._controller.project.samples + if 0 <= position < len(samples): + return samples[position].id + + return None diff --git a/src/sampletones_application/logic/sequencer/clipboard.py b/src/sampletones_application/logic/sequencer/clipboard/store.py similarity index 100% rename from src/sampletones_application/logic/sequencer/clipboard.py rename to src/sampletones_application/logic/sequencer/clipboard/store.py diff --git a/src/sampletones_application/logic/sequencer/clipboard/tracker.py b/src/sampletones_application/logic/sequencer/clipboard/tracker.py new file mode 100644 index 000000000..53b4f2b30 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/clipboard/tracker.py @@ -0,0 +1,273 @@ +from typing import Callable, Dict, Final, List, Optional + +from sampletones_application.logic.sequencer.tracker.block import ( + BlockKey, + BlockNote, + TrackerBlock, +) +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import ( + SLOT_COUNT, + column_slot_base, + slot_from_flat, +) +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.general import ( + MAX_TRANSPOSE, + MAX_VOLUME, + MIN_TRANSPOSE, + SILENT_VOLUME, +) +from sampletones_core.project.instruments.note_off import NoteOff +from sampletones_core.utils.display import ( + NOTE_OFF, + display_id, + display_transpose, + display_volume, +) +from sampletones_shared.constants.symbols import PLUS, SIGNS + +from .fields import ( + FieldReading, + read_hexadecimal, + read_placeholder, + state_mixed, + store_reading, +) +from .header import BlockShape, parse_header, state_header +from .samples import SampleDirectory + +TRACKER_GRID: Final[str] = "tracker" +SLOT_KEY: Final[str] = "slots" +COLUMN_SEPARATOR: Final[str] = "|" +NOTE_WIDTH: Final[int] = len(display_id(None)) +TRANSPOSE_WIDTH: Final[int] = len(display_transpose(None)) +VOLUME_WIDTH: Final[int] = len(display_volume(None)) + + +class TrackerBlockText: + """States a tracker block as the lines the grid prints, and reads the same form back. + + Every field carries what the grid shows in its cell, which is what makes the three states a + cell reaches a block in survive a round trip: a value reads as its value, an empty cell as + the dots beneath it, and a mixed one as the marks filling its field. A bar stands between + columns, so a line reads as the row it was taken from. + + A note names its sample by the list position the grid prints, so a block carried to another + project plays whichever sample stands at that position there. + """ + + def __init__(self, *, samples: SampleDirectory) -> None: + self._samples = samples + + def state(self, block: TrackerBlock, region: TrackerRegion) -> str: + """The text a copy puts on the system clipboard, the region supplying the shape. + + The region is what states the slots the block stands on, since a mixed cell leaves its + key out and a block alone therefore names less than the rectangle it was read from. + """ + shape = BlockShape( + rows=len(region.rows), + first=region.first_slot, + last=region.last_slot, + ) + lines = [state_header(grid=TRACKER_GRID, span_key=SLOT_KEY, shape=shape)] + lines.extend( + self._state_row( + block, + region, + row_offset, + ) + for row_offset in range(shape.rows) + ) + return "\n".join(lines) + + def parse(self, text: str) -> Optional[TrackerBlock]: + """The block a text states, present while it is one this grid writes. + + Text naming another grid, declaring a shape its lines do not fill, or carrying a field + the form has no reading for states no block, so the slot the tracker copied into stands. + """ + lines = text.strip().splitlines() + if not lines: + return None + + shape = parse_header(lines[0], grid=TRACKER_GRID, span_key=SLOT_KEY) + if shape is None or shape.last >= SLOT_COUNT or len(lines) != shape.rows + 1: + return None + + return self._read_rows(lines[1:], shape) + + def _state_row( + self, + block: TrackerBlock, + region: TrackerRegion, + row_offset: int, + ) -> str: + """One row of the block, its fields in slot order and its columns held apart by a bar.""" + base = column_slot_base(slot_from_flat(region.first_slot).generator) + fields: List[str] = [] + for position, slot in enumerate(region.slots): + if position > 0 and slot.generator != region.slots[position - 1].generator: + fields.append(COLUMN_SEPARATOR) + + key = (row_offset, region.first_slot + position - base) + fields.append(self._state_slot(block, slot.subcolumn, key)) + + return " ".join(fields) + + def _state_slot( + self, + block: TrackerBlock, + subcolumn: SubColumn, + key: BlockKey, + ) -> str: + match subcolumn: + case SubColumn.INSTRUMENT: + return self._state_note(block.notes, key) + case SubColumn.TRANSPOSE: + return self._state_number( + block.transposes, + key, + display_transpose, + TRANSPOSE_WIDTH, + ) + case SubColumn.VOLUME: + return self._state_number( + block.volumes, + key, + display_volume, + VOLUME_WIDTH, + ) + + def _state_note( + self, + notes: Dict[BlockKey, Optional[BlockNote]], + key: BlockKey, + ) -> str: + """What the note column prints at a cell, a sample naming the position it stands at. + + A note whose sample the project in place lacks prints as mixed, so reading the text back + passes that cell by, the way a paste passes over a sample it has nothing to place. + """ + if key not in notes: + return state_mixed(NOTE_WIDTH) + + match notes[key]: + case NoteOff(): + return NOTE_OFF + case str() as sample_id: + position = self._samples.position_of(sample_id) + return state_mixed(NOTE_WIDTH) if position is None else display_id(position) + case _: + return display_id(None) + + @staticmethod + def _state_number( + values: Dict[BlockKey, Optional[int]], + key: BlockKey, + display: Callable[[Optional[int]], str], + width: int, + ) -> str: + if key not in values: + return state_mixed(width) + + return display(values[key]) + + def _read_rows( + self, + lines: List[str], + shape: BlockShape, + ) -> Optional[TrackerBlock]: + """The block a body states, each kind of subcolumn gathered into a map of its own.""" + base = column_slot_base(slot_from_flat(shape.first).generator) + notes: Dict[BlockKey, Optional[BlockNote]] = {} + transposes: Dict[BlockKey, Optional[int]] = {} + volumes: Dict[BlockKey, Optional[int]] = {} + for row_offset, line in enumerate(lines): + fields = line.replace(COLUMN_SEPARATOR, " ").split() + if len(fields) != shape.width: + return None + + for position, field in enumerate(fields): + slot = slot_from_flat(shape.first + position) + key = (row_offset, shape.first + position - base) + match slot.subcolumn: + case SubColumn.INSTRUMENT: + read = store_reading( + notes, + key, + self._read_note(field), + ) + case SubColumn.TRANSPOSE: + read = store_reading( + transposes, + key, + self._read_transpose(field), + ) + case SubColumn.VOLUME: + read = store_reading( + volumes, + key, + self._read_volume(field), + ) + + if not read: + return None + + return TrackerBlock( + notes=notes, + transposes=transposes, + volumes=volumes, + ) + + def _read_note(self, field: str) -> Optional[FieldReading[BlockNote]]: + """The note a field states: the sample standing at the position it names, a cut, or emptiness. + + A position the project's samples fall short of states nothing, so a paste passes that + cell by rather than silencing it. + """ + placeholder: Optional[FieldReading[BlockNote]] = read_placeholder(field) + if placeholder is not None: + return placeholder + + if field == NOTE_OFF: + return FieldReading.of(NoteOff()) + + position = read_hexadecimal(field) + if position is None: + return None + + sample_id = self._samples.sample_at(position) + return FieldReading.mixed() if sample_id is None else FieldReading.of(sample_id) + + @staticmethod + def _read_transpose(field: str) -> Optional[FieldReading[int]]: + """The transpose a signed field states, present while it lies in the range a row accepts.""" + placeholder: Optional[FieldReading[int]] = read_placeholder(field) + if placeholder is not None: + return placeholder + + sign = field[:1] + magnitude = read_hexadecimal(field[1:]) + if sign not in SIGNS or magnitude is None: + return None + + transpose = magnitude if sign == PLUS else -magnitude + if not MIN_TRANSPOSE <= transpose <= MAX_TRANSPOSE: + return None + + return FieldReading.of(transpose) + + @staticmethod + def _read_volume(field: str) -> Optional[FieldReading[int]]: + """The volume a field states, present while it lies in the range a row accepts.""" + placeholder: Optional[FieldReading[int]] = read_placeholder(field) + if placeholder is not None: + return placeholder + + volume = read_hexadecimal(field) + if volume is None or not SILENT_VOLUME <= volume <= MAX_VOLUME: + return None + + return FieldReading.of(volume) diff --git a/src/sampletones_application/utils/gui/clipboard.py b/src/sampletones_application/utils/gui/clipboard.py index df31e3c6a..9b26290ce 100644 --- a/src/sampletones_application/utils/gui/clipboard.py +++ b/src/sampletones_application/utils/gui/clipboard.py @@ -1,10 +1,29 @@ import threading +from typing import Protocol, cast import dearpygui.dearpygui as dpg from sampletones_application.utils.gui.dpg import dpg_configure_item +class TextClipboard(Protocol): + """The clipboard the desktop shares between applications, as text going out and coming back.""" + + def read(self) -> str: ... + + def write(self, text: str) -> None: ... + + +class SystemTextClipboard: + """The desktop's clipboard, reached through the one DearPyGui holds for the viewport.""" + + def read(self) -> str: + return cast(str, dpg.get_clipboard_text()) + + def write(self, text: str) -> None: + dpg.set_clipboard_text(text) + + def copy_to_clipboard( text: str, label: str, @@ -12,7 +31,7 @@ def copy_to_clipboard( *, copied_label: str, ) -> None: - dpg.set_clipboard_text(text) + SystemTextClipboard().write(text) dpg_configure_item(button_tag, label=copied_label) diff --git a/tests/suite/surface.py b/tests/suite/surface.py index e13a4227a..052ff2e9f 100644 --- a/tests/suite/surface.py +++ b/tests/suite/surface.py @@ -2,9 +2,15 @@ from typing import Callable, Final, List, Optional from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures -from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ClipboardItems -from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface -from sampletones_application.ui.panels.sequencer.grid.surface.targets import CursorTargets +from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( + ClipboardItems, +) +from sampletones_application.ui.panels.sequencer.grid.surface.edit import ( + GridEditSurface, +) +from sampletones_application.ui.panels.sequencer.grid.surface.targets import ( + CursorTargets, +) from sampletones_application.ui.panels.sequencer.input.state import GridInputState from tests.suite.grid import CLIPBOARD_LABELS, TRACKER_BLOCK_SHORTCUTS from tests.suite.shortcuts import shipped_source diff --git a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py index 8c52f87b2..b0e7d7783 100644 --- a/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py +++ b/tests/unit/sampletones_application/coordinators/tabs/test_sequencer.py @@ -20,7 +20,13 @@ ALL_CHANNELS, SequencerChannelsLogic, ) -from sampletones_application.logic.sequencer.clipboard import SequencerClipboard +from sampletones_application.logic.sequencer.clipboard import ( + OrderBlockText, + ParsedBlockCache, + ProjectSampleDirectory, + SequencerClipboard, + TrackerBlockText, +) from sampletones_application.logic.sequencer.history_detail import SequencerHistoryDetail from sampletones_application.logic.sequencer.order import ( OrderBlockReader, @@ -1347,13 +1353,27 @@ def test_player_returns_the_guarded_wrapper( ) +class FakeTextClipboard: + """The desktop's clipboard, held in memory so a test reads what a copy put there.""" + + def __init__(self) -> None: + self.text: str = "" + + def read(self) -> str: + return self.text + + def write(self, text: str) -> None: + self.text = text + + @pytest.fixture def block_coordinator() -> SequencerTabCoordinator: """A coordinator whose block path is real, from the tracker logic through to the clipboard. A real manager observes the same controller production wires it to, so a test reads the entries a gesture actually records, and the hooks are the ones ``_wire_block_callbacks`` - assigns rather than wrappers a test built to look like them. + assigns rather than wrappers a test built to look like them. The system clipboard is the one + boundary standing in, since the desktop's own is reached through a running viewport. """ instance = object.__new__(SequencerTabCoordinator) controller = ProjectController(ProjectManager()) @@ -1365,6 +1385,11 @@ def block_coordinator() -> SequencerTabCoordinator: instance._history = history instance._sequencer_tracker_logic = SequencerTrackerLogic(controller) instance._clipboard = SequencerClipboard() + instance._system_clipboard = FakeTextClipboard() + instance._tracker_block_text = TrackerBlockText(samples=ProjectSampleDirectory(controller)) + instance._order_block_text = OrderBlockText() + instance._tracker_text_cache = ParsedBlockCache(instance._tracker_block_text.parse) + instance._order_text_cache = ParsedBlockCache(instance._order_block_text.parse) instance._tracker_block_reader = TrackerBlockReader(instance._sequencer_tracker_logic) instance._tracker_block_writer = TrackerBlockWriter(instance._sequencer_tracker_logic) instance._sequencer_order_logic = SequencerOrderLogic(controller) @@ -1562,3 +1587,110 @@ def test_a_paste_with_nothing_copied_records_nothing( coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE2)) assert len(coordinator._history.entries) == recorded + + +class TestSystemClipboardCopy: + """A copy writes both clipboards, so the same gesture reaches a paste here and elsewhere.""" + + def test_a_copy_states_the_block_as_text( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + + coordinator._on_tracker_copy_block(PULSE1_CELL) + + assert coordinator._system_clipboard.text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ." + + def test_an_order_copy_states_its_own_grid( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + + coordinator._on_order_copy_block(PULSE1_FRAME) + + assert coordinator._system_clipboard.text == "SampleToNES/1 order rows=1 positions=0..0\n00" + + def test_a_cut_states_the_block_it_took( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + + coordinator._sequencer_tracker_panel.on_cut_block(PULSE1_CELL) + + assert coordinator._system_clipboard.text == "SampleToNES/1 tracker rows=1 slots=3..5\n.. +05 ." + + +class TestSystemClipboardPrecedence: + """Text that reads as a block for this grid stands ahead of the slot it copied into.""" + + def test_a_block_copied_elsewhere_is_the_one_a_paste_writes( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """This is a second instance's copy arriving, which is what carries a block between them.""" + coordinator = block_coordinator + _place_transpose(coordinator, 5) + coordinator._on_tracker_copy_block(PULSE1_CELL) + coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1)) + + assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 9 + + def test_unrelated_text_leaves_the_copied_block_in_hand( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + coordinator._on_tracker_copy_block(PULSE1_CELL) + coordinator._system_clipboard.write("a line from a message") + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1)) + + assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 5 + + def test_a_truncated_block_leaves_the_copied_block_in_hand( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + coordinator = block_coordinator + _place_transpose(coordinator, 5) + coordinator._on_tracker_copy_block(PULSE1_CELL) + coordinator._system_clipboard.write("SampleToNES/1 tracker rows=4 slots=3..5\n.. +09 .") + + coordinator._sequencer_tracker_panel.on_paste_block(TrackerCell(row=1, generator=GeneratorName.PULSE1)) + + assert coordinator._sequencer_tracker_logic.row(GeneratorName.PULSE1, 1).transpose == 5 + + def test_the_other_grid_s_text_leaves_the_copied_block_in_hand( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """A tracker copy stands on the clipboard while the order pastes, so each grid keeps its own.""" + coordinator = block_coordinator + coordinator._on_order_copy_block(PULSE1_FRAME) + coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + + coordinator._sequencer_order_panel.on_paste_block(OrderCell(generator=GeneratorName.NOISE, position=1)) + + assert coordinator._sequencer_order_logic.entry(GeneratorName.NOISE, 1) == 0 + + def test_a_paste_offers_itself_on_the_text_standing_on_the_clipboard( + self, + block_coordinator: SequencerTabCoordinator, + ) -> None: + """The menu asks the same question the paste does, so it offers what the next press reaches.""" + coordinator = block_coordinator + + assert not coordinator._can_paste_tracker_block() + + coordinator._system_clipboard.write("SampleToNES/1 tracker rows=1 slots=3..5\n.. +09 .") + + assert coordinator._can_paste_tracker_block() + assert not coordinator._can_paste_order_block() diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/__init__.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_cache.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_cache.py new file mode 100644 index 000000000..a425d534a --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_cache.py @@ -0,0 +1,60 @@ +from typing import List, Optional + +from sampletones_application.logic.sequencer.clipboard.cache import ParsedBlockCache + +BLOCK = "SampleToNES/1 tracker rows=1 slots=3..5" +OTHER = "SampleToNES/1 order rows=1 positions=0..0" + + +class FakeParser: + """A parser recording every text it was put to, reading each one as its own length.""" + + def __init__(self) -> None: + self.asked: List[str] = [] + + def parse(self, text: str) -> Optional[int]: + self.asked.append(text) + return len(text) if text.startswith("SampleToNES") else None + + +class TestReadingTheSameTextTwice: + def test_a_text_asked_about_again_is_read_once(self) -> None: + parser = FakeParser() + cache: ParsedBlockCache[int] = ParsedBlockCache(parser.parse) + + first = cache.block(BLOCK) + second = cache.block(BLOCK) + + assert first == second == len(BLOCK) + assert parser.asked == [BLOCK] + + def test_a_text_reading_as_no_block_is_held_the_same_way(self) -> None: + """A menu opening over unrelated text costs one comparison, as one over a block does.""" + parser = FakeParser() + cache: ParsedBlockCache[int] = ParsedBlockCache(parser.parse) + + assert cache.block("a message") is None + assert cache.block("a message") is None + assert parser.asked == ["a message"] + + +class TestReadingAnotherText: + def test_text_replaced_on_the_clipboard_is_read_afresh(self) -> None: + parser = FakeParser() + cache: ParsedBlockCache[int] = ParsedBlockCache(parser.parse) + + cache.block(BLOCK) + second = cache.block(OTHER) + + assert second == len(OTHER) + assert parser.asked == [BLOCK, OTHER] + + def test_returning_to_an_earlier_text_reads_it_again(self) -> None: + parser = FakeParser() + cache: ParsedBlockCache[int] = ParsedBlockCache(parser.parse) + + cache.block(BLOCK) + cache.block(OTHER) + + assert cache.block(BLOCK) == len(BLOCK) + assert parser.asked == [BLOCK, OTHER, BLOCK] diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_header.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_header.py new file mode 100644 index 000000000..cd3eec82b --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_header.py @@ -0,0 +1,79 @@ +from dataclasses import dataclass +from typing import List, Optional + +import pytest + +from sampletones_application.logic.sequencer.clipboard.header import ( + BLOCK_MAGIC, + BlockShape, + parse_header, + state_header, +) + +GRID = "tracker" +SPAN_KEY = "slots" + + +def _parse(line: str) -> Optional[BlockShape]: + return parse_header(line, grid=GRID, span_key=SPAN_KEY) + + +class TestStating: + def test_a_header_names_the_grid_and_the_shape(self) -> None: + shape = BlockShape(rows=4, first=3, last=11) + + line = state_header(grid=GRID, span_key=SPAN_KEY, shape=shape) + + assert line == f"{BLOCK_MAGIC} tracker rows=4 slots=3..11" + + def test_a_span_of_one_slot_names_the_same_bound_twice(self) -> None: + shape = BlockShape(rows=1, first=7, last=7) + + line = state_header(grid=GRID, span_key=SPAN_KEY, shape=shape) + + assert line == f"{BLOCK_MAGIC} tracker rows=1 slots=7..7" + + +class TestParsing: + def test_a_stated_header_reads_back_as_the_shape_it_named(self) -> None: + shape = BlockShape(rows=4, first=3, last=11) + + assert _parse(state_header(grid=GRID, span_key=SPAN_KEY, shape=shape)) == shape + + def test_the_width_counts_both_bounds(self) -> None: + assert BlockShape(rows=1, first=3, last=11).width == 9 + + def test_surrounding_spaces_leave_the_shape_as_it_stands(self) -> None: + assert _parse(f" {BLOCK_MAGIC} tracker rows=2 slots=0..2 ") == BlockShape(rows=2, first=0, last=2) + + +@dataclass(frozen=True) +class RefusalCase: + name: str + line: str + + +REFUSALS: List[RefusalCase] = [ + RefusalCase("another application", "Tracker/1 tracker rows=2 slots=0..2"), + RefusalCase("another grid", f"{BLOCK_MAGIC} order rows=2 slots=0..2"), + RefusalCase("another span", f"{BLOCK_MAGIC} tracker rows=2 positions=0..2"), + RefusalCase("a missing span", f"{BLOCK_MAGIC} tracker rows=2"), + RefusalCase("a trailing word", f"{BLOCK_MAGIC} tracker rows=2 slots=0..2 more"), + RefusalCase("no rows at all", f"{BLOCK_MAGIC} tracker rows=0 slots=0..2"), + RefusalCase("a fractional count", f"{BLOCK_MAGIC} tracker rows=2.5 slots=0..2"), + RefusalCase("a negative count", f"{BLOCK_MAGIC} tracker rows=-2 slots=0..2"), + RefusalCase("bounds out of order", f"{BLOCK_MAGIC} tracker rows=2 slots=11..3"), + RefusalCase("one bound", f"{BLOCK_MAGIC} tracker rows=2 slots=3"), + RefusalCase("a wordy bound", f"{BLOCK_MAGIC} tracker rows=2 slots=three..11"), + RefusalCase("a label with no value", f"{BLOCK_MAGIC} tracker rows slots=3..11"), + RefusalCase("a line of prose", "have a look at this pattern"), + RefusalCase("nothing at all", ""), +] + + +class TestRefusals: + """A header states this grid's form, and anything else states no shape at all.""" + + @pytest.mark.parametrize("case", REFUSALS, ids=lambda case: case.name) + def test_a_header_this_grid_never_wrote_states_no_shape(self, case: RefusalCase) -> None: + assert _parse(case.line) is None diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_order.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_order.py new file mode 100644 index 000000000..be444d6b6 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_order.py @@ -0,0 +1,163 @@ +from dataclasses import dataclass +from typing import List + +import pytest + +from sampletones_application.logic.sequencer.clipboard.order import OrderBlockText +from sampletones_application.logic.sequencer.order.block import OrderBlock +from sampletones_application.view_model.sequencer.region import OrderRegion + + +@pytest.fixture +def text() -> OrderBlockText: + return OrderBlockText() + + +def _region( + *, + rows: int = 1, + first_position: int = 0, + positions: int = 1, + first_row: int = 0, +) -> OrderRegion: + return OrderRegion( + first_row=first_row, + last_row=first_row + rows - 1, + first_position=first_position, + last_position=first_position + positions - 1, + ) + + +def _body(text: OrderBlockText, block: OrderBlock, region: OrderRegion) -> List[str]: + return text.state(block, region).splitlines()[1:] + + +class TestTheFormAFieldTakes: + """Every field carries what the table shows in its cell.""" + + def test_a_pattern_prints_the_index_the_table_shows(self, text: OrderBlockText) -> None: + block = OrderBlock(entries={(0, 0): 1, (0, 1): 26}) + + assert _body(text, block, _region(positions=2)) == ["01 1A"] + + def test_a_silent_slot_prints_the_dots_beneath_it(self, text: OrderBlockText) -> None: + assert _body(text, OrderBlock(entries={(0, 0): None}), _region()) == [".."] + + def test_a_mixed_cell_fills_its_field_with_marks(self, text: OrderBlockText) -> None: + assert _body(text, OrderBlock(entries={}), _region()) == ["??"] + + def test_a_row_of_the_block_prints_a_line_of_its_own(self, text: OrderBlockText) -> None: + block = OrderBlock(entries={(0, 0): 0, (1, 0): 1, (2, 0): None}) + + assert _body(text, block, _region(rows=3)) == ["00", "01", ".."] + + +class TestTheShapeAStatementCovers: + def test_a_header_opens_the_text_with_the_grid_and_the_positions(self, text: OrderBlockText) -> None: + region = _region(rows=3, first_position=5, positions=4) + + header = text.state(OrderBlock(entries={}), region).splitlines()[0] + + assert header == "SampleToNES/1 order rows=3 positions=5..8" + + +@dataclass(frozen=True) +class RoundTripCase: + name: str + block: OrderBlock + region: OrderRegion + + +ROUND_TRIPS: List[RoundTripCase] = [ + RoundTripCase( + "the three states across one row", + OrderBlock(entries={(0, 0): 3, (0, 1): None}), + _region(positions=3), + ), + RoundTripCase( + "a block starting past the first frame", + OrderBlock(entries={(0, 0): 1, (0, 1): 2}), + _region(first_position=7, positions=2), + ), + RoundTripCase( + "every channel row", + OrderBlock(entries={(0, 0): 1, (1, 0): 1, (2, 0): 2, (3, 0): None, (4, 0): 0}), + _region(rows=5, positions=1), + ), + RoundTripCase( + "the master row over channels that disagree", + OrderBlock(entries={(1, 0): 1, (1, 1): 2, (2, 0): 1}), + _region(rows=3, positions=2), + ), + RoundTripCase( + "an index past a single digit", + OrderBlock(entries={(0, 0): 255}), + _region(), + ), +] + + +class TestRoundTrip: + """A block stated as text and read back is the block it set out as.""" + + @pytest.mark.parametrize("case", ROUND_TRIPS, ids=lambda case: case.name) + def test_a_block_survives_being_stated_and_read( + self, + text: OrderBlockText, + case: RoundTripCase, + ) -> None: + assert text.parse(text.state(case.block, case.region)) == case.block + + def test_a_master_row_the_channels_disagree_over_states_nothing(self, text: OrderBlockText) -> None: + """Its marks reach the reading as an absent key, so a paste passes that cell by.""" + stated = text.state(OrderBlock(entries={(0, 1): 4}), _region(positions=2)) + + assert text.parse(stated) == OrderBlock(entries={(0, 1): 4}) + + +class TestTextTypedByHand: + def test_hexadecimal_reads_in_either_case(self, text: OrderBlockText) -> None: + upper = text.parse("SampleToNES/1 order rows=1 positions=0..1\n0a 1f") + lower = text.parse("SampleToNES/1 order rows=1 positions=0..1\n0A 1F") + + assert upper == lower + assert upper == OrderBlock(entries={(0, 0): 10, (0, 1): 31}) + + def test_a_trailing_line_break_leaves_the_block_as_it_stands(self, text: OrderBlockText) -> None: + assert text.parse("SampleToNES/1 order rows=1 positions=0..0\n01\n") is not None + + +@dataclass(frozen=True) +class RefusalCase: + name: str + text: str + + +HEADER = "SampleToNES/1 order rows=2 positions=0..1" + +REFUSALS: List[RefusalCase] = [ + RefusalCase("nothing at all", ""), + RefusalCase("unrelated text", "the order goes\nintro then verse"), + RefusalCase("a header alone", HEADER), + RefusalCase("a truncated body", f"{HEADER}\n01 02"), + RefusalCase("a body reaching past the header", f"{HEADER}\n01 02\n01 02\n01 02"), + RefusalCase("a line short of a field", f"{HEADER}\n01\n01 02"), + RefusalCase("a line with a field too many", f"{HEADER}\n01 02 03\n01 02"), + RefusalCase("a tracker's block", "SampleToNES/1 tracker rows=1 slots=3..5\n01 +00 F"), + RefusalCase("more rows than the table has", "SampleToNES/1 order rows=6 positions=0..0\n01\n01\n01\n01\n01\n01"), + RefusalCase("a word in a field", f"{HEADER}\nxx 02\n01 02"), + RefusalCase("a signed index", f"{HEADER}\n+1 02\n01 02"), + RefusalCase("dots and marks in one field", f"{HEADER}\n.? 02\n01 02"), +] + + +class TestRefusals: + """Text this table never wrote states no block, so the slot the order copied into stands.""" + + @pytest.mark.parametrize("case", REFUSALS, ids=lambda case: case.name) + def test_text_outside_the_form_states_no_block( + self, + text: OrderBlockText, + case: RefusalCase, + ) -> None: + assert text.parse(case.text) is None diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py new file mode 100644 index 000000000..6e374b736 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_samples.py @@ -0,0 +1,74 @@ +import pytest + +from sampletones_application.logic.project.controller import ProjectController +from sampletones_application.logic.project.manager import ProjectManager +from sampletones_application.logic.sequencer.clipboard.samples import ( + ProjectSampleDirectory, +) +from sampletones_core.constants.enums import GeneratorName +from tests.suite.sequencer import sample_reconstruction + + +@pytest.fixture +def controller() -> ProjectController: + controller = ProjectController(ProjectManager()) + controller.new() + return controller + + +@pytest.fixture +def directory(controller: ProjectController) -> ProjectSampleDirectory: + return ProjectSampleDirectory(controller) + + +def _add_sample(controller: ProjectController, name: str) -> str: + sample = controller.add_sample( + sample_reconstruction([GeneratorName.PULSE1]), + name=name, + ) + return sample.id + + +class TestReadingBothWays: + def test_a_sample_stands_at_the_position_it_is_listed_at( + self, + controller: ProjectController, + directory: ProjectSampleDirectory, + ) -> None: + first = _add_sample(controller, "kick") + second = _add_sample(controller, "snare") + + assert directory.position_of(first) == 0 + assert directory.position_of(second) == 1 + assert directory.sample_at(0) == first + assert directory.sample_at(1) == second + + def test_a_sample_the_project_lacks_stands_nowhere( + self, + directory: ProjectSampleDirectory, + ) -> None: + assert directory.position_of("absent") is None + + def test_a_position_the_list_falls_short_of_names_no_sample( + self, + controller: ProjectController, + directory: ProjectSampleDirectory, + ) -> None: + _add_sample(controller, "kick") + + assert directory.sample_at(1) is None + assert directory.sample_at(-1) is None + + +class TestFollowingTheProject: + def test_a_sample_added_later_is_reached( + self, + controller: ProjectController, + directory: ProjectSampleDirectory, + ) -> None: + """The project is read on each lookup, so an undo putting another one in place is followed.""" + assert directory.sample_at(0) is None + + added = _add_sample(controller, "hat") + + assert directory.sample_at(0) == added diff --git a/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py new file mode 100644 index 000000000..2a20eb0dc --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/clipboard/test_tracker.py @@ -0,0 +1,289 @@ +from dataclasses import dataclass +from typing import List, Optional + +import pytest + +from sampletones_application.logic.sequencer.clipboard.tracker import TrackerBlockText +from sampletones_application.logic.sequencer.tracker.block import TrackerBlock +from sampletones_application.view_model.sequencer.region import TrackerRegion +from sampletones_application.view_model.sequencer.slot import TrackerSlot +from sampletones_application.view_model.sequencer.subcolumn import SubColumn +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.project.instruments.note_off import NoteOff + +SAMPLE_IDS: List[str] = ["kick", "snare", "hat"] + + +class FakeSampleDirectory: + """A list of samples, standing where the project's own list would.""" + + def __init__(self, sample_ids: List[str]) -> None: + self._sample_ids = sample_ids + + def position_of(self, sample_id: str) -> Optional[int]: + if sample_id not in self._sample_ids: + return None + + return self._sample_ids.index(sample_id) + + def sample_at(self, position: int) -> Optional[str]: + if 0 <= position < len(self._sample_ids): + return self._sample_ids[position] + + return None + + +@pytest.fixture +def text() -> TrackerBlockText: + return TrackerBlockText(samples=FakeSampleDirectory(SAMPLE_IDS)) + + +def _slot(generator: Optional[GeneratorName], subcolumn: SubColumn) -> int: + return TrackerSlot(generator, subcolumn).flat_index + + +def _region( + *, + first_slot: int, + last_slot: int, + rows: int = 1, +) -> TrackerRegion: + return TrackerRegion( + first_row=0, + last_row=rows - 1, + first_slot=first_slot, + last_slot=last_slot, + ) + + +PULSE1_CELL = _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), +) + + +def _body(text: TrackerBlockText, block: TrackerBlock, region: TrackerRegion) -> List[str]: + return text.state(block, region).splitlines()[1:] + + +class TestTheFormAFieldTakes: + """Every field carries what the grid shows in its cell, each kind in its own width.""" + + def test_a_cell_of_values_prints_the_three_the_grid_prints(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={(0, 0): "snare"}, transposes={(0, 1): 0}, volumes={(0, 2): 15}) + + assert _body(text, block, PULSE1_CELL) == ["01 +00 F"] + + def test_an_empty_cell_prints_the_dots_beneath_it(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={(0, 0): None}, transposes={(0, 1): None}, volumes={(0, 2): None}) + + assert _body(text, block, PULSE1_CELL) == [".. ... ."] + + def test_a_mixed_cell_fills_its_fields_with_marks(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={}, transposes={}, volumes={}) + + assert _body(text, block, PULSE1_CELL) == ["?? ??? ?"] + + def test_a_cut_prints_the_mark_the_note_column_shows(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={(0, 0): NoteOff()}, transposes={}, volumes={}) + + assert _body(text, block, PULSE1_CELL) == ["~~ ??? ?"] + + def test_a_transpose_below_zero_prints_its_sign(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={}, transposes={(0, 1): -10}, volumes={}) + + assert _body(text, block, PULSE1_CELL) == ["?? -0A ?"] + + def test_a_note_naming_a_sample_the_list_lacks_prints_as_mixed(self, text: TrackerBlockText) -> None: + """A paste has nothing to place for it, so the text states nothing about that cell.""" + block = TrackerBlock(notes={(0, 0): "cowbell"}, transposes={}, volumes={}) + + assert _body(text, block, PULSE1_CELL) == ["?? ??? ?"] + + +class TestTheShapeAStatementCovers: + def test_a_header_opens_the_text_with_the_grid_and_the_slots(self, text: TrackerBlockText) -> None: + region = _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE2, SubColumn.VOLUME), + rows=4, + ) + + header = text.state(TrackerBlock(notes={}, transposes={}, volumes={}), region).splitlines()[0] + + assert header == "SampleToNES/1 tracker rows=4 slots=3..8" + + def test_a_bar_stands_between_the_columns_a_row_crosses(self, text: TrackerBlockText) -> None: + region = _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE2, SubColumn.VOLUME), + ) + + assert _body(text, TrackerBlock(notes={}, transposes={}, volumes={}), region) == ["?? ??? ? | ?? ??? ?"] + + def test_a_row_of_the_block_prints_a_line_of_its_own(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={}, transposes={(0, 1): 1, (2, 1): 3}, volumes={}) + region = _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + rows=3, + ) + + assert _body(text, block, region) == ["?? +01 ?", "?? ??? ?", "?? +03 ?"] + + +@dataclass(frozen=True) +class RoundTripCase: + name: str + block: TrackerBlock + region: TrackerRegion + + +ROUND_TRIPS: List[RoundTripCase] = [ + RoundTripCase( + "the three states across one cell", + TrackerBlock(notes={(0, 0): "kick"}, transposes={(0, 1): None}, volumes={}), + PULSE1_CELL, + ), + RoundTripCase( + "a cut and an empty note", + TrackerBlock(notes={(0, 0): NoteOff(), (1, 0): None}, transposes={}, volumes={}), + _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + rows=2, + ), + ), + RoundTripCase( + "the whole transpose range", + TrackerBlock(notes={}, transposes={(0, 1): -24, (1, 1): 36, (2, 1): 0}, volumes={}), + _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + rows=3, + ), + ), + RoundTripCase( + "the whole volume range", + TrackerBlock(notes={}, transposes={}, volumes={(0, 2): 0, (1, 2): 15}), + _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + rows=2, + ), + ), + RoundTripCase( + "a block anchored at the sample column", + TrackerBlock(notes={(0, 0): "hat"}, transposes={(0, 4): 2}, volumes={(0, 5): 9}), + _region( + first_slot=_slot(None, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.PULSE1, SubColumn.VOLUME), + ), + ), + RoundTripCase( + "a block starting and ending mid-cell", + TrackerBlock(notes={(0, 3): "snare"}, transposes={(0, 1): 5, (0, 4): None}, volumes={(0, 2): 3}), + _region( + first_slot=_slot(GeneratorName.PULSE1, SubColumn.TRANSPOSE), + last_slot=_slot(GeneratorName.PULSE2, SubColumn.TRANSPOSE), + ), + ), + RoundTripCase( + "the whole grid", + TrackerBlock(notes={(0, 12): "kick"}, transposes={(1, 1): -1}, volumes={(1, 14): 4}), + _region( + first_slot=_slot(None, SubColumn.INSTRUMENT), + last_slot=_slot(GeneratorName.NOISE, SubColumn.VOLUME), + rows=2, + ), + ), +] + + +class TestRoundTrip: + """A block stated as text and read back is the block it set out as.""" + + @pytest.mark.parametrize("case", ROUND_TRIPS, ids=lambda case: case.name) + def test_a_block_survives_being_stated_and_read( + self, + text: TrackerBlockText, + case: RoundTripCase, + ) -> None: + assert text.parse(text.state(case.block, case.region)) == case.block + + def test_a_note_reaches_the_sample_standing_at_its_position(self, text: TrackerBlockText) -> None: + """The position is what crosses, so a block lands on the list the reading project holds.""" + block = TrackerBlock(notes={(0, 0): "snare"}, transposes={}, volumes={}) + stated = text.state(block, PULSE1_CELL) + + elsewhere = TrackerBlockText(samples=FakeSampleDirectory(["bass", "clap"])) + + assert elsewhere.parse(stated) == TrackerBlock(notes={(0, 0): "clap"}, transposes={}, volumes={}) + + def test_a_position_the_reading_list_falls_short_of_states_nothing(self, text: TrackerBlockText) -> None: + block = TrackerBlock(notes={(0, 0): "hat"}, transposes={}, volumes={}) + stated = text.state(block, PULSE1_CELL) + + elsewhere = TrackerBlockText(samples=FakeSampleDirectory(["bass"])) + + assert elsewhere.parse(stated) == TrackerBlock(notes={}, transposes={}, volumes={}) + + +class TestTextTypedByHand: + """The form is readable, so a reader typing it reaches the same block a copy would.""" + + def test_hexadecimal_reads_in_either_case(self, text: TrackerBlockText) -> None: + upper = text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n02 -0a f") + lower = text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n02 -0A F") + + assert upper == lower + assert upper == TrackerBlock(notes={(0, 0): "hat"}, transposes={(0, 1): -10}, volumes={(0, 2): 15}) + + def test_the_bars_between_columns_are_a_reading_aid(self, text: TrackerBlockText) -> None: + with_bars = text.parse("SampleToNES/1 tracker rows=1 slots=3..8\n01 +00 F | .. ... .") + without = text.parse("SampleToNES/1 tracker rows=1 slots=3..8\n01 +00 F .. ... .") + + assert with_bars is not None + assert with_bars == without + + def test_a_trailing_line_break_leaves_the_block_as_it_stands(self, text: TrackerBlockText) -> None: + assert text.parse("SampleToNES/1 tracker rows=1 slots=3..5\n01 +00 F\n") is not None + + +@dataclass(frozen=True) +class RefusalCase: + name: str + text: str + + +HEADER = "SampleToNES/1 tracker rows=2 slots=3..5" + +REFUSALS: List[RefusalCase] = [ + RefusalCase("nothing at all", ""), + RefusalCase("unrelated text", "check out this riff\nit goes hard"), + RefusalCase("a header alone", HEADER), + RefusalCase("a truncated body", f"{HEADER}\n01 +00 F"), + RefusalCase("a body reaching past the header", f"{HEADER}\n01 +00 F\n01 +00 F\n01 +00 F"), + RefusalCase("a line short of a field", f"{HEADER}\n01 +00\n01 +00 F"), + RefusalCase("a line with a field too many", f"{HEADER}\n01 +00 F 2\n01 +00 F"), + RefusalCase("an order's block", "SampleToNES/1 order rows=1 positions=0..1\n01 02"), + RefusalCase("a slot past the grid", "SampleToNES/1 tracker rows=1 slots=13..15\n01 +00 F"), + RefusalCase("a word in a note field", f"{HEADER}\nxx +00 F\n01 +00 F"), + RefusalCase("an unsigned transpose", f"{HEADER}\n01 12 F\n01 +00 F"), + RefusalCase("a transpose past the range", f"{HEADER}\n01 +40 F\n01 +00 F"), + RefusalCase("a transpose below the range", f"{HEADER}\n01 -40 F\n01 +00 F"), + RefusalCase("a volume past the range", f"{HEADER}\n01 +00 FF\n01 +00 F"), + RefusalCase("dots and marks in one field", f"{HEADER}\n.? +00 F\n01 +00 F"), +] + + +class TestRefusals: + """Text this grid never wrote states no block, so the slot the tracker copied into stands.""" + + @pytest.mark.parametrize("case", REFUSALS, ids=lambda case: case.name) + def test_text_outside_the_form_states_no_block( + self, + text: TrackerBlockText, + case: RefusalCase, + ) -> None: + assert text.parse(case.text) is None From 79b4465076cec975374e93dbd6fcf9b9cae46d63 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 15:55:45 +0200 Subject: [PATCH 092/152] Documented: selection shapes, drag travel and the clipboard form --- docs/development/sequencer-blocks.md | 110 +++++++++++++++++++++++++-- docs/guide/interface.md | 4 +- docs/guide/sequencer.md | 32 ++++++-- 3 files changed, 131 insertions(+), 15 deletions(-) diff --git a/docs/development/sequencer-blocks.md b/docs/development/sequencer-blocks.md index 78479db75..142b26523 100644 --- a/docs/development/sequencer-blocks.md +++ b/docs/development/sequencer-blocks.md @@ -4,8 +4,9 @@ A **block** is a rectangle of one sequencer grid, lifted out of the song so it c written back somewhere else. Copy, cut, paste and delete are the four gestures over it, and both grids — the tracker's pattern rows and the order's frames — carry the same set. -This document states the rules those gestures follow, and how a grid's actions reach the -menus and the keyboard that fire them. The layering they sit in is +This document states the rules those gestures follow, how a block leaves the app as text, +how a selection is drawn, and how a grid's actions reach the menus and the keyboard that +fire them. The layering they sit in is [Architecture](architecture.md); the conventions the code is held to are the [coding guidelines](guidelines.md). @@ -101,6 +102,58 @@ Each cell reaches the grid through the single-cell adjustment that already gover pasted cell does, so a shift lands exactly the writes the same nudge repeated by hand would make — the transpose and volume ranges included. +## A block states itself as text + +A copy also writes the block to the desktop's clipboard, as the lines the grid prints — a +tracker block: + +``` +SampleToNES/1 tracker rows=2 slots=3..5 +00 +05 3 +.. -02 . +``` + +and an order block: + +``` +SampleToNES/1 order rows=1 positions=0..1 +00 03 +``` + +The form and its reading live in `logic/sequencer/clipboard/`, which deals in blocks and +strings alone; the desktop's clipboard is reached through +`utils/gui/clipboard.py::TextClipboard`, one more piece of external behaviour standing behind +a protocol ([Architecture](architecture.md), principle 11). The sequencer coordinator wires +the two. + +**A field prints what the grid prints in its cell**, which is what carries the three states +across: a value reads as its value, an empty cell as the dots beneath it, and a mixed one as +the marks filling its field. The marks fill the whole width, so every line measures the same +and a block pasted into a message still reads as a grid; reading takes any run of them. + +**The header is a declaration the body is held to.** It names the grid, the count of rows, and +the span of slots or positions the block stands on, and a body whose lines or fields disagree +with it states no block. The span also carries the alignment a tracker block needs, since the +first slot decides which subcolumn the block opens on. + +**A note names its sample by list position**, the figure the grid prints, so a block carried to +another project plays whichever sample stands at that position there. A position the project's +list falls short of reads as mixed, which is what the writer already makes of a sample it has +nothing to place. + +A field the form has no reading for refuses the whole text, so a parse answers with a block or +with nothing. Digits are read in either case, and transpose and volume are held to the ranges a +row accepts, so text typed by hand lands the values the grid would. + +### Which block a paste writes + +A copy writes both clipboards, and a paste reads the desktop's text first: it stands while it +parses as a block for *that* grid, and any other text leaves the grid's own block in hand. So a +block copied in a second instance pastes here, and a copy taken in this one survives whatever +else the desktop picks up afterwards. `can_paste_block` asks the same question through a +`ParsedBlockCache`, which reparses only when the text has changed, so opening a menu costs one +string compare. + ## A grid declares its actions once Where they are shown is decided by whoever asks for them. Each grid builds its whole @@ -146,6 +199,23 @@ covers is its coalescing target, so a streak over one selection leaves a single shift after the cursor moves or the selection is reached out starts the next entry. Transpose and volume count separately, each carrying its own action. +## A shape selects to the grid's own edges + +`Ctrl+A` and its neighbours select a whole shape at once. Each shape is stated on the input +state as a run of bounds along one axis — slots in the tracker, rows in the order — handed to a +single builder that spans the other axis to the grid's full extent and lands the cursor on the +far corner. The whole frame, a column and a subcolumn are therefore three namings of one +rectangle, as the whole order and a channel row are of the other, and a grid laying out nothing +keeps the selection it had. + +The aggregate is an ordinary member of the axis here: selecting the **Sample** column selects a +column the way selecting a channel does, and the **Master** row a row. + +A press names its shape from the cell the cursor stands on, which is the cell the context menu's +items name too, so a key and an item reach the same rectangle. In the tracker a shape ends at +the frame's last row, so standing one carries the grid to where the cursor landed — the same +reveal a `Shift+End` reach makes. + ## Dragging a range out Both grids compose one `TableSelection` (`ui/elements/table/selection.py`), which holds what @@ -167,13 +237,41 @@ position lookup is arithmetic in the same way, taking its pitch from the first t columns; its channel lookup walks the rows, because the master row stands apart from the channels beneath it. +### A drag past the edge carries the view + +A pointer held past the cells on screen travels the grid under it, so a selection reaches +further than the viewport holds. `grid/scroll/` states this in three pieces: a `ScrollAxis` +naming the one DearPyGui axis a table scrolls along and the pointer coordinate that runs past +its edges, a `TravelBand` saying where the cells stand along that axis, and the `DragTravel` +that reads the two each frame. The tracker travels vertically and the order horizontally, both +from the same class. + +Three rules make the travel feel like one gesture: + +- **The pointer report drives it.** A held pointer keeps reporting wherever it is carried to, + including past the window, so the travel runs off the same report the drag itself reads. +- **The frame's own duration paces it**, so the same stretch of grid passes under the pointer + however fast the frames arrive. The pace answers how far past the edge the pointer stands, + rising from a floor to a ceiling over a few cells' overshoot: a nudge creeps, a reach covers + the grid. +- **Each step is added to the offset last issued.** A table reports the scroll it was drawn + with rather than the one just set, so a travel reading it back would re-issue an offset it + has already reached. It rests as soon as the pointer stands within the band again, at the + press that opens the next gesture, and on a rebuild — and the travel that follows sets out + from the offset the grid is drawn with. + ## Accepted limitations - **A rebuilt table has no selection.** Both grids reconstruct their input state on rebuild, so following playback and the rebuild after a growing paste leave the cursor and drop the selection. The rows a region named belong to the body that was replaced. - **The selection stays put after a paste** rather than becoming the pasted footprint. -- **Cross-project paste is lossy in the note column and exact in transpose and volume.** - A slot survives a project close, because it must survive `on_project_replaced`, which - fires on every undo; a note naming a sample the project in place lacks is left out of - the write, and the target keeps what it had. +- **A note crosses a project by whichever route it took.** The in-app slot survives a project + close, because it must survive `on_project_replaced`, which fires on every undo, and it names + its sample by id: a note whose sample the project in place lacks is left out of the write, and + the target keeps what it had. The clipboard's text names a list position instead, so the same + note pasted through it plays whichever sample stands at that position. Transpose and volume + are exact by either route. +- **A drag past the edge and the followed playhead both write the scroll.** With **Follow rows** + on during playback, `_reveal_playing_row` carries the sounding row to the head of the band + while a held pointer travels the grid, so the two take turns each frame. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 12bc78057..efdef079e 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -79,7 +79,9 @@ Each menu covers one kind of work: **File** for projects, **Edit** for undo, red and what you can do where your cursor stands, **Reconstruction** for the current reconstruction and its exports, **Playback** for playing and for muting the sequencer's channels, **View** for settings and the window, and **Help** for -**About**. +**About**. What **Edit** offers below undo and redo follows your cursor: the block +actions of the sequencer grid you are in, or the actions of the sample you have +picked in the **Samples** list. Two items write audio you can play anywhere: **Reconstruction ▸ Export to WAV...** for the reconstruction on show, and **File ▸ Render song...** (`Ctrl+Shift+E`) for diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index de22cb8db..d0e24d901 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -18,8 +18,9 @@ the project already has samples, _SampleToNES_ warns with **Different NES frequency**; **Add anyway** adds it regardless. Manage the imported samples in the **Samples** list on the right: right-click one -to **Rename**, **Duplicate**, **Remove**, or reorder it, and toggle its **Loop** -flag. Removing a sample that patterns still use asks **Remove sample** first, +to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder it, and toggle its +**Loop** flag. The **Edit** menu carries the same actions for the sample you have +picked. Removing a sample that patterns still use asks **Remove sample** first, because it clears every row that references it. ## Writing a pattern @@ -46,22 +47,30 @@ own so you can change it on its own, and **Insert frame**, **Clear frame**, Both grids take a **selection** — a rectangle of cells you copy, cut, paste, and delete in one go. Hold `Shift` and press the arrow keys to reach out from the cursor, or drag the pointer across the cells; `Shift`+click carries the selection to -the cell you click. Any plain move, and `Escape`, puts it away again. +the cell you click. Dragging past the edge of a grid scrolls it along, so a selection +can run further than the screen shows. Any plain move, and `Escape`, puts the +selection away again. | Key | Action | |-----|--------| | `Shift`+arrows | Reach the selection out a cell at a time | | `Shift+Home` / `Shift+End` | Reach it to the first or the last row (tracker) or position (order) | +| `Ctrl+A` | Select the whole frame, or the whole order | +| `Ctrl+Shift+A` | Select the column you are in (tracker), or your channel's row (order) | +| `Ctrl+Alt+A` | Select the subcolumn you are in (tracker) | | `Ctrl+C` | Copy | | `Ctrl+X` | Cut — copy, then empty what was selected | | `Ctrl+V` | Paste, starting at the cursor | | `Del` | Empty the selection | -With nothing selected these act on the cell the cursor stands on, so copying one -cell needs no selection first. The same four sit on each grid's right-click menu: -raised inside a selection they act on the whole of it, raised anywhere else on the -cell you clicked. Each grid keeps its own copy, so a tracker block pastes into the -tracker and an order block into the order. +Copy, cut, paste and delete act on the cell the cursor stands on when nothing is +selected, so copying one cell needs no selection first. All four sit on each grid's +right-click menu: raised inside a selection they act on the whole of it, raised +anywhere else on the cell you clicked. Each grid keeps its own copy, so a tracker +block pastes into the tracker and an order block into the order. + +The **Select** keys work from the cell you are on and reach the whole length of the +grid. They sit on the right-click menu too. A paste is anchored: the block starts at the cell you paste onto and lands the rest down and to the right of it. @@ -80,6 +89,13 @@ as it was. Emptying cells keeps the rows and frames they sit in, and every block action is one step in the history, so a single **Undo** takes it all back. +A copy also goes to your desktop's clipboard as plain text, so a block carries between +two open windows of _SampleToNES_ — copy in one, paste in the other — and you can paste +one into a message to show someone what you wrote. Anything else on the clipboard +leaves you with the last block you copied here. Notes travel by their number in the +**Samples** list, so a block pasted into another project plays whichever sample holds +that number there. + ## Transposing and shading In the **Tracker**, transpose and volume move whatever the selection covers, so a From caced3765b7c2a43d6b62ee76f753d1c1b4fa5bb Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 16:24:48 +0200 Subject: [PATCH 093/152] Changed: cell menus title --- .../ui/panels/sequencer/display.py | 24 +++++++- .../ui/panels/sequencer/order.py | 60 +++++++++++++++---- .../ui/panels/sequencer/tracker.py | 2 +- 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/src/sampletones_application/ui/panels/sequencer/display.py b/src/sampletones_application/ui/panels/sequencer/display.py index 03d10e471..4b99693ac 100644 --- a/src/sampletones_application/ui/panels/sequencer/display.py +++ b/src/sampletones_application/ui/panels/sequencer/display.py @@ -10,6 +10,8 @@ CellKey = Tuple[int, Optional[GeneratorName], SubColumn] CellValues = Dict[CellKey, str] +CELL_TITLE_SEPARATOR: Final[str] = " | " + _DEFAULT_LABELS: Final[Dict[SubColumn, str]] = { SubColumn.INSTRUMENT: display_id(None), SubColumn.TRANSPOSE: display_transpose(None), @@ -18,10 +20,19 @@ def indexed_label(index: int, label: str) -> str: - """Joins a formatted index and a label into one display string, e.g. ``"03 Pulse 1"``.""" + """Joins a formatted index and a label into one display string, e.g. ``"03 Bass"``.""" return f"{display_id(index)} {label}" +def cell_title(index: int, label: str) -> str: + """Names the cell a menu was raised on, e.g. ``"0C | Pulse 1"``. + + Both grids title their cell menus this way: where along the grid the cell sits, then the + channel it belongs to, so a menu states its target the same wherever it is opened. + """ + return f"{display_id(index)}{CELL_TITLE_SEPARATOR}{label}" + + def cell_display(cell_view_model: SequencerCellViewModel, subcolumn: SubColumn) -> str: """Extract the pre-formatted display string for one subcolumn from a cell view model.""" match subcolumn: @@ -56,8 +67,15 @@ def subcolumn_label( is_active = ( cursor is not None and cursor.row == row and cursor.generator == generator and cursor.subcolumn == subcolumn ) - stored = cell_values.get((row, generator, subcolumn), _DEFAULT_LABELS[subcolumn]) + stored = cell_values.get( + (row, generator, subcolumn), + _DEFAULT_LABELS[subcolumn], + ) if is_active: - return pending_label(pending, stored, len(_DEFAULT_LABELS[subcolumn])) + return pending_label( + pending, + stored, + len(_DEFAULT_LABELS[subcolumn]), + ) return stored diff --git a/src/sampletones_application/ui/panels/sequencer/order.py b/src/sampletones_application/ui/panels/sequencer/order.py index 9dcfb3a73..0be02c15a 100644 --- a/src/sampletones_application/ui/panels/sequencer/order.py +++ b/src/sampletones_application/ui/panels/sequencer/order.py @@ -44,15 +44,20 @@ channel_tooltip, ) from sampletones_application.ui.panels.sequencer.columns import channel_color +from sampletones_application.ui.panels.sequencer.display import cell_title from sampletones_application.ui.panels.sequencer.grid.gestures import BlockGestures -from sampletones_application.ui.panels.sequencer.grid.scroll.axis import HorizontalScroll +from sampletones_application.ui.panels.sequencer.grid.scroll.axis import ( + HorizontalScroll, +) from sampletones_application.ui.panels.sequencer.grid.scroll.band import TravelBand from sampletones_application.ui.panels.sequencer.grid.scroll.travel import DragTravel from sampletones_application.ui.panels.sequencer.grid.surface.clipboard import ( BlockShortcuts, ClipboardItems, ) -from sampletones_application.ui.panels.sequencer.grid.surface.edit import GridEditSurface +from sampletones_application.ui.panels.sequencer.grid.surface.edit import ( + GridEditSurface, +) from sampletones_application.ui.panels.sequencer.input.order import ( INDEX_DIGITS, OrderCursor, @@ -72,7 +77,10 @@ KeyRouter, ) from sampletones_application.utils.gui.keyboard.keys import HEX_KEYS -from sampletones_application.utils.gui.keyboard.modifiers import Modifier, capture_modifiers +from sampletones_application.utils.gui.keyboard.modifiers import ( + Modifier, + capture_modifiers, +) from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource from sampletones_application.utils.gui.tooltip import show_tooltip @@ -1050,7 +1058,10 @@ def _show_channel_menu(self, generator: Optional[GeneratorName]) -> None: header = dpg.add_text(self._row_labels[generator]) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() - self._channel_switch.add_menu_items(generator, self._current_channels) + self._channel_switch.add_menu_items( + generator, + self._current_channels, + ) def _show_context_menu( self, @@ -1059,7 +1070,12 @@ def _show_context_menu( ) -> None: target = self._surface.target_at(OrderCursor(generator, position)) with context_menu(): - header = dpg.add_text(display_id(position)) + header = dpg.add_text( + cell_title( + position, + self._row_labels[generator], + ) + ) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() add_play_menu_item( @@ -1108,12 +1124,18 @@ def _add_select_items(self, cell: OrderCursor) -> None: dpg.add_menu_item( label=self._lbl_context_select_all, shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ALL), - callback=lambda: self._select_shape(ShortcutId.ORDER_SELECT_ALL, cell), + callback=lambda: self._select_shape( + ShortcutId.ORDER_SELECT_ALL, + cell, + ), ) dpg.add_menu_item( label=self._lbl_context_select_row, shortcut=self._shortcuts.display(ShortcutId.ORDER_SELECT_ROW), - callback=lambda: self._select_shape(ShortcutId.ORDER_SELECT_ROW, cell), + callback=lambda: self._select_shape( + ShortcutId.ORDER_SELECT_ROW, + cell, + ), ) def _add_frame_items(self, position: int) -> None: @@ -1178,12 +1200,19 @@ def _add_move_item( 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) + target = MOVE_DIRECTIONS[shortcut_id].target( + position, + self._position_count, + ) dpg.add_menu_item( label=label, shortcut=self._shortcuts.display(shortcut_id), enabled=target is not None, - callback=lambda: self.call(self.on_move_requested, position, target), + callback=lambda: self.call( + self.on_move_requested, + position, + target, + ), ) def _keys_active(self) -> bool: @@ -1294,10 +1323,19 @@ def _select_shape( return True def _select_all(self) -> None: - self._apply_state(self._committed_state().select_all(self._position_count)) + self._apply_state( + self._committed_state().select_all( + self._position_count, + ) + ) def _select_row(self, cell: OrderCursor) -> None: - self._apply_state(self._committed_state().select_row(cell, self._position_count)) + self._apply_state( + self._committed_state().select_row( + cell, + self._position_count, + ) + ) def _block_action(self, shortcut_id: ShortcutId) -> bool: """Acts on the selected block, reporting whether the action was one of its gestures. diff --git a/src/sampletones_application/ui/panels/sequencer/tracker.py b/src/sampletones_application/ui/panels/sequencer/tracker.py index b1734fe30..8427560d8 100644 --- a/src/sampletones_application/ui/panels/sequencer/tracker.py +++ b/src/sampletones_application/ui/panels/sequencer/tracker.py @@ -1323,7 +1323,7 @@ def _show_context_menu( target = self._surface.target_at(TrackerCursor(row_index, generator, subcolumn)) with context_menu(): header = dpg.add_text( - tracker_display.indexed_label(row_index, self._column_labels[generator]), + tracker_display.cell_title(row_index, self._column_labels[generator]), ) FontRegistry.bind_to_item(header, Font.MONO_BOLD) dpg.add_separator() From f65badb98b631e7e7ccade6bbc0f28c53db7f06d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 16:35:44 +0200 Subject: [PATCH 094/152] Changed: the stacked graph ceiling --- .../coordinators/tabs/instructions.py | 6 +- .../layout/general/responsive.py | 6 +- .../parameters/instructions.py | 6 +- .../ui/elements/layout/responsive.py | 9 ++- .../layout/general/responsive.yaml | 2 +- .../parameters/test_instructions.py | 2 +- .../ui/elements/layout/test_responsive.py | 65 ++++++------------- 7 files changed, 36 insertions(+), 60 deletions(-) diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index 3b42f1b6f..697422322 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -114,7 +114,7 @@ def __init__( self._side_panel_count: int self._baseline_viewport_height = layout.baseline_viewport_height self._base_graph_height = layout.base_graph_height - self._max_stack_height = layout.max_stack_height + self._max_graph_height = layout.max_graph_height self._details_width = layout.right_column_width self._right_height = layout.right_column_height self._ttl_generation_status = language_manager["instructions.library.title.generation_status_dialog"] @@ -377,13 +377,13 @@ def _sync_library_width(self) -> None: dpg_configure_item(_LEFT_COLUMN_TAG, width=width) def _sync_graph_heights(self) -> None: - """Grows the stacked graphs to share the viewport's vertical surplus equally, filling the centre column.""" + """Grows the stacked graphs to share the viewport's vertical surplus equally, up to their ceiling.""" height = stacked_graph_height( self._base_graph_height, dpg.get_viewport_client_height(), self._baseline_viewport_height, len(self._graph_panels), - self._max_stack_height, + self._max_graph_height, ) for panel in self._graph_panels: panel.set_display_height(height) diff --git a/src/sampletones_application/layout/general/responsive.py b/src/sampletones_application/layout/general/responsive.py index 138f8b83f..fc632ed34 100644 --- a/src/sampletones_application/layout/general/responsive.py +++ b/src/sampletones_application/layout/general/responsive.py @@ -8,10 +8,10 @@ class ResponsiveLayout(BaseModel, extra="forbid", frozen=True): dimensions at which the side columns sit at their configured widths and the stacked graphs at their configured heights. Surplus above either baseline is shared out — width widens the side columns (``expanded_side_width``), height grows the graph stack - (``stacked_graph_height``). ``max_stack_height`` caps the combined height that a - vertical graph stack grows to before the surplus is left free. + (``stacked_graph_height``). ``max_graph_height`` is the tallest a single stacked graph + grows to, from where the surplus is left free. """ baseline_viewport_width: int baseline_viewport_height: int - max_stack_height: int + max_graph_height: int diff --git a/src/sampletones_application/parameters/instructions.py b/src/sampletones_application/parameters/instructions.py index 6c90f2175..10c592ead 100644 --- a/src/sampletones_application/parameters/instructions.py +++ b/src/sampletones_application/parameters/instructions.py @@ -18,7 +18,7 @@ class InstructionsTabParameters: """Everything the Instructions tab coordinator needs, shaped for the coordinator. The stacked-graph geometry — the vertical baseline, the per-graph base height, and the - ceiling the stack grows to — is flattened alongside the shared column geometry because it + ceiling each graph grows to — is flattened alongside the shared column geometry because it feeds the ``stacked_graph_height`` pure-int sink; the choice panel's slice of the general layout is narrowed to a ``PitchStepperStyle`` so the whole ``GeneralLayout`` never reaches a panel. @@ -26,7 +26,7 @@ class InstructionsTabParameters: geometry: TabGeometry baseline_viewport_height: int - max_stack_height: int + max_graph_height: int base_graph_height: int right_column_width: int right_column_height: int @@ -44,7 +44,7 @@ def from_config(cls, config: LayoutConfig) -> InstructionsTabParameters: return cls( geometry=TabGeometry.from_config(config), baseline_viewport_height=general.responsive.baseline_viewport_height, - max_stack_height=general.responsive.max_stack_height, + max_graph_height=general.responsive.max_graph_height, base_graph_height=config.graphs.dimensions.height, right_column_width=config.tabs.instructions.right_column.width, right_column_height=config.tabs.instructions.right_column.height, diff --git a/src/sampletones_application/ui/elements/layout/responsive.py b/src/sampletones_application/ui/elements/layout/responsive.py index 0253a2076..8caffb6f4 100644 --- a/src/sampletones_application/ui/elements/layout/responsive.py +++ b/src/sampletones_application/ui/elements/layout/responsive.py @@ -25,17 +25,16 @@ def stacked_graph_height( viewport_height: int, baseline_viewport_height: int, graph_count: int, - max_stack_height: int, + max_graph_height: int, ) -> int: """Grows each graph of a vertical stack as the viewport grows past the lowest-resolution baseline. At ``baseline_viewport_height`` — the smallest supported window — the stacked graphs sit at ``base_height`` and together fill their column. The extra room a taller viewport offers is shared - equally across the ``graph_count`` graphs, so the stack keeps filling as the window grows, until the - graphs together reach ``max_stack_height``; from there each graph holds at its - ``max_stack_height // graph_count`` cap and the surplus stays free. + equally across the ``graph_count`` graphs, so the stack keeps filling as the window grows, until + each graph stands at ``max_graph_height`` and holds there, leaving the remaining surplus free. The + ceiling reads as one graph's height so it stays the same however many graphs the stack holds. """ surplus = viewport_height - baseline_viewport_height expansion = max(0, round(surplus / graph_count)) - max_graph_height = max_stack_height // graph_count return min(base_height + expansion, max_graph_height) diff --git a/src/sampletones_config/layout/general/responsive.yaml b/src/sampletones_config/layout/general/responsive.yaml index c4409429a..4cd9d8f43 100644 --- a/src/sampletones_config/layout/general/responsive.yaml +++ b/src/sampletones_config/layout/general/responsive.yaml @@ -1,3 +1,3 @@ baseline_viewport_width: 1280 baseline_viewport_height: 800 -max_stack_height: 1200 +max_graph_height: 350 diff --git a/tests/unit/sampletones_application/parameters/test_instructions.py b/tests/unit/sampletones_application/parameters/test_instructions.py index 47867977b..3741ce0ee 100644 --- a/tests/unit/sampletones_application/parameters/test_instructions.py +++ b/tests/unit/sampletones_application/parameters/test_instructions.py @@ -11,7 +11,7 @@ def test_forwards_models_and_flattens_geometry(self, layout_config: LayoutConfig params = InstructionsTabParameters.from_config(layout_config) assert params.baseline_viewport_height == layout_config.general.responsive.baseline_viewport_height - assert params.max_stack_height == layout_config.general.responsive.max_stack_height + assert params.max_graph_height == layout_config.general.responsive.max_graph_height assert params.base_graph_height == layout_config.graphs.dimensions.height assert params.right_column_width == layout_config.tabs.instructions.right_column.width assert params.right_column_height == layout_config.tabs.instructions.right_column.height diff --git a/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py b/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py index de1b57e00..9f1f0fb17 100644 --- a/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py +++ b/tests/unit/sampletones_application/ui/elements/layout/test_responsive.py @@ -10,32 +10,10 @@ from tests.suite.case import BaseRegularTestCase -@dataclass(frozen=True) -class StackedHeightCase: - label: str - base_height: int - viewport_height: int - baseline_viewport_height: int - graph_count: int - max_stack_height: int - expected: int - - -@dataclass(frozen=True) -class SideWidthCase: - label: str - base_width: int - viewport_width: int - baseline_viewport_width: int - side_panel_count: int - center_weight: int - expected: int - - 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.""" + shares the taller viewport's surplus equally across the graphs until each one stands at the + configured maximum, where it holds however many graphs the stack carries.""" @dataclass(frozen=True, kw_only=True) class StackedHeightCase(BaseRegularTestCase): @@ -43,7 +21,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height: int baseline_viewport_height: int graph_count: int - max_stack_height: int + max_graph_height: int expected: int test_cases = ( @@ -53,7 +31,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=800, baseline_viewport_height=800, graph_count=2, - max_stack_height=1200, + max_graph_height=600, expected=292, ), StackedHeightCase( @@ -62,7 +40,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=640, baseline_viewport_height=800, graph_count=2, - max_stack_height=1200, + max_graph_height=600, expected=292, ), StackedHeightCase( @@ -71,7 +49,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=1000, baseline_viewport_height=800, graph_count=2, - max_stack_height=1200, + max_graph_height=600, expected=392, ), StackedHeightCase( @@ -80,7 +58,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=1414, baseline_viewport_height=800, graph_count=2, - max_stack_height=1200, + max_graph_height=600, expected=599, ), StackedHeightCase( @@ -89,7 +67,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=1416, baseline_viewport_height=800, graph_count=2, - max_stack_height=1200, + max_graph_height=600, expected=600, ), StackedHeightCase( @@ -98,7 +76,7 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=2200, baseline_viewport_height=800, graph_count=2, - max_stack_height=1200, + max_graph_height=600, expected=600, ), StackedHeightCase( @@ -107,17 +85,17 @@ class StackedHeightCase(BaseRegularTestCase): viewport_height=1100, baseline_viewport_height=800, graph_count=3, - max_stack_height=1200, + max_graph_height=600, expected=392, ), StackedHeightCase( - label="three_graphs_lower_cap", + label="three_graphs_take_the_same_cap", base_height=292, - viewport_height=1124, + viewport_height=2200, baseline_viewport_height=800, graph_count=3, - max_stack_height=1200, - expected=400, + max_graph_height=600, + expected=600, ), ) @@ -129,23 +107,22 @@ def test_height_follows_the_surplus_rule(self, case: StackedHeightCase) -> None: case.viewport_height, case.baseline_viewport_height, case.graph_count, - case.max_stack_height, + case.max_graph_height, ) == case.expected ) @pytest.mark.parametrize("viewport_height", range(600, 3000, 37)) - def test_stays_within_base_and_combined_cap( + def test_stays_between_the_base_and_the_cap( self, viewport_height: int, ) -> None: - """Across the whole viewport range each graph sits at or above its base height and the graphs - together stay within the combined maximum.""" - graph_count = 2 - max_stack_height = 1200 - height = stacked_graph_height(292, viewport_height, 800, graph_count, max_stack_height) + """Across the whole viewport range a graph sits at or above its base height and at or below + the configured maximum.""" + max_graph_height = 600 + height = stacked_graph_height(292, viewport_height, 800, 2, max_graph_height) assert height >= 292 - assert height * graph_count <= max_stack_height + assert height <= max_graph_height class TestExpandedSideWidth(BaseTestSuite): From 9e5842a8c1b652d111465349b139c5c4c0a04a1a Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 17:45:29 +0200 Subject: [PATCH 095/152] Added: the FamiTracker driver memory footprint --- docs/development/guidelines.md | 1 + docs/formats/famitracker.md | 47 +++++ .../formats/famitracker/footprint.py | 130 +++++++++++++ .../famitracker/specification/memory.py | 15 ++ .../formats/famitracker/test_footprint.py | 182 ++++++++++++++++++ 5 files changed, 375 insertions(+) create mode 100644 src/sampletones_core/formats/famitracker/footprint.py create mode 100644 src/sampletones_core/formats/famitracker/specification/memory.py create mode 100644 tests/unit/sampletones_core/formats/famitracker/test_footprint.py diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index b3085b39c..75ff89bde 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -86,6 +86,7 @@ These rules govern the Python in this repository. They complement 1. A test file mirrors the ownership of the code it exercises. 1. When functionality moves between packages, move its direct unit tests in the same change. 1. Parametrize tests that share a body, using a test-case dataclass. +1. Test case classes and cases themselves should be defined inside the testing class, unless these objects are shared between test classes. Inherit from `BaseTestSuite` and `BaseTestCase`. 1. For a multi-step scenario, use a test-scenario suite class — a series of functions with assertions. 1. Prefer fixtures over factories, and define shared fixtures in an appropriate place. 1. Do not assert default values of configurations, layouts, settings, and similar. Defaults are not contracts, and pinning them overconstrains the tests. Test behavior instead: validation bounds, serialization round-trips, and invariants. The exception is when values must match by contract rather than equal a chosen constant — e.g. project metadata at creation or after a save/load round-trip should be asserted to match, never hardcoded to a version string. diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index d0ba9971e..f7472ada0 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -202,3 +202,50 @@ for order slots the song leaves unset; a channel that already fills indices up t 127 leaves no room for it, which the exporter reports rather than emitting a corrupt order. When the domain model grows to enforce these limits, the editor can prevent reaching a state the exporter would reject. + +## D. Driver memory footprint + +Compiling a module into an NSF lays each instrument out across two regions of the driver's +data, and an instrument's sequences size both of them. `footprint.py` measures the two, and +`specification/memory.py` names every field the measurement counts. The instruments panel and +the samples context menu display the result, so the cost of a sample is readable before an +export. + +The **instrument region** holds the instrument list — one pointer per instrument — followed by +each instrument's body: a sequence-enable bitmask, then one pointer per populated sequence. The +**sequence region** holds one chunk per sequence: a four-field header followed by the items. + +| Field | Bytes | Region | +| --- | --- | --- | +| instrument list entry | 2 | instrument | +| sequence-enable bitmask | 1 | instrument | +| sequence pointer, per populated sequence | 2 | instrument | +| item count · loop point · release point · setting | 1 each | sequence | +| item, per tick | 1 | sequence | + +An instrument with `n` populated sequences carrying `s₁ … sₙ` items therefore occupies +`3 + 2n` bytes of the instrument region and `Σ (4 + sᵢ)` of the sequence region. A dimension the +channel leaves unused is written as a disabled slot, and the populated sequences alone are +charged: `n` is 3 on the pulse and noise channels (volume, arpeggio, duty) and 2 on triangle. +Every populated sequence of one instrument shares a length (section B), so the sequence region +comes to `n · (4 + s)` and an instrument tops out at 777 bytes — three sequences at the 252-item +limit. + +These two figures are the ones FamiTracker itself prints while creating an NSF — +`Instruments used: N (X bytes)` and `Sequences used: M (Y bytes)` — which is how a measurement +is held against the tracker. + +**Version.** The figures are vanilla FamiTracker 0.4.6, the target section A names. The 0CC and +Dn-FamiTracker forks open each instrument body with a channel-type byte, so an instrument costs +one byte more there. + +**Pooling narrows a module's total.** The `SEQUENCES` block stores each distinct sequence once +(section A.2), so a module holding two instruments with the same volume envelope pays for that +chunk once. A per-instrument or per-sample figure states that instrument's own cost, and a +module total is therefore at most the sum of them. Within one instrument each kind appears +once, so its own sequences are charged once each. + +**Looping shortens the sequences.** A looping instrument shares its shortest dimension's length +and a one-shot its longest (section B), so one set of envelopes costs less as a loop. A sample +carries the flag that decides which applies; a reconstruction standing on its own is measured as +a one-shot, matching the instrument its **Export instrument** writes. diff --git a/src/sampletones_core/formats/famitracker/footprint.py b/src/sampletones_core/formats/famitracker/footprint.py new file mode 100644 index 000000000..ffa795ee6 --- /dev/null +++ b/src/sampletones_core/formats/famitracker/footprint.py @@ -0,0 +1,130 @@ +from dataclasses import dataclass +from typing import Dict, Iterable + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.feature import Features +from sampletones_core.formats.famitracker.model.instrument import Instrument2A03 +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.sequences.features import ( + features_to_instrument_sequences, +) +from sampletones_core.formats.famitracker.specification.memory import ( + INSTRUMENT_DEFINITION_BYTES, + SEQUENCE_HEADER_BYTES, + SEQUENCE_ITEM_BYTES, + SEQUENCE_POINTER_BYTES, +) +from sampletones_core.reconstructions import Reconstruction + + +@dataclass(frozen=True) +class InstrumentFootprint: + """The bytes an instrument occupies once FamiTracker compiles it into an NSF. + + The two fields are the two regions the driver keeps an instrument in, which FamiTracker's + own export log reports side by side: the instrument list and body under ``instrument_bytes``, + the sequence chunks the body points at under ``sequence_bytes``. See + `docs/formats/famitracker.md` for the layout each figure counts. + + Attributes: + instrument_bytes: Bytes the instrument's table entry and body occupy. + sequence_bytes: Bytes the instrument's sequences occupy. + """ + + instrument_bytes: int + sequence_bytes: int + + @property + def total_bytes(self) -> int: + """The whole footprint, the figure a size display names.""" + return self.instrument_bytes + self.sequence_bytes + + +def sequence_footprint(sequence: InstrumentSequence) -> int: + """Measures the bytes one sequence chunk occupies: its four-field header and its items.""" + return SEQUENCE_HEADER_BYTES + SEQUENCE_ITEM_BYTES * len(sequence.items) + + +def sequences_footprint( + sequences: Iterable[InstrumentSequence], +) -> InstrumentFootprint: + """Measures the instrument the given sequences make up. + + A populated sequence earns the instrument a pointer to its chunk and contributes the chunk + itself; an empty one is written as a disabled slot the driver stores nothing for, so the + populated sequences alone decide both figures. + """ + populated = [sequence for sequence in sequences if sequence.enabled] + return InstrumentFootprint( + instrument_bytes=INSTRUMENT_DEFINITION_BYTES + SEQUENCE_POINTER_BYTES * len(populated), + sequence_bytes=sum(sequence_footprint(sequence) for sequence in populated), + ) + + +def instrument_footprint(instrument: Instrument2A03) -> InstrumentFootprint: + """Measures one built instrument, the form an export writes.""" + return sequences_footprint(instrument.sequences.values()) + + +def features_footprint( + features: Features, + *, + loop: bool, +) -> InstrumentFootprint: + """Measures the instrument a generator slice's envelopes export to. + + The envelopes pass through the same builder an export uses, so the measured item counts are + the ones a file carries: brought to one shared length and capped at what a FamiTracker + sequence holds. + + Args: + features: The per-dimension envelopes describing the slice. + loop: Whether the instrument loops while its note is held, which decides the shared length. + + Returns: + InstrumentFootprint: The footprint of the instrument those envelopes describe. + """ + sequences = features_to_instrument_sequences( + volume=features.volume, + arpeggio=features.arpeggio, + pitch=features.pitch, + hi_pitch=features.hi_pitch, + duty_cycle=features.duty_cycle, + loop=loop, + ) + return sequences_footprint(sequences.values()) + + +def reconstruction_footprints( + reconstruction: Reconstruction, + *, + loop: bool, +) -> Dict[GeneratorName, InstrumentFootprint]: + """Measures one instrument per channel a reconstruction covers. + + A reconstruction exports one instrument for each of its one to four channels, so the result + holds an entry per covered channel and :func:`total_footprint` sums them into what the whole + sample costs. + + Args: + reconstruction: The reconstruction whose channels are measured. + loop: Whether the sample carrying it loops while its note is held. + + Returns: + Dict[GeneratorName, InstrumentFootprint]: The footprint of each channel's instrument. + """ + return { + generator_name: features_footprint(features, loop=loop) + for generator_name, features in reconstruction.export().items() + } + + +def total_footprint( + footprints: Iterable[InstrumentFootprint], +) -> InstrumentFootprint: + """Sums footprints region by region, giving what a set of instruments costs together.""" + measured = list(footprints) + return InstrumentFootprint( + instrument_bytes=sum(footprint.instrument_bytes for footprint in measured), + sequence_bytes=sum(footprint.sequence_bytes for footprint in measured), + ) diff --git a/src/sampletones_core/formats/famitracker/specification/memory.py b/src/sampletones_core/formats/famitracker/specification/memory.py new file mode 100644 index 000000000..1df6a6791 --- /dev/null +++ b/src/sampletones_core/formats/famitracker/specification/memory.py @@ -0,0 +1,15 @@ +from typing import Final + +INSTRUMENT_POINTER_BYTES: Final[int] = 2 +SEQUENCE_ENABLE_MASK_BYTES: Final[int] = 1 +SEQUENCE_POINTER_BYTES: Final[int] = 2 +INSTRUMENT_DEFINITION_BYTES: Final[int] = INSTRUMENT_POINTER_BYTES + SEQUENCE_ENABLE_MASK_BYTES + +SEQUENCE_LENGTH_BYTES: Final[int] = 1 +SEQUENCE_LOOP_POINT_BYTES: Final[int] = 1 +SEQUENCE_RELEASE_POINT_BYTES: Final[int] = 1 +SEQUENCE_SETTING_BYTES: Final[int] = 1 +SEQUENCE_ITEM_BYTES: Final[int] = 1 +SEQUENCE_HEADER_BYTES: Final[int] = ( + SEQUENCE_LENGTH_BYTES + SEQUENCE_LOOP_POINT_BYTES + SEQUENCE_RELEASE_POINT_BYTES + SEQUENCE_SETTING_BYTES +) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py new file mode 100644 index 000000000..d4fecae7d --- /dev/null +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -0,0 +1,182 @@ +from dataclasses import dataclass +from typing import Final, Optional, Sequence + +import numpy as np +import pytest + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.exporters.feature import Features +from sampletones_core.formats.famitracker.builder import build_instrument +from sampletones_core.formats.famitracker.footprint import ( + InstrumentFootprint, + features_footprint, + instrument_footprint, + reconstruction_footprints, + sequence_footprint, + sequences_footprint, + total_footprint, +) +from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence +from sampletones_core.formats.famitracker.specification.memory import ( + INSTRUMENT_DEFINITION_BYTES, + SEQUENCE_HEADER_BYTES, + SEQUENCE_POINTER_BYTES, +) +from sampletones_core.formats.famitracker.specification.sequences import ( + MAX_SEQUENCE_ITEMS, + SequenceKind, +) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +from .conftest import dual_generator_sample, pulse_sample + +REFERENCE_PITCH: Final[int] = 60 +OVER_LONG_LENGTH: Final[int] = MAX_SEQUENCE_ITEMS + 48 + + +def build_features( + volume: Sequence[int], + arpeggio: Sequence[int], + duty_cycle: Optional[Sequence[int]], +) -> Features: + """Builds the envelopes of one generator slice, leaving the pitch dimensions unused.""" + return Features( + initial_pitch=REFERENCE_PITCH, + volume=np.array(volume, dtype=int), + arpeggio=np.array(arpeggio, dtype=int), + pitch=None, + hi_pitch=None, + duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=int), + ) + + +class TestFeaturesFootprint(BaseTestSuite): + @dataclass(frozen=True, kw_only=True) + class FootprintCase(BaseRegularTestCase): + features: Features + loop: bool + expected: InstrumentFootprint + + test_cases = ( + FootprintCase( + features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), + loop=False, + expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=24), + label="pulse_one_shot", + ), + FootprintCase( + features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), + loop=True, + expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=21), + label="pulse_loop", + ), + FootprintCase( + features=build_features([15, 12, 0], [0, 1], None), + loop=False, + expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=14), + label="triangle", + ), + FootprintCase( + features=build_features([], [], None), + loop=False, + expected=InstrumentFootprint(instrument_bytes=3, sequence_bytes=0), + label="silent", + ), + FootprintCase( + features=build_features( + list(range(OVER_LONG_LENGTH)), + [0] * OVER_LONG_LENGTH, + None, + ), + loop=False, + expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=512), + label="capped_at_the_sequence_limit", + ), + FootprintCase( + features=build_features( + [0] * MAX_SEQUENCE_ITEMS, + [0] * MAX_SEQUENCE_ITEMS, + [0] * MAX_SEQUENCE_ITEMS, + ), + loop=False, + expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=768), + label="largest_instrument_famitracker_holds", + ), + ) + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_both_regions_are_measured_from_the_populated_sequences( + self, + case: FootprintCase, + ) -> None: + assert features_footprint(case.features, loop=case.loop) == case.expected + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_the_built_instrument_measures_the_same(self, case: FootprintCase) -> None: + """Both entry points measure one export, so a slice reads the same either way.""" + instrument = build_instrument(0, case.label, case.features, loop=case.loop) + assert instrument_footprint(instrument) == case.expected + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_the_total_sums_both_regions(self, case: FootprintCase) -> None: + footprint = features_footprint(case.features, loop=case.loop) + assert footprint.total_bytes == case.expected.instrument_bytes + case.expected.sequence_bytes + + +class TestSequenceFootprint: + def test_a_sequence_holds_its_header_and_one_byte_per_item(self) -> None: + sequence = InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 12, 9)) + assert sequence_footprint(sequence) == SEQUENCE_HEADER_BYTES + 3 + + def test_a_disabled_sequence_costs_nothing(self) -> None: + sequences = ( + InstrumentSequence(kind=SequenceKind.VOLUME, items=(15, 12)), + InstrumentSequence(kind=SequenceKind.PITCH, items=()), + ) + footprint = sequences_footprint(sequences) + assert footprint.instrument_bytes == INSTRUMENT_DEFINITION_BYTES + SEQUENCE_POINTER_BYTES + assert footprint.sequence_bytes == SEQUENCE_HEADER_BYTES + 2 + + +class TestTotalFootprint: + def test_regions_are_summed_separately(self) -> None: + footprints = ( + InstrumentFootprint(instrument_bytes=9, sequence_bytes=24), + InstrumentFootprint(instrument_bytes=7, sequence_bytes=16), + ) + assert total_footprint(footprints) == InstrumentFootprint(instrument_bytes=16, sequence_bytes=40) + + def test_no_instruments_cost_nothing(self) -> None: + assert total_footprint(()) == InstrumentFootprint(instrument_bytes=0, sequence_bytes=0) + + +class TestReconstructionFootprints: + def test_one_entry_per_covered_channel(self) -> None: + sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) + footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loop) + assert set(footprints) == {GeneratorName.PULSE1, GeneratorName.TRIANGLE} + + def test_a_triangle_slice_carries_one_sequence_less_than_a_pulse_slice(self) -> None: + """Triangle exports volume and arpeggio; pulse adds duty, hence one more pointer.""" + sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) + footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loop) + pulse = footprints[GeneratorName.PULSE1] + triangle = footprints[GeneratorName.TRIANGLE] + assert pulse.instrument_bytes - triangle.instrument_bytes == SEQUENCE_POINTER_BYTES + + def test_each_channel_is_measured_under_the_given_loop_flag(self) -> None: + sample = pulse_sample("lead", pitch=60) + features = sample.reconstruction.export() + for loop in (False, True): + assert reconstruction_footprints(sample.reconstruction, loop=loop) == { + generator_name: features_footprint(feature, loop=loop) for generator_name, feature in features.items() + } + + def test_looping_costs_the_shortest_dimensions_length(self) -> None: + """A looping instrument shares the shortest dimension's length, so it stores fewer items.""" + sample = pulse_sample("lead", pitch=60) + one_shot = total_footprint(reconstruction_footprints(sample.reconstruction, loop=False).values()) + looping = total_footprint(reconstruction_footprints(sample.reconstruction, loop=True).values()) + assert one_shot.instrument_bytes == looping.instrument_bytes + assert looping.sequence_bytes < one_shot.sequence_bytes From 9b468c98e8a9fa87d6ae4030841336dd0f23f788 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 19:20:34 +0200 Subject: [PATCH 096/152] Added: instrument and sample size to the instruments panel --- docs/formats/famitracker.md | 41 +++-- .../coordinators/tabs/reconstruction.py | 1 + .../logic/reconstruction/instruments.py | 112 +++++++++++-- .../tags/reconstructions.py | 7 + .../reconstruction/instruments/instruments.py | 78 +++++++++ .../view_model/reconstruction/instruments.py | 4 +- .../view_model/shared/footprint.py | 55 ++++++ src/sampletones_config/lang/en.yaml | 3 + src/sampletones_core/exporters/lengths.py | 44 ++++- .../formats/famitracker/sequences/features.py | 45 +++-- .../logic/reconstruction/test_instruments.py | 135 +++++++++++++++ .../reconstruction/test_instruments_panel.py | 157 +++++++++++++++++- .../exporters/test_lengths.py | 30 +++- .../famitracker/sequences/test_features.py | 41 +++-- .../formats/famitracker/test_footprint.py | 10 +- .../formats/famitracker/test_fti.py | 12 +- 16 files changed, 701 insertions(+), 74 deletions(-) create mode 100644 src/sampletones_application/view_model/shared/footprint.py diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index f7472ada0..978fbb9e2 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -146,16 +146,23 @@ sequence, so its envelopes repeat from the start while the note is held; a one-s instrument leaves every loop point at `-1`. A sample's `loop` flag drives this when the sample is exported into a module. -**Equal lengths.** FamiTracker advances each sequence on its own per-tick counter, so -every populated sequence of an instrument carries the same item count and the -dimensions stay in step. The volume envelope arrives one item longer than the others, -carrying a trailing zero that releases the note. A looping instrument therefore keeps -the shortest length, dropping that trailing item so the loop sustains; a one-shot -keeps the longest, each shorter dimension holding its final value through the release -tick. That shared length stays within the 252 items a FamiTracker sequence holds, so a -reconstruction longer than 252 frames — 8.4 s at the default 30 fps — exports its opening -252 frames and logs the shortening. The instruments panel colours a sequence input warning -orange once it passes that length, so the limit is visible before an export. +**Lengths.** FamiTracker advances each sequence on its own per-tick counter. A sequence +that reaches its last item halts and leaves the value it wrote applied, which the driver +holds for as long as the note sounds (`CSeqInstHandler::UpdateInstrument`). A one-shot +instrument therefore carries every dimension at the length it was written: a two-item +volume envelope beside a one-item duty envelope plays exactly as a padded pair would, and +costs the padding less. A looping instrument brings its populated dimensions to the +shortest length instead, so the envelopes repeat in step and the trailing zero that +releases the note is dropped from the cycle. + +Every length stays within the 252 items a FamiTracker sequence holds, so a reconstruction +longer than 252 frames — 8.4 s at the default 30 fps — exports its opening 252 frames and +logs the shortening. The instruments panel colours a sequence input warning orange once it +passes that length, so the limit is visible before an export. + +An empty dimension is written as a disabled sequence, which is a different instrument from +one carrying a single zero: the disabled slot leaves that dimension to the channel, while a +one-item sequence sets the value once and holds it. **How _SampleToNES_ fills an instrument.** Each generator slice of a sample's reconstruction becomes one instrument, so a sample yields one to four instruments. @@ -227,9 +234,8 @@ An instrument with `n` populated sequences carrying `s₁ … sₙ` items theref `3 + 2n` bytes of the instrument region and `Σ (4 + sᵢ)` of the sequence region. A dimension the channel leaves unused is written as a disabled slot, and the populated sequences alone are charged: `n` is 3 on the pulse and noise channels (volume, arpeggio, duty) and 2 on triangle. -Every populated sequence of one instrument shares a length (section B), so the sequence region -comes to `n · (4 + s)` and an instrument tops out at 777 bytes — three sequences at the 252-item -limit. +Each sequence is charged at its own length (section B), so shortening any one dimension shows +in the figure, and an instrument tops out at 777 bytes — three sequences at the 252-item limit. These two figures are the ones FamiTracker itself prints while creating an NSF — `Instruments used: N (X bytes)` and `Sequences used: M (Y bytes)` — which is how a measurement @@ -245,7 +251,8 @@ chunk once. A per-instrument or per-sample figure states that instrument's own c module total is therefore at most the sum of them. Within one instrument each kind appears once, so its own sequences are charged once each. -**Looping shortens the sequences.** A looping instrument shares its shortest dimension's length -and a one-shot its longest (section B), so one set of envelopes costs less as a loop. A sample -carries the flag that decides which applies; a reconstruction standing on its own is measured as -a one-shot, matching the instrument its **Export instrument** writes. +**Looping levels the sequences.** A looping instrument brings its populated dimensions to the +shortest length, while a one-shot keeps each dimension as written (section B), so the two forms +of one set of envelopes cost differently. A sample carries the flag that decides which applies; +a reconstruction standing on its own is measured as a one-shot, matching the instrument its +**Export instrument** writes. diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index d8eb98970..b11a114a5 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -611,6 +611,7 @@ def _remove_directory(self, directory: Path) -> None: def update_reconstruction(self) -> None: self._reconstruction_panel_logic.update_reconstruction() + self._reconstruction_instruments_logic.refresh_footprint() def set_reconstruction_dimmed(self, dimmed: bool) -> None: self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index 674d5e788..ac259d1c8 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -2,7 +2,9 @@ import numpy as np -from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.layout.behavior.scheduling.scheduling import ( + SchedulingBehavior, +) from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.view_model.reconstruction.instruments import ( @@ -11,8 +13,10 @@ from sampletones_application.view_model.reconstruction.update import ( ReconstructionUpdate, ) +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.exporters import Features +from sampletones_core.formats.famitracker.footprint import features_footprint from sampletones_core.types.feature import FeatureValue from sampletones_shared.utils.callbacks import CallbackMixin @@ -39,27 +43,57 @@ def __init__( self.on_reconstruction_instrument_updated: Optional[OnReconstructionInstrumentUpdatedCallback] = None def update_display(self) -> None: + generators = self._current_generators() + self.call(self.on_view_changed, self._build_view_model(generators)) + self.call(self.on_feature_data_changed, generators) + + def refresh_footprint(self) -> None: + """Reports the sizes the loaded envelopes occupy, leaving the displayed envelopes as they are. + + A regeneration replaces what an instrument exports, so the byte figures settle on it. The + envelopes themselves are left to the edit that started the regeneration, so a field the + user is still typing in keeps what they wrote. + """ + self.call(self.on_view_changed, self._build_view_model(self._current_generators())) + + def _current_generators(self) -> Optional[Dict[GeneratorName, Features]]: feature_data = self.reconstruction_manager.current_features - if feature_data is None: - self.call( - self.on_view_changed, - ReconstructionInstrumentsViewModel( - reconstruction_loaded=False, - available_generators=frozenset(), - ), + return None if feature_data is None else feature_data.generators + + def _build_view_model( + self, + generators: Optional[Dict[GeneratorName, Features]], + ) -> ReconstructionInstrumentsViewModel: + if generators is None: + return ReconstructionInstrumentsViewModel( + reconstruction_loaded=False, + available_generators=frozenset(), + footprint=None, ) - self.call(self.on_feature_data_changed, None) - return - available_generators: FrozenSet[GeneratorName] = frozenset(feature_data.generators.keys()) - self.call( - self.on_view_changed, - ReconstructionInstrumentsViewModel( - reconstruction_loaded=True, - available_generators=available_generators, - ), + available_generators: FrozenSet[GeneratorName] = frozenset(generators.keys()) + return ReconstructionInstrumentsViewModel( + reconstruction_loaded=True, + available_generators=available_generators, + footprint=self._build_footprint(generators), + ) + + def _build_footprint( + self, + generators: Dict[GeneratorName, Features], + ) -> SampleFootprintViewModel: + """Measures each channel's instrument as the size its own export writes. + + A reconstruction has no loop flag of its own — that belongs to a sample placed in a + project — so each instrument is measured playing its envelopes once, matching what + **Export instrument...** produces. + """ + return SampleFootprintViewModel.from_footprints( + { + generator_name: features_footprint(features, loop=False) + for generator_name, features in generators.items() + } ) - self.call(self.on_feature_data_changed, feature_data.generators) def handle_pitch_value_changed( self, @@ -80,6 +114,7 @@ def handle_bar_point_clicked( feature_key: FeatureKey, data: np.ndarray, ) -> None: + self._report_edited_size(generator_name, feature_key, data) self._schedule_reconstruction_update( ReconstructionUpdate( generator_name, @@ -94,6 +129,7 @@ def handle_raw_data_changed( feature_key: FeatureKey, data: np.ndarray, ) -> None: + self._report_edited_size(generator_name, feature_key, data) self._schedule_reconstruction_update( ReconstructionUpdate( generator_name, @@ -102,6 +138,46 @@ def handle_raw_data_changed( ) ) + def _report_edited_size( + self, + generator_name: GeneratorName, + feature_key: FeatureKey, + data: np.ndarray, + ) -> None: + """Reports what the edited envelope costs as the edit arrives, ahead of its regeneration. + + Measuring the envelope the user just wrote keeps the figures answering what is on screen + while the reconstruction is still being rebuilt. The regenerated instruments report again + once they land, so the figures settle on the exported form. + """ + generators = self._current_generators() + if generators is None: + return + + self.call( + self.on_view_changed, + self._build_view_model( + self._with_edit( + generators, + generator_name, + feature_key, + data, + ) + ), + ) + + def _with_edit( + self, + generators: Dict[GeneratorName, Features], + generator_name: GeneratorName, + feature_key: FeatureKey, + data: np.ndarray, + ) -> Dict[GeneratorName, Features]: + """The loaded channels with one envelope replaced, leaving the loaded ones as they are.""" + edited = generators[generator_name].model_copy(deep=True) + edited[feature_key] = data + return {**generators, generator_name: edited} + def _schedule_reconstruction_update( self, update: ReconstructionUpdate, diff --git a/src/sampletones_application/tags/reconstructions.py b/src/sampletones_application/tags/reconstructions.py index 86e6599bf..90d3f9e03 100644 --- a/src/sampletones_application/tags/reconstructions.py +++ b/src/sampletones_application/tags/reconstructions.py @@ -122,8 +122,15 @@ Widget.BUTTON, "export_instrument", ) +TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE = TagName( + Page.RECONSTRUCTIONS, + Panel.INSTRUMENTS, + Widget.TEXT, + "sample_size", +) PRE_RECONSTRUCTION_GENERATOR = compose_tag("reconstruction", "generator") SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE = "no_data_message" +SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE = "instrument_size" SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW = "window" SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE = "autoscale" diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index b510122ef..703e998f8 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -25,13 +25,16 @@ SUF_GRAPH_RAW_DATA, ) from sampletones_application.tags.reconstructions import ( + SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE, SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE, SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW, TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR, + TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE, ) from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.field import labeled_field from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.graphs.bar import GUIBarGraph @@ -54,9 +57,11 @@ dpg_configure_item, dpg_set_value, ) +from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.view_model.reconstruction.instruments import ( ReconstructionInstrumentsViewModel, ) +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import ( FeatureKey, GeneratorName, @@ -103,6 +108,8 @@ def __init__( self.tab_bar_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR self.no_data_message_tag = compose_tag(self.tab_bar_tag, SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE) self.mouse_item_handler_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_PANEL, SUF_HANDLER_REGISTRY) + self.sample_size_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TEXT_SAMPLE_SIZE + self.sample_size_group_tag = compose_tag(self.sample_size_tag, SUF_GROUP) self._graphs: Dict[str, GUIBarGraph] = {} self._sequence_lengths: Dict[Tuple[GeneratorName, FeatureKey], int] = {} @@ -126,6 +133,9 @@ def __init__( self.on_raw_data_changed: Optional[Callable[[GeneratorName, FeatureKey, np.ndarray], None]] = None self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"] + self._lbl_sample_size = language_manager["global.context.label.sample_size"] + self._lbl_instrument_size = language_manager["global.context.label.instrument_size"] + self._tpl_size_bytes = language_manager["global.context.template.size_bytes"] tooltip_template = language_manager["reconstructions.instruments.template.initial_pitch_tooltip_template"] self._pitch_tooltip = build_pitch_tooltip( language_manager, @@ -179,6 +189,17 @@ def _create_content(self) -> None: show=True, ) + with dpg.group( + tag=self.sample_size_group_tag, + parent=self._body_container, + show=False, + ): + self._create_size_field( + self._lbl_sample_size, + self.sample_size_tag, + self.sample_size_group_tag, + ) + with dpg.tab_bar( tag=self.tab_bar_tag, parent=self._body_container, @@ -186,9 +207,37 @@ def _create_content(self) -> None: ): self._create_tabs_for_generators() + def _create_size_field( + self, + label: str, + value_tag: str, + parent: str, + ) -> None: + """Draws a read-only byte figure, styled as the pitch stepper's readout is. + + The figure names how much of the NES data area an export spends, so it reads as + information beside the fields that change: the label column aligns with the stepper + below it, and the value carries the stepper's own read-only colour and font. + """ + with labeled_field( + label, + self._pitch_stepper_style.dimensions.label_width, + parent=parent, + ): + dpg.add_text(tag=value_tag, default_value="") + dpg_set_palette_color(value_tag, self._pitch_stepper_style.value_color) + FontRegistry.bind_to_item(value_tag, Font.MONO) + def _get_generator_tab_tag(self, generator_name: GeneratorName) -> str: return compose_tag(self.tab_bar_tag, generator_name) + def _get_instrument_size_tag(self, generator_name: GeneratorName) -> str: + return compose_tag( + self.tab_bar_tag, + generator_name, + SUF_RECONSTRUCTIONS_INSTRUMENTS_INSTRUMENT_SIZE, + ) + def _get_window_tag(self, tab_tag: str) -> str: return compose_tag(tab_tag, SUF_RECONSTRUCTIONS_INSTRUMENTS_WINDOW) @@ -288,6 +337,11 @@ def _create_generator_content( window_tag: str, ) -> None: initial_pitch = self._default_initial_pitch(generator_name) + self._create_size_field( + self._lbl_instrument_size, + self._get_instrument_size_tag(generator_name), + window_tag, + ) self._create_pitch_stepper(generator_name, initial_pitch, window_tag) self._create_generator_feature_displays(generator_name, window_tag) @@ -384,12 +438,36 @@ def update_view( is_loaded = view_model.reconstruction_loaded dpg_configure_item(self.no_data_message_tag, show=not is_loaded) dpg_configure_item(self.tab_bar_tag, show=is_loaded) + dpg_configure_item(self.sample_size_group_tag, show=is_loaded) + self._update_sizes(view_model.footprint) for generator_name in GeneratorName.items(): tab_tag = self._get_generator_tab_tag(generator_name) is_available = generator_name in view_model.available_generators dpg_configure_item(tab_tag, show=is_available) + def _update_sizes( + self, + footprint: Optional[SampleFootprintViewModel], + ) -> None: + """Writes the byte figures the loaded reconstruction occupies, the sample's and each channel's.""" + if footprint is None: + return + + dpg_set_value(self.sample_size_tag, self._format_size(footprint.total_bytes)) + for generator_name in GeneratorName.items(): + instrument_bytes = footprint.bytes_for(generator_name) + if instrument_bytes is None: + continue + + dpg_set_value( + self._get_instrument_size_tag(generator_name), + self._format_size(instrument_bytes), + ) + + def _format_size(self, byte_count: int) -> str: + return self._tpl_size_bytes.format(bytes=byte_count) + def update_feature_data( self, generators: Optional[Dict[GeneratorName, Features]], diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py index c599abe1f..9b3a955b5 100644 --- a/src/sampletones_application/view_model/reconstruction/instruments.py +++ b/src/sampletones_application/view_model/reconstruction/instruments.py @@ -1,10 +1,12 @@ -from typing import FrozenSet +from typing import FrozenSet, Optional from pydantic import BaseModel +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import GeneratorName class ReconstructionInstrumentsViewModel(BaseModel, frozen=True): reconstruction_loaded: bool available_generators: FrozenSet[GeneratorName] + footprint: Optional[SampleFootprintViewModel] diff --git a/src/sampletones_application/view_model/shared/footprint.py b/src/sampletones_application/view_model/shared/footprint.py new file mode 100644 index 000000000..4607001c0 --- /dev/null +++ b/src/sampletones_application/view_model/shared/footprint.py @@ -0,0 +1,55 @@ +from typing import Dict, Optional, Self, Tuple + +from pydantic import BaseModel + +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.famitracker.footprint import InstrumentFootprint + + +class InstrumentSizeViewModel(BaseModel, frozen=True): + """The bytes one channel's instrument occupies once a tracker compiles it.""" + + generator: GeneratorName + total_bytes: int + + +class SampleFootprintViewModel(BaseModel, frozen=True): + """The byte sizes a sample's instruments occupy, one entry per channel it covers. + + A sample exports one instrument per channel its reconstruction covers, so a display reads + :attr:`total_bytes` for the sample as a whole and :meth:`bytes_for` for a single channel. + Both the instruments panel and the samples menu read their figures from here, so the two + name the same size for the same sample. + """ + + instruments: Tuple[InstrumentSizeViewModel, ...] + + @classmethod + def from_footprints( + cls, + footprints: Dict[GeneratorName, InstrumentFootprint], + ) -> Self: + """Collects measured channels in the generators' own order, so displays list them alike.""" + return cls( + instruments=tuple( + InstrumentSizeViewModel( + generator=generator_name, + total_bytes=footprints[generator_name].total_bytes, + ) + for generator_name in GeneratorName.items() + if generator_name in footprints + ), + ) + + @property + def total_bytes(self) -> int: + """The bytes the whole sample occupies, its instruments summed.""" + return sum(instrument.total_bytes for instrument in self.instruments) + + def bytes_for(self, generator: GeneratorName) -> Optional[int]: + """The bytes one channel's instrument occupies, where the sample covers that channel.""" + for instrument in self.instruments: + if instrument.generator == generator: + return instrument.total_bytes + + return None diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 827d625ba..d91fe3ac8 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -157,6 +157,9 @@ global.context.label.detail_spectrum_method: "Generation method" global.context.label.detail_transformation_gamma: "Transformation gamma" global.context.label.detail_window_size: "Window size" global.context.label.detail_configuration: "Configuration" +global.context.label.instrument_size: "Instrument size" +global.context.label.sample_size: "Sample size" +global.context.template.size_bytes: "{bytes} B" # ============================================================================= # Global — Menu diff --git a/src/sampletones_core/exporters/lengths.py b/src/sampletones_core/exporters/lengths.py index 64f6144de..fc5ff1511 100644 --- a/src/sampletones_core/exporters/lengths.py +++ b/src/sampletones_core/exporters/lengths.py @@ -11,6 +11,15 @@ def _resize(items: Tuple[int, ...], length: int) -> Tuple[int, ...]: return items[:length] + items[-1:] * (length - len(items)) +def _limited_length(length: int, limit: Optional[int]) -> int: + """Brings a length within what the target format stores, reporting what that drops.""" + if limit is None or length <= limit: + return length + + logger.warning(f"Instrument envelope of {length} items keeps its first {limit}, the most the format holds") + return limit + + def _common_length(lengths: List[int], loop: bool, limit: Optional[int]) -> int: """Chooses the length every populated dimension of an instrument shares. @@ -28,12 +37,37 @@ def _common_length(lengths: List[int], loop: bool, limit: Optional[int]) -> int: Returns: int: The shared item count, at most ``limit`` where one applies. """ - length = min(lengths) if loop else max(lengths) - if limit is None or length <= limit: - return length + return _limited_length(min(lengths) if loop else max(lengths), limit) - logger.warning(f"Instrument envelope of {length} items keeps its first {limit}, the most the format holds") - return limit + +def limit_lengths( + items_by_kind: Dict[EnvelopeKey, Tuple[int, ...]], + *, + limit: int, +) -> Dict[EnvelopeKey, Tuple[int, ...]]: + """Keeps each dimension's opening items, as many as the target format stores. + + Every dimension stands at its own length, which is what a player that sustains an + exhausted envelope's final value reads: the envelope describes the frames it covers + and the last value it wrote governs the rest. + + Args: + items_by_kind: The per-dimension item tuples, empty for a dimension the channel + leaves unused. + limit: The most items the target format stores. + + Returns: + Dict[EnvelopeKey, Tuple[int, ...]]: The items with every dimension within the limit. + """ + return { + kind: items[ + : _limited_length( + len(items), + limit, + ) + ] + for kind, items in items_by_kind.items() + } def equalize_lengths( diff --git a/src/sampletones_core/formats/famitracker/sequences/features.py b/src/sampletones_core/formats/famitracker/sequences/features.py index 75d88510e..24e2057f8 100644 --- a/src/sampletones_core/formats/famitracker/sequences/features.py +++ b/src/sampletones_core/formats/famitracker/sequences/features.py @@ -2,7 +2,7 @@ import numpy as np -from sampletones_core.exporters.lengths import equalize_lengths +from sampletones_core.exporters.lengths import equalize_lengths, limit_lengths from sampletones_core.formats.famitracker.model.sequence import InstrumentSequence from sampletones_core.formats.famitracker.specification.sequences import ( LOOP_FROM_START, @@ -18,6 +18,25 @@ def _to_items(array: Optional[np.ndarray]) -> Tuple[int, ...]: return tuple(int(value) for value in array) +def _sequence_items( + arrays: Dict[SequenceKind, Optional[np.ndarray]], + loop: bool, +) -> Dict[SequenceKind, Tuple[int, ...]]: + """Reads the dimensions as the item tuples an instrument stores. + + A looping instrument brings every populated dimension to one length, so its envelopes + repeat in step cycle after cycle. A one-shot carries each dimension at the length it + was written: a FamiTracker sequence that runs out halts and leaves its final value + applied for as long as the note sounds, so the shorter dimensions govern the whole + instrument on their own. + """ + items_by_kind = {kind: _to_items(array) for kind, array in arrays.items()} + if loop: + return equalize_lengths(items_by_kind, loop, limit=MAX_SEQUENCE_ITEMS) + + return limit_lengths(items_by_kind, limit=MAX_SEQUENCE_ITEMS) + + def features_to_instrument_sequences( *, volume: np.ndarray, @@ -29,12 +48,12 @@ def features_to_instrument_sequences( ) -> Dict[SequenceKind, InstrumentSequence]: """Builds the five 2A03 sequences from per-dimension envelope arrays. - Each dimension becomes an :class:`InstrumentSequence`; a dimension passed as - ``None`` becomes a disabled (empty) sequence. Populated dimensions are brought to a - common length so they stay in step tick for tick, capped at the ``MAX_SEQUENCE_ITEMS`` - items FamiTracker stores, so a longer reconstruction exports its opening frames and - the shortening is logged. When ``loop`` is set, every populated sequence loops from - its first item so the instrument sustains on a held note. + Each dimension becomes an :class:`InstrumentSequence`; a dimension passed as ``None`` + or as an empty envelope becomes a disabled sequence the instrument stores nothing for. + Item counts stay within the ``MAX_SEQUENCE_ITEMS`` items FamiTracker holds, so a longer + reconstruction exports its opening frames and the shortening is logged. When ``loop`` + is set, every populated sequence loops from its first item so the instrument sustains + on a held note, and the populated dimensions share one length to repeat in step. """ arrays: Dict[SequenceKind, Optional[np.ndarray]] = { SequenceKind.VOLUME: volume, @@ -44,15 +63,15 @@ def features_to_instrument_sequences( SequenceKind.DUTY: duty_cycle, } - items_by_kind = equalize_lengths( - {kind: _to_items(array) for kind, array in arrays.items()}, - loop, - limit=MAX_SEQUENCE_ITEMS, - ) + items_by_kind = _sequence_items(arrays, loop) sequences: Dict[SequenceKind, InstrumentSequence] = {} for kind, items in items_by_kind.items(): loop_point = LOOP_FROM_START if loop and items else NO_LOOP_POINT - sequences[kind] = InstrumentSequence(kind=kind, items=items, loop_point=loop_point) + sequences[kind] = InstrumentSequence( + kind=kind, + items=items, + loop_point=loop_point, + ) return sequences diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index f8fd4aa8b..625f13480 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -15,6 +15,10 @@ ) from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.exporters import Features +from sampletones_core.formats.famitracker.footprint import ( + features_footprint, + total_footprint, +) from sampletones_core.reconstructions import Reconstruction @@ -96,6 +100,137 @@ def test_with_features_exposes_available_generators( assert GeneratorName.PULSE1 in received[0].available_generators +class TestReconstructionInstrumentsLogicFootprint: + """The byte figures the view carries, measured from the envelopes the manager holds.""" + + def test_no_reconstruction_carries_no_footprint( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + ) -> None: + mock_reconstruction_manager.current_features = None + received: List[ReconstructionInstrumentsViewModel] = [] + instruments_logic.on_view_changed = received.append + instruments_logic.update_display() + assert received[0].footprint is None + + def test_every_covered_channel_is_measured( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + feature_data = FeatureData.load(reconstruction_factory()) + mock_reconstruction_manager.current_features = feature_data + received: List[ReconstructionInstrumentsViewModel] = [] + instruments_logic.on_view_changed = received.append + instruments_logic.update_display() + footprint = received[0].footprint + assert footprint is not None + assert {instrument.generator for instrument in footprint.instruments} == set(feature_data.generators) + + def test_the_size_is_the_one_a_one_shot_export_writes( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """A reconstruction exports its instruments as one-shots, so that is the size shown.""" + feature_data = FeatureData.load(reconstruction_factory()) + mock_reconstruction_manager.current_features = feature_data + received: List[ReconstructionInstrumentsViewModel] = [] + instruments_logic.on_view_changed = received.append + instruments_logic.update_display() + footprint = received[0].footprint + assert footprint is not None + expected = total_footprint( + features_footprint(features, loop=False) for features in feature_data.generators.values() + ) + assert footprint.total_bytes == expected.total_bytes + + def test_an_envelope_edit_is_measured_as_it_arrives( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """The typed envelope is measured at once, so the figure answers what is on screen.""" + feature_data = FeatureData.load(reconstruction_factory()) + mock_reconstruction_manager.current_features = feature_data + received: List[ReconstructionInstrumentsViewModel] = [] + instruments_logic.on_view_changed = received.append + + volume = np.array([15, 12, 8, 4, 0], dtype=np.int8) + instruments_logic.handle_raw_data_changed( + GeneratorName.PULSE1, + FeatureKey.VOLUME, + volume, + ) + + edited = feature_data.generators[GeneratorName.PULSE1].model_copy(deep=True) + edited[FeatureKey.VOLUME] = volume + footprint = received[0].footprint + assert footprint is not None + assert footprint.bytes_for(GeneratorName.PULSE1) == features_footprint(edited, loop=False).total_bytes + + def test_a_bar_edit_is_measured_as_it_arrives( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + feature_data = FeatureData.load(reconstruction_factory()) + mock_reconstruction_manager.current_features = feature_data + received: List[ReconstructionInstrumentsViewModel] = [] + instruments_logic.on_view_changed = received.append + + instruments_logic.handle_bar_point_clicked( + GeneratorName.PULSE1, + FeatureKey.ARPEGGIO, + np.zeros(6, dtype=np.int8), + ) + + assert received[0].footprint is not None + + def test_measuring_an_edit_leaves_the_loaded_envelopes_as_they_are( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """The regeneration owns the loaded envelopes, so the measurement reads a copy.""" + feature_data = FeatureData.load(reconstruction_factory()) + mock_reconstruction_manager.current_features = feature_data + loaded_volume = feature_data.generators[GeneratorName.PULSE1].volume.copy() + + instruments_logic.handle_raw_data_changed( + GeneratorName.PULSE1, + FeatureKey.VOLUME, + np.array([15, 12, 8, 4, 0], dtype=np.int8), + ) + + assert np.array_equal(feature_data.generators[GeneratorName.PULSE1].volume, loaded_volume) + + def test_a_refresh_reports_the_view_alone( + self, + instruments_logic: ReconstructionInstrumentsLogic, + mock_reconstruction_manager: MagicMock, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """A regenerated reconstruction refreshes the figures, leaving the edited envelopes displayed.""" + mock_reconstruction_manager.current_features = FeatureData.load(reconstruction_factory()) + received: List[ReconstructionInstrumentsViewModel] = [] + feature_updates: List[Optional[Dict[GeneratorName, Features]]] = [] + instruments_logic.on_view_changed = received.append + instruments_logic.on_feature_data_changed = feature_updates.append + + instruments_logic.refresh_footprint() + + assert len(received) == 1 + assert received[0].footprint is not None + assert feature_updates == [] + + class TestReconstructionInstrumentsLogicHandlePitchValueChanged: def test_schedules_reconstruction_update( self, 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 7458f13a7..962ee4d0b 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -1,4 +1,5 @@ -from typing import Final, List +from dataclasses import dataclass +from typing import Dict, Final, List from unittest.mock import MagicMock import pytest @@ -19,6 +20,7 @@ ) from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.pitch_stepper import PitchStepperStyle +from sampletones_application.ui.panels.reconstruction.instruments import instruments as instruments_module from sampletones_application.ui.panels.reconstruction.instruments.instruments import ( GUIReconstructionInstrumentsPanel, ) @@ -26,13 +28,44 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource +from sampletones_application.view_model.reconstruction.instruments import ( + ReconstructionInstrumentsViewModel, +) +from sampletones_application.view_model.shared.footprint import ( + InstrumentSizeViewModel, + SampleFootprintViewModel, +) from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase SEQUENCE_STATUS_KEY: Final[str] = "reconstructions.instruments.message.status_sequence" +NOT_LOADED: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( + reconstruction_loaded=False, + available_generators=frozenset(), + footprint=None, +) + + +def build_view_model( + channel_bytes: Dict[GeneratorName, int], +) -> ReconstructionInstrumentsViewModel: + """A loaded reconstruction covering the given channels, each measured at the given size.""" + return ReconstructionInstrumentsViewModel( + reconstruction_loaded=True, + available_generators=frozenset(channel_bytes), + footprint=SampleFootprintViewModel( + instruments=tuple( + InstrumentSizeViewModel(generator=generator_name, total_bytes=byte_count) + for generator_name, byte_count in channel_bytes.items() + ), + ), + ) + @pytest.fixture def layout_config() -> LayoutConfig: @@ -59,6 +92,26 @@ def bound_themes(monkeypatch: pytest.MonkeyPatch) -> List[str]: return tags +@pytest.fixture +def written(monkeypatch: pytest.MonkeyPatch) -> Dict[str, str]: + """Records the texts written to items, standing in for the DPG values.""" + values: Dict[str, str] = {} + monkeypatch.setattr(instruments_module, "dpg_set_value", values.__setitem__) + return values + + +@pytest.fixture +def shown(monkeypatch: pytest.MonkeyPatch) -> Dict[str, bool]: + """Records which items the panel shows, standing in for the DPG configuration.""" + flags: Dict[str, bool] = {} + + def configure(tag: str, *, show: bool) -> None: + flags[tag] = show + + monkeypatch.setattr(instruments_module, "dpg_configure_item", configure) + return flags + + @pytest.fixture def panel(layout_config: LayoutConfig) -> GUIReconstructionInstrumentsPanel: return GUIReconstructionInstrumentsPanel( @@ -179,3 +232,105 @@ def test_a_sequence_beyond_the_limit_names_the_limit( message = panel._sequence_status_message(GeneratorName.PULSE1, FeatureKey.VOLUME) assert "300" in message assert str(MAX_SEQUENCE_ITEMS) in message + + +class TestSizeFields(BaseTestSuite): + """The two read-only byte figures: the sample's above the tabs, each channel's inside its tab.""" + + @dataclass(frozen=True, kw_only=True) + class SizeCase(BaseRegularTestCase): + channel_bytes: Dict[GeneratorName, int] + expected: str + + test_cases = ( + SizeCase( + label="a single channel spends what its instrument does", + channel_bytes={GeneratorName.PULSE1: 777}, + expected="777 B", + ), + SizeCase( + label="three channels spend their instruments together", + channel_bytes={ + GeneratorName.PULSE1: 777, + GeneratorName.TRIANGLE: 519, + GeneratorName.NOISE: 777, + }, + expected="2073 B", + ), + SizeCase( + label="a silent channel spends the instrument definition alone", + channel_bytes={GeneratorName.TRIANGLE: 3}, + expected="3 B", + ), + ) + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_the_sample_size_sums_its_channels( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + case: SizeCase, + ) -> None: + panel.update_view(build_view_model(case.channel_bytes)) + assert written[panel.sample_size_tag] == case.expected + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_each_channel_states_its_own_size( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + case: SizeCase, + ) -> None: + panel.update_view(build_view_model(case.channel_bytes)) + assert { + generator_name: written[panel._get_instrument_size_tag(generator_name)] + for generator_name in case.channel_bytes + } == {generator_name: f"{byte_count} B" for generator_name, byte_count in case.channel_bytes.items()} + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_an_uncovered_channel_is_left_alone( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + case: SizeCase, + ) -> None: + """A channel the reconstruction leaves out exports no instrument, so its tab holds no figure.""" + panel.update_view(build_view_model(case.channel_bytes)) + uncovered = [ + panel._get_instrument_size_tag(generator_name) + for generator_name in GeneratorName.items() + if generator_name not in case.channel_bytes + ] + assert [tag for tag in uncovered if tag in written] == [] + + +class TestSizeVisibility: + def test_a_loaded_reconstruction_shows_the_sample_size( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + ) -> None: + panel.update_view(build_view_model({GeneratorName.PULSE1: 777})) + assert shown[panel.sample_size_group_tag] is True + + def test_no_reconstruction_hides_the_sample_size( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + ) -> None: + panel.update_view(NOT_LOADED) + assert shown[panel.sample_size_group_tag] is False + + def test_no_reconstruction_states_no_figures( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + ) -> None: + panel.update_view(NOT_LOADED) + assert written == {} diff --git a/tests/unit/sampletones_core/exporters/test_lengths.py b/tests/unit/sampletones_core/exporters/test_lengths.py index b3c4bb0ca..785563b0d 100644 --- a/tests/unit/sampletones_core/exporters/test_lengths.py +++ b/tests/unit/sampletones_core/exporters/test_lengths.py @@ -3,7 +3,7 @@ import pytest -from sampletones_core.exporters.lengths import equalize_lengths +from sampletones_core.exporters.lengths import equalize_lengths, limit_lengths VOLUME: Final[str] = "volume" ARPEGGIO: Final[str] = "arpeggio" @@ -44,6 +44,34 @@ def test_all_dimensions_empty_stay_empty(self) -> None: assert all(items == () for items in equalized.values()) +class TestLimitLengths: + def test_every_dimension_keeps_its_own_length(self) -> None: + limited = limit_lengths({VOLUME: (15, 12, 9, 0), ARPEGGIO: (0, 2, 4)}, limit=ITEM_LIMIT) + assert limited[VOLUME] == (15, 12, 9, 0) + assert limited[ARPEGGIO] == (0, 2, 4) + + def test_empty_dimensions_stay_empty(self) -> None: + limited = limit_lengths({VOLUME: (15, 12, 0), ARPEGGIO: ()}, limit=ITEM_LIMIT) + assert limited[ARPEGGIO] == () + + def test_an_over_long_envelope_keeps_its_opening_items(self) -> None: + limited = limit_lengths(volume_and_arpeggio(ITEM_LIMIT + 48), limit=ITEM_LIMIT) + assert limited[VOLUME] == items_of(ITEM_LIMIT) + assert len(limited[ARPEGGIO]) == ITEM_LIMIT + + def test_an_over_long_envelope_is_reported(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + limit_lengths(volume_and_arpeggio(ITEM_LIMIT + 1), limit=ITEM_LIMIT) + + assert str(ITEM_LIMIT) in caplog.text + + def test_an_envelope_within_the_limit_is_quiet(self, caplog: pytest.LogCaptureFixture) -> None: + with caplog.at_level(logging.WARNING): + limit_lengths(volume_and_arpeggio(ITEM_LIMIT), limit=ITEM_LIMIT) + + assert caplog.text == "" + + class TestItemLimit: @pytest.mark.parametrize("loop", [False, True], ids=["one_shot", "loop"]) def test_an_over_long_envelope_keeps_its_opening_items(self, loop: bool) -> None: diff --git a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py index 20d8e26b3..9f625a53a 100644 --- a/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py +++ b/tests/unit/sampletones_core/formats/famitracker/sequences/test_features.py @@ -1,5 +1,4 @@ import numpy as np -import pytest from sampletones_core.formats.famitracker.sequences.features import ( features_to_instrument_sequences, @@ -97,7 +96,7 @@ def test_no_loop_leaves_loop_point_disabled(self) -> None: assert sequences[SequenceKind.VOLUME].loop_point == NO_LOOP_POINT -class TestSequenceLengthsAreEqualized: +class TestSequenceLengths: def test_loop_drops_the_trailing_note_off_volume_item(self) -> None: sequences = features_to_instrument_sequences( volume=np.array([15, 12, 9, 0]), @@ -111,31 +110,31 @@ def test_loop_drops_the_trailing_note_off_volume_item(self) -> None: assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) assert sequences[SequenceKind.DUTY].items == (1, 1, 2) - def test_one_shot_holds_the_shorter_dimensions_final_value(self) -> None: + def test_one_shot_carries_each_dimension_as_written(self) -> None: + """A halted sequence holds its final value, so a shorter dimension governs the rest itself.""" sequences = features_to_instrument_sequences( volume=np.array([15, 12, 9, 0]), arpeggio=np.array([0, 2, 4]), pitch=None, hi_pitch=None, - duty_cycle=np.array([1, 1, 2]), + duty_cycle=np.array([1]), loop=False, ) assert sequences[SequenceKind.VOLUME].items == (15, 12, 9, 0) - assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4, 4) - assert sequences[SequenceKind.DUTY].items == (1, 1, 2, 2) + assert sequences[SequenceKind.ARPEGGIO].items == (0, 2, 4) + assert sequences[SequenceKind.DUTY].items == (1,) - @pytest.mark.parametrize("loop", [False, True], ids=["one_shot", "loop"]) - def test_every_populated_dimension_shares_one_length(self, loop: bool) -> None: + def test_a_loop_brings_every_populated_dimension_to_one_length(self) -> None: sequences = features_to_instrument_sequences( volume=np.array([15, 12, 9, 0]), arpeggio=np.array([0, 2, 4]), pitch=np.array([0, 1]), hi_pitch=None, duty_cycle=np.array([1, 1, 2]), - loop=loop, + loop=True, ) lengths = {len(sequence.items) for sequence in sequences.values() if sequence.enabled} - assert len(lengths) == 1 + assert lengths == {2} def test_disabled_dimensions_stay_empty(self) -> None: sequences = features_to_instrument_sequences( @@ -149,6 +148,28 @@ def test_disabled_dimensions_stay_empty(self) -> None: assert sequences[SequenceKind.ARPEGGIO].items == () assert sequences[SequenceKind.PITCH].items == () + def test_an_empty_envelope_differs_from_one_holding_a_single_zero(self) -> None: + """An empty dimension leaves its sequence disabled; a single zero is a value the instrument sets.""" + cleared = features_to_instrument_sequences( + volume=np.array([15, 0]), + arpeggio=np.array([], dtype=int), + pitch=None, + hi_pitch=None, + duty_cycle=None, + loop=False, + ) + zeroed = features_to_instrument_sequences( + volume=np.array([15, 0]), + arpeggio=np.array([0]), + pitch=None, + hi_pitch=None, + duty_cycle=None, + loop=False, + ) + assert cleared[SequenceKind.ARPEGGIO].enabled is False + assert zeroed[SequenceKind.ARPEGGIO].enabled is True + assert zeroed[SequenceKind.ARPEGGIO].items == (0,) + def test_all_dimensions_empty_stays_empty(self) -> None: sequences = features_to_instrument_sequences( volume=np.array([], dtype=int), diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index d4fecae7d..7af908e39 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -62,7 +62,7 @@ class FootprintCase(BaseRegularTestCase): FootprintCase( features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), loop=False, - expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=24), + expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=22), label="pulse_one_shot", ), FootprintCase( @@ -71,10 +71,16 @@ class FootprintCase(BaseRegularTestCase): expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=21), label="pulse_loop", ), + FootprintCase( + features=build_features([15, 0], [0], [0]), + loop=False, + expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=16), + label="dimensions_of_differing_lengths", + ), FootprintCase( features=build_features([15, 12, 0], [0, 1], None), loop=False, - expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=14), + expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=13), label="triangle", ), FootprintCase( diff --git a/tests/unit/sampletones_core/formats/famitracker/test_fti.py b/tests/unit/sampletones_core/formats/famitracker/test_fti.py index ec16cfc93..8503bd4bc 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_fti.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_fti.py @@ -19,9 +19,9 @@ GOLDEN_FTI_BYTES = ( b"FTI2.4\x01\x0f\x00\x00\x00Test Instrument\x05" b"\x01\x04\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x0f\x0c\x08\x00" - b"\x01\x04\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x02\xfd\xfd" + b"\x01\x03\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x02\xfd" b"\x00\x00" - b"\x01\x04\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x01\x01\x01" + b"\x01\x02\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\x00\x00\x00\x00\x00\x01" b"\x00\x00\x00\x00\x00\x00\x00\x00" ) @@ -118,9 +118,9 @@ def parse_fti(data: bytes) -> ParsedFti: class TestWriteFtiGoldenBytes: - """Pins the byte output so a change in the writer is caught. Every populated - sequence carries the same item count, the arpeggio and duty envelopes holding - their final value through the volume envelope's trailing note-off item.""" + """Pins the byte output so a change in the writer is caught. Each populated + sequence carries the items its own envelope was written with, the shorter + arpeggio and duty envelopes ending before the volume envelope does.""" def test_output_matches_golden(self, tmp_path: Path) -> None: path = tmp_path / "golden.fti" @@ -162,7 +162,7 @@ def test_enabled_sequence_items_round_trip(self, tmp_path: Path) -> None: parsed = parse_fti(path.read_bytes()) assert parsed.sequences[0].enabled is True assert parsed.sequences[0].items == [15, 12, 8, 0] - assert parsed.sequences[1].items == [0, 2, -3, -3] + assert parsed.sequences[1].items == [0, 2, -3] def test_missing_sequences_are_disabled(self, tmp_path: Path) -> None: path = tmp_path / "instrument.fti" From 8cbc4a11907e733dd5a6dbe6097aec0762aca2d4 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 20:21:12 +0200 Subject: [PATCH 097/152] Added: envelopes the channel governs --- docs/formats/famitracker.md | 4 +- docs/formats/reconstructions.md | 8 +- .../services/regeneration.py | 23 ++- src/sampletones_core/exporters/exporter.py | 28 ++- src/sampletones_core/exporters/feature.py | 29 ++- src/sampletones_core/features/__init__.py | 2 + src/sampletones_core/features/spec.py | 9 + .../reconstruction/instructions.py | 10 +- .../reconstruction/reconstruction.py | 25 ++- .../services/conftest.py | 1 + .../logic/project/test_controller.py | 1 + .../services/test_regeneration.py | 9 +- .../exporters/test_exporter.py | 180 +++++++++++++++++- .../exporters/test_feature.py | 24 +++ .../formats/famitracker/test_builder.py | 1 + .../reconstruction/test_reconstruction.py | 63 +++++- 16 files changed, 391 insertions(+), 26 deletions(-) diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 978fbb9e2..38b22c3b9 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -162,7 +162,9 @@ passes that length, so the limit is visible before an export. An empty dimension is written as a disabled sequence, which is a different instrument from one carrying a single zero: the disabled slot leaves that dimension to the channel, while a -one-item sequence sets the value once and holds it. +one-item sequence sets the value once and holds it. A dimension arrives empty when the +reconstruction records it as one the channel governs — the state clearing the envelope in the +instruments panel puts it in (see [Reconstructions](reconstructions.md)). **How _SampleToNES_ fills an instrument.** Each generator slice of a sample's reconstruction becomes one instrument, so a sample yields one to four instruments. diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index 7b455b989..cea07b3a7 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -34,7 +34,13 @@ A `.stn` file holds: measured against, chosen once when the reconstruction is built and stored with the instructions it describes. An export reads the offsets against this pitch, so editing an arpeggio moves the frames around a base that stays put (see - [FamiTracker export](famitracker.md)). + [FamiTracker export](famitracker.md)); +* **per-channel held dimensions** — the envelopes each channel leaves to the + player. An instruction states a value for every dimension of its frame, so this + is what says which of them the instrument itself writes; the rest are the + channel's, and the player keeps the value it already holds for them. A freshly + built reconstruction writes them all, and clearing an envelope in the + instruments panel adds that dimension here. ## Detached reconstructions diff --git a/src/sampletones_application/services/regeneration.py b/src/sampletones_application/services/regeneration.py index 7281497d1..5602803d6 100644 --- a/src/sampletones_application/services/regeneration.py +++ b/src/sampletones_application/services/regeneration.py @@ -12,6 +12,7 @@ from sampletones_application.utils.parallelization.coalescing import LatestWinsExecutor from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.exporters import GENERATOR_NAME_TO_EXPORTER_MAP, Features +from sampletones_core.generators import GeneratorUnion from sampletones_core.instructions import InstructionUnion from sampletones_core.reconstructions import Reconstruction from sampletones_core.types.feature import FeatureValue @@ -100,9 +101,7 @@ def _run( exporter_class.from_features(features), ) generator = generator_class(reconstruction.config, generator_name) - audio = np.concatenate( - [generator(instruction, save=True) for instruction in instructions], # type: ignore[arg-type] - ) + audio = self._render(generator, instructions) updated = reconstruction.model_copy(deep=True) updated.update_generator_data( @@ -110,6 +109,7 @@ def _run( instructions, audio, features.initial_pitch, + features.held_features, ) self._emit( ServiceSuccess( @@ -122,3 +122,20 @@ def _run( ) except Exception as exception: # pylint: disable=broad-exception-caught self._emit(ServiceError(exception=exception)) + + @staticmethod + def _render( + generator: GeneratorUnion, + instructions: List[InstructionUnion], + ) -> np.ndarray: + """Synthesizes the frames the instructions describe, one after another. + + An instrument whose every dimension is left to the channel describes no frame, and + sounds as the silence of an empty waveform. + """ + if not instructions: + return np.zeros(0, dtype=np.float32) + + return np.concatenate( + [generator(instruction, save=True) for instruction in instructions], # type: ignore[arg-type] + ) diff --git a/src/sampletones_core/exporters/exporter.py b/src/sampletones_core/exporters/exporter.py index 798388bc0..b45502170 100644 --- a/src/sampletones_core/exporters/exporter.py +++ b/src/sampletones_core/exporters/exporter.py @@ -1,9 +1,10 @@ from abc import ABC, abstractmethod -from typing import ClassVar, Dict, Final, Generic, List, Optional, Union, cast +from typing import ClassVar, Dict, Generic, Iterable, List, Optional, Union, cast import numpy as np from sampletones_core.constants.enums import FeatureKey +from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS from sampletones_core.generators import GeneratorTypeUnion from sampletones_core.instructions import ( InstructionFields, @@ -15,8 +16,6 @@ from .feature import Features -EMPTY_ENVELOPE_VALUE: Final[int] = 0 - class Exporter(ABC, Generic[InstructionT]): """ @@ -38,18 +37,26 @@ def to_features( self, instructions: List[InstructionT], initial_pitch: int, + held_features: Iterable[FeatureKey], ) -> Features: """Converts an instruction sequence into its :class:`Features`. + An instruction states every dimension of its frame, so the dimensions the instrument + leaves to the channel are named alongside the sequence and come back with empty + envelopes: what the frames carry for them is the value the channel held. + Args: instructions: The channel's per-frame instructions. initial_pitch: Reference pitch the arpeggio envelope is measured against. + held_features: The dimensions the channel governs. Returns: Features: The envelope representation of the sequence. """ feature_map = self.get_feature_map(instructions, initial_pitch) - return self.from_feature_map_to_features(feature_map) + features = self.from_feature_map_to_features(feature_map) + features.leave_to_channel(held_features) + return features @staticmethod def from_feature_map_to_features(feature_map: FeatureMap) -> Features: @@ -115,7 +122,10 @@ def from_features(cls, features: Features) -> List[InstructionT]: Walks the envelopes frame by frame and assembles one instruction per frame. Every envelope is read relative to itself — a dimension trimmed shorter than the sequence holds its own final value over the remaining frames — so the arpeggio stays an - offset from ``initial_pitch`` for the whole sequence. + offset from ``initial_pitch`` for the whole sequence. A dimension the instrument + leaves to the channel carries no item, and every frame states the value a channel + holds for it from the start of a song, which is what the sequence sounds like played + on its own. Args: features: The envelope representation of a channel. @@ -139,7 +149,13 @@ def from_features(cls, features: Features) -> List[InstructionT]: if not attribute: continue - instruction_dictionary[attribute] = int(hold(array, index, default=EMPTY_ENVELOPE_VALUE)) + instruction_dictionary[attribute] = int( + hold( + array, + index, + default=CHANNEL_FEATURE_DEFAULTS[key], + ) + ) instructions.append(cls._features_dictionary_to_instruction(instruction_dictionary, initial_pitch)) diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index 33634cce3..450de014e 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Dict, List, Optional, Tuple, cast +from typing import Any, Dict, Iterable, List, Optional, Tuple, cast import numpy as np from pydantic import BaseModel, ConfigDict @@ -15,9 +15,11 @@ class Features(BaseModel): Each field is the frame-by-frame envelope for one dimension — volume, arpeggio, pitch, hi-pitch, and duty cycle — alongside the ``initial_pitch`` the arpeggio - envelope is relative to. An optional dimension is absent when the channel does not - use it. The mapping interface (subscript, ``get``, ``keys``/``items``/``values``, - ``in``) exposes the envelopes keyed by :class:`FeatureKey`, passing over absent ones. + envelope is relative to. A dimension the channel offers is an array, ``None`` for + one it lacks; an array of no items marks a dimension the instrument leaves to the + channel, which keeps the value it holds. The mapping interface (subscript, ``get``, + ``keys``/``items``/``values``, ``in``) exposes the envelopes keyed by + :class:`FeatureKey`, listing the dimensions the channel offers. Attributes: initial_pitch: Reference pitch the arpeggio envelope is measured against. @@ -106,3 +108,22 @@ def frame_count(self) -> int: """The frame count the envelopes describe, taken from the longest populated dimension.""" arrays = (self.volume, self.arpeggio, self.pitch, self.hi_pitch, self.duty_cycle) return max((len(array) for array in arrays if array is not None), default=0) + + @property + def held_features(self) -> Tuple[FeatureKey, ...]: + """The dimensions the channel governs, whose envelopes carry no item. + + An instrument writes the dimensions it describes and leaves the rest to the channel, + which keeps the value it already holds for as long as the instrument sounds. These + are the dimensions it leaves, listed in the order the model declares them. + """ + return tuple(key for key, value in self.items() if isinstance(value, np.ndarray) and value.size == 0) + + def leave_to_channel(self, feature_keys: Iterable[FeatureKey]) -> None: + """Empties the given dimensions' envelopes, so the channel governs them. + + Args: + feature_keys: The dimensions the instrument leaves to the channel. + """ + for feature_key in feature_keys: + self[feature_key] = np.array([], dtype=np.int8) diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py index e2027c6fa..f4d98233a 100644 --- a/src/sampletones_core/features/__init__.py +++ b/src/sampletones_core/features/__init__.py @@ -1,4 +1,5 @@ from .spec import ( + CHANNEL_FEATURE_DEFAULTS, FEATURE_DIMENSION_ORDER, GENERATOR_FEATURE_RANGES, GENERATOR_KIND, @@ -9,6 +10,7 @@ ) __all__ = [ + "CHANNEL_FEATURE_DEFAULTS", "FEATURE_DIMENSION_ORDER", "GENERATOR_FEATURE_RANGES", "GENERATOR_KIND", diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index 132662b48..c9f7e9f59 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -27,6 +27,15 @@ class FeatureRange: ) +CHANNEL_FEATURE_DEFAULTS: Final[Dict[FeatureKey, int]] = { + FeatureKey.VOLUME: MAX_VOLUME, + FeatureKey.ARPEGGIO: 0, + FeatureKey.PITCH: 0, + FeatureKey.HI_PITCH: 0, + FeatureKey.DUTY_CYCLE: 0, +} + + GENERATOR_FEATURE_RANGES: Final[Dict[LibraryGeneratorName, Dict[FeatureKey, FeatureRange]]] = { LibraryGeneratorName.PULSE: { FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME), diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py index be2663e5e..51788d609 100644 --- a/src/sampletones_core/reconstructions/reconstruction/instructions.py +++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py @@ -1,10 +1,10 @@ from __future__ import annotations -from typing import List +from typing import Iterable, List from pydantic import ConfigDict, Field -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import DataModel from sampletones_core.instructions import InstructionData, InstructionUnion @@ -24,6 +24,10 @@ class InstructionsItem(DataModel): ..., description="Reference pitch the generator's arpeggio envelope is measured against", ) + held_features: List[FeatureKey] = Field( + ..., + description="Dimensions the channel governs, keeping the value it holds while the generator sounds", + ) @classmethod def create( @@ -31,6 +35,7 @@ def create( generator_name: GeneratorName, instructions: List[InstructionUnion], initial_pitch: int, + held_features: Iterable[FeatureKey], ) -> InstructionsItem: return InstructionsItem( generator_name=generator_name, @@ -42,4 +47,5 @@ def create( for instruction in instructions ], initial_pitch=initial_pitch, + held_features=list(held_features), ) diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index c4c882ce4..e6a46954a 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -3,14 +3,14 @@ import struct from functools import cached_property from pathlib import Path -from typing import Any, Dict, Final, List, Mapping, Optional, Self, Sequence +from typing import Any, Dict, Final, Iterable, List, Mapping, Optional, Self, Sequence, Tuple from uuid import uuid4 import numpy as np from pydantic import ConfigDict, Field, ValidationError, field_serializer from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import DataModel, Metadata, MetadataContract from sampletones_core.exporters import ( GENERATOR_NAME_TO_EXPORTER_MAP, @@ -99,6 +99,16 @@ def initial_pitches(self) -> Dict[GeneratorName, int]: """The reference pitch each generator's arpeggio envelope is measured against.""" return {item.generator_name: item.initial_pitch for item in self.instructions_data} + @cached_property + def held_features(self) -> Dict[GeneratorName, Tuple[FeatureKey, ...]]: + """The dimensions each generator leaves to the channel. + + An instruction states every dimension of its frame, so which of them the instrument + itself writes is stated here: the rest are the channel's, and an export leaves their + envelopes empty for the player to fill from the value it holds. + """ + return {item.generator_name: tuple(item.held_features) for item in self.instructions_data} + @staticmethod def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion: return INSTRUCTION_TO_EXPORTER_MAP[type(instruction)] @@ -144,6 +154,7 @@ def create( generator_name=generator_name, instructions=channel_instructions, initial_pitch=cls._derive_initial_pitch(generator_name, channel_instructions), + held_features=(), ) ) @@ -187,11 +198,14 @@ def update_generator_data( instructions: List[InstructionUnion], partial_approximation: np.ndarray, initial_pitch: int, + held_features: Iterable[FeatureKey], ) -> None: - """Replaces one generator's instructions, audio, and reference pitch. + """Replaces one generator's instructions, audio, reference pitch, and held dimensions. The reference pitch travels with the instructions it produced, so a later export - measures the arpeggio against the same base the edit was made from. + measures the arpeggio against the same base the edit was made from. The held + dimensions travel with them for the same reason: the frames state a value for every + dimension, and this is what says which of them the instrument itself wrote. """ partial_approximation = np.trim_zeros(partial_approximation, trim="b") max_length = max( @@ -210,6 +224,7 @@ def update_generator_data( generator_name=generator_name, instructions=instructions, initial_pitch=initial_pitch, + held_features=held_features, ) if item.generator_name == generator_name else item @@ -317,6 +332,7 @@ def _invalidate_derived_caches(reconstruction: Reconstruction) -> None: reconstruction.__dict__.pop("approximations", None) reconstruction.__dict__.pop("instructions", None) reconstruction.__dict__.pop("initial_pitches", None) + reconstruction.__dict__.pop("held_features", None) @classmethod def load(cls, path: Pathlike, fast: bool = True) -> Reconstruction: @@ -387,6 +403,7 @@ def export(self) -> Dict[GeneratorName, Features]: feature: Features = exporter.to_features( instructions, # type: ignore[arg-type] self.initial_pitches[name], + self.held_features[name], ) features[name] = feature diff --git a/tests/integration/sampletones_application/services/conftest.py b/tests/integration/sampletones_application/services/conftest.py index 140ca4120..10db4394d 100644 --- a/tests/integration/sampletones_application/services/conftest.py +++ b/tests/integration/sampletones_application/services/conftest.py @@ -36,6 +36,7 @@ def pulse_features(pulse_instructions) -> Features: return PulseExporter().to_features( pulse_instructions, PulseExporter.derive_initial_pitch(pulse_instructions), + (), ) diff --git a/tests/unit/sampletones_application/logic/project/test_controller.py b/tests/unit/sampletones_application/logic/project/test_controller.py index bde1c6425..54dfae4de 100644 --- a/tests/unit/sampletones_application/logic/project/test_controller.py +++ b/tests/unit/sampletones_application/logic/project/test_controller.py @@ -580,6 +580,7 @@ def test_in_place_reconstruction_edit_is_visible_through_project( new_instructions, np.zeros(64, dtype=np.float32), 72, + (), ) stored = controller.project.sample(sample.id).reconstruction diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 33350b890..26f92df0f 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -1,6 +1,6 @@ import threading from types import SimpleNamespace -from typing import Any, Callable, Dict, Final, Iterator, List, TypeAlias, cast +from typing import Any, Callable, Dict, Final, Iterator, List, Tuple, TypeAlias, cast from unittest.mock import MagicMock, patch import numpy as np @@ -26,13 +26,18 @@ class FakeFeatures(Dict[Any, Any]): """Stands in for ``Features``: records the edited dimension and carries a reference pitch. Assigning ``FeatureKey.INITIAL_PITCH`` moves the reference pitch, matching the real model, - so the pitch stepper's edit is observable through ``initial_pitch``. + so the pitch stepper's edit is observable through ``initial_pitch``. The dimensions left to + the channel are read the same way the real model reports them: those whose envelope is empty. """ def __init__(self, initial_pitch: int) -> None: super().__init__() self.initial_pitch = initial_pitch + @property + def held_features(self) -> Tuple[FeatureKey, ...]: + return tuple(key for key, value in self.items() if isinstance(value, np.ndarray) and value.size == 0) + def __setitem__(self, feature_key: Any, value: Any) -> None: if feature_key == FeatureKey.INITIAL_PITCH: self.initial_pitch = value diff --git a/tests/unit/sampletones_core/exporters/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py index 950135da1..349a4049f 100644 --- a/tests/unit/sampletones_core/exporters/test_exporter.py +++ b/tests/unit/sampletones_core/exporters/test_exporter.py @@ -1,10 +1,11 @@ from dataclasses import dataclass -from typing import Any, Callable, Final, List, Sequence +from typing import Any, Callable, Final, List, Optional, Sequence, Tuple import numpy as np import pytest from sampletones_core.constants.enums import FeatureKey +from sampletones_core.constants.general import MAX_VOLUME from sampletones_core.exporters import ( ExporterTypeUnion, Features, @@ -40,6 +41,37 @@ def _read_period(instruction: Any) -> int: return period +def _read_volume(instruction: Any) -> int: + volume: int = instruction.volume + return volume + + +def _read_duty_cycle(instruction: Any) -> int: + duty_cycle: int = instruction.duty_cycle + return duty_cycle + + +def _read_short(instruction: Any) -> int: + return int(instruction.short) + + +def _features( + *, + initial_pitch: int, + volume: Tuple[int, ...], + arpeggio: Tuple[int, ...], + duty_cycle: Optional[Tuple[int, ...]], +) -> Features: + return Features( + initial_pitch=initial_pitch, + volume=np.array(volume, dtype=np.int8), + arpeggio=np.array(arpeggio, dtype=np.int8), + pitch=None, + hi_pitch=None, + duty_cycle=None if duty_cycle is None else np.array(duty_cycle, dtype=np.int8), + ) + + def _pulse_line(pitch: int) -> List[PulseInstruction]: return [PulseInstruction(on=True, pitch=pitch, volume=PULSE_VOLUME, duty_cycle=0) for _ in range(SOUNDING_FRAMES)] @@ -103,7 +135,11 @@ class TestCase(BaseRegularTestCase): @staticmethod def _export(test_case: TestCase, instructions: Sequence[InstructionUnion]) -> Features: - return test_case.exporter().to_features(list(instructions), test_case.expected) + return test_case.exporter().to_features( + list(instructions), + test_case.expected, + (), + ) @classmethod def _edited(cls, test_case: TestCase) -> List[InstructionUnion]: @@ -263,3 +299,143 @@ def test_audible_frames_stay_audible(self, test_case: TestCase) -> None: assert instructions[0].on is True assert instructions[-1].on is False + + +class TestChannelHeldDimensions(BaseTestSuite): + """A dimension left to the channel sounds at the value a channel holds from a song's start. + + An instruction states every dimension of its frame, so rebuilding a sequence from envelopes + that leave one out still has to state it. The value stated is the channel's own — full volume, + no arpeggio offset, the first timbre — which is what the instrument sounds like played alone. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + exporter: ExporterTypeUnion + features: Features + read_value: Callable[[Any], int] + expected: int + + test_cases = ( + TestCase( + label="pulse_volume", + exporter=PulseExporter, + features=_features( + initial_pitch=REFERENCE_PITCH, + volume=(), + arpeggio=(0, 0, 0), + duty_cycle=(1,), + ), + read_value=_read_volume, + expected=MAX_VOLUME, + ), + TestCase( + label="pulse_duty_cycle", + exporter=PulseExporter, + features=_features( + initial_pitch=REFERENCE_PITCH, + volume=(PULSE_VOLUME, PULSE_VOLUME, 0), + arpeggio=(0,), + duty_cycle=(), + ), + read_value=_read_duty_cycle, + expected=0, + ), + TestCase( + label="noise_volume", + exporter=NoiseExporter, + features=_features( + initial_pitch=REFERENCE_PERIOD, + volume=(), + arpeggio=(0, 0, 0), + duty_cycle=(0,), + ), + read_value=_read_volume, + expected=MAX_VOLUME, + ), + TestCase( + label="noise_mode", + exporter=NoiseExporter, + features=_features( + initial_pitch=REFERENCE_PERIOD, + volume=(NOISE_VOLUME, NOISE_VOLUME, 0), + arpeggio=(0,), + duty_cycle=(), + ), + read_value=_read_short, + expected=0, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_every_frame_states_the_value_the_channel_holds(self, test_case: TestCase) -> None: + instructions = test_case.exporter.from_features(test_case.features) + + assert [test_case.read_value(instruction) for instruction in instructions] == [test_case.expected] * len( + instructions + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_written_dimensions_set_the_frame_count(self, test_case: TestCase) -> None: + instructions = test_case.exporter.from_features(test_case.features) + + assert len(instructions) == test_case.features.frame_count + + +class TestHeldDimensionRoundTrip: + """A dimension the channel governs comes back empty, telling it apart from one holding a zero.""" + + def test_a_held_dimension_comes_back_empty(self) -> None: + features = _features( + initial_pitch=REFERENCE_PITCH, + volume=(PULSE_VOLUME, PULSE_VOLUME, 0), + arpeggio=(), + duty_cycle=(1,), + ) + instructions = PulseExporter.from_features(features) + + exported = PulseExporter().to_features( + instructions, + REFERENCE_PITCH, + features.held_features, + ) + + assert exported.arpeggio.size == 0 + assert exported.held_features == (FeatureKey.ARPEGGIO,) + + def test_a_written_dimension_comes_back_with_its_items(self) -> None: + features = _features( + initial_pitch=REFERENCE_PITCH, + volume=(PULSE_VOLUME, PULSE_VOLUME, 0), + arpeggio=(), + duty_cycle=(1,), + ) + instructions = PulseExporter.from_features(features) + + exported = PulseExporter().to_features( + instructions, + REFERENCE_PITCH, + features.held_features, + ) + + assert exported.volume.tolist() == [PULSE_VOLUME, PULSE_VOLUME, 0] + assert exported.duty_cycle is not None + assert exported.duty_cycle.tolist() == [1] + + def test_an_instrument_holding_every_dimension_describes_no_frame(self) -> None: + features = _features( + initial_pitch=REFERENCE_PITCH, + volume=(), + arpeggio=(), + duty_cycle=(), + ) + + assert PulseExporter.from_features(features) == [] diff --git a/tests/unit/sampletones_core/exporters/test_feature.py b/tests/unit/sampletones_core/exporters/test_feature.py index 192f830e1..ce4501e9a 100644 --- a/tests/unit/sampletones_core/exporters/test_feature.py +++ b/tests/unit/sampletones_core/exporters/test_feature.py @@ -2,6 +2,7 @@ import numpy as np +from sampletones_core.constants.enums import FeatureKey from sampletones_core.exporters import Features @@ -26,3 +27,26 @@ def test_absent_dimensions_leave_the_count_to_the_others(self) -> None: def test_empty_envelopes_count_no_frames(self) -> None: assert build_features(0).frame_count == 0 + + +class TestHeldFeatures: + """The dimensions an instrument leaves to the channel, read off the envelopes.""" + + def test_an_instrument_writing_every_dimension_leaves_none(self) -> None: + assert build_features(8, duty_cycle_frames=8).held_features == () + + def test_an_empty_envelope_marks_a_dimension_the_channel_governs(self) -> None: + features = build_features(8, duty_cycle_frames=8) + features[FeatureKey.ARPEGGIO] = np.array([], dtype=np.int8) + assert features.held_features == (FeatureKey.ARPEGGIO,) + + def test_a_dimension_the_channel_lacks_stays_out_of_the_listing(self) -> None: + """The triangle channel offers no duty cycle, which is a different absence.""" + assert build_features(8).held_features == () + + def test_leaving_a_dimension_to_the_channel_empties_its_envelope(self) -> None: + features = build_features(8, duty_cycle_frames=8) + features.leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE)) + assert features.volume.size == 0 + assert features.duty_cycle is not None and features.duty_cycle.size == 0 + assert features.held_features == (FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE) diff --git a/tests/unit/sampletones_core/formats/famitracker/test_builder.py b/tests/unit/sampletones_core/formats/famitracker/test_builder.py index d5c66db34..0d04f2812 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_builder.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_builder.py @@ -69,6 +69,7 @@ def test_slot_keeps_its_pitch_after_an_arpeggio_edit(self, project_fixture: Proj arpeggiated, np.ones(RECONSTRUCTION_LENGTH, dtype=np.float32), LEAD_PITCH, + (), ) instruments, slots = build_instrument_table(project_fixture.project) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 2f3e92487..3260166ef 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -7,7 +7,7 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import Metadata from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction @@ -247,6 +247,7 @@ def test_export_measures_the_arpeggio_against_the_stored_reference(self) -> None arpeggiated, np.ones(_AUDIO_LENGTH, dtype=np.float32), _BASE_PITCH, + (), ) features = reconstruction.export()[GeneratorName.PULSE1] @@ -262,6 +263,7 @@ def test_update_generator_data_replaces_the_reference(self) -> None: [_pulse(_RESET_PITCH)], np.ones(_AUDIO_LENGTH, dtype=np.float32), _RESET_PITCH, + (), ) assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _RESET_PITCH @@ -276,6 +278,65 @@ def test_reference_survives_a_save_load_round_trip(self, tmp_path: Path) -> None assert loaded.initial_pitches == reconstruction.initial_pitches +class TestHeldFeatures: + """The dimensions each generator leaves to the channel travel with its instructions. + + A frame states every dimension, so an export reads which of them the instrument itself + wrote from the reconstruction rather than from the frames. + """ + + def test_a_fresh_reconstruction_writes_every_dimension(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + assert reconstruction.held_features[GeneratorName.PULSE1] == () + + def test_a_held_dimension_exports_an_empty_envelope(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) + + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [_pulse(_BASE_PITCH)] * 3, + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _BASE_PITCH, + (FeatureKey.ARPEGGIO,), + ) + + features = reconstruction.export()[GeneratorName.PULSE1] + assert features.arpeggio.size == 0 + assert features.volume.size > 0 + + def test_the_written_dimensions_export_their_items(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) + + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [_pulse(_BASE_PITCH)] * 3, + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _BASE_PITCH, + (FeatureKey.ARPEGGIO,), + ) + + features = reconstruction.export()[GeneratorName.PULSE1] + assert features.duty_cycle is not None + assert features.duty_cycle.size > 0 + + def test_held_dimensions_survive_a_save_load_round_trip(self, tmp_path: Path) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [_pulse(_BASE_PITCH)] * 3, + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _BASE_PITCH, + (FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), + ) + path = tmp_path / "held.stn" + + reconstruction.save(path) + loaded = Reconstruction.load(path) + + assert loaded.held_features == reconstruction.held_features + + class TestWithNesFrequency: def test_rebuilds_config(self, reconstruction_factory: ReconstructionFactory) -> None: reconstruction = reconstruction_factory() From b04453b17b3cd09ec7d7528dacdd2842509ea852 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 21:25:22 +0200 Subject: [PATCH 098/152] Added: every channel present in a reconstruction --- docs/formats/famitracker.md | 4 + docs/formats/reconstructions.md | 11 +- .../coordinators/tabs/reconstruction.py | 2 +- .../logic/reconstruction/feature.py | 12 +- .../logic/reconstruction/instruments.py | 28 +-- .../logic/reconstruction/reconstruction.py | 64 +++++-- src/sampletones_application/tags/general.py | 6 + .../reconstruction/instruments/instruments.py | 49 ++++-- .../ui/panels/reconstruction/plot.py | 16 +- .../view_model/reconstruction/instruments.py | 9 +- .../reconstruction/reconstruction.py | 10 +- .../tabs.yaml} | 0 .../theme/instruments/tabs_muted.yaml | 21 +++ .../theme/panel/instrument.yaml | 3 + src/sampletones_core/exporters/feature.py | 10 ++ src/sampletones_core/exporters/slices.py | 12 +- src/sampletones_core/features/__init__.py | 6 + src/sampletones_core/features/spec.py | 25 +++ .../formats/famitracker/footprint.py | 11 +- .../reconstruction/instructions.py | 22 +++ .../reconstruction/reconstruction.py | 165 ++++++++++++------ tests/integration/assets/reconstruction.py | 8 +- .../logic/reconstruction/test_feature.py | 24 +-- .../logic/reconstruction/test_instruments.py | 16 +- .../reconstruction/test_reconstruction.py | 98 +++++++++++ .../reconstruction/test_instruments_panel.py | 82 +++++++-- .../ui/panels/reconstruction/test_plot.py | 97 ++++++++++ .../reconstruction/test_reconstruction.py | 3 +- .../sampletones_core/exporters/test_slices.py | 72 ++++++++ .../formats/famitracker/test_footprint.py | 7 +- .../reconstruction/test_reconstruction.py | 90 ++++++++++ 31 files changed, 831 insertions(+), 152 deletions(-) rename src/sampletones_config/theme/{instrument_tabs.yaml => instruments/tabs.yaml} (100%) create mode 100644 src/sampletones_config/theme/instruments/tabs_muted.yaml create mode 100644 tests/unit/sampletones_core/exporters/test_slices.py diff --git a/docs/formats/famitracker.md b/docs/formats/famitracker.md index 38b22c3b9..b528b94f5 100644 --- a/docs/formats/famitracker.md +++ b/docs/formats/famitracker.md @@ -168,6 +168,10 @@ instruments panel puts it in (see [Reconstructions](reconstructions.md)). **How _SampleToNES_ fills an instrument.** Each generator slice of a sample's reconstruction becomes one instrument, so a sample yields one to four instruments. +A reconstruction holds a stream for every channel, and one describing no frame is a +channel standing by (see [Reconstructions](reconstructions.md#contents)): it takes no +place in the instrument table, so the instruments an export writes are the channels +that play. The arpeggio sequence carries the reconstruction's pitch contour as signed offsets, and triggering the instrument at `initial_pitch` replays that contour. Volume, duty (or noise mode) and any pitch sequences carry across directly. The DPCM diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index cea07b3a7..4eee98e17 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -26,10 +26,14 @@ A `.stn` file holds: * **approximation** — the rendered NES audio: the sum of every channel's output, the closest match to the original; * **per-channel approximations** — the audio each channel contributes on its own, - one waveform per enabled channel (`pulse1`, `pulse2`, `triangle`, `noise`); + one waveform per channel that sounds; * **per-channel instructions** — the instruction stream each channel plays, one [instruction](../glossary.md#instruction) per frame. This is the data a - FamiTracker export is built from; + FamiTracker export is built from. A reconstruction holds a stream for every one + of the four channels (`pulse1`, `pulse2`, `triangle`, `noise`), and a stream of + no frames is a channel standing by: it is written by no export and costs + nothing, while staying open to edit, so writing an envelope into it puts the + channel in play and clearing every envelope takes it out again; * **per-channel reference pitch** — the note each channel's arpeggio offsets are measured against, chosen once when the reconstruction is built and stored with the instructions it describes. An export reads the offsets against this pitch, @@ -42,6 +46,9 @@ A `.stn` file holds: built reconstruction writes them all, and clearing an envelope in the instruments panel adds that dimension here. +A channel standing by rests at a reference pitch of its own, so the first envelope +written into it sounds on a mid-range note. + ## Detached reconstructions A reconstruction normally remembers the path to its source audio. Embedding one diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index b11a114a5..5053f44c8 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -611,7 +611,7 @@ def _remove_directory(self, directory: Path) -> None: def update_reconstruction(self) -> None: self._reconstruction_panel_logic.update_reconstruction() - self._reconstruction_instruments_logic.refresh_footprint() + self._reconstruction_instruments_logic.refresh_view() def set_reconstruction_dimmed(self, dimmed: bool) -> None: self._reconstruction_plot_panel.set_reconstruction_dimmed(dimmed) diff --git a/src/sampletones_application/logic/reconstruction/feature.py b/src/sampletones_application/logic/reconstruction/feature.py index 430dd9725..a6a653f61 100644 --- a/src/sampletones_application/logic/reconstruction/feature.py +++ b/src/sampletones_application/logic/reconstruction/feature.py @@ -12,6 +12,12 @@ @dataclass(frozen=True) class FeatureData: + """The envelopes of every channel a reconstruction holds, keyed by channel. + + A reconstruction exports one entry per channel whatever it sounds, so a subscript answers + for any of them and :attr:`Features.has_frames` says which ones play. + """ + generators: Dict[GeneratorName, Features] def __getitem__(self, generator_name: GeneratorName) -> Features: @@ -36,9 +42,3 @@ def load(cls, reconstruction: Reconstruction) -> FeatureData: generators[generator_name] = feature return cls(generators=generators) - - def get_generator_features( - self, - generator_name: GeneratorName, - ) -> Optional[Features]: - return self.generators.get(generator_name) diff --git a/src/sampletones_application/logic/reconstruction/instruments.py b/src/sampletones_application/logic/reconstruction/instruments.py index ac259d1c8..aec69a882 100644 --- a/src/sampletones_application/logic/reconstruction/instruments.py +++ b/src/sampletones_application/logic/reconstruction/instruments.py @@ -47,12 +47,12 @@ def update_display(self) -> None: self.call(self.on_view_changed, self._build_view_model(generators)) self.call(self.on_feature_data_changed, generators) - def refresh_footprint(self) -> None: - """Reports the sizes the loaded envelopes occupy, leaving the displayed envelopes as they are. + def refresh_view(self) -> None: + """Reports which channels play and the sizes they occupy, leaving the displayed envelopes as they are. - A regeneration replaces what an instrument exports, so the byte figures settle on it. The - envelopes themselves are left to the edit that started the regeneration, so a field the - user is still typing in keeps what they wrote. + A regeneration replaces what an instrument exports, so the byte figures and the standing-by + channels settle on it. The envelopes themselves are left to the edit that started the + regeneration, so a field the user is still typing in keeps what they wrote. """ self.call(self.on_view_changed, self._build_view_model(self._current_generators())) @@ -67,14 +67,16 @@ def _build_view_model( if generators is None: return ReconstructionInstrumentsViewModel( reconstruction_loaded=False, - available_generators=frozenset(), + playing_generators=frozenset(), footprint=None, ) - available_generators: FrozenSet[GeneratorName] = frozenset(generators.keys()) + playing_generators: FrozenSet[GeneratorName] = frozenset( + generator_name for generator_name, features in generators.items() if features.has_frames + ) return ReconstructionInstrumentsViewModel( reconstruction_loaded=True, - available_generators=available_generators, + playing_generators=playing_generators, footprint=self._build_footprint(generators), ) @@ -82,16 +84,18 @@ def _build_footprint( self, generators: Dict[GeneratorName, Features], ) -> SampleFootprintViewModel: - """Measures each channel's instrument as the size its own export writes. + """Measures each playing channel's instrument as the size its own export writes. A reconstruction has no loop flag of its own — that belongs to a sample placed in a project — so each instrument is measured playing its envelopes once, matching what - **Export instrument...** produces. + **Export instrument...** produces. A channel standing by is written nowhere, so it is + measured nowhere and the sample's total names what the export costs. """ return SampleFootprintViewModel.from_footprints( { generator_name: features_footprint(features, loop=False) for generator_name, features in generators.items() + if features.has_frames } ) @@ -214,6 +218,4 @@ def _get_features(self, generator_name: GeneratorName) -> Features: current_features = self.reconstruction_manager.current_features assert current_features is not None, "Current features should not be None" - features = current_features.get_generator_features(generator_name) - assert features is not None, f"Features for generator {generator_name} should not be None" - return features + return current_features[generator_name] diff --git a/src/sampletones_application/logic/reconstruction/reconstruction.py b/src/sampletones_application/logic/reconstruction/reconstruction.py index ab4a51d3e..39e0a7353 100644 --- a/src/sampletones_application/logic/reconstruction/reconstruction.py +++ b/src/sampletones_application/logic/reconstruction/reconstruction.py @@ -70,6 +70,7 @@ def __init__( self._tracker_backends = tracker_backends self._current_audio_source: AudioSourceType = AudioSourceType.RECONSTRUCTION + self._playing_generators: FrozenSet[GeneratorName] = frozenset() self._selected_generators: List[GeneratorName] = [] self.on_view_changed: Optional[Callable[[ReconstructionViewModel], None]] = None @@ -90,18 +91,10 @@ def display_reconstruction(self) -> None: if not reconstruction_data: return - available_generators: FrozenSet[GeneratorName] = frozenset( - reconstruction_data.reconstruction.instructions.keys() - ) - self._selected_generators = list(available_generators) + self._playing_generators = frozenset(reconstruction_data.reconstruction.playing_generators) + self._selected_generators = self._in_channel_order(self._playing_generators) - reconstruction_file, original_audio = self._build_path_view_models(reconstruction_data) - view_model = ReconstructionViewModel( - reconstruction_loaded=True, - available_generators=available_generators, - reconstruction_file=reconstruction_file, - original_audio=original_audio, - ) + view_model = self._build_view_model(reconstruction_data) if not view_model.audio_source_enabled: self._current_audio_source = AudioSourceType.RECONSTRUCTION @@ -119,6 +112,9 @@ def update_reconstruction(self) -> None: if not reconstruction_data: return + self._adopt_playing_generators(frozenset(reconstruction_data.reconstruction.playing_generators)) + + self.call(self.on_view_changed, self._build_view_model(reconstruction_data)) self.call( self.on_waveform_update_changed, reconstruction_data.waveform_data(), @@ -127,8 +123,42 @@ def update_reconstruction(self) -> None: if self._current_audio_source != AudioSourceType.ORIGINAL: self._emit_audio_data() + def _adopt_playing_generators( + self, + playing_generators: FrozenSet[GeneratorName], + ) -> None: + """Carries the reader's choice of channels across an edit. + + An edit puts a channel in play or takes it out. A channel that keeps playing keeps + whatever the reader chose for it, and one gaining its first frame joins the waveform, + so the checkboxes report what plays while a deliberate choice survives. + """ + selected = (set(self._selected_generators) & playing_generators) | ( + playing_generators - self._playing_generators + ) + self._playing_generators = playing_generators + self._selected_generators = self._in_channel_order(frozenset(selected)) + + @staticmethod + def _in_channel_order(generators: FrozenSet[GeneratorName]) -> List[GeneratorName]: + return [generator_name for generator_name in GeneratorName.items() if generator_name in generators] + + def _build_view_model( + self, + reconstruction_data: ReconstructionData, + ) -> ReconstructionViewModel: + reconstruction_file, original_audio = self._build_path_view_models(reconstruction_data) + return ReconstructionViewModel( + reconstruction_loaded=True, + playing_generators=self._playing_generators, + selected_generators=frozenset(self._selected_generators), + reconstruction_file=reconstruction_file, + original_audio=original_audio, + ) + def close_reconstruction(self) -> None: self._current_audio_source = AudioSourceType.RECONSTRUCTION + self._playing_generators = frozenset() self._selected_generators = [] self.call(self.on_audio_data_changed, None) self.call(self.on_waveform_cleared) @@ -140,7 +170,8 @@ def close_reconstruction(self) -> None: self.on_view_changed, ReconstructionViewModel( reconstruction_loaded=False, - available_generators=frozenset(), + playing_generators=frozenset(), + selected_generators=frozenset(), reconstruction_file=empty_path, original_audio=empty_path, ), @@ -182,8 +213,7 @@ def request_export_instrument_dialog( if not reconstruction_data: raise AssertionError("Expected reconstruction data to be loaded before exporting an instrument") - feature_data = reconstruction_data.feature_data - if generator_name not in feature_data.generators: + if generator_name not in reconstruction_data.reconstruction.playing_generators: return instrument_name = self._get_instrument_name(generator_name) @@ -267,11 +297,12 @@ def handle_export_instruments_confirmed( destination: Path, tracker_format: TrackerFormat, ) -> None: - """Writes every generator slice of the loaded reconstruction to ``destination``. + """Writes the slice of every playing channel of the loaded reconstruction to ``destination``. The destination names the batch: each slice takes its generator suffix from the stem, so a format gathering the whole reconstruction into one document writes it there while - one keeping an instrument per file writes its slices beside it. + one keeping an instrument per file writes its slices beside it. A channel standing by + describes no frame and is written nowhere. Args: destination: The file the export was confirmed with. @@ -292,6 +323,7 @@ def handle_export_instruments_confirmed( instrument_slice_name(base_name, generator_name), ) for generator_name, feature in reconstruction_data.feature_data.generators.items() + if feature.has_frames ), nes_frequency=self._nes_frequency(), ) diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 34749614d..da52f4903 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -182,6 +182,12 @@ Widget.THEME, "instrument_tabs", ) +TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "instrument_tabs_muted", +) TAG_GLOBAL_THEME_PANEL_INSTRUMENT = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index 703e998f8..c3766a7f3 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -18,6 +18,7 @@ TAG_GLOBAL_THEME_INPUT_INVALID, TAG_GLOBAL_THEME_INPUT_WARNING, TAG_GLOBAL_THEME_INSTRUMENT_TABS, + TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, TAG_GLOBAL_THEME_PANEL_INSTRUMENT, ) from sampletones_application.tags.graphs import ( @@ -67,9 +68,8 @@ GeneratorName, LibraryGeneratorName, ) -from sampletones_core.constants.general import MAX_PERIOD, MIN_PITCH from sampletones_core.exporters import Features -from sampletones_core.features import GENERATOR_KIND, supported_features +from sampletones_core.features import GENERATOR_KIND, resting_reference, supported_features from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) @@ -104,6 +104,7 @@ def __init__( self.generator_plots: Dict[GeneratorName, Dict[FeatureKey, GUIBarGraph]] = {} self._pitch_steppers: Dict[GeneratorName, GUIPitchStepper] = {} + self._export_buttons: Dict[GeneratorName, GUIButton] = {} self.tab_bar_tag = TAG_RECONSTRUCTIONS_INSTRUMENTS_TABS_BAR self.no_data_message_tag = compose_tag(self.tab_bar_tag, SUF_RECONSTRUCTIONS_INSTRUMENTS_NO_DATA_MESSAGE) @@ -306,7 +307,7 @@ def _create_generator_tab(self, generator_name: GeneratorName) -> None: ): self.generator_plots[generator_name] = {} button_tag = compose_tag(TAG_RECONSTRUCTIONS_INSTRUMENTS_BUTTON_EXPORT_INSTRUMENT, tab_tag) - GUIButton( + self._export_buttons[generator_name] = GUIButton( tag=button_tag, parent=tab_tag, label=self._language_manager["reconstructions.instruments.label.export_instrument_button"], @@ -346,7 +347,7 @@ def _create_generator_content( self._create_generator_feature_displays(generator_name, window_tag) def _default_initial_pitch(self, generator_name: GeneratorName) -> int: - return MAX_PERIOD if generator_name == GeneratorName.NOISE else MIN_PITCH + return resting_reference(generator_name) def _create_generator_feature_displays( self, @@ -435,6 +436,12 @@ def update_view( self, view_model: ReconstructionInstrumentsViewModel, ) -> None: + """Shows a tab per channel, marking the ones standing by. + + Every channel is editable for as long as a reconstruction is open, so writing an + envelope into a channel standing by is what puts it in play. A muted tab label and a + withheld export say which channels are there. + """ is_loaded = view_model.reconstruction_loaded dpg_configure_item(self.no_data_message_tag, show=not is_loaded) dpg_configure_item(self.tab_bar_tag, show=is_loaded) @@ -443,26 +450,46 @@ def update_view( for generator_name in GeneratorName.items(): tab_tag = self._get_generator_tab_tag(generator_name) - is_available = generator_name in view_model.available_generators - dpg_configure_item(tab_tag, show=is_available) + dpg_configure_item(tab_tag, show=is_loaded) + self._apply_playing_state( + generator_name, + generator_name in view_model.playing_generators, + ) + + def _apply_playing_state( + self, + generator_name: GeneratorName, + is_playing: bool, + ) -> None: + """Marks one channel's tab as playing or standing by. + + The muted theme reaches the tab label alone; the tab's body carries its own text colour, + so a channel standing by stays as readable to edit as one that plays. + """ + theme_tag = TAG_GLOBAL_THEME_INSTRUMENT_TABS if is_playing else TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED + ThemeRegistry.get(theme_tag).bind_to_item(self._get_generator_tab_tag(generator_name)) + + export_button = self._export_buttons.get(generator_name) + if export_button is not None: + export_button.set_enabled(is_playing) def _update_sizes( self, footprint: Optional[SampleFootprintViewModel], ) -> None: - """Writes the byte figures the loaded reconstruction occupies, the sample's and each channel's.""" + """Writes the byte figures the loaded reconstruction occupies, the sample's and each channel's. + + A channel standing by is written by no export, so it reads as the nothing it costs. + """ if footprint is None: return dpg_set_value(self.sample_size_tag, self._format_size(footprint.total_bytes)) for generator_name in GeneratorName.items(): instrument_bytes = footprint.bytes_for(generator_name) - if instrument_bytes is None: - continue - dpg_set_value( self._get_instrument_size_tag(generator_name), - self._format_size(instrument_bytes), + self._format_size(instrument_bytes if instrument_bytes is not None else 0), ) def _format_size(self, byte_count: int) -> str: diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py index 617d96cfa..be4006933 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -86,16 +86,22 @@ def create_panel(self, parent: str) -> None: self._create_tooltips() def update_view(self, view_model: ReconstructionViewModel) -> None: + """Offers a checkbox for each channel that plays, ticked where the reader keeps it on. + + The channels an edit puts in play arrive already selected and one switched off by hand + arrives as it was left, so the boxes report what plays without overruling a choice. + """ for generator_name in GeneratorName: tag = self._get_generator_checkbox_tag(generator_name) - is_available = generator_name in view_model.available_generators + is_playing = generator_name in view_model.playing_generators + is_selected = generator_name in view_model.selected_generators dpg_configure_item( tag, - enabled=is_available, - default_value=is_available, + enabled=is_playing, + default_value=is_selected, ) - dpg_set_value(tag, is_available) - if is_available: + dpg_set_value(tag, is_selected) + if is_playing: ThemeRegistry.get(_GENERATOR_THEME_TAGS[generator_name]).bind_to_item(tag) else: dpg.bind_item_theme(tag, 0) diff --git a/src/sampletones_application/view_model/reconstruction/instruments.py b/src/sampletones_application/view_model/reconstruction/instruments.py index 9b3a955b5..15c808fb7 100644 --- a/src/sampletones_application/view_model/reconstruction/instruments.py +++ b/src/sampletones_application/view_model/reconstruction/instruments.py @@ -7,6 +7,13 @@ class ReconstructionInstrumentsViewModel(BaseModel, frozen=True): + """What the instruments panel renders: every channel, and which of them play. + + A reconstruction holds a tab per channel whatever it sounds, so a channel standing by stays + editable and giving it an envelope puts it in play. :attr:`playing_generators` is what the + panel reads to mark the standing-by tabs and to offer their export. + """ + reconstruction_loaded: bool - available_generators: FrozenSet[GeneratorName] + playing_generators: FrozenSet[GeneratorName] footprint: Optional[SampleFootprintViewModel] diff --git a/src/sampletones_application/view_model/reconstruction/reconstruction.py b/src/sampletones_application/view_model/reconstruction/reconstruction.py index b20059505..7c48cf0e3 100644 --- a/src/sampletones_application/view_model/reconstruction/reconstruction.py +++ b/src/sampletones_application/view_model/reconstruction/reconstruction.py @@ -33,8 +33,16 @@ class ReconstructionPathViewModel(BaseModel, frozen=True): class ReconstructionViewModel(BaseModel, frozen=True): + """What the reconstruction view renders, including which channels the waveform offers. + + A channel plays once its instruction stream describes a frame, which is what makes its + generator checkbox reachable; :attr:`selected_generators` is the subset the reader keeps + switched on, so a channel switched off by hand stays off across an edit. + """ + reconstruction_loaded: bool - available_generators: FrozenSet[GeneratorName] + playing_generators: FrozenSet[GeneratorName] + selected_generators: FrozenSet[GeneratorName] reconstruction_file: ReconstructionPathViewModel original_audio: ReconstructionPathViewModel diff --git a/src/sampletones_config/theme/instrument_tabs.yaml b/src/sampletones_config/theme/instruments/tabs.yaml similarity index 100% rename from src/sampletones_config/theme/instrument_tabs.yaml rename to src/sampletones_config/theme/instruments/tabs.yaml diff --git a/src/sampletones_config/theme/instruments/tabs_muted.yaml b/src/sampletones_config/theme/instruments/tabs_muted.yaml new file mode 100644 index 000000000..b6d0c2145 --- /dev/null +++ b/src/sampletones_config/theme/instruments/tabs_muted.yaml @@ -0,0 +1,21 @@ +name: instrument_tabs_muted +tag: global.theme.instrument_tabs_muted + +components: + - item_type: All + entries: + - type: color + key: Text + value: .text_muted + - type: color + key: Tab + value: .recess + - type: color + key: TabHovered + value: .ground/0.75 + - type: color + key: TabSelected + value: .ground + - type: color + key: TabDimmedSelected + value: .recess diff --git a/src/sampletones_config/theme/panel/instrument.yaml b/src/sampletones_config/theme/panel/instrument.yaml index 4e003dd03..d3355cdbc 100644 --- a/src/sampletones_config/theme/panel/instrument.yaml +++ b/src/sampletones_config/theme/panel/instrument.yaml @@ -7,3 +7,6 @@ components: - type: color key: ChildBg value: .recess + - type: color + key: Text + value: .text diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index 450de014e..bfd3e2a68 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -109,6 +109,16 @@ def frame_count(self) -> int: arrays = (self.volume, self.arpeggio, self.pitch, self.hi_pitch, self.duty_cycle) return max((len(array) for array in arrays if array is not None), default=0) + @property + def has_frames(self) -> bool: + """Whether the envelopes describe a frame, which is what a channel plays. + + Every dimension left to the channel leaves an instrument describing nothing, so this + is what tells a channel that sounds from one that stands by: an export writes the + instruments that have frames, and the driver stores only those. + """ + return self.frame_count > 0 + @property def held_features(self) -> Tuple[FeatureKey, ...]: """The dimensions the channel governs, whose envelopes carry no item. diff --git a/src/sampletones_core/exporters/slices.py b/src/sampletones_core/exporters/slices.py index 6b87f51e3..2d3618693 100644 --- a/src/sampletones_core/exporters/slices.py +++ b/src/sampletones_core/exporters/slices.py @@ -59,10 +59,10 @@ def slot(self) -> InstrumentSlot: def iterate_sample_slices(project: Project) -> Iterator[SampleSlice]: """Walks every generator slice of every sample in instrument-table order. - A sample contributes one slice per channel its reconstruction covers, so it yields - one to four. Slices are numbered in sample order, then channel order, which fixes - the instrument numbering every tracker format builds on. Each sample's features are - exported once, so a caller reads a reconstruction's envelopes at a single cost. + A sample contributes one slice per channel that plays, so it yields one to four. Slices + are numbered in sample order, then channel order, which fixes the instrument numbering + every tracker format builds on. Each sample's features are exported once, so a caller + reads a reconstruction's envelopes at a single cost. Args: project: The project whose samples are exported. @@ -74,8 +74,8 @@ def iterate_sample_slices(project: Project) -> Iterator[SampleSlice]: for sample in project.samples: features_by_generator = sample.reconstruction.export() for generator in GeneratorName.items(): - features = features_by_generator.get(generator) - if features is None: + features = features_by_generator[generator] + if not features.has_frames: continue yield SampleSlice( diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py index f4d98233a..3a9e0af09 100644 --- a/src/sampletones_core/features/__init__.py +++ b/src/sampletones_core/features/__init__.py @@ -3,8 +3,11 @@ FEATURE_DIMENSION_ORDER, GENERATOR_FEATURE_RANGES, GENERATOR_KIND, + RESTING_REFERENCE_PERIOD, + RESTING_REFERENCE_PITCH, FeatureRange, feature_range, + resting_reference, supported_features, supports, ) @@ -14,8 +17,11 @@ "FEATURE_DIMENSION_ORDER", "GENERATOR_FEATURE_RANGES", "GENERATOR_KIND", + "RESTING_REFERENCE_PERIOD", + "RESTING_REFERENCE_PITCH", "FeatureRange", "feature_range", + "resting_reference", "supported_features", "supports", ] diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index c9f7e9f59..f7a76f2f1 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -9,6 +9,7 @@ MAX_NOISE_MODE, MAX_PERIOD, MAX_VOLUME, + NUM_PERIODS, ) @@ -36,6 +37,10 @@ class FeatureRange: } +RESTING_REFERENCE_PITCH: Final[int] = 60 +RESTING_REFERENCE_PERIOD: Final[int] = NUM_PERIODS // 2 + + GENERATOR_FEATURE_RANGES: Final[Dict[LibraryGeneratorName, Dict[FeatureKey, FeatureRange]]] = { LibraryGeneratorName.PULSE: { FeatureKey.VOLUME: FeatureRange(0, MAX_VOLUME), @@ -62,6 +67,26 @@ class FeatureRange: } +def resting_reference(generator_name: GeneratorName) -> int: + """The reference an arpeggio envelope is measured against while a channel describes no frame. + + A channel with no frames still carries a reference, since the first envelope given to it + sounds every frame at that value. Resting mid-range puts a channel added by hand on an + audible note, and on a noise period between the extremes. + + Args: + generator_name: The channel whose resting reference is read. + + Returns: + int: The pitch a tonal channel rests at, or the period the noise channel rests at. + """ + match GENERATOR_KIND[generator_name]: + case LibraryGeneratorName.NOISE: + return RESTING_REFERENCE_PERIOD + case _: + return RESTING_REFERENCE_PITCH + + def supported_features(kind: LibraryGeneratorName) -> list[FeatureKey]: ranges = GENERATOR_FEATURE_RANGES[kind] return [feature for feature in FEATURE_DIMENSION_ORDER if feature in ranges] diff --git a/src/sampletones_core/formats/famitracker/footprint.py b/src/sampletones_core/formats/famitracker/footprint.py index ffa795ee6..481ad4941 100644 --- a/src/sampletones_core/formats/famitracker/footprint.py +++ b/src/sampletones_core/formats/famitracker/footprint.py @@ -100,22 +100,23 @@ def reconstruction_footprints( *, loop: bool, ) -> Dict[GeneratorName, InstrumentFootprint]: - """Measures one instrument per channel a reconstruction covers. + """Measures one instrument per channel a reconstruction plays. - A reconstruction exports one instrument for each of its one to four channels, so the result - holds an entry per covered channel and :func:`total_footprint` sums them into what the whole - sample costs. + An export writes an instrument for each channel that plays, so the result holds an entry + per playing channel and :func:`total_footprint` sums them into what the whole sample costs. + A channel standing by is written nowhere and therefore measured nowhere. Args: reconstruction: The reconstruction whose channels are measured. loop: Whether the sample carrying it loops while its note is held. Returns: - Dict[GeneratorName, InstrumentFootprint]: The footprint of each channel's instrument. + Dict[GeneratorName, InstrumentFootprint]: The footprint of each playing channel's instrument. """ return { generator_name: features_footprint(features, loop=loop) for generator_name, features in reconstruction.export().items() + if features.has_frames } diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py index 51788d609..a71b4cf21 100644 --- a/src/sampletones_core/reconstructions/reconstruction/instructions.py +++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py @@ -6,6 +6,7 @@ from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import DataModel +from sampletones_core.features import resting_reference from sampletones_core.instructions import InstructionData, InstructionUnion @@ -49,3 +50,24 @@ def create( initial_pitch=initial_pitch, held_features=list(held_features), ) + + @classmethod + def resting(cls, generator_name: GeneratorName) -> InstructionsItem: + """The stream a channel carries while it stands by, describing no frame. + + A reconstruction holds one stream per channel, so a channel it leaves silent is + present and editable: it rests at the reference its first envelope will sound at, + and describing a frame is what puts it back in play. + + Args: + generator_name: The channel the resting stream belongs to. + + Returns: + InstructionsItem: The stream of a channel that stands by. + """ + return cls.create( + generator_name=generator_name, + instructions=[], + initial_pitch=resting_reference(generator_name), + held_features=(), + ) diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index e6a46954a..51e3f47a7 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -3,7 +3,18 @@ import struct from functools import cached_property from pathlib import Path -from typing import Any, Dict, Final, Iterable, List, Mapping, Optional, Self, Sequence, Tuple +from typing import ( + Any, + Dict, + Final, + Iterable, + List, + Mapping, + Optional, + Self, + Sequence, + Tuple, +) from uuid import uuid4 import numpy as np @@ -19,6 +30,7 @@ ExporterUnion, Features, ) +from sampletones_core.features import resting_reference from sampletones_core.generators.maps import GENERATOR_CLASSES from sampletones_core.instructions import InstructionUnion from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION @@ -109,10 +121,36 @@ def held_features(self) -> Dict[GeneratorName, Tuple[FeatureKey, ...]]: """ return {item.generator_name: tuple(item.held_features) for item in self.instructions_data} + @cached_property + def playing_generators(self) -> Tuple[GeneratorName, ...]: + """The channels whose instruction stream describes a frame. + + A reconstruction holds a stream for every channel, so this is what says which of them + play: the rest stand by, exporting nothing and costing nothing, while describing a + frame is what puts one in play. + """ + return tuple(name for name in GeneratorName.items() if self.instructions.get(name)) + @staticmethod def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion: return INSTRUCTION_TO_EXPORTER_MAP[type(instruction)] + @classmethod + def _exporter_class( + cls, + generator_name: GeneratorName, + instructions: List[InstructionUnion], + ) -> ExporterTypeUnion: + """The exporter a channel's stream is read through. + + The instruction type names the exporter wherever the stream describes a frame; a + channel standing by takes the exporter its generator name pairs with. + """ + if not instructions: + return GENERATOR_NAME_TO_EXPORTER_MAP[generator_name] + + return cls._get_exporter_class(instructions[0]) + @classmethod def _derive_initial_pitch( cls, @@ -122,12 +160,12 @@ def _derive_initial_pitch( """Chooses the reference pitch a channel's arpeggio envelope is measured against. The instruction type selects the exporter, matching how `export` resolves one. A - channel carrying no instructions takes the exporter its generator name pairs with, - which reports that exporter's resting reference. + channel describing no frame rests at the reference its first envelope will sound at. """ - exporter_class = ( - cls._get_exporter_class(instructions[0]) if instructions else GENERATOR_NAME_TO_EXPORTER_MAP[generator_name] - ) + if not instructions: + return resting_reference(generator_name) + + exporter_class = cls._get_exporter_class(instructions[0]) return exporter_class.derive_initial_pitch(instructions) # type: ignore[arg-type] @classmethod @@ -147,13 +185,16 @@ def create( ] instructions_data: List[InstructionsItem] = [] - for generator_name, instructions_list in instructions.items(): - channel_instructions = list(instructions_list) + for generator_name in GeneratorName.items(): + channel_instructions = list(instructions.get(generator_name, ())) instructions_data.append( InstructionsItem.create( generator_name=generator_name, instructions=channel_instructions, - initial_pitch=cls._derive_initial_pitch(generator_name, channel_instructions), + initial_pitch=cls._derive_initial_pitch( + generator_name, + channel_instructions, + ), held_features=(), ) ) @@ -206,30 +247,32 @@ def update_generator_data( measures the arpeggio against the same base the edit was made from. The held dimensions travel with them for the same reason: the frames state a value for every dimension, and this is what says which of them the instrument itself wrote. + + The channel keeps its place among the streams however the edit leaves it, so one + cleared of every frame stands by and stays editable. Its rendered audio lasts as + long as it carries samples, which keeps silence out of the stored waveforms. """ partial_approximation = np.trim_zeros(partial_approximation, trim="b") + rendered = {name: audio for name, audio in self.approximations.items() if name != generator_name} + if partial_approximation.size: + rendered[generator_name] = partial_approximation + max_length = max( - len(partial_approximation), - *(len(np.trim_zeros(audio, trim="b")) for audio in self.approximations.values()), + (len(np.trim_zeros(audio, trim="b")) for audio in rendered.values()), + default=0, ) - rendered = { - name: partial_approximation if name == generator_name else audio - for name, audio in self.approximations.items() - } self.approximations_data = self._build_approximations_data(rendered, max_length) + + streams = {item.generator_name: item for item in self.instructions_data} + streams[generator_name] = InstructionsItem.create( + generator_name=generator_name, + instructions=instructions, + initial_pitch=initial_pitch, + held_features=held_features, + ) self.instructions_data = [ - ( - InstructionsItem.create( - generator_name=generator_name, - instructions=instructions, - initial_pitch=initial_pitch, - held_features=held_features, - ) - if item.generator_name == generator_name - else item - ) - for item in self.instructions_data + streams[name] if name in streams else InstructionsItem.resting(name) for name in GeneratorName.items() ] self._invalidate_derived_caches(self) self.approximation = self._sum_approximations([item.approximation for item in self.approximations_data]) @@ -268,22 +311,29 @@ def _resynthesized(self, config: Config) -> Reconstruction: """Re-renders every generator's approximation from its instructions at ``config``. Each instruction spans ``config.frame_length`` samples, so re-rendering at a new frame - length re-times the audio. Per-generator arrays are padded to a common length and summed; - the mixer weight is baked into each generator's output, so a plain sum reproduces the - stored approximation shape. Drive is left at unity to match the regeneration path. + length re-times the audio. The channels describing frames are rendered, padded to a + common length and summed; the mixer weight is baked into each generator's output, so a + plain sum reproduces the stored approximation shape. Drive is left at unity to match the + regeneration path. """ rendered: Dict[GeneratorName, np.ndarray] = {} for generator_name, instructions in self.instructions.items(): - generator = GENERATOR_CLASSES[generator_name](config, generator_name.value) - if instructions: - rendered[generator_name] = np.concatenate( - [generator(instruction, save=True) for instruction in instructions] # type: ignore[arg-type] - ) - else: - rendered[generator_name] = np.zeros(0, dtype=np.float32) + if not instructions: + continue + + generator = GENERATOR_CLASSES[generator_name]( + config, + generator_name.value, + ) + rendered[generator_name] = np.concatenate( + [generator(instruction, save=True) for instruction in instructions] # type: ignore[arg-type] + ) max_length = max((len(audio) for audio in rendered.values()), default=0) - approximations_data = self._build_approximations_data(rendered, max_length) + approximations_data = self._build_approximations_data( + rendered, + max_length, + ) approximation = self._sum_approximations([item.approximation for item in approximations_data]) retuned: Reconstruction = self.model_copy( @@ -314,16 +364,18 @@ def _build_approximations_data( rendered: Mapping[GeneratorName, np.ndarray], length: int, ) -> List[ApproximationsItem]: - """Pads each generator's audio to ``length`` and pairs it with its generator name. + """Pads each rendered channel's audio to ``length``, in channel order. - A shared length lets the per-generator arrays stack and sum into the mixed approximation. + A shared length lets the per-generator arrays stack and sum into the mixed approximation, + and a fixed order keeps a stored reconstruction reading the same however an edit reached it. """ return [ ApproximationsItem( - generator_name=name, - approximation=pad(audio, 0, length), + generator_name=generator_name, + approximation=pad(rendered[generator_name], 0, length), ) - for name, audio in rendered.items() + for generator_name in GeneratorName.items() + if generator_name in rendered ] @staticmethod @@ -333,6 +385,7 @@ def _invalidate_derived_caches(reconstruction: Reconstruction) -> None: reconstruction.__dict__.pop("instructions", None) reconstruction.__dict__.pop("initial_pitches", None) reconstruction.__dict__.pop("held_features", None) + reconstruction.__dict__.pop("playing_generators", None) @classmethod def load(cls, path: Pathlike, fast: bool = True) -> Reconstruction: @@ -371,7 +424,10 @@ def validate_metadata(metadata: Metadata) -> None: if not isinstance(metadata, Metadata): return - RECONSTRUCTION_DATA_CONTRACT.validate(metadata, metadata.reconstruction_data_version) + RECONSTRUCTION_DATA_CONTRACT.validate( + metadata, + metadata.reconstruction_data_version, + ) def _validate_instructions( self, @@ -392,20 +448,27 @@ def _validate_instructions( ) def export(self) -> Dict[GeneratorName, Features]: - features: Dict[GeneratorName, Features] = {} - for name, instructions in self.instructions.items(): - if not instructions: - continue + """The envelopes each channel exports, one entry per channel the reconstruction holds. - exporter_class = self._get_exporter_class(instructions[0]) + A channel standing by describes no frame, so its envelopes come back empty and every + reader tells it from a channel that plays by :attr:`Features.has_frames`. + + Returns: + Dict[GeneratorName, Features]: The envelope representation of each channel. + """ + features: Dict[GeneratorName, Features] = {} + for name in GeneratorName.items(): + instructions = self.instructions[name] + exporter_class = self._exporter_class(name, instructions) exporter: ExporterUnion = exporter_class() - self._validate_instructions(exporter, instructions) - feature: Features = exporter.to_features( + if instructions: + self._validate_instructions(exporter, instructions) + + features[name] = exporter.to_features( instructions, # type: ignore[arg-type] self.initial_pitches[name], self.held_features[name], ) - features[name] = feature return features diff --git a/tests/integration/assets/reconstruction.py b/tests/integration/assets/reconstruction.py index 60fba8bac..662e9a07e 100644 --- a/tests/integration/assets/reconstruction.py +++ b/tests/integration/assets/reconstruction.py @@ -81,11 +81,11 @@ def make_sample( expected_slices: FrozenSet[GeneratorName], loop: bool = False, ) -> Sample: - """Reconstructs ``audio`` into a `Sample`, asserting the covered channel slices.""" + """Reconstructs ``audio`` into a `Sample`, asserting the channels it plays.""" reconstruction = reconstruct_sample(audio, config, library, tmp_dir=tmp_dir, name=name) - covered = frozenset(reconstruction.instructions) - if covered != expected_slices: - raise AssertionError(f"Sample '{name}' covers {set(covered)}, expected {set(expected_slices)}") + played = frozenset(reconstruction.playing_generators) + if played != expected_slices: + raise AssertionError(f"Sample '{name}' covers {set(played)}, expected {set(expected_slices)}") return Sample(name=name, reconstruction=reconstruction, loop=loop) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py index b62e8edb1..96bb0d6be 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_feature.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_feature.py @@ -24,11 +24,20 @@ def feature_data(reconstruction: Reconstruction) -> FeatureData: class TestFeatureDataLoad: def test_load_creates_entry_for_each_generator( + self, + feature_data: FeatureData, + ) -> None: + assert set(feature_data.generators.keys()) == set(GeneratorName.items()) + + def test_a_channel_standing_by_carries_empty_envelopes( self, reconstruction: Reconstruction, feature_data: FeatureData, ) -> None: - assert set(feature_data.generators.keys()) == set(reconstruction.approximations.keys()) + """A channel the reconstruction leaves silent is loaded describing no frame.""" + standing_by = set(GeneratorName.items()) - set(reconstruction.playing_generators) + assert standing_by + assert all(not feature_data[generator_name].has_frames for generator_name in standing_by) def test_loaded_features_include_initial_pitch( self, @@ -39,15 +48,10 @@ def test_loaded_features_include_initial_pitch( class TestFeatureDataQueries: - def test_get_generator_features_returns_features_for_present( - self, - feature_data: FeatureData, - ) -> None: - result = feature_data.get_generator_features(GeneratorName.PULSE1) - assert isinstance(result, Features) - - def test_get_generator_features_returns_none_for_absent( + @pytest.mark.parametrize("generator_name", GeneratorName.items(), ids=lambda name: name.value) + def test_every_channel_answers_with_its_features( self, feature_data: FeatureData, + generator_name: GeneratorName, ) -> None: - assert feature_data.get_generator_features(GeneratorName.TRIANGLE) is None + assert isinstance(feature_data[generator_name], Features) diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py index 625f13480..3ab55ba90 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_instruments.py @@ -87,7 +87,7 @@ def test_with_features_fires_on_feature_data_changed_with_data( instruments_logic.update_display() assert received == [feature_data.generators] - def test_with_features_exposes_available_generators( + def test_with_features_exposes_the_playing_generators( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, @@ -97,7 +97,7 @@ def test_with_features_exposes_available_generators( received: List[ReconstructionInstrumentsViewModel] = [] instruments_logic.on_view_changed = received.append instruments_logic.update_display() - assert GeneratorName.PULSE1 in received[0].available_generators + assert GeneratorName.PULSE1 in received[0].playing_generators class TestReconstructionInstrumentsLogicFootprint: @@ -114,7 +114,7 @@ def test_no_reconstruction_carries_no_footprint( instruments_logic.update_display() assert received[0].footprint is None - def test_every_covered_channel_is_measured( + def test_every_playing_channel_is_measured( self, instruments_logic: ReconstructionInstrumentsLogic, mock_reconstruction_manager: MagicMock, @@ -127,7 +127,9 @@ def test_every_covered_channel_is_measured( instruments_logic.update_display() footprint = received[0].footprint assert footprint is not None - assert {instrument.generator for instrument in footprint.instruments} == set(feature_data.generators) + assert {instrument.generator for instrument in footprint.instruments} == { + generator_name for generator_name, features in feature_data.generators.items() if features.has_frames + } def test_the_size_is_the_one_a_one_shot_export_writes( self, @@ -144,7 +146,9 @@ def test_the_size_is_the_one_a_one_shot_export_writes( footprint = received[0].footprint assert footprint is not None expected = total_footprint( - features_footprint(features, loop=False) for features in feature_data.generators.values() + features_footprint(features, loop=False) + for features in feature_data.generators.values() + if features.has_frames ) assert footprint.total_bytes == expected.total_bytes @@ -224,7 +228,7 @@ def test_a_refresh_reports_the_view_alone( instruments_logic.on_view_changed = received.append instruments_logic.on_feature_data_changed = feature_updates.append - instruments_logic.refresh_footprint() + instruments_logic.refresh_view() assert len(received) == 1 assert received[0].footprint is not None diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index ad0671d7e..8c9f1c40d 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -18,6 +18,7 @@ from sampletones_core.audio import write_wave from sampletones_core.configs import Config from sampletones_core.constants.enums import AudioSourceType, GeneratorName +from sampletones_core.instructions import TriangleInstruction from sampletones_core.paths import ( EXT_FILE_BITPHASE, EXT_FILE_INSTRUMENT, @@ -282,6 +283,103 @@ def test_update_skips_audio_when_source_is_original( callback.assert_not_called() +class TestReconstructionPanelLogicPlayingChannels: + """Which channels the waveform offers, and what an edit does to the reader's choice.""" + + @staticmethod + def _received(panel_logic: ReconstructionPanelLogic) -> List[ReconstructionViewModel]: + received: List[ReconstructionViewModel] = [] + panel_logic.on_view_changed = received.append + return received + + def test_display_offers_the_channels_that_play( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + received = self._received(panel_logic) + + panel_logic.display_reconstruction() + + assert received[0].playing_generators == frozenset({GeneratorName.PULSE1}) + assert received[0].selected_generators == frozenset({GeneratorName.PULSE1}) + + def test_an_edit_reports_the_view_again( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.display_reconstruction() + received = self._received(panel_logic) + + panel_logic.update_reconstruction() + + assert received[0].playing_generators == frozenset({GeneratorName.PULSE1}) + + def test_a_channel_switched_off_by_hand_survives_an_edit( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.display_reconstruction() + panel_logic.set_selected_generators([]) + received = self._received(panel_logic) + + panel_logic.update_reconstruction() + + assert received[0].selected_generators == frozenset() + + def test_a_channel_gaining_its_first_frame_joins_the_waveform( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.display_reconstruction() + loaded_data.reconstruction.update_generator_data( + GeneratorName.TRIANGLE, + [TriangleInstruction(on=True, pitch=48)], + np.ones(64, dtype=np.float32), + 48, + (), + ) + received = self._received(panel_logic) + + panel_logic.update_reconstruction() + + assert received[0].playing_generators == frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE}) + assert received[0].selected_generators == frozenset({GeneratorName.PULSE1, GeneratorName.TRIANGLE}) + + def test_a_channel_taken_out_of_play_leaves_the_waveform( + self, + panel_logic: ReconstructionPanelLogic, + mock_reconstruction_manager: MagicMock, + loaded_data: ReconstructionData, + ) -> None: + mock_reconstruction_manager.current_reconstruction = loaded_data + panel_logic.display_reconstruction() + loaded_data.reconstruction.update_generator_data( + GeneratorName.PULSE1, + [], + np.zeros(0, dtype=np.float32), + 60, + (), + ) + received = self._received(panel_logic) + + panel_logic.update_reconstruction() + + assert received[0].playing_generators == frozenset() + assert received[0].selected_generators == frozenset() + + class TestReconstructionPanelLogicClose: def test_close_fires_on_waveform_cleared( self, 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 962ee4d0b..128ca0bab 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_instruments_panel.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, Final, List +from typing import Dict, Final, List, cast from unittest.mock import MagicMock import pytest @@ -17,7 +17,10 @@ from sampletones_application.tags.general import ( TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_INPUT_WARNING, + TAG_GLOBAL_THEME_INSTRUMENT_TABS, + TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, ) +from sampletones_application.ui.elements.button import GUIButton from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.pitch_stepper import PitchStepperStyle from sampletones_application.ui.panels.reconstruction.instruments import instruments as instruments_module @@ -46,7 +49,7 @@ NOT_LOADED: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( reconstruction_loaded=False, - available_generators=frozenset(), + playing_generators=frozenset(), footprint=None, ) @@ -57,7 +60,7 @@ def build_view_model( """A loaded reconstruction covering the given channels, each measured at the given size.""" return ReconstructionInstrumentsViewModel( reconstruction_loaded=True, - available_generators=frozenset(channel_bytes), + playing_generators=frozenset(channel_bytes), footprint=SampleFootprintViewModel( instruments=tuple( InstrumentSizeViewModel(generator=generator_name, total_bytes=byte_count) @@ -84,9 +87,13 @@ def registered_themes(layout_config: LayoutConfig) -> None: ) -@pytest.fixture +@pytest.fixture(autouse=True) def bound_themes(monkeypatch: pytest.MonkeyPatch) -> List[str]: - """Records the theme tags bound to items, standing in for the DPG binding.""" + """Records the theme tags bound to items, standing in for the DPG binding. + + The panel binds a theme wherever it marks an item, so every test stands in for the + binding and the ones asserting on it read the record. + """ tags: List[str] = [] monkeypatch.setattr(Theme, "bind_to_item", lambda self, item: tags.append(self.tag)) return tags @@ -290,21 +297,74 @@ def test_each_channel_states_its_own_size( } == {generator_name: f"{byte_count} B" for generator_name, byte_count in case.channel_bytes.items()} @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) - def test_an_uncovered_channel_is_left_alone( + def test_a_channel_standing_by_costs_nothing( self, panel: GUIReconstructionInstrumentsPanel, written: Dict[str, str], shown: Dict[str, bool], case: SizeCase, ) -> None: - """A channel the reconstruction leaves out exports no instrument, so its tab holds no figure.""" + """A channel that describes no frame is written by no export, so its tab states what that costs.""" panel.update_view(build_view_model(case.channel_bytes)) - uncovered = [ - panel._get_instrument_size_tag(generator_name) + assert { + generator_name: written[panel._get_instrument_size_tag(generator_name)] for generator_name in GeneratorName.items() if generator_name not in case.channel_bytes - ] - assert [tag for tag in uncovered if tag in written] == [] + } == { + generator_name: "0 B" + for generator_name in GeneratorName.items() + if generator_name not in case.channel_bytes + } + + +class TestPlayingChannels: + """Every channel keeps a tab; a muted label and a withheld export mark the ones standing by. + + ``update_view`` marks each channel once in channel order, so the recorded bindings read as + one theme per channel. + """ + + def test_every_channel_keeps_its_tab( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + ) -> None: + panel.update_view(build_view_model({GeneratorName.PULSE1: 777})) + assert { + generator_name: shown[panel._get_generator_tab_tag(generator_name)] + for generator_name in GeneratorName.items() + } == {generator_name: True for generator_name in GeneratorName.items()} + + def test_a_channel_standing_by_reads_muted( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + bound_themes: List[str], + ) -> None: + panel.update_view(build_view_model({GeneratorName.PULSE1: 777})) + assert dict(zip(GeneratorName.items(), bound_themes)) == { + GeneratorName.PULSE1: TAG_GLOBAL_THEME_INSTRUMENT_TABS, + GeneratorName.PULSE2: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, + GeneratorName.TRIANGLE: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, + GeneratorName.NOISE: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, + } + + def test_only_a_playing_channel_offers_its_export( + self, + panel: GUIReconstructionInstrumentsPanel, + written: Dict[str, str], + shown: Dict[str, bool], + ) -> None: + buttons = {generator_name: MagicMock() for generator_name in GeneratorName.items()} + panel._export_buttons.update(cast(Dict[GeneratorName, GUIButton], buttons)) + + panel.update_view(build_view_model({GeneratorName.TRIANGLE: 519})) + + assert {generator_name: button.set_enabled.call_args.args[0] for generator_name, button in buttons.items()} == { + generator_name: generator_name is GeneratorName.TRIANGLE for generator_name in GeneratorName.items() + } class TestSizeVisibility: diff --git a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py index 7d3dac572..76f4d1900 100644 --- a/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py +++ b/tests/unit/sampletones_application/ui/panels/reconstruction/test_plot.py @@ -7,6 +7,11 @@ from sampletones_application.ui.panels.reconstruction.plot import ( GUIReconstructionPlotPanel, ) +from sampletones_application.view_model.reconstruction.reconstruction import ( + ReconstructionPathState, + ReconstructionPathViewModel, + ReconstructionViewModel, +) from sampletones_core.constants.enums import GeneratorName from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase @@ -14,6 +19,17 @@ ALL_GENERATORS = frozenset(GeneratorName) +class StubTheme: + """Stands in for a registered theme, recording the items it was bound to.""" + + def __init__(self, tag: str, bindings: Dict[str, str]) -> None: + self.tag = tag + self._bindings = bindings + + def bind_to_item(self, item: str) -> None: + self._bindings[item] = self.tag + + class Harness: """The panel over its generator checkboxes, each shown or disabled as a reconstruction leaves it.""" @@ -28,22 +44,103 @@ def __init__( self.values: Dict[str, bool] = {self._tag(generator): generator in selected for generator in GeneratorName} self.enabled: Dict[str, bool] = {self._tag(generator): generator in available for generator in GeneratorName} self.reported: List[List[GeneratorName]] = [] + self.bound_themes: Dict[str, str] = {} monkeypatch.setattr(plot_module.dpg, "get_value", self.values.__getitem__) monkeypatch.setattr(plot_module.dpg, "is_item_enabled", self.enabled.__getitem__) + monkeypatch.setattr(plot_module.dpg, "bind_item_theme", lambda item, theme: self.bound_themes.pop(item, None)) monkeypatch.setattr(plot_module, "dpg_set_value", self.values.__setitem__) + monkeypatch.setattr(plot_module, "dpg_configure_item", self._configure) + monkeypatch.setattr( + plot_module.ThemeRegistry, + "get", + lambda tag: StubTheme(tag, self.bound_themes), + ) self.panel = GUIReconstructionPlotPanel.__new__(GUIReconstructionPlotPanel) self.panel.on_generators_changed = self.reported.append + def _configure(self, tag: str, *, enabled: bool, default_value: bool) -> None: + self.enabled[tag] = enabled + self.values[tag] = default_value + @staticmethod def _tag(generator: GeneratorName) -> str: return GUIReconstructionPlotPanel._get_generator_checkbox_tag(generator) + def offered(self) -> FrozenSet[GeneratorName]: + return frozenset(generator for generator in GeneratorName if self.enabled[self._tag(generator)]) + def selected(self) -> FrozenSet[GeneratorName]: return frozenset(generator for generator in GeneratorName if self.values[self._tag(generator)]) +def _view_model( + playing: FrozenSet[GeneratorName], + selected: FrozenSet[GeneratorName], +) -> ReconstructionViewModel: + empty_path = ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path="") + return ReconstructionViewModel( + reconstruction_loaded=True, + playing_generators=playing, + selected_generators=selected, + reconstruction_file=empty_path, + original_audio=empty_path, + ) + + +class TestGeneratorCheckboxes: + """The checkboxes offer the channels that play and tick the ones the reader keeps on.""" + + def test_a_channel_that_plays_is_offered( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + harness = Harness(selected=frozenset(), available=frozenset(), monkeypatch=monkeypatch) + playing = frozenset({GeneratorName.PULSE1, GeneratorName.NOISE}) + + harness.panel.update_view(_view_model(playing, playing)) + + assert harness.offered() == playing + assert harness.selected() == playing + + def test_a_channel_switched_off_by_hand_stays_off( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """An edit reports the view again, and the report carries the reader's choice.""" + harness = Harness(selected=ALL_GENERATORS, available=ALL_GENERATORS, monkeypatch=monkeypatch) + playing = frozenset({GeneratorName.PULSE1, GeneratorName.NOISE}) + + harness.panel.update_view(_view_model(playing, frozenset({GeneratorName.NOISE}))) + + assert harness.offered() == playing + assert harness.selected() == frozenset({GeneratorName.NOISE}) + + def test_a_channel_standing_by_is_left_unticked( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + harness = Harness(selected=ALL_GENERATORS, available=ALL_GENERATORS, monkeypatch=monkeypatch) + playing = frozenset({GeneratorName.PULSE1}) + + harness.panel.update_view(_view_model(playing, playing)) + + assert harness.selected() == playing + assert GeneratorName.PULSE2 not in harness.offered() + + def test_a_channel_that_plays_carries_its_own_tint( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + harness = Harness(selected=frozenset(), available=frozenset(), monkeypatch=monkeypatch) + playing = frozenset({GeneratorName.TRIANGLE}) + + harness.panel.update_view(_view_model(playing, playing)) + + assert set(harness.bound_themes) == {Harness._tag(GeneratorName.TRIANGLE)} + + class TestToggleGenerator(BaseTestSuite): """The key a channel answers to switches its slice in and out of the waveform.""" 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 e44326b1a..0a07b7869 100644 --- a/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/view_model/reconstruction/test_reconstruction.py @@ -71,7 +71,8 @@ def test_enablement_follows_original_audio_state( ) -> None: view_model = ReconstructionViewModel( reconstruction_loaded=case.reconstruction_loaded, - available_generators=frozenset(), + playing_generators=frozenset(), + selected_generators=frozenset(), reconstruction_file=ReconstructionPathViewModel(state=ReconstructionPathState.EMPTY, path=""), original_audio=ReconstructionPathViewModel(state=case.original_audio_state, path=""), ) diff --git a/tests/unit/sampletones_core/exporters/test_slices.py b/tests/unit/sampletones_core/exporters/test_slices.py new file mode 100644 index 000000000..856bb3bd4 --- /dev/null +++ b/tests/unit/sampletones_core/exporters/test_slices.py @@ -0,0 +1,72 @@ +from typing import List, Sequence + +import numpy as np + +from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.exporters.slices import iterate_sample_slices +from sampletones_core.project.instruments.sample import Sample +from sampletones_core.project.project import Project +from sampletones_core.project.settings import ProjectSettings +from sampletones_core.structures import IdentifiedCollection +from tests.suite.sequencer import sample_reconstruction + + +def _project(samples: Sequence[Sample]) -> Project: + collection: IdentifiedCollection[Sample] = IdentifiedCollection() + for sample in samples: + collection.append(sample) + + project = Project.create(title="Slices", author="Tester", settings=ProjectSettings()) + project.samples = collection + return project + + +def _sample(name: str, generators: Sequence[GeneratorName]) -> Sample: + return Sample(name=name, reconstruction=sample_reconstruction(list(generators))) + + +class TestSampleSlices: + """The walk numbers the instruments a module writes, so it visits the channels that play. + + A sample carries every channel whatever it sounds, and one standing by is written nowhere, + so it takes no place in the instrument table and shifts no index behind it. + """ + + def test_a_sample_contributes_one_slice_per_playing_channel(self) -> None: + project = _project([_sample("lead", [GeneratorName.PULSE1, GeneratorName.NOISE])]) + + slices = list(iterate_sample_slices(project)) + + assert [sample_slice.generator for sample_slice in slices] == [ + GeneratorName.PULSE1, + GeneratorName.NOISE, + ] + + def test_a_channel_standing_by_takes_no_place_in_the_table(self) -> None: + sample = _sample("lead", [GeneratorName.PULSE1, GeneratorName.PULSE2]) + sample.reconstruction.update_generator_data( + GeneratorName.PULSE1, + [], + np.zeros(0, dtype=np.float32), + sample.reconstruction.initial_pitches[GeneratorName.PULSE1], + (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), + ) + project = _project([sample]) + + slices = list(iterate_sample_slices(project)) + + assert [(sample_slice.index, sample_slice.generator) for sample_slice in slices] == [ + (0, GeneratorName.PULSE2), + ] + + def test_slices_are_numbered_across_the_samples_in_order(self) -> None: + project = _project( + [ + _sample("lead", [GeneratorName.PULSE1]), + _sample("pad", [GeneratorName.TRIANGLE, GeneratorName.NOISE]), + ] + ) + + indices: List[int] = [sample_slice.index for sample_slice in iterate_sample_slices(project)] + + assert indices == [0, 1, 2] diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index 7af908e39..917b02a00 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -158,7 +158,8 @@ def test_no_instruments_cost_nothing(self) -> None: class TestReconstructionFootprints: - def test_one_entry_per_covered_channel(self) -> None: + def test_one_entry_per_playing_channel(self) -> None: + """The sample holds every channel; the two that play are the two an export writes.""" sample = dual_generator_sample("bell", pulse_pitch=72, triangle_pitch=36) footprints = reconstruction_footprints(sample.reconstruction, loop=sample.loop) assert set(footprints) == {GeneratorName.PULSE1, GeneratorName.TRIANGLE} @@ -176,7 +177,9 @@ def test_each_channel_is_measured_under_the_given_loop_flag(self) -> None: features = sample.reconstruction.export() for loop in (False, True): assert reconstruction_footprints(sample.reconstruction, loop=loop) == { - generator_name: features_footprint(feature, loop=loop) for generator_name, feature in features.items() + generator_name: features_footprint(feature, loop=loop) + for generator_name, feature in features.items() + if feature.has_frames } def test_looping_costs_the_shortest_dimensions_length(self) -> None: diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 3260166ef..e9285f7ee 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -9,6 +9,7 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import Metadata +from sampletones_core.features import resting_reference from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction from sampletones_shared.application import ( @@ -337,6 +338,95 @@ def test_held_dimensions_survive_a_save_load_round_trip(self, tmp_path: Path) -> assert loaded.held_features == reconstruction.held_features +class TestChannelSet: + """A reconstruction holds every channel, so one that stands by stays editable. + + An instruction stream describing no frame is what a channel standing by looks like: it + exports empty envelopes, costs nothing, and gaining a frame is what puts it in play. + """ + + def test_a_fresh_reconstruction_holds_every_channel(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + assert set(reconstruction.instructions) == set(GeneratorName.items()) + assert reconstruction.playing_generators == (GeneratorName.PULSE1,) + + def test_a_channel_standing_by_rests_at_the_shared_reference(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + assert reconstruction.initial_pitches[GeneratorName.TRIANGLE] == resting_reference(GeneratorName.TRIANGLE) + assert reconstruction.initial_pitches[GeneratorName.NOISE] == resting_reference(GeneratorName.NOISE) + + def test_a_channel_standing_by_exports_empty_envelopes(self) -> None: + features = _reconstruction([_pulse(_BASE_PITCH)]).export()[GeneratorName.PULSE2] + + assert not features.has_frames + assert features.volume.size == 0 + assert features.arpeggio.size == 0 + + def test_a_channel_standing_by_renders_no_audio(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + assert GeneratorName.PULSE2 not in reconstruction.approximations + + def test_clearing_every_frame_keeps_the_channel(self) -> None: + """Taking a channel out of play leaves its stream in place, so the edit is reversible.""" + reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) + + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [], + np.zeros(0, dtype=np.float32), + _BASE_PITCH, + (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), + ) + + assert reconstruction.playing_generators == () + assert GeneratorName.PULSE1 in reconstruction.instructions + assert reconstruction.initial_pitches[GeneratorName.PULSE1] == _BASE_PITCH + assert not reconstruction.export()[GeneratorName.PULSE1].has_frames + + def test_a_frame_puts_a_channel_standing_by_into_play(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + reconstruction.update_generator_data( + GeneratorName.PULSE2, + [_pulse(_BASE_PITCH)] * 2, + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _BASE_PITCH, + (), + ) + + assert reconstruction.playing_generators == (GeneratorName.PULSE1, GeneratorName.PULSE2) + assert reconstruction.export()[GeneratorName.PULSE2].has_frames + assert GeneratorName.PULSE2 in reconstruction.approximations + + def test_a_reconstruction_of_channels_standing_by_stays_valid(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [], + np.zeros(0, dtype=np.float32), + _BASE_PITCH, + (), + ) + + assert reconstruction.approximations == {} + assert reconstruction.approximation.size == 0 + + def test_the_channel_set_survives_a_save_load_round_trip(self, tmp_path: Path) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + path = tmp_path / "channels.stn" + + reconstruction.save(path) + loaded = Reconstruction.load(path) + + assert set(loaded.instructions) == set(GeneratorName.items()) + assert loaded.playing_generators == reconstruction.playing_generators + assert loaded.initial_pitches == reconstruction.initial_pitches + + class TestWithNesFrequency: def test_rebuilds_config(self, reconstruction_factory: ReconstructionFactory) -> None: reconstruction = reconstruction_factory() From b7204c7e10c8f1657b46e4122bd57aa5ef6e9061 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 21:59:59 +0200 Subject: [PATCH 099/152] Added: channel-held envelope values in playback --- docs/development/playback.md | 18 ++ docs/formats/reconstructions.md | 3 +- .../playback/synthesizer/__init__.py | 2 + .../sequencer/playback/synthesizer/state.py | 14 +- .../playback/synthesizer/synthesizer.py | 8 +- .../sequencer/playback/synthesizer/voice.py | 84 ++++++++ src/sampletones_core/exporters/exporter.py | 58 +++++ .../reconstruction/reconstruction.py | 34 ++- .../logic/sequencer/playback/conftest.py | 23 +- .../sequencer/playback/test_synthesizer.py | 109 +++++++++- .../logic/sequencer/playback/test_voice.py | 199 ++++++++++++++++++ .../exporters/test_exporter.py | 110 +++++++++- .../reconstruction/test_reconstruction.py | 35 +++ 13 files changed, 677 insertions(+), 20 deletions(-) create mode 100644 src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py create mode 100644 tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py diff --git a/docs/development/playback.md b/docs/development/playback.md index 4f33ace3c..6446cd8b5 100644 --- a/docs/development/playback.md +++ b/docs/development/playback.md @@ -162,6 +162,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. +## What the channel holds + +A sample states every dimension of every frame, and its reconstruction names which of those +dimensions the instrument itself wrote. The rest are the channel's: each channel carries a value per +dimension — volume, arpeggio, timbre — and an instrument leaving one empty sounds it at the value the +channel holds. That is what clearing an envelope in the instruments panel means once the sample is +played in a song, and it is the same rule a FamiTracker instrument follows with a sequence left out. + +The value moves as the song plays. Every frame an instrument writes hands its value to the channel, +so the channel keeps the last one written and an instrument that leaves the dimension empty picks it +up. A silent frame states its level alone, leaving pitch and timbre where the channel holds them. + +A pass through the song begins on the values a channel holds from the start — full volume, no +arpeggio offset, the first timbre — so starting the song and looping back to its first row both +sound the same. Seeking within a running song keeps the values, since the channel has reached them. + ## Rendering the song to a file A render writes the whole song to an audio file through the kernel that plays it. `RowSynthesizer` @@ -231,6 +247,8 @@ terminating would reclaim. | Where the playhead stands, and both grids' marks for it | `SequencerTabCoordinator` (`coordinators/tabs/sequencer.py`) | | Marking and revealing the sounding row in the tracker | `GUISequencerTrackerPanel` (`ui/panels/sequencer/tracker.py`) | | Row mixing, and the mask it pulls while rendering | `RowSynthesizer` (`logic/sequencer/playback/synthesizer/`) | +| Filling in the dimensions a channel governs, frame by frame | `SampleVoice` (`logic/sequencer/playback/synthesizer/voice.py`) | +| The values a channel holds between frames | `ChannelState` (`logic/sequencer/playback/synthesizer/state.py`) | | The channel generators and the rates they are built at | `ChannelBank` (`logic/sequencer/playback/synthesizer/bank.py`) | | How long each row of a pattern lasts | `Groove` (`sampletones_core/timing/`), indexed by row while rendering | | How many samples one of that row's ticks spans | `TickClock` (`sampletones_core/timing/`), followed by `EngineRates` | diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index 4eee98e17..d0fb31747 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -47,7 +47,8 @@ A `.stn` file holds: instruments panel adds that dimension here. A channel standing by rests at a reference pitch of its own, so the first envelope -written into it sounds on a mid-range note. +written into it sounds on a mid-range note. A file naming a stream for the channels +it plays alone reads as the whole four, with the rest coming back standing by. ## Detached reconstructions diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py index 40b55f8c5..a60f7ba81 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/__init__.py @@ -6,6 +6,7 @@ from .state import ChannelState from .synthesizer import RowSynthesizer from .timing import SongTiming +from .voice import SampleVoice __all__ = [ "ChannelBank", @@ -13,6 +14,7 @@ "EngineRates", "RowFrames", "RowSynthesizer", + "SampleVoice", "SongLength", "SongTiming", "apply_modifiers", diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py index ca2974a84..5af877fe1 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/state.py @@ -1,7 +1,9 @@ from dataclasses import dataclass, field -from typing import Optional +from typing import Dict, Optional +from sampletones_core.constants.enums import FeatureKey from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS from ..protocol import ChannelGeneratorProtocol @@ -15,12 +17,17 @@ class ChannelState: far into the sounding sample's instructions the channel has played, which is what lets a note sustain across rows. + The channel carries a value per envelope dimension too, which is what an instrument leaving a + dimension to the channel sounds at. A frame the instrument writes hands its value over, so the + channel keeps the last one written for as long as the song runs. + Attributes: generator: The synthesiser filling the channel's ticks. sample_id: The sample the channel is sounding, or ``None`` while it is silent. tick_index: How many ticks of that sample's instructions the channel has played. transpose: The semitone offset a row last set. volume: The level a row last set. + feature_values: The value the channel holds for each envelope dimension. """ generator: ChannelGeneratorProtocol @@ -28,10 +35,14 @@ class ChannelState: tick_index: int = field(default=0) transpose: int = field(default=0) volume: int = field(default=MAX_VOLUME) + feature_values: Dict[FeatureKey, int] = field(default_factory=CHANNEL_FEATURE_DEFAULTS.copy) def reset(self) -> None: """Returns the channel to silence at full volume, as a song starts it. + The envelope dimensions return to the values a channel holds from the start of a song, + so a pass through the song sounds the same however the previous one left them. + The generator is kept, since it is built from the rates in force rather than from anything a song reaches. """ @@ -39,3 +50,4 @@ def reset(self) -> None: self.tick_index = 0 self.transpose = 0 self.volume = MAX_VOLUME + self.feature_values = CHANNEL_FEATURE_DEFAULTS.copy() diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py index 3035821ef..2e1de2d80 100644 --- a/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/synthesizer.py @@ -23,6 +23,7 @@ from .rates import EngineRates from .state import ChannelState from .timing import SongTiming +from .voice import SampleVoice class RowSynthesizer: @@ -261,10 +262,11 @@ def _synthesize_ticks( if sample is None: return silence(frames.total) - instructions = sample.reconstruction.instructions.get(generator_name) + instructions = sample.reconstruction.instructions[generator_name] if not instructions: return silence(frames.total) + voice = SampleVoice.read(sample.reconstruction, generator_name) output = silence(frames.total) silence_frame = silence(frames.longest) @@ -275,6 +277,7 @@ def _synthesize_ticks( silence_frame[:frame_length], sample.loop, frame_length, + voice, ) output[frames.bounds[tick] : frames.bounds[tick + 1]] = frame state.tick_index += 1 @@ -288,6 +291,7 @@ def _synthesize_tick( silence_frame: np.ndarray, loop: bool, frame_length: int, + voice: SampleVoice, ) -> np.ndarray: if loop: instruction = instructions[state.tick_index % len(instructions)] @@ -299,7 +303,7 @@ def _synthesize_tick( state.generator.frame_length = frame_length return state.generator( apply_modifiers( - instruction, + voice.sound(instruction, state.feature_values), state.transpose, state.volume, ), diff --git a/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py b/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py new file mode 100644 index 000000000..51b348328 --- /dev/null +++ b/src/sampletones_application/logic/sequencer/playback/synthesizer/voice.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Dict, Tuple + +from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.exporters import GENERATOR_NAME_TO_EXPORTER_MAP, ExporterTypeUnion +from sampletones_core.instructions import InstructionUnion +from sampletones_core.reconstructions import Reconstruction + + +@dataclass(frozen=True) +class SampleVoice: + """How one channel reads a sample's frames. + + A sample carries a frame per tick stating every dimension the channel reads, and the + reconstruction names which of those dimensions the instrument itself wrote. The rest are the + channel's own: the instrument leaves an empty envelope for them and the channel sounds them at + the value it holds, which is what clearing an envelope in the instruments panel means once the + sample is played in a song. + + Attributes: + exporter: The reading that turns this channel's frames into envelope values and back. + initial_pitch: Reference pitch the arpeggio values are measured against. + held_features: The dimensions the instrument leaves to the channel. + """ + + exporter: ExporterTypeUnion + initial_pitch: int + held_features: Tuple[FeatureKey, ...] + + @classmethod + def read( + cls, + reconstruction: Reconstruction, + generator_name: GeneratorName, + ) -> SampleVoice: + """The voice one channel of ``reconstruction`` is played through. + + Args: + reconstruction: The sample's reconstruction. + generator_name: The channel being sounded. + + Returns: + SampleVoice: The reading of that channel's frames. + """ + return cls( + exporter=GENERATOR_NAME_TO_EXPORTER_MAP[generator_name], + initial_pitch=reconstruction.initial_pitches[generator_name], + held_features=reconstruction.held_features[generator_name], + ) + + def sound( + self, + instruction: InstructionUnion, + feature_values: Dict[FeatureKey, int], + ) -> InstructionUnion: + """The frame the channel sounds, once the dimensions it governs are filled in. + + ``feature_values`` is the channel's own, and this is where it moves: the dimensions the + frame states and the instrument writes are handed over to it, and every dimension the + frame plays is then read back out of it. So an instrument that writes a dimension sets + what the channel holds, and one that leaves it empty sounds at what the channel holds. + + Args: + instruction: The frame as the sample holds it. + feature_values: The values the channel holds, updated with what the instrument writes. + + Returns: + InstructionUnion: The frame to sound, before the pattern's transpose and volume. + """ + stated = self.exporter.feature_values( + instruction, # type: ignore[arg-type] + self.initial_pitch, + ) + for feature_key, value in stated.items(): + if feature_key not in self.held_features: + feature_values[feature_key] = value + + sounded: InstructionUnion = self.exporter.instruction_from_values( + feature_values, + self.initial_pitch, + ) + return sounded diff --git a/src/sampletones_core/exporters/exporter.py b/src/sampletones_core/exporters/exporter.py index b45502170..154e0d61c 100644 --- a/src/sampletones_core/exporters/exporter.py +++ b/src/sampletones_core/exporters/exporter.py @@ -161,6 +161,64 @@ def from_features(cls, features: Features) -> List[InstructionT]: return instructions + @classmethod + def feature_values( + cls, + instruction: InstructionT, + initial_pitch: int, + ) -> Dict[FeatureKey, int]: + """The envelope values one frame states. + + A frame that sounds states every dimension the channel reads, each in the terms its + envelope is written in. A silent frame states its level alone, leaving the rest to the + channel, which is how a sequence holds its pitch and timbre across a rest. + + Reading the frame as a sequence of one is what keeps this the same reading `to_features` + gives it, so a frame played in a song carries the values its envelopes show. + + Args: + instruction: The frame to read. + initial_pitch: Reference pitch the arpeggio value is measured against. + + Returns: + Dict[FeatureKey, int]: The value the frame states for each dimension it names. + """ + if not instruction.on: + return {FeatureKey.VOLUME: 0} + + feature_map = cls.get_feature_map([instruction], initial_pitch) + return { + key: int(value[0]) for key, value in feature_map.items() if isinstance(value, np.ndarray) and value.size + } + + @classmethod + def instruction_from_values( + cls, + values: Dict[FeatureKey, int], + initial_pitch: int, + ) -> InstructionT: + """The frame a row of envelope values describes. + + This is the single-frame form of `from_features`: values arrive in envelope terms and + come back as the instruction a generator sounds, with the arpeggio measured against + ``initial_pitch``. Dimensions this channel reads nothing from are passed over, so one + set of values serves every channel. + + Args: + values: The value each dimension carries for one frame. + initial_pitch: Reference pitch the arpeggio value is measured against. + + Returns: + InstructionT: The frame those values describe. + """ + dictionary: Dict[str, Union[bool, int]] = {} + for key, value in values.items(): + attribute = cls._remap_feature_key(key) + if attribute is not None: + dictionary[attribute] = value + + return cls._features_dictionary_to_instruction(dictionary, initial_pitch) + @classmethod @abstractmethod def _features_dictionary_to_instruction( diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index 51e3f47a7..4655c608e 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -99,17 +99,32 @@ class Reconstruction(DataModel): def approximations(self) -> Dict[GeneratorName, np.ndarray]: return {item.generator_name: item.approximation for item in self.approximations_data} + @cached_property + def streams(self) -> Dict[GeneratorName, InstructionsItem]: + """The instruction stream each channel carries, in channel order. + + This is where the channel set is made whole: a channel the stored data names a stream + for keeps it, and one it names none for rests, which is what a channel standing by + carries. Every per-channel view reads from here, so each of them covers the four + channels however a reconstruction reached memory. + """ + stored = {item.generator_name: item for item in self.instructions_data} + return { + generator_name: stored.get(generator_name, InstructionsItem.resting(generator_name)) + for generator_name in GeneratorName.items() + } + @cached_property def instructions(self) -> Dict[GeneratorName, List[InstructionUnion]]: return { - item.generator_name: [instruction.instruction for instruction in item.instructions] - for item in self.instructions_data + generator_name: [instruction.instruction for instruction in item.instructions] + for generator_name, item in self.streams.items() } @cached_property def initial_pitches(self) -> Dict[GeneratorName, int]: """The reference pitch each generator's arpeggio envelope is measured against.""" - return {item.generator_name: item.initial_pitch for item in self.instructions_data} + return {generator_name: item.initial_pitch for generator_name, item in self.streams.items()} @cached_property def held_features(self) -> Dict[GeneratorName, Tuple[FeatureKey, ...]]: @@ -119,7 +134,7 @@ def held_features(self) -> Dict[GeneratorName, Tuple[FeatureKey, ...]]: itself writes is stated here: the rest are the channel's, and an export leaves their envelopes empty for the player to fill from the value it holds. """ - return {item.generator_name: tuple(item.held_features) for item in self.instructions_data} + return {generator_name: tuple(item.held_features) for generator_name, item in self.streams.items()} @cached_property def playing_generators(self) -> Tuple[GeneratorName, ...]: @@ -129,7 +144,7 @@ def playing_generators(self) -> Tuple[GeneratorName, ...]: play: the rest stand by, exporting nothing and costing nothing, while describing a frame is what puts one in play. """ - return tuple(name for name in GeneratorName.items() if self.instructions.get(name)) + return tuple(generator_name for generator_name, item in self.streams.items() if item.instructions) @staticmethod def _get_exporter_class(instruction: InstructionUnion) -> ExporterTypeUnion: @@ -264,16 +279,14 @@ def update_generator_data( self.approximations_data = self._build_approximations_data(rendered, max_length) - streams = {item.generator_name: item for item in self.instructions_data} + streams = dict(self.streams) streams[generator_name] = InstructionsItem.create( generator_name=generator_name, instructions=instructions, initial_pitch=initial_pitch, held_features=held_features, ) - self.instructions_data = [ - streams[name] if name in streams else InstructionsItem.resting(name) for name in GeneratorName.items() - ] + self.instructions_data = [streams[name] for name in GeneratorName.items()] self._invalidate_derived_caches(self) self.approximation = self._sum_approximations([item.approximation for item in self.approximations_data]) @@ -281,7 +294,7 @@ def get_generator_instructions( self, generator_name: GeneratorName, ) -> List[InstructionUnion]: - return self.instructions.get(generator_name, []) + return self.instructions[generator_name] def detach_source(self) -> None: """Drops the local source-audio location so the reconstruction becomes self-contained. @@ -382,6 +395,7 @@ def _build_approximations_data( def _invalidate_derived_caches(reconstruction: Reconstruction) -> None: """Drops the memoized per-generator views so they recompute from their backing data.""" reconstruction.__dict__.pop("approximations", None) + reconstruction.__dict__.pop("streams", None) reconstruction.__dict__.pop("instructions", None) reconstruction.__dict__.pop("initial_pitches", None) reconstruction.__dict__.pop("held_features", None) diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py index 5548b6beb..7d65e04f8 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/conftest.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, FrozenSet +from typing import Callable, FrozenSet, Iterable import numpy as np import pytest @@ -10,7 +10,7 @@ from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.instructions import ( NoiseInstruction, PulseInstruction, @@ -52,10 +52,15 @@ def make_pulse_reconstruction( pitch: int = 60, volume: int = 15, count: int = 1, + held_features: Iterable[FeatureKey] = (), ) -> Reconstruction: - """Single-generator reconstruction with ``count`` identical PulseInstructions.""" + """Single-generator reconstruction with ``count`` identical PulseInstructions. + + ``held_features`` names the dimensions the instrument leaves to the channel, which is what + an envelope cleared in the instruments panel produces. + """ instructions = [PulseInstruction(on=True, pitch=pitch, volume=volume, duty_cycle=0)] * count - return Reconstruction.create( + reconstruction = Reconstruction.create( approximation=np.zeros(64, dtype=np.float32), approximations={GeneratorName.PULSE1: np.zeros(64, dtype=np.float32)}, instructions={GeneratorName.PULSE1: instructions}, @@ -63,6 +68,16 @@ def make_pulse_reconstruction( coefficient=1.0, audio_filepath=Path("/dev/null"), ) + if held_features: + reconstruction.update_generator_data( + GeneratorName.PULSE1, + list(instructions), + np.zeros(64, dtype=np.float32), + reconstruction.initial_pitches[GeneratorName.PULSE1], + held_features, + ) + + return reconstruction def make_triangle_reconstruction( 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 cba1f8084..dbb76e04e 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_synthesizer.py @@ -2,6 +2,7 @@ from typing import Dict, Final, FrozenSet, List, Optional, Tuple import numpy as np +import pytest from sampletones_application.constants.playback import ( MAX_TICKS_PER_ROW, @@ -12,8 +13,10 @@ from sampletones_application.logic.sequencer.playback.synthesizer import RowSynthesizer from sampletones_core.configs import Config from sampletones_core.constants.audio import DEFAULT_SAMPLE_RATE -from sampletones_core.constants.enums import GeneratorName +from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.reconstructions import Reconstruction from sampletones_core.timing import Metre, RowRate, calculate_groove from tests.suite.scenario import BaseTestScenario, ScenarioStep from tests.unit.sampletones_application.logic.sequencer.playback.conftest import ( @@ -27,6 +30,8 @@ ) SAMPLE_RATE: Final[int] = DEFAULT_SAMPLE_RATE +SUSTAINED_FRAMES: Final[int] = 64 +QUIET_VOLUME: Final[int] = 3 class MaskProvider: @@ -848,3 +853,105 @@ def test_frequency_change_between_rows_takes_effect_without_restart(self) -> Non assert pulse_state.generator.frame_length == round(SAMPLE_RATE / 30) assert pulse_state.sample_id is not None + + +class TestChannelHeldValues: + """A dimension an instrument leaves to the channel sounds at the value the channel holds. + + The channel carries that value from the start of a song, taking up a new one wherever an + instrument writes it, so an instrument with an empty volume envelope plays at whatever the + one before it left behind. + """ + + @staticmethod + def _place( + context: SynthesizerContext, + reconstruction: Reconstruction, + *, + row_index: int, + name: str, + ) -> None: + sample = add_sample(_controller(context), reconstruction, name=name) + place_row( + _controller(context), + generator=GeneratorName.PULSE1, + row_index=row_index, + sample_id=sample.id, + ) + + @staticmethod + def _peak(audio: np.ndarray) -> float: + return float(np.max(np.abs(audio))) + + def test_the_channel_takes_up_the_level_its_instrument_writes(self) -> None: + context = _make_context() + self._place( + context, + make_pulse_reconstruction(volume=QUIET_VOLUME, count=SUSTAINED_FRAMES), + row_index=0, + name="writes", + ) + + _render(context) + + assert _state(context).feature_values[FeatureKey.VOLUME] == QUIET_VOLUME + + def test_a_sample_holding_its_level_sounds_at_the_channels(self) -> None: + context = _make_context() + self._place( + context, + make_pulse_reconstruction(volume=QUIET_VOLUME, count=SUSTAINED_FRAMES), + row_index=0, + name="writes", + ) + self._place( + context, + make_pulse_reconstruction( + volume=MAX_VOLUME, + count=SUSTAINED_FRAMES, + held_features=(FeatureKey.VOLUME,), + ), + row_index=1, + name="holds", + ) + + written = _render(context) + held = _render(context) + + assert self._peak(held) == pytest.approx(self._peak(written)) + + def test_a_song_starts_a_held_level_at_full_volume(self) -> None: + holding = _make_context() + self._place( + holding, + make_pulse_reconstruction( + volume=QUIET_VOLUME, + count=SUSTAINED_FRAMES, + held_features=(FeatureKey.VOLUME,), + ), + row_index=0, + name="holds", + ) + writing = _make_context() + self._place( + writing, + make_pulse_reconstruction(volume=MAX_VOLUME, count=SUSTAINED_FRAMES), + row_index=0, + name="writes", + ) + + assert self._peak(_render(holding)) == pytest.approx(self._peak(_render(writing))) + + def test_a_reset_returns_every_channel_to_the_values_a_song_starts_on(self) -> None: + context = _make_context() + self._place( + context, + make_pulse_reconstruction(volume=QUIET_VOLUME, count=SUSTAINED_FRAMES), + row_index=0, + name="writes", + ) + _render(context) + + context.synthesizer.reset() + + assert _state(context).feature_values == CHANNEL_FEATURE_DEFAULTS diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py new file mode 100644 index 000000000..be4cb06b2 --- /dev/null +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py @@ -0,0 +1,199 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, Iterable, List, Sequence + +import numpy as np +import pytest + +from sampletones_application.logic.sequencer.playback.synthesizer import SampleVoice +from sampletones_core.configs import Config +from sampletones_core.constants.enums import FeatureKey, GeneratorName +from sampletones_core.constants.general import MAX_VOLUME +from sampletones_core.features import CHANNEL_FEATURE_DEFAULTS +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) +from sampletones_core.reconstructions import Reconstruction +from tests.suite.base import BaseTestSuite +from tests.suite.case import BaseRegularTestCase + +AUDIO_LENGTH: Final[int] = 64 +REFERENCE_PITCH: Final[int] = 60 +REFERENCE_PERIOD: Final[int] = 4 +SAMPLE_VOLUME: Final[int] = 9 +CHANNEL_VOLUME: Final[int] = 4 +DUTY_CYCLE: Final[int] = 2 + + +def _reconstruction( + generator_name: GeneratorName, + instructions: Sequence[InstructionUnion], + held_features: Iterable[FeatureKey], +) -> Reconstruction: + """A one-channel reconstruction whose instrument leaves ``held_features`` to the channel.""" + reconstruction = Reconstruction.create( + approximation=np.zeros(AUDIO_LENGTH, dtype=np.float32), + approximations={generator_name: np.zeros(AUDIO_LENGTH, dtype=np.float32)}, + instructions={generator_name: list(instructions)}, + config=Config(), + coefficient=1.0, + audio_filepath=Path("/dev/null"), + ) + reconstruction.update_generator_data( + generator_name, + list(instructions), + np.ones(AUDIO_LENGTH, dtype=np.float32), + reconstruction.initial_pitches[generator_name], + held_features, + ) + return reconstruction + + +def _voice( + generator_name: GeneratorName, + instructions: Sequence[InstructionUnion], + held_features: Iterable[FeatureKey], +) -> SampleVoice: + return SampleVoice.read(_reconstruction(generator_name, instructions, held_features), generator_name) + + +def _channel_values() -> Dict[FeatureKey, int]: + return CHANNEL_FEATURE_DEFAULTS.copy() + + +class TestAFrameSoundsAsTheInstrumentWroteIt(BaseTestSuite): + """An instrument writing every dimension sounds its frames exactly as it holds them.""" + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + generator_name: GeneratorName + instructions: List[InstructionUnion] + + test_cases = ( + TestCase( + label="pulse", + generator_name=GeneratorName.PULSE1, + instructions=[ + PulseInstruction( + on=True, + pitch=REFERENCE_PITCH, + volume=SAMPLE_VOLUME, + duty_cycle=DUTY_CYCLE, + ) + ], + ), + TestCase( + label="triangle", + generator_name=GeneratorName.TRIANGLE, + instructions=[TriangleInstruction(on=True, pitch=REFERENCE_PITCH)], + ), + TestCase( + label="noise", + generator_name=GeneratorName.NOISE, + instructions=[ + NoiseInstruction( + on=True, + period=REFERENCE_PERIOD, + volume=SAMPLE_VOLUME, + short=True, + ) + ], + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_frame_plays_as_it_stands(self, test_case: TestCase) -> None: + voice = _voice(test_case.generator_name, test_case.instructions, ()) + + assert voice.sound(test_case.instructions[0], _channel_values()) == test_case.instructions[0] + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_channel_takes_up_what_the_instrument_writes(self, test_case: TestCase) -> None: + voice = _voice(test_case.generator_name, test_case.instructions, ()) + values = _channel_values() + + voice.sound(test_case.instructions[0], values) + + assert values[FeatureKey.ARPEGGIO] == 0 + assert values[FeatureKey.VOLUME] == (MAX_VOLUME if test_case.label == "triangle" else SAMPLE_VOLUME) + + +class TestAHeldDimensionSoundsAtTheChannelsValue: + """A dimension the instrument leaves empty is the channel's, so it sounds at the value it holds.""" + + _INSTRUCTION = PulseInstruction( + on=True, + pitch=REFERENCE_PITCH, + volume=SAMPLE_VOLUME, + duty_cycle=DUTY_CYCLE, + ) + + def test_the_channels_level_carries_over_the_frame(self) -> None: + voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) + values = _channel_values() + values[FeatureKey.VOLUME] = CHANNEL_VOLUME + + assert voice.sound(self._INSTRUCTION, values).volume == CHANNEL_VOLUME + + def test_a_held_dimension_leaves_the_channels_value_where_it_stands(self) -> None: + voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) + values = _channel_values() + values[FeatureKey.VOLUME] = CHANNEL_VOLUME + + voice.sound(self._INSTRUCTION, values) + + assert values[FeatureKey.VOLUME] == CHANNEL_VOLUME + + def test_the_dimensions_the_instrument_writes_still_sound_its_own(self) -> None: + voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) + + sounded = voice.sound(self._INSTRUCTION, _channel_values()) + + assert sounded.pitch == REFERENCE_PITCH + assert sounded.duty_cycle == DUTY_CYCLE + + def test_a_level_one_instrument_wrote_is_what_the_next_one_holds(self) -> None: + """The channel carries a value across samples, which is what makes an empty envelope mean this.""" + writes = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], ()) + holds = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) + values = _channel_values() + + writes.sound(self._INSTRUCTION, values) + + assert holds.sound(self._INSTRUCTION, values).volume == SAMPLE_VOLUME + + def test_an_instrument_holding_its_level_sounds_a_silent_frame(self) -> None: + """Silence is stated by a volume envelope, so an instrument leaving one out plays on.""" + rest = PulseInstruction.null_instruction() + voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION, rest], (FeatureKey.VOLUME,)) + + assert voice.sound(rest, _channel_values()).on is True + + def test_a_silent_frame_takes_the_channel_to_silence_where_the_instrument_writes_its_level(self) -> None: + rest = PulseInstruction.null_instruction() + voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION, rest], ()) + values = _channel_values() + + assert voice.sound(rest, values).on is False + assert values[FeatureKey.VOLUME] == 0 + + def test_a_silent_frame_leaves_the_other_dimensions_where_the_channel_holds_them(self) -> None: + rest = PulseInstruction.null_instruction() + voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION, rest], ()) + values = _channel_values() + values[FeatureKey.DUTY_CYCLE] = DUTY_CYCLE + + voice.sound(rest, values) + + assert values[FeatureKey.DUTY_CYCLE] == DUTY_CYCLE diff --git a/tests/unit/sampletones_core/exporters/test_exporter.py b/tests/unit/sampletones_core/exporters/test_exporter.py index 349a4049f..4b07b3955 100644 --- a/tests/unit/sampletones_core/exporters/test_exporter.py +++ b/tests/unit/sampletones_core/exporters/test_exporter.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, Callable, Final, List, Optional, Sequence, Tuple +from typing import Any, Callable, Dict, Final, List, Optional, Sequence, Tuple import numpy as np import pytest @@ -439,3 +439,111 @@ def test_an_instrument_holding_every_dimension_describes_no_frame(self) -> None: ) assert PulseExporter.from_features(features) == [] + + +class TestSingleFrameReading(BaseTestSuite): + """One frame reads into envelope values and back, which is what a player works a tick in. + + A song plays a sample frame by frame and fills in the dimensions its instrument leaves to + the channel, so the two directions `to_features` and `from_features` run over a whole + sequence are needed over a single frame as well. + """ + + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + exporter: ExporterTypeUnion + instruction: InstructionUnion + silent: InstructionUnion + reference: int + expected: Dict[FeatureKey, int] + + test_cases = ( + TestCase( + label="pulse", + exporter=PulseExporter, + instruction=PulseInstruction( + on=True, + pitch=REFERENCE_PITCH + OCTAVE, + volume=PULSE_VOLUME, + duty_cycle=1, + ), + silent=PulseInstruction.null_instruction(), + reference=REFERENCE_PITCH, + expected={ + FeatureKey.VOLUME: PULSE_VOLUME, + FeatureKey.ARPEGGIO: OCTAVE, + FeatureKey.DUTY_CYCLE: 1, + }, + ), + TestCase( + label="triangle", + exporter=TriangleExporter, + instruction=TriangleInstruction(on=True, pitch=REFERENCE_PITCH - OCTAVE), + silent=TriangleInstruction.null_instruction(), + reference=REFERENCE_PITCH, + expected={ + FeatureKey.VOLUME: MAX_VOLUME, + FeatureKey.ARPEGGIO: -OCTAVE, + }, + ), + TestCase( + label="noise", + exporter=NoiseExporter, + instruction=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD + PERIOD_STEP, + volume=NOISE_VOLUME, + short=True, + ), + silent=NoiseInstruction.null_instruction(), + reference=REFERENCE_PERIOD, + expected={ + FeatureKey.VOLUME: NOISE_VOLUME, + FeatureKey.ARPEGGIO: PERIOD_STEP, + FeatureKey.DUTY_CYCLE: 1, + }, + ), + ) + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_sounding_frame_states_every_dimension(self, test_case: TestCase) -> None: + values = test_case.exporter.feature_values(test_case.instruction, test_case.reference) + + assert values == test_case.expected + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_silent_frame_states_its_level_alone(self, test_case: TestCase) -> None: + """The rest is the channel's, which is how a sequence holds its pitch across a rest.""" + values = test_case.exporter.feature_values(test_case.silent, test_case.reference) + + assert values == {FeatureKey.VOLUME: 0} + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_values_a_frame_states_sound_it_back(self, test_case: TestCase) -> None: + values = test_case.exporter.feature_values(test_case.instruction, test_case.reference) + + assert test_case.exporter.instruction_from_values(values, test_case.reference) == test_case.instruction + + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_dimension_the_channel_reads_nothing_from_is_passed_over(self, test_case: TestCase) -> None: + """One set of channel values serves every channel, so each takes the dimensions it reads.""" + values = dict(test_case.expected) + values[FeatureKey.HI_PITCH] = 3 + + assert test_case.exporter.instruction_from_values(values, test_case.reference) == test_case.instruction diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index e9285f7ee..3879dad4d 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -55,6 +55,20 @@ def _reconstruction(instructions: List[PulseInstruction]) -> Reconstruction: ) +def _saved_playing_channels_only(path: Path) -> Path: + """Writes a reconstruction the way a file saved before the channel set holds one. + + Such a file names a stream for the channels it plays, leaving the rest to be filled in + on the way back. + """ + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + reconstruction.instructions_data = [ + item for item in reconstruction.instructions_data if item.generator_name == GeneratorName.PULSE1 + ] + reconstruction.save(path) + return path + + class TestRoundTrip: def test_save_load_round_trip( self, @@ -426,6 +440,27 @@ def test_the_channel_set_survives_a_save_load_round_trip(self, tmp_path: Path) - assert loaded.playing_generators == reconstruction.playing_generators assert loaded.initial_pitches == reconstruction.initial_pitches + def test_a_file_storing_fewer_streams_reads_as_the_whole_channel_set(self, tmp_path: Path) -> None: + loaded = Reconstruction.load(_saved_playing_channels_only(tmp_path / "one_channel.stn")) + + assert set(loaded.instructions) == set(GeneratorName.items()) + assert loaded.playing_generators == (GeneratorName.PULSE1,) + assert loaded.initial_pitches[GeneratorName.NOISE] == resting_reference(GeneratorName.NOISE) + assert not loaded.export()[GeneratorName.TRIANGLE].has_frames + + def test_editing_such_a_file_writes_the_whole_channel_set(self, tmp_path: Path) -> None: + loaded = Reconstruction.load(_saved_playing_channels_only(tmp_path / "one_channel.stn")) + + loaded.update_generator_data( + GeneratorName.PULSE2, + [_pulse(_BASE_PITCH)], + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _BASE_PITCH, + (), + ) + + assert [item.generator_name for item in loaded.instructions_data] == list(GeneratorName.items()) + class TestWithNesFrequency: def test_rebuilds_config(self, reconstruction_factory: ReconstructionFactory) -> None: From a12ceb522c032d16c1a217a525dc11242c20f98e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 22:52:05 +0200 Subject: [PATCH 100/152] Changed: Bitphase interpretation of empty envelope --- docs/formats/bitphase.md | 18 +++++-- docs/guide/interface.md | 4 +- .../formats/bitphase/envelopes.py | 39 ++++++++++++--- .../formats/bitphase/test_envelopes.py | 49 +++++++++++++++++++ 4 files changed, 96 insertions(+), 14 deletions(-) diff --git a/docs/formats/bitphase.md b/docs/formats/bitphase.md index 5973e08f6..e0693bb64 100644 --- a/docs/formats/bitphase.md +++ b/docs/formats/bitphase.md @@ -69,7 +69,7 @@ carries every register value the channel takes for that tick. From | Field | Range | Runtime meaning | What the exporter writes | | --- | --- | --- | --- | | `pulseWidth` | 0–3 | square duty cycle; on the noise channel, any nonzero value selects the short LFSR | the duty-cycle envelope item (squares), the short/long mode (noise), a flat value (triangle) | -| `volumeOrRate` | 0–15 | the literal channel volume while `envelope` stays off | the volume envelope item | +| `volumeOrRate` | 0–15 | the literal channel volume while `envelope` stays off | the volume envelope item, or a full level where the slice leaves its volume to the channel | | `envelope` | bool | reads `volumeOrRate` as a hardware decay rate | `false`, so each item is the volume itself | | `soundLength` | 0–511 | length counter in ticks; `0` holds the note | `0`, so the volume envelope alone shapes the note | | `toneAdd` | −4096–4095 | period offset added to the tuning-table period (squares and triangle) | `0` in a document, the pitch contour in a preset | @@ -79,10 +79,18 @@ carries every register value the channel takes for that tick. From **Looping.** Playback returns to the instrument's `loop` row once it runs off the end, which is the only mode there is. A looping slice therefore sets `loop = 0` so its -envelopes repeat from the start while the note is held; a one-shot sets -`loop = len - 1`, and since the volume envelope ends on a note-off item, the -instrument rests in silence once it has played through. A sample's `loop` flag drives -this, the same flag the FamiTracker exporter reads. +envelopes repeat from the start while the note is held; a one-shot sets `loop = len - 1` +and rests on the level that row carries — silence where the volume envelope ends on a +note-off item, the channel's own level where the slice holds its volume. A sample's +`loop` flag drives this, the same flag the FamiTracker exporter reads. + +**A held volume.** A slice whose volume envelope carries no item leaves its level to the +channel, so the exporter writes a full `volumeOrRate` for every frame the slice +describes. Playback combines a row's level with the pattern's volume column through a +PT3 volume table, where a full-level row comes out at the column's own level, so those +rows sound at whatever level the channel carries — the same reading FamiTracker gives a +disabled volume sequence. A slice describing no frame at all is what writes a single +silent row, the smallest instrument Bitphase plays. **Equal lengths.** Instrument rows and table rows advance on independent per-tick counters, so they share a length and a loop point and stay in step for as long as the diff --git a/docs/guide/interface.md b/docs/guide/interface.md index efdef079e..9d2dc885f 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -51,7 +51,9 @@ Sequencer** (see the [sequencer guide](sequencer.md)). For finer control, the **Instruments** panel on the right shows each channel's instrument — its pitch, volume, arpeggio, and duty sequences — which you can edit -by dragging the bars or typing values. **Export instrument...** writes the channel +by dragging the bars or typing values. Clearing a sequence hands that dimension to +the channel, so an instrument with no volume sequence plays at whatever level its +channel carries. **Export instrument...** writes the channel on show, for whichever tracker the save dialog's file type names — see [where your files live](files.md#exported-files). diff --git a/src/sampletones_core/formats/bitphase/envelopes.py b/src/sampletones_core/formats/bitphase/envelopes.py index 917e4f3e8..abea08b43 100644 --- a/src/sampletones_core/formats/bitphase/envelopes.py +++ b/src/sampletones_core/formats/bitphase/envelopes.py @@ -11,6 +11,7 @@ from sampletones_core.formats.bitphase.specification.instruments import ( FLAT_PULSE_WIDTH, LOOP_FROM_START, + MAX_VOLUME_OR_RATE, NO_TABLE_OFFSET, NOISE_MODE_LONG, NOISE_MODE_SHORT, @@ -70,6 +71,22 @@ def _table_offset(generator: GeneratorName, arpeggio: int) -> int: return arpeggio +def _held_volume(frames: int) -> Tuple[int, ...]: + """The volume envelope of a slice whose level the channel governs. + + Bitphase combines each row's level with the pattern's volume column, and a full-level + row comes out at the column's own level, so an instrument holding one for every frame + it describes sounds at whatever level the channel carries. + + Args: + frames: The frames the slice describes. + + Returns: + Tuple[int, ...]: One full-level item per frame. + """ + return (MAX_VOLUME_OR_RATE,) * frames + + def features_to_envelopes( features: Features, generator: GeneratorName, @@ -80,9 +97,14 @@ def features_to_envelopes( Volume becomes the instrument's per-tick level, the duty cycle becomes the channel's waveform field, and the arpeggio becomes the table contour that moves the note. A - looping slice returns to its first row so it sustains for as long as the note is - held; a one-shot returns to its last row, which the volume envelope already leaves - silent, so it rests there once it has played through. + slice that leaves its volume to the channel takes a full level for every frame it + describes, so the channel governs how loud it sounds. A looping slice returns to its + first row so it sustains for as long as the note is held; a one-shot returns to its + last row, resting on the level its volume envelope ends with — silence where the + slice writes its own, the channel's level where it holds one. + + A slice describing no frame comes back as the one silent row that is the smallest + instrument Bitphase plays. Args: features: The per-dimension envelopes describing the slice. @@ -98,18 +120,19 @@ def features_to_envelopes( FeatureKey.DUTY_CYCLE: features.duty_cycle, } items = equalize_lengths({key: _to_items(array) for key, array in arrays.items()}, loop) + frames = max(len(values) for values in items.values()) - volumes = items[FeatureKey.VOLUME] - arpeggios = items[FeatureKey.ARPEGGIO] - duty_cycles = items[FeatureKey.DUTY_CYCLE] - - if not volumes: + if not frames: return ChannelEnvelopes( rows=(SILENT_ROW,), table_rows=(NO_TABLE_OFFSET,), loop=LOOP_FROM_START, ) + volumes = items[FeatureKey.VOLUME] or _held_volume(frames) + arpeggios = items[FeatureKey.ARPEGGIO] + duty_cycles = items[FeatureKey.DUTY_CYCLE] + rows = tuple( NesInstrumentRow( pulse_width=_pulse_width(generator, duty_cycles[frame] if duty_cycles else FLAT_PULSE_WIDTH), diff --git a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py index 1d409e745..414cbc07f 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_envelopes.py @@ -12,6 +12,7 @@ from sampletones_core.formats.bitphase.specification.instruments import ( FLAT_PULSE_WIDTH, LOOP_FROM_START, + MAX_VOLUME_OR_RATE, NO_TABLE_OFFSET, NOISE_MODE_LONG, NOISE_MODE_SHORT, @@ -166,6 +167,54 @@ def test_the_loop_row_exists_in_both_lists(self, loop: bool) -> None: assert envelopes.loop < len(envelopes.table_rows) +class TestASliceThatLeavesItsVolumeToTheChannel: + """An instrument with no volume envelope sounds at the level its channel carries, so + every frame it describes reaches Bitphase as a full-level row. + """ + + def test_it_holds_a_full_row_per_frame(self) -> None: + envelopes = features_to_envelopes( + build_features([], arpeggio=PITCH_CONTOUR), + GeneratorName.PULSE1, + loop=False, + ) + assert [row.volume_or_rate for row in envelopes.rows] == [MAX_VOLUME_OR_RATE] * len(PITCH_CONTOUR) + + def test_its_contour_still_moves_the_note(self) -> None: + envelopes = features_to_envelopes( + build_features([], arpeggio=PITCH_CONTOUR), + GeneratorName.PULSE1, + loop=False, + ) + assert list(envelopes.table_rows) == PITCH_CONTOUR + + def test_its_duty_envelope_still_reaches_the_rows(self) -> None: + duty_cycles = [0, 1, 2, 3] + envelopes = features_to_envelopes( + build_features([], duty_cycle=duty_cycles), + GeneratorName.PULSE1, + loop=False, + ) + assert [row.pulse_width for row in envelopes.rows] == duty_cycles + + def test_a_one_shot_rests_at_the_level_the_channel_holds(self) -> None: + envelopes = features_to_envelopes( + build_features([], arpeggio=PITCH_CONTOUR), + GeneratorName.PULSE1, + loop=False, + ) + assert envelopes.rows[envelopes.loop].volume_or_rate == MAX_VOLUME_OR_RATE + + def test_a_looping_slice_takes_the_length_its_contour_states(self) -> None: + envelopes = features_to_envelopes( + build_features([], arpeggio=PITCH_CONTOUR), + GeneratorName.PULSE1, + loop=True, + ) + assert len(envelopes.rows) == len(PITCH_CONTOUR) + assert envelopes.loop == LOOP_FROM_START + + class TestAnEmptySlice: """An instrument holds at least one row, so a slice with no volume envelope still reaches Bitphase as a playable silent instrument. From c69fa4eb137894ff72aca3aeba55a51bd49eb525 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Wed, 12 Aug 2026 23:35:10 +0200 Subject: [PATCH 101/152] Added: sample size to the samples context menu --- docs/guide/interface.md | 4 +- docs/guide/sequencer.md | 6 +- .../categories/context.py | 22 +++ .../coordinators/tabs/sequencer.py | 2 + .../logic/sequencer/samples.py | 40 ++++- .../parameters/sequencer.py | 3 + .../ui/elements/context_menu.py | 32 +++- .../ui/elements/tree/tree.py | 43 +++-- src/sampletones_application/ui/menu.py | 10 +- .../ui/panels/sequencer/samples.py | 95 +++++++++-- .../logic/sequencer/test_samples.py | 51 ++++++ .../ui/panels/sequencer/test_samples_menu.py | 155 +++++++++++++++++- 12 files changed, 420 insertions(+), 43 deletions(-) diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 9d2dc885f..69742a960 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -53,7 +53,9 @@ For finer control, the **Instruments** panel on the right shows each channel's instrument — its pitch, volume, arpeggio, and duty sequences — which you can edit by dragging the bars or typing values. Clearing a sequence hands that dimension to the channel, so an instrument with no volume sequence plays at whatever level its -channel carries. **Export instrument...** writes the channel +channel carries. Beside each channel is the room its instrument takes on the NES, +with the whole sample's above them, so you can see what an edit costs. +**Export instrument...** writes the channel on show, for whichever tracker the save dialog's file type names — see [where your files live](files.md#exported-files). diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index d0e24d901..20c3502e3 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -20,8 +20,10 @@ frequency**; **Add anyway** adds it regardless. Manage the imported samples in the **Samples** list on the right: right-click one to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder it, and toggle its **Loop** flag. The **Edit** menu carries the same actions for the sample you have -picked. Removing a sample that patterns still use asks **Remove sample** first, -because it clears every row that references it. +picked. The right-click menu also names how much room the sample takes on the NES — +its total, then each channel it plays — measured as its **Loop** flag has it. +Removing a sample that patterns still use asks **Remove sample** first, because it +clears every row that references it. ## Writing a pattern diff --git a/src/sampletones_application/categories/context.py b/src/sampletones_application/categories/context.py index 69d18a9b7..30a98bcb7 100644 --- a/src/sampletones_application/categories/context.py +++ b/src/sampletones_application/categories/context.py @@ -1,6 +1,16 @@ +from typing import Dict, Final + from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager +from sampletones_core.constants.enums import GeneratorName + +CHANNEL_ELEMENTS: Final[Dict[GeneratorName, ContextElements]] = { + GeneratorName.PULSE1: ContextElements.PULSE_1, + GeneratorName.PULSE2: ContextElements.PULSE_2, + GeneratorName.TRIANGLE: ContextElements.TRIANGLE, + GeneratorName.NOISE: ContextElements.NOISE, +} def context_label( @@ -19,3 +29,15 @@ def context_label( TextType.LABEL, element, ] + + +def channel_label( + language_manager: LanguageManager, + generator: GeneratorName, +) -> str: + """Resolves an NES channel's name, the words every display naming a channel prints. + + The playback menu's mix, the samples menu's byte figures and anything else addressing a + channel read it from one entry, so a reader meets the same name for the same channel. + """ + return context_label(language_manager, CHANNEL_ELEMENTS[generator]) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index c53a14e2d..5b7f7f4e9 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -276,6 +276,7 @@ def __init__( ) self._sequencer_samples_panel: GUISequencerSamplesPanel = GUISequencerSamplesPanel( layout=layout.sequencer, + detail_color=layout.muted_color, initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_INSTRUMENTS_PANEL), language_manager=language_manager, key_router=key_router, @@ -601,6 +602,7 @@ def _wire_samples_callbacks(self) -> None: self._sequencer_samples_logic.on_samples_changed = self._on_samples_changed self._sequencer_samples_logic.on_edit_sample_requested = self._dispatch_edit_sample self._sequencer_samples_logic.on_autoplay_error = self._on_preview_error + self._sequencer_samples_panel.sample_footprint = self._sequencer_samples_logic.build_sample_footprint self._sequencer_samples_panel.on_sample_selected = self._on_sample_selected self._sequencer_samples_panel.on_sample_edit_requested = self._sequencer_samples_logic.request_edit self._sequencer_samples_panel.on_loop_changed = self._undoable( diff --git a/src/sampletones_application/logic/sequencer/samples.py b/src/sampletones_application/logic/sequencer/samples.py index 4f4052b8b..49051bf3c 100644 --- a/src/sampletones_application/logic/sequencer/samples.py +++ b/src/sampletones_application/logic/sequencer/samples.py @@ -1,7 +1,9 @@ from typing import Callable, Optional from sampletones_application.config.managers.session import SessionManager -from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior +from sampletones_application.layout.behavior.scheduling.scheduling import ( + SchedulingBehavior, +) from sampletones_application.logic.project.controller import ProjectController from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.utils.callbacks.queue import CallbackQueue @@ -9,7 +11,9 @@ SampleEntryViewModel, SequencerSamplesViewModel, ) +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.audio import AudioDeviceManager +from sampletones_core.formats.famitracker.footprint import reconstruction_footprints from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.utils.display import display_sample @@ -73,12 +77,37 @@ def rename_sample(self, sample_id: str, name: str) -> None: def is_sample_used(self, sample_id: str) -> bool: return self._controller.is_sample_used(sample_id) + def build_sample_footprint(self, sample_id: str) -> Optional[SampleFootprintViewModel]: + """Measures one sample's instruments as the module export writes them. + + A sample carries its own loop flag, and a looping instrument is compiled to the shortest + length its envelopes share, so the sample is measured the way it is placed. Measuring a + single sample on demand keeps a pool edit clear of an export it was not asked for. + + Args: + sample_id: The sample to measure. + + Returns: + Optional[SampleFootprintViewModel]: The sample's byte figures, or ``None`` while the + pool holds no such sample. + """ + sample = self._controller.project.samples.get(sample_id) + if sample is None: + return None + + return SampleFootprintViewModel.from_footprints( + reconstruction_footprints(sample.reconstruction, loop=sample.loop) + ) + def sample_name(self, sample_id: str) -> str: return self._controller.project.samples[sample_id].name def sample_position(self, sample_id: str) -> str: """Returns the sample's hex list position, matching how the tracker labels it.""" - return display_sample(samples=self._controller.project.samples, sample_id=sample_id) + return display_sample( + samples=self._controller.project.samples, + sample_id=sample_id, + ) def remove_sample(self, sample_id: str) -> None: self._controller.remove_sample(sample_id) @@ -125,7 +154,12 @@ def _execute_autoplay(self) -> None: if self._session_manager.autoplay: self._play_sample(sample_id, priority=PlaybackPriority.PREVIEW) - def _play_sample(self, sample_id: str, *, priority: PlaybackPriority) -> None: + def _play_sample( + self, + sample_id: str, + *, + priority: PlaybackPriority, + ) -> None: sample = self._controller.project.samples.get(sample_id) if sample is None: return diff --git a/src/sampletones_application/parameters/sequencer.py b/src/sampletones_application/parameters/sequencer.py index 04a3c820a..d071ee3c6 100644 --- a/src/sampletones_application/parameters/sequencer.py +++ b/src/sampletones_application/parameters/sequencer.py @@ -10,6 +10,7 @@ from sampletones_application.layout.tabs.sequencer import SequencerLayout from sampletones_application.parameters.geometry import TabGeometry from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.utils.palette.colors.base import BaseColor @dataclass(frozen=True) @@ -33,6 +34,7 @@ class SequencerTabParameters: plus_minus: PlusMinusButtonsLayout feature_colors: FeatureColors tree_colors: TreeColors + muted_color: BaseColor scheduling: SchedulingBehavior @classmethod @@ -52,5 +54,6 @@ def from_config(cls, config: LayoutConfig) -> SequencerTabParameters: general.colors, accent=general.colors.headers.reconstruction, ), + muted_color=general.colors.text.disabled, scheduling=config.behavior.scheduling, ) diff --git a/src/sampletones_application/ui/elements/context_menu.py b/src/sampletones_application/ui/elements/context_menu.py index b882407de..0e456f6d9 100644 --- a/src/sampletones_application/ui/elements/context_menu.py +++ b/src/sampletones_application/ui/elements/context_menu.py @@ -1,8 +1,12 @@ import contextlib -from typing import Iterator +from typing import Iterator, Sequence, Tuple import dearpygui.dearpygui as dpg +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.elements.fonts.registry import FontRegistry +from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color +from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_shared.types.callback import VoidCallback @@ -43,3 +47,29 @@ def add_play_menu_item( shortcut=shortcut, callback=on_play, ) + + +def add_detail_items( + items: Sequence[Tuple[str, str]], + *, + color: BaseColor, +) -> None: + """Add a block of read-only ``label: value`` lines to the context menu being built. + + A menu states what its target is alongside what can be done to it: a file browser prints the + settings a reconstruction was made with, and the samples menu prints the bytes a sample + occupies. Both read as the same tinted, monospaced block under a separator of its own, so the + facts stay apart from the items a reader clicks. + + Args: + items: The label and value of each line, in the order the menu prints them. + color: The tint the lines take, which marks them as facts rather than actions. + """ + if not items: + return + + dpg.add_separator() + for label, value in items: + detail_text = dpg.add_text(f"{label}: {value}") + dpg_set_palette_color(detail_text, color) + FontRegistry.bind_to_item(detail_text, Font.MONO_SMALL) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 3c7a2ad74..18b37a051 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -33,7 +33,10 @@ TAG_INSTRUCTIONS_LIBRARY_THEME_INSTRUCTION, ) from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.context_menu import add_play_menu_item +from sampletones_application.ui.elements.context_menu import ( + add_detail_items, + add_play_menu_item, +) from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel @@ -390,7 +393,11 @@ def double_click_callback( ) -> None: user_data = dpg.get_item_user_data(app_data[1]) if item_double_click_callback is not None: - item_double_click_callback(sender, app_data, user_data=user_data) + item_double_click_callback( + sender, + app_data, + user_data=user_data, + ) return double_click_callback @@ -570,22 +577,17 @@ def _reconstruction_detail_items(self, directory_name: str) -> List[Tuple[str, s ] def _add_context_menu_details(self, node: TreeNode) -> None: - detail_items = self._node_detail_items(node) - if not detail_items: - return - - dpg.add_separator() - for label, value in detail_items: - detail_text = dpg.add_text(f"{label}: {value}") - dpg_set_palette_color(detail_text, self._colors.muted) - FontRegistry.bind_to_item(detail_text, Font.MONO_SMALL) + add_detail_items(self._node_detail_items(node), color=self._colors.muted) def _add_context_menu_play_item(self, node: FileSystemNode) -> None: if not self._logic.is_playable_file(node): return dpg.add_separator() - add_play_menu_item(self._language_manager["global.context.label.play"], lambda: self._logic.play_node(node)) + add_play_menu_item( + self._language_manager["global.context.label.play"], + lambda: self._logic.play_node(node), + ) def _add_context_menu_path_items(self, path: Path) -> None: dpg.add_separator() @@ -723,7 +725,11 @@ def _update_node_visibility_recursive(self, node: TreeNode) -> None: for child in node.children: self._update_node_visibility_recursive(child) - def apply_filter(self, query: str, predicate: Callable[[TreeNode, str], bool]) -> None: + def apply_filter( + self, + query: str, + predicate: Callable[[TreeNode, str], bool], + ) -> None: self.tree.apply_filter(query, predicate) def clear_filter(self) -> None: @@ -759,7 +765,10 @@ def _resolve_node_theme_tag( if isinstance(node, FileSystemNode): match node.node_type: case NodeType.DIRECTORY: - return self._resolve_directory_theme_tag(node, has_favorite_ancestor=has_favorite_ancestor) + return self._resolve_directory_theme_tag( + node, + has_favorite_ancestor=has_favorite_ancestor, + ) case NodeType.FILE: return self._resolve_file_theme_tag( node, @@ -823,7 +832,11 @@ def _resolve_other_theme_tag(self, node: TreeNode) -> str: case _: return TAG_GLOBAL_THEME_DEFAULT - def _reapply_theme_recursively(self, node: FileSystemNode, has_favorite_ancestor: bool = False) -> None: + def _reapply_theme_recursively( + self, + node: FileSystemNode, + has_favorite_ancestor: bool = False, + ) -> None: node_tag = self._generate_node_tag(node) if not dpg.does_item_exist(node_tag): return diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 377429b8d..0894f5337 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -3,7 +3,7 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import context_label +from sampletones_application.categories.context import channel_label, context_label from sampletones_application.categories.elements.global_ import ( ContextElements, MenuElements, @@ -113,12 +113,6 @@ ContextElements.PASTE, ContextElements.DELETE, ) -CHANNEL_LABELS: Final[Dict[GeneratorName, ContextElements]] = { - GeneratorName.PULSE1: ContextElements.PULSE_1, - GeneratorName.PULSE2: ContextElements.PULSE_2, - GeneratorName.TRIANGLE: ContextElements.TRIANGLE, - GeneratorName.NOISE: ContextElements.NOISE, -} class MenuBar: @@ -516,7 +510,7 @@ def _create_channels_menu(self, state: MenuBarViewModel) -> None: shortcut_id, callback=partial(self._on_channel_muted, generator), tag=self._channel_menu_item_tag(generator), - label=self._context_label(CHANNEL_LABELS[generator]), + label=channel_label(self._language_manager, generator), check=True, default_value=not state.channels.is_muted(generator), ) diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index 1ccc4c986..dc89d966f 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -3,9 +3,11 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import context_label +from sampletones_application.categories.context import channel_label, context_label from sampletones_application.categories.elements.global_ import ContextElements -from sampletones_application.categories.elements.sequencer import SequencerInstrumentsElements +from sampletones_application.categories.elements.sequencer import ( + SequencerInstrumentsElements, +) from sampletones_application.categories.hierarchy import Page, Panel, TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.tabs.sequencer import SequencerLayout @@ -19,6 +21,7 @@ TAG_SEQUENCER_INSTRUMENTS_WINDOW, ) from sampletones_application.ui.elements.context_menu import ( + add_detail_items, add_play_menu_item, context_menu, ) @@ -36,13 +39,16 @@ ) from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.source import ShortcutSource +from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_application.view_model.sequencer.move import MoveDirection from sampletones_application.view_model.sequencer.samples import ( SampleEntryViewModel, SampleSelection, SequencerSamplesViewModel, ) -from sampletones_core.utils.display import display_id, display_sample_label +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.utils.display import display_id from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import StringCallback @@ -89,6 +95,7 @@ def __init__( self, *, layout: SequencerLayout, + detail_color: BaseColor, language_manager: LanguageManager, key_router: KeyRouter, tab_active: ActivePredicate, @@ -97,6 +104,7 @@ def __init__( ) -> None: self._language_manager = language_manager self._layout = layout + self._detail_color = detail_color self._router = key_router self._tab_active = tab_active self._shortcuts = shortcut_source @@ -106,6 +114,9 @@ def __init__( self._selected_row: Optional[int] = None self._editing_sample_id: Optional[str] = None self._entries: Tuple[SampleEntryViewModel, ...] = () + self._lbl_sample_size = language_manager["global.context.label.sample_size"] + self._tpl_size_bytes = language_manager["global.context.template.size_bytes"] + self.sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] = None self.on_sample_selected: Optional[StringCallback] = None self.on_sample_edit_requested: Optional[StringCallback] = None self.on_loop_changed: Optional[Callable[[str, bool], None]] = None @@ -175,17 +186,26 @@ def _create_samples_table(self) -> None: ), ): dpg.add_table_column( - label=self._label(self._language_manager, SequencerInstrumentsElements.COLUMN_ID), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.COLUMN_ID, + ), width_fixed=True, init_width_or_weight=self._layout.table_cells.instrument.id, ) dpg.add_table_column( - label=self._label(self._language_manager, SequencerInstrumentsElements.COLUMN_NAME), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.COLUMN_NAME, + ), width_stretch=True, init_width_or_weight=self._layout.table_cells.instrument.name, ) dpg.add_table_column( - label=self._label(self._language_manager, SequencerInstrumentsElements.COLUMN_LOOP), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.COLUMN_LOOP, + ), width_fixed=True, init_width_or_weight=self._layout.table_cells.instrument.loop, ) @@ -211,7 +231,11 @@ def _rebuild(self) -> None: if self._selected_row is None: self._selected_sample_id = None - def _build_sample_row(self, position: int, entry: SampleEntryViewModel) -> None: + def _build_sample_row( + self, + position: int, + entry: SampleEntryViewModel, + ) -> None: row_id = dpg.add_table_row(parent=TAG_SEQUENCER_INSTRUMENTS_TABLE) self._build_id_cell(row_id, position, entry) self._build_name_cell(row_id, position, entry) @@ -530,6 +554,10 @@ def _show_context_menu(self, position: int, sample_id: str) -> None: with context_menu(): header = dpg.add_text(target.label) FontRegistry.bind_to_item(header, Font.MONO_BOLD) + add_detail_items( + self._footprint_items(sample_id), + color=self._detail_color, + ) dpg.add_separator() add_play_menu_item( context_label(self._language_manager, ContextElements.PLAY), @@ -541,6 +569,33 @@ def _show_context_menu(self, position: int, sample_id: str) -> None: dpg.add_separator() self.add_action_items(target) + def _footprint_items(self, sample_id: str) -> List[Tuple[str, str]]: + """The byte figures the menu prints for a sample: its total, then each channel that plays. + + The figures are asked for as the menu opens, so they name what the sample occupies at the + moment a reader looks. A channel standing by is written by no export, so it costs nothing + and the menu names the channels that do. + """ + footprint = self.query(self.sample_footprint, sample_id, default=None) + if footprint is None: + return [] + + items = [(self._lbl_sample_size, self._format_size(footprint.total_bytes))] + for generator_name in GeneratorName.items(): + instrument_bytes = footprint.bytes_for(generator_name) + if instrument_bytes is not None: + items.append( + ( + channel_label(self._language_manager, generator_name), + self._format_size(instrument_bytes), + ) + ) + + return items + + def _format_size(self, byte_count: int) -> str: + return self._tpl_size_bytes.format(bytes=byte_count) + def owns_edit_actions(self) -> bool: """Whether the Edit menu states this panel's actions, which it does while it holds a sample. @@ -563,21 +618,33 @@ def add_action_items(self, target: SampleSelection) -> None: selection holds. An action added here reaches both, printing the key it answers to. """ dpg.add_menu_item( - label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_EDIT), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.CONTEXT_EDIT, + ), callback=lambda: self.call(self.on_sample_edit_requested, target.sample_id), ) dpg.add_menu_item( - label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_RENAME), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.CONTEXT_RENAME, + ), shortcut=self._shortcuts.display(ShortcutId.SAMPLES_RENAME_SAMPLE), callback=lambda: self._start_rename(target.sample_id), ) dpg.add_menu_item( - label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_DUPLICATE), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.CONTEXT_DUPLICATE, + ), callback=lambda: self.call(self.on_duplicate_requested, target.sample_id), ) dpg.add_separator() dpg.add_menu_item( - label=self._label(self._language_manager, SequencerInstrumentsElements.CONTEXT_REMOVE), + label=self._label( + self._language_manager, + SequencerInstrumentsElements.CONTEXT_REMOVE, + ), shortcut=self._shortcuts.display(ShortcutId.SAMPLES_REMOVE_SAMPLE), callback=lambda: self.call(self.on_remove_requested, target.sample_id), ) @@ -596,7 +663,11 @@ def _add_move_item( label=self._label(self._language_manager, move.element), shortcut=self._shortcuts.display(move.shortcut), enabled=position is not None, - callback=lambda: self.call(self.on_move_requested, target.sample_id, position), + callback=lambda: self.call( + self.on_move_requested, + target.sample_id, + position, + ), ) @staticmethod diff --git a/tests/unit/sampletones_application/logic/sequencer/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/test_samples.py index c67407732..d4c8c5b5f 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_samples.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_samples.py @@ -7,9 +7,12 @@ from sampletones_application.logic.project.manager import ProjectManager from sampletones_application.logic.sequencer.samples import SequencerSamplesLogic from sampletones_application.logic.shared.playback_priority import PlaybackPriority +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.famitracker.footprint import reconstruction_footprints from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.reconstructions import Reconstruction +from tests.suite.sequencer import sample_reconstruction def _logic() -> Tuple[ProjectController, SequencerSamplesLogic]: @@ -163,6 +166,54 @@ def test_lists_added_samples_in_insertion_order( ] +class TestBuildSampleFootprint: + """The samples menu prints what a sample occupies, measured the way the sample is placed.""" + + def test_it_names_each_playing_channel(self) -> None: + controller, logic = _logic() + generators = (GeneratorName.PULSE1, GeneratorName.TRIANGLE) + sample = controller.add_sample(sample_reconstruction(generators), name="bell") + + footprint = logic.build_sample_footprint(sample.id) + + assert footprint is not None + assert [instrument.generator for instrument in footprint.instruments] == list(generators) + + def test_it_measures_the_sample_under_its_own_loop_flag( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + controller, logic = _logic() + sample = controller.add_sample(reconstruction_factory(), name="lead") + controller.set_sample_loop(sample.id, True) + + footprint = logic.build_sample_footprint(sample.id) + + assert footprint == SampleFootprintViewModel.from_footprints( + reconstruction_footprints(sample.reconstruction, loop=True) + ) + + def test_a_looping_sample_costs_less_than_a_one_shot( + self, + reconstruction_factory: Callable[[], Reconstruction], + ) -> None: + """A looping instrument shares the shortest dimension's length, so it stores fewer items.""" + controller, logic = _logic() + sample = controller.add_sample(reconstruction_factory(), name="lead") + one_shot = logic.build_sample_footprint(sample.id) + + controller.set_sample_loop(sample.id, True) + looping = logic.build_sample_footprint(sample.id) + + assert one_shot is not None and looping is not None + assert looping.total_bytes < one_shot.total_bytes + + def test_a_sample_the_pool_has_dropped_is_measured_nowhere(self) -> None: + _, logic = _logic() + + assert logic.build_sample_footprint("missing") is None + + class TestPlaySample: def test_plays_reconstruction_regardless_of_autoplay( self, reconstruction_factory: Callable[[], Reconstruction] diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py index c2a64353d..9326e8c06 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py @@ -1,13 +1,24 @@ +import contextlib from dataclasses import dataclass, field -from typing import Any, Callable, List, Optional, Tuple +from typing import Any, Callable, Iterator, List, Optional, Tuple import pytest +from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import SequencerInstrumentsElements +from sampletones_application.ui.elements import context_menu as context_menu_module +from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.panels.sequencer import samples as samples_module from sampletones_application.ui.panels.sequencer.samples import SAMPLE_MOVES, GUISequencerSamplesPanel from sampletones_application.utils.gui.shortcuts.ids import ShortcutId +from sampletones_application.utils.palette.colors.literal import LiteralColor from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel +from sampletones_application.view_model.shared.footprint import ( + InstrumentSizeViewModel, + SampleFootprintViewModel, +) +from sampletones_core.constants.enums import GeneratorName +from sampletones_core.utils.display import display_sample_label from tests.suite.shortcuts import shipped_source ENTRIES: Tuple[SampleEntryViewModel, ...] = ( @@ -19,6 +30,19 @@ SELECTED_ID = "bass-id" SELECTED_ROW = 1 +SAMPLE_SIZE_LABEL = "Sample size" +SIZE_TEMPLATE = "{bytes} B" +DETAIL_COLOR = LiteralColor((0, 0, 0, 255)) + +PULSE_1_BYTES = 41 +NOISE_BYTES = 19 +FOOTPRINT = SampleFootprintViewModel( + instruments=( + InstrumentSizeViewModel(generator=GeneratorName.PULSE1, total_bytes=PULSE_1_BYTES), + InstrumentSizeViewModel(generator=GeneratorName.NOISE, total_bytes=NOISE_BYTES), + ), +) + EDIT_ITEM = 0 RENAME_ITEM = 1 DUPLICATE_ITEM = 2 @@ -89,6 +113,8 @@ def _panel( tab_active: bool = True, editing: Optional[str] = None, field_focused: bool = False, + footprint: Optional[SampleFootprintViewModel] = FOOTPRINT, + footprint_wired: bool = True, ) -> SamplesPanelFixture: """A samples panel whose menu builder can run with no DearPyGui context behind it.""" panel = GUISequencerSamplesPanel.__new__(GUISequencerSamplesPanel) @@ -100,6 +126,10 @@ def _panel( panel._editing_sample_id = editing panel._tab_active = lambda: tab_active panel._router = _Router(field_focused=field_focused) + panel._detail_color = DETAIL_COLOR + panel._lbl_sample_size = SAMPLE_SIZE_LABEL + panel._tpl_size_bytes = SIZE_TEMPLATE + panel.sample_footprint = (lambda _sample_id: footprint) if footprint_wired else None requests = Requests() panel.on_sample_edit_requested = requests.edited.append @@ -117,6 +147,61 @@ def __getitem__(self, key: Tuple[Any, ...]) -> str: return str(key[-1].value) +@dataclass(frozen=True) +class MenuWidget: + """One widget as the menu registered it, which is the whole of what a reader meets.""" + + kind: str + text: str + + +class _MenuBuildRecorder: + """Every widget a whole menu build registers, in the order they are printed.""" + + def __init__(self) -> None: + self.widgets: List[MenuWidget] = [] + + def add_text(self, text: str, **_kwargs: Any) -> int: + self.widgets.append(MenuWidget(kind="text", text=text)) + return 0 + + def add_separator(self, **_kwargs: Any) -> int: + self.widgets.append(MenuWidget(kind="separator", text="")) + return 0 + + def add_menu_item(self, **kwargs: Any) -> int: + self.widgets.append(MenuWidget(kind="item", text=kwargs["label"])) + return 0 + + def texts_before_the_first_item(self) -> List[str]: + widgets: List[str] = [] + for widget in self.widgets: + if widget.kind == "item": + break + if widget.kind == "text": + widgets.append(widget.text) + + return widgets + + +@contextlib.contextmanager +def _null_menu() -> Iterator[None]: + yield + + +@pytest.fixture +def build_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuBuildRecorder: + """Records a whole context-menu build, with the DearPyGui calls behind it stood down.""" + recorded = _MenuBuildRecorder() + monkeypatch.setattr(samples_module.dpg, "add_text", recorded.add_text) + monkeypatch.setattr(samples_module.dpg, "add_separator", recorded.add_separator) + monkeypatch.setattr(samples_module.dpg, "add_menu_item", recorded.add_menu_item) + monkeypatch.setattr(samples_module, "context_menu", _null_menu) + monkeypatch.setattr(context_menu_module, "dpg_set_palette_color", lambda _item, _color: None) + monkeypatch.setattr(FontRegistry, "bind_to_item", lambda _item, _font: None) + return recorded + + @dataclass(frozen=True) class _Router: """The key router as the panel's own scope reads it.""" @@ -194,6 +279,74 @@ def test_a_move_with_nowhere_to_go_is_greyed_out( assert recorder.items[MOVE_BOTTOM_ITEM].enabled +class TestTheSizeRows: + """A sample's menu names the bytes it occupies, so what a pool costs is read where it is edited.""" + + def test_the_rows_read_as_the_total_then_each_playing_channel(self, monkeypatch: pytest.MonkeyPatch) -> None: + items = _panel(monkeypatch).panel._footprint_items(SELECTED_ID) + + assert items == [ + (SAMPLE_SIZE_LABEL, f"{PULSE_1_BYTES + NOISE_BYTES} B"), + (ContextElements.PULSE_1.value, f"{PULSE_1_BYTES} B"), + (ContextElements.NOISE.value, f"{NOISE_BYTES} B"), + ] + + def test_a_channel_standing_by_is_named_nowhere(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A channel that does not play is written by no export, so it costs nothing to name.""" + labels = [label for label, _value in _panel(monkeypatch).panel._footprint_items(SELECTED_ID)] + + assert ContextElements.PULSE_2.value not in labels + assert ContextElements.TRIANGLE.value not in labels + + def test_the_figures_name_the_sample_the_pointer_landed_on(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The figures are asked for as the menu opens, so they answer for the row right-clicked.""" + measured: List[str] = [] + + def _measure(sample_id: str) -> SampleFootprintViewModel: + measured.append(sample_id) + return FOOTPRINT + + fixture = _panel(monkeypatch) + fixture.panel.sample_footprint = _measure + + fixture.panel._footprint_items("lead-id") + + assert measured == ["lead-id"] + + def test_a_sample_the_pool_has_dropped_prints_no_rows(self, monkeypatch: pytest.MonkeyPatch) -> None: + assert _panel(monkeypatch, footprint=None).panel._footprint_items(SELECTED_ID) == [] + + def test_an_unwired_hook_prints_no_rows(self, monkeypatch: pytest.MonkeyPatch) -> None: + """A panel tolerates its hooks being unset until the coordinator wires them.""" + assert _panel(monkeypatch, footprint_wired=False).panel._footprint_items(SELECTED_ID) == [] + + +class TestMenuComposition: + def test_the_sizes_sit_between_the_sample_name_and_the_actions( + self, + monkeypatch: pytest.MonkeyPatch, + build_recorder: _MenuBuildRecorder, + ) -> None: + """Pins where the figures are printed: under the name they belong to, above what can be done.""" + _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID) + + assert build_recorder.texts_before_the_first_item() == [ + display_sample_label(SELECTED_ROW, "Bass"), + f"{SAMPLE_SIZE_LABEL}: {PULSE_1_BYTES + NOISE_BYTES} B", + f"{ContextElements.PULSE_1.value}: {PULSE_1_BYTES} B", + f"{ContextElements.NOISE.value}: {NOISE_BYTES} B", + ] + + def test_a_menu_with_no_figures_reads_as_it_always_has( + self, + monkeypatch: pytest.MonkeyPatch, + build_recorder: _MenuBuildRecorder, + ) -> None: + _panel(monkeypatch, footprint=None).panel._show_context_menu(SELECTED_ROW, SELECTED_ID) + + assert build_recorder.texts_before_the_first_item() == [display_sample_label(SELECTED_ROW, "Bass")] + + class TestEditActions: def test_the_panel_answers_while_it_holds_a_selection(self, monkeypatch: pytest.MonkeyPatch) -> None: assert _panel(monkeypatch).panel.owns_edit_actions() From 84a71b2e346f3ec9299af48faa4123824016801f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 13 Aug 2026 16:21:19 +0200 Subject: [PATCH 102/152] Refactored: channel labels, pitch tooltips and footprint totals --- docs/development/guidelines.md | 2 +- .../categories/pitch.py | 96 +++++++++++++++---- .../ui/panels/instruction/choice.py | 15 +-- .../ui/panels/main/reconstructor.py | 30 ++---- .../reconstruction/instruments/instruments.py | 21 ++-- .../ui/panels/reconstruction/plot.py | 23 +---- .../ui/themes/channels.py | 16 ++++ .../view_model/shared/footprint.py | 28 ++++-- .../reconstruction/test_instruments_panel.py | 74 +++++++------- .../ui/panels/sequencer/test_samples_menu.py | 22 ++--- 10 files changed, 183 insertions(+), 144 deletions(-) create mode 100644 src/sampletones_application/ui/themes/channels.py diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 75ff89bde..313d3d535 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -31,7 +31,7 @@ These rules govern the Python in this repository. They complement 1. An `__init__` exposes only names from within its own tree hierarchy. 1. Give each module a single area of responsibility. 1. If a module contains many class and function definitions, split into a subpackage divided by a single concern. -1. If a private function serves only a class in the module it lives, move it to the class as a static/class method or isolate helper functions into a separate utility module. +1. If a private function (or public that does not have any external consumers) serves only a class in the module it lives, move it to the class as a static/class method or isolate helper functions into a separate utility module. 1. Prefer subpackages over a flat directory structure. 1. Isolate platform-, desktop-, or external-tool-specific behaviour behind a `Protocol` with one implementation per target, selected by a runtime factory that probes availability and environment. Callers depend only on the `Protocol` and stay platform-agnostic. 1. Wrap a third-party library or OS tool whose behaviour differs across platforms behind our own typed interface, and encode each quirk inside the matching implementation. A comment naming the third-party behaviour is warranted there. diff --git a/src/sampletones_application/categories/pitch.py b/src/sampletones_application/categories/pitch.py index 22bcda631..c5235a70d 100644 --- a/src/sampletones_application/categories/pitch.py +++ b/src/sampletones_application/categories/pitch.py @@ -1,21 +1,77 @@ +from dataclasses import dataclass +from typing import Self + from sampletones_application.categories.manager import LanguageManager -from sampletones_core.utils.pitch_kind import PERIOD_VALUE_KIND, PitchValueKind - - -def build_pitch_tooltip( - language_manager: LanguageManager, - kind: PitchValueKind, - template: str, -) -> str: - """Fills a pitch stepper's help template with the shared example for ``kind``: the quantity's name - ("pitch" or "period"), an example note name, and the matching numeric value. The value is resolved - from the example name through the kind itself, so the name and value the tooltip shows always agree. - Both the reconstruction and instruction steppers compose their tooltips through here, keeping one - definition of the example while each supplies its own surrounding wording via ``template``.""" - is_period = kind is PERIOD_VALUE_KIND - type_name = language_manager["global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name"] - example_name = language_manager[ - "global.pitch.label.period_example" if is_period else "global.pitch.label.pitch_example" - ] - example_value = kind.from_text(example_name, kind.minimum) - return template.format(type_name, example_name, example_value) +from sampletones_core.utils.pitch_kind import ( + PERIOD_VALUE_KIND, + PITCH_VALUE_KIND, + PitchValueKind, +) + + +@dataclass(frozen=True) +class PitchTooltips: + """A pitch stepper's help in both readings, so a panel resolves the one its field takes. + + A stepper states a pitch on the tonal channels and a period on the noise channel, and a panel + holding steppers of both kinds phrases each from the same template. Building the pair together + keeps the two readings in step and leaves the choice to the moment a field is drawn. + + Attributes: + pitch: The help a stepper reading a pitch shows. + period: The help a stepper reading a period shows. + """ + + pitch: str + period: str + + @classmethod + def build( + cls, + language_manager: LanguageManager, + template: str, + ) -> Self: + """Phrases both readings from one template. + + Args: + language_manager: Where the example note name and value are read from. + template: The panel's own surrounding wording. + + Returns: + PitchTooltips: The help in both readings. + """ + return cls( + pitch=cls.build_pitch_tooltip( + language_manager, + PITCH_VALUE_KIND, + template, + ), + period=cls.build_pitch_tooltip( + language_manager, + PERIOD_VALUE_KIND, + template, + ), + ) + + def for_kind(self, kind: PitchValueKind) -> str: + """The help a stepper of ``kind`` shows.""" + return self.period if kind is PERIOD_VALUE_KIND else self.pitch + + @staticmethod + def build_pitch_tooltip( + language_manager: LanguageManager, + kind: PitchValueKind, + template: str, + ) -> str: + """Fills a pitch stepper's help template with the shared example for ``kind``: the quantity's name + ("pitch" or "period"), an example note name, and the matching numeric value. The value is resolved + from the example name through the kind itself, so the name and value the tooltip shows always agree. + Both the reconstruction and instruction steppers compose their tooltips through here, keeping one + definition of the example while each supplies its own surrounding wording via ``template``.""" + is_period = kind is PERIOD_VALUE_KIND + type_name = language_manager["global.pitch.label.period_name" if is_period else "global.pitch.label.pitch_name"] + example_name = language_manager[ + "global.pitch.label.period_example" if is_period else "global.pitch.label.pitch_example" + ] + example_value = kind.from_text(example_name, kind.minimum) + return template.format(type_name, example_name, example_value) diff --git a/src/sampletones_application/ui/panels/instruction/choice.py b/src/sampletones_application/ui/panels/instruction/choice.py index cd6da0ae4..c21235299 100644 --- a/src/sampletones_application/ui/panels/instruction/choice.py +++ b/src/sampletones_application/ui/panels/instruction/choice.py @@ -3,7 +3,7 @@ import dearpygui.dearpygui as dpg from sampletones_application.categories.manager import LanguageManager -from sampletones_application.categories.pitch import build_pitch_tooltip +from sampletones_application.categories.pitch import PitchTooltips from sampletones_application.layout.tabs.instructions import InstructionsLayout from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import SUF_HANDLER_REGISTRY @@ -73,16 +73,9 @@ def __init__( self._pitch_stepper: Optional[GUIPitchStepper] = None self._msg_status_input = language_manager["global.status.message.input"] - tooltip_template = language_manager["instructions.details.template.pitch_tooltip_template"] - self._pitch_tooltip = build_pitch_tooltip( + self._pitch_tooltips = PitchTooltips.build( language_manager, - PITCH_VALUE_KIND, - tooltip_template, - ) - self._period_tooltip = build_pitch_tooltip( - language_manager, - PERIOD_VALUE_KIND, - tooltip_template, + language_manager["instructions.details.template.pitch_tooltip_template"], ) super().__init__( @@ -170,7 +163,7 @@ def _create_pitch_stepper( kind=kind, initial_value=initial_value, label=label, - tooltip=self._period_tooltip if is_period else self._pitch_tooltip, + tooltip=self._pitch_tooltips.for_kind(kind), status_message=( self._language_manager["instructions.details.message.status_input_period"] if is_period diff --git a/src/sampletones_application/ui/panels/main/reconstructor.py b/src/sampletones_application/ui/panels/main/reconstructor.py index df579fecb..398f686a3 100644 --- a/src/sampletones_application/ui/panels/main/reconstructor.py +++ b/src/sampletones_application/ui/panels/main/reconstructor.py @@ -2,17 +2,12 @@ import dearpygui.dearpygui as dpg +from sampletones_application.categories.context import channel_label from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.general.inputs import InputsLayout from sampletones_application.layout.tabs.main.reconstructor import ReconstructorLayout from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import ( - SUF_HANDLER_REGISTRY, - TAG_GLOBAL_THEME_CHANNEL_NOISE, - TAG_GLOBAL_THEME_CHANNEL_PULSE1, - TAG_GLOBAL_THEME_CHANNEL_PULSE2, - TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, -) +from sampletones_application.tags.general import SUF_HANDLER_REGISTRY from sampletones_application.tags.main import ( PRE_MAIN_RECONSTRUCTOR_GENERATOR, TAG_MAIN_RECONSTRUCTOR_PANEL, @@ -23,6 +18,7 @@ from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_set_value from sampletones_application.utils.gui.tooltip import show_tooltip @@ -98,21 +94,11 @@ def _create_generator_selection(self) -> None: def _generator_chips(self) -> List[Tuple[GeneratorName, str, str]]: return [ ( - GeneratorName.PULSE1, - self._language_manager["global.context.label.pulse_1"], - TAG_GLOBAL_THEME_CHANNEL_PULSE1, - ), - ( - GeneratorName.PULSE2, - self._language_manager["global.context.label.pulse_2"], - TAG_GLOBAL_THEME_CHANNEL_PULSE2, - ), - ( - GeneratorName.TRIANGLE, - self._language_manager["global.context.label.triangle"], - TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, - ), - (GeneratorName.NOISE, self._language_manager["global.context.label.noise"], TAG_GLOBAL_THEME_CHANNEL_NOISE), + generator_name, + channel_label(self._language_manager, generator_name), + CHANNEL_THEME_TAGS[generator_name], + ) + for generator_name in GeneratorName.items() ] def _create_drive_slider(self) -> None: diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index c3766a7f3..c4b313040 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -4,8 +4,9 @@ import dearpygui.dearpygui as dpg import numpy as np +from sampletones_application.categories.context import channel_label from sampletones_application.categories.manager import LanguageManager -from sampletones_application.categories.pitch import build_pitch_tooltip +from sampletones_application.categories.pitch import PitchTooltips from sampletones_application.layout.general.colors.feature import FeatureColors from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.compose import compose_tag @@ -137,22 +138,12 @@ def __init__( self._lbl_sample_size = language_manager["global.context.label.sample_size"] self._lbl_instrument_size = language_manager["global.context.label.instrument_size"] self._tpl_size_bytes = language_manager["global.context.template.size_bytes"] - tooltip_template = language_manager["reconstructions.instruments.template.initial_pitch_tooltip_template"] - self._pitch_tooltip = build_pitch_tooltip( + self._pitch_tooltips = PitchTooltips.build( language_manager, - PITCH_VALUE_KIND, - tooltip_template, - ) - self._period_tooltip = build_pitch_tooltip( - language_manager, - PERIOD_VALUE_KIND, - tooltip_template, + language_manager["reconstructions.instruments.template.initial_pitch_tooltip_template"], ) self._generator_labels: Dict[GeneratorName, str] = { - GeneratorName.PULSE1: language_manager["global.context.label.pulse_1"], - GeneratorName.PULSE2: language_manager["global.context.label.pulse_2"], - GeneratorName.TRIANGLE: language_manager["global.context.label.triangle"], - GeneratorName.NOISE: language_manager["global.context.label.noise"], + generator_name: channel_label(language_manager, generator_name) for generator_name in GeneratorName.items() } super().__init__( @@ -568,7 +559,7 @@ def _create_pitch_stepper( if is_noise else self._language_manager["reconstructions.instruments.label.initial_pitch"] ), - tooltip=self._period_tooltip if is_noise else self._pitch_tooltip, + tooltip=self._pitch_tooltips.for_kind(kind), status_message=( self._language_manager["reconstructions.instruments.message.status_input_period"] if is_noise diff --git a/src/sampletones_application/ui/panels/reconstruction/plot.py b/src/sampletones_application/ui/panels/reconstruction/plot.py index be4006933..d19132337 100644 --- a/src/sampletones_application/ui/panels/reconstruction/plot.py +++ b/src/sampletones_application/ui/panels/reconstruction/plot.py @@ -2,15 +2,10 @@ import dearpygui.dearpygui as dpg +from sampletones_application.categories.context import channel_label from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.graphs import GraphsLayout from sampletones_application.tags.compose import compose_tag -from sampletones_application.tags.general import ( - TAG_GLOBAL_THEME_CHANNEL_NOISE, - TAG_GLOBAL_THEME_CHANNEL_PULSE1, - TAG_GLOBAL_THEME_CHANNEL_PULSE2, - TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, -) from sampletones_application.tags.reconstructions import ( PRE_RECONSTRUCTION_GENERATOR, SUF_RECONSTRUCTIONS_RECONSTRUCTION_AUTOSCALE, @@ -23,6 +18,7 @@ from sampletones_application.ui.elements.graphs.waveform import GUIWaveformGraph from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.themes.channels import CHANNEL_THEME_TAGS from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value from sampletones_application.utils.gui.tooltip import show_tooltip @@ -34,13 +30,6 @@ from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import MessageCallback -_GENERATOR_THEME_TAGS = { - GeneratorName.PULSE1: TAG_GLOBAL_THEME_CHANNEL_PULSE1, - GeneratorName.PULSE2: TAG_GLOBAL_THEME_CHANNEL_PULSE2, - GeneratorName.TRIANGLE: TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, - GeneratorName.NOISE: TAG_GLOBAL_THEME_CHANNEL_NOISE, -} - class GUIReconstructionPlotPanel(GUIPanel): def __init__( @@ -102,7 +91,7 @@ def update_view(self, view_model: ReconstructionViewModel) -> None: ) dpg_set_value(tag, is_selected) if is_playing: - ThemeRegistry.get(_GENERATOR_THEME_TAGS[generator_name]).bind_to_item(tag) + ThemeRegistry.get(CHANNEL_THEME_TAGS[generator_name]).bind_to_item(tag) else: dpg.bind_item_theme(tag, 0) @@ -167,10 +156,8 @@ def _create_waveform_display(self) -> None: def _create_generator_checkboxes(self) -> None: generator_labels = { - GeneratorName.PULSE1: self._language_manager["global.context.label.pulse_1"], - GeneratorName.PULSE2: self._language_manager["global.context.label.pulse_2"], - GeneratorName.TRIANGLE: self._language_manager["global.context.label.triangle"], - GeneratorName.NOISE: self._language_manager["global.context.label.noise"], + generator_name: channel_label(self._language_manager, generator_name) + for generator_name in GeneratorName.items() } with dpg.group( diff --git a/src/sampletones_application/ui/themes/channels.py b/src/sampletones_application/ui/themes/channels.py new file mode 100644 index 000000000..c37865540 --- /dev/null +++ b/src/sampletones_application/ui/themes/channels.py @@ -0,0 +1,16 @@ +from typing import Dict, Final + +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_CHANNEL_NOISE, + TAG_GLOBAL_THEME_CHANNEL_PULSE1, + TAG_GLOBAL_THEME_CHANNEL_PULSE2, + TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, +) +from sampletones_core.constants.enums import GeneratorName + +CHANNEL_THEME_TAGS: Final[Dict[GeneratorName, str]] = { + GeneratorName.PULSE1: TAG_GLOBAL_THEME_CHANNEL_PULSE1, + GeneratorName.PULSE2: TAG_GLOBAL_THEME_CHANNEL_PULSE2, + GeneratorName.TRIANGLE: TAG_GLOBAL_THEME_CHANNEL_TRIANGLE, + GeneratorName.NOISE: TAG_GLOBAL_THEME_CHANNEL_NOISE, +} diff --git a/src/sampletones_application/view_model/shared/footprint.py b/src/sampletones_application/view_model/shared/footprint.py index 4607001c0..b8578a31f 100644 --- a/src/sampletones_application/view_model/shared/footprint.py +++ b/src/sampletones_application/view_model/shared/footprint.py @@ -3,14 +3,26 @@ from pydantic import BaseModel from sampletones_core.constants.enums import GeneratorName -from sampletones_core.formats.famitracker.footprint import InstrumentFootprint +from sampletones_core.formats.famitracker.footprint import ( + InstrumentFootprint, + total_footprint, +) class InstrumentSizeViewModel(BaseModel, frozen=True): - """The bytes one channel's instrument occupies once a tracker compiles it.""" + """The bytes one channel's instrument occupies once a tracker compiles it. + + The measurement is carried as it was taken, both regions intact, so a display naming the + whole and one naming a region read the same figure. + """ generator: GeneratorName - total_bytes: int + footprint: InstrumentFootprint + + @property + def total_bytes(self) -> int: + """The bytes this channel's instrument occupies, its two regions together.""" + return self.footprint.total_bytes class SampleFootprintViewModel(BaseModel, frozen=True): @@ -34,7 +46,7 @@ def from_footprints( instruments=tuple( InstrumentSizeViewModel( generator=generator_name, - total_bytes=footprints[generator_name].total_bytes, + footprint=footprints[generator_name], ) for generator_name in GeneratorName.items() if generator_name in footprints @@ -43,8 +55,12 @@ def from_footprints( @property def total_bytes(self) -> int: - """The bytes the whole sample occupies, its instruments summed.""" - return sum(instrument.total_bytes for instrument in self.instruments) + """The bytes the whole sample occupies, its instruments summed region by region. + + The sum is the measurement's own, so a sample's figure and a channel's are arrived at + the same way. + """ + return total_footprint(instrument.footprint for instrument in self.instruments).total_bytes def bytes_for(self, generator: GeneratorName) -> Optional[int]: """The bytes one channel's instrument occupies, where the sample covers that channel.""" 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 128ca0bab..94cb1e862 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 @@ -24,29 +24,25 @@ from sampletones_application.ui.elements.panel import GUIPanel from sampletones_application.ui.elements.pitch_stepper import PitchStepperStyle from sampletones_application.ui.panels.reconstruction.instruments import instruments as instruments_module -from sampletones_application.ui.panels.reconstruction.instruments.instruments import ( - GUIReconstructionInstrumentsPanel, -) +from sampletones_application.ui.panels.reconstruction.instruments.instruments import GUIReconstructionInstrumentsPanel from sampletones_application.ui.themes.setup import setup_themes from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.palette.catalog import PaletteCatalog from sampletones_application.utils.palette.source import PaletteSource -from sampletones_application.view_model.reconstruction.instruments import ( - ReconstructionInstrumentsViewModel, -) -from sampletones_application.view_model.shared.footprint import ( - InstrumentSizeViewModel, - SampleFootprintViewModel, -) +from sampletones_application.view_model.reconstruction.instruments import ReconstructionInstrumentsViewModel +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import FeatureKey, GeneratorName -from sampletones_core.formats.famitracker.specification.sequences import ( - MAX_SEQUENCE_ITEMS, -) +from sampletones_core.formats.famitracker.footprint import InstrumentFootprint +from sampletones_core.formats.famitracker.specification.sequences import MAX_SEQUENCE_ITEMS from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase SEQUENCE_STATUS_KEY: Final[str] = "reconstructions.instruments.message.status_sequence" +LARGEST_PULSE: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=9, sequence_bytes=768) +LARGEST_TRIANGLE: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=7, sequence_bytes=512) +SILENT_INSTRUMENT: Final[InstrumentFootprint] = InstrumentFootprint(instrument_bytes=3, sequence_bytes=0) + NOT_LOADED: Final[ReconstructionInstrumentsViewModel] = ReconstructionInstrumentsViewModel( reconstruction_loaded=False, playing_generators=frozenset(), @@ -55,18 +51,13 @@ def build_view_model( - channel_bytes: Dict[GeneratorName, int], + channel_footprints: Dict[GeneratorName, InstrumentFootprint], ) -> ReconstructionInstrumentsViewModel: - """A loaded reconstruction covering the given channels, each measured at the given size.""" + """A loaded reconstruction playing the given channels, each measured as given.""" return ReconstructionInstrumentsViewModel( reconstruction_loaded=True, - playing_generators=frozenset(channel_bytes), - footprint=SampleFootprintViewModel( - instruments=tuple( - InstrumentSizeViewModel(generator=generator_name, total_bytes=byte_count) - for generator_name, byte_count in channel_bytes.items() - ), - ), + playing_generators=frozenset(channel_footprints), + footprint=SampleFootprintViewModel.from_footprints(channel_footprints), ) @@ -246,27 +237,27 @@ class TestSizeFields(BaseTestSuite): @dataclass(frozen=True, kw_only=True) class SizeCase(BaseRegularTestCase): - channel_bytes: Dict[GeneratorName, int] + channel_footprints: Dict[GeneratorName, InstrumentFootprint] expected: str test_cases = ( SizeCase( label="a single channel spends what its instrument does", - channel_bytes={GeneratorName.PULSE1: 777}, + channel_footprints={GeneratorName.PULSE1: LARGEST_PULSE}, expected="777 B", ), SizeCase( label="three channels spend their instruments together", - channel_bytes={ - GeneratorName.PULSE1: 777, - GeneratorName.TRIANGLE: 519, - GeneratorName.NOISE: 777, + channel_footprints={ + GeneratorName.PULSE1: LARGEST_PULSE, + GeneratorName.TRIANGLE: LARGEST_TRIANGLE, + GeneratorName.NOISE: LARGEST_PULSE, }, expected="2073 B", ), SizeCase( label="a silent channel spends the instrument definition alone", - channel_bytes={GeneratorName.TRIANGLE: 3}, + channel_footprints={GeneratorName.TRIANGLE: SILENT_INSTRUMENT}, expected="3 B", ), ) @@ -279,7 +270,7 @@ def test_the_sample_size_sums_its_channels( shown: Dict[str, bool], case: SizeCase, ) -> None: - panel.update_view(build_view_model(case.channel_bytes)) + panel.update_view(build_view_model(case.channel_footprints)) assert written[panel.sample_size_tag] == case.expected @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) @@ -290,11 +281,14 @@ def test_each_channel_states_its_own_size( shown: Dict[str, bool], case: SizeCase, ) -> None: - panel.update_view(build_view_model(case.channel_bytes)) + panel.update_view(build_view_model(case.channel_footprints)) assert { generator_name: written[panel._get_instrument_size_tag(generator_name)] - for generator_name in case.channel_bytes - } == {generator_name: f"{byte_count} B" for generator_name, byte_count in case.channel_bytes.items()} + for generator_name in case.channel_footprints + } == { + generator_name: f"{footprint.total_bytes} B" + for generator_name, footprint in case.channel_footprints.items() + } @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) def test_a_channel_standing_by_costs_nothing( @@ -305,15 +299,15 @@ def test_a_channel_standing_by_costs_nothing( case: SizeCase, ) -> None: """A channel that describes no frame is written by no export, so its tab states what that costs.""" - panel.update_view(build_view_model(case.channel_bytes)) + panel.update_view(build_view_model(case.channel_footprints)) assert { generator_name: written[panel._get_instrument_size_tag(generator_name)] for generator_name in GeneratorName.items() - if generator_name not in case.channel_bytes + if generator_name not in case.channel_footprints } == { generator_name: "0 B" for generator_name in GeneratorName.items() - if generator_name not in case.channel_bytes + if generator_name not in case.channel_footprints } @@ -330,7 +324,7 @@ def test_every_channel_keeps_its_tab( written: Dict[str, str], shown: Dict[str, bool], ) -> None: - panel.update_view(build_view_model({GeneratorName.PULSE1: 777})) + panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) assert { generator_name: shown[panel._get_generator_tab_tag(generator_name)] for generator_name in GeneratorName.items() @@ -343,7 +337,7 @@ def test_a_channel_standing_by_reads_muted( shown: Dict[str, bool], bound_themes: List[str], ) -> None: - panel.update_view(build_view_model({GeneratorName.PULSE1: 777})) + panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) assert dict(zip(GeneratorName.items(), bound_themes)) == { GeneratorName.PULSE1: TAG_GLOBAL_THEME_INSTRUMENT_TABS, GeneratorName.PULSE2: TAG_GLOBAL_THEME_INSTRUMENT_TABS_MUTED, @@ -360,7 +354,7 @@ def test_only_a_playing_channel_offers_its_export( buttons = {generator_name: MagicMock() for generator_name in GeneratorName.items()} panel._export_buttons.update(cast(Dict[GeneratorName, GUIButton], buttons)) - panel.update_view(build_view_model({GeneratorName.TRIANGLE: 519})) + panel.update_view(build_view_model({GeneratorName.TRIANGLE: LARGEST_TRIANGLE})) assert {generator_name: button.set_enabled.call_args.args[0] for generator_name, button in buttons.items()} == { generator_name: generator_name is GeneratorName.TRIANGLE for generator_name in GeneratorName.items() @@ -374,7 +368,7 @@ def test_a_loaded_reconstruction_shows_the_sample_size( written: Dict[str, str], shown: Dict[str, bool], ) -> None: - panel.update_view(build_view_model({GeneratorName.PULSE1: 777})) + panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) assert shown[panel.sample_size_group_tag] is True def test_no_reconstruction_hides_the_sample_size( diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py index 9326e8c06..5e9d37db0 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py @@ -13,11 +13,9 @@ from sampletones_application.utils.gui.shortcuts.ids import ShortcutId from sampletones_application.utils.palette.colors.literal import LiteralColor from sampletones_application.view_model.sequencer.samples import SampleEntryViewModel -from sampletones_application.view_model.shared.footprint import ( - InstrumentSizeViewModel, - SampleFootprintViewModel, -) +from sampletones_application.view_model.shared.footprint import SampleFootprintViewModel from sampletones_core.constants.enums import GeneratorName +from sampletones_core.formats.famitracker.footprint import InstrumentFootprint from sampletones_core.utils.display import display_sample_label from tests.suite.shortcuts import shipped_source @@ -34,13 +32,15 @@ SIZE_TEMPLATE = "{bytes} B" DETAIL_COLOR = LiteralColor((0, 0, 0, 255)) -PULSE_1_BYTES = 41 -NOISE_BYTES = 19 -FOOTPRINT = SampleFootprintViewModel( - instruments=( - InstrumentSizeViewModel(generator=GeneratorName.PULSE1, total_bytes=PULSE_1_BYTES), - InstrumentSizeViewModel(generator=GeneratorName.NOISE, total_bytes=NOISE_BYTES), - ), +PULSE_1_FOOTPRINT = InstrumentFootprint(instrument_bytes=9, sequence_bytes=32) +NOISE_FOOTPRINT = InstrumentFootprint(instrument_bytes=7, sequence_bytes=12) +PULSE_1_BYTES = PULSE_1_FOOTPRINT.total_bytes +NOISE_BYTES = NOISE_FOOTPRINT.total_bytes +FOOTPRINT = SampleFootprintViewModel.from_footprints( + { + GeneratorName.PULSE1: PULSE_1_FOOTPRINT, + GeneratorName.NOISE: NOISE_FOOTPRINT, + } ) EDIT_ITEM = 0 From a8ea490eb7839250529adf78631ed6b6b5354698 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 13 Aug 2026 16:45:35 +0200 Subject: [PATCH 103/152] Fixed: held-dimension bookkeeping on channels --- docs/formats/reconstructions.md | 10 ++-- src/sampletones_core/exporters/feature.py | 8 ++- src/sampletones_core/features/__init__.py | 2 + src/sampletones_core/features/spec.py | 29 +++++++++-- .../reconstruction/instructions.py | 8 +-- .../reconstruction/reconstruction.py | 32 ++++++------ .../categories/test_pitch.py | 32 +++++++++--- .../exporters/test_feature.py | 7 +++ .../reconstruction/test_reconstruction.py | 49 +++++++++++++++++++ 9 files changed, 141 insertions(+), 36 deletions(-) diff --git a/docs/formats/reconstructions.md b/docs/formats/reconstructions.md index d0fb31747..d91c4adb8 100644 --- a/docs/formats/reconstructions.md +++ b/docs/formats/reconstructions.md @@ -42,13 +42,15 @@ A `.stn` file holds: * **per-channel held dimensions** — the envelopes each channel leaves to the player. An instruction states a value for every dimension of its frame, so this is what says which of them the instrument itself writes; the rest are the - channel's, and the player keeps the value it already holds for them. A freshly - built reconstruction writes them all, and clearing an envelope in the + channel's, and the player keeps the value it already holds for them. A channel + in play writes them all as it is built, and clearing an envelope in the instruments panel adds that dimension here. A channel standing by rests at a reference pitch of its own, so the first envelope -written into it sounds on a mid-range note. A file naming a stream for the channels -it plays alone reads as the whole four, with the rest coming back standing by. +written into it sounds on a mid-range note, and it leaves every dimension it offers +to the player, which is the record a channel edited down to empty envelopes reaches +as well. A file naming a stream for the channels it plays alone reads as the whole +four, with the rest coming back standing by. ## Detached reconstructions diff --git a/src/sampletones_core/exporters/feature.py b/src/sampletones_core/exporters/feature.py index bfd3e2a68..54f1bba48 100644 --- a/src/sampletones_core/exporters/feature.py +++ b/src/sampletones_core/exporters/feature.py @@ -130,10 +130,14 @@ def held_features(self) -> Tuple[FeatureKey, ...]: return tuple(key for key, value in self.items() if isinstance(value, np.ndarray) and value.size == 0) def leave_to_channel(self, feature_keys: Iterable[FeatureKey]) -> None: - """Empties the given dimensions' envelopes, so the channel governs them. + """Empties the envelope of each named dimension the channel offers, so the channel governs it. + + The dimensions a channel offers are the ones it can hold a value for, so the record acts + on those and leaves the shape of the features as the channel defines it. Args: feature_keys: The dimensions the instrument leaves to the channel. """ for feature_key in feature_keys: - self[feature_key] = np.array([], dtype=np.int8) + if feature_key in self: + self[feature_key] = np.array([], dtype=np.int8) diff --git a/src/sampletones_core/features/__init__.py b/src/sampletones_core/features/__init__.py index 3a9e0af09..d14b4b0ce 100644 --- a/src/sampletones_core/features/__init__.py +++ b/src/sampletones_core/features/__init__.py @@ -7,6 +7,7 @@ RESTING_REFERENCE_PITCH, FeatureRange, feature_range, + resting_held_features, resting_reference, supported_features, supports, @@ -21,6 +22,7 @@ "RESTING_REFERENCE_PITCH", "FeatureRange", "feature_range", + "resting_held_features", "resting_reference", "supported_features", "supports", diff --git a/src/sampletones_core/features/spec.py b/src/sampletones_core/features/spec.py index f7a76f2f1..dd3c0b0b1 100644 --- a/src/sampletones_core/features/spec.py +++ b/src/sampletones_core/features/spec.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Dict, Final, Tuple +from typing import Dict, Final, List, Tuple from sampletones_core.constants.enums import FeatureKey, GeneratorName, LibraryGeneratorName from sampletones_core.constants.general import ( @@ -87,12 +87,35 @@ def resting_reference(generator_name: GeneratorName) -> int: return RESTING_REFERENCE_PITCH -def supported_features(kind: LibraryGeneratorName) -> list[FeatureKey]: +def resting_held_features( + generator_name: GeneratorName, +) -> Tuple[FeatureKey, ...]: + """The dimensions a channel governs while it describes no frame. + + A stream with no frames writes no dimension, so every dimension the channel offers is the + channel's to hold. Recording them makes a channel that has always stood by read the same as + one edited down to empty envelopes. + + Args: + generator_name: The channel whose resting record is read. + + Returns: + Tuple[FeatureKey, ...]: The dimensions the channel offers, in dimension order. + """ + return tuple(supported_features(GENERATOR_KIND[generator_name])) + + +def supported_features( + kind: LibraryGeneratorName, +) -> List[FeatureKey]: ranges = GENERATOR_FEATURE_RANGES[kind] return [feature for feature in FEATURE_DIMENSION_ORDER if feature in ranges] -def feature_range(kind: LibraryGeneratorName, feature: FeatureKey) -> FeatureRange: +def feature_range( + kind: LibraryGeneratorName, + feature: FeatureKey, +) -> FeatureRange: return GENERATOR_FEATURE_RANGES[kind][feature] diff --git a/src/sampletones_core/reconstructions/reconstruction/instructions.py b/src/sampletones_core/reconstructions/reconstruction/instructions.py index a71b4cf21..7fe2fd894 100644 --- a/src/sampletones_core/reconstructions/reconstruction/instructions.py +++ b/src/sampletones_core/reconstructions/reconstruction/instructions.py @@ -6,7 +6,7 @@ from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.data import DataModel -from sampletones_core.features import resting_reference +from sampletones_core.features import resting_held_features, resting_reference from sampletones_core.instructions import InstructionData, InstructionUnion @@ -57,7 +57,9 @@ def resting(cls, generator_name: GeneratorName) -> InstructionsItem: A reconstruction holds one stream per channel, so a channel it leaves silent is present and editable: it rests at the reference its first envelope will sound at, - and describing a frame is what puts it back in play. + and describing a frame is what puts it back in play. Writing no frame leaves every + dimension the channel offers to the channel, which is what an edit clearing the last + frame records and what an export of this stream reads back. Args: generator_name: The channel the resting stream belongs to. @@ -69,5 +71,5 @@ def resting(cls, generator_name: GeneratorName) -> InstructionsItem: generator_name=generator_name, instructions=[], initial_pitch=resting_reference(generator_name), - held_features=(), + held_features=resting_held_features(generator_name), ) diff --git a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py index 4655c608e..b066f129b 100644 --- a/src/sampletones_core/reconstructions/reconstruction/reconstruction.py +++ b/src/sampletones_core/reconstructions/reconstruction/reconstruction.py @@ -30,7 +30,6 @@ ExporterUnion, Features, ) -from sampletones_core.features import resting_reference from sampletones_core.generators.maps import GENERATOR_CLASSES from sampletones_core.instructions import InstructionUnion from sampletones_shared.application import SAMPLETONES_RECONSTRUCTION_DATA_VERSION @@ -167,19 +166,11 @@ def _exporter_class( return cls._get_exporter_class(instructions[0]) @classmethod - def _derive_initial_pitch( - cls, - generator_name: GeneratorName, - instructions: List[InstructionUnion], - ) -> int: - """Chooses the reference pitch a channel's arpeggio envelope is measured against. + def _derive_initial_pitch(cls, instructions: List[InstructionUnion]) -> int: + """Chooses the reference pitch the arpeggio envelope of a channel in play is measured against. - The instruction type selects the exporter, matching how `export` resolves one. A - channel describing no frame rests at the reference its first envelope will sound at. + The instruction type selects the exporter, matching how `export` resolves one. """ - if not instructions: - return resting_reference(generator_name) - exporter_class = cls._get_exporter_class(instructions[0]) return exporter_class.derive_initial_pitch(instructions) # type: ignore[arg-type] @@ -195,21 +186,26 @@ def create( ) -> Self: approximation = np.nan_to_num(approximation, nan=0.0) approximations_data: List[ApproximationsItem] = [ - ApproximationsItem(generator_name=name, approximation=approximation) - for name, approximation in approximations.items() + ApproximationsItem( + generator_name=generator_name, + approximation=approximations[generator_name], + ) + for generator_name in GeneratorName.items() + if generator_name in approximations ] instructions_data: List[InstructionsItem] = [] for generator_name in GeneratorName.items(): channel_instructions = list(instructions.get(generator_name, ())) + if not channel_instructions: + instructions_data.append(InstructionsItem.resting(generator_name)) + continue + instructions_data.append( InstructionsItem.create( generator_name=generator_name, instructions=channel_instructions, - initial_pitch=cls._derive_initial_pitch( - generator_name, - channel_instructions, - ), + initial_pitch=cls._derive_initial_pitch(channel_instructions), held_features=(), ) ) diff --git a/tests/unit/sampletones_application/categories/test_pitch.py b/tests/unit/sampletones_application/categories/test_pitch.py index e1c317f8f..2d1ef7eef 100644 --- a/tests/unit/sampletones_application/categories/test_pitch.py +++ b/tests/unit/sampletones_application/categories/test_pitch.py @@ -1,7 +1,7 @@ import pytest from sampletones_application.categories.manager import LanguageManager -from sampletones_application.categories.pitch import build_pitch_tooltip +from sampletones_application.categories.pitch import PitchTooltips from sampletones_application.paths import LANG_EN from sampletones_core.utils.pitch_kind import PERIOD_VALUE_KIND, PITCH_VALUE_KIND @@ -13,23 +13,43 @@ def language_manager() -> LanguageManager: class TestBuildPitchTooltip: def test_fills_every_template_placeholder(self, language_manager: LanguageManager) -> None: - tooltip = build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, "{}/{}/{}") + tooltip = PitchTooltips.build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, "{}/{}/{}") assert "{}" not in tooltip assert len(tooltip.split("/")) == 3 def test_example_value_agrees_with_example_name(self, language_manager: LanguageManager) -> None: - _type_name, example_name, example_value = build_pitch_tooltip( + _type_name, example_name, example_value = PitchTooltips.build_pitch_tooltip( language_manager, PITCH_VALUE_KIND, "{}|{}|{}" ).split("|") assert PITCH_VALUE_KIND.to_name(int(example_value)) == example_name def test_period_example_value_agrees_with_example_name(self, language_manager: LanguageManager) -> None: - _type_name, example_name, example_value = build_pitch_tooltip( + _type_name, example_name, example_value = PitchTooltips.build_pitch_tooltip( language_manager, PERIOD_VALUE_KIND, "{}|{}|{}" ).split("|") assert PERIOD_VALUE_KIND.to_name(int(example_value)) == example_name def test_pitch_and_period_name_the_quantity_differently(self, language_manager: LanguageManager) -> None: - pitch_type = build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, "{}") - period_type = build_pitch_tooltip(language_manager, PERIOD_VALUE_KIND, "{}") + pitch_type = PitchTooltips.build_pitch_tooltip(language_manager, PITCH_VALUE_KIND, "{}") + period_type = PitchTooltips.build_pitch_tooltip(language_manager, PERIOD_VALUE_KIND, "{}") assert pitch_type != period_type + + +class TestPitchTooltips: + """A panel phrases both readings once and picks the one each field takes.""" + + def test_a_field_reads_the_help_its_kind_names(self, language_manager: LanguageManager) -> None: + tooltips = PitchTooltips.build(language_manager, "{}|{}|{}") + + assert tooltips.for_kind(PITCH_VALUE_KIND) == tooltips.pitch + assert tooltips.for_kind(PERIOD_VALUE_KIND) == tooltips.period + + def test_the_two_readings_phrase_the_same_template_differently( + self, + language_manager: LanguageManager, + ) -> None: + tooltips = PitchTooltips.build(language_manager, "{}|{}|{}") + + assert tooltips.pitch != tooltips.period + assert "{}" not in tooltips.pitch + assert "{}" not in tooltips.period diff --git a/tests/unit/sampletones_core/exporters/test_feature.py b/tests/unit/sampletones_core/exporters/test_feature.py index ce4501e9a..3c4372c33 100644 --- a/tests/unit/sampletones_core/exporters/test_feature.py +++ b/tests/unit/sampletones_core/exporters/test_feature.py @@ -50,3 +50,10 @@ def test_leaving_a_dimension_to_the_channel_empties_its_envelope(self) -> None: assert features.volume.size == 0 assert features.duty_cycle is not None and features.duty_cycle.size == 0 assert features.held_features == (FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE) + + def test_leaving_a_dimension_the_channel_lacks_keeps_it_absent(self) -> None: + """A record naming a duty cycle on the triangle channel leaves the channel's shape intact.""" + features = build_features(8) + features.leave_to_channel((FeatureKey.VOLUME, FeatureKey.DUTY_CYCLE)) + assert features.duty_cycle is None + assert features.held_features == (FeatureKey.VOLUME,) diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 3879dad4d..13b27c77c 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -12,6 +12,7 @@ from sampletones_core.features import resting_reference from sampletones_core.instructions import PulseInstruction from sampletones_core.reconstructions import Reconstruction +from sampletones_core.reconstructions.reconstruction.instructions import InstructionsItem from sampletones_shared.application import ( SAMPLETONES_RECONSTRUCTION_DATA_VERSION, ) @@ -335,6 +336,54 @@ def test_the_written_dimensions_export_their_items(self) -> None: assert features.duty_cycle is not None assert features.duty_cycle.size > 0 + def test_the_record_reads_back_off_the_exported_envelopes(self) -> None: + """What a reconstruction says it holds is what its export shows, on every channel. + + The record is the only place an empty envelope's meaning is kept, so a channel in play + and one standing by both have to state the dimensions their export leaves empty. + """ + reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) + + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [_pulse(_BASE_PITCH)] * 3, + np.ones(_AUDIO_LENGTH, dtype=np.float32), + _BASE_PITCH, + (FeatureKey.ARPEGGIO,), + ) + + exported = reconstruction.export() + assert reconstruction.held_features == { + generator_name: features.held_features for generator_name, features in exported.items() + } + + def test_a_channel_standing_by_leaves_every_dimension_it_offers(self) -> None: + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + + assert reconstruction.held_features[GeneratorName.TRIANGLE] == ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + ) + assert reconstruction.held_features[GeneratorName.NOISE] == ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + FeatureKey.DUTY_CYCLE, + ) + + def test_clearing_the_last_frame_records_what_standing_by_records(self) -> None: + """A channel edited out of play reads the same as one that never played.""" + reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) + + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [], + np.zeros(0, dtype=np.float32), + resting_reference(GeneratorName.PULSE1), + (FeatureKey.VOLUME, FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE), + ) + + assert reconstruction.streams[GeneratorName.PULSE1] == InstructionsItem.resting(GeneratorName.PULSE1) + def test_held_dimensions_survive_a_save_load_round_trip(self, tmp_path: Path) -> None: reconstruction = _reconstruction([_pulse(_BASE_PITCH)] * 3) reconstruction.update_generator_data( From a8c0bbfa824fc9fa307ba9c1585f4b9579046ecb Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 13 Aug 2026 20:26:53 +0200 Subject: [PATCH 104/152] Documented: the tracker byte counter --- docs/guide/interface.md | 9 ++--- docs/guide/sequencer.md | 3 +- .../categories/context.py | 34 +++++++++++++++---- .../categories/elements/global_.py | 3 ++ .../ui/elements/context_menu.py | 7 +++- .../reconstruction/instruments/instruments.py | 22 +++++++++--- .../ui/panels/sequencer/samples.py | 8 +++-- src/sampletones_config/lang/en.yaml | 1 + .../ui/panels/sequencer/test_samples_menu.py | 18 ++++++++++ 9 files changed, 85 insertions(+), 20 deletions(-) diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 69742a960..4f202762c 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -54,10 +54,11 @@ instrument — its pitch, volume, arpeggio, and duty sequences — which you can by dragging the bars or typing values. Clearing a sequence hands that dimension to the channel, so an instrument with no volume sequence plays at whatever level its channel carries. Beside each channel is the room its instrument takes on the NES, -with the whole sample's above them, so you can see what an edit costs. -**Export instrument...** writes the channel -on show, for whichever tracker the save dialog's file type names — see -[where your files live](files.md#exported-files). +with the whole sample's above them, so you can see what an edit costs. The figures +are in bytes, and they count what a FamiTracker export saves, so clearing a +sequence brings them down. **Export instrument...** writes the channel on show, for +whichever tracker the save dialog's file type names — see [where your files +live](files.md#exported-files). ## Instructions diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 20c3502e3..8c976f6ca 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -21,7 +21,8 @@ Manage the imported samples in the **Samples** list on the right: right-click on to **Edit**, **Rename**, **Duplicate**, **Remove**, or reorder it, and toggle its **Loop** flag. The **Edit** menu carries the same actions for the sample you have picked. The right-click menu also names how much room the sample takes on the NES — -its total, then each channel it plays — measured as its **Loop** flag has it. +its total, then each channel it plays — measured as its **Loop** flag has it. The +figures are in bytes, and they count what a FamiTracker export saves. Removing a sample that patterns still use asks **Remove sample** first, because it clears every row that references it. diff --git a/src/sampletones_application/categories/context.py b/src/sampletones_application/categories/context.py index 30a98bcb7..820d60a3a 100644 --- a/src/sampletones_application/categories/context.py +++ b/src/sampletones_application/categories/context.py @@ -13,24 +13,46 @@ } -def context_label( +def context_text( language_manager: LanguageManager, + text_type: TextType, element: ContextElements, ) -> str: - """Resolves a context-action label, the words every menu offering that action prints. + """Resolves one reading of a context element: its label, the template it fills or its tooltip. - Cut, Copy and Play name one gesture wherever they are offered, so the cell menus of the - sequencer grids, the file trees and the menu bar read them from one entry. A reader then - meets the same word for the same action, and a translation reaches all of them at once. + A context element is stated once and read in several voices — the byte figures name a size + with a label, print it through a template and explain it in a tooltip — so every voice of an + element comes from the same place. + + Args: + language_manager: The catalogue the words are read from. + text_type: The voice the element is read in. + element: The context element being read. + + Returns: + str: The words the catalogue holds for that element in that voice. """ return language_manager[ Page.GLOBAL, Panel.CONTEXT, - TextType.LABEL, + text_type, element, ] +def context_label( + language_manager: LanguageManager, + element: ContextElements, +) -> str: + """Resolves a context-action label, the words every menu offering that action prints. + + Cut, Copy and Play name one gesture wherever they are offered, so the cell menus of the + sequencer grids, the file trees and the menu bar read them from one entry. A reader then + meets the same word for the same action, and a translation reaches all of them at once. + """ + return context_text(language_manager, TextType.LABEL, element) + + def channel_label( language_manager: LanguageManager, generator: GeneratorName, diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index f41b3494b..7d1fbec7c 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -49,6 +49,9 @@ class ContextElements(AbstractElement): PULSE_1 = "pulse_1" PULSE_2 = "pulse_2" NOISE = "noise" + SAMPLE_SIZE = "sample_size" + INSTRUMENT_SIZE = "instrument_size" + SIZE_BYTES = "size_bytes" class NodeDetailElements(AbstractElement): diff --git a/src/sampletones_application/ui/elements/context_menu.py b/src/sampletones_application/ui/elements/context_menu.py index 0e456f6d9..e63e62f56 100644 --- a/src/sampletones_application/ui/elements/context_menu.py +++ b/src/sampletones_application/ui/elements/context_menu.py @@ -1,11 +1,12 @@ import contextlib -from typing import Iterator, Sequence, Tuple +from typing import Iterator, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color +from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_shared.types.callback import VoidCallback @@ -53,6 +54,7 @@ def add_detail_items( items: Sequence[Tuple[str, str]], *, color: BaseColor, + tooltip: Optional[str] = None, ) -> None: """Add a block of read-only ``label: value`` lines to the context menu being built. @@ -64,6 +66,7 @@ def add_detail_items( Args: items: The label and value of each line, in the order the menu prints them. color: The tint the lines take, which marks them as facts rather than actions. + tooltip: An explanation the whole block shares, reached by hovering any of its lines. """ if not items: return @@ -73,3 +76,5 @@ def add_detail_items( detail_text = dpg.add_text(f"{label}: {value}") dpg_set_palette_color(detail_text, color) FontRegistry.bind_to_item(detail_text, Font.MONO_SMALL) + if tooltip is not None: + show_tooltip(detail_text, tooltip) diff --git a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py index c4b313040..c33aa6d57 100644 --- a/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py +++ b/src/sampletones_application/ui/panels/reconstruction/instruments/instruments.py @@ -4,7 +4,9 @@ import dearpygui.dearpygui as dpg import numpy as np -from sampletones_application.categories.context import channel_label +from sampletones_application.categories.context import channel_label, context_label, context_text +from sampletones_application.categories.elements.global_ import ContextElements +from sampletones_application.categories.hierarchy import TextType from sampletones_application.categories.manager import LanguageManager from sampletones_application.categories.pitch import PitchTooltips from sampletones_application.layout.general.colors.feature import FeatureColors @@ -15,6 +17,7 @@ SUF_GROUP, SUF_HANDLER_REGISTRY, SUF_TEXT, + SUF_TOOLTIP, TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_INPUT_INVALID, TAG_GLOBAL_THEME_INPUT_WARNING, @@ -60,6 +63,7 @@ dpg_set_value, ) from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color +from sampletones_application.utils.gui.tooltip import show_tooltip from sampletones_application.view_model.reconstruction.instruments import ( ReconstructionInstrumentsViewModel, ) @@ -135,9 +139,10 @@ def __init__( self.on_raw_data_changed: Optional[Callable[[GeneratorName, FeatureKey, np.ndarray], None]] = None self._lbl_copy = language_manager["reconstructions.instruments.label.copy_button"] - self._lbl_sample_size = language_manager["global.context.label.sample_size"] - self._lbl_instrument_size = language_manager["global.context.label.instrument_size"] - self._tpl_size_bytes = language_manager["global.context.template.size_bytes"] + self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) + self._lbl_instrument_size = context_label(language_manager, ContextElements.INSTRUMENT_SIZE) + self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES) + self._tip_size_bytes = context_text(language_manager, TextType.TOOLTIP, ContextElements.SIZE_BYTES) self._pitch_tooltips = PitchTooltips.build( language_manager, language_manager["reconstructions.instruments.template.initial_pitch_tooltip_template"], @@ -209,7 +214,8 @@ def _create_size_field( The figure names how much of the NES data area an export spends, so it reads as information beside the fields that change: the label column aligns with the stepper - below it, and the value carries the stepper's own read-only colour and font. + below it, and the value carries the stepper's own read-only colour and font. A tooltip + names the export the figure measures, since the formats spend differently. """ with labeled_field( label, @@ -220,6 +226,12 @@ def _create_size_field( dpg_set_palette_color(value_tag, self._pitch_stepper_style.value_color) FontRegistry.bind_to_item(value_tag, Font.MONO) + show_tooltip( + value_tag, + self._tip_size_bytes, + tag=compose_tag(value_tag, SUF_TOOLTIP), + ) + def _get_generator_tab_tag(self, generator_name: GeneratorName) -> str: return compose_tag(self.tab_bar_tag, generator_name) diff --git a/src/sampletones_application/ui/panels/sequencer/samples.py b/src/sampletones_application/ui/panels/sequencer/samples.py index dc89d966f..6814c5b02 100644 --- a/src/sampletones_application/ui/panels/sequencer/samples.py +++ b/src/sampletones_application/ui/panels/sequencer/samples.py @@ -3,7 +3,7 @@ import dearpygui.dearpygui as dpg -from sampletones_application.categories.context import channel_label, context_label +from sampletones_application.categories.context import channel_label, context_label, context_text from sampletones_application.categories.elements.global_ import ContextElements from sampletones_application.categories.elements.sequencer import ( SequencerInstrumentsElements, @@ -114,8 +114,9 @@ def __init__( self._selected_row: Optional[int] = None self._editing_sample_id: Optional[str] = None self._entries: Tuple[SampleEntryViewModel, ...] = () - self._lbl_sample_size = language_manager["global.context.label.sample_size"] - self._tpl_size_bytes = language_manager["global.context.template.size_bytes"] + self._lbl_sample_size = context_label(language_manager, ContextElements.SAMPLE_SIZE) + self._tpl_size_bytes = context_text(language_manager, TextType.TEMPLATE, ContextElements.SIZE_BYTES) + self._tip_size_bytes = context_text(language_manager, TextType.TOOLTIP, ContextElements.SIZE_BYTES) self.sample_footprint: Optional[Callable[[str], Optional[SampleFootprintViewModel]]] = None self.on_sample_selected: Optional[StringCallback] = None self.on_sample_edit_requested: Optional[StringCallback] = None @@ -557,6 +558,7 @@ def _show_context_menu(self, position: int, sample_id: str) -> None: add_detail_items( self._footprint_items(sample_id), color=self._detail_color, + tooltip=self._tip_size_bytes, ) dpg.add_separator() add_play_menu_item( diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index d91fe3ac8..6c8471efa 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -160,6 +160,7 @@ global.context.label.detail_configuration: "Configuration" global.context.label.instrument_size: "Instrument size" global.context.label.sample_size: "Sample size" global.context.template.size_bytes: "{bytes} B" +global.context.tooltip.size_bytes: "How many bytes this takes as a FamiTracker instrument." # ============================================================================= # Global — Menu diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py index 5e9d37db0..9a8504006 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_samples_menu.py @@ -30,6 +30,7 @@ SAMPLE_SIZE_LABEL = "Sample size" SIZE_TEMPLATE = "{bytes} B" +SIZE_TOOLTIP = "Bytes a FamiTracker export spends." DETAIL_COLOR = LiteralColor((0, 0, 0, 255)) PULSE_1_FOOTPRINT = InstrumentFootprint(instrument_bytes=9, sequence_bytes=32) @@ -129,6 +130,7 @@ def _panel( panel._detail_color = DETAIL_COLOR panel._lbl_sample_size = SAMPLE_SIZE_LABEL panel._tpl_size_bytes = SIZE_TEMPLATE + panel._tip_size_bytes = SIZE_TOOLTIP panel.sample_footprint = (lambda _sample_id: footprint) if footprint_wired else None requests = Requests() @@ -160,11 +162,16 @@ class _MenuBuildRecorder: def __init__(self) -> None: self.widgets: List[MenuWidget] = [] + self.tooltips: List[str] = [] def add_text(self, text: str, **_kwargs: Any) -> int: self.widgets.append(MenuWidget(kind="text", text=text)) return 0 + def add_tooltip(self, _parent: int, message: str, **_kwargs: Any) -> int: + self.tooltips.append(message) + return 0 + def add_separator(self, **_kwargs: Any) -> int: self.widgets.append(MenuWidget(kind="separator", text="")) return 0 @@ -198,6 +205,7 @@ def build_recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuBuildRecorder: monkeypatch.setattr(samples_module.dpg, "add_menu_item", recorded.add_menu_item) monkeypatch.setattr(samples_module, "context_menu", _null_menu) monkeypatch.setattr(context_menu_module, "dpg_set_palette_color", lambda _item, _color: None) + monkeypatch.setattr(context_menu_module, "show_tooltip", recorded.add_tooltip) monkeypatch.setattr(FontRegistry, "bind_to_item", lambda _item, _font: None) return recorded @@ -337,6 +345,16 @@ def test_the_sizes_sit_between_the_sample_name_and_the_actions( f"{ContextElements.NOISE.value}: {NOISE_BYTES} B", ] + def test_every_figure_names_the_export_it_measures( + self, + monkeypatch: pytest.MonkeyPatch, + build_recorder: _MenuBuildRecorder, + ) -> None: + """A byte count means one export, so each line a reader hovers says which one it counts.""" + _panel(monkeypatch).panel._show_context_menu(SELECTED_ROW, SELECTED_ID) + + assert build_recorder.tooltips == [SIZE_TOOLTIP] * 3 + def test_a_menu_with_no_figures_reads_as_it_always_has( self, monkeypatch: pytest.MonkeyPatch, From bbeb67fedc80fba5a964a78366dc807ad55157fc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Thu, 13 Aug 2026 21:02:53 +0200 Subject: [PATCH 105/152] Added: coverage for cleared envelopes and per-channel held dimensions --- docs/development/guidelines.md | 2 +- tests/suite/sequencer.py | 55 +++++-- .../logic/sequencer/playback/test_voice.py | 141 +++++++++++++++--- .../logic/sequencer/test_samples.py | 15 ++ .../services/test_regeneration.py | 79 ++++++++++ .../reconstruction/test_instruments_panel.py | 51 +++---- .../formats/famitracker/test_footprint.py | 38 ++--- .../reconstruction/test_reconstruction.py | 31 ++++ 8 files changed, 331 insertions(+), 81 deletions(-) diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 313d3d535..7c72f87ae 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -86,7 +86,7 @@ These rules govern the Python in this repository. They complement 1. A test file mirrors the ownership of the code it exercises. 1. When functionality moves between packages, move its direct unit tests in the same change. 1. Parametrize tests that share a body, using a test-case dataclass. -1. Test case classes and cases themselves should be defined inside the testing class, unless these objects are shared between test classes. Inherit from `BaseTestSuite` and `BaseTestCase`. +1. Test case classes and cases themselves should be defined inside the testing class, unless these objects are shared between test classes. A suite inherits from `BaseTestSuite` and names its case class `TestCase`, which inherits from `BaseRegularTestCase`, or from `BaseAutolabelTestCase` where the case derives its own label. The parametrized argument carries the case as `test_case`. 1. For a multi-step scenario, use a test-scenario suite class — a series of functions with assertions. 1. Prefer fixtures over factories, and define shared fixtures in an appropriate place. 1. Do not assert default values of configurations, layouts, settings, and similar. Defaults are not contracts, and pinning them overconstrains the tests. Test behavior instead: validation bounds, serialization round-trips, and invariants. The exception is when values must match by contract rather than equal a chosen constant — e.g. project metadata at creation or after a save/load round-trip should be asserted to match, never hardcoded to a version string. diff --git a/tests/suite/sequencer.py b/tests/suite/sequencer.py index 365514001..ff4b13f71 100644 --- a/tests/suite/sequencer.py +++ b/tests/suite/sequencer.py @@ -12,7 +12,12 @@ from sampletones_application.view_model.sequencer.subcolumn import SubColumn from sampletones_core.configs import Config from sampletones_core.constants.enums import GeneratorName -from sampletones_core.instructions import PulseInstruction +from sampletones_core.instructions import ( + InstructionUnion, + NoiseInstruction, + PulseInstruction, + TriangleInstruction, +) from sampletones_core.project.instruments.instrument import Instrument from sampletones_core.project.instruments.note_off import NoteOff from sampletones_core.project.patterns.row import NoteCommand @@ -26,6 +31,10 @@ from sampletones_shared.constants.symbols import MINUS, MIXED, PLUS SAMPLE_LENGTH: Final[int] = 64 +SAMPLE_PITCH: Final[int] = 60 +SAMPLE_VOLUME: Final[int] = 8 +SAMPLE_PERIOD: Final[int] = 4 +SAMPLE_DUTY_CYCLE: Final[int] = 0 COLUMN_SEPARATOR: Final[str] = "|" UNKNOWN_SAMPLE: Final[str] = "!!" UNKNOWN_SAMPLE_ID: Final[str] = "a-sample-no-project-holds" @@ -37,18 +46,12 @@ def sample_reconstruction(generators: Sequence[GeneratorName]) -> Reconstruction The channels a reconstruction covers are what a sample governs in the sequencer, so this is the knob a sequencer test turns: the audio itself is silent, since what is under test is which channels a sample reaches and not how it sounds. + + Each channel carries the instruction its own generator sounds, since the instruction type is + what names the exporter a channel is read through — so a reading taken off this reconstruction + is the reading the channel gives. """ - instructions = { - generator: [ - PulseInstruction( - on=True, - pitch=60, - volume=8, - duty_cycle=0, - ) - ] - for generator in generators - } + instructions = {generator: [_instruction(generator)] for generator in generators} approximations = {generator: np.zeros(SAMPLE_LENGTH, dtype=np.float32) for generator in generators} return Reconstruction.create( approximation=np.zeros(SAMPLE_LENGTH, dtype=np.float32), @@ -257,6 +260,34 @@ def parse_volume(token: str) -> Optional[int]: return int(token, 16) +def _instruction(generator: GeneratorName) -> InstructionUnion: + """The instruction a channel sounds, which is the type its generator and exporter pair with. + + The two pulse channels share the pulse instruction; the triangle and the noise each take their + own. + """ + match generator: + case GeneratorName.TRIANGLE: + return TriangleInstruction( + on=True, + pitch=SAMPLE_PITCH, + ) + case GeneratorName.NOISE: + return NoiseInstruction( + on=True, + period=SAMPLE_PERIOD, + volume=SAMPLE_VOLUME, + short=False, + ) + case _: + return PulseInstruction( + on=True, + pitch=SAMPLE_PITCH, + volume=SAMPLE_VOLUME, + duty_cycle=SAMPLE_DUTY_CYCLE, + ) + + def _fill_cell( tracker_logic: SequencerTrackerLogic, row_index: int, diff --git a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py index be4cb06b2..e7b1778eb 100644 --- a/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py +++ b/tests/unit/sampletones_application/logic/sequencer/playback/test_voice.py @@ -25,6 +25,9 @@ REFERENCE_PERIOD: Final[int] = 4 SAMPLE_VOLUME: Final[int] = 9 CHANNEL_VOLUME: Final[int] = 4 +CHANNEL_ARPEGGIO: Final[int] = 7 +CHANNEL_DUTY_CYCLE: Final[int] = 1 +CHANNEL_LONG_MODE: Final[int] = 0 DUTY_CYCLE: Final[int] = 2 @@ -129,8 +132,14 @@ def test_the_channel_takes_up_what_the_instrument_writes(self, test_case: TestCa assert values[FeatureKey.VOLUME] == (MAX_VOLUME if test_case.label == "triangle" else SAMPLE_VOLUME) -class TestAHeldDimensionSoundsAtTheChannelsValue: - """A dimension the instrument leaves empty is the channel's, so it sounds at the value it holds.""" +class TestAHeldDimensionSoundsAtTheChannelsValue(BaseTestSuite): + """A dimension the instrument leaves empty is the channel's, so it sounds at the value it holds. + + Each channel offers its own dimensions and spells them in its own terms — an arpeggio is a + pitch on pulse and triangle and a period on noise, and a duty cycle is a waveform on pulse and + the noise mode on noise — so every dimension a channel offers is held here in the terms that + channel reads it in. + """ _INSTRUCTION = PulseInstruction( on=True, @@ -139,29 +148,125 @@ class TestAHeldDimensionSoundsAtTheChannelsValue: duty_cycle=DUTY_CYCLE, ) - def test_the_channels_level_carries_over_the_frame(self) -> None: - voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) - values = _channel_values() - values[FeatureKey.VOLUME] = CHANNEL_VOLUME + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseRegularTestCase): + generator_name: GeneratorName + instruction: InstructionUnion + held_feature: FeatureKey + channel_value: int + expected: InstructionUnion - assert voice.sound(self._INSTRUCTION, values).volume == CHANNEL_VOLUME + test_cases = ( + TestCase( + label="pulse volume", + generator_name=GeneratorName.PULSE1, + instruction=_INSTRUCTION, + held_feature=FeatureKey.VOLUME, + channel_value=CHANNEL_VOLUME, + expected=PulseInstruction( + on=True, + pitch=REFERENCE_PITCH, + volume=CHANNEL_VOLUME, + duty_cycle=DUTY_CYCLE, + ), + ), + TestCase( + label="pulse arpeggio", + generator_name=GeneratorName.PULSE1, + instruction=_INSTRUCTION, + held_feature=FeatureKey.ARPEGGIO, + channel_value=CHANNEL_ARPEGGIO, + expected=PulseInstruction( + on=True, + pitch=REFERENCE_PITCH + CHANNEL_ARPEGGIO, + volume=SAMPLE_VOLUME, + duty_cycle=DUTY_CYCLE, + ), + ), + TestCase( + label="pulse duty cycle", + generator_name=GeneratorName.PULSE1, + instruction=_INSTRUCTION, + held_feature=FeatureKey.DUTY_CYCLE, + channel_value=CHANNEL_DUTY_CYCLE, + expected=PulseInstruction( + on=True, + pitch=REFERENCE_PITCH, + volume=SAMPLE_VOLUME, + duty_cycle=CHANNEL_DUTY_CYCLE, + ), + ), + TestCase( + label="triangle arpeggio", + generator_name=GeneratorName.TRIANGLE, + instruction=TriangleInstruction(on=True, pitch=REFERENCE_PITCH), + held_feature=FeatureKey.ARPEGGIO, + channel_value=CHANNEL_ARPEGGIO, + expected=TriangleInstruction(on=True, pitch=REFERENCE_PITCH + CHANNEL_ARPEGGIO), + ), + TestCase( + label="noise period", + generator_name=GeneratorName.NOISE, + instruction=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD, + volume=SAMPLE_VOLUME, + short=True, + ), + held_feature=FeatureKey.ARPEGGIO, + channel_value=CHANNEL_ARPEGGIO, + expected=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD + CHANNEL_ARPEGGIO, + volume=SAMPLE_VOLUME, + short=True, + ), + ), + TestCase( + label="noise mode", + generator_name=GeneratorName.NOISE, + instruction=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD, + volume=SAMPLE_VOLUME, + short=True, + ), + held_feature=FeatureKey.DUTY_CYCLE, + channel_value=CHANNEL_LONG_MODE, + expected=NoiseInstruction( + on=True, + period=REFERENCE_PERIOD, + volume=SAMPLE_VOLUME, + short=False, + ), + ), + ) - def test_a_held_dimension_leaves_the_channels_value_where_it_stands(self) -> None: - voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_the_frame_sounds_the_channels_value_and_the_instruments_rest(self, test_case: TestCase) -> None: + voice = _voice(test_case.generator_name, [test_case.instruction], (test_case.held_feature,)) values = _channel_values() - values[FeatureKey.VOLUME] = CHANNEL_VOLUME - - voice.sound(self._INSTRUCTION, values) + values[test_case.held_feature] = test_case.channel_value - assert values[FeatureKey.VOLUME] == CHANNEL_VOLUME + assert voice.sound(test_case.instruction, values) == test_case.expected - def test_the_dimensions_the_instrument_writes_still_sound_its_own(self) -> None: - voice = _voice(GeneratorName.PULSE1, [self._INSTRUCTION], (FeatureKey.VOLUME,)) + @pytest.mark.parametrize( + "test_case", + test_cases, + ids=lambda test_case: test_case.label, + ) + def test_a_held_dimension_leaves_the_channels_value_where_it_stands(self, test_case: TestCase) -> None: + voice = _voice(test_case.generator_name, [test_case.instruction], (test_case.held_feature,)) + values = _channel_values() + values[test_case.held_feature] = test_case.channel_value - sounded = voice.sound(self._INSTRUCTION, _channel_values()) + voice.sound(test_case.instruction, values) - assert sounded.pitch == REFERENCE_PITCH - assert sounded.duty_cycle == DUTY_CYCLE + assert values[test_case.held_feature] == test_case.channel_value def test_a_level_one_instrument_wrote_is_what_the_next_one_holds(self) -> None: """The channel carries a value across samples, which is what makes an empty envelope mean this.""" diff --git a/tests/unit/sampletones_application/logic/sequencer/test_samples.py b/tests/unit/sampletones_application/logic/sequencer/test_samples.py index d4c8c5b5f..f0a23ecad 100644 --- a/tests/unit/sampletones_application/logic/sequencer/test_samples.py +++ b/tests/unit/sampletones_application/logic/sequencer/test_samples.py @@ -208,6 +208,21 @@ def test_a_looping_sample_costs_less_than_a_one_shot( assert one_shot is not None and looping is not None assert looping.total_bytes < one_shot.total_bytes + def test_each_channel_is_measured_as_the_instrument_it_sounds(self) -> None: + """A channel's figure is the cost of its own instrument, and the channels differ. + + The triangle states a pitch alone where the pulse states a level and a waveform too, so + the same frame written on each costs the triangle the less. + """ + controller, logic = _logic() + generators = (GeneratorName.PULSE1, GeneratorName.TRIANGLE) + sample = controller.add_sample(sample_reconstruction(generators), name="bell") + + footprint = logic.build_sample_footprint(sample.id) + + assert footprint is not None + assert footprint.bytes_for(GeneratorName.TRIANGLE) < footprint.bytes_for(GeneratorName.PULSE1) + def test_a_sample_the_pool_has_dropped_is_measured_nowhere(self) -> None: _, logic = _logic() diff --git a/tests/unit/sampletones_application/services/test_regeneration.py b/tests/unit/sampletones_application/services/test_regeneration.py index 26f92df0f..ede8ea039 100644 --- a/tests/unit/sampletones_application/services/test_regeneration.py +++ b/tests/unit/sampletones_application/services/test_regeneration.py @@ -14,6 +14,8 @@ ) from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.exporters import Features +from sampletones_core.reconstructions import Reconstruction +from tests.conftest import ReconstructionFactory REFERENCE_PITCH: Final[int] = 60 @@ -366,6 +368,83 @@ def test_run_exception_does_not_update_reconstruction( reconstruction.update_generator_data.assert_not_called() +class TestClearingEveryEnvelope: + """An instrument left with no envelope at all describes no frame, so its channel stands by. + + This is the edit the instruments panel offers on the last dimension an instrument writes, and + it runs the whole way through the service: the exporter produces no instruction, the render + produces no audio, and the reconstruction that comes back holds the channel without playing it. + """ + + @staticmethod + def _regenerated(reconstruction: Reconstruction) -> Reconstruction: + """The reconstruction the service returns once every dimension is left to the channel.""" + features = reconstruction.export()[GeneratorName.PULSE1] + features.leave_to_channel([FeatureKey.ARPEGGIO, FeatureKey.DUTY_CYCLE]) + service = RegenerationService() + results: List[Any] = [] + service.subscribe(results.append) + + service._run( + reconstruction, + GeneratorName.PULSE1, + features, + FeatureKey.VOLUME, + np.array([], dtype=np.int8), + ) + + assert isinstance(results[0], ServiceSuccess) + regenerated: Reconstruction = results[0].value.reconstruction + return regenerated + + def test_a_cleared_instrument_takes_its_channel_out_of_play( + self, + reconstruction_factory: ReconstructionFactory, + ) -> None: + reconstruction = reconstruction_factory() + + regenerated = self._regenerated(reconstruction) + + assert regenerated.instructions[GeneratorName.PULSE1] == [] + assert regenerated.playing_generators == () + + def test_a_cleared_instrument_sounds_as_an_empty_waveform( + self, + reconstruction_factory: ReconstructionFactory, + ) -> None: + reconstruction = reconstruction_factory() + + regenerated = self._regenerated(reconstruction) + + assert regenerated.approximations == {} + assert regenerated.approximation.size == 0 + + def test_the_cleared_channel_records_every_dimension_as_the_channels( + self, + reconstruction_factory: ReconstructionFactory, + ) -> None: + reconstruction = reconstruction_factory() + + regenerated = self._regenerated(reconstruction) + + assert regenerated.held_features[GeneratorName.PULSE1] == ( + FeatureKey.VOLUME, + FeatureKey.ARPEGGIO, + FeatureKey.DUTY_CYCLE, + ) + assert not regenerated.export()[GeneratorName.PULSE1].has_frames + + def test_the_reconstruction_the_edit_was_made_from_keeps_playing( + self, + reconstruction_factory: ReconstructionFactory, + ) -> None: + reconstruction = reconstruction_factory() + + self._regenerated(reconstruction) + + assert reconstruction.playing_generators == (GeneratorName.PULSE1,) + + class TestRegenerationServiceCancellationConstraints: """Tests that document the non-preemptive cancellation behaviour. 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 94cb1e862..9b9104241 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 @@ -90,7 +90,7 @@ def bound_themes(monkeypatch: pytest.MonkeyPatch) -> List[str]: return tags -@pytest.fixture +@pytest.fixture(autouse=True) def written(monkeypatch: pytest.MonkeyPatch) -> Dict[str, str]: """Records the texts written to items, standing in for the DPG values.""" values: Dict[str, str] = {} @@ -98,7 +98,7 @@ def written(monkeypatch: pytest.MonkeyPatch) -> Dict[str, str]: return values -@pytest.fixture +@pytest.fixture(autouse=True) def shown(monkeypatch: pytest.MonkeyPatch) -> Dict[str, bool]: """Records which items the panel shows, standing in for the DPG configuration.""" flags: Dict[str, bool] = {} @@ -236,17 +236,17 @@ class TestSizeFields(BaseTestSuite): """The two read-only byte figures: the sample's above the tabs, each channel's inside its tab.""" @dataclass(frozen=True, kw_only=True) - class SizeCase(BaseRegularTestCase): + class TestCase(BaseRegularTestCase): channel_footprints: Dict[GeneratorName, InstrumentFootprint] expected: str test_cases = ( - SizeCase( + TestCase( label="a single channel spends what its instrument does", channel_footprints={GeneratorName.PULSE1: LARGEST_PULSE}, expected="777 B", ), - SizeCase( + TestCase( label="three channels spend their instruments together", channel_footprints={ GeneratorName.PULSE1: LARGEST_PULSE, @@ -255,59 +255,56 @@ class SizeCase(BaseRegularTestCase): }, expected="2073 B", ), - SizeCase( + TestCase( label="a silent channel spends the instrument definition alone", channel_footprints={GeneratorName.TRIANGLE: SILENT_INSTRUMENT}, expected="3 B", ), ) - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_the_sample_size_sums_its_channels( self, panel: GUIReconstructionInstrumentsPanel, written: Dict[str, str], - shown: Dict[str, bool], - case: SizeCase, + test_case: TestCase, ) -> None: - panel.update_view(build_view_model(case.channel_footprints)) - assert written[panel.sample_size_tag] == case.expected + panel.update_view(build_view_model(test_case.channel_footprints)) + assert written[panel.sample_size_tag] == test_case.expected - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_each_channel_states_its_own_size( self, panel: GUIReconstructionInstrumentsPanel, written: Dict[str, str], - shown: Dict[str, bool], - case: SizeCase, + test_case: TestCase, ) -> None: - panel.update_view(build_view_model(case.channel_footprints)) + panel.update_view(build_view_model(test_case.channel_footprints)) assert { generator_name: written[panel._get_instrument_size_tag(generator_name)] - for generator_name in case.channel_footprints + for generator_name in test_case.channel_footprints } == { generator_name: f"{footprint.total_bytes} B" - for generator_name, footprint in case.channel_footprints.items() + for generator_name, footprint in test_case.channel_footprints.items() } - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_a_channel_standing_by_costs_nothing( self, panel: GUIReconstructionInstrumentsPanel, written: Dict[str, str], - shown: Dict[str, bool], - case: SizeCase, + test_case: TestCase, ) -> None: """A channel that describes no frame is written by no export, so its tab states what that costs.""" - panel.update_view(build_view_model(case.channel_footprints)) + panel.update_view(build_view_model(test_case.channel_footprints)) assert { generator_name: written[panel._get_instrument_size_tag(generator_name)] for generator_name in GeneratorName.items() - if generator_name not in case.channel_footprints + if generator_name not in test_case.channel_footprints } == { generator_name: "0 B" for generator_name in GeneratorName.items() - if generator_name not in case.channel_footprints + if generator_name not in test_case.channel_footprints } @@ -321,7 +318,6 @@ class TestPlayingChannels: def test_every_channel_keeps_its_tab( self, panel: GUIReconstructionInstrumentsPanel, - written: Dict[str, str], shown: Dict[str, bool], ) -> None: panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) @@ -333,8 +329,6 @@ def test_every_channel_keeps_its_tab( def test_a_channel_standing_by_reads_muted( self, panel: GUIReconstructionInstrumentsPanel, - written: Dict[str, str], - shown: Dict[str, bool], bound_themes: List[str], ) -> None: panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) @@ -348,8 +342,6 @@ def test_a_channel_standing_by_reads_muted( def test_only_a_playing_channel_offers_its_export( self, panel: GUIReconstructionInstrumentsPanel, - written: Dict[str, str], - shown: Dict[str, bool], ) -> None: buttons = {generator_name: MagicMock() for generator_name in GeneratorName.items()} panel._export_buttons.update(cast(Dict[GeneratorName, GUIButton], buttons)) @@ -365,7 +357,6 @@ class TestSizeVisibility: def test_a_loaded_reconstruction_shows_the_sample_size( self, panel: GUIReconstructionInstrumentsPanel, - written: Dict[str, str], shown: Dict[str, bool], ) -> None: panel.update_view(build_view_model({GeneratorName.PULSE1: LARGEST_PULSE})) @@ -374,7 +365,6 @@ def test_a_loaded_reconstruction_shows_the_sample_size( def test_no_reconstruction_hides_the_sample_size( self, panel: GUIReconstructionInstrumentsPanel, - written: Dict[str, str], shown: Dict[str, bool], ) -> None: panel.update_view(NOT_LOADED) @@ -384,7 +374,6 @@ def test_no_reconstruction_states_no_figures( self, panel: GUIReconstructionInstrumentsPanel, written: Dict[str, str], - shown: Dict[str, bool], ) -> None: panel.update_view(NOT_LOADED) assert written == {} diff --git a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py index 917b02a00..ca7a69fc0 100644 --- a/tests/unit/sampletones_core/formats/famitracker/test_footprint.py +++ b/tests/unit/sampletones_core/formats/famitracker/test_footprint.py @@ -53,43 +53,43 @@ def build_features( class TestFeaturesFootprint(BaseTestSuite): @dataclass(frozen=True, kw_only=True) - class FootprintCase(BaseRegularTestCase): + class TestCase(BaseRegularTestCase): features: Features loop: bool expected: InstrumentFootprint test_cases = ( - FootprintCase( + TestCase( features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), loop=False, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=22), label="pulse_one_shot", ), - FootprintCase( + TestCase( features=build_features([15, 12, 9, 0], [0, 2, 4], [1, 1, 2]), loop=True, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=21), label="pulse_loop", ), - FootprintCase( + TestCase( features=build_features([15, 0], [0], [0]), loop=False, expected=InstrumentFootprint(instrument_bytes=9, sequence_bytes=16), label="dimensions_of_differing_lengths", ), - FootprintCase( + TestCase( features=build_features([15, 12, 0], [0, 1], None), loop=False, expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=13), label="triangle", ), - FootprintCase( + TestCase( features=build_features([], [], None), loop=False, expected=InstrumentFootprint(instrument_bytes=3, sequence_bytes=0), label="silent", ), - FootprintCase( + TestCase( features=build_features( list(range(OVER_LONG_LENGTH)), [0] * OVER_LONG_LENGTH, @@ -99,7 +99,7 @@ class FootprintCase(BaseRegularTestCase): expected=InstrumentFootprint(instrument_bytes=7, sequence_bytes=512), label="capped_at_the_sequence_limit", ), - FootprintCase( + TestCase( features=build_features( [0] * MAX_SEQUENCE_ITEMS, [0] * MAX_SEQUENCE_ITEMS, @@ -111,23 +111,23 @@ class FootprintCase(BaseRegularTestCase): ), ) - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) def test_both_regions_are_measured_from_the_populated_sequences( self, - case: FootprintCase, + test_case: TestCase, ) -> None: - assert features_footprint(case.features, loop=case.loop) == case.expected + assert features_footprint(test_case.features, loop=test_case.loop) == test_case.expected - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) - def test_the_built_instrument_measures_the_same(self, case: FootprintCase) -> None: + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_built_instrument_measures_the_same(self, test_case: TestCase) -> None: """Both entry points measure one export, so a slice reads the same either way.""" - instrument = build_instrument(0, case.label, case.features, loop=case.loop) - assert instrument_footprint(instrument) == case.expected + instrument = build_instrument(0, test_case.label, test_case.features, loop=test_case.loop) + assert instrument_footprint(instrument) == test_case.expected - @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) - def test_the_total_sums_both_regions(self, case: FootprintCase) -> None: - footprint = features_footprint(case.features, loop=case.loop) - assert footprint.total_bytes == case.expected.instrument_bytes + case.expected.sequence_bytes + @pytest.mark.parametrize("test_case", test_cases, ids=lambda test_case: test_case.label) + def test_the_total_sums_both_regions(self, test_case: TestCase) -> None: + footprint = features_footprint(test_case.features, loop=test_case.loop) + assert footprint.total_bytes == test_case.expected.instrument_bytes + test_case.expected.sequence_bytes class TestSequenceFootprint: diff --git a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py index 13b27c77c..fe7f269e6 100644 --- a/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_core/reconstructions/reconstruction/test_reconstruction.py @@ -549,6 +549,37 @@ def test_leaves_original_untouched(self, reconstruction_factory: ReconstructionF assert reconstruction.config.nes_frequency == original_frequency assert len(reconstruction.approximation) == original_length + def test_a_channel_standing_by_stays_standing_by( + self, + reconstruction_factory: ReconstructionFactory, + ) -> None: + """A channel describing no frame renders nothing, so a retuned copy holds audio for the rest.""" + reconstruction = reconstruction_factory() + + retuned = reconstruction.with_nes_frequency(_RETUNED_FREQUENCY) + + assert set(retuned.approximations) == {GeneratorName.PULSE1} + assert set(retuned.instructions) == set(GeneratorName.items()) + assert retuned.playing_generators == (GeneratorName.PULSE1,) + + def test_a_reconstruction_of_channels_standing_by_retunes_to_silence(self) -> None: + """Every channel standing by leaves nothing to render, and the retuned copy says so.""" + reconstruction = _reconstruction([_pulse(_BASE_PITCH)]) + reconstruction.update_generator_data( + GeneratorName.PULSE1, + [], + np.zeros(0, dtype=np.float32), + _BASE_PITCH, + (), + ) + + retuned = reconstruction.with_nes_frequency(_RETUNED_FREQUENCY) + + assert retuned.config.nes_frequency == _RETUNED_FREQUENCY + assert retuned.approximations == {} + assert retuned.approximation.size == 0 + assert retuned.playing_generators == () + def test_matching_rate_returns_self(self, reconstruction_factory: ReconstructionFactory) -> None: reconstruction = reconstruction_factory() From a33e708b0e8d7947d1c82c34818e2ed59eda0a46 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 14 Aug 2026 13:34:41 +0200 Subject: [PATCH 106/152] =?UTF-8?q?Rebound:=20tab=20selection=20to=20F1?= =?UTF-8?q?=E2=80=93F4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/development/guidelines.md | 5 +- docs/guide/interface.md | 10 +- docs/guide/sequencer.md | 5 +- src/sampletones_application/application.py | 1 + .../categories/elements/settings.py | 4 + src/sampletones_application/shell.py | 12 + .../utils/gui/shortcuts/ids.py | 12 + .../keybindings/default.yaml | 12 +- src/sampletones_config/keybindings/macos.yaml | 12 +- src/sampletones_config/lang/en.yaml | 4 + .../sampletones_application/test_startup.py | 50 +- .../utils/gui/shortcuts/test_shipped.py | 532 ------------------ 12 files changed, 100 insertions(+), 559 deletions(-) delete mode 100644 tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py diff --git a/docs/development/guidelines.md b/docs/development/guidelines.md index 7c72f87ae..0d2277aae 100644 --- a/docs/development/guidelines.md +++ b/docs/development/guidelines.md @@ -89,6 +89,9 @@ These rules govern the Python in this repository. They complement 1. Test case classes and cases themselves should be defined inside the testing class, unless these objects are shared between test classes. A suite inherits from `BaseTestSuite` and names its case class `TestCase`, which inherits from `BaseRegularTestCase`, or from `BaseAutolabelTestCase` where the case derives its own label. The parametrized argument carries the case as `test_case`. 1. For a multi-step scenario, use a test-scenario suite class — a series of functions with assertions. 1. Prefer fixtures over factories, and define shared fixtures in an appropriate place. -1. Do not assert default values of configurations, layouts, settings, and similar. Defaults are not contracts, and pinning them overconstrains the tests. Test behavior instead: validation bounds, serialization round-trips, and invariants. The exception is when values must match by contract rather than equal a chosen constant — e.g. project metadata at creation or after a save/load round-trip should be asserted to match, never hardcoded to a version string. +1. **A shipped value is a choice, not a contract.** Defaults, keybinding schemes, palettes, layouts, and the settings a build opens on are tuned freely, so a test that restates one turns every adjustment into a test edit. Assert behaviour instead: validation bounds, serialization round-trips, fallback and recovery paths, and the invariants a value satisfies — a default lies within the range offered, every palette declares the same tokens, every action the application names is answered. +1. **Read a configured value; do not repeat it.** Where a case needs the keys an action answers, a palette's colour, a layout's dimension, or a default a model falls back to, it reads that value from the configuration under test and derives the rest of the case from it. A case that presses a key states which action it is pressing, resolves the combination from the scheme, and keeps passing once that action is rebound. +1. **A literal shipped value needs a stated reason.** Write one only where the value itself is the contract — a file format's constant, a value another system reads back, an interoperability requirement — and say so in the case. Asserting against the named constant that defines the value (`DEFAULT_MAX_FPS`, `DEFAULT_SCHEME_NAME`) states where the value comes from and is welcome; a bare literal standing for the same thing is the pin this rule forbids. +1. Values that must match by contract are asserted to match, never hardcoded — e.g. project metadata at creation or after a save/load round-trip is held against its source, never against a version string. 1. Unit tests may mock system boundaries (file I/O, external services, IPC channels), but must not mock the domain logic that is the subject of the test. Integration tests must exercise real computation pipelines against real (synthetically built) data. 1. When a test expectation diverges from the production code's actual behaviour, determine which is wrong before acting. A failing test is evidence of a potential bug in the production code unless the test itself is demonstrably incorrect (wrong imports, misread API contract, incorrect fixture). Never silently delete or weaken a test to make it pass. If uncertain, flag the divergence explicitly and ask before changing either side. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 4f202762c..f77ba6578 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -99,9 +99,15 @@ 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 +`F1` to `F4` bring up the four tabs in order — **Main**, **Reconstruction**, +**Sequencer**, and **Instructions** — and work while you are typing, so any tab is +one key away. + +`1` to `4` toggle the four NES channels on the tab in front of you: the generators on **Main**, the channels drawn on **Reconstructions**, and the song's -mix on the **Sequencer**. +mix on the **Sequencer**. In the sequencer's grids the digits type values into the +cell you are on, so use the channel names or the **Playback ▸ Channels** menu to +mute there. ### Keyboard shortcuts diff --git a/docs/guide/sequencer.md b/docs/guide/sequencer.md index 8c976f6ca..1f47a3fdd 100644 --- a/docs/guide/sequencer.md +++ b/docs/guide/sequencer.md @@ -168,8 +168,9 @@ wherever you see it. | Right-click any name | The same actions as a menu | The **Playback ▸ Channels** submenu carries the same mix: a check marks each channel -that sounds, and **Unmute all channels** returns the whole set. `F1` to `F4` do the -same from the keyboard, one key per channel. +that sounds, and **Unmute all channels** returns the whole set. `1` to `4` do the +same from the keyboard, one key per channel, wherever the grids are not holding your +cursor — inside them the digits enter values. Muting is for listening only. The song keeps every channel, so saving, exporting a module, and undo all work on the full arrangement, and a mute survives undo and diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 9f7ce0acb..ede3f176c 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -623,6 +623,7 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: about=self._open_about_dialog, next_tab=self._next_tab, previous_tab=self._previous_tab, + select_tab=self._set_current_tab, ) def _setup_shell(self, bindings: ShortcutBindings) -> None: diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 676463a1a..6fa1ac892 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -82,6 +82,10 @@ class KeybindingActionElements(AbstractElement): ABOUT_DIALOG = "about_dialog" NEXT_TAB = "next_tab" PREVIOUS_TAB = "previous_tab" + SELECT_TAB_MAIN = "select_tab_main" + SELECT_TAB_RECONSTRUCTIONS = "select_tab_reconstructions" + SELECT_TAB_SEQUENCER = "select_tab_sequencer" + SELECT_TAB_INSTRUCTIONS = "select_tab_instructions" ORDER_PREVIOUS_POSITION = "order_previous_position" ORDER_NEXT_POSITION = "order_next_position" diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index e74b9bbad..46bc618c0 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -43,6 +43,7 @@ FOLLOW_MODE_SHORTCUT_IDS, PROJECT_EXPORT_SHORTCUT_IDS, SAMPLE_EXPORT_SHORTCUT_IDS, + TAB_SHORTCUT_IDS, ShortcutId, ) from sampletones_application.utils.gui.shortcuts.manager import ShortcutManager @@ -106,6 +107,7 @@ class ShortcutBindings: about: Callback next_tab: Callback previous_tab: Callback + select_tab: Callable[[Tab], None] class ApplicationShell: @@ -251,6 +253,7 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback **ApplicationShell._export_callbacks(bindings), **ApplicationShell._follow_mode_callbacks(bindings), **ApplicationShell._channel_callbacks(bindings), + **ApplicationShell._tab_callbacks(bindings), } @staticmethod @@ -302,6 +305,15 @@ def _channel_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback] ShortcutId.UNMUTE_ALL_CHANNELS: bindings.unmute_all_channels, } + @staticmethod + def _tab_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback]: + """One action per tab, each carrying the tab it brings to the front. + + A tab is reached by naming it as well as by stepping to the next one, so a reader moves + across the whole window in one press. + """ + return {shortcut_id: partial(bindings.select_tab, tab) for tab, shortcut_id in TAB_SHORTCUT_IDS.items()} + def _setup_handlers(self) -> None: self._key_router.bind() diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index 8fc11fef1..a4defd277 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -1,6 +1,7 @@ from enum import Enum, StrEnum from typing import Dict, Final, Self, Tuple +from sampletones_application.categories.hierarchy import Tab from sampletones_application.constants.playback import FollowMode from sampletones_core.constants.enums import GeneratorName from sampletones_core.trackers.format import TrackerFormat @@ -88,6 +89,10 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: ABOUT_DIALOG = ("AboutDialog", ShortcutCategory.APPLICATION) NEXT_TAB = ("NextTab", ShortcutCategory.APPLICATION) PREVIOUS_TAB = ("PreviousTab", ShortcutCategory.APPLICATION) + SELECT_TAB_MAIN = ("SelectTabMain", ShortcutCategory.APPLICATION) + SELECT_TAB_RECONSTRUCTIONS = ("SelectTabReconstructions", ShortcutCategory.APPLICATION) + SELECT_TAB_SEQUENCER = ("SelectTabSequencer", ShortcutCategory.APPLICATION) + SELECT_TAB_INSTRUCTIONS = ("SelectTabInstructions", ShortcutCategory.APPLICATION) ORDER_PREVIOUS_POSITION = ("OrderPreviousPosition", ShortcutCategory.ORDER) ORDER_NEXT_POSITION = ("OrderNextPosition", ShortcutCategory.ORDER) @@ -193,6 +198,13 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: FollowMode.OFF: ShortcutId.FOLLOW_OFF, } +TAB_SHORTCUT_IDS: Final[Dict[Tab, ShortcutId]] = { + Tab.MAIN: ShortcutId.SELECT_TAB_MAIN, + Tab.RECONSTRUCTIONS: ShortcutId.SELECT_TAB_RECONSTRUCTIONS, + Tab.SEQUENCER: ShortcutId.SELECT_TAB_SEQUENCER, + Tab.INSTRUCTIONS: ShortcutId.SELECT_TAB_INSTRUCTIONS, +} + CHANNEL_SHORTCUT_IDS: Final[Dict[GeneratorName, ShortcutId]] = { GeneratorName.PULSE1: ShortcutId.TOGGLE_CHANNEL_PULSE_1, GeneratorName.PULSE2: ShortcutId.TOGGLE_CHANNEL_PULSE_2, diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index d1f0b3ada..86914813e 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -43,10 +43,10 @@ bindings: 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"} + ToggleChannelPulse1: {combination: "1"} + ToggleChannelPulse2: {combination: "2"} + ToggleChannelTriangle: {combination: "3"} + ToggleChannelNoise: {combination: "4"} UnmuteAllChannels: {combination: ~} # view @@ -58,6 +58,10 @@ bindings: AboutDialog: {combination: ~} NextTab: {combination: "Ctrl+PgDn", field_transparent: true} PreviousTab: {combination: "Ctrl+PgUp", field_transparent: true} + SelectTabMain: {combination: "F1", field_transparent: true} + SelectTabReconstructions: {combination: "F2", field_transparent: true} + SelectTabSequencer: {combination: "F3", field_transparent: true} + SelectTabInstructions: {combination: "F4", field_transparent: true} # order table OrderPreviousPosition: {combination: "Left"} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index 9613c344a..a4ede06aa 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -43,10 +43,10 @@ bindings: 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"} + ToggleChannelPulse1: {combination: "1"} + ToggleChannelPulse2: {combination: "2"} + ToggleChannelTriangle: {combination: "3"} + ToggleChannelNoise: {combination: "4"} UnmuteAllChannels: {combination: ~} # view @@ -58,6 +58,10 @@ bindings: AboutDialog: {combination: ~} NextTab: {combination: "Cmd+Alt+Right", aliases: ["Cmd+PgDn"], field_transparent: true} PreviousTab: {combination: "Cmd+Alt+Left", aliases: ["Cmd+PgUp"], field_transparent: true} + SelectTabMain: {combination: "F1", field_transparent: true} + SelectTabReconstructions: {combination: "F2", field_transparent: true} + SelectTabSequencer: {combination: "F3", field_transparent: true} + SelectTabInstructions: {combination: "F4", field_transparent: true} # order table OrderPreviousPosition: {combination: "Left"} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 6c8471efa..7840744f2 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -782,6 +782,10 @@ settings.keybindings.label.toggle_fullscreen: "Fullscreen" settings.keybindings.label.about_dialog: "About" settings.keybindings.label.next_tab: "Next tab" settings.keybindings.label.previous_tab: "Previous tab" +settings.keybindings.label.select_tab_main: "Go to the Main tab" +settings.keybindings.label.select_tab_reconstructions: "Go to the Reconstruction tab" +settings.keybindings.label.select_tab_sequencer: "Go to the Sequencer tab" +settings.keybindings.label.select_tab_instructions: "Go to the Instructions tab" settings.keybindings.label.order_previous_position: "Previous position" settings.keybindings.label.order_next_position: "Next position" settings.keybindings.label.order_previous_channel: "Previous channel" diff --git a/tests/unit/sampletones_application/test_startup.py b/tests/unit/sampletones_application/test_startup.py index ad01f3133..dac387964 100644 --- a/tests/unit/sampletones_application/test_startup.py +++ b/tests/unit/sampletones_application/test_startup.py @@ -13,9 +13,9 @@ 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, + TAB_SHORTCUT_IDS, ShortcutId, ) from sampletones_application.utils.parallelization.background import ( @@ -331,6 +331,13 @@ def test_embedded_sample_is_a_detached_copy( assert not app._editing_project_sample() +def _press_shortcut(app: Application, shortcut_id: ShortcutId) -> None: + """Routes the press the scheme in place gives an action, so a rebind carries the case with it.""" + combination = app._shortcut_source.shortcut(shortcut_id).combination + assert combination is not None + app.key_router.route(KeyEvent(key=combination.key, modifiers=combination.modifiers)) + + class TestChannelKeys: """One key per channel, reaching the switch of the tab in front of the reader. @@ -340,40 +347,55 @@ class TestChannelKeys: """ @staticmethod - def _press(app: Application, key: int, tab: Tab) -> None: + def _press(app: Application, generator: GeneratorName, 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"] + _press_shortcut(app, CHANNEL_SHORTCUT_IDS[generator]) def test_the_main_tab_switches_the_generator_a_reconstruction_is_built_from(self, app: Application) -> None: selected = frozenset(app.config_manager.config.generation.generators) - self._press(app, dpg.mvKey_F3, Tab.MAIN) + self._press(app, GeneratorName.TRIANGLE, Tab.MAIN) assert frozenset(app.config_manager.config.generation.generators) == selected ^ {GeneratorName.TRIANGLE} def test_the_sequencer_switches_its_mix(self, app: Application) -> None: - self._press(app, dpg.mvKey_F4, Tab.SEQUENCER) + self._press(app, GeneratorName.NOISE, Tab.SEQUENCER) assert app._sequencer_tab.channels.is_muted(GeneratorName.NOISE) def test_a_second_press_returns_the_mix_it_started_from(self, app: Application) -> None: - self._press(app, dpg.mvKey_F1, Tab.SEQUENCER) - self._press(app, dpg.mvKey_F1, Tab.SEQUENCER) + self._press(app, GeneratorName.PULSE1, Tab.SEQUENCER) + self._press(app, GeneratorName.PULSE1, Tab.SEQUENCER) assert not app._sequencer_tab.channels.any_muted def test_the_reconstructions_tab_holding_nothing_leaves_the_mix_alone(self, app: Application) -> None: """With no reconstruction loaded every slice reads as unavailable, so the key rests there.""" - self._press(app, dpg.mvKey_F2, Tab.RECONSTRUCTIONS) + self._press(app, GeneratorName.PULSE2, Tab.RECONSTRUCTIONS) assert not app._sequencer_tab.channels.any_muted def test_the_main_tab_leaves_the_sequencer_mix_alone(self, app: Application) -> None: - self._press(app, dpg.mvKey_F1, Tab.MAIN) + self._press(app, GeneratorName.PULSE1, Tab.MAIN) assert not app._sequencer_tab.channels.any_muted + + +class TestTabKeys: + """One key per tab, bringing it to the front from wherever the reader stands. + + The whole application answers here, so a press travels the way it does at runtime: the router + hands it to the dispatcher, the scheme names the action, and the shell puts the tab on screen. + """ + + @pytest.mark.parametrize("tab", tuple(TAB_SHORTCUT_IDS), ids=lambda tab: str(tab)) + def test_the_key_puts_its_tab_on_screen(self, app: Application, tab: Tab) -> None: + with patch.object(app._shell, "set_current_tab") as set_current_tab: + _press_shortcut(app, TAB_SHORTCUT_IDS[tab]) + + set_current_tab.assert_called_once_with(tab) + + @pytest.mark.parametrize("tab", tuple(TAB_SHORTCUT_IDS), ids=lambda tab: str(tab)) + def test_the_key_answers_while_a_field_is_edited(self, app: Application, tab: Tab) -> None: + """Naming a tab reaches it the way stepping to the next one does, typing included.""" + assert app._shortcut_source.shortcut(TAB_SHORTCUT_IDS[tab]).field_transparent diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py deleted file mode 100644 index ff7bdb281..000000000 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_shipped.py +++ /dev/null @@ -1,532 +0,0 @@ -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" -CLONE_FRAME_COMBINATION = "Ctrl+Shift+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_clone_frame_reads_under_the_combination_it_answers(self, shipped: ShortcutScheme) -> None: - assert shipped.shortcut(ShortcutId.ORDER_CLONE_FRAME).display() == CLONE_FRAME_COMBINATION - - def test_clone_frame_answers_its_press_in_the_order_table(self, shipped: ShortcutScheme) -> None: - """Shift is what separates the deep copy from the repeat, so the two keys stay adjacent.""" - action = shipped.action(ShortcutCategory.ORDER, _press(CLONE_FRAME_COMBINATION)) - - assert action is ShortcutId.ORDER_CLONE_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 TestSelectKeys(BaseTestSuite): - """The A chord is selection and nothing else, each modifier narrowing the shape it names.""" - - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - category: ShortcutCategory - shortcut_id: ShortcutId - expected: str - - test_cases = ( - TestCase( - label="the whole frame", - category=ShortcutCategory.TRACKER, - shortcut_id=ShortcutId.TRACKER_SELECT_ALL, - expected="Ctrl+A", - ), - TestCase( - label="a column", - category=ShortcutCategory.TRACKER, - shortcut_id=ShortcutId.TRACKER_SELECT_COLUMN, - expected="Ctrl+Shift+A", - ), - TestCase( - label="a subcolumn", - category=ShortcutCategory.TRACKER, - shortcut_id=ShortcutId.TRACKER_SELECT_SUBCOLUMN, - expected="Ctrl+Alt+A", - ), - TestCase( - label="the whole order", - category=ShortcutCategory.ORDER, - shortcut_id=ShortcutId.ORDER_SELECT_ALL, - expected="Ctrl+A", - ), - TestCase( - label="an order row", - category=ShortcutCategory.ORDER, - shortcut_id=ShortcutId.ORDER_SELECT_ROW, - expected="Ctrl+Shift+A", - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_a_shape_reads_under_the_combination_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_shape_answers_its_press_in_the_grid_that_states_it( - self, - test_case: TestCase, - shipped: ShortcutScheme, - ) -> None: - action = shipped.action(test_case.category, _press(test_case.expected)) - - assert action is test_case.shortcut_id - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_a_mac_reaches_the_shape_through_its_own_modifier( - self, - test_case: TestCase, - macos: ShortcutScheme, - ) -> None: - """A Mac spells the chord with Command, so the family reads the same on either keyboard.""" - combination = test_case.expected.replace("Ctrl", "Cmd") - - assert macos.action(test_case.category, _press(combination)) is test_case.shortcut_id - - -class TestDisplacedSettingsKeys(BaseTestSuite): - """Where the two settings the A chord displaced now answer, each keeping its family's shape.""" - - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - shortcut_id: ShortcutId - expected: str - mac_expected: str - - test_cases = ( - TestCase( - label="audio settings", - shortcut_id=ShortcutId.AUDIO_SETTINGS, - expected="Ctrl+U", - mac_expected="Cmd+U", - ), - TestCase( - label="advanced settings", - shortcut_id=ShortcutId.TOGGLE_ADVANCED_SETTINGS, - expected="Ctrl+Alt+T", - mac_expected="Cmd+Alt+T", - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_the_setting_reads_under_the_combination_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_the_setting_answers_its_press_wherever_no_grid_claims_it( - self, - test_case: TestCase, - shipped: ShortcutScheme, - ) -> None: - action = shipped.action(ShortcutCategory.APPLICATION, _press(test_case.expected)) - - assert action is test_case.shortcut_id - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_the_setting_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.mac_expected - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_the_grids_leave_the_settings_key_alone( - self, - test_case: TestCase, - shipped: ShortcutScheme, - ) -> None: - """A grid is asked before the application is, so a dialog opens while the cursor stands in one.""" - press = _press(test_case.expected) - - assert shipped.action(ShortcutCategory.TRACKER, press) is None - assert shipped.action(ShortcutCategory.ORDER, press) is None - - -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 TestTrackerAdjustKeys(BaseTestSuite): - """The keys the tracker's shifts answer to: Ctrl carries pitch, Alt carries volume, and Shift - makes the step the bigger one.""" - - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - shortcut_id: ShortcutId - expected: str - - test_cases = ( - TestCase(label="transpose up", shortcut_id=ShortcutId.TRACKER_TRANSPOSE_UP, expected="Ctrl+Up"), - TestCase(label="transpose down", shortcut_id=ShortcutId.TRACKER_TRANSPOSE_DOWN, expected="Ctrl+Down"), - TestCase( - label="an octave up", - shortcut_id=ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP, - expected="Ctrl+Shift+Up", - ), - TestCase( - label="an octave down", - shortcut_id=ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN, - expected="Ctrl+Shift+Down", - ), - TestCase(label="volume up", shortcut_id=ShortcutId.TRACKER_VOLUME_UP, expected="Alt+Up"), - TestCase(label="volume down", shortcut_id=ShortcutId.TRACKER_VOLUME_DOWN, expected="Alt+Down"), - TestCase( - label="a coarse volume up", - shortcut_id=ShortcutId.TRACKER_VOLUME_UP_COARSE, - expected="Alt+Shift+Up", - ), - TestCase( - label="a coarse volume down", - shortcut_id=ShortcutId.TRACKER_VOLUME_DOWN_COARSE, - expected="Alt+Shift+Down", - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_a_shift_reads_under_the_combination_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_shift_answers_its_press_in_the_tracker( - self, - test_case: TestCase, - shipped: ShortcutScheme, - ) -> None: - action = shipped.action(ShortcutCategory.TRACKER, _press(test_case.expected)) - - assert action is test_case.shortcut_id - - -class TestMacosAdjustKeys(BaseTestSuite): - """What a Mac reaches the tracker's shifts through. - - The alternatives a Mac keyboard needs already answer on Cmd and Alt with the arrows, so the - shifts take Cmd+Alt there and read their axis from the direction: the arrows up and down carry - pitch, those left and right carry volume, and Shift makes the step the bigger one. - """ - - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseRegularTestCase): - shortcut_id: ShortcutId - expected: str - - test_cases = ( - TestCase(label="transpose up", shortcut_id=ShortcutId.TRACKER_TRANSPOSE_UP, expected="Cmd+Alt+Up"), - TestCase(label="transpose down", shortcut_id=ShortcutId.TRACKER_TRANSPOSE_DOWN, expected="Cmd+Alt+Down"), - TestCase( - label="an octave up", - shortcut_id=ShortcutId.TRACKER_TRANSPOSE_OCTAVE_UP, - expected="Cmd+Alt+Shift+Up", - ), - TestCase( - label="an octave down", - shortcut_id=ShortcutId.TRACKER_TRANSPOSE_OCTAVE_DOWN, - expected="Cmd+Alt+Shift+Down", - ), - TestCase(label="volume up", shortcut_id=ShortcutId.TRACKER_VOLUME_UP, expected="Cmd+Alt+Right"), - TestCase(label="volume down", shortcut_id=ShortcutId.TRACKER_VOLUME_DOWN, expected="Cmd+Alt+Left"), - TestCase( - label="a coarse volume up", - shortcut_id=ShortcutId.TRACKER_VOLUME_UP_COARSE, - expected="Cmd+Alt+Shift+Right", - ), - TestCase( - label="a coarse volume down", - shortcut_id=ShortcutId.TRACKER_VOLUME_DOWN_COARSE, - expected="Cmd+Alt+Shift+Left", - ), - ) - - @pytest.mark.parametrize( - "test_case", - test_cases, - ids=lambda test_case: test_case.label, - ) - def test_a_shift_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 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 - ) From d2fb29b076d61a9e5131b9b3db45d0432dc4c769 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Fri, 14 Aug 2026 14:29:43 +0200 Subject: [PATCH 107/152] Added: application image --- README.md | 2 ++ docs/images/sampletones.png | Bin 0 -> 72964 bytes 2 files changed, 2 insertions(+) create mode 100644 docs/images/sampletones.png diff --git a/README.md b/README.md index c7751818d..9688052a6 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ _SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/). +SampleToNES + The core idea is to approximate an audio sample using only the chip's basic oscillators — two pulse channels, a triangle, and noise — **without any DPCM samples**. A built-in sequencer lets you arrange the reconstructed samples into patterns and play them back inside the application, so you can experiment with the results before exporting the instruments into FamiTracker. diff --git a/docs/images/sampletones.png b/docs/images/sampletones.png new file mode 100644 index 0000000000000000000000000000000000000000..3a95d82e56c70a2c1a8c9638847ea1899474af68 GIT binary patch literal 72964 zcmagE2RK~a*EdW=ZwVrZC}ET^Izf~m(MHSYMrZUEy^9{5!RWpBK1zh>y>~%~UJ@ll zl*o6u@8|!#&+}dH_npgJ=QwBWz1LoQt-XG0?G>h?BtwWtg@=ZQMkpuy8j6O72}VQ1 zIKjaH-c*v}MFBT-C#Z}hTIDG9F7VHNa|uNWG_;x+{A(jjfD%nbLG2Co6DGQ+&uN(i zz+eGJ22L8?3yG?6zci8* zSCtS~6n>?|A*m$-{8_zuDQnCRwGo23y)^PwQdCn=P|{I<^F~@rMM+l`s%N1q<7A=W zX(s(f-tw)2h16SXO)U!T3&=}5J0f2rA*CaZ;y&a3Ut+pXruZAQzT-hU^&_qwhB^uupv?RNV- zPWs=8nuo|~hid5sLG%$;u&{UfaXQW!(w5=6CJ_oI$(rWzQZC6h79X7KL+qUbT-`&> zt&=@W(k-1bynW)8%yLz%%cOmB_1p`f_O)tGAAQVnZJl#n+)KTDYqh)vb^Ip%eG}gM zMFd%uh1=CdJ2c0>`Aud(Z?nU*E&_JdJQ`U ztw)w0w|ok>4cmVDb}hf_JM!q=HtqWpAA<}C2o3#^92J)tAD>x{3`;JKOD#>Vsf&w^ z%J~qLAB`wPCKm++eGE^mPsnJ=$*TBJRTo-XmY!FJtSwH-YyD8yRN7LQn_E#_(okJh zUsdz5wytfUF=nwfucae1qqDWVqrADVq`0rSroU#iD{7%HY@jb?vOn(cLc&_Ve@B1r z-<7xcYh_OWEOsW4$Oif>T`*?nNPkH@dOLI?S>qKVOw~Byob)g5% z;Xkvo*Q=^8^741e%TMd-Za%jUMRkp)^!0z}osa9AsOp@`>Fa3!Jk;F37~Z)*KK7-n zb8v8QW^8v{_k)@q8hteB0I(`mr4sIS&bQx!Ws0pqmXC^2291REf z2NR779TN>5c#;QxPyT%dOcRX-9pmo(J>Z!e{T|vw;I0GwEJN?#y|)3LZ!bgOciaBI z?*Dt#Tf0Sz#YVdi&~Wkaa-nDc)9-B?%mU3fbf)gfIKsEK%AfdYHkXRhU~tByFiVK)mPLy5K}UVE zLW4xcGSj`&V~HIp*=^`37G!&S->+A(1jSezIelft zR1fq)^|jj05yuU$4EwLWp%Lwg@Nm3ePu>aQhmZ*P^SW_;duIVQeD{_NRQ)x)OPkIC zM*h%q-!vPnq3fiY(CN!YWbXgjR$FAJU>!b_K=ZxY-7frj_1B0lZ3=*t*mK)-dSJ@! z=T>3yy{k`x>A~oY@Z)SZA~vR%Tdn`|)jsI|^Ob)y_>+E+B)z~)_&6Rkm&>$?v98Jp ziKSIAWZGl_c2&pl+^*KIH5rZgclaIK=4W_8gw%hKwi|HYfIGLMk9ctd;*Bc17=$+dd=Kl)|muyK1E zf%XZEO&SPXLlY;4m+P=-hWFr{?vwkZSOiR-p1EN|P%>)jieu!Tn)Xw%|g-YN}D10K5kt?pS6+R(;RVRK$$)PCJyX6RT+#OAomkTt(O(oI;9H$jM5n+Zl40<*<)-XK{^nvHU;pC#b*}dz>>fSu*xL9W*$=Kk zmkG_O(h+O=jz#vLop|mwzLw3qbK|9uk4oh7_yw=LOc+kg)85pfy4T3n_s3s`o=s-y zCH8`Sl9W9980j(rKHe7a!IMWeQ5(Yy&!gY`>Gxv)(E3nBpV1lR`AOd%)0dC{TZILx zJ@KGof;>O@i=#y>&DW14lW@|ZQdA^kRWFnDyRgjH&uQlRSaZVktn~DzCQtFMxrsha z<&0LETGijnG1N`{k<&oXAPo()aDFUl(W=TJRfjd;xr<*H+r#wnXbz_vG5MOGDzei; z?1xOvge=dapU){zge7c_!gEquSp~VhT!L$NhJ2^_{Z)Dr>4U7k^~R^@z$Wbp)rnmQ z5j{G9qU{%me|#>7Y=abu%pf&3!4ZH=$w?}5jKoi910U`&@i)Y0UUgOFDB@&w#FM%t ze<6vs{awZU@Km}m>f>L-*1|$8cG47F^+qN+XGOk;d_?N+5?!DwC1u-I=4auX#mvpR zJMtYL#3|3JVkL1vUeB-Tbs+3C`0f+;=UG<*Ehtkl_u(wVk}dW1_RVE|hcSIB4=z7c zOyTrE6*J+D_JNhp2G75A!m>Nj=(pRyv`JoIL^>CNUp zvjN$>Wcym6nmLQy`gbl5`1_t4G7{BG(njXiZH6lrm-SgaHnT}d&CI0Hw9$?WI*}vA z?96e(%n4`I9eW=qh(s(r-RqG>f~oMs6>%J`w?kCc}^RHit~~9`QY+_{76NKkY>6q3Wy^ z832t+r=O#Z=s9AQCzm+MdoBBwv4+&*8Q)4UNh&EFPm>2A$;yx^foBo?GKbW&knr!r z-hf4vgQNZxHc^h!Yg^nR4s~IM92J%iD$GkVfxOolZxz3)g1*a&&=I=2xVTUf+Y60` zfjJrY#L!WUE|?#{{Sb3`@)g`-u;*D&3D}YnG*r$N=xFxnS#DTmYHUz!>`AEMY9wSv zZpJZ0*Y992@m+k&gVcF-Y^oUnxozh+@*ldd%{1hHT@oDm~C+!%eay>cz$GMwKVal)H_RNIgJSnRy zAR($?<($R)noYeY9MUBuIjz>BE=nwt^e-!X|TVPktVPZR^Q9`Qz zT6KU-H_ebPiM#hfX&bxV(+DU`LqS%PFwa%D;Kf4TwEDYeiLEX8ehS|r_|@EJOiFgk zK3V`A&rD6u=+)3PM5f!UP>9y?(TZgATwZ$t)vwyXdyvtWD(C-6U*R zRcoBu?yC33=sGnBiG8<#pKnYonjW8rmre1gd4+)1kfCwQ?&7+|Qi9rfzp_&`b7+*;$N+%hyklZxScTOhBo*cn9a- z9LTZe5a5WXw+ptwz5V9V{9(@;J3$jT0{fFwZL+e>|3)9&n%|em)D7Doht_eeqS$FD zc1@>8rrb^dKtk{zK=N6K8RK2eVrp70*)J~u`UH{?g?6nu2vYxl-ITDJbPH}N&~-U9 z;_xhM%O`MW^|n%AgG-}rR(9jQ=dGLy{(t4x9q^>1W2`q)CFfCbLdRZpS@H|K7A{QJKu5uJkvMEv z?P#i$q07Ab@DohEYb`rB*o$hug@>pEJm;W-xnp7$f~i_IhkTcYkllebPnY5D#e(FTv^2{3v& zcYZ`ip|mS}Tq(%64!QT6KkiL}GdFZzRHM6k0i1Yu(D-;R=Ht4vX2f}?PLIc?V4)Si z-7d3ZC4Zuvzvvu>Qj_x)ki!`GPNm&#M#1GRG3IQ{k}8|9PvR70WMqrb-bX_2-46J_ zy8On<1%c?AL#(rgWXTXn9ya#&N`P zhTsi0i*Bh~aC>)5G+mD_|G6rrT}hDf72nt;mzUSk>Ckw{LT7H(W@@R)KASt_NZjk3 z_=G&fwnXFYB6&oxuFG3Xmf9flId;S}=eBEHJQl-m5gb^s9sNHmB2#h*A-C1KqH0s6W!AsMlT zkCIzv%<;Z-JeReSlauo>7ADC+27o|$4wE_B&*w}_4JJvIUt^I5Fj*fA9x}AFD3WWG zRv2$YpnkBqfY(W6W63lh%k3vQ6&ZoPEl;Q^ht=RMOa8MM!9T`%$B`^a-XOMhvFd^^ zz@lVY<)K=2G@&HMeNifTqngVI<>EbsXfF{H%|X%3INTU!Z%1ghDcL?99oU-R+6|7{}m zAV@C*h;(@#2?BBC2noHm6^a_G1yA(ds*VffbMflK{K_{GZT$24>M!wK zYa94~YWgOT?5v-to^p1es3SO8S;Ne&h8$yPN7*k6?ApH`R-LHHZMZ(Wq@tZE+RxqaDT?wB{?BKt`U){YhQgeOrE<$fRyz)W{s#HSoAaY7A|@mg7e+iajF?i6oY* zt1gKB(9x6pL1l#`DF4H1#C;JxsM`fXc~v+nx8?H=Nerjh-ZA8%L^fcbIzD>1)djjL zBm5=4jO+d?2F-^$6?`-36OjFwBpdr%&JgIatfBbT-?0shpN$)}1V`Mt{^&_g+@ z+@60l#Yk>F7G5ri@lo{F6OBLh6ln5sT~o=^*PwYTZxJtHs zGp8(|8mVeI9UJ(_%bhVAy?Z|EznYIch7crzv;(H436qTR>;VIvr~MHeGiuo!w~7IN zG@tf&ra@CxkXp&#kf@E_pGq4(La)?L*8q1F)YG#2cg+qalAhlBK%ws4#~T1z5kytX z7`VQQDzDvoP-HMJYTC5_(kY#Tz42eg{xD)|2X5{YkdGNF{`e<c)aq^Zm7X<6og+pVQ*wiQ2Zw@R?wpD@b*98-8k7Y3aOK0zfZ^ecN?B*vKBT z@;3|9Y}x&uy^@mf?@U$pWE)&;)bxx>&*3>wL3ndr*Zd^eN;n#jyWsLWleZ{;nIr{Zis8v5iZA#DbKiZJxizoRC4?Qs)DY4ZWg|Ct z*AZ`mUg)8alR_#u&L6y>BP6|n;<2>Evsa2{^&Vp-FK-NTf}ONm5Xt^@3+n`g5Z=0f zBx+t+vqCazJ5bdj^r{&&`VUL08+ScLogr6I!>j-T*EaTvgaUJ*yX&OZ#c?=03O;!^ zs?+V%wCr&0hL$9s4CNQFwh9St9f=lcRWFe&A)A8*ka7@Jg|wgg%gm$v1LeQcuULkh zOVZ1M&q8&ZpNK_I$ED7}>MLTW4;J2^i{3NyUgOtt?+XXvI2em;cNI#UXJ~ zU*7J^`Ar%%6DnN%@%?x&Ft>1RCxLV?mh3-~Z|C%`zE5wB*CVJZtUX6tjRm}-|1L4E z;es$}NeV;#|IkZ^U;H7S5Z-CaFHuzO)1}rA2J~WM7^xgv@hjo-*S2rF-!ARyaM)e*D0@*RqT}vh`;AxVm68xOx%?m>%eL(d1=qi zZxMDk(c#p(wWVgK7;94J{mKj1#aoURhf@o;$(Y6c0_gr`s3wnOuKS(n5BrpYkLis( zDduU7l@*jaeVd#e9id(n)>c!~aeuqm#@?$c)Bqx?-)uIx{$|E0({F{Aveghyocf1&Bh&>v@4e&_?ViX1=1Pp4J=k^zE}BBW*6q@8>Lj}Oa2ljZ&> zDI_cTnl(-OZjH=F7LH82q-b{=Gg^Ku+82GTOVog-Y1?`##W~W}jL6uuoN>!@e{s52 zj`#m9B@Lq@#<-F?z^&C6PqLLe2PxX73ZMA6Z)P~rYA3HaLWX;hkSsyBA0?qw*AUt{ z_Ndd7%uH_`Uwjdzn^V5e0{&)tA%D|FxjpPo-nFobr+QBwp2w5>;XIMhOmtN(P2|o| z+8Y&xbQm_*7JMCx%$#wV^L^QibYv?5=LrC+)Ul2xqBY5zCfaApsAOyDOEFJvta6~= z{+D?B19;O%4wj}P<4at1&%w@daw!Q3GbTsb#5O*#6}@K(TROGk&FUhQUsIZqJxN3cj(Wv>W<{?Zy$M z2iZ4CFh*_v&SElu=x0@3aA9QV^g)#SVq=5!1|F|mi&C>z-S6LCQ-4t1H|%ZM3^Fqs zDp2P5`C{q3TV^|R`bN*g_+$yH($Bg2eWTfFJF_?KeWrwUw(-U81|z}aEO)Du$AnGC zhrvH>W8zhNQWaS>3lmM?m%dmkWq@vQKxca#dqe17mjeNoLkwP^#6Pw-Q zAG%JQLOmHTV9)m}uUv?#DS-w6!Rw~176~( zt(u<(rEE${6iXPgLNz7mG?f*-?E&eSKsH-9Nn3s*tW2IdOh=nC9(x!fFNaFqoVxoadE?~@=HPSY+XmhxBTq*q`WN6HfS zvCm$U`$e1uo%s|WA2W1e1y?8r80q>HVuWMM9*-=fNS*pSK6c2v2-S1@-Z&)?T9`PE ze4Cc3+D+m5gzl&4c3alh2wRV@V~6}T1q=Twa1cB6yAi-w1;gJ^&CHAD86)Fc<%Rs* zH*ETow8l*@$|`B+({oKNK#0}){t2xeL$~?n2AY{FqV#&>ly0}HBym?NV_J{J!X%}h zuV*}6Jkg$eE?3WOl$7(s3YC#WlXdH#>P zk#kCQn{Bw=*ju*74HtOQJv!NcQHRby#nQM+c$B?`o`A7R^tEa2?3xndo{QTCcl!Vm!4+%x)gONs|%vyob~d1L_az}W|{ zEg`+BOt6nGx`pbxJ;|-{zB=Irogp3UAiizg=#DU8Ic}lCEj9iu>=_2&`7Kc_dJsTU zZiid*Rs*`eMws0}h{OXGxaB|YoRsI{<)OWwQLv>3b&Mfrb#^^Ei?^lmeCtfQ)$ZTLyZ;9kwa7k$0d* z4*wP)t=}RG;@&%E@`G<777&5Kwy;g^hMK&CbWI?hTDXhicQCb+m38SQV= zx#>{I*?7*!q*0YiJ{;caHl-0xGE-6K;Q>zvG-iPQ!5#fd=SL)J$uZsqqRMx5eOUmZ zqM|99;^|5sCnO|<%d=~JQ-nI zOX47rrW@aVPJdLMObixghg(AKj0rwtJ$PDmsTcfK7A7EixsxZZT`^(uJtD@{0z=LB z0wU1EnmC);qwRN*WaCIQ(2d;RPktwORPWjA;H>cA+GNVHiEue-fh-&zLQoz`dEyzA zZzo})B%GL@0TvRNKNQMQD`ys-s^B#1UP96zyi)b16JJ2K3D^vk=Fw+yy7STDIB@yxAobLGwT$Dr3EGAfjj!o#FXA5y`=0FJ`<3iDp5eE66V^4tMWhG&Xqx6&u zj&f2MY(oQb6!+rgKO-e0HXa9U#Zl0xVdmA?(ca+5$ZJ-^o%vXBAR)$qH{8U1BX7=LP-1^`XHin7 zFowAncuSf)Rx?j9;8c`MWQLLFIGGhQi!tGlW+}X8A`vqZo~4X(_CV zda6g>@96SJ5{?H@1k`6HFcQ+rZjElDnfpU1f_Jdz8}rDO$HK4TId9+s#O6__`UEL2G;k#us`7Dh|IcPb%&fqKez92gdJjLp zqT)(c>%7|3u`$EOq%5@d*TiGH=3w#*$O8r>FK|-tyW7B?&M(veHsKM8S*b6bPWO!I_yi z#<)8zlRuHCd3LtN;KYBVuxj`1kq3q=Go}$iMHh!z1dSM@hAR|eR#*eL%xy5 zk~CBLM~=auBZ~@{+M(m23AU7iRL^{5J#;Grs!phFCknvSk+9rsl=s?%jDh!Yl0Lfi zQ827u_Bx3ZM;}xI6=00)uKAvfw)SQTlaaIi8p03dD48vpM<8>wtG&xQ7ZCp7Kl_Rt zVexT-LLh!&rr+k-FxIH($jHdwk|b^Cfvu4}ACOS`jyV?)CC?%vgpD%9G4+fqv^1QMw8e>wa(tfW5lIL_Dks?ra>1bY#QR1aRZ#n!n$+f;nvM^BZY5~k!dp65 zF=Z!2+}347>O-p~lF7J+fdn3}2uh7)yhMg{_EWf7=Usq|i+3*t5_RbVSh$?#kb% z<>%8K;9@H(4`p>0?LF46m7OfAf!mnk^lnqZu(I)mbLXKK#jqz<5V*P^6R_h_P&mgh zdG56+*e*Q#!b27I`q_rAyaOY@yot;=hX$22xg3;@_3?$bEt`)+Z>=rvDXScz!W<&A z(Z(qLF%IAfRA4utX8&#EW53CXiHWymbs)u)S+UD+d4(uO^u`B2|Sl*Ud#QQEBu_7+HVveU3G8M zChcuFfC5>WtFZKmc{k2*3Uhr8G)vPoi2ru)`ixP*0;ba1DM*9f*+4e)S_P7rOM@f? zYqIqoMdSYRBJw3oq4#qUR4=POA4Fa4R{s+E?GKT$ajG6B7sjbTO(5x)lGwbm zf8nWs5&~`*^GT%CyQY^uDO*IjqiPwev=ETlvw+nsSr93GhqxF9XLun8^ZG#SYRs5I ze7+%1qD4xHYUd_FVR|c-yXbK2oRmcvq=Yf@iVP;5Z+a5J-9C%#lzlGrF!+Z99xe1l z#=|yXs>GB}$(FSWukZdBQRY}`g5Rk{CIJR}BmtTPsOwy9oK8^Nyx^%=8YZpXZp#N{ z969uZ@z-R^=@n+rAHWd1gI_c*ed=E>gdEb9zAQalDfNN$wG*dm-pzK`=M=N!Y6rj3 z9n)J6#{ERG^!tNWMb23Y+mZzTxy6|k8H3`pZ~MHC`MR~cJvtJsP}K_-f;fuUl0hdLo5pQ+D=wE;%K|pJsOHd%pzAa* zc{SW4-LU_mrSL)a)$90@>`yyUW3bEU%2UmT*inXeg-B$c|0}&A$xp)ooWyK|Z$>=T zUz>Pr;C`GW44cePHE8>N@ArrWq&f0vCxR34eDzIK0tCp>mJ$>vyg%(y#$K+&2 z5BZNM8NH$mmq?sUhcM;MP>d#q%Tw>4%+9f@lM&R_xG$a^1-E>Byrak96caDX{8XrM zlRx4vR6q>d;PQ$X^0V?Pvg_H4-i$Duc(M#n+<|sW3;pc&a#oSMxC6pdU!cT&@jrws{}38+ zMmlI&S@AR!&`nop0ntS*{AeuQb?1LX7q^iDe%4QJ(M~YsXl%ssg>A+gy$#?bN5C`Xw1QMYFso^$l$rw8!Yl8ZVS6b1{yT8|0zgLFdz;D) zPK7x-s($Otua#!Ii{E@Q|0}GD3j+r~_xp1fAzd#{`P>Fn|Hs!jU=;E`yk)vsAj)YQ zbNWwQRR`*01!((6niIMv)||)I$Z-CDBC>B5W7_o}Mihr~-=x-RKl+u>qGcr5slGSh zOnAyBihUQA)M8*qQ_ccW9%>`Mrz3N{o1Z=0v-Y{obg+{=uR&n*S;0F9MZ4IO4&uWF zLz~;ow35!jIrTPbLUJTt-6#*tF8sTF^ zydO@G(_k8Kow0rIfAHMnIyR7RUx;}=1q_++#%y&IZf4DulmS^satWY4$q{NTyI$P( z9C5oI=FQnpoBdpms54bm{e=pKWutF7D3o zO!pDVy*s$6*$9Kkj8(NJ$tufCRJF3X%J8zMEgmFpnf=-3vtF&~pAb;=2NZ1G4bHF;rPfnw zMxRa4yRZFo1kZ)b4iHi!h#8FXBD#4%MH$w%lsgjLQ=g?)n!r-vklnyv|16sjh|7GT zN1VjSsYVrw({Yj4EJZEJ6m$DheLKZBbuE`~-3cQtkS-pQKPBFL;lu200;PX(M0W-A zXAJ2&gBvm+!1VE~ADfLk$^8;-AHR01%+?CoyNOMMqhx6L%3X!pssZe9mT(R`~fJy~x$3 z=m*D-VA3S=Tk53dX3iIvl?}}-IIBHz20FRaBjDvU{!y}Odwsc+@M<&1f0hUn$$MxOAKU8v&_rp(v&tsno+ zOLj3V&3E300 z#8ZB5CYOpVWI<6Yf)JCBibzLL(RHk3>D*%7OeQTPVhD4~p&4xAr#lR}T7M|6Kf>{^ zuPAQjs7cHb)peXv6=bN>ry|phQ_rEy6|L$v0qF+Kp4i@QT}kPLiLgr|Q97JX3~VB5 zL5CQo40b8Gs)Y$RS-s3#X&b^k5zCnr$@Z-g^R+@8J`a!GMV4`QYY#d#@L`@IftjP5 zhJ$o91>z*4sLu6cc5(9W#{+PY97QW5D;%q#Bq>o%1`wx=y!>@dKHpm+R@<=Y;y!7YmLk8t*rP{AX6!<)$XdW{@TU ztF&nv^7%zqJZX|O!w>Eo$&S|5!cD$*1*qW5)YGZay!ZQVEB7rDmp>FGGQ5#o0}rK} zK3M8tQgwz7!FrhbFQQ%&9q?Tt>egy9_@hEo@2}N7jf#p&T@ls`LA1dmRGi-1+y{~v zH}7qClXOH#x$-lj4PhMe(t{WkG|G$<2T#%q&8aJFSYKmgDmuVfY6IpS-Fu!HjIV3I z8|DmpCjYf^sMcPHD&}HI>x1617`>PdlqZ#%Bvlm*RE4Y;{0RH-p7$dux|?aLGv~|< zZCQM34VYno+`z4L!ST4|g zWhK58Nl?fOJC8^*j1pX8>TLc&^qF&;jzEK0jrFdR&Yv1JbW0d1XTa6D!;vPtpLa08vvG=1hPj4*d0t{xcW2Ft zjX^C%oSc&j(Aet5L30gHkqE>{esT(!!TbTg&Q5#!LE};m}EnonQo)QS;3y zWDFDVS0&&i+i;s&m$fm08rC-vfNA}zZ#}_FGrqO29ovP(6Wok}j?C9FboGVOUQ9Yc zqbWI=zmw?{ym=m`$o>x6)iURsypVgg$e<9ZM@i7l5bz*&&q8qI173(@z&NYi1l?%Y zg)?^;E*!*mwXyzpRY{@=n@E2KiH#~>xqe@LIo?G~?Tyyja=1qIkH9m|)jB_TY>z%A zrk-zh`f1{$7@*_1J&Ns0)7{a8-*m{WlSf?*@54yT`8+xz`8{XmR`%Qz&x>tbzHWID z9dIp@aC*9YH6cE*(Djjh@WX%V(aXe}EFIg_M5}|c5Y?vv)$9JATf_wXJc?gy^K|AV z_rVpge3v(It9W3wBUZ!8g0q(nvg^r<2$&e#k^{bP4mr%l!#G8S%8M0?s) zQdj3BC>zcm-hU2~08D!UIUoP~YfBCrFNu#hIhZxfEh(W@B!tv^Qj3oY(`U~{Xzh5! zxj{Bl7*~2pj~BVG+vS5~r7@@Unh3XRPtXzA4B?5wnprX;a7@g0n73hf&@knR(q)fLv4RKZ}_ zFWeb<>xT{vmKvqI50NE8Tt%2(bx7naSYXNwH|Wux~$+G`&a~gMEjxK3YPcR z=12c9NxDNknWeS0H3NKRChPsN01D*&wK8Gza!64CfHbc|P~9}TgU>_Z{_`h)rmw2n zvOGBqPznmNP&R^I22s&KM3pw)D;|#&3i>?r|70SJA$OSwZdAhKymERm0hd1UV(IU& zAA%0b{OR-FtNx)^&%+cQNW!Mj0kbbISs*B#Cm?6y--QIK-8UKxz>hZNqLf#;v=|o_D1Fp{)SqZ`=c*8L^Wq#hIhX-}#KXi#c=MeI3txiT=rukjgV+>5o)Yv$PR3`25#4Qjov*KbBvW6auq0qyelz?gvneP%tU-v@!liCujrpnb!Xxnz*lQo65 zO@6Cq3!AbgRu9<^VNVLam+ay9Xz~k(?*L^8$*_v=WQb%U_%4%O71#mw~4rl!J%zktAP(W|5@J zjE;;NP#(VTY^((@;H726jk%#vL?18Dr|zkHgONSMvwr$tc*y=N@LX?otid54qxtzn zey`RR^!d(?AJv(N!%+2W9Xpo=IdDSpCoA=QWDTO9vWOQ#L%M*}d={X>JTu_{ z>Z(ugMV=TtAE-p@Wc!BFi9~uCp5lG)!cJT%vWX8RMhHGVd%5$#?Q55?ACL)>RV4ac z&Qz0V*u=oT?@fqb_Q>Sp_9}5W_>X}s@3P6mm?Fi!lbE8m_hn3*3?-&k z1aA0!p!Yk#!RkCzko>9sN<-&5u5_TJ=IQ*=ZE3Exvtan4ZdHVFe}#xjGF3nWT&^*p zm1J^}hq0iNiFr?65?Y+^%#x_kGjILvcj!w}674z0wn@^T$>o1rQSh!>oF7mDfz3tY$2BM?q^uM4U@m+S%io4q} zs6KOoUA9q>1Rb;acF>Xqg|YeO2(d&ORFXX7MPKlA(F+*=6Fi<#o{Ng~`Svpg;^NqZ z=tu~B1EA2*zdOeDbKUz_sA-(2E2|^^cM_ksnO46_n~}jFuX- zp8fusmQm!rvYD9ot8HREOLeQ61{l#yEFTq`zEuJz>cC6kW4?$w!OVMpw}Wyrnw_#o zt~N|x3tAk1j>?N@h^vIL`Qvkkx~?#kueiyIo)wqPIu@V~9#|1okZahNknIsa7tlb` z7tM8Eilc}*+g%&}WOgxf>PV^vImYxorlSo4op6%#CqF6_mC@D8rcHPaR!LS54uEB; zdlpo->QY1{B{X$#77XUCD9pZUuk1b3OsiN0_SZsGcbo3lYWB-I#WaDA%}jV&G@;CH z$sqh~uJW~!Z+dG2EM!JsrcYYr0rqF4rbLFO++dmJOJOnDodEb`J>MI1NDi+$^Y1=r zSjq>w-^|c~*Rn@3W1l08hlU2+c^H4M^G-3cCM$q+%=Qu-gaTQyBLEwgYha0(&?S8w z)a&E(o5-q(i32C5A|%Je;A;nyMDbU*ozFQ;O-`3+_YJ|ZjMUhe8-MAHXxr$ZUp1?K zhN9}(8`|31q#XQ+l;SaH_FvYMwix9*=b6cQlu}^r^Zj_>y^MWHb?mk<2V9|;f99?F zMQ~DHURnjGirF5j7*zDg&m6p*KToEcw%L6-U-Zd8kwZ$(z~|_*grL2L12bREe@B<) zT09tf13%boTAIOAd6tcmK$w~Gr8dWC%Au>)VlI$a?Dy(qh+@}%QivK1f&U6f%%`?( zI!*eu{=K=%9^PL0yRJTPqHA^+*-WoiF%*&RmeCquYU0FBgHBjwXrqu_i9q^}T)aEh zeXAyuzhOK+!1`Ojcu>r)j^{({7CFIJqzZ^sRBXh~sd#fXC8}K~AKfOsg%N-L9@eC0 zxm@1aEY z+p){3Mer^pNH?xA{5!YyDbeqWZ|F^rEXf7gZ9Z;S??w-_0C6=$UW${rp=Dv(dISjx zrPW=mKA30PG3dm6XEOqSxPTONVbvuyz(IM7%YF4elIRN|+qL+%cG20eY4vM`4*x#` zq~yK%0>tetCbb;HrO!;vYOodyS-Ajcn*#2LeCudGUby3MYEDQqIy@Z5^eIo z^F(WUL)W>^98tFi-QxdMr~ss+(;hzPquCUA^}p$jV(gTnfjY@0}DW$ydaM!-G2Y^St9JZ6r5r?17SG<0mct(#JpQVdb)nf^b38f_U)87@W zIBKUtq4z`~*)0k`lY(ufDM5^kF8Gp8lah{D2R4>H$fp70&W6|*Dq-8)OEq;m#R~+a z>xEJ#v`~d&q52-XPDwPRR$V;D#Cs(xkFxpuLhbMR(33GTID}u)e9@l5nTi)j<<-=@ z<=v(Huk-|3{T|xC67!m}2^Xr$KZdo4IPq}CNtHLg#zK`FiFTmN%O^lYL3`*XHkRR& z>6*qz_HL!}cZcmCCC8T|qX-BaaE{v8=6Qsv$nG1QTM(Ga+Oql`Sd!oW8*A|3-_vzn zX$zV#7yN(+Q7rrZj~J%U`Efh9u( zE#|@bjn~tshwYbpGh_<7XG^pN8`gqHF^mLOJ3GILlehO~<|+$Uz8v`{1PSZk!qJ%k zd0VG@w&wA-sqmS5`?y5e4F%cG^FWzUT-p?;|4LA)S`inwE1U~WAhH+6P?AG#(;KGD z^o$fY>%o7W@l(X$NWLm1ZGleoKiYvw`fd-aD0?eBAR#@mQt83Dq9F|mdVm4wdH7jQD6QacG%(XaYY~a`aEpVG@%Cs2 z@qtJ&dkq$OlT%+?sPzUK+$b$rN9)EPT=qgdwmm#dLjqoQCGZu)$9fhBV#}^*ybNX5 z_oRS)&sQ^v`I29M6`q7@m-Mz-Noe`}Ka{;?Sd{P9H!KnYg3=|@DGVqfAt9l}&>_Om zFysu40uoAtbPNsBO6SmB!XVusjg-{TQtyTTy`R0G`@N6jJzhWfVYsd{*0s)+zqL*^ zqIY6yr58U7P0f>=p>ow_0b|7m6b6Bfd4O~A_mqC{nX7l@Qc^mmr*~pyppK>b3LJxqa!L>(mYYV6aOG4s6%vl&(+0+ z#gO?~se+HE>-If`vK8k?3*Z9m_?cf=GXnr(y$)o1_HvG_81z$d{TBUHC*r5^*jxDs zmvmIZv>_>qA?!5jlyv1wheg}HtG>U;0S(BX7(W*l31V{3-e3mqx|t_d86~o?kXM>` z6`xcik2%?=-olhCklDFp)yER_`O}XF;z*LS*M>34gf3=p+YhxAT{NZ6-pS=sF{-2a__2-=w$Qbl zT+cnc;1A&uY2~0BOrF!m$#RS^&h_Z0;|Mj?t?Gz~^_=j>I*hwp&WcjGNRdy-%HCT% zon#^2gV(kwl4BO$*)TpQ*)+1w(q20zX&6dHDv>G&=`XwSYT}h?$g|@{)+>4VG&eS2 zi#=S_AnZh}W?|B{Os!X^T%2g*OYdMp9*h|3c+rbAz0N^TNnGZY?=9VcGX~I|<2V(s z0n>$WYVD4usj;G>M)#)O2S0g@<;_5QQLVO$;F*6YGYH{);oTAk9AH9^PY!N8do2*6``_T5QFITRbHD5~vlC1lnx9fky z9k{XH{W)6yaV%>l#xpUnybk}3QY>F*(E^6$m&}^#TndsUm!x6D5P87i#~D-e;D$XD zgIW?3RzZ=M22=m|!{VM#BgyYeLydMmv1L+}bmHEV-@&rsYqa`K=HD%I3L~cb0L%9v zjj~=TM{>-u9}Zpd3gnaNE5{b{eaz~@)vHfE{=hwFJNTT&7ET#t$jj?`FIwuj2Aw7 zAt5g|c`&D4yPq~Ub3khqVYN>_H#s)z#3c?2Ih3Ee=na1~A2b@T2YJ_+;aQT6$wSqP z>|~wyF}f;^dTS5Vw7`;zK>>)`IK_5CNt*=?MYa~ILt8j!SWXP-@H464oG&Y1Jqg$} zLl8-#jKXP(uUEzp*9S@FOr83qdw_5F@HA;Lxt2i;srT0FOKItzN$r%g75h=x5pvFb zQH{PiZ*CIzy*7PojnVzc;`t!n;IY&3?e;a?;;}z}{yb>A5)oE@FvMF_?!AzS>DHQB z6PhWfyz<~Nj0qr^u@Uf(zHX|ThVP;DuZU*7%skcE7XonFceyqvkNqWO1p&kD#QOCC znlJ7M$X(I;A0pgHzoKFiq$cA41Q=j|dbjDg=iF?#5%P?5N^YDnT=&HJA0K;fJ*gY< z6PzMW&{zZ)g8;$#1ka+=odss5q1c2|L%a#Yb5*EU;fy;o%gUCyzNJ)%yD_SQ?|JUm z#A*;WquRZMTJp;SV}>EIA9JO2(Wt^4Io?NS+ zsi3I(v@MTgv1m=|dEHl_e8cNNl}YB{nSl@gY0<=`^dJ@&WQHt79I#B&Mf9r#|JuZq z6m}@xnUNPVP?78=N3VT8>93+|jqj-|kkI6!EXMapGSAFiJ15FtbD8d@#Fjk&+I#)!g%Fy39H|+YGw~Phk-{MhDv~* zIdU?^(OOvHyVAJfu6c>!a*rxUS(k60r&pg}@k;%6Cz zd=cUcA1Wy<9_K=2r>{C?a*@k&Aj1CW$TRxKG(<`I7X*>Gx^hrNVmDgd?W9^@O~};E z{$Alg+zRjQWd(K#fFoLR>J9~8KuTJXV@A2uN(8TR$MCceSV^pYP=z^c?vzh-RxsMk zDJuPXYFQ|Q_#E6n%-W4>_7*UgZH+%D4*0bCfCmL&-w!`K#rivf%`L%Xu`HZy5tG8%Lon^vMa&=fwQgC*@0Df z9Lv@n41PV-MzBGcz>q8{Jgj^Gk`pCaeb-&n+<#ZOI););RF2m+;NFAdY@Tt1uwXfu zC)-??GW`QzH8kCxXu03sbK*k;jn+aZ@A0Z1;0`Z50Rm%4BsaLMr3yHnS)=3}$@|$A z4ASf*-dc~Phc|7iXp7kIm*(Bi{Gt{9ruAvcl#`X-etP0`Zk&1r_x(X}>Y0kZqxa8g zA7mDU{xmZ``9eeUa zO#0G{UxinrHs{6qug_uHZY8}#2v3dy1;68$sk0SBR6?%|;qJ;&-#}|UW)8wQo)ZRO zJCwAUSYREfl2sGpWZ+aJ&kpWJjBEGV_4E>J|Iy73?|E7SMFKJENT9CM(Zq?$Q~TPb zVr+V3uT+JyFNs{b+Gz4SOri!lVQvSGlv{j^8S$y$=ILXJ06hq}>72M*&0WwWIV}U4NE1NP1E>`;tY&-iK!Kgxw*V&d zW!Qs^kHa3YCupH`y8l@Y;JxGfHrh|?6D*sDFx)(!b|Y|xSlq8+&1c}a$CT363z{JO z@vqm7y@;HhLS1G4{lHuL&eT-XpF{tif8yRV_WZX(E-LU%2J@x1+a>+yr)g4?^nzch zDzggj;?^sz*?Coi^jmk^>{PZZKhDNz5*0l(RZ%Io+nAftP^6-J5H=J_ortUNmCGH8 zEttVG%6>7>x-!%gUX2B`P-1WWFs${2PHnV!d3aIj$%Fe;snKjULnYKoso{KMLZbY! zskq+`_{E35T~^v;*9XPwuZDP5Z4oYUEW-J7|<@bVk z6G%5731;gLq$B|Jrh{=RJoSjjW9utpUDF|-KzuUKw`pku;Gdg?f0`MTj@<-t50zr5 z88JS)wO}nDdH8Xjkv%qn>!$gZ@_V$a(IcUmc&$pM8Bk9CFsz6`BDXLU7dW;W|W5*y=UIKGj z&Se46hU(6s23vTS>9flR*cJ}=p33KS9Lw>lx)I0tw#zD+@qRx0B3mKzw2Nmq?g^6R zxGU?ab*YhNMvNg+4DTcdNf1Hk2yqI%(;+41@{0aW70VRai$oI8)Tg{E+NN)FlM}Bc z;%bN?$m;XH?T88MmE_1jp3M|qGoI~3L--;!tj_aJ4rC_azEYYa00Z-SQG09i_wP<- zBStBUC;7;tUD0S==BQcgXru48* z;w)yz#5X!ZByv`!l#1L!6G3vzRMQBKcT7FVReog$B?r+_{{h5s$*%d6H@gP{J-NJ0 z>GDkEk-VYErc@eqr-vBbpc`eUtc3=#i9r0F7FYHh6h<`zCq_gExm%V@I$C(B$iHtf zHhK&p^!QjE!c$%byVe=3rSztM3g(u>Az%XL6XkOEDyo>tbeQqRT=ns%4B4s%Bfb{ z-P04lw&J5Y>RdK+l^E=d>RCyOc&Q{n6kXy1I`?W{31Lb0@*7=Kl+tKt)>#bc5=%FJ zDol4TIz|n&CL6W}mwJfI&4)wldU_%{%eBwt#l$HX(YUFNI6a&0$|p}$1;e_a7p|&9ZYnDh6hyy5f!)3LH-dW zo^D()knLX}P@1FTo;*vT4#9`Z&Gwg|pIRT{$;fc6jv*+328#Ewu*#6KZDaUd-gusE z^3`MLP4g!>O+6b*d?mW%g>GcZtobGw>IyOY)anz628;bj&%{6dFx|AiTu>di$i&&m zZ(#vhdvMh3S#S@jLf4ni^01Kb{23AnwtBI2){z)dYfl#!f$2Po9Aq3<>t_2AiY}6* zpdzOPJEQTa%^`J)&#kJ?4*EzWLBU$0-n^H@JDy#rADs|rcuon#p8A!)eL-=OO7p)> zASgb&8+G`n2*`QQD69$7Qfv?8E-_3@kB|l%KCZ5Ts?lui?(Q0zRyX>VmXnt^$Y=EsQSThL+6X-yKKB^R&CDeK)rtsU|8mjG`l=j?Ft#A+J1hKFd9hJgaenwmzez#14iH93nj5hRMb{(I7t5jbMteYFcf zXqVIHHZp1f9Q3jeW44Sqm(A#!2Bl0BAyg zkRFL~vO#TBsZynxz&5EB#@zc-1OYclOcA?0QBIx!&sVL)Ujs`FdGffaS92G0=FOiz zM;+3@l^O4xxtglqtdt^wfp2Qx!IQWEa4apxS;1L&!lan?pV?j@VWA26)1ztB@A5%x zjdUa|V`}6kKwwn7d#1vZC0;2UqyY~ByT##~K2yaq!rXWx4b_X_A@+pBE2s|KQD|hm z1ml;MoKerQJ);pmAKpV?Kc8m#AQtxEjNLCw`W;rD)(z`#vCi)1@aA8~eHbPgolpCC z5kq5zS9h22kJ=@whk`X&f9+t&NMg&TbFx#1Z~etO@joiM63LptDkn9byOVB49pyuT z$JTPnDJ1_c-R!T523%z)T_*vK`l_GK+?c|3ZS|RetpY_N#~yQ~+S2uWh=AT<7kVos z02nF#BZx-Z-eJb~Jn&Om)EyPOGCQkV=P?&_-sKSS;9Np~R=kJrW9*}%N6EnQ0kmn| z$JvUvWp*2>Gb2*p+szQbsGPJBK-GOY;o>UJcHqD_P`E10xu13*ii>%2yUP0(2+A%z z?>t4IV)s|=kL(K=f2p27Gl5J=>`WqN}IOZ*950psRl<{Shjh*nB7?c67G#P;iKb z&SRrF_XwoofjimBsR5Kv!f@DY0PQ&d7a6qpJ8MGmf_L{^;}lSC-utn|#0l-#azh#G zvwv}=s=AyYx#)AX&PIPK`7GqT&dB?;cEM*)#Lf9Zm?CKglRf?E1yEzn&OW{7fIl#p z!LTT4uJ@lg=wKqH|2@ZVc-`+!cE<(P4zJi)@KOf*)iryY8)5AFfHDuz`#=J~lb`aY zq&8C0o{ed$io4Py85=*~UhH!Yt!l`NoqjeF1I->W3oF%uK7YFSp7Cso$>V>UOz4o> zxw2+WPF4IW=NEICixXO6!&pUH620zyBNMACE^@B9rEoLIK4|iI#CYweW^63VpCrH_=^{lR{O1Z|OXv*j_QZF|Lr99mTc6~h1c$$#(4ov$S z%>tzi-{=dVs9Z&2$>{kxOAYkd#}^6RCAohYhXdDp;;r-N87k!>9B6W?98u9D(H^9E zX$a*NK^@xT`*Fo_O8;m_e_~2CD8sXT?`9)^%d(%I&4)Pga)UDVA;{0swPSU41nNd~ z-MKot9}?CwnD*ixj+dzunzH4rYb7e8@SIat0_fwgsqy{OWe@w{Ieo^ZNDWl|P8v9N z8y*4VC$po-lDM@E^a|9XGrh37fX&*1JFXC~BxOn(>8wl9#zhwWKV}eLnvKNp*4Yk@ z?b?Cu@T7Qd<;SYd-Sd*Y9261m+)YU(m;*QAsX>O*}Wft z&**!h)PR@(+8;dsatF7l=ocLyRjCQ~SL}z?JIv<5zVne)xSYSJoz{+{wxQcX<*fW> zc1R?_+~nfv!ux@}7D`%`BLcL3*l0$p`lG>yDgZiy4Vx zr)MjXH)He+lg&$>>_;H>%!ioT5*PzsKt};P6w}f}i+_4UJW{(8x{;bokks=v`Zxpe z+LD-Wf-p@JqNg^-zuLFWIGJ(~RyRFEf_bI5Vf19~g~)m5Y7@rUDdkWxi$E)Ne}-iE z!{@Pw2pXQV3PLd&P1zFcwI+l=M**@x zdMbvo!-%496&|QhU1u0{j9TqPT<517zEk;-R0W*N>|UH3dNz|wuf5e(nbY|TP0xCC zek^^j`0KfvPG-0bDu|6e!^+C-?h6uj2@&@P>ynHp%}T6+xlEC@tYtRv#Nos-=>~7avXjw4l{K|!Y|gvngfD}| z+T}}xJ)1}B6neh52l#T2dVo~3`pdVP7p1n*jR!;tz+YQ88wwu^Rb%y z%7L5oAL=$&_d6-7na(kITmDvjZ&kqwAV@*2&8EhgkBm%5#l3`YI9x3MJ#z`j`Belg ziHl|`1KaTz9>ByRjAX;BhUxomK>e;8vrJx}wz@9|OoE|`wu}GM3N@V$UvsC&AK93k zRSt5Cib4~YqD+&`L>7gEsClyhj8ZDwzf>Y1EN%rn!B-aG0ne_3bY}Ke&t-fNh)xyk z+Z=5I|B9tP<(YVJVg>oB{It>xHJsAt1&9C9FUnUX$-I}{i?yk)2!2#@S(0gTxs{i@ z-I8db{04VFwESdrgcP{U{>xxSNyj5nt> z^cL7Sa6`}@Z&sA2OPt>o5aGwz^FdfyCAycHd=nTM8X}6GsC>e8=Pyy7y@q+J+~{7g z68^J%z#B|mbQ_E&ZT&xXv}}-AG!Q;AB72N>^KHq9W-9;+z8ZooG@eP3u5P9yNElUN zT0G*?+gitOA52ssp)y)}6Hi6yR3lX7N!6Wp(Uv2AMSb{p44*Y!ybU8RWjo5KHUgG$ zyCFU>1nurFEXtRWa1TZej=+-E;G)QuOeVl7XZ*nQ>7^LZ!UN$kO|KMYoR4w*trZCVm`Fo&qVW{Y8N>DUCXI5zd*WBzO2 zIfOGNpj0UV<-Ou?U=bW#{s?Cpz0tt+ZHC0KUUN-R%{@k+K|_&?5~V{2FD$gR?VwN5 z(bHdRL+Z1AVvaQ+WL^;#q)_czUo8;+J7!`qVQC}U8y&Ax<>$DQARth6_aEcHQ^GMK zqJN#V2Z==-5m|b<>ikE@P8oaX9Yag6`CFnGFt2=zj!p_8_7GC95*~oq0g1=$iSQY+=f07h z>!H&#G4FXqXjV&duhPu`Qw!)HC>;j+5VIw(FCYEFPYD*n|EA=K8{MW&x5TmO-)AgN z42j4=z(c!Qg2Tb(QzNT&Z0n$6vss)!wi8X`z7NezAuQ(QC=A?Rty$teHJ#s8jC-G2a;?GxOU+8^~I)Us+P z&ZyY(`wzF*`Uu_GnC%$m?7k82!Ez+@++ScNNQl^)ZJyO#46Z?m)h$uyq)o#)i4`B2 zXJJu4KGG+6fJQ#gD8-JOc5(?>w6)D{@y#(7_lj|I82zq9>k%8GOvrrJj0#E00B!$S zzJE*^9e`PyIPb*`NJ~HtL;)C)sP#NnmqD!b6ly0k_JTs70%lbQMeQ{G=eK1iG^q)M z-KNvU8*X%{rERUhX*^g?@UfYpDR3uS>Mp{7``7S*v7ev+#|gi!hu;I@y6;{hVJr&bo0u(-RV{&Ka^FGc>bdLUVlNR}dKUg4ghJ)k3#AIDHYw}0; zeiu$>ewmUqQ2f7F4us8CNp7v%R-hs;>A{3n7jUjGEU#wdz5~W$1e`jK<$QRqbx{yBpRSOAb9w5%fo&Y5C5E$4W2Tc(GRlzP1eC_%_qHKjobS%C@x6M|<`IjsD z=MkTBQ4{>!5E`{Zkx3>bV64AnT;yNf$0G&YU*L&+a z^eT%Y#so`^3a}hXUfr(iqpzBIz1E`+jlfX+9cElWsXIfGpHlL#&dkINM4={nx*y$s zy<$*8NhyQ`w+tewRwKb7_wTpc8OVm~z&l>*>B+BS?~B;d2>QV;aKl_>DCZFYNsN>8 z2$#UQu-g%GL73ayh_Xi+W$6#QOQC;ha_dIiDt8k^i$K3ip#F5&^A}qffPzZ#zbAn~&5R*@DxBk~pIjXiriKH{Wzars+ zJTGcdC<$=hF#((>90w;qR~e%QWIuUj^_p@K%Oe`Ptrjt3>dSNZMGri})@rb|yy~m*{!btPZUVF&46mZIJ`OIzXze;JT5@iT4?x0N z-wmo`)^a(^S=R?5mXQ4j{ppzRXds7towOjgx`idR(OmSOC-2sBJBJj2fe69vfDy27 zeHuvUpqmb@;op81e=BmZGTm7wK)CHOnNwekhhooXbt*>V3im8c(3B^jpscl(La@&PMZE9LML zr7GO=19>fcV#qtTdml)|(R%-En6|Y6QTJ+7zk;y19Z>MoQyM4PDu^+}r#PcTDlHf% zfdK{9zq&QFTb>E{7y8cfv%f9_dG+#0;j*q)FZN5`uI!`ujEJu3_|OwT?ZpIu$Yr+7 z72wngSa)Ucnm{S6XWFZs!B3z4+{wm3?C;eNetQ8G#`sLp6^o%QcnY&7)G=L(QR7*s&c42&0}h6>Lp}q3&1pU z2>14aOzmIa5r#iCpBC>Y|9Fx2v?N2%sU)!p1O@X|upn9dcDQ8P18%n@nW-1=XlsZk7`N(1mQr4(iB)q z`X0LRKfQq~xb}XJnc}-U*%9!)is+xJc>i7Vc!qxv(Vyyhq&BXl6Kj_#ll_dp|Kjb& zG7)?7x4u3@@(@l3fWd(dL3o9MZ+a_1!yIX*`ZVKt9kowLbr?bg!zh zcKSvApN)9VR=h?+!=G1kQTXP|A^-TX2R1^*ncb4hUmjtJ-KiX>7ba%Cj|s^t5zYb8 z6Mn~l7PG(pl8uinnhl5|qW&p}%5PosErM}wYjA;2-Y++cdeAuGHI8J+;K*mux14Q` z(@EkjT7uU3|9ei7SHaxJSFIs$>7>SciKGumGuvDuw}0?YPseO%u!ARn=IQtVSYVb<^{rr) z;yhd--KN9`krRc^I-rYUkVZ*)lKSDYNv|h|K_I4SrTP=lEcNHSk3W6rR8fJ_=>}snT%Afejv?Ggu^@utDa*r36X*gR-N_ z57&YR5aZ_ia0`@5D%-uqNyf6Zsv76yqbHf!qB?+a_~}@2U1MhlMsQi$b!>QHHC<&!#FwlaFEqXw*R1 zYQ{7$>QC0+)m?EjResRc1XuuA6GVxnc!+Mj_onhc8y^rWgX$IxtU0mH2q*wKMoTUJ z1QVW>`>TR6MwVcp(PWyx?J^BSF(xkT+&Y<-NSU?!-Z#+Ix0gR;`O*`8m>s2XON@|X zi+xai#H29t12A5$?Ikn;#T(zCzXtz_ktz+NE^?|MJerR7qvzyBF z;^q)RR9qVL?W`5Rd^D=BS7b`iXH%#UM&uFg?U-HWsPo?6aLzMNA?kg{|1-o|%wqlA zj*~J$o&GhMn}$8Aw(vN#5Z*2*y5)o?+=2@T6LKJi*4qx`qcLtj-_(d}-F^Sf?$E{& zs40qQszN}J3_pMbm}buSA2A4I^=@;_1C3?SQ#X!XCp?F(5kVXoM@>k5ZaI+N8DCIo2s^GDy$8XF*YB^@)l<^%nZeg%LG z9D5(u=zA|ay0Xvl)by}pi`nU{hBgRfl|xUf2DRx(r`nzUwEt=wR1v&){rAfE+W4k+ z@4+N`WSCM%OD^-bq%)F)`@7h2IgAM(m;^@=8gN}21=e1{L#9?hBG>*6Gp<&we*JnI zeEbyzg3dYo(!hM74YhV3CMYYS(F?c@f?A_q{YEb9k8=1q0`&2ocB1>YIslxg5>~m- zBXz**wV*@)f31cat}C$eP(@vB%WV+Q!K47&-YT%lD%OT?!jr;)|9`){ zG6T={RGT!8kN2<4R4w)x=e6zI3H)uOS9#plKjUn=-=(rQtMXN2)wSkC)DZ8Ojjflk7g9!aFylM+Z zvxIaBGr+I|2ZRAV?PiuZzU#(Wt3}WL@1k9aw2rB+(3ZwxD=Be|Fd;F zaNHf;t@SE$4MTx>OcGfukzjjMqD)!bOk-abd~@%)|0&|jek;nRf1*_F+Lp8O!1k>h z^iNuwLwwwnh<(s-7eLihCoCeXI@F^oIYz%^-Aelk&4emjJtAWb2Um*k#AH#xT{Jr} zeZi~WQ&)!_TYTKPcVDge$rHXDH^|1@c=4e7i%>}Sa+-Pi!2vEli~397?TrMt@)%q#N~Od;I?k=u;uCgKdzU|ndimRlOm8RkMt9cqfP+_(LuR=D4aYw5_ zQ>e2$u67X7R%$nSWQ4C^4_lwwWZ1`U9AaOTBoyjL3hgYI)^vl^>(3zo^!xusk0C%E z2s->YxEt^gF4rb? zx)5a=d=rhMlH1@*6>b9Z?YjIuNdNk|q(u|b-(_4+Pcq;$k=okY*H6gd2*G!#r!m%e zP-b$>Exm6kV?YuCZr!OKXAR8$Kh#08uzzFSmS$gnIVg$XgVJe|%M^Jcy%%^~%{j7u zxE8eIK(@-=Ct!s!?IZype&9)rbBM$_Y?04=gv&^7jyc2}g1Q|Z(ra+bPYiY_UqH>j?`L2y5QF2f^Ls%?!!O!R4ELh*$j2vN8hzKed|E?0UyJ&BKK?1@mrJI!`=8A z@Jo^XM0Nnm#Fp&#e}dZ|ZhuZaMVk9inx9gS(uTFZ@EO;Ws|tt7Q13U?0iCg^A~Q6-w}f?F&60rqbT zAimt;DAp0Cn%T#XoEq4>V@Wfx<_yeMrhGrG$_ns&%P3V+lFetI+x=zk!a*ERUkd^$ z(09V!e*o(#g;Og@Ik}AOq|2Yl6O=Ey_3u28$5=azC$4EwL^m`*e(wGvbK$G|zh0Rx znn6RK@SC=oSI#!c+oJ4hWY9#z%=1$5U(@BEREOG`^nZT>Y{1^4vwyN0DFk@JXM z*_1laih*5C#w?%srXQdWO;*TWm!gv4A11T91##Vl-Y|_#<(O*~O*d0|WZBwmo!T9~ zflYmG2&|qxk^Y{XJhL;)rQUV!@s!aV23$sQSa-65?|)*k17zjg;o$cyyG8;6JL<4H z`F*gIN%X~^R^qjnXg=|HM_z5lf6)uJ|pY3UfD1nQ-yGm>E4*rX<#L_U4k_ zvr}`5$S`bhEuhQf-%Fq0Uuv*K+NIdYe%)LStfrihUQVjEC*LL+>Hk8e^{>f`tXV2j?{0JI zcX}MDjAu1{7|aSi{FC%f#a?Ek-;qNva;RaJsJ<2_v->`7nh0Tl4GnL5uR@U#7i7XL zA-!pTx9cJ0HZz~!tdsZb$j-^z%a7_D?96r)ZAS@F)VDk>VhiNZU%VoF_YSjO?S;1b z!;@Iu#&>%GE4XRXp=v+kC^MTrsPBvgR@(|GX)nqOjBm6eW^3A7=JB;0DI%vV@Bck> z&h@V`T}fA9Hn&atk*VTr%m*G+iXEIkM&l`mrt`vSGT}{S)sB1CO`u2lNc&kNp=fF+ zqFVE5qUsBh7NEb5(dQKaK(*FhwU(8za=>w|t~zjgR)LXp+$lPU9u>Je{&1{<&r%<= zWprr@;=;{fA*2{KsL;b|T(~$PCk66Ig>#*2(!1!#AekL1z#1j?SE^vMy(dbO*nTA_ zh-)BLi6fZS^fg_tD7D~KH9bFYBO!TwC}{X#<@<+nyv)$h;=MOfc28cPhm~Sx=IxMw z{U!Zova1eM2jXXmL~?7-J3=Y|)sj4ywkz&+-5%iK9|7EIvo{u$oC;s(O*gqfeE)EH ziCz2H0GWIGJK(c}DVp0wZ?GUBeo*=8o1U%#`$?^Iz~esbMVN>e_2A3U!F0zr{X{*o zIvAfQ*?|!OW?<6BH#H}2%6n{<9bC%U1X@})9_f{zW@BdgWSoH9N4b1e-`W##3Kw@K zlyx%Og0AhY$rFEjSoc!dB;RQ5HT3%z;pYRW#=mc%o?r|n{g(XYROg!_PgBE+0q;o4&<3HvN-f=Fa#90XBln06rm@s%v3mvcwsJkyCFq^p~{FHL{usYQKldo=UGVo1Z@o*LwQoSg_wZTztg$ffzRCC z8fSM-s$L~C<}?=GYr+2+ExJQMx&{~DugyZ*agI#j#7e{@aVBHhz1L0o5*K?IPt7&u zfwo4Cq?0=VavWfzxej>UQy6Dw&jA5R^JYkX!DNqo zgRF;=IKNyneN*AdUoGT+v$5Tq4+1wxEB&MEUs(uEa&h1czy;|*iJYd3VB$7?H{PyhKu=~-pDBQ*lmYtfGc>Hqnb{qG^K=i1^H z4Ozk?BO)S@wVLLCW}o1BM6B>~4OY8cWFN3J>cAAzgE#>+tr^_9h+PsX0EhxWmzB^u~6IMS>?7$yru^k};l%cH%%nH!y=gCTpJ2koCpcGBt7cscCUe7Wb zKFqA!B^}gd>tMZC)krg8^3Zj-&~$KpF}-pU$B7Kn*IjkjEzs=iJx42%!NfyBd+SJ- zkJ*4uIhQ7$s-T}mi;g1&h&?r>RdZd>6-hV#_G5JI5)P8VxaE?@qczVg=iX#0hYw{p_%noQ&cOI&!B

oP7N_s4l%W_xpZhNk`NXOsv+<~{jSVje?levV&j`wu0w_)Mnxlqlbz zh3q=)n=i8)yj}REjjo0xy^omTFS~&+LixF5{qq!J@Mv2#Z>tOv7_D^cQY-^5h1Ops zTYFs6uv@?V2GFw2?>?L>?+9h{UTs*MW-J`D3SQ;ed)~ZrYpT0Ktr=ZA`!}D2r`R{w z>1#eU!#4q+pHjbeLC)Emf?1p(cVAhfH0;1HXt516k)0LhuIIUsmXr03Uy^cPFDG~z zT@DlC2~IP7c5L@I2U}bYC+$G*eU5EwZQWv%&#HMDPYr~aUABdYoS6NaAEqeFcoXEg(N}yGe8~8zasBxo4*c4aB1jA+)GvgSvVJ1xL@uXuO2< znf|#ko}v;TNFG!FOMdQJU1aXb+*(@gsE+rf@&^{m$hB zDvNJSwb?UCSAY}(8w7nGM98@yJ&`()PVZZqWWsVx0Tl486Eblc%~sH}=SBY0ebfNT zARWN#b&Pz`@@c5X{?!K9xP7)c(5`vA(CEBRXU}Ox)`fC6Rs(|Aytmwm5L!5{uBfn| zi%@f?c3QY5GfIO{&w-g*+|;Pg;8_7{&zNo z_wycya8bxS&IzVGQ~ma@lrKDdB>9uA<3JlJUa8}%y^w4D!sW2u7ErdtdmYOhm2-Xa zpJO6(ujW^_S`k;f+pP$p`im~UQ`&Luu2oGCSY2EI4$Ib3F*Y z8mjRGdICxKT_8RiU@pHrI08iFRZ~z`$F~092x$@DtxM*Jm)lAxvI~jnNnWh^#rnm5 zI^qIh44$iLG@RS3H=OIZKp=(j>+@jN@9qr?HS0ty-c`ET@rh`@Y(1#LS2kvsJnK%6PdSk-Af$gW7_u&kpG~G2Mu!P6TENM!A+Ps$=G&J?*b$BwVpZ zr(V7GvIMp{_2FY<@KuuvSVaFD0sNeDNvIZJ^uvCkMWIm1Khf8BYcwmKq^4g27vKqq z(Y9gxuEN&POACY}pX$1AkM10EyQ?D*5%QdNYT>Z3L1{1P{dG;FFK0*Ap2Q0YT=Y$m zuv8h@qpq2%)|idKXVcMci59b2 z-mk2;n3RFm9_9&`Lz4y&O;^rP6wp_;{0owH3SPGMZGWdUXs_0gz=vxRNdR0qe)J1q z9>6iKvy6bBpRWTGOwQ^;PJBUoW3w?XFKB;z<3RWhFE<=bfod+WyIRqV#s_9EQG8R> z#S&%ibD&Ky&Ci*0agM8o3v!6}z!bVc1eps|b72Rqtry>!LjVak=fTujz9PVwfSrp0 zpSt%l z0J_%$9kg-HQ1jl<+zEh|S4_Jjdfa%kF`f&TQ|Ou{p*Fp)G+tBF81TcwjVBJ0%z9R|YVAGR46MU3@@+2Uez~0~PR+Vc zTiM{nklD2o5rv%krr&D3>F5s!GM33iy1FL@KsAn3ZyHF4fZo>CmB%u6^e$3t`abfq z-V`9dc)w8fgJ#vDB6J>{Kia}k5HsCq0iMvSJx9|OD(7OPbQw0ms zY)Az`ypnEHPFoeJYwb>hvMwae!>AcMk<+8s!&r5$+4xyz)qoj=FxDI(Kk?67;O3>lB^A3x<`EaaibHlaLslu%mq0a z7(pyAhrvQx;!VL+_LWF7a>&VN>S>LlR-{<_4BER~=l33O6v!`{&y(`D{O;Ds2QECz zjh-Mk_#_{=4q7-9xO`p?Z5Tjo7r{0cM{qYGp-oo|{9TBPX=l$k+9@>kIASIUmFqWy zt}lYQ=S*6G9cy6VjklJ-Cx3^$6k9;L1O(oTFBCg7vI7IznmTq%q)Qznd)UmJYK_Vi zHQ7-LnI%b=@rR#t!N63_?NVW?rpYpJPE$x|LpkWcT}*T4#)r$`y=T|)5Bqxkg+CrR zNUZ(4WgvTDryHP`+OlguUs!92&*NqvO@+Q8MC98kl508Mv;{9&f4P9XbIr_}wPSNM zBj~*uq?y=T^YT6nbwW=BDe3zR8ecDUfY7`ZM#5m`nibj0XJcK}xS4qm^Lgxs@`Tdn zKQ9P)i5)wN7u--~<9$z@o7TACGRilG>^=-Ky4l_fenIMbY@D+c+sS8(1Uiwg_^H@HYww9RQFtAsL}v9%>8Y7u9Ycjz6geQTD3r^ut~1~$fl;B!5|ED z>DQWEg~3zYB1x{o!@`d$ldHC zgMADhAU&~e?0N}mP}n+u_-odP%O_b+LMKD!VX*Uh8`R`qqi(R9Gt4kSaQ39+Rk$q? z+OA0UtGb|JSMtt>O!oS`Jny~d_FaKZwhYzpW({$5H8mT&KCl)D8$}KwS8`f)4Y~lA zV6fovz)U1p)p=6PBM445zLJUupck6gSf>$96i9#6&IFQ0T%a~Qa3ra+SaIyGrRoyIr>P#^R#~g zt{ILiXfRiH(yk{jR(aBot@T3tliDaYt&zLBP+t!aWzv3vXIT!nRJMcZ>gnmx5i5eh z2|>>+`rfa`ddxz!C)%GoAo8AObcmZ!Us8p~+zRs7bThYHz;s>#}D! z+r!M!St3zq+RzE+quuBJJS9?J5AI4^s>oy}cYnss^cb=qwf;Wx5w81U{OsVI|MQZw zxZ_q)Z#Zb_0n>6X!5)Lh;pqOitEK%HV~C8O#x^KDbU@#1CaKJ%a299^H z7F{La2Wm3C;b%gwiQb2JkGnpaLL;k?ajf5FV){5AL1Gja$4pDx5Kp;-qQyZ^A3>HM z@Ul#Vem1yjAXJSTefHxKDkO2yQ$b^bXd|U9j^p4gm@hMe{ zgPA7E2xb=xE@?qcL;sWASJj%2CQ5!j)&4{}td(tpnyJno^biaP0d41>ywOVSHbd{s zR@Ef|iUns0gO5jbZ2DSC9d<6XK&PsNV0pK07yM| z`HsjVFdUDvX(^+NCjqVhy!th{4cINv;bs>w>rc16Z;|UYTt_8y{?a#Ap!J<^x%EqI)v?D zP)1O*$aPYlttzkW|HIgq$3yk@e^-jiR!Xv0l9Ex`cP&aama>G7eJy6}l6A$MHixGtwGnOnv*0Jx*bLrdn`@8S^xu4hbn}0dyT<5yZ^;zDZ_vd{P1ZHS; zr;yr6+aaVDP^khMb>r*K*V5Y=8@ljd$q%mSk_J<`0+9cN0*~SjzXfp3_=mMB}iwZ5;$d?hvoqtqV8rL?8tgM7zvE^ zK3dMURUwWfAA=UZhED14@vGdTTH z4W9?W$Tdz|^lck-8=sv!d9^zdquIofqYX3iLMS-qj`%li8fmcIaE9Y6cC-<-0~l=Ho0F3P1~ zo?%xVaPz%0%Af^1vL2?L_T=A*TPXg?wvfB&#gZj}9Ok&fz-T=O_`<3@?=O`r2=E!W zKa^I8)S@HKr4M8km(wE~rz{AuH3Kv5)dLPex*D1L3qtgQ251ACPmq&|L%vQIRqpd& zAFf{xO23EgruM+cE`bTU(7a2ZuoI74u9Rz&=HN)dQ!buE1br(Ho-~Ji>j4h+-0?NJ zjRkyQFiL$|V>w<4H*i_;azl#nbN8EM`JM+`gPe#%XlX;Mi68m973A4!Tv*8bK5T0A z+*{?0*#U!JwedkEUFVD|N5x}j92FSBq7_(aaOEKz)Cd%f4|Bs>Oq@99Ios_!J5xNN zQh2VX6CRhGbo+46jF1&VJ4{>q-U2(w=O&$ott`>rv-F-73nT7&)Lc{Hb9l<)eT?k4 zvs^ub>Ad{xM#kn*kmM5?$QW_58!kGglcf_*E>vE+1^DM3>g3a08Wu0cKUSitG0 zj{t`D=4U?F2`@K2tR->gCY$ah6Xu6Mrfu(l$Y!T=3FUlj{1MuOHR4?l3|4r!lLMox zQ`w4Ig-kX}heyppZ}$khf_#+Qd1ND}6CTh?L{$JT$&%yB%OG&{xvDAq*)IZ8BEFZm zXWZ@8`AYv)2?)&l6CTF6{c2@YdJLhn2=~H!0Mq-W+PG;i{7c_kkh1plJd(ypk2VbE^ zeo~>v;hKaXWgqd4maLjVa!T!tjU4xRy*k=q^4y)St{RiAo5X5Zxs5b;cno@kdT(Af zFM%wyg`~;mB|WNw2N&g_96!{14zJ;0fllP(Py2KElr(D3aftt(UWK_=;ik?C?iXhD zX$$QB2*v6G$yMGy0ID~~ZIF{MtshoUCKBTJYeQR+jxydi@D#ahN{UxJM_sS}4Tq5a z%3^)Bq*=ABz03SdC7BF#Uy$8dedodk&;y$Nj<$sJ`4qjalROXPa|QF6BI;|x95uKw zsYST>Iv_AC{hSCp{H#kwYx`5m_k|x~U1oW#YF`_!9^MJ3Xvk($l+Pec+!zn(f+WSi zu-GERDtH;ZBCd`!XAp*FEm%i_ompRy<{Ukt@~4y#Z1=gaa~BS-IcS8e2li>F9G;-# zf`U^~0~|XC&RY=pgK7DjE%LNi1v@mfC=z2Lbi&p`rzFd!cGjj=E{%LA=H9gK`Cy$pn(P# zRQ>9>%&VBQP^KJvu#kQevcVmP(tL#KA3@akRh$mjVr5Of#GulP!B0-StbdO835ty0 z&L#@IF500BpE4s%6-vj#9D-}!MY-P@!y1HRJ%)n~$a9+2M3{mg-T(q{I2-;9yH9$@ zZ72uukP8IsJ7KY4wL^XJAcR%Nte(PCuv6}c3-GPR;b6byJ|fNW*My;P-*Ch0%14i` zY%SwaxS1=rH_A^hC1_!~LHb>cK_B#`qvP<-My(GB5a;G+Y-Zk3XRDl!v&s2-3`Hr2-3wf zEqJhCQ|tUu{dX{{av!yztvcb~cu15XIp?kVb?|lI@Z;3#=xXJ?w6&*TdB#=U18HL+ z1FhA{ObKhzGd5G;%WNa(_$U)|OaL;EA)q&-)v&9w2xTG-C3#5L5<@89+4g0D>po2q zteGE`733zt33tv+m%%9(4ylWePSs1`|BAxKwUv{2N@_tL+kl=#(@7az zv2f2d%<7K_Y~u{~Ri_A4V@;4Ly%fHJgO6TfM=MubH=ABcu3*_c*A=ZkOen|eJ~#n< z{s$3C+WFs%3Bb!XD8A=0@!@KCplLARfWS1iPU4&(H-IR%&J9R2)aus4?!pL$0eW<7 z;KpPa>YP7HuWWu|22F%_G&A{k{nXS8qIEgaBMMenwAFb=566TPTUJbnpu*bVm)@V> zeIM=}sYqQuS=RT|Cn;C(+Lohj3ue`>4CTOjrJrrf6o_#^#<9a+;P77xhvs&tuv8%% z>ody1CR*54ZVZ9H8z3sRaJuP^2wZUnk&Tfp`6>W6y;+3}J-(CXV!=_;nJKPFX@Y_^ zZ}FQ!zlT~V?0vLs;MS?Q#w6KfD({OHI31W=)hXK0lbMRmx>N;Xo3i26{+~FIbAZB8 zJWqEUg#~av3UcGF1d-;bp(xsg4Ud(AyHNH0X6*`QTI*6*6)aQ*r&K$3EyYez15Fsz z$1(e|YGDU3F%4kt?xBxTruhz3!S3F=2qgg=RkbI0twTudDT{4^wuTA+mOKEq`g8oZ#+pg`rTMv2J3Rz#XMs}+=A>O^U@Jyo06I4H2 z&AGBZQAGUB`!c1q!x1^&!qHkX4n#_2cfM>6728^@kSrU?N!FsBY~-VFFkVz z?q6wcf|sbK@vN_<20XQ4aVPvaB@9e`nF#m}RS- zB+fOMqHlZiYMfz%azb!i0I=~|&lsp!JC3Jh5aCFkQ~fyyz(yE9YZS9V6!T#gBG)7g zJYPwnhvJ)~QD^~O(K`6R#3^e>uPqLyx8wNwnp3X_PYe4zE7igjb3T1E%+)TSm&6z! zjYudk%bgT3NRM5S+e&y0flOQw`0m9R#a$g_U$;DFdb`;Vdr`F#L#aK^35fOb9Wm1d3f|GZleQY(wy z>PVyRfb_84*QtRSXlfv8z;V4%TIB+dJ%f#PW!eT&Yu`86p9WgybWNF%+QS|2@J>g1 zsJwiCX6R@id{l|8z}$?O^ZbkR{?93n2PeME>xM5)D~1cVv~xP1NiiFr8M>9n_ujqW zxT_QM<#%=BDerHo4_%d!_27G-Z{w6h-wiK5`&|3=w5l*jmCj1^FRn%xCN3Cud-Zv`9lzr(#uH;~^yzfs>}f04sB zMR>f*RxG@0y3El~dDP->+R+ymrKTf>-uSjAKs{oQLG78WXgd&VZ@TjOr}I@;0jEQd zZ!54zr7m+BGZT)nj7$&6dYAgexS_4(C1qCHdMb{YM(y7ye-eSg>} zq36YQD20_%d=VofZX36seA$`Ye|cco;b;GYYznt06pTmpHv!@`D0CbN{+*?}zM($( zlcYCEc!(FTBEf?9^~?axwt1 zq#S*?R(aE*dWLuA1ZkkIA>dSoB9rw(2WGg1G>!Myd@{sS(q>W|_nCJh8zwaaR9{8! zkahWi>|+bcbS5)10y9+iMf?~xv~H=J`#xsXV)B|Tn)Li@6KtLSeCJrWHdIXuMa@!` zY6iM+&D-QING)?tB>1?L{`}yZp?hRReB~y~*VIdo<1~4Mc6Gq)qjm>Lo?Jhqq@)0K ztwZAl8ao#Eh|`TyDDnl5;_Y$ZoIK;UM_`!VEt>)j%s5mh004WHJOeUvfKr3du$ymd zqhG_=7RSxExr3G0PPvZwg2|n?oo|ck?k(%hfT!@f@4Zab9KLg4_f$_|3&vy2B@vZ+ zz^o3KH13Gc*nP^ocaf?zJ#qv$%FNm&J{AC61kLbIL31qESVueHTb;Pwt@iHl@!|eg z+WDWBEb3vI(hpjAK{;O@k8>|3eic7|c(tz6{p>q(SQhmdwnqd6N8lqC0s8E!!zJxC z1G*hB?WpnM@_=*=?Q^~$Q?uq~Vm%D-0739#$AmT>*J}@GE7u|lly9&@Q-ycnsLR5Z z(uVT%O8X42dkai9jw|Om9UzNGgNCNyY^MMD8r;Y{)Y!CVgs&~a2 zZL(JSA?MeJk6*c*RP*l;oek zi`)1aSSyEcE_MWMj1x3- z#y|f({U@LN){(pfiou!YRPyYz{P{ z`yCh}KOq}TEWYOGD29(?9=!2NyAcsL5BBX1T5}5NFdXv_V~4m@0+*6JD_AhAp4YF=()Yu?OZVL}eK`TcOwLt31#s5Kv|jVGxz4 zS0`l3d5o>k9J0#>TM?i|=vuf;1N`~J={_GzUSb=i8UdBfl?oZWpedGMVz)b$E_t0E z;wv)iHPge<{2msXi0h#?oDbdVf?~Ur8KEWzl;|I@U*h!N3qRcz0SBXgU4Iim!XGu!xwqLjNxbG#UWZV$}j_*Z86 z1ZBfWoCEQHKS)|Tw#YL{RN5XqB^H36>g$c@f)KBBi{a5KfNP^y8u+yxm#R0(z8oI? z^doK6e8nW5gCjkCW}Sw@IG~?|S(~A$0;YrsmFNk3TY}B{SfiR67qrAunh0H!LONQ7 zwkEp4$CFRN_P|oxFejir2Jw(`n)zp}Btl|r3H-6doJ|~S1?LuI4yvX609=VX88=Iui*xjn$5Tu~YM!}`dHp8o4X#uSAS z*_KiL$%yxQTR|#{dL)55#ZN>nv3$f+F!#7+7$aT>(A8Eub6%JN05$>wqP66STL(#EkT_EaN+-C^_w#m z^=<48#D%=P#bcde z>&wdh4%TQnNok;vSgjZh>WsUs9w3u$2H;z4hG&c%N{^SJj!&OI?61tHDk1AyZZTq*NicyJ;p&I#KfaxP`r2EeWoq#Nf!zU3 zR{@m(axYzG-Jsi`Z*)4Y)$a)koP1;VLw9A97AFt2ot3UNHqV-POvI@iPWa(#5k>|^ zv#TN%qPIiqpTJYLHlJd;6TmZtK+)YXy)!B5e-@z`_T8vO+9KAsH`{;>h$3%+r{_^p z(ohKvNKY?KB%d;%N^fY~4R1a`O8Y5nS$cZM`4I&sWHK%TwM^y63P}4|(x;9|-AMj& zk5DsAFNi@SVssCcT<#qgLcAyBR-%FR*tT+A!m_zZqr@=N^TZ$QOfN$4vX?CginTC~ zf2(wpssn8qJ^Y~@1Tdf6nKVtaOjUDqv@L=AD75sZTg$zg@ii7FB z2yCh{>)u}gfGuXhoMA0@D>Ta7k4Pj}=uHYDJjam3MrCEHh5++DyUerrXz>@fIbln2 zus0J}J$)X>duZ>QI{6fvgpmG&si=P7E^7V;7#5bl{M1Mm-$)21<9J!ZX_oecE|5be zZRR?zqfOCW^X6HFM9J)u%ad+Jd7!dt*cyOJ5L6Tra277v4~UUFpkNnU@Nb(RmUvQc zaslU&gSzyn0~VFeryR^cMSej7h2w~G=ZQIbr7SlyT#AU8If1V=g9GaWqwx+ugppE9 zTLO;qFC%)(ehu1+0_RGKV7oOjMJXnwTYG!v0C>cNSf%duzxMQTNLSU_lKrdMf_Jzc z5EnZU5eu}1CFB{3qE;BYPJu3y{%D9ecTh_Rdgbr$MufW#K}v(p&uv|ge>i;yTs?;2 ztVg_g$#Xz25Hvu*03DUc+gFpEauuOuNo)rft5*V0xB!(|fF4a4Zk7-#;mnEJiCI*C zEqsxhE7}R`nB50>n>kuSuNwO~W(E3z4A?4o&~TYYC>eCSd>*fDX9x^VRD2YJ%3qc7*J0;;dMy#alg{=4B&3Vky!>_tayS%~ z5VTLo5-NX>r=--v6;kMq{}l2$@w)OFqOmAK6;np^xbidmDc_9s|kS6NL7I6InzcSF{9dklg5;z_Rsq$3e; zh4OuSa^&$t%U&~%CK9|heYB@H^ftyL5jcn;aa9X~zzL>LS5MPb9BV*IjqyoZl|677 zi5zv^;m196VTg8_7*3_^cx5wu@>@NuOp4=&glk%Y>eILN##Bo*Mig`V%5`DFOf(~cypJ^I(9L3#f+ z@(W+Ax4b9y;Cyb%)F!b2s^G95!ZX9o=*7j`}Jk3_p{PXIo{Jp)T3dl@NFUAl(Kyyyk}v zK`);BD);bq_n(_~H)2IxQ#s<*%rG8hWvH_t2lIISA_*0_P!p=@a{ZYaZQikAXSTi_ z0Zk9}J?DUIgX6lw{-S}_lHim4CQOyOx>uz)u?y6ZT$DPH22md^$Wk=wcRl!Carb(WAZMcCCiTTwYc+7o>D`o)^Cb zP9_*5M+)SX$*jS;H3ylt3r7wj7>{vUV9{5QnIC2ew>tLDrj8qctu2^Pp67R`hy!qMi7%Z0bNh;RZP z5G*jO9#65UoDYn}5ktOx#hz_Mw$XIN^;O9h85h^8=fM>!l2Ylpp99bLPXWL5!A-|N z4WP8!@cy16Xgj*{YmiIKs5_?p)~Dpz6U~`keD8BP`pQ<`%b9&NN6VHCPKJgN72KgMZd<}=KH_}{>^)EDj_FR`qT8SI@kty{y}yM5uvy#v?k5!OB`8RO zmuy{51=NKEH$}0TFO|8Qjuufa(9#guZ8`;U&5-|Gkc8})c8zLezEVavEIJRMQ{yck zi!2LhDFD^I;R*;rT}TI$ywR4F-{aJbDOz~d4VTDLEd#cxU|etsi4+`)%9tOBtH!N6 z&}ML_s~D?6ACQWZjr2Rld~NncQ{|XCyJcgU%!GOT1KS!>K6kS4uq;12JHd>?Tvev_rp6j?P>s_yg(s{mo`~sQ%r(P^suW>zr*&0f(+AXdC2oBcvEG+T<+$C!hJ)%x=0J~ul=fziLm9Q^A}38; zjv%%DsV0+>nV_6ta|9`UX|er9it>muen7hgNp3eGvY*^bb}Ur&wekDfpu1Q(I`EiH zf{M8>#g--1?vwZrQcz9yG_xBrwhr#8Pn`nA`~VgqVBnf@i~>%k84toh6WAML5B#q8 zQ&ih|alsJe@Ew&8Mx1>PV_#t9dSsyqsTEpmBeD7R74!?TOo}EU3Mcw1-wtU5aAndY zahvwEv=lzQ*=kxaH70H7T;NAyv+p{Nyu4vSm{7x{Ry@3th0jGMd$YpZE_?QM!1hAO zhDhYL1tA@`Ce3~ZO_Vmb_w1_{m@?H&J$uxD^uZniSR;*{CL9t5#|E8ruX3FEZBYKN zUaWW9y;%79&>N2Dmm6y+dHfRyqI4W7yF{q!BdrB1%SquWTt2V`6*)5Yp z(3WIArNRu_3cwJefDSvG2G)ZvaPl}3SSx{)1`y}uP27vhV^dKaXOTcJCCQq5+#=Z2 zOAZ7rrwew3%}LfE3B+|#4ipGfI89hI9SG9E{stW_x9Ck%AhIc=MRxV^v>cwfnz>DuhRUP?^`cZW)gGW4eRlIz-MjvpPzTxXlPI$W1N z$mwVPTYK1S+$E0KTkT8xbzClKyZnY3gkLVyjdC9((Ko%7l+B^w@1^{7&TpxPao9Xc ze@MT=V!^q=J5%(- z;pBau;LzHgPr+1xAR!Jlt1V>-9jE=r!$ z8(50K8XRAmKIl-3xQ1TuE)u~OX{}!nfLQ!MEc&Jj$tz!|eTNrRVEV$>4G#+`hYVa> zk^tOGI-irzdmDEGp?^Ba_wF$$t)|893+zXx?03b7iX`iPMQ_*gU%b|HDJa^{ZG7W{ zuyEtPTSo-r>c76(F02SIy0fU3h`p_EZicBcIaXXEnEU>Rpe9y5ZA@s&F$>H-jy&-^ zLABTNU@C`xMj(OYrGY!1psn&8%EvvL#AYoiRv`HHO@0m~1rUJcCQFwvsjkOS7nt== zBbGAE6}lTM7K`NswJ&Loo`B&X(%d;XGxoI}K7k~OXQO7z;>IvzZe~zlToG!Z>fI^ztJpJPz}g1`b1M!kq1@2j+$6>1 zON$E+I^jKICCidS01WWXh-%Zn?@?ibALYWm20i0h$t4%LM^BAL@LP%gr zo&{8W1u#047!RpigR7=nK%2H++fMQ)bvmaWr!+PXwCR9@oKwA)==*(Isc-vt+f65O-#R1q$>}Tx91tia+LgrZu zoT*iYs!+#p0#v%?H$#0}f}scAleBjmB^(2#xbzwMlr8%uFuWp47c3287?pf0`b66w z@Q8A~L;yZ%_0@6wE)eYajR_O?(uJW=q$3!Q6~H5>=Ae}8kW#?X=KkHB^miY5ojE}h zkGk}HV96w?2GqJ_7H3ih9Azw^UFNi0QG9jg#Ro`WAK5KAE@z>+9(IIp`x=5XG%q;w z83#_G_UEA9H#V2kgHRXGw+AW@#CmvDVaM*dyKGyHO3=}$2EZD|o4322m-`o3CJ)vb zN1s;eKg&|MbuCb~$f(s$)cQ5=cc!}#iR1MX6t#Td%ab(JRJ7Ud^k!F<{fq$LEVm-5 zG9P+NVjCI$`VFvHm%}G+UVZ*M5&!${0Iq-r`X~8*@e(uef=mY@E359RAl@H$k>}`V zyZb;T;el|xs|<8T9;)`^!*~>|&;$Dj)&PKbOxxN6wV=&%qzA@b#jFBW&hjCsn(hJ+ zVBYxnlZVungio3^Y{?UcxXU8Ddysb+oZC5M)gb7XXFTp71|y`E?)=pT-3P z7LzSn<~^JQzGnNSJz@nEK5&Pt3JJRv-ts#CCAsl&hZ-Eq0S$U3sG)yhS5bf@Ht&Fb zD$sEx_b6I) z;^d&g8$uGuhOQZ=vGFhvc-qzn+eOq8mu5dDrl6pp#uEDF|wlE<1ne zsgXY*KdkPtKzGRjp9Z~NPgKKG1Vrr#AGTGkK+m`!4e4l64+^FCR$5zN{s9*{tcn0L zHNoTi5~xfTySj6d*uPZ5A39;j`BomHR&xkyEbCb_8l2Yd36pcKxUZ{?+)xexHg$@>i^Z&bH!|6DY9#sabmA`QF!utM{Gy>OGL3qFg&a_iZ7Jk^9ym z@pb#);a2$4Ya@k^39O}zve8qW&i(_ z(^5lNZJn}{-WcPLX8h=u0I!Sw+M_dh7DR8url47UFVbP^tCCWc z3HqBKdF<!_6Kuca|28$dHgJrt%k8Tx<`R1FhJ9NVM_S_GX6161Adz>m3D8 z9|FC*b~=!6-C|rpmLBNJ_=WMH+wfWj&B@)cqo!9Mc1-z5=p!Mf3WoL*4EJ#-t=}!^ zf1`woXqvoYdzqQmWWeOlmOgZP!iO-L3OyIJxqVH4+v-yx|HIjW-B* z(_*ZnsdxvdPmm%xp~0CjK+pZP;xt@F+ikmgtL-88&_uEEbZx@xyeQOVRTUM z{TK!x5#t3_n=#}X;~<2xDF=zY=Kl_C*|SiTQKntC!#-ApcsnSkg2AHd5{W-FMvFyg zb26>^(pb7MD|TEsKRuK_DJxY%E2v8mtLuDgEp4O##i@adi0PHB2YL@Fk1?xX%@N9z z;YF@Vhz;u7fo#Oe<_fNRou}vdxcU4w=k(rY%j}&HP5>?+vpu)zG>3F|JURr zRJ*PqLsFLQrS2U4ZOx6B7tZYjk^3g|ptu}lzTX<%-XC!P7_$3UWUboiDTAHo#?n7r zIy#YNm`PE5Ao=}Rgs%GKycR0I@$rutH}_TR>(*fn_7y`lXAX3;n$eXi*jm`+TwHp2(wf(KmoL{1 z=LIg%T|TV+3IQ7ytE>BbV4y`S_SK7kw$4C*H%*A$uflXK?T;@HYk>P-VvC;cYcu=D z47lwBa6Aw3#fJ?(5ujC+lhvf|<$vF9`vxDMA;Lkfm;Pw3)tR9=?a^n&M4z>@$w5aR zoNRG={MR^v#%)1TgxfwqemVg4E%DkD%xnJ&Xp#ys`y8P+&z=0=!aora-#_ChM1jUv zd+dSme?DK-`x4blK7~RBRtNtm!fgwpoUt%^M9x|H6W07*Zak0UkH?RF?hf<+~ehKzm;OErT-tsaw-M3k)_qbzD=PX%> z`J4=DHb0eer8C4Xe>DcYqnc*DYTh3uH*m&WoJgQ#_bCX@bN=I>X^6BObH%E=f0E30QhmGGaWo+lij!G~-A4+-PIs3`r4 z#o8E<-@IB_n@^tKkO2bAWUW&}K(OrUW1u>`?RdYOwD|u3Zjwk3i?*`uGZ1a)S;|7J ze?|rlw>U5nEJ_c(c~U!JcA7awLQ~K9qD`#S3+-pGGdK{bX)Eh5*teu|ELrN8BOBoM zRGIQE^L3;9a!{0o_BUxZKyLl_vi-T!e&~%M#enGwCVQk#vf(>|wb7xVg3-iD??IV^ z?~utT6q0w z*77HvZ7;S<>)u*lZg|oW|TJ1{~B-MmY<{NK;XDJC58ko~NZ$@!0QDMDFo zD-0T_{QEzfD-oT?#7xgbt^EwZG?i-pN&kME74V#cWalyJU`36?2et#zagVxRzWk`8 z6D>h&|M&X>sK0#f(ni22QN?SRV(KEo9ed+OEsP(g`YxCBZ>kNw4z|)0f>BRy#vZxz z=3fjX*!**5gk^1T;JB}_i%}DyYUEcKO;iOd>wER=(dO&>gR^Ee|G9)!B zU7AT+aWNW`7OSqcW%N2bV|<*WZ_r1bz2wJo0E2Sw_mQq>Rz5@T&7W1R%EH`*G7U~T zgT%QlReoH3%6Y%R1{iFp_vDt+BN{64Xb5At=YOYSkDS(9IEuJleY;EFobYx9xeyv$*U zy)~bu@@mxPqMVvA^| zw7H*Dn$~*B_DIdibG|WUTT|l#T^IPhX9k&FMsrP)lA&+^+^%%K&x=~-9!)!hy!|t( zGsHQe_3*|rv~SR8K9)fBwV0pd$00^sel9$!f(XhzYpcm#U`{`|j#|2E(%LH%zCAiq zW6YQrm!nF=(ql!Q*Uc^8JcYB{jmITclyBN3<|^ENrT7IqD?D5cA9A=bOMe4%VGlH2 zAxj&qs%kY+UK#sZ6>#pg7_NL}u!^GsPd!^vxS}q9u&aA6V4l{C;y3Xf5)RH-TdYt< zDHoB-jIhNQ8Aod1ov%)v(4xNSKJK;P8QZL#tR9OQec44h!-BR>y{Th2I=_0ol{pw? zh7JLf^$5j3Sy5VCytaC7QfjgP^I64FT5+klJFDVDN2koCBVfXG!GtAp=;;|7_|8`HjqaCidFH`}2_Y3uSvx^mkdTrfJ zf1I{pcb>q0D3(C-uw!=~P3njjKRR+e2E_2Dglx6aOKgx@1H6C33*FL>%a!1I?7|bN z)_2O)|44HHDjXJ3ZT1wslBw$QGU-~P>b3q%Kf89bq#81&UE%wUC-?M^3$r{f=0$EN z%UJ$!TUWV8&9mk>PyleSr(rgm(_*(B9K{sa_Xs-d!0dzM)Jm^@KsE;7nyh>u;9+=m zDz75cBj4(Q<(ba~ssWQT%#Ft{8R7Eybub)=3#TIFa*16EWb!wyvz9c?dYDDi{kt_Dj z_`V4nsp{uD;w|CfBdUvcGwSKz!l>NOzp(c&p1l(A$@hd5f{igNX(hjMN~iPK{0M3Z zrtc3{G=59qNvX*jpL=&Xb4mIF2f|d?l-rz4Y<^R7GuAdgv#`;|-vH#D-Ms{&-{gIYTH^SbJtWbm1DK_xGb0grQYfqJJbb3>YnN-2NAasuz zeBPN6J~e-XgKFeczb2EoLVJd+!#OLv^X)nPlNy3LO1`SolzvdY8hDl#xsE!fg_w#c zTI02rQ$??^twkkH?HIhoe~&08ES^Uhp!bT96^Qb*)$*W621 ze5axLKaUlVt9&(!cQty)``1Mo`SK<5U%hNyR6r(XGyxyqH;lx7p|eBgC24-CDWSeUS%$f1`z!Ckv0bi)GUPc9X$~g6~_6q&M$JJ&)t92baKvUl-xE z#|IT_x^X`LT9e9mqPIAj!0V9Y^Y_!`Oz&}?oBpZA{CjDcu#MW!eH4$J?SZ#f>$J!e zys8D3r&N}Tr^863cs$mzx3RHFSv06B0JnUxy^jNm=?)w;dbelZ9NpjUCt#kZIyq4J zQjr(`V(>#*huM$1`OZT<`maKBbH#7h57Pf$+a#U$oXbZ8{+iUFxZWhe2SW%Lq=(Bi zkAuF(9rskx#A@@TX>Ft6lM6x&$Xm95cTZ;V5mkJzhR2J$Th3$&#kY>m$I0E!h-)2{ zY-MC=V8RyZC@I<3=Lms>+NP`6YIhr|CCveRp1ab&4h_W#(nNn)>F9sn{&Jsz-B1Z@ zU*@=1Wk?iz7sh!zx$~12{<7tC)&cgq9p687aAQv1Z5-y7Y$w{yyf;HSO^t+OFlFVJ zuPL{xT`$$L@72Cpon~H-$cR7Ydd{nO1F*7|Luw2IeJIMEqYvxW&3%z z{{4BEMPIEg+6tL)vFWZcs0a<>N2fFv{d5B-<6W0DG>aJ|?}U^}^rW-s8^uT2=gYqn zN%w=evS|l(htrg*VMXh14avl__YS^@A+hyvUB)?^63)B_p##to&9js>?{wjGR(z8v zBFUvFHry={#dDUo&pLJ_RKr%KX8&!ciyc$hPsF^{VyNv+r!_}XQ;Z8O2nr5U%o@Qd zAKnc!`~77s{!C9PNlZTHa3?wO(gVxkm`xry{mCUVG=D*!lzwOVTCn!~cf8=#qNe8Z zF06r#;PJ+PoXKws=8sAV1di4t+v=N|LThr{yL5Dro8;N=kEFcjoTn%!{@7(}A;&0l{7A^+|x2Gi?a-G*~)!cZRW7Ipc8`D9BLw2E#(+LbYu<25oe z7l_%fOc*|B^}cI9*5>B)mqBO2$O>+|^?&@sG0hIXjAybhvTVE7YBJ@JlexY8+2rrM z(3`TV?0S1GDr>i~q35=Hh_`fcD>vwO3HV;y^9_Day-V+~2Mug!K?j_xx%c<3BJ5D> z%}})d**k2z%$^=i`fUDL-{e`}Yi^SB3uv}-YE-+q2Vv z=={>heg3t|A|~-%syv>XH0M4}-Cz1+Lv%sN<^pDQAxzO)SOFc>I4QRWj9%m^YV;L1 zi7fzqY=8yTfO9@l4ZDjbAjF((xNmc#tRrNMRhjl|vveV(wdiVl0&T2pu4LLd(NJ_g z@F7(*AvffC^KLL;;6vi;F2tHts3rBu zs%sCa%8pO~BMqV1_dnp&v~l)K~IQ98DZ;C9-n?nNk*^HDA1+-iSD zWslym5wSTe@(2~}`!%V2^+k6Z*XeSSdXkodl3}rea5RB~(h+-E*liSCkA?aMnugWFnCy#X|gr;%90Rn za!yKLcYq*0`DVGTJ)MxEC+pr_Y|fJemltg;Zuq#lgO<799^whsgVr9opGxZ^tS>8V zER>Unf-)hP@pD;b^c|To?I46qo`?FRVUQE~(J?`K2XV6zo z!Jsc-$2M0r`#+b{He&Q!_5iN{JTBNd+zEYNCO;m1O?hq|uLj%3)_K~iW8Va(#^Gx^ zShEjoFZ%~nf-r~uv_xxb zU!)V+ZJ{h1evc4zk>lP@;GV%IN_>QCy6;lKbCp?kbFUcbr}Mx3=U;{iB&?KRWMjc6 zgG*^eM_!KU()98cRfKn;rscq&cL@UT^0!y3gUeHPs@9%%G$}>lIE7v4k)es42Yz~ojZ{_ASYm4U@zitQ%Y0zJ~$S^yNfk@{n0sd*~-gc z!s#H5ecNWkjNo_k(80zB8XlW*o?Xo>Ng*lZsrnn=PcNZW@*gNAuzD9e>YL|L8faTo z!)Q6ljR96$^o}mh+Xa~iM&7u5)8QP3(f$mkhF;IE zf^YQi7pR7J>NYV&dt?cT5)pM+iIj38B+CQJ^R$Ad`#|Oq@sg+ z8pG!kmdhV=-96hBKjGzulLn04D#z#Ex~3Z$&%)62F5#h9&PIk%CR4adoUOImnJ}kX zYcv|BMWzR9meW?EvdW|PaAF9{6>9?wV-2%NkFTL9l?Qh!pMm5&l{;b*i*xC}UC9aM zY*e&xI!FRrh>?nIIR!z}i0|d@wilHCo@WxRv16`lk)v(ba0?r>Mils1Mw1a9NXB6! zPQk(}V(NfuB%Zf^`2g>c7WgU}7O+<2*vdE&^OvJvd$7kamgT>AdVL%Fy1{3<-HZ2# zfVp2I^}g??b#mn-14j@YJF+LQ*h;^TNsatqw9_Ss2}wF7G6Ez7#dZ$+S5zo%hL#j z-1r*j{0sNI?3Aoky?%`MeQ~r?dYW&AG=+`7xc}_?d~=78;3?>4&P!~}*4EI19>2J@ zFQ$902DdSzxV}0>#jP|bdYRw~{ZZ`=(3sIu?|@A#jG{_Hk=?D7h76hj{Y$d>-n4l9 zNgy^5>CR#z)gB2ZZ(ssfz9HIh_W^A+CqG^z3o$O=7)!}QLDO6Fl*pFY8UBkek}L>` zLj5G9$H!?aVw3+yZj1(QWn-gm^bQ#p){8m~c7Ape>8AxIau4!5Xd(C01ryffFj$EbNzmgmfNV{4uI|D-p) zixg4zA}Jb*kiA8VJ^M2DeVHNqP9-5B$;e)0U&k_ov1E@4A;ZkrBKwv-`|lY`RG-iL z^Zoq(_+DSv<*Li`Jm)#jIp@Cb*L~lw`<%kc`Yz@`2>MZmC>rv-DN`u3AWd!Jd9()% zD+6Pre>ZLV)ESH&=%RZEEvSiSoa5V!--0`ig(kJYv>!Jc zyN|?=IZRkNVN)XIx9mk?dsjkpwAVNo(3|${_K!~UKy=&)D^qH9R3Z}8sSBSuuBGt9 zORZ&Paj{SxGTY}QOACVoj`&C3eE1q}sIIDm{v$6lPo}=Mzw`V&B8ZXLVf&B|h zQE*m>#Thb=XE(oChY6kPY>!=1!k?cnkWVVP8F};l#K1{N19^kvSU9jQST<)va>RA# zal9L|lzN^Y%bVB7GjhrY8cQF8LZbcRp;@lbNN%K);2UNMJQgd2pg>@Z#UNdDe7Eg% z+)T#u*_Y3I_js%iq9c|nD%@k_w;bNcOKb)Up3?Pl_VD<$aOwDmop7av@~<*1q}WwE41#uK)*HOCZR+!f>y=Z6ehmkD^VLVw{E?8r;J5=m)_-A?;&X5q?b((Fjm93WfUAIzXfV(c#QHg^=~$tTIC{ z)J5b3xB1%7Ow+#JpQ9&uULs{@s}MRD!Q$*SX?+{sNhhP))h7#e-QBQYZdJ{6#J5)t z&`9~yUPVYtxsOl)O>QQgM->?9QTLfcyQu8~dy?1L&x#mg{VcE9^8z?=GWz0q10V= zYqR6HX1EEuTTR$ACtPMmwD*3FrXF}&K+vv@PxuEN80d&=5D3}6?DZNbzPX(-GTRC1 z*Swz-5Y3xK@=zx|?=>WnyFyqYrNRRqy2;jVlo8(Br6c3*QXaZFcc(I-;Se9eqNh`c z*DVz-B4X4Nx|BwhU`dvMecAfz7lLJy^#1Xca>jeHdRaEc3fKdfzhd?AS7EzD;9+KD zFtQ6O8J*SCZI)FJTouV0q-C+B)natcw1|ag$Y{c}z{!+)x`!rT`EYkqk}E5u@~jSEf0 zFZ-y)pQ;U+&wF7Hn^WtZJuU{joC`>JDG z$&I{G^x1p^juUkQakCp+4@~GbUvzq{TDz~oi|Uo`GWG{>qQ8@Axa@1m+@Tjm??4hI zBmQsSHUhZ{#V;@2^`N$zKv>kaZdp}R{$X8)NK?It^&8RqWDiV)Dv4=he=d14h!C5# zIeLx7lI7H9>&8V(jV6ouSTDJTxL@I*3WLXsDPnN5Kj7I7Dm$M~dbrhozms#J))|v? z2}}Lt0GQp?2>NI`+uPve8SixA%7M+4Wp5TKijvA9H*0=eaLNt{X9X}Jbt6oskqWUu z1hgHE? zd-DaRG?0_E(MyR^6y*#=8u_KM==W;2d)rMi4?SR)hB`gH3(MNGXcpWKy1$7&jm9ZiM*IOC;R~x!21p)rff@8I?6FnexBB1 zdZg#p$}Ig_eXMDvH2`P~D}nuIcER|Z64Wf*$-T6(28)fnXk<%rp)hIf{4pXG%))IM zJ*vQKl*aQfb!LRR?Yy3W*a^BBNS(ZzikVv#X$BrGL~G3EX^F(QkV)`dKOcspDmg5i z9hSrsa3?CGw7xvA+dNrG2DKCsk((582Vru9hA%-jOEJmN_hYx-InL)e;k!w_`fRPz z7y42o>vVl6De?h|3C3X_M)C4!_ zaBTNaz1Y6gAi&Pd*}~%QW$>z|F_3Win50ypJM~TGc7pWQqftrGrP7`>=G^Bmd%~J~ z$E23u)=7)Pp1F4rXQ1udvYcFN@mCR((=?h$Aj*{X;3NzHFw>|pRc8hwh+2+)k_7NE zYZ_2ymYK^*ZJ7BObyo(+CHM!#Wvx8M*(${Gf68tcu1+|0-OZd|L}V_xIFIjzoQeWK z#Ba{<0}Ycsytm$vNCCNIi-trpX&#-JYCS8$J9iuY{I|f9mV&k!dg$wO6Pfn2iJePJ zZ^(gt)1O4uur}Q$21{Sg1^Ji`tc0pQxBYh+i?0wYvlA*&v6DV`j^~OEIplAun{tMs z!|xse^NNKLtGmLmr=m6OH7t~~Ff@WIm-Af_S_(0OytipJCad~o|?j)ZDzUY96aZ(e~(Col!*x8d`fR)L76j+V# zpmYzNOR!^U(lTVbjeK@|rrzlcLO$p~ z_q$=lZ%V-KpFW@(3VU+uHA3&k2@)d?2fV!izd!%r1B?pU0z1mIB>3FSY@i%1UO>)(l^|lGr*e}H1G-FK=H(Gff&l!rtsRaW9PSS=zC#C_2Q&Z>Mf^Yd zIJ_URAVzVW^=?|bKDrHSel}g@{!|K^nkui2ScBa7m8-wZr#T=$QU3Q4_Ko8+#-y%^ z179XXaY11Tijxr^KLLqR`S*S)zf4~!l&pR1h9UzSY^C3 zBi6&>zRDQOiCF*oIHX7iiQ36L_cpAE*ckH{jez>LhdILfj;``09o#uCT$wHx6l(9R zHGYoTSQgFT0JhWKpMGF->;!i7Ngl|=hjP{Py!SLMuelj7Bw?>*Fjh+YA+gD>31RfD zxrX8F+v1vURqN5GTwia^24*F@N|(Lk=P}ECk@s3m8Pb4a;HrW>vwdiIMlz^3 z?@}iV5-$S{`=snN@&LnJ(|hKi1Y9oI%M$KJfejOH_-T1}sn)}c5oJ|Z>N}Fz^RHN02g~-lxn8(lWx_o zvfY{aYdV(zUJnk0El`k8t&R%Ugsame!mMTTxo9sT zd%i!80R+?jJF&o__@9g}S!T3VZTLGg*bHKMQutNls?`3hD+AjB8pzZwKndBM0beLn zrVVwIB8)zWna&e+C|k}i{J2fN+`J?Qe}aK;>h-hZ1QVMNtyS{Ndi4+zIU>K96MyR9 zV%-kbsL6HrGE?NSu}PfDFfYcPDJbJ-$xke^bhI)4F%e*vk?E5C)Clbca@lx)a7`FK zhSFW5Zo!+BFN<)IjE67b4Lg400;Gci{2gHeNIco}%Qlo3kiXft9PmG6^$mz;&$2#& zXohEmicAfE%u1cmA!KcSH>@?o^^xSQx!`w7Hs(!eIXc^tXkK)LnUnq1w(9OTh1VZD)V^2Ux>_WsH7%x{Fuyz~s>_90FfB zwkK+xxdq?Wk0HoC9)qa=+BGirVnc(NcTlM=-tWL0f3CU0Q^#lC3q)fo%zz@tz?^fF z!0(s9oSUTpHs5<;F|0aBh10RLZi@m;3iM?E0_}yyCR$}vAZNsXNtUDcv;~uma;Kly zEdNta;MZ-I-yi6w;$A;H+q6*msP@wOttP>(`S?DQSKrIN*Ur3FlQ7IGsT+0%Vk&3c zUQAsn_Z!W9`4b4_ZedL+w^VV2q_uxK|8stM0KT7JMlYI!;VAVlU3vAF@oA<{xKy6*p#^)8Z#9s$x9E9$d>d2~2u!89 zS=BEmS{Y5!T1P`$kzD;a2aU1s0{CkR4oYnFYW{C%QA~)F6A(=}Zm@|e7&Kp6EO?zR z?)j5ff<$}k1SB&wIGAkjTtetE2!4RGL|MzJl`TLDwP~uKG|$oMVW6`Vn+0%wGi;IG zou7i&4XWVx_nvNCr-y0t0B{z-GjAhZ;v&{A;AS$(Q`K=1Po1t@zbNqfXmPD#nOX*% zIHeQV0|_ZY?>O{0%?SP2on3py(Eirmq9I_~KQ$A;M~ZC9?{FI^4SmGk61!5MPAJhn_Lh6p1t60&s~my!TStqW%S-z(B{Cf$c|=POgPQ z0lLuyke@jS^sM*u$hhfFqf8C9&jfbSPm>=&2!{nP)<6Hl4|v`XfAgCe1V}jyj9x`l z|6oS;!sdZI<-VKo-)JbnHTpjdJ_7ziCe2uUIC)h%mK3ap^}$I-9!(Lm5vvRmEX?vF z?fa?Aw!KRL>~YFTp2rIfEjn9Hd5H6fiFH|Ti+nXplrEDbry(i$iSZt++J*!FNzR!L zdioh!n$;uvDZ^|gEnUj(5q_=G0PW)}-A@;h;A5PNklt`B4CA2);M^cdpq}lTcS>>l zz`F6zee_f|$h|wSM=YuL$M1H14_-Xe z4jH~E6LdG^MY%RrOltKn9s$>mM_^LBlSaBLjre3QrRLttX$FKW53DP&DSGmwlD16< zDK<2e<+*BsHk2IK8x?|W)h#R$MGq?3J3dCMo-z*=1D84WwZ^FL+r-?;Mi`!ta~S6bdI--Z*(g&|UTnyNGOXdX;fB#*k z@PCkC_3P-L*>2@8Q*Hz0WJGJYe6!HF?BhFvA?LKsn6n3x^uM|DM^J_T=l;+Kc2wO`c^X)f8_rv3oIX?*qkP;T5vvHdnQnB z>FQ-r8i=a7#7e{#NVX*VlnqBQw`oCh(qc1!X5UUDvV(+Nqc`f3JAn|jH~h>WsYWzGXG_>7*L_SuAqttCY9F6CXqD!iSrcFXoZ<}kJIW*#}}=d2VhSJ+`VW_xbY+TLC} z1vAbs1{1d$?nNzKx2hjg`RXFo&bD0Cur!msxR@uRZJ*uDT>!ADKW~4V5Mn2g(AP8k zZa)AB6TK3*4a6aoWvA>yB8A0K<;umM>a1|g%Tbv3YWQ|YWa(4#By+Z zr=yl-wq3iqJEKJJC6?3KV=tw{qeVglvd4Cs8tPK=&MvKY9I>R++#XDTFSO$jEt%^x zW;L0fb3D_3F;JwbsxTJOWgbj-zcaD0Av^2xn8^ES0km(6Q#RJuM8+NZfCmrjjNzKG z1?Hyyh`9O=scZ)~x?j5zJP3?)I^C76p>4t~Lb=3|CqkrGdcwT`qm-6};b&4BVsctB zx!qUIysT;EWXf@^Mh^S)*arTA5?}Yy7Sk+ghbbY;JOYnSDL5=m7 zas;(t5Td?p7?n2n73h`ku+9WGs}7|#uNm--y`JpcI0Z`^(OfaMQqnI+|`=f zBy~a@A*10qYr-CBj$rI|)d0X6{6%N)5A2_oLEo1%lcR79p8@r0uYgbjm*8^)oO`=K+0%uFcf0zqchrO0*a{9c;ec*``rZ6I?0 zoq_l<9zfk;)s|fDbRZ{l>jqWdW1c~ShS+ChWH?i1$gkle_IFI=$DdOq-Lke-$?33! zd>G3gx=}nZWs}31*s=}iM#Ik3JQx2=Fq^+nR*nvV_5w#9h->#6mtSozr91ewZijpp zq{t2Lu#6T1#A7DO*f^=ZsL{o8Ep0lJOYX^Cubc(do`NWVN)Y|(_Dr$+g^D;C0LuB? z?*vM+UmjUKPYO?wVno zFHQ(ey%MH*Qo^w9_P`ZxLX

Vdnd8Z3kVRxT+><)UqAXJ$&9aS;O@NNx0!%@z1E4 zWOWG_l!lzljQH8l0``zUow9W)ZKXnId+9i~G+x(ZFR3sr=6<#Cp;CgFd}p_Pk+(29 z2YsECDWmrms6^V;cFn0(fzL$f6f&4yWgc=2T5c0{ceK}O)xJB4ql_cawdOVh)or5V zE?9(X_n%aW8BC>hwE`%`0IzXZhSO20*XtG8=P)}Y%3Xo&a8k4lPL%$Zb@N~w=~D>< zjQ7GnZiJj!?l6r87pVP*#Art}+o}YJdM6=1{?VY>{4&>tM$bZ5QNlf? zZrT6xo}BntZ_nl7aXoJ{pQaq|^{Kg$GBSzHh5kyPGH)gqLMfmkyPfT3-{~B2WiZnp zUCHl489p)gSjP7)R~V5r#pif+Zw)lHP?i^Udw=hrv3X6Q4U8()%2ZQ3$7%I)n-$2v zXkvugw$4UR!sUDa2v9J&{^@SCo}ReN!`VfkigGIz(m%lH7oE8NOnG=2@A18IBk&lO z3u&K4;Ml|W9oVSYuMfD5Op(8a$2**8*MLKi1Gxl-Hdh7lu*v!vU5Ov)Bx}}b;KZ`6 zYTFqph)x5NqqmtbAG>9NJGt#Cfyfk9Jyay2HU{gM_wFolr zE#Ls6TA|=?C#Fvd(69B|sIy#}qD5K|Ita+(e~NbfilG5zX~v!)Cf?}q&{AA!>C+Ld zy^ydf9=mSreYM!RA?JH30wCGgKy)_;TM#lN4}%l08!wONOeRO`g_}m>3F~gV#N)ry zt7xuXEwxN!z<3-SD41i;&-C7lf~!Hz^0$W91IfrFyf^w&1j#7N^&8h$1_H_S*6Yf1 z)&~XzrzC;YG-dOaGt$FjB?t$x55$ZERR?5XT99V2ivig0TxX(Cqf%lkTcVBnhr+Sk zwy#ixMcnB7yVo*L(2z-PBXvZk`GUMvFf)h|JY1~2F~PAbgq9+H8xiX2>JnSNJ;|HV zj!7fydJ*cA)9=VAxtlwB4~<+;mlSuibsSO;tOjXi5DH46Kq8&r)_t3rx&9zCVaU?x z^Ag76Q}9xHM_K6C3=UnPw3sDHSDgl!qnB-c#a_fd13|q$)X%f@ipOhnrLx=;xmxi? zjGrI`Rpp~$JpQTOQQHgUx8lGGklODmy>O`U^V#%x{~%uw8HANNcmECTRTao@>l=bH zz#))(x<#KGLiaG-EK&|0$?q2~&|#_L#a^!)wp$r!h^FFVid%er3JJ9c?kary=x6Sw zFqXC+rWZ4ROhPzj@NL^u0l$$d*iGjXj&2uB_R@QAF6aD_-P@@Oq=HU5CEs5f2LlAq z?Yx63-eRSy960G~W>rS?6-cpGHoBTXutv2v$$MY>n0oI22uArP30t&vrR8jP0Y9A2V* z0hTzv?KgZGVT>apVjPu{ys1*tp1$(KXnzL$q zHHDI&Ij;KCY#00On@B?49$~~iseCkAcdHYrZvLysMt+j!UBbUlj`@d^tK+vL_R?l- zx^G8@_uCv@0hG;7NIr8j_-a?;D{Y<5od}(B%BQn7Dlt*wf+U1U>CTt5_<~5%-<&kD z4C18Wk{sSVs;EeQB1pQd@@_)7Y`G#|P#Gx;gQ*n-Ch1iJW@7!)c&V0Vs46C_GpVP9 zpXHLf=?VCNXo@OO>w2IM1cmo|7VyvV;u@m^<;5RLH2GPu5?pqwEC%Q}J`#z*Yczg9 z`geDT=k@pU_E)~SUE4|9f6TW~6MFJ5Q-G=p9=;U>KnP0MVm!vqAo*O(;` zp-T_I@0507U*HUW%>n}MzD16=Tghp2v|E!F{mG1Nj>sOY*{SH-Cr2Y|(|sy$!r!TU z1O<;5Y6VeTrvbl$%c`19L!s7N-?Wd;+((uFy+6-xe+W6i+TG9E%V37-L~%eSFg+DN zt)*4IP0H9=A%rI#$P`n|lrG%9HmL@b>S@i})&1e{-&EmG6@FbF;efkjMWM@3X2Mq_ z?y5l$ntucB*jCb={uQ*No7#LiRa;Al&UJ;*SgCM1zS&Ow??{9g4-ld&NN)6cQvW1s zI%@a_AqKezS$AQaAnFm5#o)w&u@5vtyKXD=e-NbqPx<>_dTh7uQ%RU%wo2r#dkr(y zq~U8}ij?Ze#W(!|?$^IJ9r)axx|G(Brc}ol=Z5$g}Osjl_`tURBWX31G<_M@;5Ef z{;K&eW9Eg6&T4SW@bLA;n%L(dnP-hpA`TyWv^(v8z8j7ZE-$q&w^6eFxOL8^ z5wOKHEvr2y36>!?w8R93zuR8UK@v@zX3KBFcz>~mLe2M{8G1d{;7lwxh*Di&Cildi zBU%i~ma=QeYHuaF-$}39{leuzvnLnT(WJRl;;#m~kyQYHg>dbs_VLw8V5hUlcQtCu9!5U#;MLn1T%?C>wz^tXtd zm2R`fLAMJK$FL428)O6H^NBHI0zbdf!Ri4%@1p5|{>fb}V`saM6q%6)V(3?0|yWi(>6)y#KkC0xH?}jllozW24`o#c2Xp zGW~M1`b@U8M<_5E^9B49^|o&&CM*i&zL$=zqqUK_UTzw&PPqfpCf^I|6aPE)NxNI4 zuI^QEd66Nll}_HECqAze95iUY;buFFx7SfHsJRyr5uqLtk&_u8CqM!zUXiuyNN}m`ARgvu=kc_} zy%{GE54p}XZSB2A5xvRB)@%RX3j1mq&zGEEaARec9y)JhH z;J}H55e07{SrMi1{!?b26tdu2xbUx4wrQp?cB6fxc%ddOrx{lRfux#WbRAUH+Y3R5xsyJ1epXe-Urj6XijMwev#?G5whH z$IcZN&*SJWl49quf|3=Yrw|U5wg*Sy0DhT~hwzkkH_flpNCLK; zkxx<2(kr^pr!HiTc0|Lh6YF8a?dVRME`Hkv7~q`Q#0^ghq}-mE8(3k(!-~-q6F|G* zoWAwOY_E}CZQbrQ$JkHvz@9(9d^;e8kG)r1*y=Wg-uG0N4!4~9YrTaV+qNkLeJVuY z?cLfLBj7$H#lGnPF!@ldGX|=eT@BNB+cgOQ81Ho*aYlf=fkSu4-u;9buJx@$Grd5v zbzvWhe}4-CX+8$DX2Jg7T%G{WS?oVIdzcb&xP7++zy=`$e=|e=PWIhd+CUF#sYSb} zWRLyC@XtA1a@pU|?t>A9@A@)VK>NBhuD=qdueIM>(Mq3x-vJIA^al<^@bHQ_oDwkR2!Y47c=tXs6f09 zuq*7zszYggiD)!B1C3U{i5E9mWJ+7iDkPSuQugLKR*tDYrn_{j>fi^tks`jRMqSaA zx?5|F&wK!VG<&_Xt8LA}fkziS$7Vj8#os3iACE)*GHNX~uu9cW=7Y~wzhHkL!V`I; zb8=X$#oc?l9dYI&>OYn~mb(8460>oFxH_-lZ8Ww(tSH>pq!2Xl&tX@;Lo%Rl&m3lIpfRW-T>=_$3a-VxX{a=KtXYGry169~wCMo?UZ z%hLDt+Q4D947VaHG)Ie*Yx|C(avWYkfTp0xUeRt(Xn5CbSDW}D9^(sByYJG4L`P)A zHUBb6`FTSf9mu(5@2u0{Aq>NCW=bZj_4IVkvVSwu<(*ba5RYIaT30kr;Fak>3s|xx zlGGb6{St*JK$cykrs4i^bVjyY_=%^n>x@T)M^*uJwe9_g-*4{#+~Pnpb#({=U13(r znR~RjSkT!Qt-%1+VrQv<3)n%5%h$eDagX1>*DM&+whUAE4a)FCp(t~Hm^jCR#N3h2 z4VXr}xJRtKAohy7!_vW38y61R;Io~lSgU%8pUS(zVDn-a`3kKO{oo6=8JqlkLh(us zJc(T@2gGyCHv`k!8mcP04xC^u2)RNJL30>JK(b1wYCCFcX&8o||28sklYZ4WzthgX zbYmTujHZVO?TW^eOhO0`(;yKV@SL~+WeawqSm(LuXV^41R?7l^t-^CO;sPMj_jc7@ z0BFN`@!@P4BDNM8ja_S@LB6cp)BI-hx9`tjal_~nP1uid#=n~?+S;ZS;t<#S% zJkfPM!#NfSW+Cs{T%Z;+uX1Sc(|HqLWs!h2px=ePqzLj-kyxzVMB65w!4!?E$V2lF zujIg@4nZ467A%~s*5>bhf@O8ucopX8WFW4^>y~>N<={(-N=o#RjAEsw9KroqRK85P z8f5L*vd1tdpcme^?zr(`NRxg&@zvtBVP?H#3dbN8zNvNe>U_hEZxWiac!uv4kI6-! zpk&H1%Ok;5zpSMOJn#ASwVFF0evvYHXz|MGK-AZ6?XL&bi&zTcn&5)+xKQ=)ci;F$ z;DC*e{wp=*b_kr#L|mUJ|BL225VVN<1at1R4+NP1Uj*r?513e1aIY#&!2?;Th+qE5 zJ^k@jVtKpE0T+NVq)YMvoxBg)qVW?Qr8gvH#&=P{TvHm})$4Y~?cS=0}8s zAB;GZ^eh!?;8mJ<)-e}IBr4EqfcSpiwH}SXB(-hsvpsd7lNO+ zd)6y?SebAENAP)B%S63?3$#jOY-o5GID4f66!As;2fN&TeP9S>g(JXsxaDIDJGAwW^7z8p zy`-q$23{k&`m#=L?C-a(Pt9L~Je6&Mrf*VP5Pi61&+!rVxa2U=BP4LV$YaD)ZepSv z&l-3?!(`u-GXxM3^+UgLt0(osh=_k&^Qe%Ousmb5^G!GFGJvj1XT50N50sN-%nK+jE6A6K| zWB0wPCa=rJU&-B&?2WJ{>nxX{9F)J^8&=-OUs2sHFSO9`lzkmfqHpj(XQf%Bt>b%l zb9F3vYxD8eueXum8|FrqdCKFwu%J9&i>=E{4I$q3V4)?;l4!EU)=;c#QMDlD)2aI2 zh-uH@d(ON->n&g3x4$$nz~iozy4$U-$ZKqg$RZp6TJ}5B?^nJwMV$MxTUF;$2nY$r z``TH>rl`ql+-WT6W?PPDK9mQR$=2?LN+Dhcf2*$;WQvRMWlO8=ymvdeLL!Uvyxdst zh>HV<{S<0}QQinWm)O)amw3B6p92{`^K`MI+TF6#|NCvdwIw1ASrv6@L}_PZ?z<;E z>Paku*CJ_O_H@EqmB0=^oJ(5`!^(T0tphWO1`&?e3hnPoDVHn@#eL>r8x)FZhWSl? z1NUBik8g3VTJ1AOEV*Y}RNh&?s?HNLQmDY=zP zp{BlBDTF*p4T}@G2X>6DF=lZQKU(-Nk9uli03sc%DCm^4W?-X?_Bh- zS-pHW_4OIcOTmV6R_ITnI0R)AWl}khFzy3#&r5wpBAtbaS&#bqPWK(>FZ~l%Py7|(vNGym3i?^T$_}en#i7HL}=#Q z)9Zrs#BygY@PXRhA0(rzF$Q;qt(-h&ye7jG`#4T_l$gH~cokf~Y*9gy=C{BtCUSBH z#?&m#9r)Oj9&)${?dKUYmpJE?Q3%bluO7tv{7AC!_LFOgcJi2{h%p0Z#ik(jGjHbY z`P*qTGV5DBM1lU}(pXsYB(Aw&5ynr^jl~wq>a!cq@Y@eNfc!oGa&X62k| zbF*@GcaJuUHI}hHv6+tjBuwF|>=b9=R&Xx%Stkra8FF;8%|@%|71M}8M!%TVj8~@e zT$zFZNAIhsQU;-V`{iO()>5u+8PI8rUF<26rM}^Fbf8)OTPv-Ya}~F0F;ZVox@+)6 zpeN?s+t-grm)k!SIPrKs6E={V*-+4y4I=67G!5)AB>wOup+sr5cP@0ECM?=Q&1|bw2Fm4+?wH<i3f>gBwgpU>UyNr)wW!IQO7jVfQXj5Bm5Nz z9WsfobxNiKZQeOOCCVGCDIEW9Zlyz@Vzl20KJT+UZ*F6cdT^y9us9u57_G?_zM}p0 zD<<269>TU{a-p*wr&FITEqv^hEgud+=|9UV{6xo1Md4?*$vrBnANCVP#RTCy$(T4aX{q;_AsWXAFGOKsi|X%eN$lvn|E z`MP;5xmo%QGRd0HiL*Hw!*2^Kl}jdCzUC62Yym-@0&{|+?nPh75=54nP4!T&@$`Gr zhahCzUq^L&y99Hrq47Qwl05M(vsL8vCy5~ksE+=m> z&58KG&R2${Wy8E&4dv`(F)UE5pXr9j442N8AGMq#r^!ATzAI5G7wPGS7Iww5ENlI> z86b!N-)ok@3YtJ@yDcN{7CY8dw3^YM)N#RB7Y0YgHFN>Qd`E{jY>C}&U+kv)6`^T; zFJhnPr@4p7hV|Kv#gR@J34pVzvMptRy%ydrZi;SyLdd()mL^)C_~#wpHmmJLv3kAC zlO0HEB#%T4HHj^~oOpJjLT^dmoOzhd(9?PpS#OJ%&1&B{WRjiJID?{^Oob?X_^Uxi zVUyR%$RmV7aBc-Fl2f$9%`7XZivdFh^?3eDIr^PjX&GhjS-~=O4ezXBK5K z(U@)nY<$}EesE=z7K>$@q4wx3&Py7L@fk7xws9RbTc*H(yrdudq}w24}DRb4}acSbBtkmq7JW$Z^blqLelA)`Ng58q>p{^IRvr<3ZcF~u4 zIH9N`WCDJrcrjvS2;Wfj)f9QezS?~x%unb@i*UB14?cLj(E<xGF@Pn~l2DdrL_{tk#i&yYvB2wo|9N<< ilHHDo=#0Yn7TIC7F~Msel*kD8NZpZ_%)X`n Date: Sat, 15 Aug 2026 13:03:58 +0200 Subject: [PATCH 108/152] Updated: todos --- docs/development/bugs-and-todos.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 4658b213e..4fe889109 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -4,9 +4,13 @@ * Interface scale * Tree navigation using keys +* Reconstruction method and generator subdirectories +* Transposed topology of Reconstruction browser view into sample breakdown * Waveform LOD for zooming +* Alt for scrolling graphs * Drag and drop * Multiple Reconstruction views +* In-project sample selection in Reconstruction view * Playing a fragment by clicking on a waveform * Note pitch shown as a transpose offset rather than a note name @@ -17,7 +21,8 @@ ### Workflow * Waveform construction preview for single-file conversion -* Selection and trimming for a reconstruction (reconstruction editing) +* Selection operations on a reconstruction +* Reconstruction trimming ### Features @@ -27,12 +32,11 @@ ### Technical * API documentation -* Code documentation (docstrings) +* Code documentation * Backward compatibility: library/reconstruction upgrade scheme * Respecting FamiTracker limitations -* Carrying the project comment into a Bitphase document, once the format holds it * Per-tab undo routing -* Delete duplicated HistoryAction enumeration +* In-application console ## Bugs From 3993093d832db8158a54f54b6c8341c696f8be0d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 15 Aug 2026 14:48:13 +0200 Subject: [PATCH 109/152] Added: reconstructions subdirectory view --- docs/development/bugs-and-todos.md | 1 - .../logic/reconstruction/browser_manager.py | 101 +++++++++++++++++- .../ui/panels/reconstruction/browser.py | 21 ++++ .../ui/panels/sequencer/browser.py | 25 ++++- src/sampletones_core/configs/display.py | 6 ++ src/sampletones_shared/constants/symbols.py | 1 + .../reconstruction/test_browser_manager.py | 89 +++++++++++++-- 7 files changed, 231 insertions(+), 13 deletions(-) diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 4fe889109..398ba1476 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -4,7 +4,6 @@ * Interface scale * Tree navigation using keys -* Reconstruction method and generator subdirectories * Transposed topology of Reconstruction browser view into sample breakdown * Waveform LOD for zooming * Alt for scrolling graphs diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py index b58812753..ca0a9a38b 100644 --- a/src/sampletones_application/logic/reconstruction/browser_manager.py +++ b/src/sampletones_application/logic/reconstruction/browser_manager.py @@ -3,7 +3,14 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager -from sampletones_core.configs.display import DISPLAY_SEPARATOR, short_hash +from sampletones_core.configs.display import ( + DISPLAY_SEPARATOR, + GAMMA_PREFIX, + disambiguated_display_name, + format_nes_frequency, + format_sample_rate, + format_spectrum_method, +) from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( @@ -43,7 +50,7 @@ def refresh_tree(self) -> None: for path in sorted(self.reconstructions_directory.iterdir()): self._build_tree(path, parent=container_root) - self._assign_directory_display_names(container_root) + self._organize_top_level_config_directories(container_root) self.tree.set_root(container_root) def _build_tree( @@ -81,6 +88,94 @@ def _build_tree( return directory_node + def _organize_top_level_config_directories( + self, + container_root: TreeNode, + ) -> None: + """Groups top-level config directories under frequencies/method nodes, leaving other folders flat. + + A config directory moves under ``frequencies`` ▶ ``method`` artificial group nodes and is + renamed to its generator abbreviation, while any other top-level folder keeps the existing + flat friendly naming for the config directories nested inside it. + """ + for child in list(container_root.children): + if not isinstance(child, FileSystemNode) or child.node_type != NodeType.DIRECTORY: + continue + + fields = ConfigDirectoryFields.from_directory_name(child.filepath.name) + if fields is None: + self._assign_directory_display_names(child) + continue + + self._attach_config_directory_under_groups( + child, + fields, + container_root, + ) + + self._disambiguate_generator_siblings(container_root) + + def _attach_config_directory_under_groups( + self, + directory_node: FileSystemNode, + fields: ConfigDirectoryFields, + container_root: TreeNode, + ) -> None: + frequencies_name = DISPLAY_SEPARATOR.join( + [ + format_sample_rate(fields.sr), + format_nes_frequency(fields.nf), + ] + ) + method_name = DISPLAY_SEPARATOR.join( + [ + format_spectrum_method(fields.sm), + f"{GAMMA_PREFIX}{fields.tg}", + ] + ) + frequencies_node = self._find_or_create_group_node( + frequencies_name, + container_root, + ) + method_node = self._find_or_create_group_node( + method_name, + frequencies_node, + ) + + directory_node.name = fields.gn + directory_node.parent = method_node + + def _find_or_create_group_node( + self, + name: str, + parent: TreeNode, + ) -> TreeNode: + for child in parent.children: + if isinstance(child, TreeNode) and child.node_type == NodeType.GROUP and child.name == name: + return child + + return TreeNode(name, node_type=NodeType.GROUP, parent=parent) + + def _disambiguate_generator_siblings(self, node: TreeNode) -> None: + """Appends a short config hash to generator leaves that share a name under one method group.""" + if node.node_type == NodeType.GROUP: + by_name: Dict[str, List[FileSystemNode]] = {} + for child in node.children: + if isinstance(child, FileSystemNode) and child.node_type == NodeType.DIRECTORY: + by_name.setdefault(child.name, []).append(child) + + for name, members in by_name.items(): + if len(members) <= 1: + continue + + for directory_node in members: + fields = ConfigDirectoryFields.from_directory_name(directory_node.filepath.name) + if fields is not None: + directory_node.name = disambiguated_display_name(name, fields.ch) + + for child in node.children: + self._disambiguate_generator_siblings(child) + def _assign_directory_display_names(self, node: TreeNode) -> None: """Renames config-directory nodes to friendly labels, disambiguating colliding siblings. @@ -111,7 +206,7 @@ def _rename_config_directory_children(self, node: TreeNode) -> None: continue for directory_node, fields in members: - directory_node.name = f"{display_name}{DISPLAY_SEPARATOR}#{short_hash(fields.ch)}" + directory_node.name = disambiguated_display_name(display_name, fields.ch) def get_all_reconstruction_files(self) -> List[Path]: file_nodes = [ diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index 3873d72f5..e82feecbb 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -8,6 +8,7 @@ SchedulingBehavior, ) from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_SECONDARY_BUTTON, ) from sampletones_application.tags.reconstructions import ( @@ -112,6 +113,10 @@ def create_panel(self, parent: str) -> None: def _setup_handlers(self) -> None: self._node_handlers = { + NodeType.GROUP: NodeHandler( + tag=self._get_node_handler_tag(NodeType.GROUP), + node_type=NodeType.GROUP, + ), NodeType.DIRECTORY: NodeHandler( tag=self._get_node_handler_tag(NodeType.DIRECTORY), node_type=NodeType.DIRECTORY, @@ -187,6 +192,16 @@ def _build_tree_node( if node.node_type == NodeType.ROOT: return + if node.node_type == NodeType.GROUP: + self._append_spec( + node=node, + node_tag=node_tag, + parent=state.parent, + should_expand=self._should_expand_node(node), + ) + state.parent = node_tag + return + if not isinstance(node, FileSystemNode): return @@ -212,6 +227,12 @@ def _build_tree_node( state.parent = node_tag + def _resolve_other_theme_tag(self, node: TreeNode) -> str: + if node.node_type == NodeType.GROUP: + return TAG_GLOBAL_THEME_DEFAULT + + return super()._resolve_other_theme_tag(node) + def set_tree_enabled(self, enabled: bool) -> None: dpg_configure_item( TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE, diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index d6fe796a6..74e8eaf27 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -6,7 +6,10 @@ from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) -from sampletones_application.tags.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DEFAULT, + TAG_GLOBAL_THEME_SECONDARY_BUTTON, +) from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, @@ -101,6 +104,10 @@ def create_panel(self, parent: str) -> None: def _setup_handlers(self) -> None: self._node_handlers = { + NodeType.GROUP: NodeHandler( + tag=self._get_node_handler_tag(NodeType.GROUP), + node_type=NodeType.GROUP, + ), NodeType.DIRECTORY: NodeHandler( tag=self._get_node_handler_tag(NodeType.DIRECTORY), node_type=NodeType.DIRECTORY, @@ -176,6 +183,16 @@ def _build_tree_node( if node.node_type == NodeType.ROOT: return + if node.node_type == NodeType.GROUP: + self._append_spec( + node=node, + node_tag=node_tag, + parent=state.parent, + should_expand=self._should_expand_node(node), + ) + state.parent = node_tag + return + if not isinstance(node, FileSystemNode): return @@ -201,6 +218,12 @@ def _build_tree_node( state.parent = node_tag + def _resolve_other_theme_tag(self, node: TreeNode) -> str: + if node.node_type == NodeType.GROUP: + return TAG_GLOBAL_THEME_DEFAULT + + return super()._resolve_other_theme_tag(node) + def set_tree_enabled(self, enabled: bool) -> None: dpg_configure_item(TAG_SEQUENCER_BROWSER_GROUP_TREE, enabled=enabled) dpg_configure_item( diff --git a/src/sampletones_core/configs/display.py b/src/sampletones_core/configs/display.py index 6d3d00ed9..e5b5ab78e 100644 --- a/src/sampletones_core/configs/display.py +++ b/src/sampletones_core/configs/display.py @@ -1,6 +1,7 @@ from typing import Dict, Final from sampletones_core.constants.enums import SpectrumMethod +from sampletones_shared.constants.symbols import HASH DISPLAY_SEPARATOR: Final[str] = "·" GAMMA_PREFIX: Final[str] = "γ" @@ -39,3 +40,8 @@ def format_spectrum_method(method: SpectrumMethod) -> str: def short_hash(config_hash: str) -> str: return config_hash[:DISPLAY_HASH_LENGTH] + + +def disambiguated_display_name(name: str, config_hash: str) -> str: + """Appends the short config hash, marked with ``#``, so colliding names stay distinct.""" + return f"{name}{DISPLAY_SEPARATOR}{HASH}{short_hash(config_hash)}" diff --git a/src/sampletones_shared/constants/symbols.py b/src/sampletones_shared/constants/symbols.py index 38f9f1e6a..05f61f5f9 100644 --- a/src/sampletones_shared/constants/symbols.py +++ b/src/sampletones_shared/constants/symbols.py @@ -1,6 +1,7 @@ from typing import Final, Tuple HEXADECIMAL: Final[str] = "0123456789ABCDEF" +HASH: Final[str] = "#" DOT: Final[str] = "." UNDERSCORE: Final[str] = "_" MIXED: Final[str] = "?" diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py index bfb3b8201..54d30427f 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py @@ -7,7 +7,7 @@ import pytest from sampletones_application.logic.reconstruction.browser_manager import BrowserManager -from sampletones_core.structures.tree import FileSystemNode, NodeType +from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode HASH_A = "6edf7c948606917a78b45d153c7ca7e0" HASH_B = "a1b2c3d4e5f60718293a4b5c6d7e8f90" @@ -16,13 +16,25 @@ def directory_nodes(browser_manager: BrowserManager) -> Dict[str, FileSystemNode]: root = browser_manager.tree.get_root() assert root is not None + return directory_children(root) + + +def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: return { child.name: child - for child in root.children + for child in node.children if isinstance(child, FileSystemNode) and child.node_type == NodeType.DIRECTORY } +def group_children(node: TreeNode) -> Dict[str, TreeNode]: + return { + child.name: child + for child in node.children + if isinstance(child, TreeNode) and child.node_type == NodeType.GROUP + } + + @pytest.fixture def config_manager(tmp_path: Path) -> MagicMock: mock = MagicMock() @@ -117,7 +129,7 @@ def test_empty_subdirectory_is_not_returned( class TestBrowserManagerFriendlyNames: - def test_config_directory_gets_friendly_name( + def test_config_directory_groups_by_frequency_method_generators( self, browser_manager: BrowserManager, tmp_path: Path, @@ -128,7 +140,16 @@ def test_config_directory_gets_friendly_name( browser_manager.refresh_tree() - assert "44.1 kHz·30 Hz·FFT·γ0·PpT" in directory_nodes(browser_manager) + root = browser_manager.tree.get_root() + assert root is not None + + frequencies = group_children(root) + assert set(frequencies) == {"44.1 kHz·30 Hz"} + + methods = group_children(frequencies["44.1 kHz·30 Hz"]) + assert set(methods) == {"FFT·γ0"} + + assert set(directory_children(methods["FFT·γ0"])) == {"PpT"} def test_colliding_config_directories_get_hash_suffix( self, @@ -142,12 +163,64 @@ def test_colliding_config_directories_get_hash_suffix( browser_manager.refresh_tree() - names = set(directory_nodes(browser_manager)) - assert names == { - f"44.1 kHz·30 Hz·FFT·γ0·PpT·#{HASH_A[:7]}", - f"44.1 kHz·30 Hz·FFT·γ0·PpT·#{HASH_B[:7]}", + root = browser_manager.tree.get_root() + assert root is not None + methods = group_children(group_children(root)["44.1 kHz·30 Hz"]) + assert set(directory_children(methods["FFT·γ0"])) == { + f"PpT·#{HASH_A[:7]}", + f"PpT·#{HASH_B[:7]}", } + def test_distinct_frequencies_form_separate_groups( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + for sample_rate, nes_frequency in ((44100, 30), (48000, 60)): + config_dir = tmp_path / f"sr_{sample_rate}_nf_{nes_frequency}_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + root = browser_manager.tree.get_root() + assert root is not None + assert set(group_children(root)) == {"44.1 kHz·30 Hz", "48 kHz·60 Hz"} + + def test_distinct_methods_form_separate_groups( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + for spectrum_method in ("fft", "cqt"): + config_dir = tmp_path / f"sr_44100_nf_30_sm_{spectrum_method}_tg_0_gn_PTN_ch_{HASH_A}" + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + root = browser_manager.tree.get_root() + assert root is not None + methods = group_children(group_children(root)["44.1 kHz·30 Hz"]) + assert set(methods) == {"FFT·γ0", "CQT·γ0"} + + def test_distinct_generators_share_method_group_without_hash( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + for generators in ("PTN", "TN"): + config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_{generators}_ch_{HASH_A}" + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + root = browser_manager.tree.get_root() + assert root is not None + methods = group_children(group_children(root)["44.1 kHz·30 Hz"]) + assert set(directory_children(methods["FFT·γ0"])) == {"PTN", "TN"} + def test_non_config_directory_keeps_raw_name( self, browser_manager: BrowserManager, From e4f433c67204bb4d7e85ff968cf0eaa7f0e4c7cc Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 15 Aug 2026 15:27:55 +0200 Subject: [PATCH 110/152] Refactored: browser view --- .../ui/panels/reconstruction/browser.py | 288 ++-------------- .../ui/panels/sequencer/browser.py | 276 ++-------------- .../ui/panels/shared/browser.py | 310 ++++++++++++++++++ .../sequencer/test_browser_context_menu.py | 6 +- 4 files changed, 360 insertions(+), 520 deletions(-) create mode 100644 src/sampletones_application/ui/panels/shared/browser.py diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index e82feecbb..2f30f2d74 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Callable, Dict, Optional, Tuple +from typing import Callable, Optional import dearpygui.dearpygui as dpg @@ -7,10 +7,6 @@ from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) -from sampletones_application.tags.general import ( - TAG_GLOBAL_THEME_DEFAULT, - TAG_GLOBAL_THEME_SECONDARY_BUTTON, -) from sampletones_application.tags.reconstructions import ( TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, @@ -19,32 +15,24 @@ TAG_RECONSTRUCTIONS_BROWSER_TREE, TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, ) -from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.context_menu import context_menu -from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors -from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol -from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.elements.tree.tree import GUITreePanel -from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_configure_item -from sampletones_application.utils.parallelization.thread import concurrent -from sampletones_core.structures.tree import ( - FileSystemNode, - NodeType, - Tree, - TreeNode, - TreeTraversal, - traverse, +from sampletones_application.ui.panels.shared.browser import ( + GUIReconstructionBrowserPanel, ) +from sampletones_core.structures.tree import FileSystemNode, Tree from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import PathCallback, VoidCallback -class GUIBrowserPanel(GUITreePanel): - _MONOSPACE_CONFIG_NODES: bool = True +class GUIBrowserPanel(GUIReconstructionBrowserPanel): + _panel_tag = TAG_RECONSTRUCTIONS_BROWSER_PANEL + _tree_tag = TAG_RECONSTRUCTIONS_BROWSER_TREE + _button_refresh_tag = TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS + _group_controls_tag = TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS + _group_tree_tag = TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE + _window_tree_tag = TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE def __init__( self, @@ -59,189 +47,29 @@ def __init__( initial_collapsed: bool = False, ) -> None: self._language_manager = language_manager - self.on_refresh_tree: Optional[VoidCallback] = None - self.on_reconstruct_file: Optional[VoidCallback] = None - self.on_reconstruct_directory: Optional[VoidCallback] = None - self.on_load_reconstruction: Optional[PathCallback] = None - self.on_reconstruction_remove_requested: Optional[PathCallback] = None - self.on_directory_remove_requested: Optional[PathCallback] = None - - self._is_operation_active = is_operation_active - - self._lbl_reconstructions = language_manager["reconstructions.browser.label.reconstructions_tree"] - - self._node_handlers: Dict[NodeType, NodeHandler] - super().__init__( tree=tree, - tag=TAG_RECONSTRUCTIONS_BROWSER_PANEL, - tree_tag=TAG_RECONSTRUCTIONS_BROWSER_TREE, tree_logic=tree_logic, scheduling=scheduling, - search_label=language_manager["global.browser.label.search"], language_manager=language_manager, status_bar=status_bar, colors=colors, - ) - - self._enable_horizontal_collapse( + reconstructions_label=language_manager["reconstructions.browser.label.reconstructions_tree"], + refresh_button_label=language_manager["reconstructions.browser.label.refresh_button"], + refresh_status_message=language_manager["reconstructions.browser.message.status_refresh"], initial_collapsed=initial_collapsed, - side=CollapseAxis.HORIZONTAL_LEFT, ) - def create_panel(self, parent: str) -> None: - self._setup_handlers() - with ( - dpg.child_window( - tag=self.tag, - width=self.width, - height=self.height, - parent=parent, - border=False, - ), - self._collapsible_section( - self._lbl_reconstructions, - glyph=self._glyphs.headers.reconstruction, - ), - ): - self._create_buttons() - dpg.add_separator() - self._create_tree_window() - - self._create_detail_tooltip(TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE) - self.rebuild_tree() - - def _setup_handlers(self) -> None: - self._node_handlers = { - NodeType.GROUP: NodeHandler( - tag=self._get_node_handler_tag(NodeType.GROUP), - node_type=NodeType.GROUP, - ), - NodeType.DIRECTORY: NodeHandler( - tag=self._get_node_handler_tag(NodeType.DIRECTORY), - node_type=NodeType.DIRECTORY, - item_click_callback=self._on_directory_node_clicked, - status_bar_callback=self._create_status_bar_message_function_for_directory_node(), - ), - NodeType.FILE: NodeHandler( - tag=self._get_node_handler_tag(NodeType.FILE), - node_type=NodeType.FILE, - item_click_callback=self._on_reconstruction_node_clicked, - item_double_click_callback=self._on_reconstruction_node_double_clicked, - status_bar_callback=self._create_status_bar_message_function_for_reconstruction_node(), - ), - } - - super()._setup_handlers() - - def _create_buttons(self) -> None: - with dpg.group(tag=TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS): - GUIButton( - tag=TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, - label=self._language_manager["reconstructions.browser.label.refresh_button"], - width=-1, - callback=self.rebuild_tree, - theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), - ) - self._status_bar.bind_to_item( - TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, - self._language_manager["reconstructions.browser.message.status_refresh"], - ) - - def _create_tree_window(self) -> None: - self.create_search(self._body_container) - with ( - dpg.child_window( - tag=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, - horizontal_scrollbar=True, - ), - dpg.group(tag=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE), - dpg.tree_node( - label=self._lbl_reconstructions, - tag=self.tree_tag, - default_open=True, - ), - ): - pass - - def refresh(self) -> None: - self.rebuild_tree() - - @concurrent(wait=False, method_bound=True) - def rebuild_tree(self) -> None: - self._launch_rebuild( - lambda: self.call(self.on_refresh_tree), - lambda: self._collect_specs(self.tree_tag), - root_tag=self.tree_tag, - ) - - def _has_relevant_content(self, node: TreeNode) -> bool: - if node.node_type == NodeType.FILE: - return True - - return bool(node.children) - - @traverse(TreeTraversal.BFS) - def _build_tree_node( - self, - node: TreeNode, - state: TreeNodeState, - **kwargs: Any, - ) -> None: - node_tag = self._generate_node_tag(node) - if node.node_type == NodeType.ROOT: - return - - if node.node_type == NodeType.GROUP: - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - should_expand=self._should_expand_node(node), - ) - state.parent = node_tag - return - - if not isinstance(node, FileSystemNode): - return - - is_favorite = self._logic.is_node_favorite(node) - state.has_favorite_ancestor |= is_favorite - if node.node_type == NodeType.DIRECTORY: - should_expand = self._should_expand_node(node) - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - should_expand=should_expand, - has_favorite_ancestor=state.has_favorite_ancestor, - ) - else: - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - leaf=True, - has_favorite_ancestor=state.has_favorite_ancestor, - ) - - state.parent = node_tag - - def _resolve_other_theme_tag(self, node: TreeNode) -> str: - if node.node_type == NodeType.GROUP: - return TAG_GLOBAL_THEME_DEFAULT + self.on_reconstruct_file: Optional[VoidCallback] = None + self.on_reconstruct_directory: Optional[VoidCallback] = None + self.on_load_reconstruction: Optional[PathCallback] = None + self.on_reconstruction_remove_requested: Optional[PathCallback] = None + self.on_directory_remove_requested: Optional[PathCallback] = None - return super()._resolve_other_theme_tag(node) + self._is_operation_active = is_operation_active - def set_tree_enabled(self, enabled: bool) -> None: - dpg_configure_item( - TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE, - enabled=enabled, - ) - dpg_configure_item( - TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, - enabled=enabled, - ) + def _open_reconstruction(self, node: FileSystemNode) -> None: + self._load_reconstruction(node) def _reconstruct_file(self) -> None: self.call(self.on_reconstruct_file) @@ -249,73 +77,13 @@ def _reconstruct_file(self) -> None: def _reconstruct_directory(self) -> None: self.call(self.on_reconstruct_directory) - def _on_directory_node_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - user_data: Tuple[FileSystemNode, str], - ) -> None: - mouse_button, _ = app_data - node, _ = user_data - if mouse_button == dpg.mvMouseButton_Right: - return self._show_directory_context_menu(node) - - return None - - def _on_reconstruction_node_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - user_data: Tuple[FileSystemNode, str], - ) -> None: - mouse_button, _ = app_data - node, node_tag = user_data - if mouse_button == dpg.mvMouseButton_Left: - self._logic.request_autoplay(node) - - if mouse_button == dpg.mvMouseButton_Right: - self._show_reconstruction_context_menu(node, node_tag) - - def _on_reconstruction_node_double_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - user_data: Tuple[FileSystemNode, str], - ) -> None: - mouse_button, _ = app_data - node, _ = user_data - if mouse_button == dpg.mvMouseButton_Left: - self._logic.cancel_autoplay() - self._load_reconstruction(node) - - def _show_directory_context_menu(self, node: FileSystemNode) -> None: - if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY: - return - - with context_menu(): - self._add_context_menu_text(node) - self._add_context_menu_details(node) - self._add_context_menu_path_items(node.filepath) - self._add_context_menu_remove_directory_item(node) - self._add_context_menu_favorite_item(node) - - def _show_reconstruction_context_menu( - self, - node: FileSystemNode, - _node_tag: str, - ) -> None: - if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: - return + def _add_directory_context_menu_items(self, node: FileSystemNode) -> None: + self._add_context_menu_remove_directory_item(node) - with context_menu(): - self._add_context_menu_text(node) - self._add_context_menu_play_item(node) - self._add_context_menu_load_reconstruction_item(node) - self._add_context_menu_remove_reconstruction_item(node) - self._add_context_menu_sequencer_items(node) - self._add_context_menu_path_items(node.filepath) - self._add_context_menu_locate_audio_item(node) - self._add_context_menu_favorite_item(node) + def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None: + self._add_context_menu_load_reconstruction_item(node) + self._add_context_menu_remove_reconstruction_item(node) + self._add_context_menu_sequencer_items(node) def _add_context_menu_load_reconstruction_item( self, diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index 74e8eaf27..2ed293f67 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -1,15 +1,7 @@ -from typing import Any, Dict, Optional, Tuple - -import dearpygui.dearpygui as dpg - from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) -from sampletones_application.tags.general import ( - TAG_GLOBAL_THEME_DEFAULT, - TAG_GLOBAL_THEME_SECONDARY_BUTTON, -) from sampletones_application.tags.sequencer import ( TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, @@ -18,32 +10,22 @@ TAG_SEQUENCER_BROWSER_TREE, TAG_SEQUENCER_BROWSER_WINDOW_TREE, ) -from sampletones_application.ui.elements.button import GUIButton -from sampletones_application.ui.elements.context_menu import context_menu -from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors -from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol -from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.elements.tree.tree import GUITreePanel -from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_configure_item -from sampletones_application.utils.parallelization.thread import concurrent -from sampletones_core.structures.tree import ( - FileSystemNode, - NodeType, - Tree, - TreeNode, - TreeTraversal, - traverse, +from sampletones_application.ui.panels.shared.browser import ( + GUIReconstructionBrowserPanel, ) -from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import VoidCallback +from sampletones_core.structures.tree import FileSystemNode, Tree -class GUISequencerBrowserPanel(GUITreePanel): - _MONOSPACE_CONFIG_NODES: bool = True +class GUISequencerBrowserPanel(GUIReconstructionBrowserPanel): + _panel_tag = TAG_SEQUENCER_BROWSER_PANEL + _tree_tag = TAG_SEQUENCER_BROWSER_TREE + _button_refresh_tag = TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS + _group_controls_tag = TAG_SEQUENCER_BROWSER_GROUP_CONTROLS + _group_tree_tag = TAG_SEQUENCER_BROWSER_GROUP_TREE + _window_tree_tag = TAG_SEQUENCER_BROWSER_WINDOW_TREE def __init__( self, @@ -56,243 +38,23 @@ def __init__( colors: TreeColors, initial_collapsed: bool = False, ) -> None: - self._language_manager = language_manager - self.on_refresh_tree: Optional[VoidCallback] = None - - self._lbl_reconstructions = language_manager["sequencer.browser.label.reconstructions_tree"] - - self._node_handlers: Dict[NodeType, NodeHandler] - super().__init__( tree=tree, - tag=TAG_SEQUENCER_BROWSER_PANEL, - tree_tag=TAG_SEQUENCER_BROWSER_TREE, tree_logic=tree_logic, scheduling=scheduling, - search_label=language_manager["global.browser.label.search"], language_manager=language_manager, status_bar=status_bar, colors=colors, - ) - - self._enable_horizontal_collapse( + reconstructions_label=language_manager["sequencer.browser.label.reconstructions_tree"], + refresh_button_label=language_manager["sequencer.browser.label.refresh_button"], + refresh_status_message=language_manager["sequencer.browser.message.status_refresh"], initial_collapsed=initial_collapsed, - side=CollapseAxis.HORIZONTAL_LEFT, - ) - - def create_panel(self, parent: str) -> None: - self._setup_handlers() - with ( - dpg.child_window( - tag=self.tag, - width=self.width, - height=self.height, - parent=parent, - border=False, - ), - self._collapsible_section( - self._lbl_reconstructions, - glyph=self._glyphs.headers.reconstruction, - ), - ): - self._create_buttons() - dpg.add_separator() - self._create_tree_window() - - self._create_detail_tooltip(TAG_SEQUENCER_BROWSER_WINDOW_TREE) - self.rebuild_tree() - - def _setup_handlers(self) -> None: - self._node_handlers = { - NodeType.GROUP: NodeHandler( - tag=self._get_node_handler_tag(NodeType.GROUP), - node_type=NodeType.GROUP, - ), - NodeType.DIRECTORY: NodeHandler( - tag=self._get_node_handler_tag(NodeType.DIRECTORY), - node_type=NodeType.DIRECTORY, - item_click_callback=self._on_directory_node_clicked, - status_bar_callback=self._create_status_bar_message_function_for_directory_node(), - ), - NodeType.FILE: NodeHandler( - tag=self._get_node_handler_tag(NodeType.FILE), - node_type=NodeType.FILE, - item_click_callback=self._on_reconstruction_node_clicked, - item_double_click_callback=self._on_reconstruction_node_double_clicked, - status_bar_callback=self._create_status_bar_message_function_for_reconstruction_node(), - ), - } - - super()._setup_handlers() - - def _create_buttons(self) -> None: - with dpg.group(tag=TAG_SEQUENCER_BROWSER_GROUP_CONTROLS): - GUIButton( - tag=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, - label=self._language_manager["sequencer.browser.label.refresh_button"], - width=-1, - callback=self.rebuild_tree, - theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), - ) - self._status_bar.bind_to_item( - TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, - self._language_manager["sequencer.browser.message.status_refresh"], - ) - - def _create_tree_window(self) -> None: - self.create_search(self._body_container) - with ( - dpg.child_window( - tag=TAG_SEQUENCER_BROWSER_WINDOW_TREE, - horizontal_scrollbar=True, - ), - dpg.group(tag=TAG_SEQUENCER_BROWSER_GROUP_TREE), - dpg.tree_node( - label=self._lbl_reconstructions, - tag=self.tree_tag, - default_open=True, - ), - ): - pass - - def refresh(self) -> None: - self.rebuild_tree() - - @concurrent(wait=False, method_bound=True) - def rebuild_tree(self) -> None: - self._launch_rebuild( - lambda: self.call(self.on_refresh_tree), - lambda: self._collect_specs(self.tree_tag), - root_tag=self.tree_tag, - ) - - def _has_relevant_content(self, node: TreeNode) -> bool: - if node.node_type == NodeType.FILE: - return True - - return bool(node.children) - - @traverse(TreeTraversal.BFS) - def _build_tree_node( - self, - node: TreeNode, - state: TreeNodeState, - **kwargs: Any, - ) -> None: - node_tag = self._generate_node_tag(node) - if node.node_type == NodeType.ROOT: - return - - if node.node_type == NodeType.GROUP: - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - should_expand=self._should_expand_node(node), - ) - state.parent = node_tag - return - - if not isinstance(node, FileSystemNode): - return - - is_favorite = self._logic.is_node_favorite(node) - state.has_favorite_ancestor |= is_favorite - if node.node_type == NodeType.DIRECTORY: - should_expand = self._should_expand_node(node) - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - should_expand=should_expand, - has_favorite_ancestor=state.has_favorite_ancestor, - ) - else: - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - leaf=True, - has_favorite_ancestor=state.has_favorite_ancestor, - ) - - state.parent = node_tag - - def _resolve_other_theme_tag(self, node: TreeNode) -> str: - if node.node_type == NodeType.GROUP: - return TAG_GLOBAL_THEME_DEFAULT - - return super()._resolve_other_theme_tag(node) - - def set_tree_enabled(self, enabled: bool) -> None: - dpg_configure_item(TAG_SEQUENCER_BROWSER_GROUP_TREE, enabled=enabled) - dpg_configure_item( - TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, - enabled=enabled, ) - def _on_directory_node_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - user_data: Tuple[FileSystemNode, str], - ) -> None: - mouse_button, _ = app_data - node, _ = user_data - if mouse_button == dpg.mvMouseButton_Right: - return self._show_directory_context_menu(node) - - return None - - def _on_reconstruction_node_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - user_data: Tuple[FileSystemNode, str], - ) -> None: - mouse_button, _ = app_data - node, node_tag = user_data - if mouse_button == dpg.mvMouseButton_Left: - self._logic.request_autoplay(node) - - if mouse_button == dpg.mvMouseButton_Right: - self._show_reconstruction_context_menu(node, node_tag) - - def _on_reconstruction_node_double_clicked( - self, - _sender: Sender, - app_data: Tuple[int, int], - user_data: Tuple[FileSystemNode, str], - ) -> None: - mouse_button, _ = app_data - node, _ = user_data - if mouse_button == dpg.mvMouseButton_Left: - self._logic.cancel_autoplay() - self.call(self.on_add_to_sequencer, node.filepath) - - def _show_directory_context_menu(self, node: FileSystemNode) -> None: - if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY: - return - - with context_menu(): - self._add_context_menu_text(node) - self._add_context_menu_details(node) - self._add_context_menu_path_items(node.filepath) - self._add_context_menu_favorite_item(node) - - def _show_reconstruction_context_menu( - self, - node: FileSystemNode, - _node_tag: str, - ) -> None: - if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: - return + def _open_reconstruction(self, node: FileSystemNode) -> None: + self._logic.cancel_autoplay() + self.call(self.on_add_to_sequencer, node.filepath) - with context_menu(): - self._add_context_menu_text(node) - self._add_context_menu_play_item(node) - self._add_context_menu_sequencer_items(node) - self._add_context_menu_replace_item(node) - self._add_context_menu_path_items(node.filepath) - self._add_context_menu_locate_audio_item(node) - self._add_context_menu_favorite_item(node) + def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None: + self._add_context_menu_sequencer_items(node) + self._add_context_menu_replace_item(node) diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py new file mode 100644 index 000000000..637322b59 --- /dev/null +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -0,0 +1,310 @@ +from abc import abstractmethod +from typing import Any, Optional, Tuple + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.behavior.scheduling.scheduling import ( + SchedulingBehavior, +) +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DEFAULT, + TAG_GLOBAL_THEME_SECONDARY_BUTTON, +) +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.context_menu import context_menu +from sampletones_application.ui.elements.layout.collapse import CollapseAxis +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.handler import NodeHandler +from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol +from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.ui.elements.tree.tree import GUITreePanel +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.utils.parallelization.thread import concurrent +from sampletones_core.structures.tree import ( + FileSystemNode, + NodeType, + Tree, + TreeNode, + TreeTraversal, + traverse, +) +from sampletones_shared.types.application import Sender +from sampletones_shared.types.callback import VoidCallback + + +class GUIReconstructionBrowserPanel(GUITreePanel): + """Shared skeleton of the reconstructions browser in the Sequencer and Reconstruction tabs. + + Builds the refresh button and the searchable tree, resolves every node into a spec, and routes + node clicks to the subclass through :meth:`_open_reconstruction`. The subclass supplies its DPG + tags, its displayed labels, and the extra items each context menu offers. + """ + + _MONOSPACE_CONFIG_NODES: bool = True + + _panel_tag: str + _tree_tag: str + _button_refresh_tag: str + _group_controls_tag: str + _group_tree_tag: str + _window_tree_tag: str + + def __init__( + self, + tree: Tree, + tree_logic: TreeLogicProtocol, + *, + scheduling: SchedulingBehavior, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + colors: TreeColors, + reconstructions_label: str, + refresh_button_label: str, + refresh_status_message: str, + initial_collapsed: bool = False, + ) -> None: + self._language_manager = language_manager + self._reconstructions_label = reconstructions_label + self._refresh_button_label = refresh_button_label + self._refresh_status_message = refresh_status_message + self.on_refresh_tree: Optional[VoidCallback] = None + + super().__init__( + tree=tree, + tag=self._panel_tag, + tree_tag=self._tree_tag, + tree_logic=tree_logic, + scheduling=scheduling, + search_label=language_manager["global.browser.label.search"], + language_manager=language_manager, + status_bar=status_bar, + colors=colors, + ) + + self._enable_horizontal_collapse( + initial_collapsed=initial_collapsed, + side=CollapseAxis.HORIZONTAL_LEFT, + ) + + def create_panel(self, parent: str) -> None: + self._setup_handlers() + with ( + dpg.child_window( + tag=self.tag, + width=self.width, + height=self.height, + parent=parent, + border=False, + ), + self._collapsible_section( + self._reconstructions_label, + glyph=self._glyphs.headers.reconstruction, + ), + ): + self._create_buttons() + dpg.add_separator() + self._create_tree_window() + + self._create_detail_tooltip(self._window_tree_tag) + self.rebuild_tree() + + def _setup_handlers(self) -> None: + self._node_handlers = { + NodeType.GROUP: NodeHandler( + tag=self._get_node_handler_tag(NodeType.GROUP), + node_type=NodeType.GROUP, + ), + NodeType.DIRECTORY: NodeHandler( + tag=self._get_node_handler_tag(NodeType.DIRECTORY), + node_type=NodeType.DIRECTORY, + item_click_callback=self._on_directory_node_clicked, + status_bar_callback=self._create_status_bar_message_function_for_directory_node(), + ), + NodeType.FILE: NodeHandler( + tag=self._get_node_handler_tag(NodeType.FILE), + node_type=NodeType.FILE, + item_click_callback=self._on_reconstruction_node_clicked, + item_double_click_callback=self._on_reconstruction_node_double_clicked, + status_bar_callback=self._create_status_bar_message_function_for_reconstruction_node(), + ), + } + + super()._setup_handlers() + + def _create_buttons(self) -> None: + with dpg.group(tag=self._group_controls_tag): + GUIButton( + tag=self._button_refresh_tag, + label=self._refresh_button_label, + width=-1, + callback=self.rebuild_tree, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), + ) + self._status_bar.bind_to_item( + self._button_refresh_tag, + self._refresh_status_message, + ) + + def _create_tree_window(self) -> None: + self.create_search(self._body_container) + with ( + dpg.child_window( + tag=self._window_tree_tag, + horizontal_scrollbar=True, + ), + dpg.group(tag=self._group_tree_tag), + dpg.tree_node( + label=self._reconstructions_label, + tag=self.tree_tag, + default_open=True, + ), + ): + pass + + def refresh(self) -> None: + self.rebuild_tree() + + @concurrent(wait=False, method_bound=True) + def rebuild_tree(self) -> None: + self._launch_rebuild( + lambda: self.call(self.on_refresh_tree), + lambda: self._collect_specs(self.tree_tag), + root_tag=self.tree_tag, + ) + + def _has_relevant_content(self, node: TreeNode) -> bool: + if node.node_type == NodeType.FILE: + return True + + return bool(node.children) + + @traverse(TreeTraversal.BFS) + def _build_tree_node( + self, + node: TreeNode, + state: TreeNodeState, + **kwargs: Any, + ) -> None: + node_tag = self._generate_node_tag(node) + if node.node_type == NodeType.ROOT: + return + + if node.node_type == NodeType.GROUP: + self._append_spec( + node=node, + node_tag=node_tag, + parent=state.parent, + should_expand=self._should_expand_node(node), + ) + state.parent = node_tag + return + + if not isinstance(node, FileSystemNode): + return + + is_favorite = self._logic.is_node_favorite(node) + state.has_favorite_ancestor |= is_favorite + if node.node_type == NodeType.DIRECTORY: + should_expand = self._should_expand_node(node) + self._append_spec( + node=node, + node_tag=node_tag, + parent=state.parent, + should_expand=should_expand, + has_favorite_ancestor=state.has_favorite_ancestor, + ) + else: + self._append_spec( + node=node, + node_tag=node_tag, + parent=state.parent, + leaf=True, + has_favorite_ancestor=state.has_favorite_ancestor, + ) + + state.parent = node_tag + + def _resolve_other_theme_tag(self, node: TreeNode) -> str: + if node.node_type == NodeType.GROUP: + return TAG_GLOBAL_THEME_DEFAULT + + return super()._resolve_other_theme_tag(node) + + def set_tree_enabled(self, enabled: bool) -> None: + dpg_configure_item(self._group_tree_tag, enabled=enabled) + dpg_configure_item(self._group_controls_tag, enabled=enabled) + + def _on_directory_node_clicked( + self, + _sender: Sender, + app_data: Tuple[int, int], + user_data: Tuple[FileSystemNode, str], + ) -> None: + mouse_button, _ = app_data + node, _ = user_data + if mouse_button == dpg.mvMouseButton_Right: + self._show_directory_context_menu(node) + + def _on_reconstruction_node_clicked( + self, + _sender: Sender, + app_data: Tuple[int, int], + user_data: Tuple[FileSystemNode, str], + ) -> None: + mouse_button, _ = app_data + node, node_tag = user_data + if mouse_button == dpg.mvMouseButton_Left: + self._logic.request_autoplay(node) + + if mouse_button == dpg.mvMouseButton_Right: + self._show_reconstruction_context_menu(node, node_tag) + + def _on_reconstruction_node_double_clicked( + self, + _sender: Sender, + app_data: Tuple[int, int], + user_data: Tuple[FileSystemNode, str], + ) -> None: + mouse_button, _ = app_data + node, _ = user_data + if mouse_button == dpg.mvMouseButton_Left: + self._open_reconstruction(node) + + def _show_directory_context_menu(self, node: FileSystemNode) -> None: + if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY: + return + + with context_menu(): + self._add_context_menu_text(node) + self._add_context_menu_details(node) + self._add_context_menu_path_items(node.filepath) + self._add_directory_context_menu_items(node) + self._add_context_menu_favorite_item(node) + + def _show_reconstruction_context_menu( + self, + node: FileSystemNode, + _node_tag: str, + ) -> None: + if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: + return + + with context_menu(): + self._add_context_menu_text(node) + self._add_context_menu_play_item(node) + self._add_reconstruction_context_menu_items(node) + self._add_context_menu_path_items(node.filepath) + self._add_context_menu_locate_audio_item(node) + self._add_context_menu_favorite_item(node) + + def _add_directory_context_menu_items(self, node: FileSystemNode) -> None: + pass + + @abstractmethod + def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None: ... + + @abstractmethod + def _open_reconstruction(self, node: FileSystemNode) -> None: ... diff --git a/tests/unit/sampletones_application/ui/panels/sequencer/test_browser_context_menu.py b/tests/unit/sampletones_application/ui/panels/sequencer/test_browser_context_menu.py index f67e08288..87a03b7db 100644 --- a/tests/unit/sampletones_application/ui/panels/sequencer/test_browser_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/sequencer/test_browser_context_menu.py @@ -5,8 +5,8 @@ import pytest from sampletones_application.ui.elements.tree import tree as tree_module -from sampletones_application.ui.panels.sequencer import browser as browser_module from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.ui.panels.shared import browser as shared_browser_module from sampletones_core.structures.tree.node import FileSystemNode, NodeType from tests.suite.language import FakeLanguageManager @@ -167,7 +167,7 @@ def test_replace_follows_the_add_item(self, monkeypatch: pytest.MonkeyPatch) -> def _menu() -> Iterator[None]: yield - monkeypatch.setattr(browser_module, "context_menu", _menu) + monkeypatch.setattr(shared_browser_module, "context_menu", _menu) panel._show_reconstruction_context_menu(_node(), "node-tag") @@ -196,7 +196,7 @@ def test_directory_menu_offers_no_replacement(self, monkeypatch: pytest.MonkeyPa def _menu() -> Iterator[None]: yield - monkeypatch.setattr(browser_module, "context_menu", _menu) + monkeypatch.setattr(shared_browser_module, "context_menu", _menu) panel._show_directory_context_menu(_node(NodeType.DIRECTORY)) From f13261e645dcd8347574754dc606a5b97f7f38aa Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 15 Aug 2026 16:22:50 +0200 Subject: [PATCH 111/152] Added: multiple browser views --- .../logic/reconstruction/browser_manager.py | 81 ++++++++-- .../ui/panels/reconstruction/browser.py | 1 - .../ui/panels/sequencer/browser.py | 1 - .../ui/panels/shared/browser.py | 7 +- src/sampletones_config/lang/en.yaml | 5 +- .../reconstruction/test_browser_manager.py | 141 +++++++++++++++--- 6 files changed, 197 insertions(+), 39 deletions(-) diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py index ca0a9a38b..86772f850 100644 --- a/src/sampletones_application/logic/reconstruction/browser_manager.py +++ b/src/sampletones_application/logic/reconstruction/browser_manager.py @@ -47,10 +47,22 @@ def refresh_tree(self) -> None: name=self._language_manager["global.browser.label.root"], node_type=NodeType.ROOT, ) + reconstructions_node = TreeNode( + name=self._language_manager["global.browser.label.reconstructions"], + node_type=NodeType.GROUP, + parent=container_root, + ) + samples_node = TreeNode( + name=self._language_manager["global.browser.label.samples"], + node_type=NodeType.GROUP, + parent=container_root, + ) + for path in sorted(self.reconstructions_directory.iterdir()): - self._build_tree(path, parent=container_root) + self._build_tree(path, parent=reconstructions_node) - self._organize_top_level_config_directories(container_root) + self._organize_top_level_config_directories(reconstructions_node) + self._build_samples_children(samples_node) self.tree.set_root(container_root) def _build_tree( @@ -90,7 +102,7 @@ def _build_tree( def _organize_top_level_config_directories( self, - container_root: TreeNode, + reconstructions_node: TreeNode, ) -> None: """Groups top-level config directories under frequencies/method nodes, leaving other folders flat. @@ -98,7 +110,7 @@ def _organize_top_level_config_directories( renamed to its generator abbreviation, while any other top-level folder keeps the existing flat friendly naming for the config directories nested inside it. """ - for child in list(container_root.children): + for child in list(reconstructions_node.children): if not isinstance(child, FileSystemNode) or child.node_type != NodeType.DIRECTORY: continue @@ -110,16 +122,16 @@ def _organize_top_level_config_directories( self._attach_config_directory_under_groups( child, fields, - container_root, + reconstructions_node, ) - self._disambiguate_generator_siblings(container_root) + self._disambiguate_generator_siblings(reconstructions_node) def _attach_config_directory_under_groups( self, directory_node: FileSystemNode, fields: ConfigDirectoryFields, - container_root: TreeNode, + reconstructions_node: TreeNode, ) -> None: frequencies_name = DISPLAY_SEPARATOR.join( [ @@ -135,7 +147,7 @@ def _attach_config_directory_under_groups( ) frequencies_node = self._find_or_create_group_node( frequencies_name, - container_root, + reconstructions_node, ) method_node = self._find_or_create_group_node( method_name, @@ -208,10 +220,55 @@ def _rename_config_directory_children(self, node: TreeNode) -> None: for directory_node, fields in members: directory_node.name = disambiguated_display_name(display_name, fields.ch) + def _build_samples_children(self, samples_node: TreeNode) -> None: + """Populates the transposed Samples branch: source-audio directories ▶ audio ▶ config variants.""" + variants_by_audio: Dict[Tuple[Tuple[str, ...], str], List[Tuple[ConfigDirectoryFields, Path]]] = {} + + for config_directory in sorted(self.reconstructions_directory.iterdir()): + if not config_directory.is_dir(): + continue + + fields = ConfigDirectoryFields.from_directory_name(config_directory.name) + if fields is None: + continue + + for reconstruction_path in sorted(config_directory.rglob(f"*{EXT_FILE_RECONSTRUCTION}")): + relative = reconstruction_path.relative_to(config_directory) + audio_key = (relative.parent.parts, relative.stem) + variants_by_audio.setdefault(audio_key, []).append((fields, reconstruction_path)) + + for audio_key in sorted(variants_by_audio): + directory_parts, audio_name = audio_key + parent = samples_node + for part in directory_parts: + parent = self._find_or_create_group_node(part, parent) + + audio_node = self._find_or_create_group_node(audio_name, parent) + self._append_config_variants(audio_node, variants_by_audio[audio_key]) + + def _append_config_variants( + self, + audio_node: TreeNode, + variants: List[Tuple[ConfigDirectoryFields, Path]], + ) -> None: + variants_by_display_name: Dict[str, List[Tuple[ConfigDirectoryFields, Path]]] = {} + for fields, reconstruction_path in variants: + variants_by_display_name.setdefault(fields.display_name, []).append((fields, reconstruction_path)) + + for display_name, members in variants_by_display_name.items(): + for fields, reconstruction_path in members: + label = display_name if len(members) == 1 else disambiguated_display_name(display_name, fields.ch) + FileSystemNode( + label, + filepath=reconstruction_path, + node_type=NodeType.FILE, + parent=audio_node, + ) + def get_all_reconstruction_files(self) -> List[Path]: - file_nodes = [ - node + file_paths = { + node.filepath for node in self.tree.collect_leaves() if isinstance(node, FileSystemNode) and node.node_type == NodeType.FILE - ] - return [node.filepath for node in file_nodes] + } + return sorted(file_paths) diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index 2f30f2d74..ebcd61010 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -54,7 +54,6 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=colors, - reconstructions_label=language_manager["reconstructions.browser.label.reconstructions_tree"], refresh_button_label=language_manager["reconstructions.browser.label.refresh_button"], refresh_status_message=language_manager["reconstructions.browser.message.status_refresh"], initial_collapsed=initial_collapsed, diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index 2ed293f67..c3b2aff70 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -45,7 +45,6 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=colors, - reconstructions_label=language_manager["sequencer.browser.label.reconstructions_tree"], refresh_button_label=language_manager["sequencer.browser.label.refresh_button"], refresh_status_message=language_manager["sequencer.browser.message.status_refresh"], initial_collapsed=initial_collapsed, diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 637322b59..e54ce85c9 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -61,13 +61,12 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, - reconstructions_label: str, refresh_button_label: str, refresh_status_message: str, initial_collapsed: bool = False, ) -> None: self._language_manager = language_manager - self._reconstructions_label = reconstructions_label + self._browser_label = language_manager["global.browser.label.browser"] self._refresh_button_label = refresh_button_label self._refresh_status_message = refresh_status_message self.on_refresh_tree: Optional[VoidCallback] = None @@ -100,7 +99,7 @@ def create_panel(self, parent: str) -> None: border=False, ), self._collapsible_section( - self._reconstructions_label, + self._browser_label, glyph=self._glyphs.headers.reconstruction, ), ): @@ -157,7 +156,7 @@ def _create_tree_window(self) -> None: ), dpg.group(tag=self._group_tree_tag), dpg.tree_node( - label=self._reconstructions_label, + label=self._browser_label, tag=self.tree_tag, default_open=True, ), diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 7840744f2..102e8f80f 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -126,6 +126,9 @@ global.traceback.label.hide: "Hide traceback" # Global — Tree # ============================================================================= global.browser.label.root: "Root" +global.browser.label.browser: "Browser" +global.browser.label.reconstructions: "Reconstructions" +global.browser.label.samples: "Samples" global.browser.label.search: "Search" global.browser.label.filter: "Filter" global.browser.label.clear_search: "Clear" @@ -359,7 +362,6 @@ main.advanced.message.status_select_output: "Choose the directory for reconstruc # Reconstructions tab — Browser # ============================================================================= reconstructions.browser.label.refresh_button: "Refresh reconstructions" -reconstructions.browser.label.reconstructions_tree: "Reconstructions" reconstructions.browser.label.context_load_reconstruction: "Load reconstruction" reconstructions.browser.label.context_remove_reconstruction: "Remove reconstruction" reconstructions.browser.label.context_remove_directory: "Remove directory" @@ -433,7 +435,6 @@ reconstructions.instruments.template.initial_pitch_tooltip_template: "Enter the # Sequencer tab — Browser # ============================================================================= sequencer.browser.label.refresh_button: "Refresh reconstructions" -sequencer.browser.label.reconstructions_tree: "Reconstructions" sequencer.browser.message.status_refresh: "Rescan for available reconstructions." # ============================================================================= diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py index 54d30427f..54438f226 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py @@ -13,10 +13,20 @@ HASH_B = "a1b2c3d4e5f60718293a4b5c6d7e8f90" -def directory_nodes(browser_manager: BrowserManager) -> Dict[str, FileSystemNode]: +def reconstructions_node(browser_manager: BrowserManager) -> TreeNode: + root = browser_manager.tree.get_root() + assert root is not None + return group_children(root)["Reconstructions"] + + +def samples_node(browser_manager: BrowserManager) -> TreeNode: root = browser_manager.tree.get_root() assert root is not None - return directory_children(root) + return group_children(root)["Samples"] + + +def directory_nodes(browser_manager: BrowserManager) -> Dict[str, FileSystemNode]: + return directory_children(reconstructions_node(browser_manager)) def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: @@ -27,6 +37,14 @@ def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: } +def file_children(node: TreeNode) -> Dict[str, FileSystemNode]: + return { + child.name: child + for child in node.children + if isinstance(child, FileSystemNode) and child.node_type == NodeType.FILE + } + + def group_children(node: TreeNode) -> Dict[str, TreeNode]: return { child.name: child @@ -42,10 +60,18 @@ def config_manager(tmp_path: Path) -> MagicMock: return mock +BROWSER_LABELS = { + "global.browser.label.root": "Root", + "global.browser.label.browser": "Browser", + "global.browser.label.reconstructions": "Reconstructions", + "global.browser.label.samples": "Samples", +} + + @pytest.fixture def language_manager() -> MagicMock: mock = MagicMock() - mock.__getitem__ = MagicMock(return_value="Reconstructions") + mock.__getitem__ = MagicMock(side_effect=BROWSER_LABELS.__getitem__) return mock @@ -140,10 +166,8 @@ def test_config_directory_groups_by_frequency_method_generators( browser_manager.refresh_tree() - root = browser_manager.tree.get_root() - assert root is not None - - frequencies = group_children(root) + reconstructions = reconstructions_node(browser_manager) + frequencies = group_children(reconstructions) assert set(frequencies) == {"44.1 kHz·30 Hz"} methods = group_children(frequencies["44.1 kHz·30 Hz"]) @@ -163,9 +187,8 @@ def test_colliding_config_directories_get_hash_suffix( browser_manager.refresh_tree() - root = browser_manager.tree.get_root() - assert root is not None - methods = group_children(group_children(root)["44.1 kHz·30 Hz"]) + reconstructions = reconstructions_node(browser_manager) + methods = group_children(group_children(reconstructions)["44.1 kHz·30 Hz"]) assert set(directory_children(methods["FFT·γ0"])) == { f"PpT·#{HASH_A[:7]}", f"PpT·#{HASH_B[:7]}", @@ -183,9 +206,7 @@ def test_distinct_frequencies_form_separate_groups( browser_manager.refresh_tree() - root = browser_manager.tree.get_root() - assert root is not None - assert set(group_children(root)) == {"44.1 kHz·30 Hz", "48 kHz·60 Hz"} + assert set(group_children(reconstructions_node(browser_manager))) == {"44.1 kHz·30 Hz", "48 kHz·60 Hz"} def test_distinct_methods_form_separate_groups( self, @@ -199,9 +220,7 @@ def test_distinct_methods_form_separate_groups( browser_manager.refresh_tree() - root = browser_manager.tree.get_root() - assert root is not None - methods = group_children(group_children(root)["44.1 kHz·30 Hz"]) + methods = group_children(group_children(reconstructions_node(browser_manager))["44.1 kHz·30 Hz"]) assert set(methods) == {"FFT·γ0", "CQT·γ0"} def test_distinct_generators_share_method_group_without_hash( @@ -216,9 +235,7 @@ def test_distinct_generators_share_method_group_without_hash( browser_manager.refresh_tree() - root = browser_manager.tree.get_root() - assert root is not None - methods = group_children(group_children(root)["44.1 kHz·30 Hz"]) + methods = group_children(group_children(reconstructions_node(browser_manager))["44.1 kHz·30 Hz"]) assert set(directory_children(methods["FFT·γ0"])) == {"PTN", "TN"} def test_non_config_directory_keeps_raw_name( @@ -235,6 +252,92 @@ def test_non_config_directory_keeps_raw_name( assert "my_songs" in directory_nodes(browser_manager) +class TestBrowserManagerSamplesView: + def test_samples_are_grouped_by_source_directory_and_audio( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" + audio_dir = config_dir / "Amen Breaks" / "Amen Breaks vol.1" + audio_dir.mkdir(parents=True) + (audio_dir / "cw_amen02_165.stn").touch() + + browser_manager.refresh_tree() + + samples = samples_node(browser_manager) + amen_breaks = group_children(samples)["Amen Breaks"] + amen_breaks_vol1 = group_children(amen_breaks)["Amen Breaks vol.1"] + audio = group_children(amen_breaks_vol1)["cw_amen02_165"] + variant = file_children(audio)["44.1 kHz·30 Hz·FFT·γ0·PTN"] + assert variant.filepath == audio_dir / "cw_amen02_165.stn" + + def test_one_audio_lists_each_config_variant( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + for spectrum_method in ("fft", "cqt"): + config_dir = tmp_path / f"sr_44100_nf_30_sm_{spectrum_method}_tg_0_gn_PTN_ch_{HASH_A}" + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + audio = group_children(samples_node(browser_manager))["song"] + assert set(file_children(audio)) == { + "44.1 kHz·30 Hz·FFT·γ0·PTN", + "44.1 kHz·30 Hz·CQT·γ0·PTN", + } + + def test_colliding_variants_of_one_audio_get_hash_suffix( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + for config_hash in (HASH_A, HASH_B): + config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{config_hash}" + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + audio = group_children(samples_node(browser_manager))["song"] + assert set(file_children(audio)) == { + f"44.1 kHz·30 Hz·FFT·γ0·PTN·#{HASH_A[:7]}", + f"44.1 kHz·30 Hz·FFT·γ0·PTN·#{HASH_B[:7]}", + } + + def test_single_file_conversion_appears_at_samples_root( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + samples = samples_node(browser_manager) + assert set(group_children(samples)) == {"song"} + assert set(file_children(group_children(samples)["song"])) == {"44.1 kHz·30 Hz·FFT·γ0·PTN"} + + def test_non_config_directory_is_excluded_from_samples( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + plain = tmp_path / "my_songs" + plain.mkdir() + (plain / "song.stn").touch() + + browser_manager.refresh_tree() + + assert group_children(samples_node(browser_manager)) == {} + assert "my_songs" in directory_nodes(browser_manager) + + class TestBrowserManagerSetDirectory: def test_set_reconstructions_directory_updates_directory( self, From 229c6e01c1bd76be6b30fef234e4a42d583c37a9 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Sat, 15 Aug 2026 16:39:59 +0200 Subject: [PATCH 112/152] Improved: reconstruction double view --- src/sampletones_application/ui/panels/shared/browser.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index e54ce85c9..d3bb4c1f8 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -155,11 +155,7 @@ def _create_tree_window(self) -> None: horizontal_scrollbar=True, ), dpg.group(tag=self._group_tree_tag), - dpg.tree_node( - label=self._browser_label, - tag=self.tree_tag, - default_open=True, - ), + dpg.group(tag=self.tree_tag), ): pass From dece1e299eda1a7fe4469d612ab94f5853845c2c Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 02:29:17 +0200 Subject: [PATCH 113/152] Added: parsed configuration fields --- .../logic/main/explorer_manager.py | 11 +- .../logic/reconstruction/browser_manager.py | 64 +++++------ .../ui/elements/tree/tree.py | 11 +- .../structures/tree/__init__.py | 5 +- .../structures/tree/factory.py | 38 +++++++ src/sampletones_core/structures/tree/node.py | 36 ++++++ .../reconstruction/test_browser_manager.py | 62 +++++++++- .../ui/elements/tree/__init__.py | 0 .../ui/elements/tree/test_detail_items.py | 107 ++++++++++++++++++ .../structures/tree/test_factory.py | 40 +++++++ .../structures/tree/test_node.py | 40 +++++++ 11 files changed, 362 insertions(+), 52 deletions(-) create mode 100644 src/sampletones_core/structures/tree/factory.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/__init__.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py create mode 100644 tests/unit/sampletones_core/structures/tree/test_factory.py diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py index 40b84ed26..11824e234 100644 --- a/src/sampletones_application/logic/main/explorer_manager.py +++ b/src/sampletones_application/logic/main/explorer_manager.py @@ -13,6 +13,7 @@ NodeType, Tree, TreeNode, + create_directory_node, ) from sampletones_shared.utils.system.system import System @@ -56,10 +57,9 @@ def _create_directory_node( directory_path: Path, parent: Optional[TreeNode] = None, ) -> FileSystemNode: - node = FileSystemNode( + node = create_directory_node( + directory_path, name=directory_path.name or str(directory_path), - filepath=directory_path, - node_type=NodeType.DIRECTORY, parent=parent, ) @@ -93,10 +93,9 @@ def _load_directory_children( if entry_path.name.startswith("."): continue - child_node = FileSystemNode( + child_node = create_directory_node( + entry_path, name=entry_path.name, - filepath=entry_path, - node_type=NodeType.DIRECTORY, parent=directory_node, ) if level < self.depth: diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py index 86772f850..6ff474416 100644 --- a/src/sampletones_application/logic/reconstruction/browser_manager.py +++ b/src/sampletones_application/logic/reconstruction/browser_manager.py @@ -14,10 +14,12 @@ from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( + ConfigNode, FileSystemNode, NodeType, Tree, TreeNode, + create_directory_node, ) @@ -89,10 +91,9 @@ def _build_tree( if child_node is not None: children_nodes.append(child_node) - directory_node = FileSystemNode( - path.name, - filepath=path, - node_type=NodeType.DIRECTORY, + directory_node = create_directory_node( + path, + name=path.name, parent=parent, ) for child_node in children_nodes: @@ -111,28 +112,23 @@ def _organize_top_level_config_directories( flat friendly naming for the config directories nested inside it. """ for child in list(reconstructions_node.children): - if not isinstance(child, FileSystemNode) or child.node_type != NodeType.DIRECTORY: - continue - - fields = ConfigDirectoryFields.from_directory_name(child.filepath.name) - if fields is None: - self._assign_directory_display_names(child) - continue - - self._attach_config_directory_under_groups( - child, - fields, - reconstructions_node, - ) + match child: + case ConfigNode() if child.node_type == NodeType.DIRECTORY: + self._attach_config_directory_under_groups( + child, + reconstructions_node, + ) + case FileSystemNode() if child.node_type == NodeType.DIRECTORY: + self._assign_directory_display_names(child) self._disambiguate_generator_siblings(reconstructions_node) def _attach_config_directory_under_groups( self, - directory_node: FileSystemNode, - fields: ConfigDirectoryFields, + directory_node: ConfigNode, reconstructions_node: TreeNode, ) -> None: + fields = directory_node.config frequencies_name = DISPLAY_SEPARATOR.join( [ format_sample_rate(fields.sr), @@ -171,9 +167,9 @@ def _find_or_create_group_node( def _disambiguate_generator_siblings(self, node: TreeNode) -> None: """Appends a short config hash to generator leaves that share a name under one method group.""" if node.node_type == NodeType.GROUP: - by_name: Dict[str, List[FileSystemNode]] = {} + by_name: Dict[str, List[ConfigNode]] = {} for child in node.children: - if isinstance(child, FileSystemNode) and child.node_type == NodeType.DIRECTORY: + if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY: by_name.setdefault(child.name, []).append(child) for name, members in by_name.items(): @@ -181,9 +177,7 @@ def _disambiguate_generator_siblings(self, node: TreeNode) -> None: continue for directory_node in members: - fields = ConfigDirectoryFields.from_directory_name(directory_node.filepath.name) - if fields is not None: - directory_node.name = disambiguated_display_name(name, fields.ch) + directory_node.name = disambiguated_display_name(name, directory_node.config.ch) for child in node.children: self._disambiguate_generator_siblings(child) @@ -200,25 +194,20 @@ def _assign_directory_display_names(self, node: TreeNode) -> None: self._assign_directory_display_names(child) def _rename_config_directory_children(self, node: TreeNode) -> None: - groups: Dict[str, List[Tuple[FileSystemNode, ConfigDirectoryFields]]] = {} + groups: Dict[str, List[ConfigNode]] = {} for child in node.children: - if not isinstance(child, FileSystemNode) or child.node_type != NodeType.DIRECTORY: + if not isinstance(child, ConfigNode) or child.node_type != NodeType.DIRECTORY: continue - fields = ConfigDirectoryFields.from_directory_name(child.filepath.name) - if fields is None: - continue - - groups.setdefault(fields.display_name, []).append((child, fields)) + groups.setdefault(child.config.display_name, []).append(child) for display_name, members in groups.items(): if len(members) == 1: - directory_node, _ = members[0] - directory_node.name = display_name + members[0].name = display_name continue - for directory_node, fields in members: - directory_node.name = disambiguated_display_name(display_name, fields.ch) + for directory_node in members: + directory_node.name = disambiguated_display_name(display_name, directory_node.config.ch) def _build_samples_children(self, samples_node: TreeNode) -> None: """Populates the transposed Samples branch: source-audio directories ▶ audio ▶ config variants.""" @@ -258,10 +247,11 @@ def _append_config_variants( for display_name, members in variants_by_display_name.items(): for fields, reconstruction_path in members: label = display_name if len(members) == 1 else disambiguated_display_name(display_name, fields.ch) - FileSystemNode( + ConfigNode( label, - filepath=reconstruction_path, node_type=NodeType.FILE, + filepath=reconstruction_path, + config=fields, parent=audio_node, ) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 18b37a051..110ef7316 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -74,6 +74,7 @@ from sampletones_core.library import InstructionLibraryKey from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( + ConfigNode, FileSystemNode, LibraryNode, NodeType, @@ -545,8 +546,8 @@ def _node_detail_items(self, node: TreeNode) -> List[Tuple[str, str]]: match node: case LibraryNode(): return self._library_detail_items(node.library_key) - case FileSystemNode() if node.node_type == NodeType.DIRECTORY: - return self._reconstruction_detail_items(node.filepath.name) + case ConfigNode(): + return self._reconstruction_detail_items(node.config) return [] @@ -561,11 +562,7 @@ def _library_detail_items(self, key: InstructionLibraryKey) -> List[Tuple[str, s (self._lbl_detail_configuration, short_hash(key.config_hash)), ] - def _reconstruction_detail_items(self, directory_name: str) -> List[Tuple[str, str]]: - fields = ConfigDirectoryFields.from_directory_name(directory_name) - if fields is None: - return [] - + def _reconstruction_detail_items(self, fields: ConfigDirectoryFields) -> List[Tuple[str, str]]: generators = ", ".join(generator.capitalized for generator in fields.generators) return [ (self._lbl_detail_sample_rate, format_sample_rate(fields.sr)), diff --git a/src/sampletones_core/structures/tree/__init__.py b/src/sampletones_core/structures/tree/__init__.py index 1b5a29021..3c1dacf67 100644 --- a/src/sampletones_core/structures/tree/__init__.py +++ b/src/sampletones_core/structures/tree/__init__.py @@ -1,11 +1,13 @@ from .arguments import Arguments -from .node import FileSystemNode, GeneratorNode, LibraryNode, TreeNode +from .factory import create_directory_node +from .node import ConfigNode, FileSystemNode, GeneratorNode, LibraryNode, TreeNode from .traversal import TreeTraversal, traverse from .tree import Tree from .type import NodeType __all__ = [ "Arguments", + "ConfigNode", "FileSystemNode", "GeneratorNode", "LibraryNode", @@ -13,5 +15,6 @@ "Tree", "TreeNode", "TreeTraversal", + "create_directory_node", "traverse", ] diff --git a/src/sampletones_core/structures/tree/factory.py b/src/sampletones_core/structures/tree/factory.py new file mode 100644 index 000000000..5ab2d6649 --- /dev/null +++ b/src/sampletones_core/structures/tree/factory.py @@ -0,0 +1,38 @@ +from pathlib import Path +from typing import Optional + +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields + +from .node import ConfigNode, FileSystemNode, TreeNode +from .type import NodeType + + +def create_directory_node( + directory: Path, + *, + name: str, + parent: Optional[TreeNode], +) -> FileSystemNode: + """Builds the directory node that fits the folder, reading its configuration where it names one. + + A folder whose name parses as a reconstruction configuration directory becomes a + :class:`ConfigNode` carrying those fields; every other folder becomes a plain + :class:`FileSystemNode`. Routing every directory through here keeps the decision of which node + class carries a configuration in one place. + """ + config = ConfigDirectoryFields.from_directory_name(directory.name) + if config is None: + return FileSystemNode( + name, + node_type=NodeType.DIRECTORY, + filepath=directory, + parent=parent, + ) + + return ConfigNode( + name, + node_type=NodeType.DIRECTORY, + filepath=directory, + config=config, + parent=parent, + ) diff --git a/src/sampletones_core/structures/tree/node.py b/src/sampletones_core/structures/tree/node.py index 556f10822..9f6b34c57 100644 --- a/src/sampletones_core/structures/tree/node.py +++ b/src/sampletones_core/structures/tree/node.py @@ -7,6 +7,7 @@ from sampletones_core.constants.enums import LibraryGeneratorName from sampletones_core.library import InstructionLibraryKey +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from .type import NodeType @@ -45,6 +46,41 @@ def copy(self, parent: Optional[TreeNode] = None) -> FileSystemNode: ) +class ConfigNode(FileSystemNode): + """A filesystem node belonging to a reconstruction configuration, carrying the parsed fields. + + A configuration directory encodes its fields in its name, and both the directory itself and the + reconstructions inside it are read as belonging to that configuration. Holding the parsed + :class:`ConfigDirectoryFields` on the node lets every reader — labels, tooltips, fonts — state + the configuration from the node it already has, whatever the node's own filename says. + """ + + def __init__( + self, + name: str, + node_type: NodeType, + filepath: Path, + config: ConfigDirectoryFields, + parent: Optional[TreeNode] = None, + ) -> None: + super().__init__( + name, + node_type=node_type, + filepath=filepath, + parent=parent, + ) + self.config = config + + def copy(self, parent: Optional[TreeNode] = None) -> ConfigNode: + return ConfigNode( + self.name, + node_type=self.node_type, + filepath=self.filepath, + config=self.config, + parent=parent, + ) + + class LibraryNode(TreeNode): def __init__( self, diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py index 54438f226..ac27ef6ab 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py @@ -7,7 +7,13 @@ import pytest from sampletones_application.logic.reconstruction.browser_manager import BrowserManager -from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree import ( + ConfigNode, + FileSystemNode, + NodeType, + TreeNode, +) HASH_A = "6edf7c948606917a78b45d153c7ca7e0" HASH_B = "a1b2c3d4e5f60718293a4b5c6d7e8f90" @@ -338,6 +344,60 @@ def test_non_config_directory_is_excluded_from_samples( assert "my_songs" in directory_nodes(browser_manager) +class TestBrowserManagerConfigNodes: + def test_config_directory_carries_its_parsed_configuration( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + directory_name = f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" + config_dir = tmp_path / directory_name + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + methods = group_children(group_children(reconstructions_node(browser_manager))["44.1 kHz·30 Hz"]) + directory_node = directory_children(methods["FFT·γ0"])["PTN"] + assert isinstance(directory_node, ConfigNode) + assert directory_node.config == ConfigDirectoryFields.from_directory_name(directory_name) + + def test_sample_variant_carries_the_configuration_of_its_directory( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + """A leaf in the sample view states the configuration its directory names. + + Its own filename is the audio name, so the configuration reaches the tooltip and the + configuration font from the node rather than from the path. + """ + directory_name = f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" + config_dir = tmp_path / directory_name + config_dir.mkdir() + (config_dir / "song.stn").touch() + + browser_manager.refresh_tree() + + audio = group_children(samples_node(browser_manager))["song"] + variant = next(iter(file_children(audio).values())) + assert isinstance(variant, ConfigNode) + assert variant.config == ConfigDirectoryFields.from_directory_name(directory_name) + + def test_plain_directory_carries_no_configuration( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + plain = tmp_path / "my_songs" + plain.mkdir() + (plain / "song.stn").touch() + + browser_manager.refresh_tree() + + assert not isinstance(directory_nodes(browser_manager)["my_songs"], ConfigNode) + + class TestBrowserManagerSetDirectory: def test_set_reconstructions_directory_updates_directory( self, diff --git a/tests/unit/sampletones_application/ui/elements/tree/__init__.py b/tests/unit/sampletones_application/ui/elements/tree/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py new file mode 100644 index 000000000..d74612507 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py @@ -0,0 +1,107 @@ +from pathlib import Path +from typing import Final, List + +import pytest + +from sampletones_application.ui.elements.fonts.font import Font +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_core.configs import Config +from sampletones_core.configs.display import format_sample_rate, short_hash +from sampletones_core.paths import EXT_FILE_RECONSTRUCTION +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree.node import ConfigNode, FileSystemNode, TreeNode +from sampletones_core.structures.tree.type import NodeType +from tests.suite.language import FakeLanguageManager + +CONFIG_FIELDS: Final[ConfigDirectoryFields] = ConfigDirectoryFields.from_config(Config()) +CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions") / CONFIG_FIELDS.directory_name +RECONSTRUCTION_PATH: Final[Path] = CONFIG_DIRECTORY / f"song{EXT_FILE_RECONSTRUCTION}" + +DETAIL_LABELS: Final[List[str]] = [ + "sample_rate", + "nes_frequency", + "spectrum_method", + "transformation_gamma", + "window_size", + "generators", + "configuration", +] + + +@pytest.fixture +def panel() -> GUISequencerBrowserPanel: + """Builds a browser panel without its DearPyGui-dependent constructor. + + Resolving a node's detail items reads only the language-resolved detail labels, so the pieces + the constructor would build around a running GUI context are unnecessary here. A concrete + browser stands in for the base because the configuration font is a browser-level opt-in. + """ + instance = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) + instance._language_manager = FakeLanguageManager() + for label in DETAIL_LABELS: + setattr(instance, f"_lbl_detail_{label}", label) + + return instance + + +def config_directory_node() -> ConfigNode: + return ConfigNode( + CONFIG_FIELDS.gn, + node_type=NodeType.DIRECTORY, + filepath=CONFIG_DIRECTORY, + config=CONFIG_FIELDS, + ) + + +def config_variant_node() -> ConfigNode: + return ConfigNode( + CONFIG_FIELDS.display_name, + node_type=NodeType.FILE, + filepath=RECONSTRUCTION_PATH, + config=CONFIG_FIELDS, + ) + + +class TestConfigDetailItems: + def test_config_directory_states_its_configuration( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + items = dict(panel._node_detail_items(config_directory_node())) + assert items["sample_rate"] == format_sample_rate(CONFIG_FIELDS.sr) + assert items["configuration"] == short_hash(CONFIG_FIELDS.ch) + + def test_config_variant_leaf_states_the_same_configuration( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + """A reconstruction listed by its configuration answers with that configuration. + + In the sample view a leaf carries the configuration its directory names, which its own + filename says nothing about. + """ + assert panel._node_detail_items(config_variant_node()) == panel._node_detail_items(config_directory_node()) + + def test_config_variant_leaf_reads_in_the_configuration_font( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + assert panel._resolve_node_name_font(config_variant_node()) == Font.MONO_SMALL + + def test_plain_directory_states_nothing( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + node = FileSystemNode( + "my_songs", + node_type=NodeType.DIRECTORY, + filepath=Path("/reconstructions/my_songs"), + ) + assert panel._node_detail_items(node) == [] + assert panel._resolve_node_name_font(node) == Font.REGULAR_SMALL + + def test_group_states_nothing( + self, + panel: GUISequencerBrowserPanel, + ) -> None: + assert panel._node_detail_items(TreeNode("Samples", NodeType.GROUP)) == [] diff --git a/tests/unit/sampletones_core/structures/tree/test_factory.py b/tests/unit/sampletones_core/structures/tree/test_factory.py new file mode 100644 index 000000000..21af2ec25 --- /dev/null +++ b/tests/unit/sampletones_core/structures/tree/test_factory.py @@ -0,0 +1,40 @@ +from pathlib import Path + +from sampletones_core.configs import Config +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree.factory import create_directory_node +from sampletones_core.structures.tree.node import ConfigNode, FileSystemNode, TreeNode +from sampletones_core.structures.tree.type import NodeType + +CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config()) +RECONSTRUCTIONS_DIRECTORY = Path("/reconstructions") + + +class TestCreateDirectoryNode: + def test_config_directory_becomes_a_config_node(self) -> None: + directory = RECONSTRUCTIONS_DIRECTORY / CONFIG_FIELDS.directory_name + node = create_directory_node(directory, name=directory.name, parent=None) + assert isinstance(node, ConfigNode) + assert node.config == CONFIG_FIELDS + + def test_plain_directory_becomes_a_file_system_node(self) -> None: + directory = RECONSTRUCTIONS_DIRECTORY / "my_songs" + node = create_directory_node(directory, name=directory.name, parent=None) + assert isinstance(node, FileSystemNode) + assert not isinstance(node, ConfigNode) + + def test_node_carries_the_given_name_and_path(self) -> None: + directory = RECONSTRUCTIONS_DIRECTORY / CONFIG_FIELDS.directory_name + node = create_directory_node(directory, name="friendly", parent=None) + assert node.name == "friendly" + assert node.filepath == directory + assert node.node_type == NodeType.DIRECTORY + + def test_node_attaches_to_the_given_parent(self) -> None: + parent = TreeNode("root", NodeType.ROOT) + node = create_directory_node( + RECONSTRUCTIONS_DIRECTORY / "my_songs", + name="my_songs", + parent=parent, + ) + assert node.parent is parent diff --git a/tests/unit/sampletones_core/structures/tree/test_node.py b/tests/unit/sampletones_core/structures/tree/test_node.py index d9da8d531..2f91cc308 100644 --- a/tests/unit/sampletones_core/structures/tree/test_node.py +++ b/tests/unit/sampletones_core/structures/tree/test_node.py @@ -3,7 +3,9 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import LibraryGeneratorName from sampletones_core.library import InstructionLibraryKey +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree.node import ( + ConfigNode, FileSystemNode, GeneratorNode, LibraryNode, @@ -12,6 +14,7 @@ from sampletones_core.structures.tree.type import NodeType LIBRARY_KEY = InstructionLibraryKey.from_config(Config()) +CONFIG_FIELDS = ConfigDirectoryFields.from_config(Config()) class TestTreeNode: @@ -61,6 +64,43 @@ def test_copy_preserves_filepath_and_type(self) -> None: assert copied.node_type == NodeType.FILE +class TestConfigNode: + def test_config_is_stored(self) -> None: + node = ConfigNode( + "config", + NodeType.DIRECTORY, + filepath=Path("/reconstructions") / CONFIG_FIELDS.directory_name, + config=CONFIG_FIELDS, + ) + assert node.config == CONFIG_FIELDS + + def test_config_survives_a_filename_of_its_own(self) -> None: + node = ConfigNode( + "variant", + NodeType.FILE, + filepath=Path("/reconstructions") / CONFIG_FIELDS.directory_name / "song.stn", + config=CONFIG_FIELDS, + ) + assert node.config == CONFIG_FIELDS + + def test_copy_preserves_config_filepath_and_type(self) -> None: + path = Path("/reconstructions") / CONFIG_FIELDS.directory_name + node = ConfigNode("config", NodeType.DIRECTORY, filepath=path, config=CONFIG_FIELDS) + copied = node.copy() + assert copied.config == CONFIG_FIELDS + assert copied.filepath == path + assert copied.node_type == NodeType.DIRECTORY + + def test_node_is_a_file_system_node(self) -> None: + node = ConfigNode( + "config", + NodeType.DIRECTORY, + filepath=Path("/reconstructions") / CONFIG_FIELDS.directory_name, + config=CONFIG_FIELDS, + ) + assert isinstance(node, FileSystemNode) + + class TestLibraryNode: def test_library_key_is_stored(self) -> None: node = LibraryNode("lib", library_key=LIBRARY_KEY) From 155dbd8debb1b479b5ca68899b7832500cc96f9e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 09:37:17 +0200 Subject: [PATCH 114/152] Extracted: the unique sibling label rule --- .../logic/reconstruction/browser_manager.py | 72 +++++++++---------- src/sampletones_core/configs/display.py | 17 ++++- .../sampletones_core/configs/test_display.py | 46 ++++++++++++ 3 files changed, 94 insertions(+), 41 deletions(-) diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py index 6ff474416..860eebe47 100644 --- a/src/sampletones_application/logic/reconstruction/browser_manager.py +++ b/src/sampletones_application/logic/reconstruction/browser_manager.py @@ -1,15 +1,15 @@ from pathlib import Path -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Sequence, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager from sampletones_core.configs.display import ( DISPLAY_SEPARATOR, GAMMA_PREFIX, - disambiguated_display_name, format_nes_frequency, format_sample_rate, format_spectrum_method, + unique_display_names, ) from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields @@ -165,19 +165,11 @@ def _find_or_create_group_node( return TreeNode(name, node_type=NodeType.GROUP, parent=parent) def _disambiguate_generator_siblings(self, node: TreeNode) -> None: - """Appends a short config hash to generator leaves that share a name under one method group.""" + """Appends a short config hash to generator directories sharing a name under one method group.""" if node.node_type == NodeType.GROUP: - by_name: Dict[str, List[ConfigNode]] = {} - for child in node.children: - if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY: - by_name.setdefault(child.name, []).append(child) - - for name, members in by_name.items(): - if len(members) <= 1: - continue - - for directory_node in members: - directory_node.name = disambiguated_display_name(name, directory_node.config.ch) + self._rename_config_directories( + [(directory_node, directory_node.config.gn) for directory_node in self._config_directory_children(node)] + ) for child in node.children: self._disambiguate_generator_siblings(child) @@ -194,20 +186,25 @@ def _assign_directory_display_names(self, node: TreeNode) -> None: self._assign_directory_display_names(child) def _rename_config_directory_children(self, node: TreeNode) -> None: - groups: Dict[str, List[ConfigNode]] = {} - for child in node.children: - if not isinstance(child, ConfigNode) or child.node_type != NodeType.DIRECTORY: - continue - - groups.setdefault(child.config.display_name, []).append(child) + self._rename_config_directories( + [ + (directory_node, directory_node.config.display_name) + for directory_node in self._config_directory_children(node) + ] + ) - for display_name, members in groups.items(): - if len(members) == 1: - members[0].name = display_name - continue + @staticmethod + def _config_directory_children(node: TreeNode) -> List[ConfigNode]: + return [ + child for child in node.children if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY + ] - for directory_node in members: - directory_node.name = disambiguated_display_name(display_name, directory_node.config.ch) + @staticmethod + def _rename_config_directories(entries: Sequence[Tuple[ConfigNode, str]]) -> None: + """Names each configuration directory, marking those a sibling would otherwise shadow.""" + labels = unique_display_names([(name, directory_node.config.ch) for directory_node, name in entries]) + for (directory_node, _), label in zip(entries, labels): + directory_node.name = label def _build_samples_children(self, samples_node: TreeNode) -> None: """Populates the transposed Samples branch: source-audio directories ▶ audio ▶ config variants.""" @@ -240,20 +237,15 @@ def _append_config_variants( audio_node: TreeNode, variants: List[Tuple[ConfigDirectoryFields, Path]], ) -> None: - variants_by_display_name: Dict[str, List[Tuple[ConfigDirectoryFields, Path]]] = {} - for fields, reconstruction_path in variants: - variants_by_display_name.setdefault(fields.display_name, []).append((fields, reconstruction_path)) - - for display_name, members in variants_by_display_name.items(): - for fields, reconstruction_path in members: - label = display_name if len(members) == 1 else disambiguated_display_name(display_name, fields.ch) - ConfigNode( - label, - node_type=NodeType.FILE, - filepath=reconstruction_path, - config=fields, - parent=audio_node, - ) + labels = unique_display_names([(fields.display_name, fields.ch) for fields, _ in variants]) + for (fields, reconstruction_path), label in zip(variants, labels): + ConfigNode( + label, + node_type=NodeType.FILE, + filepath=reconstruction_path, + config=fields, + parent=audio_node, + ) def get_all_reconstruction_files(self) -> List[Path]: file_paths = { diff --git a/src/sampletones_core/configs/display.py b/src/sampletones_core/configs/display.py index e5b5ab78e..02bcb9bf8 100644 --- a/src/sampletones_core/configs/display.py +++ b/src/sampletones_core/configs/display.py @@ -1,4 +1,5 @@ -from typing import Dict, Final +from collections import Counter +from typing import Dict, Final, Sequence, Tuple from sampletones_core.constants.enums import SpectrumMethod from sampletones_shared.constants.symbols import HASH @@ -45,3 +46,17 @@ def short_hash(config_hash: str) -> str: def disambiguated_display_name(name: str, config_hash: str) -> str: """Appends the short config hash, marked with ``#``, so colliding names stay distinct.""" return f"{name}{DISPLAY_SEPARATOR}{HASH}{short_hash(config_hash)}" + + +def unique_display_names(entries: Sequence[Tuple[str, str]]) -> Tuple[str, ...]: + """Answers labels that tell one group of siblings apart, given ``(name, config hash)`` pairs. + + A name held by a single entry stands as it is. A name shared by several entries takes the short + config hash on every one of them, so each sibling states the configuration that distinguishes + it. The answer is index-aligned with ``entries``. + """ + occurrences = Counter(name for name, _ in entries) + return tuple( + name if occurrences[name] == 1 else disambiguated_display_name(name, config_hash) + for name, config_hash in entries + ) diff --git a/tests/unit/sampletones_core/configs/test_display.py b/tests/unit/sampletones_core/configs/test_display.py index 29b35d487..365c70ce3 100644 --- a/tests/unit/sampletones_core/configs/test_display.py +++ b/tests/unit/sampletones_core/configs/test_display.py @@ -1,10 +1,15 @@ +from typing import List, Tuple + import pytest from sampletones_core.configs.display import ( DISPLAY_HASH_LENGTH, + DISPLAY_SEPARATOR, + disambiguated_display_name, format_nes_frequency, format_sample_rate, short_hash, + unique_display_names, ) @@ -33,3 +38,44 @@ def test_truncates_to_display_length(self) -> None: full = "6edf7c948606917a78b45d153c7ca7e0" assert short_hash(full) == full[:DISPLAY_HASH_LENGTH] assert len(short_hash(full)) == DISPLAY_HASH_LENGTH + + +class TestUniqueDisplayNames: + @pytest.mark.parametrize( + "entries, expected", + [ + ([], []), + ([("PTN", "aaaa1111")], ["PTN"]), + ([("PTN", "aaaa1111"), ("PN", "bbbb2222")], ["PTN", "PN"]), + ( + [("PTN", "aaaa1111"), ("PTN", "bbbb2222")], + [ + disambiguated_display_name("PTN", "aaaa1111"), + disambiguated_display_name("PTN", "bbbb2222"), + ], + ), + ( + [("PTN", "aaaa1111"), ("PN", "bbbb2222"), ("PTN", "cccc3333")], + [ + disambiguated_display_name("PTN", "aaaa1111"), + "PN", + disambiguated_display_name("PTN", "cccc3333"), + ], + ), + ], + ) + def test_marks_only_the_shared_names( + self, + entries: List[Tuple[str, str]], + expected: List[str], + ) -> None: + assert unique_display_names(entries) == tuple(expected) + + def test_keeps_the_given_order(self) -> None: + entries = [("second", "aaaa1111"), ("first", "bbbb2222"), ("second", "cccc3333")] + names = unique_display_names(entries) + assert [name.split(DISPLAY_SEPARATOR)[0] for name in names] == ["second", "first", "second"] + + def test_names_stay_distinct(self) -> None: + entries = [("PTN", "aaaa1111"), ("PTN", "bbbb2222"), ("PTN", "cccc3333")] + assert len(set(unique_display_names(entries))) == len(entries) From 7966af31c7f93e79b217372e934f8a5f9689c9d1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 11:09:42 +0200 Subject: [PATCH 115/152] Refactored: reconstruction browser tree --- src/sampletones_application/application.py | 2 +- .../coordinators/tabs/reconstruction.py | 4 +- .../coordinators/tabs/sequencer.py | 2 +- .../logic/main/explorer_manager.py | 3 + .../logic/reconstruction/browser/__init__.py | 0 .../{browser.py => browser/logic.py} | 2 +- .../logic/reconstruction/browser/manager.py | 73 +++ .../reconstruction/browser/tree/__init__.py | 0 .../browser/tree/configurations.py | 151 ++++++ .../browser/tree/entries/__init__.py | 0 .../browser/tree/entries/directory.py | 29 ++ .../browser/tree/entries/reconstruction.py | 13 + .../browser/tree/entries/scan.py | 41 ++ .../reconstruction/browser/tree/group.py | 15 + .../browser/tree/samples/__init__.py | 0 .../browser/tree/samples/branch.py | 35 ++ .../browser/tree/samples/source.py | 14 + .../browser/tree/samples/variant.py | 14 + .../browser/tree/samples/variants.py | 50 ++ .../logic/reconstruction/browser/tree/scan.py | 49 ++ .../logic/reconstruction/browser_manager.py | 256 ---------- .../logic/sequencer/browser.py | 2 +- .../structures/tree/factory.py | 13 +- .../logic/reconstruction/browser/__init__.py | 0 .../logic/reconstruction/browser/conftest.py | 134 ++++++ .../browser/test_configurations.py | 164 +++++++ .../reconstruction/browser/test_manager.py | 167 +++++++ .../reconstruction/browser/test_samples.py | 113 +++++ .../logic/reconstruction/browser/test_scan.py | 110 +++++ .../reconstruction/test_browser_manager.py | 437 ------------------ .../structures/tree/test_factory.py | 26 +- 31 files changed, 1209 insertions(+), 710 deletions(-) create mode 100644 src/sampletones_application/logic/reconstruction/browser/__init__.py rename src/sampletones_application/logic/reconstruction/{browser.py => browser/logic.py} (93%) create mode 100644 src/sampletones_application/logic/reconstruction/browser/manager.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/__init__.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/configurations.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/entries/__init__.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/group.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/samples/__init__.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/samples/variant.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/samples/variants.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/scan.py delete mode 100644 src/sampletones_application/logic/reconstruction/browser_manager.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/__init__.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_scan.py delete mode 100644 tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index ede3f176c..680c85369 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -46,7 +46,7 @@ ReconstructionTitlePart, document_title, ) -from sampletones_application.logic.reconstruction.browser_manager import BrowserManager +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.logic.reconstruction.manager import ReconstructionManager from sampletones_application.logic.render import SongRenderLogic from sampletones_application.parameters import ( diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 5053f44c8..9d343ade6 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -15,8 +15,8 @@ from sampletones_application.coordinators.original_audio import OriginalAudioLocator from sampletones_application.coordinators.playback.guard import GuardedPlayer from sampletones_application.coordinators.playback.protocol import AudioPlayerProtocol -from sampletones_application.logic.reconstruction.browser import BrowserLogic -from sampletones_application.logic.reconstruction.browser_manager import BrowserManager +from sampletones_application.logic.reconstruction.browser.logic import BrowserLogic +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.logic.reconstruction.instruments import ( OnReconstructionInstrumentUpdatedCallback, ReconstructionInstrumentsLogic, diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 5b7f7f4e9..ea2b13b8a 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -19,7 +19,7 @@ from sampletones_application.logic.history.manager import HistoryManager from sampletones_application.logic.history.transaction import CoalesceKey from sampletones_application.logic.project.controller import ProjectController -from sampletones_application.logic.reconstruction.browser_manager import BrowserManager +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.logic.sequencer.browser import SequencerBrowserLogic from sampletones_application.logic.sequencer.channels import SequencerChannelsLogic from sampletones_application.logic.sequencer.clipboard import ( diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py index 11824e234..5a66fe402 100644 --- a/src/sampletones_application/logic/main/explorer_manager.py +++ b/src/sampletones_application/logic/main/explorer_manager.py @@ -8,6 +8,7 @@ EXT_FILE_RECONSTRUCTION, EXT_FILES_AUDIO, ) +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -60,6 +61,7 @@ def _create_directory_node( node = create_directory_node( directory_path, name=directory_path.name or str(directory_path), + config=ConfigDirectoryFields.from_directory_name(directory_path.name), parent=parent, ) @@ -96,6 +98,7 @@ def _load_directory_children( child_node = create_directory_node( entry_path, name=entry_path.name, + config=ConfigDirectoryFields.from_directory_name(entry_path.name), parent=directory_node, ) if level < self.depth: diff --git a/src/sampletones_application/logic/reconstruction/browser/__init__.py b/src/sampletones_application/logic/reconstruction/browser/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/reconstruction/browser.py b/src/sampletones_application/logic/reconstruction/browser/logic.py similarity index 93% rename from src/sampletones_application/logic/reconstruction/browser.py rename to src/sampletones_application/logic/reconstruction/browser/logic.py index 9e1550f57..4b512b023 100644 --- a/src/sampletones_application/logic/reconstruction/browser.py +++ b/src/sampletones_application/logic/reconstruction/browser/logic.py @@ -1,7 +1,7 @@ from pathlib import Path from sampletones_application.config.managers.config import ConfigManager -from sampletones_application.logic.reconstruction.browser_manager import BrowserManager +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_core.structures.tree import Tree from sampletones_shared.utils.system.filesystem import remove_path diff --git a/src/sampletones_application/logic/reconstruction/browser/manager.py b/src/sampletones_application/logic/reconstruction/browser/manager.py new file mode 100644 index 000000000..3844efa6c --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/manager.py @@ -0,0 +1,73 @@ +from pathlib import Path +from typing import List + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.logic.reconstruction.browser.tree.configurations import ( + build_configuration_branch, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, +) +from sampletones_application.logic.reconstruction.browser.tree.samples.branch import ( + build_sample_branch, +) +from sampletones_application.logic.reconstruction.browser.tree.scan import ( + scan_reconstructions, +) +from sampletones_core.structures.tree import NodeType, Tree, TreeNode + + +class BrowserManager: + """Owns the reconstruction browser tree, rebuilt from one reading of the reconstructions directory. + + A refresh scans the directory, builds the configuration branch and the sample branch from that + one reading, and publishes the result as the tree both browser tabs render. + """ + + def __init__( + self, + config_manager: ConfigManager, + *, + language_manager: LanguageManager, + ) -> None: + self._language_manager = language_manager + self.config_manager = config_manager + self.reconstructions_directory = config_manager.get_reconstructions_directory() + + self.tree = Tree() + self._scan = ReconstructionScan(entries=()) + + def set_reconstructions_directory(self, directory: Path) -> None: + self.reconstructions_directory = directory + self.refresh_tree() + + def refresh_tree(self) -> None: + if not self.reconstructions_directory.is_dir(): + self._scan = ReconstructionScan(entries=()) + self.tree.set_root(None) + return + + self._scan = scan_reconstructions(self.reconstructions_directory) + self.tree.set_root(self._build_root(self._scan)) + + def _build_root(self, scan: ReconstructionScan) -> TreeNode: + container_root = TreeNode( + name=self._language_manager["global.browser.label.root"], + node_type=NodeType.ROOT, + ) + build_configuration_branch( + scan, + name=self._language_manager["global.browser.label.reconstructions"], + parent=container_root, + ) + build_sample_branch( + scan, + name=self._language_manager["global.browser.label.samples"], + parent=container_root, + ) + + return container_root + + def get_all_reconstruction_files(self) -> List[Path]: + return sorted({entry.path for entry in self._scan.reconstructions}) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py new file mode 100644 index 000000000..f38d85ade --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py @@ -0,0 +1,151 @@ +from typing import List, Sequence, Tuple + +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, + ScanEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.group import ( + find_or_create_group, +) +from sampletones_core.configs.display import ( + DISPLAY_SEPARATOR, + GAMMA_PREFIX, + format_nes_frequency, + format_sample_rate, + format_spectrum_method, + unique_display_names, +) +from sampletones_core.structures.tree import ( + ConfigNode, + FileSystemNode, + NodeType, + TreeNode, + create_directory_node, +) + + +def build_configuration_branch( + scan: ReconstructionScan, + *, + name: str, + parent: TreeNode, +) -> TreeNode: + """Builds the branch listing reconstructions by the configuration that produced them. + + The scanned folders appear as they sit on disk, and a top-level configuration directory is then + lifted under frequency ▶ method groups and named by its generators, so configurations sharing a + spectrum read side by side. A configuration directory nested inside a plain folder keeps its + friendly name in place, and a reconstruction outside every configuration directory is listed + here, this being the branch that follows the disk. + """ + branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent) + for entry in scan.entries: + _append_entry(entry, parent=branch) + + _organize_top_level_config_directories(branch) + return branch + + +def _append_entry(entry: ScanEntry, *, parent: TreeNode) -> None: + match entry: + case ReconstructionEntry(): + FileSystemNode( + entry.name, + node_type=NodeType.FILE, + filepath=entry.path, + parent=parent, + ) + case DirectoryEntry(): + directory_node = create_directory_node( + entry.path, + name=entry.name, + config=entry.config, + parent=parent, + ) + for child_entry in entry.entries: + _append_entry(child_entry, parent=directory_node) + + +def _organize_top_level_config_directories(branch: TreeNode) -> None: + """Groups top-level config directories under frequencies/method nodes, leaving other folders flat. + + A config directory moves under ``frequencies`` ▶ ``method`` artificial group nodes and is + renamed to its generator abbreviation, while any other top-level folder keeps the existing + flat friendly naming for the config directories nested inside it. + """ + for child in list(branch.children): + match child: + case ConfigNode() if child.node_type == NodeType.DIRECTORY: + _attach_config_directory_under_groups(child, branch) + case FileSystemNode() if child.node_type == NodeType.DIRECTORY: + _assign_directory_display_names(child) + + _disambiguate_generator_siblings(branch) + + +def _attach_config_directory_under_groups( + directory_node: ConfigNode, + branch: TreeNode, +) -> None: + fields = directory_node.config + frequencies_name = DISPLAY_SEPARATOR.join( + [ + format_sample_rate(fields.sr), + format_nes_frequency(fields.nf), + ] + ) + method_name = DISPLAY_SEPARATOR.join( + [ + format_spectrum_method(fields.sm), + f"{GAMMA_PREFIX}{fields.tg}", + ] + ) + frequencies_node = find_or_create_group(frequencies_name, parent=branch) + method_node = find_or_create_group(method_name, parent=frequencies_node) + + directory_node.name = fields.gn + directory_node.parent = method_node + + +def _disambiguate_generator_siblings(node: TreeNode) -> None: + """Appends a short config hash to generator directories sharing a name under one method group.""" + if node.node_type == NodeType.GROUP: + _rename_config_directories( + [(directory_node, directory_node.config.gn) for directory_node in _config_directory_children(node)] + ) + + for child in node.children: + _disambiguate_generator_siblings(child) + + +def _assign_directory_display_names(node: TreeNode) -> None: + """Renames config-directory nodes to friendly labels, disambiguating colliding siblings. + + Only directories whose names parse as reconstruction config directories are rewritten; + plain folders keep their on-disk name. The check is scoped per parent because duplicate + display names among siblings would otherwise collapse to duplicate widget tags downstream. + """ + _rename_config_directories( + [(directory_node, directory_node.config.display_name) for directory_node in _config_directory_children(node)] + ) + for child in node.children: + _assign_directory_display_names(child) + + +def _config_directory_children(node: TreeNode) -> List[ConfigNode]: + return [child for child in node.children if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY] + + +def _rename_config_directories( + proposed_names: Sequence[Tuple[ConfigNode, str]], +) -> None: + """Names each configuration directory, marking those a sibling would otherwise shadow.""" + labels = unique_display_names([(name, directory_node.config.ch) for directory_node, name in proposed_names]) + for (directory_node, _), label in zip(proposed_names, labels): + directory_node.name = label diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py new file mode 100644 index 000000000..582e6e86d --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py @@ -0,0 +1,29 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Optional, Tuple + +from sampletones_core.reconstructions.converter.paths.fields import ( + ConfigDirectoryFields, +) + +if TYPE_CHECKING: + from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ScanEntry, + ) + + +@dataclass(frozen=True) +class DirectoryEntry: + """A folder a scan met, holding the configuration its name states and the entries inside it. + + A folder whose name encodes a reconstruction configuration carries those fields, read once here, + so every branch builder states the configuration from the record it already has. + """ + + path: Path + config: Optional[ConfigDirectoryFields] + entries: Tuple["ScanEntry", ...] + + @property + def name(self) -> str: + return self.path.name diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py new file mode 100644 index 000000000..ba4b4ce1e --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/reconstruction.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass +from pathlib import Path + + +@dataclass(frozen=True) +class ReconstructionEntry: + """A reconstruction file a scan met, named by the audio it reconstructs.""" + + path: Path + + @property + def name(self) -> str: + return self.path.stem diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py new file mode 100644 index 000000000..2cc3edc5e --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from typing import List, Sequence, Tuple, TypeAlias, Union + +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) + +ScanEntry: TypeAlias = Union[DirectoryEntry, ReconstructionEntry] + + +@dataclass(frozen=True) +class ReconstructionScan: + """One reading of a reconstructions directory, shared by every browser branch. + + Both branches describe the same disk because both describe this record: the configuration view + follows the entries as they sit, and the sample view regroups them by the audio they came from. + """ + + entries: Tuple[ScanEntry, ...] + + @property + def reconstructions(self) -> Tuple[ReconstructionEntry, ...]: + return self.collect_reconstructions(self.entries) + + @staticmethod + def collect_reconstructions( + entries: Sequence[ScanEntry], + ) -> Tuple[ReconstructionEntry, ...]: + """Flattens scanned entries into the reconstructions they hold, in the order the scan met them.""" + collected: List[ReconstructionEntry] = [] + for entry in entries: + match entry: + case ReconstructionEntry(): + collected.append(entry) + case DirectoryEntry(): + collected.extend(ReconstructionScan.collect_reconstructions(entry.entries)) + + return tuple(collected) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/group.py b/src/sampletones_application/logic/reconstruction/browser/tree/group.py new file mode 100644 index 000000000..7b6557cec --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/group.py @@ -0,0 +1,15 @@ +from sampletones_core.structures.tree import NodeType, TreeNode + + +def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode: + """Answers the group of this name under ``parent``, adding one where the parent holds none. + + A group stands for something the disk states rather than holds — a frequency pair, a spectrum + method, a source folder — so it is identified by its name and a builder meeting that name again + extends the group it already made. + """ + for child in parent.children: + if isinstance(child, TreeNode) and child.node_type == NodeType.GROUP and child.name == name: + return child + + return TreeNode(name, node_type=NodeType.GROUP, parent=parent) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py new file mode 100644 index 000000000..8baec53df --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py @@ -0,0 +1,35 @@ +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, +) +from sampletones_application.logic.reconstruction.browser.tree.group import ( + find_or_create_group, +) +from sampletones_application.logic.reconstruction.browser.tree.samples.variants import ( + append_variants, + collect_variants, +) +from sampletones_core.structures.tree import NodeType, TreeNode + + +def build_sample_branch( + scan: ReconstructionScan, + *, + name: str, + parent: TreeNode, +) -> TreeNode: + """Builds the branch listing each source audio with the configurations that reconstructed it. + + Every top-level configuration directory contributes its reconstructions under the source folders + they mirror, so one audio gathers its variants and each variant is labelled by its configuration. + """ + branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent) + variants_by_source = collect_variants(scan) + for source in sorted(variants_by_source): + source_node = branch + for part in source.directory_parts: + source_node = find_or_create_group(part, parent=source_node) + + audio_node = find_or_create_group(source.name, parent=source_node) + append_variants(audio_node, variants_by_source[source]) + + return branch diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py new file mode 100644 index 000000000..f6ce78116 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/source.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from typing import Tuple + + +@dataclass(frozen=True, order=True) +class SampleSource: + """The audio a set of reconstructions was made from, as its folder and name within a configuration. + + Two configuration directories reconstructing one audio file mirror the same source subtree, so + the relative folder and the audio name together gather the variants of that audio. + """ + + directory_parts: Tuple[str, ...] + name: str diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/variant.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/variant.py new file mode 100644 index 000000000..15960a450 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/variant.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from pathlib import Path + +from sampletones_core.reconstructions.converter.paths.fields import ( + ConfigDirectoryFields, +) + + +@dataclass(frozen=True) +class SampleVariant: + """One reconstruction of a source audio, with the configuration that produced it.""" + + config: ConfigDirectoryFields + path: Path diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/variants.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/variants.py new file mode 100644 index 000000000..79a4002d0 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/variants.py @@ -0,0 +1,50 @@ +from typing import Dict, List, Sequence + +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, +) +from sampletones_application.logic.reconstruction.browser.tree.samples.source import ( + SampleSource, +) +from sampletones_application.logic.reconstruction.browser.tree.samples.variant import ( + SampleVariant, +) +from sampletones_core.configs.display import unique_display_names +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree import ConfigNode, NodeType, TreeNode + + +def collect_variants(scan: ReconstructionScan) -> Dict[SampleSource, List[SampleVariant]]: + variants_by_source: Dict[SampleSource, List[SampleVariant]] = {} + for entry in scan.entries: + match entry: + case DirectoryEntry(config=ConfigDirectoryFields() as config): + for reconstruction in scan.collect_reconstructions(entry.entries): + relative_path = reconstruction.path.relative_to(entry.path) + source = SampleSource( + directory_parts=relative_path.parent.parts, + name=relative_path.stem, + ) + variants_by_source.setdefault(source, []).append( + SampleVariant(config=config, path=reconstruction.path) + ) + + return variants_by_source + + +def append_variants( + audio_node: TreeNode, + variants: Sequence[SampleVariant], +) -> None: + labels = unique_display_names([(variant.config.display_name, variant.config.ch) for variant in variants]) + for variant, label in zip(variants, labels): + ConfigNode( + label, + node_type=NodeType.FILE, + filepath=variant.path, + config=variant.config, + parent=audio_node, + ) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py new file mode 100644 index 000000000..fcc7133c5 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py @@ -0,0 +1,49 @@ +from pathlib import Path +from typing import List, Optional, Tuple + +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, + ScanEntry, +) +from sampletones_core.paths import EXT_FILE_RECONSTRUCTION +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields + + +def scan_reconstructions(directory: Path) -> ReconstructionScan: + """Reads a reconstructions directory once, recording its folders and the reconstructions inside. + + Every folder is recorded together with the configuration its name states, and every + reconstruction file beneath it. This single reading feeds both browser branches, so the two + views agree on what is on disk. + """ + return ReconstructionScan(entries=_scan_entries(directory)) + + +def _scan_entries(directory: Path) -> Tuple[ScanEntry, ...]: + entries: List[ScanEntry] = [] + for path in sorted(directory.iterdir()): + entry = _scan_path(path) + if entry is not None: + entries.append(entry) + + return tuple(entries) + + +def _scan_path(path: Path) -> Optional[ScanEntry]: + if path.is_dir(): + return DirectoryEntry( + path=path, + config=ConfigDirectoryFields.from_directory_name(path.name), + entries=_scan_entries(path), + ) + + if path.suffix == EXT_FILE_RECONSTRUCTION: + return ReconstructionEntry(path=path) + + return None diff --git a/src/sampletones_application/logic/reconstruction/browser_manager.py b/src/sampletones_application/logic/reconstruction/browser_manager.py deleted file mode 100644 index 860eebe47..000000000 --- a/src/sampletones_application/logic/reconstruction/browser_manager.py +++ /dev/null @@ -1,256 +0,0 @@ -from pathlib import Path -from typing import Dict, List, Optional, Sequence, Tuple - -from sampletones_application.categories.manager import LanguageManager -from sampletones_application.config.managers.config import ConfigManager -from sampletones_core.configs.display import ( - DISPLAY_SEPARATOR, - GAMMA_PREFIX, - format_nes_frequency, - format_sample_rate, - format_spectrum_method, - unique_display_names, -) -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION -from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields -from sampletones_core.structures.tree import ( - ConfigNode, - FileSystemNode, - NodeType, - Tree, - TreeNode, - create_directory_node, -) - - -class BrowserManager: - def __init__( - self, - config_manager: ConfigManager, - *, - language_manager: LanguageManager, - ) -> None: - self._language_manager = language_manager - self.config_manager = config_manager - self.reconstructions_directory = config_manager.get_reconstructions_directory() - - self.tree = Tree() - - def set_reconstructions_directory(self, directory: Path) -> None: - self.reconstructions_directory = directory - self.refresh_tree() - - def refresh_tree(self) -> None: - if not self.reconstructions_directory.exists() or not self.reconstructions_directory.is_dir(): - self.tree.set_root(None) - return - - container_root = TreeNode( - name=self._language_manager["global.browser.label.root"], - node_type=NodeType.ROOT, - ) - reconstructions_node = TreeNode( - name=self._language_manager["global.browser.label.reconstructions"], - node_type=NodeType.GROUP, - parent=container_root, - ) - samples_node = TreeNode( - name=self._language_manager["global.browser.label.samples"], - node_type=NodeType.GROUP, - parent=container_root, - ) - - for path in sorted(self.reconstructions_directory.iterdir()): - self._build_tree(path, parent=reconstructions_node) - - self._organize_top_level_config_directories(reconstructions_node) - self._build_samples_children(samples_node) - self.tree.set_root(container_root) - - def _build_tree( - self, - path: Path, - parent: Optional[TreeNode] = None, - ) -> Optional[FileSystemNode]: - if not path.exists(): - return None - - if path.is_file(): - if path.suffix == EXT_FILE_RECONSTRUCTION: - return FileSystemNode( - path.stem, - filepath=path, - node_type=NodeType.FILE, - parent=parent, - ) - return None - - children_nodes = [] - for child_path in sorted(path.iterdir()): - child_node = self._build_tree(child_path, parent=parent) - if child_node is not None: - children_nodes.append(child_node) - - directory_node = create_directory_node( - path, - name=path.name, - parent=parent, - ) - for child_node in children_nodes: - child_node.parent = directory_node - - return directory_node - - def _organize_top_level_config_directories( - self, - reconstructions_node: TreeNode, - ) -> None: - """Groups top-level config directories under frequencies/method nodes, leaving other folders flat. - - A config directory moves under ``frequencies`` ▶ ``method`` artificial group nodes and is - renamed to its generator abbreviation, while any other top-level folder keeps the existing - flat friendly naming for the config directories nested inside it. - """ - for child in list(reconstructions_node.children): - match child: - case ConfigNode() if child.node_type == NodeType.DIRECTORY: - self._attach_config_directory_under_groups( - child, - reconstructions_node, - ) - case FileSystemNode() if child.node_type == NodeType.DIRECTORY: - self._assign_directory_display_names(child) - - self._disambiguate_generator_siblings(reconstructions_node) - - def _attach_config_directory_under_groups( - self, - directory_node: ConfigNode, - reconstructions_node: TreeNode, - ) -> None: - fields = directory_node.config - frequencies_name = DISPLAY_SEPARATOR.join( - [ - format_sample_rate(fields.sr), - format_nes_frequency(fields.nf), - ] - ) - method_name = DISPLAY_SEPARATOR.join( - [ - format_spectrum_method(fields.sm), - f"{GAMMA_PREFIX}{fields.tg}", - ] - ) - frequencies_node = self._find_or_create_group_node( - frequencies_name, - reconstructions_node, - ) - method_node = self._find_or_create_group_node( - method_name, - frequencies_node, - ) - - directory_node.name = fields.gn - directory_node.parent = method_node - - def _find_or_create_group_node( - self, - name: str, - parent: TreeNode, - ) -> TreeNode: - for child in parent.children: - if isinstance(child, TreeNode) and child.node_type == NodeType.GROUP and child.name == name: - return child - - return TreeNode(name, node_type=NodeType.GROUP, parent=parent) - - def _disambiguate_generator_siblings(self, node: TreeNode) -> None: - """Appends a short config hash to generator directories sharing a name under one method group.""" - if node.node_type == NodeType.GROUP: - self._rename_config_directories( - [(directory_node, directory_node.config.gn) for directory_node in self._config_directory_children(node)] - ) - - for child in node.children: - self._disambiguate_generator_siblings(child) - - def _assign_directory_display_names(self, node: TreeNode) -> None: - """Renames config-directory nodes to friendly labels, disambiguating colliding siblings. - - Only directories whose names parse as reconstruction config directories are rewritten; - plain folders keep their on-disk name. The check is scoped per parent because duplicate - display names among siblings would otherwise collapse to duplicate widget tags downstream. - """ - self._rename_config_directory_children(node) - for child in node.children: - self._assign_directory_display_names(child) - - def _rename_config_directory_children(self, node: TreeNode) -> None: - self._rename_config_directories( - [ - (directory_node, directory_node.config.display_name) - for directory_node in self._config_directory_children(node) - ] - ) - - @staticmethod - def _config_directory_children(node: TreeNode) -> List[ConfigNode]: - return [ - child for child in node.children if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY - ] - - @staticmethod - def _rename_config_directories(entries: Sequence[Tuple[ConfigNode, str]]) -> None: - """Names each configuration directory, marking those a sibling would otherwise shadow.""" - labels = unique_display_names([(name, directory_node.config.ch) for directory_node, name in entries]) - for (directory_node, _), label in zip(entries, labels): - directory_node.name = label - - def _build_samples_children(self, samples_node: TreeNode) -> None: - """Populates the transposed Samples branch: source-audio directories ▶ audio ▶ config variants.""" - variants_by_audio: Dict[Tuple[Tuple[str, ...], str], List[Tuple[ConfigDirectoryFields, Path]]] = {} - - for config_directory in sorted(self.reconstructions_directory.iterdir()): - if not config_directory.is_dir(): - continue - - fields = ConfigDirectoryFields.from_directory_name(config_directory.name) - if fields is None: - continue - - for reconstruction_path in sorted(config_directory.rglob(f"*{EXT_FILE_RECONSTRUCTION}")): - relative = reconstruction_path.relative_to(config_directory) - audio_key = (relative.parent.parts, relative.stem) - variants_by_audio.setdefault(audio_key, []).append((fields, reconstruction_path)) - - for audio_key in sorted(variants_by_audio): - directory_parts, audio_name = audio_key - parent = samples_node - for part in directory_parts: - parent = self._find_or_create_group_node(part, parent) - - audio_node = self._find_or_create_group_node(audio_name, parent) - self._append_config_variants(audio_node, variants_by_audio[audio_key]) - - def _append_config_variants( - self, - audio_node: TreeNode, - variants: List[Tuple[ConfigDirectoryFields, Path]], - ) -> None: - labels = unique_display_names([(fields.display_name, fields.ch) for fields, _ in variants]) - for (fields, reconstruction_path), label in zip(variants, labels): - ConfigNode( - label, - node_type=NodeType.FILE, - filepath=reconstruction_path, - config=fields, - parent=audio_node, - ) - - def get_all_reconstruction_files(self) -> List[Path]: - file_paths = { - node.filepath - for node in self.tree.collect_leaves() - if isinstance(node, FileSystemNode) and node.node_type == NodeType.FILE - } - return sorted(file_paths) diff --git a/src/sampletones_application/logic/sequencer/browser.py b/src/sampletones_application/logic/sequencer/browser.py index 6e6ff1573..43fc35655 100644 --- a/src/sampletones_application/logic/sequencer/browser.py +++ b/src/sampletones_application/logic/sequencer/browser.py @@ -2,7 +2,7 @@ from sampletones_application.config.managers.config import ConfigManager from sampletones_application.logic.project.controller import ProjectController -from sampletones_application.logic.reconstruction.browser_manager import BrowserManager +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import Tree diff --git a/src/sampletones_core/structures/tree/factory.py b/src/sampletones_core/structures/tree/factory.py index 5ab2d6649..3b73a97f4 100644 --- a/src/sampletones_core/structures/tree/factory.py +++ b/src/sampletones_core/structures/tree/factory.py @@ -11,16 +11,17 @@ def create_directory_node( directory: Path, *, name: str, + config: Optional[ConfigDirectoryFields], parent: Optional[TreeNode], ) -> FileSystemNode: - """Builds the directory node that fits the folder, reading its configuration where it names one. + """Builds the directory node that fits the folder, given the configuration its name states. - A folder whose name parses as a reconstruction configuration directory becomes a - :class:`ConfigNode` carrying those fields; every other folder becomes a plain - :class:`FileSystemNode`. Routing every directory through here keeps the decision of which node - class carries a configuration in one place. + A folder stating a reconstruction configuration becomes a :class:`ConfigNode` carrying those + fields; a folder stating none becomes a plain :class:`FileSystemNode`. The caller states the + fields it read with :meth:`ConfigDirectoryFields.from_directory_name`, so a caller that already + read them — a scan of a reconstructions directory — reads each folder name once, and the choice + of node class stays here. """ - config = ConfigDirectoryFields.from_directory_name(directory.name) if config is None: return FileSystemNode( name, diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/__init__.py b/tests/unit/sampletones_application/logic/reconstruction/browser/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py new file mode 100644 index 000000000..a0b775846 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -0,0 +1,134 @@ +from pathlib import Path +from typing import Dict, Final +from unittest.mock import MagicMock + +import pytest + +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, + ScanEntry, +) +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.paths import EXT_FILE_RECONSTRUCTION +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode +from tests.suite.language import FakeLanguageManager + +HASH_A: Final[str] = "6edf7c948606917a78b45d153c7ca7e0" +HASH_B: Final[str] = "a1b2c3d4e5f60718293a4b5c6d7e8f90" + +RECONSTRUCTIONS: Final[Path] = Path("/reconstructions") +BRANCH_NAME: Final[str] = "branch" + +CONFIGURATION_BRANCH_KEY: Final[str] = "global.browser.label.reconstructions" +SAMPLE_BRANCH_KEY: Final[str] = "global.browser.label.samples" + + +def config_fields( + *, + sample_rate: int = 44100, + nes_frequency: int = 30, + spectrum_method: SpectrumMethod = SpectrumMethod.FFT, + transformation_gamma: int = 0, + generators: str = "PTN", + config_hash: str = HASH_A, +) -> ConfigDirectoryFields: + """Builds configuration fields, so a test states only the field whose effect it examines.""" + return ConfigDirectoryFields( + sr=sample_rate, + nf=nes_frequency, + sm=spectrum_method, + tg=transformation_gamma, + gn=generators, + ch=config_hash, + ) + + +def reconstruction_entry(directory: Path, *relative_parts: str) -> ReconstructionEntry: + return ReconstructionEntry(path=directory.joinpath(*relative_parts).with_suffix(EXT_FILE_RECONSTRUCTION)) + + +def config_entry(fields: ConfigDirectoryFields, *audio_names: str) -> DirectoryEntry: + """Records a configuration directory holding one reconstruction per stated audio name.""" + directory = RECONSTRUCTIONS / fields.directory_name + return DirectoryEntry( + path=directory, + config=fields, + entries=tuple(reconstruction_entry(directory, name) for name in audio_names), + ) + + +def plain_entry(name: str, *entries: ScanEntry) -> DirectoryEntry: + """Records a folder whose name states no configuration.""" + return DirectoryEntry(path=RECONSTRUCTIONS / name, config=None, entries=entries) + + +def scan_of(*entries: ScanEntry) -> ReconstructionScan: + return ReconstructionScan(entries=entries) + + +def config_directory(root: Path, fields: ConfigDirectoryFields) -> Path: + directory = root / fields.directory_name + directory.mkdir(parents=True, exist_ok=True) + return directory + + +def write_reconstruction(directory: Path, *relative_parts: str) -> Path: + """Creates an empty reconstruction file at the stated place, with the folders leading to it.""" + path = directory.joinpath(*relative_parts).with_suffix(EXT_FILE_RECONSTRUCTION) + path.parent.mkdir(parents=True, exist_ok=True) + path.touch() + return path + + +def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: + return { + child.name: child + for child in node.children + if isinstance(child, FileSystemNode) and child.node_type == NodeType.DIRECTORY + } + + +def file_children(node: TreeNode) -> Dict[str, FileSystemNode]: + return { + child.name: child + for child in node.children + if isinstance(child, FileSystemNode) and child.node_type == NodeType.FILE + } + + +def group_children(node: TreeNode) -> Dict[str, TreeNode]: + return {child.name: child for child in node.children if child.node_type == NodeType.GROUP} + + +def branch_of(browser_manager: BrowserManager, key: str) -> TreeNode: + root = browser_manager.tree.get_root() + assert root is not None + return group_children(root)[key] + + +def configuration_branch(browser_manager: BrowserManager) -> TreeNode: + return branch_of(browser_manager, CONFIGURATION_BRANCH_KEY) + + +def sample_branch(browser_manager: BrowserManager) -> TreeNode: + return branch_of(browser_manager, SAMPLE_BRANCH_KEY) + + +@pytest.fixture +def config_manager(tmp_path: Path) -> MagicMock: + mock = MagicMock() + mock.get_reconstructions_directory.return_value = tmp_path + return mock + + +@pytest.fixture +def browser_manager(config_manager: MagicMock) -> BrowserManager: + return BrowserManager(config_manager, language_manager=FakeLanguageManager()) # type: ignore[arg-type] diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py new file mode 100644 index 000000000..718dcf1a7 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py @@ -0,0 +1,164 @@ +from typing import Dict + +from sampletones_application.logic.reconstruction.browser.tree.configurations import ( + build_configuration_branch, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, +) +from sampletones_core.configs.display import ( + DISPLAY_SEPARATOR, + GAMMA_PREFIX, + disambiguated_display_name, + format_nes_frequency, + format_sample_rate, + format_spectrum_method, +) +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree import ( + ConfigNode, + FileSystemNode, + NodeType, + TreeNode, +) + +from .conftest import ( + BRANCH_NAME, + HASH_A, + HASH_B, + RECONSTRUCTIONS, + config_entry, + config_fields, + directory_children, + file_children, + group_children, + plain_entry, + reconstruction_entry, + scan_of, +) + + +def build_branch(scan: ReconstructionScan) -> TreeNode: + return build_configuration_branch( + scan, + name=BRANCH_NAME, + parent=TreeNode("Root", node_type=NodeType.ROOT), + ) + + +def frequencies_name(fields: ConfigDirectoryFields) -> str: + return DISPLAY_SEPARATOR.join([format_sample_rate(fields.sr), format_nes_frequency(fields.nf)]) + + +def method_name(fields: ConfigDirectoryFields) -> str: + return DISPLAY_SEPARATOR.join([format_spectrum_method(fields.sm), f"{GAMMA_PREFIX}{fields.tg}"]) + + +def generator_directories(branch: TreeNode, fields: ConfigDirectoryFields) -> Dict[str, FileSystemNode]: + frequencies_node = group_children(branch)[frequencies_name(fields)] + return directory_children(group_children(frequencies_node)[method_name(fields)]) + + +class TestTopLevelConfigDirectories: + def test_config_directory_groups_by_frequency_then_method(self) -> None: + fields = config_fields(generators="PpT") + branch = build_branch(scan_of(config_entry(fields, "song"))) + + frequencies = group_children(branch) + assert set(frequencies) == {frequencies_name(fields)} + + methods = group_children(frequencies[frequencies_name(fields)]) + assert set(methods) == {method_name(fields)} + + assert set(directory_children(methods[method_name(fields)])) == {fields.gn} + + def test_config_directory_keeps_its_reconstructions(self) -> None: + fields = config_fields() + entry = config_entry(fields, "song") + branch = build_branch(scan_of(entry)) + + directory_node = generator_directories(branch, fields)[fields.gn] + assert file_children(directory_node)["song"].filepath == entry.entries[0].path + + def test_config_directory_carries_its_parsed_configuration(self) -> None: + fields = config_fields() + branch = build_branch(scan_of(config_entry(fields, "song"))) + + directory_node = generator_directories(branch, fields)[fields.gn] + assert isinstance(directory_node, ConfigNode) + assert directory_node.config == fields + + def test_colliding_generators_get_a_hash_suffix(self) -> None: + first = config_fields(config_hash=HASH_A) + second = config_fields(config_hash=HASH_B) + branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) + + assert set(generator_directories(branch, first)) == { + disambiguated_display_name(first.gn, HASH_A), + disambiguated_display_name(second.gn, HASH_B), + } + + def test_distinct_generators_share_a_method_group_under_their_own_names(self) -> None: + first = config_fields(generators="PTN") + second = config_fields(generators="TN") + branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) + + assert set(generator_directories(branch, first)) == {"PTN", "TN"} + + def test_distinct_frequencies_form_separate_groups(self) -> None: + first = config_fields(sample_rate=44100, nes_frequency=30) + second = config_fields(sample_rate=48000, nes_frequency=60) + branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) + + assert set(group_children(branch)) == {frequencies_name(first), frequencies_name(second)} + + def test_distinct_methods_form_separate_groups(self) -> None: + first = config_fields(spectrum_method=SpectrumMethod.FFT) + second = config_fields(spectrum_method=SpectrumMethod.CQT) + branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) + + methods = group_children(group_children(branch)[frequencies_name(first)]) + assert set(methods) == {method_name(first), method_name(second)} + + +class TestPlainFolders: + def test_plain_folder_keeps_its_name_and_holds_its_reconstructions(self) -> None: + entry = plain_entry("my_songs", reconstruction_entry(RECONSTRUCTIONS / "my_songs", "song")) + branch = build_branch(scan_of(entry)) + + directory_node = directory_children(branch)["my_songs"] + assert set(file_children(directory_node)) == {"song"} + + def test_empty_folder_stays_in_place(self) -> None: + branch = build_branch(scan_of(plain_entry("empty"))) + + assert set(directory_children(branch)) == {"empty"} + + def test_nested_config_directory_takes_its_friendly_name(self) -> None: + fields = config_fields() + branch = build_branch(scan_of(plain_entry("my_songs", config_entry(fields, "song")))) + + nested = directory_children(directory_children(branch)["my_songs"]) + assert set(nested) == {fields.display_name} + + def test_colliding_nested_config_directories_get_a_hash_suffix(self) -> None: + first = config_fields(config_hash=HASH_A) + second = config_fields(config_hash=HASH_B) + branch = build_branch( + scan_of(plain_entry("my_songs", config_entry(first, "song"), config_entry(second, "song"))) + ) + + nested = directory_children(directory_children(branch)["my_songs"]) + assert set(nested) == { + disambiguated_display_name(first.display_name, HASH_A), + disambiguated_display_name(second.display_name, HASH_B), + } + + +class TestLooseReconstructions: + def test_reconstruction_beside_the_config_directories_is_listed_here(self) -> None: + entry = reconstruction_entry(RECONSTRUCTIONS, "song") + branch = build_branch(scan_of(entry)) + + assert file_children(branch)["song"].filepath == entry.path diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py new file mode 100644 index 000000000..b19afc2a9 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py @@ -0,0 +1,167 @@ +from pathlib import Path +from typing import Iterator, List + +import pytest + +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager + +from .conftest import ( + CONFIGURATION_BRANCH_KEY, + SAMPLE_BRANCH_KEY, + config_directory, + config_fields, + configuration_branch, + file_children, + group_children, + sample_branch, + write_reconstruction, +) + + +class TestRefreshTree: + def test_missing_directory_leaves_no_root( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + browser_manager.reconstructions_directory = tmp_path / "does_not_exist" + browser_manager.refresh_tree() + assert browser_manager.tree.root is None + + def test_root_holds_both_branches(self, browser_manager: BrowserManager) -> None: + browser_manager.refresh_tree() + + root = browser_manager.tree.get_root() + assert root is not None + assert list(group_children(root)) == [CONFIGURATION_BRANCH_KEY, SAMPLE_BRANCH_KEY] + + def test_reconstruction_is_reachable_from_both_branches( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + fields = config_fields() + path = write_reconstruction(config_directory(tmp_path, fields), "song") + + browser_manager.refresh_tree() + + configurations = configuration_branch(browser_manager) + frequencies = next(iter(group_children(configurations).values())) + methods = next(iter(group_children(frequencies).values())) + generators = next(iter(methods.children)) + assert file_children(generators)["song"].filepath == path + + samples = sample_branch(browser_manager) + assert file_children(group_children(samples)["song"])[fields.display_name].filepath == path + + def test_reads_every_folder_once( + self, + browser_manager: BrowserManager, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """Both branches are built from one reading, so no folder is listed twice per refresh.""" + directory = config_directory(tmp_path, config_fields()) + write_reconstruction(directory, "Amen Breaks", "cw_amen02_165") + + listed: List[Path] = [] + original_iterdir = Path.iterdir + + def counting_iterdir(directory_path: Path) -> Iterator[Path]: + listed.append(directory_path) + return original_iterdir(directory_path) + + monkeypatch.setattr(Path, "iterdir", counting_iterdir) + browser_manager.refresh_tree() + + assert tmp_path in listed + assert sorted(listed) == sorted(set(listed)) + + +class TestSetReconstructionsDirectory: + def test_directory_is_taken_over( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + directory = tmp_path / "new" + directory.mkdir() + browser_manager.set_reconstructions_directory(directory) + assert browser_manager.reconstructions_directory == directory + + def test_directory_change_refreshes_the_tree( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + directory = tmp_path / "populated" + directory.mkdir() + write_reconstruction(directory, "track") + + browser_manager.set_reconstructions_directory(directory) + + assert len(browser_manager.get_all_reconstruction_files()) == 1 + + +class TestGetAllReconstructionFiles: + def test_empty_directory_holds_no_reconstructions(self, browser_manager: BrowserManager) -> None: + browser_manager.refresh_tree() + assert browser_manager.get_all_reconstruction_files() == [] + + def test_missing_directory_holds_no_reconstructions( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + browser_manager.reconstructions_directory = tmp_path / "does_not_exist" + browser_manager.refresh_tree() + assert browser_manager.get_all_reconstruction_files() == [] + + def test_reconstructions_are_answered_in_path_order( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + root_path = write_reconstruction(tmp_path, "a") + nested_path = write_reconstruction(tmp_path / "sub", "b") + + browser_manager.refresh_tree() + + assert browser_manager.get_all_reconstruction_files() == sorted([root_path, nested_path]) + + def test_other_files_stay_out( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + (tmp_path / "audio.wav").touch() + path = write_reconstruction(tmp_path, "song") + + browser_manager.refresh_tree() + + assert browser_manager.get_all_reconstruction_files() == [path] + + def test_folder_without_reconstructions_contributes_nothing( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + audio_only = tmp_path / "audio_only" + audio_only.mkdir() + (audio_only / "track.wav").touch() + (tmp_path / "empty").mkdir() + + browser_manager.refresh_tree() + + assert browser_manager.get_all_reconstruction_files() == [] + + def test_reconstruction_in_both_branches_is_answered_once( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + path = write_reconstruction(config_directory(tmp_path, config_fields()), "song") + + browser_manager.refresh_tree() + + assert browser_manager.get_all_reconstruction_files() == [path] diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py new file mode 100644 index 000000000..c69d81039 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py @@ -0,0 +1,113 @@ +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, +) +from sampletones_application.logic.reconstruction.browser.tree.samples.branch import ( + build_sample_branch, +) +from sampletones_core.configs.display import disambiguated_display_name +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.structures.tree import ConfigNode, NodeType, TreeNode + +from .conftest import ( + BRANCH_NAME, + HASH_A, + HASH_B, + RECONSTRUCTIONS, + config_entry, + config_fields, + file_children, + group_children, + plain_entry, + reconstruction_entry, + scan_of, +) + + +def build_branch(scan: ReconstructionScan) -> TreeNode: + return build_sample_branch( + scan, + name=BRANCH_NAME, + parent=TreeNode("Root", node_type=NodeType.ROOT), + ) + + +class TestSampleGrouping: + def test_audio_appears_under_the_folders_it_came_from(self) -> None: + fields = config_fields() + directory = RECONSTRUCTIONS / fields.directory_name + entry = DirectoryEntry( + path=directory, + config=fields, + entries=(reconstruction_entry(directory, "Amen Breaks", "vol.1", "cw_amen02_165"),), + ) + branch = build_branch(scan_of(entry)) + + amen_breaks = group_children(branch)["Amen Breaks"] + volume = group_children(amen_breaks)["vol.1"] + audio_node = group_children(volume)["cw_amen02_165"] + assert file_children(audio_node)[fields.display_name].filepath == entry.entries[0].path + + def test_audio_at_the_root_of_a_config_directory_appears_at_the_branch_root(self) -> None: + fields = config_fields() + branch = build_branch(scan_of(config_entry(fields, "song"))) + + assert set(group_children(branch)) == {"song"} + assert set(file_children(group_children(branch)["song"])) == {fields.display_name} + + def test_one_audio_lists_every_configuration_that_reconstructed_it(self) -> None: + first = config_fields(spectrum_method=SpectrumMethod.FFT) + second = config_fields(spectrum_method=SpectrumMethod.CQT) + branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) + + audio_node = group_children(branch)["song"] + assert set(file_children(audio_node)) == {first.display_name, second.display_name} + + def test_each_audio_gathers_only_its_own_variants(self) -> None: + fields = config_fields() + branch = build_branch(scan_of(config_entry(fields, "first", "second"))) + + assert set(group_children(branch)) == {"first", "second"} + for audio_name in ("first", "second"): + assert set(file_children(group_children(branch)[audio_name])) == {fields.display_name} + + def test_colliding_variants_of_one_audio_get_a_hash_suffix(self) -> None: + first = config_fields(config_hash=HASH_A) + second = config_fields(config_hash=HASH_B) + branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) + + audio_node = group_children(branch)["song"] + assert set(file_children(audio_node)) == { + disambiguated_display_name(first.display_name, HASH_A), + disambiguated_display_name(second.display_name, HASH_B), + } + + +class TestSampleVariants: + def test_variant_carries_the_configuration_of_its_directory(self) -> None: + """A leaf in the sample view states the configuration its directory names. + + Its own filename is the audio name, so the configuration reaches the tooltip and the + configuration font from the node rather than from the path. + """ + fields = config_fields() + branch = build_branch(scan_of(config_entry(fields, "song"))) + + variant = next(iter(file_children(group_children(branch)["song"]).values())) + assert isinstance(variant, ConfigNode) + assert variant.config == fields + + +class TestSampleSources: + def test_folder_stating_no_configuration_stays_out(self) -> None: + entry = plain_entry("my_songs", reconstruction_entry(RECONSTRUCTIONS / "my_songs", "song")) + branch = build_branch(scan_of(entry)) + + assert branch.children == () + + def test_reconstruction_beside_the_config_directories_stays_out(self) -> None: + branch = build_branch(scan_of(reconstruction_entry(RECONSTRUCTIONS, "song"))) + + assert branch.children == () diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_scan.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_scan.py new file mode 100644 index 000000000..534d3842e --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_scan.py @@ -0,0 +1,110 @@ +from pathlib import Path + +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.scan import ( + scan_reconstructions, +) + +from .conftest import config_directory, config_fields, write_reconstruction + + +class TestScanEntries: + def test_reconstruction_file_becomes_an_entry_named_by_its_audio(self, tmp_path: Path) -> None: + path = write_reconstruction(tmp_path, "song") + + scan = scan_reconstructions(tmp_path) + + assert scan.entries == (ReconstructionEntry(path=path),) + assert scan.entries[0].name == "song" + + def test_other_files_stay_out(self, tmp_path: Path) -> None: + (tmp_path / "audio.wav").touch() + write_reconstruction(tmp_path, "song") + + scan = scan_reconstructions(tmp_path) + + assert [entry.path.name for entry in scan.entries] == ["song.stn"] + + def test_entries_follow_the_sorted_order_of_the_folder(self, tmp_path: Path) -> None: + for name in ("charlie", "alpha", "bravo"): + write_reconstruction(tmp_path, name) + + scan = scan_reconstructions(tmp_path) + + assert [entry.name for entry in scan.entries] == ["alpha", "bravo", "charlie"] + + def test_folder_becomes_an_entry_holding_what_is_inside(self, tmp_path: Path) -> None: + path = write_reconstruction(tmp_path / "my_songs", "song") + + scan = scan_reconstructions(tmp_path) + + assert scan.entries == ( + DirectoryEntry( + path=tmp_path / "my_songs", + config=None, + entries=(ReconstructionEntry(path=path),), + ), + ) + + def test_empty_folder_becomes_an_entry_holding_nothing(self, tmp_path: Path) -> None: + (tmp_path / "empty").mkdir() + + scan = scan_reconstructions(tmp_path) + + assert scan.entries == (DirectoryEntry(path=tmp_path / "empty", config=None, entries=()),) + + +class TestScanConfiguration: + def test_config_directory_states_the_configuration_its_name_encodes(self, tmp_path: Path) -> None: + fields = config_fields() + config_directory(tmp_path, fields) + + scan = scan_reconstructions(tmp_path) + + assert [entry.config for entry in scan.entries] == [fields] + + def test_plain_folder_states_no_configuration(self, tmp_path: Path) -> None: + (tmp_path / "my_songs").mkdir() + + scan = scan_reconstructions(tmp_path) + + assert [entry.config for entry in scan.entries] == [None] + + def test_nested_config_directory_states_its_configuration(self, tmp_path: Path) -> None: + fields = config_fields() + config_directory(tmp_path / "my_songs", fields) + + scan = scan_reconstructions(tmp_path) + + nested = scan.entries[0] + assert isinstance(nested, DirectoryEntry) + assert [entry.config for entry in nested.entries] == [fields] + + +class TestScanReconstructions: + def test_collects_every_reconstruction_beneath_the_directory(self, tmp_path: Path) -> None: + root_path = write_reconstruction(tmp_path, "song") + nested_path = write_reconstruction(tmp_path / "sub" / "deeper", "track") + + scan = scan_reconstructions(tmp_path) + + assert {entry.path for entry in scan.reconstructions} == {root_path, nested_path} + + def test_collects_nothing_from_an_empty_directory(self, tmp_path: Path) -> None: + assert scan_reconstructions(tmp_path).reconstructions == () + + def test_directory_entry_collects_the_reconstructions_beneath_it(self, tmp_path: Path) -> None: + fields = config_fields() + directory = config_directory(tmp_path, fields) + nested_path = write_reconstruction(directory, "Amen Breaks", "cw_amen02_165") + write_reconstruction(tmp_path, "outside") + + scan = scan_reconstructions(tmp_path) + + config_entry = next(entry for entry in scan.entries if isinstance(entry, DirectoryEntry)) + assert [entry.path for entry in scan.collect_reconstructions(config_entry.entries)] == [nested_path] diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py b/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py deleted file mode 100644 index ac27ef6ab..000000000 --- a/tests/unit/sampletones_application/logic/reconstruction/test_browser_manager.py +++ /dev/null @@ -1,437 +0,0 @@ -from __future__ import annotations - -from pathlib import Path -from typing import Dict -from unittest.mock import MagicMock - -import pytest - -from sampletones_application.logic.reconstruction.browser_manager import BrowserManager -from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields -from sampletones_core.structures.tree import ( - ConfigNode, - FileSystemNode, - NodeType, - TreeNode, -) - -HASH_A = "6edf7c948606917a78b45d153c7ca7e0" -HASH_B = "a1b2c3d4e5f60718293a4b5c6d7e8f90" - - -def reconstructions_node(browser_manager: BrowserManager) -> TreeNode: - root = browser_manager.tree.get_root() - assert root is not None - return group_children(root)["Reconstructions"] - - -def samples_node(browser_manager: BrowserManager) -> TreeNode: - root = browser_manager.tree.get_root() - assert root is not None - return group_children(root)["Samples"] - - -def directory_nodes(browser_manager: BrowserManager) -> Dict[str, FileSystemNode]: - return directory_children(reconstructions_node(browser_manager)) - - -def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: - return { - child.name: child - for child in node.children - if isinstance(child, FileSystemNode) and child.node_type == NodeType.DIRECTORY - } - - -def file_children(node: TreeNode) -> Dict[str, FileSystemNode]: - return { - child.name: child - for child in node.children - if isinstance(child, FileSystemNode) and child.node_type == NodeType.FILE - } - - -def group_children(node: TreeNode) -> Dict[str, TreeNode]: - return { - child.name: child - for child in node.children - if isinstance(child, TreeNode) and child.node_type == NodeType.GROUP - } - - -@pytest.fixture -def config_manager(tmp_path: Path) -> MagicMock: - mock = MagicMock() - mock.get_reconstructions_directory.return_value = tmp_path - return mock - - -BROWSER_LABELS = { - "global.browser.label.root": "Root", - "global.browser.label.browser": "Browser", - "global.browser.label.reconstructions": "Reconstructions", - "global.browser.label.samples": "Samples", -} - - -@pytest.fixture -def language_manager() -> MagicMock: - mock = MagicMock() - mock.__getitem__ = MagicMock(side_effect=BROWSER_LABELS.__getitem__) - return mock - - -@pytest.fixture -def browser_manager(config_manager: MagicMock, language_manager: MagicMock) -> BrowserManager: - return BrowserManager(config_manager, language_manager=language_manager) - - -class TestBrowserManagerRefreshTree: - def test_non_existent_directory_sets_root_to_none( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - browser_manager.reconstructions_directory = tmp_path / "does_not_exist" - browser_manager.refresh_tree() - assert browser_manager.tree.root is None - - def test_empty_directory_produces_empty_leaf_list( - self, - browser_manager: BrowserManager, - ) -> None: - browser_manager.refresh_tree() - assert browser_manager.get_all_reconstruction_files() == [] - - def test_stn_files_appear_as_leaves( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - (tmp_path / "song.stn").touch() - browser_manager.refresh_tree() - files = browser_manager.get_all_reconstruction_files() - assert len(files) == 1 - assert files[0] == tmp_path / "song.stn" - - def test_non_stn_files_are_excluded( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - (tmp_path / "audio.wav").touch() - (tmp_path / "song.stn").touch() - browser_manager.refresh_tree() - files = browser_manager.get_all_reconstruction_files() - assert len(files) == 1 - assert all(f.suffix == ".stn" for f in files) - - def test_nested_stn_files_are_included( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - subdir = tmp_path / "sub" - subdir.mkdir() - (subdir / "song.stn").touch() - browser_manager.refresh_tree() - files = browser_manager.get_all_reconstruction_files() - assert len(files) == 1 - assert files[0] == subdir / "song.stn" - - def test_directory_with_only_non_stn_files_is_not_returned( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - subdir = tmp_path / "audio_only" - subdir.mkdir() - (subdir / "track.wav").touch() - browser_manager.refresh_tree() - assert browser_manager.get_all_reconstruction_files() == [] - - def test_empty_subdirectory_is_not_returned( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - (tmp_path / "empty_dir").mkdir() - browser_manager.refresh_tree() - assert browser_manager.get_all_reconstruction_files() == [] - - -class TestBrowserManagerFriendlyNames: - def test_config_directory_groups_by_frequency_method_generators( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PpT_ch_{HASH_A}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - reconstructions = reconstructions_node(browser_manager) - frequencies = group_children(reconstructions) - assert set(frequencies) == {"44.1 kHz·30 Hz"} - - methods = group_children(frequencies["44.1 kHz·30 Hz"]) - assert set(methods) == {"FFT·γ0"} - - assert set(directory_children(methods["FFT·γ0"])) == {"PpT"} - - def test_colliding_config_directories_get_hash_suffix( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - for config_hash in (HASH_A, HASH_B): - config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PpT_ch_{config_hash}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - reconstructions = reconstructions_node(browser_manager) - methods = group_children(group_children(reconstructions)["44.1 kHz·30 Hz"]) - assert set(directory_children(methods["FFT·γ0"])) == { - f"PpT·#{HASH_A[:7]}", - f"PpT·#{HASH_B[:7]}", - } - - def test_distinct_frequencies_form_separate_groups( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - for sample_rate, nes_frequency in ((44100, 30), (48000, 60)): - config_dir = tmp_path / f"sr_{sample_rate}_nf_{nes_frequency}_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - assert set(group_children(reconstructions_node(browser_manager))) == {"44.1 kHz·30 Hz", "48 kHz·60 Hz"} - - def test_distinct_methods_form_separate_groups( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - for spectrum_method in ("fft", "cqt"): - config_dir = tmp_path / f"sr_44100_nf_30_sm_{spectrum_method}_tg_0_gn_PTN_ch_{HASH_A}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - methods = group_children(group_children(reconstructions_node(browser_manager))["44.1 kHz·30 Hz"]) - assert set(methods) == {"FFT·γ0", "CQT·γ0"} - - def test_distinct_generators_share_method_group_without_hash( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - for generators in ("PTN", "TN"): - config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_{generators}_ch_{HASH_A}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - methods = group_children(group_children(reconstructions_node(browser_manager))["44.1 kHz·30 Hz"]) - assert set(directory_children(methods["FFT·γ0"])) == {"PTN", "TN"} - - def test_non_config_directory_keeps_raw_name( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - plain = tmp_path / "my_songs" - plain.mkdir() - (plain / "song.stn").touch() - - browser_manager.refresh_tree() - - assert "my_songs" in directory_nodes(browser_manager) - - -class TestBrowserManagerSamplesView: - def test_samples_are_grouped_by_source_directory_and_audio( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" - audio_dir = config_dir / "Amen Breaks" / "Amen Breaks vol.1" - audio_dir.mkdir(parents=True) - (audio_dir / "cw_amen02_165.stn").touch() - - browser_manager.refresh_tree() - - samples = samples_node(browser_manager) - amen_breaks = group_children(samples)["Amen Breaks"] - amen_breaks_vol1 = group_children(amen_breaks)["Amen Breaks vol.1"] - audio = group_children(amen_breaks_vol1)["cw_amen02_165"] - variant = file_children(audio)["44.1 kHz·30 Hz·FFT·γ0·PTN"] - assert variant.filepath == audio_dir / "cw_amen02_165.stn" - - def test_one_audio_lists_each_config_variant( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - for spectrum_method in ("fft", "cqt"): - config_dir = tmp_path / f"sr_44100_nf_30_sm_{spectrum_method}_tg_0_gn_PTN_ch_{HASH_A}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - audio = group_children(samples_node(browser_manager))["song"] - assert set(file_children(audio)) == { - "44.1 kHz·30 Hz·FFT·γ0·PTN", - "44.1 kHz·30 Hz·CQT·γ0·PTN", - } - - def test_colliding_variants_of_one_audio_get_hash_suffix( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - for config_hash in (HASH_A, HASH_B): - config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{config_hash}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - audio = group_children(samples_node(browser_manager))["song"] - assert set(file_children(audio)) == { - f"44.1 kHz·30 Hz·FFT·γ0·PTN·#{HASH_A[:7]}", - f"44.1 kHz·30 Hz·FFT·γ0·PTN·#{HASH_B[:7]}", - } - - def test_single_file_conversion_appears_at_samples_root( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - config_dir = tmp_path / f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - samples = samples_node(browser_manager) - assert set(group_children(samples)) == {"song"} - assert set(file_children(group_children(samples)["song"])) == {"44.1 kHz·30 Hz·FFT·γ0·PTN"} - - def test_non_config_directory_is_excluded_from_samples( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - plain = tmp_path / "my_songs" - plain.mkdir() - (plain / "song.stn").touch() - - browser_manager.refresh_tree() - - assert group_children(samples_node(browser_manager)) == {} - assert "my_songs" in directory_nodes(browser_manager) - - -class TestBrowserManagerConfigNodes: - def test_config_directory_carries_its_parsed_configuration( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - directory_name = f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" - config_dir = tmp_path / directory_name - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - methods = group_children(group_children(reconstructions_node(browser_manager))["44.1 kHz·30 Hz"]) - directory_node = directory_children(methods["FFT·γ0"])["PTN"] - assert isinstance(directory_node, ConfigNode) - assert directory_node.config == ConfigDirectoryFields.from_directory_name(directory_name) - - def test_sample_variant_carries_the_configuration_of_its_directory( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - """A leaf in the sample view states the configuration its directory names. - - Its own filename is the audio name, so the configuration reaches the tooltip and the - configuration font from the node rather than from the path. - """ - directory_name = f"sr_44100_nf_30_sm_fft_tg_0_gn_PTN_ch_{HASH_A}" - config_dir = tmp_path / directory_name - config_dir.mkdir() - (config_dir / "song.stn").touch() - - browser_manager.refresh_tree() - - audio = group_children(samples_node(browser_manager))["song"] - variant = next(iter(file_children(audio).values())) - assert isinstance(variant, ConfigNode) - assert variant.config == ConfigDirectoryFields.from_directory_name(directory_name) - - def test_plain_directory_carries_no_configuration( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - plain = tmp_path / "my_songs" - plain.mkdir() - (plain / "song.stn").touch() - - browser_manager.refresh_tree() - - assert not isinstance(directory_nodes(browser_manager)["my_songs"], ConfigNode) - - -class TestBrowserManagerSetDirectory: - def test_set_reconstructions_directory_updates_directory( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - new_dir = tmp_path / "new" - new_dir.mkdir() - browser_manager.set_reconstructions_directory(new_dir) - assert browser_manager.reconstructions_directory == new_dir - - def test_set_reconstructions_directory_triggers_refresh( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - new_dir = tmp_path / "populated" - new_dir.mkdir() - (new_dir / "track.stn").touch() - browser_manager.set_reconstructions_directory(new_dir) - assert len(browser_manager.get_all_reconstruction_files()) == 1 - - -class TestBrowserManagerGetAllReconstructionFiles: - def test_returns_paths_for_all_stn_leaves( - self, - browser_manager: BrowserManager, - tmp_path: Path, - ) -> None: - (tmp_path / "a.stn").touch() - subdir = tmp_path / "sub" - subdir.mkdir() - (subdir / "b.stn").touch() - browser_manager.refresh_tree() - files = browser_manager.get_all_reconstruction_files() - assert len(files) == 2 - assert {f.name for f in files} == {"a.stn", "b.stn"} diff --git a/tests/unit/sampletones_core/structures/tree/test_factory.py b/tests/unit/sampletones_core/structures/tree/test_factory.py index 21af2ec25..993da11ef 100644 --- a/tests/unit/sampletones_core/structures/tree/test_factory.py +++ b/tests/unit/sampletones_core/structures/tree/test_factory.py @@ -11,21 +11,36 @@ class TestCreateDirectoryNode: - def test_config_directory_becomes_a_config_node(self) -> None: + def test_stated_configuration_becomes_a_config_node(self) -> None: directory = RECONSTRUCTIONS_DIRECTORY / CONFIG_FIELDS.directory_name - node = create_directory_node(directory, name=directory.name, parent=None) + node = create_directory_node( + directory, + name=directory.name, + config=CONFIG_FIELDS, + parent=None, + ) assert isinstance(node, ConfigNode) assert node.config == CONFIG_FIELDS - def test_plain_directory_becomes_a_file_system_node(self) -> None: + def test_folder_stating_no_configuration_becomes_a_file_system_node(self) -> None: directory = RECONSTRUCTIONS_DIRECTORY / "my_songs" - node = create_directory_node(directory, name=directory.name, parent=None) + node = create_directory_node( + directory, + name=directory.name, + config=None, + parent=None, + ) assert isinstance(node, FileSystemNode) assert not isinstance(node, ConfigNode) def test_node_carries_the_given_name_and_path(self) -> None: directory = RECONSTRUCTIONS_DIRECTORY / CONFIG_FIELDS.directory_name - node = create_directory_node(directory, name="friendly", parent=None) + node = create_directory_node( + directory, + name="friendly", + config=CONFIG_FIELDS, + parent=None, + ) assert node.name == "friendly" assert node.filepath == directory assert node.node_type == NodeType.DIRECTORY @@ -35,6 +50,7 @@ def test_node_attaches_to_the_given_parent(self) -> None: node = create_directory_node( RECONSTRUCTIONS_DIRECTORY / "my_songs", name="my_songs", + config=None, parent=parent, ) assert node.parent is parent From 853600846eee94aed06769affe89ddc34d701a72 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 11:32:50 +0200 Subject: [PATCH 116/152] Refactored: reconstruction browser tree --- .../browser/tree/configurations.py | 6 +- .../reconstruction/browser/tree/containers.py | 33 ++++++++++ .../reconstruction/browser/tree/group.py | 15 ----- .../browser/tree/samples/branch.py | 9 +-- .../ui/elements/tree/tag.py | 29 +++++++++ .../ui/elements/tree/tree.py | 23 ++++--- .../ui/panels/main/explorer.py | 2 +- .../ui/panels/shared/browser.py | 44 ++++++++----- src/sampletones_config/lang/en.yaml | 1 + src/sampletones_core/structures/tree/type.py | 1 + .../logic/reconstruction/browser/conftest.py | 4 ++ .../reconstruction/browser/test_manager.py | 3 +- .../reconstruction/browser/test_samples.py | 60 +++++++++++++++--- .../ui/elements/tree/test_tag.py | 62 +++++++++++++++++++ 14 files changed, 238 insertions(+), 54 deletions(-) create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/containers.py delete mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/group.py create mode 100644 src/sampletones_application/ui/elements/tree/tag.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_tag.py diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py index f38d85ade..fa4f372dd 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py @@ -1,5 +1,8 @@ from typing import List, Sequence, Tuple +from sampletones_application.logic.reconstruction.browser.tree.containers import ( + find_or_create_group, +) from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( DirectoryEntry, ) @@ -10,9 +13,6 @@ ReconstructionScan, ScanEntry, ) -from sampletones_application.logic.reconstruction.browser.tree.group import ( - find_or_create_group, -) from sampletones_core.configs.display import ( DISPLAY_SEPARATOR, GAMMA_PREFIX, diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/containers.py b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py new file mode 100644 index 000000000..b12eb43e4 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py @@ -0,0 +1,33 @@ +from sampletones_core.structures.tree import NodeType, TreeNode + + +def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode: + """Answers the group of this name under ``parent``, adding one where the parent holds none. + + A group stands for something the disk states rather than holds — a frequency pair, a spectrum + method, a source folder — so a builder meeting that name again extends the group it already made. + """ + return _find_or_create(name, node_type=NodeType.GROUP, parent=parent) + + +def find_or_create_sample(name: str, *, parent: TreeNode) -> TreeNode: + """Answers the sample of this name under ``parent``, adding one where the parent holds none. + + A sample stands for one source audio and gathers the reconstructions made from it. It carries a + node type of its own, so a folder and an audio of the same name stay two rows: each is looked up + among the siblings of its own kind. + """ + return _find_or_create(name, node_type=NodeType.SAMPLE, parent=parent) + + +def _find_or_create( + name: str, + *, + node_type: NodeType, + parent: TreeNode, +) -> TreeNode: + for child in parent.children: + if isinstance(child, TreeNode) and child.node_type == node_type and child.name == name: + return child + + return TreeNode(name, node_type=node_type, parent=parent) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/group.py b/src/sampletones_application/logic/reconstruction/browser/tree/group.py deleted file mode 100644 index 7b6557cec..000000000 --- a/src/sampletones_application/logic/reconstruction/browser/tree/group.py +++ /dev/null @@ -1,15 +0,0 @@ -from sampletones_core.structures.tree import NodeType, TreeNode - - -def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode: - """Answers the group of this name under ``parent``, adding one where the parent holds none. - - A group stands for something the disk states rather than holds — a frequency pair, a spectrum - method, a source folder — so it is identified by its name and a builder meeting that name again - extends the group it already made. - """ - for child in parent.children: - if isinstance(child, TreeNode) and child.node_type == NodeType.GROUP and child.name == name: - return child - - return TreeNode(name, node_type=NodeType.GROUP, parent=parent) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py index 8baec53df..7ae3c7741 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/samples/branch.py @@ -1,9 +1,10 @@ +from sampletones_application.logic.reconstruction.browser.tree.containers import ( + find_or_create_group, + find_or_create_sample, +) from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, ) -from sampletones_application.logic.reconstruction.browser.tree.group import ( - find_or_create_group, -) from sampletones_application.logic.reconstruction.browser.tree.samples.variants import ( append_variants, collect_variants, @@ -29,7 +30,7 @@ def build_sample_branch( for part in source.directory_parts: source_node = find_or_create_group(part, parent=source_node) - audio_node = find_or_create_group(source.name, parent=source_node) + audio_node = find_or_create_sample(source.name, parent=source_node) append_variants(audio_node, variants_by_source[source]) return branch diff --git a/src/sampletones_application/ui/elements/tree/tag.py b/src/sampletones_application/ui/elements/tree/tag.py new file mode 100644 index 000000000..c4e375e81 --- /dev/null +++ b/src/sampletones_application/ui/elements/tree/tag.py @@ -0,0 +1,29 @@ +from typing import Final + +from sampletones_application.tags.compose import compose_tag +from sampletones_core.structures.tree import TreeNode +from sampletones_shared.utils.serialization import calculate_hash + +NODE_TAG_DIGEST_LENGTH: Final[int] = 8 + +_IDENTITY_SEPARATOR: Final[str] = "\x00" + + +def compose_node_tag(node: TreeNode, *, panel_tag: str) -> str: + """Composes the widget tag of one tree row: readable by the names above it, unique by its path. + + The names read the row back to whoever inspects the widget tree, and the digest states the exact + path — each ancestor's node type together with its name — so every row the names alone spell + alike keeps a tag of its own: a folder and the audio beside it, or two labels differing only in + spacing or case. The separator the digest joins on is one the disk gives no name, which is what + makes one identity reach one digest. + """ + names = "_".join(str(ancestor.name) for ancestor in node.path) + return compose_tag(panel_tag, f"node_{names}", _node_digest(node)) + + +def _node_digest(node: TreeNode) -> str: + identity = _IDENTITY_SEPARATOR.join( + part for ancestor in node.path for part in (ancestor.node_type.value, str(ancestor.name)) + ) + return calculate_hash(identity, length=NODE_TAG_DIGEST_LENGTH) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 110ef7316..cac7306bb 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -47,6 +47,7 @@ from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.spec import NodeSpec from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.ui.elements.tree.tag import compose_node_tag from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.callbacks.queue import CallbackQueue from sampletones_application.utils.gui.dpg import ( @@ -473,29 +474,37 @@ def _create_status_bar_message_function_for_library_node( ) -> MessageCallback: return self._create_status_bar_message_function(self._language_manager["global.status.message.node_library"]) - def _create_status_bar_message_function_for_directory_node( + def _create_status_bar_message_function_for_expandable_node( self, ) -> MessageCallback: + """Builds the hover message of a row the reader opens, naming what that row holds. + + A folder and a sample are both opened the same way and hold different things, so the message + follows the node it is asked about: the sample names the reconstructions it gathers. + """ + def message_function( *_args: Any, - user_data: Tuple[FileSystemNode, str], + user_data: Tuple[TreeNode, str], **_kwargs: Any, ) -> str: - _, node_tag = user_data + node, node_tag = user_data expand_or_collapse = ( self._language_manager["global.dialog.template.collapse"] if dpg_get_value(node_tag) else self._language_manager["global.dialog.template.expand"] ) - return self._language_manager["global.status.message.node_directory"].format( - expand_or_collapse=expand_or_collapse + message = ( + self._language_manager["global.status.message.node_sample"] + if node.node_type == NodeType.SAMPLE + else self._language_manager["global.status.message.node_directory"] ) + return message.format(expand_or_collapse=expand_or_collapse) return self._create_status_bar_message_function(message_function) def _generate_node_tag(self, node: TreeNode) -> str: - path_parts = [ancestor.name for ancestor in node.path] - return compose_tag(self.tag, f"node_{'_'.join(path_parts)}") + return compose_node_tag(node, panel_tag=self.tag) def _context_menu_header_name(self, node: TreeNode) -> str: """Returns the raw on-disk identifier, complementing the friendly label shown in the tree.""" diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 0f477526d..774381772 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -138,7 +138,7 @@ def _setup_handlers(self) -> None: tag=self._get_node_handler_tag(NodeType.DIRECTORY), node_type=NodeType.DIRECTORY, item_click_callback=self._on_directory_node_clicked, - status_bar_callback=self._create_status_bar_message_function_for_directory_node(), + status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), ), NodeType.FILE: NodeHandler( tag=self._get_node_handler_tag(NodeType.FILE), diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index d3bb4c1f8..09cb97509 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -9,6 +9,7 @@ ) from sampletones_application.tags.general import ( TAG_GLOBAL_THEME_DEFAULT, + TAG_GLOBAL_THEME_FILE_WAVE, TAG_GLOBAL_THEME_SECONDARY_BUTTON, ) from sampletones_application.ui.elements.button import GUIButton @@ -116,11 +117,16 @@ def _setup_handlers(self) -> None: tag=self._get_node_handler_tag(NodeType.GROUP), node_type=NodeType.GROUP, ), + NodeType.SAMPLE: NodeHandler( + tag=self._get_node_handler_tag(NodeType.SAMPLE), + node_type=NodeType.SAMPLE, + status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), + ), NodeType.DIRECTORY: NodeHandler( tag=self._get_node_handler_tag(NodeType.DIRECTORY), node_type=NodeType.DIRECTORY, item_click_callback=self._on_directory_node_clicked, - status_bar_callback=self._create_status_bar_message_function_for_directory_node(), + status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), ), NodeType.FILE: NodeHandler( tag=self._get_node_handler_tag(NodeType.FILE), @@ -184,18 +190,18 @@ def _build_tree_node( **kwargs: Any, ) -> None: node_tag = self._generate_node_tag(node) - if node.node_type == NodeType.ROOT: - return - - if node.node_type == NodeType.GROUP: - self._append_spec( - node=node, - node_tag=node_tag, - parent=state.parent, - should_expand=self._should_expand_node(node), - ) - state.parent = node_tag - return + match node.node_type: + case NodeType.ROOT: + return + case NodeType.GROUP | NodeType.SAMPLE: + self._append_spec( + node=node, + node_tag=node_tag, + parent=state.parent, + should_expand=self._should_expand_node(node), + ) + state.parent = node_tag + return if not isinstance(node, FileSystemNode): return @@ -223,8 +229,16 @@ def _build_tree_node( state.parent = node_tag def _resolve_other_theme_tag(self, node: TreeNode) -> str: - if node.node_type == NodeType.GROUP: - return TAG_GLOBAL_THEME_DEFAULT + """Selects the colour of a row the browser invents: a plain group, or a sample in wave colour. + + A sample row names the audio a set of reconstructions was made from, so it reads in the + colour audio files carry elsewhere in the application. + """ + match node.node_type: + case NodeType.GROUP: + return TAG_GLOBAL_THEME_DEFAULT + case NodeType.SAMPLE: + return TAG_GLOBAL_THEME_FILE_WAVE return super()._resolve_other_theme_tag(node) diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 102e8f80f..c5c0ba9fa 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -237,6 +237,7 @@ global.status.message.clear_search: "Clear the search filter." global.status.message.input: "Ctrl + click to type value." global.status.message.combo: "Click to select a value from the list." global.status.message.node_directory: "Click to {expand_or_collapse}. Right-click to open context menu." +global.status.message.node_sample: "Click to {expand_or_collapse} the reconstructions of this sample." global.status.message.retuning_samples: "Retuning samples..." # ============================================================================= diff --git a/src/sampletones_core/structures/tree/type.py b/src/sampletones_core/structures/tree/type.py index 64dc38c19..98309ea07 100644 --- a/src/sampletones_core/structures/tree/type.py +++ b/src/sampletones_core/structures/tree/type.py @@ -7,5 +7,6 @@ class NodeType(StrEnum): FILE = "file" LIBRARY = "library" GROUP = "group" + SAMPLE = "sample" GENERATOR = "generator" INSTRUCTION = "instruction" diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py index a0b775846..4273ac947 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -108,6 +108,10 @@ def group_children(node: TreeNode) -> Dict[str, TreeNode]: return {child.name: child for child in node.children if child.node_type == NodeType.GROUP} +def sample_children(node: TreeNode) -> Dict[str, TreeNode]: + return {child.name: child for child in node.children if child.node_type == NodeType.SAMPLE} + + def branch_of(browser_manager: BrowserManager, key: str) -> TreeNode: root = browser_manager.tree.get_root() assert root is not None diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py index b19afc2a9..085ac5ed1 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py @@ -14,6 +14,7 @@ file_children, group_children, sample_branch, + sample_children, write_reconstruction, ) @@ -52,7 +53,7 @@ def test_reconstruction_is_reachable_from_both_branches( assert file_children(generators)["song"].filepath == path samples = sample_branch(browser_manager) - assert file_children(group_children(samples)["song"])[fields.display_name].filepath == path + assert file_children(sample_children(samples)["song"])[fields.display_name].filepath == path def test_reads_every_folder_once( self, diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py index c69d81039..57eeece46 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_samples.py @@ -22,6 +22,7 @@ group_children, plain_entry, reconstruction_entry, + sample_children, scan_of, ) @@ -47,44 +48,87 @@ def test_audio_appears_under_the_folders_it_came_from(self) -> None: amen_breaks = group_children(branch)["Amen Breaks"] volume = group_children(amen_breaks)["vol.1"] - audio_node = group_children(volume)["cw_amen02_165"] + audio_node = sample_children(volume)["cw_amen02_165"] assert file_children(audio_node)[fields.display_name].filepath == entry.entries[0].path def test_audio_at_the_root_of_a_config_directory_appears_at_the_branch_root(self) -> None: fields = config_fields() branch = build_branch(scan_of(config_entry(fields, "song"))) - assert set(group_children(branch)) == {"song"} - assert set(file_children(group_children(branch)["song"])) == {fields.display_name} + assert set(sample_children(branch)) == {"song"} + assert set(file_children(sample_children(branch)["song"])) == {fields.display_name} def test_one_audio_lists_every_configuration_that_reconstructed_it(self) -> None: first = config_fields(spectrum_method=SpectrumMethod.FFT) second = config_fields(spectrum_method=SpectrumMethod.CQT) branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) - audio_node = group_children(branch)["song"] + audio_node = sample_children(branch)["song"] assert set(file_children(audio_node)) == {first.display_name, second.display_name} def test_each_audio_gathers_only_its_own_variants(self) -> None: fields = config_fields() branch = build_branch(scan_of(config_entry(fields, "first", "second"))) - assert set(group_children(branch)) == {"first", "second"} + assert set(sample_children(branch)) == {"first", "second"} for audio_name in ("first", "second"): - assert set(file_children(group_children(branch)[audio_name])) == {fields.display_name} + assert set(file_children(sample_children(branch)[audio_name])) == {fields.display_name} def test_colliding_variants_of_one_audio_get_a_hash_suffix(self) -> None: first = config_fields(config_hash=HASH_A) second = config_fields(config_hash=HASH_B) branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) - audio_node = group_children(branch)["song"] + audio_node = sample_children(branch)["song"] assert set(file_children(audio_node)) == { disambiguated_display_name(first.display_name, HASH_A), disambiguated_display_name(second.display_name, HASH_B), } +class TestSampleNodeTypes: + def test_audio_is_a_sample_and_the_folder_above_it_is_a_group(self) -> None: + fields = config_fields() + directory = RECONSTRUCTIONS / fields.directory_name + entry = DirectoryEntry( + path=directory, + config=fields, + entries=(reconstruction_entry(directory, "Amen Breaks", "cw_amen02_165"),), + ) + branch = build_branch(scan_of(entry)) + + folder_node = group_children(branch)["Amen Breaks"] + assert folder_node.node_type == NodeType.GROUP + assert sample_children(folder_node)["cw_amen02_165"].node_type == NodeType.SAMPLE + + def test_a_folder_and_the_audio_beside_it_stay_two_rows(self) -> None: + """A configuration directory holding ``song.stn`` beside ``song/inner.stn`` lists both. + + The folder gathers what it holds while the audio gathers its variants, each row found among + the siblings of its own kind. + """ + fields = config_fields() + directory = RECONSTRUCTIONS / fields.directory_name + entry = DirectoryEntry( + path=directory, + config=fields, + entries=( + DirectoryEntry( + path=directory / "song", + config=None, + entries=(reconstruction_entry(directory, "song", "inner"),), + ), + reconstruction_entry(directory, "song"), + ), + ) + branch = build_branch(scan_of(entry)) + + assert set(group_children(branch)) == {"song"} + assert set(sample_children(branch)) == {"song"} + assert set(sample_children(group_children(branch)["song"])) == {"inner"} + assert set(file_children(sample_children(branch)["song"])) == {fields.display_name} + + class TestSampleVariants: def test_variant_carries_the_configuration_of_its_directory(self) -> None: """A leaf in the sample view states the configuration its directory names. @@ -95,7 +139,7 @@ def test_variant_carries_the_configuration_of_its_directory(self) -> None: fields = config_fields() branch = build_branch(scan_of(config_entry(fields, "song"))) - variant = next(iter(file_children(group_children(branch)["song"]).values())) + variant = next(iter(file_children(sample_children(branch)["song"]).values())) assert isinstance(variant, ConfigNode) assert variant.config == fields diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_tag.py b/tests/unit/sampletones_application/ui/elements/tree/test_tag.py new file mode 100644 index 000000000..ac0f5f43c --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_tag.py @@ -0,0 +1,62 @@ +from typing import Final + +from sampletones_application.ui.elements.tree.tag import compose_node_tag +from sampletones_core.structures.tree import NodeType, TreeNode + +PANEL_TAG: Final[str] = "sequencer.browser.panel" +OTHER_PANEL_TAG: Final[str] = "reconstructions.browser.panel" + + +def root() -> TreeNode: + return TreeNode("Root", node_type=NodeType.ROOT) + + +def group(name: str, parent: TreeNode) -> TreeNode: + return TreeNode(name, node_type=NodeType.GROUP, parent=parent) + + +def tag_of(node: TreeNode) -> str: + return compose_node_tag(node, panel_tag=PANEL_TAG) + + +class TestReadability: + def test_tag_states_the_panel_and_the_names_above_the_row(self) -> None: + node = group("cw_amen02_165", group("Amen Breaks", root())) + + assert tag_of(node).startswith(f"{PANEL_TAG}.") + assert "node_root_amen_breaks_cw_amen02_165" in tag_of(node) + + def test_one_node_keeps_one_tag(self) -> None: + node = group("song", root()) + + assert tag_of(node) == tag_of(node) + + def test_each_panel_names_the_row_its_own_way(self) -> None: + """Both browsers render one tree, so a row reaches each panel under a tag of that panel.""" + node = group("song", root()) + + assert compose_node_tag(node, panel_tag=PANEL_TAG) != compose_node_tag(node, panel_tag=OTHER_PANEL_TAG) + + +class TestDistinctRows: + def test_a_folder_and_the_audio_beside_it_keep_their_own_tags(self) -> None: + container = root() + folder = group("song", container) + audio = TreeNode("song", node_type=NodeType.SAMPLE, parent=container) + + assert tag_of(folder) != tag_of(audio) + + def test_names_differing_in_spacing_keep_their_own_tags(self) -> None: + """``drums/kick`` and ``drums kick`` read alike as a name path and stand as two rows.""" + container = root() + nested = group("kick", group("drums", container)) + spaced = group("drums kick", container) + + assert tag_of(nested) != tag_of(spaced) + + def test_names_differing_in_case_keep_their_own_tags(self) -> None: + container = root() + lowercase = group("song", container) + capitalized = group("Song", container) + + assert tag_of(lowercase) != tag_of(capitalized) From 5dc244b98bff6fe9995c5e056bb46494e2c68029 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 12:50:41 +0200 Subject: [PATCH 117/152] Added: pruning and deterministic ordering of browser groups --- .../logic/reconstruction/browser/manager.py | 15 +- .../browser/tree/configurations.py | 151 ------------------ .../browser/tree/configurations/__init__.py | 0 .../browser/tree/configurations/branch.py | 61 +++++++ .../browser/tree/configurations/grouping.py | 52 ++++++ .../browser/tree/configurations/naming.py | 42 +++++ .../reconstruction/browser/tree/containers.py | 9 ++ .../browser/tree/entries/directory.py | 15 +- .../browser/tree/entries/scan.py | 5 +- .../reconstruction/browser/tree/order.py | 25 +++ .../reconstruction/browser/tree/prune.py | 20 +++ .../logic/reconstruction/browser/tree/scan.py | 2 +- src/sampletones_config/lang/en.yaml | 4 +- src/sampletones_core/configs/display.py | 28 ++++ .../library/filename/utils.py | 13 +- .../reconstructions/converter/paths/fields.py | 12 +- src/sampletones_shared/utils/text.py | 32 ++++ .../logic/reconstruction/browser/conftest.py | 42 ++++- .../browser/test_configurations.py | 38 ++--- .../reconstruction/browser/test_manager.py | 34 +++- .../reconstruction/browser/test_order.py | 88 ++++++++++ .../reconstruction/browser/test_prune.py | 103 ++++++++++++ .../sampletones_core/configs/test_display.py | 19 +++ .../converter/paths/test_fields.py | 4 +- .../sampletones_shared/utils/test_text.py | 53 ++++++ 25 files changed, 656 insertions(+), 211 deletions(-) delete mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/configurations.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/configurations/__init__.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/configurations/branch.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/order.py create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/prune.py create mode 100644 src/sampletones_shared/utils/text.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_order.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_prune.py create mode 100644 tests/unit/sampletones_shared/utils/test_text.py diff --git a/src/sampletones_application/logic/reconstruction/browser/manager.py b/src/sampletones_application/logic/reconstruction/browser/manager.py index 3844efa6c..e8eed1bae 100644 --- a/src/sampletones_application/logic/reconstruction/browser/manager.py +++ b/src/sampletones_application/logic/reconstruction/browser/manager.py @@ -3,12 +3,16 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager -from sampletones_application.logic.reconstruction.browser.tree.configurations import ( +from sampletones_application.logic.reconstruction.browser.tree.configurations.branch import ( build_configuration_branch, ) from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, ) +from sampletones_application.logic.reconstruction.browser.tree.order import order_children +from sampletones_application.logic.reconstruction.browser.tree.prune import ( + prune_empty_containers, +) from sampletones_application.logic.reconstruction.browser.tree.samples.branch import ( build_sample_branch, ) @@ -22,7 +26,8 @@ class BrowserManager: """Owns the reconstruction browser tree, rebuilt from one reading of the reconstructions directory. A refresh scans the directory, builds the configuration branch and the sample branch from that - one reading, and publishes the result as the tree both browser tabs render. + one reading, shapes what came out — empty headings pruned, siblings ordered — and publishes the + result as the tree both browser tabs render. """ def __init__( @@ -58,15 +63,17 @@ def _build_root(self, scan: ReconstructionScan) -> TreeNode: ) build_configuration_branch( scan, - name=self._language_manager["global.browser.label.reconstructions"], + name=self._language_manager["global.browser.label.by_configuration"], parent=container_root, ) build_sample_branch( scan, - name=self._language_manager["global.browser.label.samples"], + name=self._language_manager["global.browser.label.by_sample"], parent=container_root, ) + prune_empty_containers(container_root) + order_children(container_root) return container_root def get_all_reconstruction_files(self) -> List[Path]: diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py deleted file mode 100644 index fa4f372dd..000000000 --- a/src/sampletones_application/logic/reconstruction/browser/tree/configurations.py +++ /dev/null @@ -1,151 +0,0 @@ -from typing import List, Sequence, Tuple - -from sampletones_application.logic.reconstruction.browser.tree.containers import ( - find_or_create_group, -) -from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( - DirectoryEntry, -) -from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( - ReconstructionEntry, -) -from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( - ReconstructionScan, - ScanEntry, -) -from sampletones_core.configs.display import ( - DISPLAY_SEPARATOR, - GAMMA_PREFIX, - format_nes_frequency, - format_sample_rate, - format_spectrum_method, - unique_display_names, -) -from sampletones_core.structures.tree import ( - ConfigNode, - FileSystemNode, - NodeType, - TreeNode, - create_directory_node, -) - - -def build_configuration_branch( - scan: ReconstructionScan, - *, - name: str, - parent: TreeNode, -) -> TreeNode: - """Builds the branch listing reconstructions by the configuration that produced them. - - The scanned folders appear as they sit on disk, and a top-level configuration directory is then - lifted under frequency ▶ method groups and named by its generators, so configurations sharing a - spectrum read side by side. A configuration directory nested inside a plain folder keeps its - friendly name in place, and a reconstruction outside every configuration directory is listed - here, this being the branch that follows the disk. - """ - branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent) - for entry in scan.entries: - _append_entry(entry, parent=branch) - - _organize_top_level_config_directories(branch) - return branch - - -def _append_entry(entry: ScanEntry, *, parent: TreeNode) -> None: - match entry: - case ReconstructionEntry(): - FileSystemNode( - entry.name, - node_type=NodeType.FILE, - filepath=entry.path, - parent=parent, - ) - case DirectoryEntry(): - directory_node = create_directory_node( - entry.path, - name=entry.name, - config=entry.config, - parent=parent, - ) - for child_entry in entry.entries: - _append_entry(child_entry, parent=directory_node) - - -def _organize_top_level_config_directories(branch: TreeNode) -> None: - """Groups top-level config directories under frequencies/method nodes, leaving other folders flat. - - A config directory moves under ``frequencies`` ▶ ``method`` artificial group nodes and is - renamed to its generator abbreviation, while any other top-level folder keeps the existing - flat friendly naming for the config directories nested inside it. - """ - for child in list(branch.children): - match child: - case ConfigNode() if child.node_type == NodeType.DIRECTORY: - _attach_config_directory_under_groups(child, branch) - case FileSystemNode() if child.node_type == NodeType.DIRECTORY: - _assign_directory_display_names(child) - - _disambiguate_generator_siblings(branch) - - -def _attach_config_directory_under_groups( - directory_node: ConfigNode, - branch: TreeNode, -) -> None: - fields = directory_node.config - frequencies_name = DISPLAY_SEPARATOR.join( - [ - format_sample_rate(fields.sr), - format_nes_frequency(fields.nf), - ] - ) - method_name = DISPLAY_SEPARATOR.join( - [ - format_spectrum_method(fields.sm), - f"{GAMMA_PREFIX}{fields.tg}", - ] - ) - frequencies_node = find_or_create_group(frequencies_name, parent=branch) - method_node = find_or_create_group(method_name, parent=frequencies_node) - - directory_node.name = fields.gn - directory_node.parent = method_node - - -def _disambiguate_generator_siblings(node: TreeNode) -> None: - """Appends a short config hash to generator directories sharing a name under one method group.""" - if node.node_type == NodeType.GROUP: - _rename_config_directories( - [(directory_node, directory_node.config.gn) for directory_node in _config_directory_children(node)] - ) - - for child in node.children: - _disambiguate_generator_siblings(child) - - -def _assign_directory_display_names(node: TreeNode) -> None: - """Renames config-directory nodes to friendly labels, disambiguating colliding siblings. - - Only directories whose names parse as reconstruction config directories are rewritten; - plain folders keep their on-disk name. The check is scoped per parent because duplicate - display names among siblings would otherwise collapse to duplicate widget tags downstream. - """ - _rename_config_directories( - [(directory_node, directory_node.config.display_name) for directory_node in _config_directory_children(node)] - ) - for child in node.children: - _assign_directory_display_names(child) - - -def _config_directory_children(node: TreeNode) -> List[ConfigNode]: - return [child for child in node.children if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY] - - -def _rename_config_directories( - proposed_names: Sequence[Tuple[ConfigNode, str]], -) -> None: - """Names each configuration directory, marking those a sibling would otherwise shadow.""" - labels = unique_display_names([(name, directory_node.config.ch) for directory_node, name in proposed_names]) - for (directory_node, _), label in zip(proposed_names, labels): - directory_node.name = label diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/__init__.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/branch.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/branch.py new file mode 100644 index 000000000..702c6a280 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/branch.py @@ -0,0 +1,61 @@ +from sampletones_application.logic.reconstruction.browser.tree.configurations.grouping import ( + organize_top_level_config_directories, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( + DirectoryEntry, + ScanEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) +from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( + ReconstructionScan, +) +from sampletones_core.structures.tree import ( + FileSystemNode, + NodeType, + TreeNode, + create_directory_node, +) + + +def build_configuration_branch( + scan: ReconstructionScan, + *, + name: str, + parent: TreeNode, +) -> TreeNode: + """Builds the branch listing reconstructions by the configuration that produced them. + + The scanned folders appear as they sit on disk, and a top-level configuration directory is then + lifted under frequency ▶ method groups and named by its generators, so configurations sharing a + spectrum read side by side. A configuration directory nested inside a plain folder keeps its + friendly name in place, and a reconstruction outside every configuration directory is listed + here, this being the branch that follows the disk. + """ + branch = TreeNode(name, node_type=NodeType.GROUP, parent=parent) + for entry in scan.entries: + _append_entry(entry, parent=branch) + + organize_top_level_config_directories(branch) + return branch + + +def _append_entry(entry: ScanEntry, *, parent: TreeNode) -> None: + match entry: + case ReconstructionEntry(): + FileSystemNode( + entry.name, + node_type=NodeType.FILE, + filepath=entry.path, + parent=parent, + ) + case DirectoryEntry(): + directory_node = create_directory_node( + entry.path, + name=entry.name, + config=entry.config, + parent=parent, + ) + for child_entry in entry.entries: + _append_entry(child_entry, parent=directory_node) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py new file mode 100644 index 000000000..c98c32653 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/grouping.py @@ -0,0 +1,52 @@ +from sampletones_application.logic.reconstruction.browser.tree.configurations.naming import ( + assign_display_names, + disambiguate_generator_siblings, +) +from sampletones_application.logic.reconstruction.browser.tree.containers import ( + find_or_create_group, +) +from sampletones_core.configs.display import ( + format_frequencies, + format_transformation, +) +from sampletones_core.structures.tree import ( + ConfigNode, + FileSystemNode, + NodeType, + TreeNode, +) + + +def organize_top_level_config_directories(branch: TreeNode) -> None: + """Groups top-level config directories under frequencies/transformation nodes, leaving other folders flat. + + A config directory moves under ``frequencies`` ▶ ``transformation`` artificial group nodes and is + renamed to its generator abbreviation, while any other top-level folder keeps the existing + flat friendly naming for the config directories nested inside it. + """ + for child in list(branch.children): + match child: + case ConfigNode() if child.node_type == NodeType.DIRECTORY: + _attach_config_directory_under_groups(child, branch) + case FileSystemNode() if child.node_type == NodeType.DIRECTORY: + assign_display_names(child) + + disambiguate_generator_siblings(branch) + + +def _attach_config_directory_under_groups( + directory_node: ConfigNode, + branch: TreeNode, +) -> None: + fields = directory_node.config + frequencies_node = find_or_create_group( + format_frequencies(fields.sr, fields.nf), + parent=branch, + ) + transformation_node = find_or_create_group( + format_transformation(fields.sm, fields.tg), + parent=frequencies_node, + ) + + directory_node.name = fields.gn + directory_node.parent = transformation_node diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py new file mode 100644 index 000000000..aac9cec3c --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/configurations/naming.py @@ -0,0 +1,42 @@ +from typing import List, Sequence, Tuple + +from sampletones_core.configs.display import unique_display_names +from sampletones_core.structures.tree import ConfigNode, NodeType, TreeNode + + +def disambiguate_generator_siblings(node: TreeNode) -> None: + """Appends a short config hash to generator directories sharing a name under one method group.""" + if node.node_type == NodeType.GROUP: + _rename_config_directories( + [(directory_node, directory_node.config.gn) for directory_node in _config_directory_children(node)] + ) + + for child in node.children: + disambiguate_generator_siblings(child) + + +def assign_display_names(node: TreeNode) -> None: + """Renames config-directory nodes to friendly labels, disambiguating colliding siblings. + + Only directories whose names parse as reconstruction config directories are rewritten; + plain folders keep their on-disk name. The check is scoped per parent because duplicate + display names among siblings would otherwise collapse to duplicate widget tags downstream. + """ + _rename_config_directories( + [(directory_node, directory_node.config.display_name) for directory_node in _config_directory_children(node)] + ) + for child in node.children: + assign_display_names(child) + + +def _config_directory_children(node: TreeNode) -> List[ConfigNode]: + return [child for child in node.children if isinstance(child, ConfigNode) and child.node_type == NodeType.DIRECTORY] + + +def _rename_config_directories( + proposed_names: Sequence[Tuple[ConfigNode, str]], +) -> None: + """Names each configuration directory, marking those a sibling would otherwise shadow.""" + labels = unique_display_names([(name, directory_node.config.ch) for directory_node, name in proposed_names]) + for (directory_node, _), label in zip(proposed_names, labels): + directory_node.name = label diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/containers.py b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py index b12eb43e4..18687ede8 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/containers.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/containers.py @@ -1,5 +1,14 @@ +from typing import Final, FrozenSet + from sampletones_core.structures.tree import NodeType, TreeNode +ARTIFICIAL_CONTAINERS: Final[FrozenSet[NodeType]] = frozenset( + { + NodeType.GROUP, + NodeType.SAMPLE, + } +) + def find_or_create_group(name: str, *, parent: TreeNode) -> TreeNode: """Answers the group of this name under ``parent``, adding one where the parent holds none. diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py index 582e6e86d..ccc1ec18d 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/directory.py @@ -1,15 +1,15 @@ from dataclasses import dataclass from pathlib import Path -from typing import TYPE_CHECKING, Optional, Tuple +from typing import Optional, Tuple, TypeAlias, Union +from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( + ReconstructionEntry, +) from sampletones_core.reconstructions.converter.paths.fields import ( ConfigDirectoryFields, ) -if TYPE_CHECKING: - from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( - ScanEntry, - ) +ScanEntry: TypeAlias = Union["DirectoryEntry", ReconstructionEntry] @dataclass(frozen=True) @@ -17,12 +17,13 @@ class DirectoryEntry: """A folder a scan met, holding the configuration its name states and the entries inside it. A folder whose name encodes a reconstruction configuration carries those fields, read once here, - so every branch builder states the configuration from the record it already has. + so every branch builder states the configuration from the record it already has. A folder holds + folders as readily as reconstructions, which is why the entry kinds are named together here. """ path: Path config: Optional[ConfigDirectoryFields] - entries: Tuple["ScanEntry", ...] + entries: Tuple[ScanEntry, ...] @property def name(self) -> str: diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py index 2cc3edc5e..c5d007e87 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/entries/scan.py @@ -1,15 +1,14 @@ from dataclasses import dataclass -from typing import List, Sequence, Tuple, TypeAlias, Union +from typing import List, Sequence, Tuple from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( DirectoryEntry, + ScanEntry, ) from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( ReconstructionEntry, ) -ScanEntry: TypeAlias = Union[DirectoryEntry, ReconstructionEntry] - @dataclass(frozen=True) class ReconstructionScan: diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/order.py b/src/sampletones_application/logic/reconstruction/browser/tree/order.py new file mode 100644 index 000000000..901f8517a --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/order.py @@ -0,0 +1,25 @@ +from typing import Tuple + +from sampletones_core.structures.tree import NodeType, TreeNode +from sampletones_shared.utils.text import NaturalSortKey, natural_sort_key + + +def order_children(node: TreeNode) -> None: + """Sorts every set of siblings into reading order: what opens first, then names read naturally. + + The pass runs once every label is final, so a row sits where its displayed name puts it — `8 kHz` + ahead of `44.1 kHz`, whatever the folder names on disk spell. The branches directly under the + container root keep the order the browser states them in. + """ + for child in node.children: + order_children(child) + + if node.node_type != NodeType.ROOT: + node.children = tuple(sorted(node.children, key=_sibling_key)) + + +def _sibling_key(node: TreeNode) -> Tuple[bool, NaturalSortKey]: + return ( + node.node_type == NodeType.FILE, + natural_sort_key(str(node.name)), + ) diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/prune.py b/src/sampletones_application/logic/reconstruction/browser/tree/prune.py new file mode 100644 index 000000000..1bc61eef9 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/prune.py @@ -0,0 +1,20 @@ +from sampletones_application.logic.reconstruction.browser.tree.containers import ( + ARTIFICIAL_CONTAINERS, +) +from sampletones_core.structures.tree import TreeNode + + +def prune_empty_containers(node: TreeNode) -> None: + """Drops the containers the browser invents that gather nothing, deepest first. + + A group or a sample is a heading the browser writes itself, so one left holding nothing says + nothing and leaves. Working from the deepest rows upwards lets a whole chain of such headings go + at once, the branch root among them, which keeps a reconstructions directory holding nothing to + show silent. A folder the disk holds stays where it is, since the configuration branch reads the + disk as it is. + """ + for child in list(node.children): + prune_empty_containers(child) + + if node.node_type in ARTIFICIAL_CONTAINERS and not node.children and node.parent is not None: + node.parent = None diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py index fcc7133c5..cd34400ef 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py @@ -3,13 +3,13 @@ from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( DirectoryEntry, + ScanEntry, ) from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( ReconstructionEntry, ) from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, - ScanEntry, ) from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index c5c0ba9fa..0dc0d2640 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -127,8 +127,8 @@ global.traceback.label.hide: "Hide traceback" # ============================================================================= global.browser.label.root: "Root" global.browser.label.browser: "Browser" -global.browser.label.reconstructions: "Reconstructions" -global.browser.label.samples: "Samples" +global.browser.label.by_configuration: "By configuration" +global.browser.label.by_sample: "By sample" global.browser.label.search: "Search" global.browser.label.filter: "Filter" global.browser.label.clear_search: "Clear" diff --git a/src/sampletones_core/configs/display.py b/src/sampletones_core/configs/display.py index 02bcb9bf8..80b92f032 100644 --- a/src/sampletones_core/configs/display.py +++ b/src/sampletones_core/configs/display.py @@ -39,6 +39,34 @@ def format_spectrum_method(method: SpectrumMethod) -> str: return SPECTRUM_METHOD_LABELS[method] +def format_transformation_gamma(transformation_gamma: int) -> str: + """Marks a transformation gamma with ``γ`` (e.g. ``γ0``).""" + return f"{GAMMA_PREFIX}{transformation_gamma}" + + +def format_frequencies(sample_rate: int, nes_frequency: int) -> str: + """Renders the rates a reconstruction runs at, audio before frame (e.g. ``44.1 kHz·30 Hz``).""" + return DISPLAY_SEPARATOR.join( + [ + format_sample_rate(sample_rate), + format_nes_frequency(nes_frequency), + ], + ) + + +def format_transformation( + spectrum_method: SpectrumMethod, + transformation_gamma: int, +) -> str: + """Renders the spectrum a library was built from, method before gamma (e.g. ``FFT·γ0``).""" + return DISPLAY_SEPARATOR.join( + [ + format_spectrum_method(spectrum_method), + format_transformation_gamma(transformation_gamma), + ], + ) + + def short_hash(config_hash: str) -> str: return config_hash[:DISPLAY_HASH_LENGTH] diff --git a/src/sampletones_core/library/filename/utils.py b/src/sampletones_core/library/filename/utils.py index 194cd7008..26b0927bc 100644 --- a/src/sampletones_core/library/filename/utils.py +++ b/src/sampletones_core/library/filename/utils.py @@ -2,10 +2,8 @@ from sampletones_core.configs.display import ( DISPLAY_SEPARATOR, - GAMMA_PREFIX, - format_nes_frequency, - format_sample_rate, - format_spectrum_method, + format_frequencies, + format_transformation, ) from sampletones_core.library.filename.fields import InstructionsFilenameFields from sampletones_core.library.key import InstructionLibraryKey @@ -39,12 +37,9 @@ def create_key_from_filename(filename: Pathlike) -> InstructionLibraryKey: def get_display_name_from_key(key: InstructionLibraryKey) -> str: nes_frequency = round(key.sample_rate / key.frame_length) - gamma = f"{GAMMA_PREFIX}{key.transformation_gamma}" return DISPLAY_SEPARATOR.join( [ - format_sample_rate(key.sample_rate), - format_nes_frequency(nes_frequency), - format_spectrum_method(key.spectrum_method), - gamma, + format_frequencies(key.sample_rate, nes_frequency), + format_transformation(key.spectrum_method, key.transformation_gamma), ] ) diff --git a/src/sampletones_core/reconstructions/converter/paths/fields.py b/src/sampletones_core/reconstructions/converter/paths/fields.py index 4da06eb4e..9ff18843c 100644 --- a/src/sampletones_core/reconstructions/converter/paths/fields.py +++ b/src/sampletones_core/reconstructions/converter/paths/fields.py @@ -5,10 +5,8 @@ from sampletones_core.configs import Config from sampletones_core.configs.display import ( DISPLAY_SEPARATOR, - GAMMA_PREFIX, - format_nes_frequency, - format_sample_rate, - format_spectrum_method, + format_frequencies, + format_transformation, ) from sampletones_core.constants.enums import ( GENERATOR_ABBREVIATION_PATTERN, @@ -93,10 +91,8 @@ def directory_name(self) -> str: def display_name(self) -> str: return DISPLAY_SEPARATOR.join( [ - format_sample_rate(self.sr), - format_nes_frequency(self.nf), - format_spectrum_method(self.sm), - f"{GAMMA_PREFIX}{self.tg}", + format_frequencies(self.sr, self.nf), + format_transformation(self.sm, self.tg), self.gn, ] ) diff --git a/src/sampletones_shared/utils/text.py b/src/sampletones_shared/utils/text.py new file mode 100644 index 000000000..1abc9f9e8 --- /dev/null +++ b/src/sampletones_shared/utils/text.py @@ -0,0 +1,32 @@ +import re +from typing import Final, Tuple, TypeAlias + +NaturalSortKey: TypeAlias = Tuple[Tuple[int, str], ...] + +_DIGIT_RUN_PATTERN: Final[re.Pattern[str]] = re.compile(r"(\d+)") + + +def natural_sort_key(text: str) -> NaturalSortKey: + """ + Builds the sort key that orders text the way a reader expects. + + Digit runs compare as the numbers they spell, so `8 kHz` precedes `44.1 kHz`, and the text + around them compares case-insensitively, so `Amen` and `amen` sit together. The text itself + closes the key, so two labels reading alike keep a fixed order. + + Args: + text: The label to order by. + + Returns: + A tuple comparing as the reading order of the label. + + Examples: + >>> sorted(["44.1 kHz", "8 kHz"], key=natural_sort_key) + ['8 kHz', '44.1 kHz'] + >>> sorted(["track10", "track2"], key=natural_sort_key) + ['track2', 'track10'] + """ + tokens = tuple( + (int(part), "") if part.isdecimal() else (0, part.casefold()) for part in _DIGIT_RUN_PATTERN.split(text) + ) + return tokens + ((0, text),) diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py index 4273ac947..04664f09a 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, Final +from typing import Dict, Final, List from unittest.mock import MagicMock import pytest @@ -7,13 +7,13 @@ from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.logic.reconstruction.browser.tree.entries.directory import ( DirectoryEntry, + ScanEntry, ) from sampletones_application.logic.reconstruction.browser.tree.entries.reconstruction import ( ReconstructionEntry, ) from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, - ScanEntry, ) from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.paths import EXT_FILE_RECONSTRUCTION @@ -27,8 +27,8 @@ RECONSTRUCTIONS: Final[Path] = Path("/reconstructions") BRANCH_NAME: Final[str] = "branch" -CONFIGURATION_BRANCH_KEY: Final[str] = "global.browser.label.reconstructions" -SAMPLE_BRANCH_KEY: Final[str] = "global.browser.label.samples" +CONFIGURATION_BRANCH_KEY: Final[str] = "global.browser.label.by_configuration" +SAMPLE_BRANCH_KEY: Final[str] = "global.browser.label.by_sample" def config_fields( @@ -88,6 +88,40 @@ def write_reconstruction(directory: Path, *relative_parts: str) -> Path: return path +def container_root() -> TreeNode: + return TreeNode("Root", node_type=NodeType.ROOT) + + +def group_node(name: str, parent: TreeNode) -> TreeNode: + return TreeNode(name, node_type=NodeType.GROUP, parent=parent) + + +def sample_node(name: str, parent: TreeNode) -> TreeNode: + return TreeNode(name, node_type=NodeType.SAMPLE, parent=parent) + + +def directory_node(name: str, parent: TreeNode) -> FileSystemNode: + return FileSystemNode( + name, + node_type=NodeType.DIRECTORY, + filepath=RECONSTRUCTIONS / name, + parent=parent, + ) + + +def file_node(name: str, parent: TreeNode) -> FileSystemNode: + return FileSystemNode( + name, + node_type=NodeType.FILE, + filepath=(RECONSTRUCTIONS / name).with_suffix(EXT_FILE_RECONSTRUCTION), + parent=parent, + ) + + +def child_names(node: TreeNode) -> List[str]: + return [str(child.name) for child in node.children] + + def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: return { child.name: child diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py index 718dcf1a7..c8a5bdea3 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_configurations.py @@ -1,18 +1,15 @@ from typing import Dict -from sampletones_application.logic.reconstruction.browser.tree.configurations import ( +from sampletones_application.logic.reconstruction.browser.tree.configurations.branch import ( build_configuration_branch, ) from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, ) from sampletones_core.configs.display import ( - DISPLAY_SEPARATOR, - GAMMA_PREFIX, disambiguated_display_name, - format_nes_frequency, - format_sample_rate, - format_spectrum_method, + format_frequencies, + format_transformation, ) from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields @@ -48,30 +45,33 @@ def build_branch(scan: ReconstructionScan) -> TreeNode: def frequencies_name(fields: ConfigDirectoryFields) -> str: - return DISPLAY_SEPARATOR.join([format_sample_rate(fields.sr), format_nes_frequency(fields.nf)]) + return format_frequencies(fields.sr, fields.nf) -def method_name(fields: ConfigDirectoryFields) -> str: - return DISPLAY_SEPARATOR.join([format_spectrum_method(fields.sm), f"{GAMMA_PREFIX}{fields.tg}"]) +def transformation_name(fields: ConfigDirectoryFields) -> str: + return format_transformation(fields.sm, fields.tg) -def generator_directories(branch: TreeNode, fields: ConfigDirectoryFields) -> Dict[str, FileSystemNode]: +def generator_directories( + branch: TreeNode, + fields: ConfigDirectoryFields, +) -> Dict[str, FileSystemNode]: frequencies_node = group_children(branch)[frequencies_name(fields)] - return directory_children(group_children(frequencies_node)[method_name(fields)]) + return directory_children(group_children(frequencies_node)[transformation_name(fields)]) class TestTopLevelConfigDirectories: - def test_config_directory_groups_by_frequency_then_method(self) -> None: + def test_config_directory_groups_by_frequencies_then_transformation(self) -> None: fields = config_fields(generators="PpT") branch = build_branch(scan_of(config_entry(fields, "song"))) frequencies = group_children(branch) assert set(frequencies) == {frequencies_name(fields)} - methods = group_children(frequencies[frequencies_name(fields)]) - assert set(methods) == {method_name(fields)} + transformations = group_children(frequencies[frequencies_name(fields)]) + assert set(transformations) == {transformation_name(fields)} - assert set(directory_children(methods[method_name(fields)])) == {fields.gn} + assert set(directory_children(transformations[transformation_name(fields)])) == {fields.gn} def test_config_directory_keeps_its_reconstructions(self) -> None: fields = config_fields() @@ -99,7 +99,7 @@ def test_colliding_generators_get_a_hash_suffix(self) -> None: disambiguated_display_name(second.gn, HASH_B), } - def test_distinct_generators_share_a_method_group_under_their_own_names(self) -> None: + def test_distinct_generators_share_a_transformation_group_under_their_own_names(self) -> None: first = config_fields(generators="PTN") second = config_fields(generators="TN") branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) @@ -113,13 +113,13 @@ def test_distinct_frequencies_form_separate_groups(self) -> None: assert set(group_children(branch)) == {frequencies_name(first), frequencies_name(second)} - def test_distinct_methods_form_separate_groups(self) -> None: + def test_distinct_transformations_form_separate_groups(self) -> None: first = config_fields(spectrum_method=SpectrumMethod.FFT) second = config_fields(spectrum_method=SpectrumMethod.CQT) branch = build_branch(scan_of(config_entry(first, "song"), config_entry(second, "song"))) - methods = group_children(group_children(branch)[frequencies_name(first)]) - assert set(methods) == {method_name(first), method_name(second)} + transformations = group_children(group_children(branch)[frequencies_name(first)]) + assert set(transformations) == {transformation_name(first), transformation_name(second)} class TestPlainFolders: diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py index 085ac5ed1..825eefe24 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py @@ -11,6 +11,7 @@ config_directory, config_fields, configuration_branch, + directory_children, file_children, group_children, sample_branch, @@ -29,13 +30,44 @@ def test_missing_directory_leaves_no_root( browser_manager.refresh_tree() assert browser_manager.tree.root is None - def test_root_holds_both_branches(self, browser_manager: BrowserManager) -> None: + def test_root_holds_both_branches( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + write_reconstruction(config_directory(tmp_path, config_fields()), "song") + browser_manager.refresh_tree() root = browser_manager.tree.get_root() assert root is not None assert list(group_children(root)) == [CONFIGURATION_BRANCH_KEY, SAMPLE_BRANCH_KEY] + def test_directory_holding_nothing_to_show_leaves_no_branches( + self, + browser_manager: BrowserManager, + ) -> None: + """Both views are headings over reconstructions, so neither is offered where there are none.""" + browser_manager.refresh_tree() + + root = browser_manager.tree.get_root() + assert root is not None + assert root.children == () + + def test_the_configuration_branch_still_lists_a_folder_holding_no_reconstruction( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + (tmp_path / "empty").mkdir() + + browser_manager.refresh_tree() + + root = browser_manager.tree.get_root() + assert root is not None + assert list(group_children(root)) == [CONFIGURATION_BRANCH_KEY] + assert set(directory_children(configuration_branch(browser_manager))) == {"empty"} + def test_reconstruction_is_reachable_from_both_branches( self, browser_manager: BrowserManager, diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_order.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_order.py new file mode 100644 index 000000000..911a9a5fb --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_order.py @@ -0,0 +1,88 @@ +from sampletones_application.logic.reconstruction.browser.tree.order import order_children + +from .conftest import ( + CONFIGURATION_BRANCH_KEY, + SAMPLE_BRANCH_KEY, + child_names, + container_root, + directory_node, + file_node, + group_node, + sample_node, +) + + +class TestNameOrder: + def test_numbers_read_as_numbers(self) -> None: + """A frequency group sits by the value its label states, whatever the folder name spells.""" + root = container_root() + branch = group_node("branch", root) + for name in ("44.1 kHz·30 Hz", "8 kHz·60 Hz", "22.05 kHz·30 Hz"): + group_node(name, branch) + + order_children(root) + + assert child_names(branch) == ["8 kHz·60 Hz", "22.05 kHz·30 Hz", "44.1 kHz·30 Hz"] + + def test_names_read_as_a_reader_reads_them(self) -> None: + """A capital letter states nothing about order, so names read alphabetically as they look.""" + root = container_root() + branch = group_node("branch", root) + for name in ("Beats", "amen", "Cymbals"): + sample_node(name, branch) + + order_children(root) + + assert child_names(branch) == ["amen", "Beats", "Cymbals"] + + def test_order_reaches_every_level(self) -> None: + root = container_root() + branch = group_node("branch", root) + sample = sample_node("song", branch) + for name in ("FFT·γ0", "CQT·γ0"): + file_node(name, sample) + + order_children(root) + + assert child_names(sample) == ["CQT·γ0", "FFT·γ0"] + + +class TestContainersFirst: + def test_folders_and_groups_precede_reconstructions(self) -> None: + root = container_root() + branch = group_node("branch", root) + file_node("aaa", branch) + group_node("zzz group", branch) + directory_node("zzz folder", branch) + sample_node("zzz sample", branch) + + order_children(root) + + assert child_names(branch) == ["zzz folder", "zzz group", "zzz sample", "aaa"] + + +class TestBranches: + def test_branches_keep_the_order_the_browser_states(self) -> None: + """The two views read in the order they are built, rather than by the labels they carry.""" + root = container_root() + group_node(CONFIGURATION_BRANCH_KEY, root) + group_node(SAMPLE_BRANCH_KEY, root) + + order_children(root) + + assert child_names(root) == [CONFIGURATION_BRANCH_KEY, SAMPLE_BRANCH_KEY] + + +class TestSubtrees: + def test_reordered_rows_keep_what_they_hold(self) -> None: + root = container_root() + branch = group_node("branch", root) + second = group_node("second", branch) + file_node("song", second) + group_node("first", branch) + + order_children(root) + + assert child_names(branch) == ["first", "second"] + assert child_names(second) == ["song"] + assert second.parent is branch diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_prune.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_prune.py new file mode 100644 index 000000000..41fdd30ff --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_prune.py @@ -0,0 +1,103 @@ +from sampletones_application.logic.reconstruction.browser.tree.prune import ( + prune_empty_containers, +) +from sampletones_core.structures.tree import NodeType + +from .conftest import ( + child_names, + container_root, + directory_node, + file_node, + group_node, + sample_node, +) + + +class TestEmptyContainers: + def test_group_holding_nothing_leaves(self) -> None: + root = container_root() + group_node("44.1 kHz·30 Hz", root) + + prune_empty_containers(root) + + assert root.children == () + + def test_sample_holding_nothing_leaves(self) -> None: + root = container_root() + sample_node("cw_amen02_165", root) + + prune_empty_containers(root) + + assert root.children == () + + def test_a_whole_chain_of_empty_containers_leaves(self) -> None: + """The deepest rows go first, so a heading emptied by its own children goes with them.""" + root = container_root() + branch = group_node("branch", root) + sample_node("cw_amen02_165", group_node("Amen Breaks", branch)) + + prune_empty_containers(root) + + assert root.children == () + + def test_the_container_root_stays(self) -> None: + root = container_root() + group_node("branch", root) + + prune_empty_containers(root) + + assert root.node_type == NodeType.ROOT + assert root.parent is None + + +class TestGatheringContainers: + def test_group_holding_a_reconstruction_stays(self) -> None: + root = container_root() + file_node("song", group_node("branch", root)) + + prune_empty_containers(root) + + assert child_names(root) == ["branch"] + + def test_sample_holding_its_variants_stays(self) -> None: + root = container_root() + sample = sample_node("song", root) + file_node("44.1 kHz·30 Hz·FFT·γ0·PTN", sample) + + prune_empty_containers(root) + + assert child_names(root) == ["song"] + assert child_names(sample) == ["44.1 kHz·30 Hz·FFT·γ0·PTN"] + + def test_a_branch_keeps_the_containers_leading_to_a_reconstruction(self) -> None: + root = container_root() + branch = group_node("branch", root) + kept = group_node("Amen Breaks", branch) + file_node("song", sample_node("cw_amen02_165", kept)) + group_node("Beats", branch) + + prune_empty_containers(root) + + assert child_names(branch) == ["Amen Breaks"] + assert child_names(kept) == ["cw_amen02_165"] + + +class TestFolders: + def test_folder_holding_nothing_stays(self) -> None: + """The configuration branch reads the disk as it is, so an empty folder is still a folder.""" + root = container_root() + branch = group_node("branch", root) + directory_node("empty", branch) + + prune_empty_containers(root) + + assert child_names(branch) == ["empty"] + + def test_group_holding_only_an_empty_folder_stays(self) -> None: + root = container_root() + branch = group_node("branch", root) + directory_node("empty", group_node("44.1 kHz·30 Hz", branch)) + + prune_empty_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz"] diff --git a/tests/unit/sampletones_core/configs/test_display.py b/tests/unit/sampletones_core/configs/test_display.py index 365c70ce3..a5f3cee13 100644 --- a/tests/unit/sampletones_core/configs/test_display.py +++ b/tests/unit/sampletones_core/configs/test_display.py @@ -6,11 +6,15 @@ DISPLAY_HASH_LENGTH, DISPLAY_SEPARATOR, disambiguated_display_name, + format_frequencies, format_nes_frequency, format_sample_rate, + format_transformation, + format_transformation_gamma, short_hash, unique_display_names, ) +from sampletones_core.constants.enums import SpectrumMethod class TestFormatSampleRate: @@ -33,6 +37,21 @@ def test_appends_hertz_unit(self) -> None: assert format_nes_frequency(30) == "30 Hz" +class TestFormatTransformationGamma: + def test_marks_the_gamma(self) -> None: + assert format_transformation_gamma(0) == "γ0" + + +class TestFormatFrequencies: + def test_reads_audio_rate_then_frame_rate(self) -> None: + assert format_frequencies(44100, 30) == "44.1 kHz·30 Hz" + + +class TestFormatTransformation: + def test_reads_method_then_gamma(self) -> None: + assert format_transformation(SpectrumMethod.FFT, 2) == "FFT·γ2" + + class TestShortHash: def test_truncates_to_display_length(self) -> None: full = "6edf7c948606917a78b45d153c7ca7e0" diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py index b3040208b..ce127850c 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_fields.py @@ -3,10 +3,10 @@ from sampletones_core.configs import Config from sampletones_core.configs.display import ( DISPLAY_SEPARATOR, - GAMMA_PREFIX, format_nes_frequency, format_sample_rate, format_spectrum_method, + format_transformation_gamma, ) from sampletones_core.constants.enums import GeneratorName, abbreviate_generator_names from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields @@ -89,6 +89,6 @@ def test_display_name_combines_formatted_parts(self, config: Config) -> None: assert format_sample_rate(config.library.sample_rate) in display assert format_nes_frequency(config.library.nes_frequency) in display assert format_spectrum_method(config.library.spectrum_method) in display - assert f"{GAMMA_PREFIX}{config.library.transformation_gamma}" in display + assert format_transformation_gamma(config.library.transformation_gamma) in display assert abbreviate_generator_names(list(config.generation.generators)) in display assert DISPLAY_SEPARATOR in display diff --git a/tests/unit/sampletones_shared/utils/test_text.py b/tests/unit/sampletones_shared/utils/test_text.py new file mode 100644 index 000000000..c5b960799 --- /dev/null +++ b/tests/unit/sampletones_shared/utils/test_text.py @@ -0,0 +1,53 @@ +from typing import List + +import pytest + +from sampletones_shared.utils.text import natural_sort_key + + +class TestNumbers: + @pytest.mark.parametrize( + ("names", "expected"), + [ + (["44.1 kHz", "8 kHz"], ["8 kHz", "44.1 kHz"]), + (["track10", "track2"], ["track2", "track10"]), + (["10", "9", "100"], ["9", "10", "100"]), + (["γ10", "γ2"], ["γ2", "γ10"]), + ], + ) + def test_digit_runs_compare_as_numbers( + self, + names: List[str], + expected: List[str], + ) -> None: + assert sorted(names, key=natural_sort_key) == expected + + def test_leading_zeros_keep_a_fixed_order(self) -> None: + """``01`` and ``1`` state the same number, and the text itself settles which reads first.""" + assert sorted(["1", "01"], key=natural_sort_key) == ["01", "1"] + + def test_a_number_reads_before_the_text_beside_it(self) -> None: + assert sorted(["kick", "2 kick"], key=natural_sort_key) == ["2 kick", "kick"] + + +class TestText: + def test_case_states_nothing_about_order(self) -> None: + assert sorted(["Beats", "amen", "Cymbals"], key=natural_sort_key) == ["amen", "Beats", "Cymbals"] + + def test_names_reading_alike_keep_a_fixed_order(self) -> None: + assert sorted(["song", "Song"], key=natural_sort_key) == ["Song", "song"] + + def test_a_shorter_name_reads_first(self) -> None: + assert sorted(["amen breaks", "amen"], key=natural_sort_key) == ["amen", "amen breaks"] + + def test_the_empty_name_reads_first(self) -> None: + assert sorted(["", "a"], key=natural_sort_key) == ["", "a"] + + +class TestKey: + def test_one_name_reaches_one_key(self) -> None: + assert natural_sort_key("44.1 kHz") == natural_sort_key("44.1 kHz") + + def test_a_name_the_reader_alone_can_spell(self) -> None: + """A digit-like glyph outside the decimal digits is text, and the key states it as text.""" + assert sorted(["m²", "m1"], key=natural_sort_key) == ["m1", "m²"] From 9f839b81a63ae60a8e3ae6a6107fc93d383ea920 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 13:26:17 +0200 Subject: [PATCH 118/152] Fixed: favourite repainting across both browser views --- src/sampletones_application/application.py | 12 + .../coordinators/tabs/reconstruction.py | 7 +- .../coordinators/tabs/sequencer.py | 8 +- .../logic/shared/tree.py | 17 +- .../ui/elements/tree/tree.py | 16 +- .../ui/panels/main/explorer.py | 6 +- .../ui/panels/shared/browser.py | 3 +- src/sampletones_core/structures/tree/tree.py | 29 ++- .../logic/shared/test_tree.py | 42 ++++ .../ui/elements/tree/test_favorites.py | 217 ++++++++++++++++++ .../structures/tree/test_tree.py | 38 ++- 11 files changed, 372 insertions(+), 23 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_favorites.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 680c85369..d05cdd636 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -144,6 +144,7 @@ from sampletones_core.paths import EXT_FILES_AUDIO from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction +from sampletones_core.structures.tree import FileSystemNode from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.registry import build_tracker_backends @@ -392,6 +393,7 @@ def __init__( on_reconstruct_file=self._reconstruct_file_dialog, on_reconstruct_directory=self._reconstruct_directory_dialog, on_change_audio_state=self._update_menu, + on_favorite_changed=self._repaint_reconstruction_favorites, on_reconstruction_instrument_updated=self._regenerate_instrument, is_operation_active=self._is_operation_active, original_audio_locator=self._original_audio_locator, @@ -457,6 +459,7 @@ def __init__( dialogs=self.dialogs, status_bar=self.status_bar, on_edit_sample_requested=self._edit_project_sample, + on_favorite_changed=self._repaint_reconstruction_favorites, on_sample_reconstruction_replaced=self._rebind_replaced_sample, on_tab_switch=self._set_current_tab, on_nes_frequency_changed=self._retune_samples_for_rate, @@ -928,6 +931,15 @@ def _refresh_reconstruction_trees(self) -> None: self._reconstructions_tab.refresh_browser() self._sequencer_tab.refresh_browser() + def _repaint_reconstruction_favorites(self, node: FileSystemNode) -> None: + """Repaints the toggled path in both browsers, whichever tab the star was clicked in. + + The two browsers render one tree and read one set of favorites, so each of them holds a row + for the path that just changed. + """ + self._reconstructions_tab.repaint_browser_favorites(node) + self._sequencer_tab.repaint_browser_favorites(node) + def _navigate_to_reconstructions(self) -> None: self._set_current_tab(Tab.RECONSTRUCTIONS) diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 9d343ade6..9221ac1c6 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -80,6 +80,7 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters.truncation import EnvelopeTruncation from sampletones_core.paths import EXT_FILE_WAVE +from sampletones_core.structures.tree import FileSystemNode from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.scope import ExportScope @@ -113,6 +114,7 @@ def __init__( on_reconstruct_file: VoidCallback, on_reconstruct_directory: VoidCallback, on_change_audio_state: VoidCallback, + on_favorite_changed: Callable[[FileSystemNode], None], on_reconstruction_instrument_updated: OnReconstructionInstrumentUpdatedCallback, is_operation_active: Callable[[], bool], original_audio_locator: OriginalAudioLocator, @@ -167,7 +169,7 @@ def __init__( initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_BROWSER_PANEL), ) self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled - self._browser_tree_logic.on_favorite_changed = self._browser_panel.update_favorite_indicator + self._browser_tree_logic.on_favorite_changed = on_favorite_changed self._browser_tree_logic.on_search_update_needed = self._browser_panel.update_tree_visibility self._browser_tree_logic.on_autoplay_error = self._on_browser_autoplay_error self._browser_panel.set_collapse_handler(self._on_browser_collapse_changed) @@ -549,6 +551,9 @@ def unlock(self) -> None: def refresh_browser(self) -> None: self._browser_panel.refresh() + def repaint_browser_favorites(self, node: FileSystemNode) -> None: + self._browser_panel.update_favorite_indicator(node) + def display_reconstruction(self) -> None: self._reconstruction_panel_logic.display_reconstruction() self._reconstruction_instruments_logic.update_display() diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index ea2b13b8a..1123cf159 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -127,6 +127,7 @@ from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.project.song_position import SongPosition from sampletones_core.reconstructions import Reconstruction +from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger from sampletones_shared.types.callback import StringCallback, VoidCallback @@ -157,6 +158,7 @@ def __init__( dialogs: DialogsRenderer, status_bar: GUIStatusBar, on_edit_sample_requested: StringCallback, + on_favorite_changed: Callable[[FileSystemNode], None], on_sample_reconstruction_replaced: Callable[[str, Reconstruction], None], on_tab_switch: Callable[[Tab], None], on_nes_frequency_changed: Callable[[int], None], @@ -167,6 +169,7 @@ def __init__( self._history = history self._original_audio_locator = original_audio_locator self._on_edit_sample_requested = on_edit_sample_requested + self._on_favorite_changed = on_favorite_changed self._on_sample_reconstruction_replaced = on_sample_reconstruction_replaced self._on_tab_switch = on_tab_switch self._on_nes_frequency_changed = on_nes_frequency_changed @@ -633,7 +636,7 @@ def _wire_browser_callbacks(self) -> None: self._sequencer_browser_panel.on_locate_original_audio = self._original_audio_locator.locate self._sequencer_browser_panel.on_refresh_tree = self._sequencer_browser_logic.refresh_tree self._sequencer_tree_logic.on_lock_state_changed = self._sequencer_browser_panel.set_tree_enabled - self._sequencer_tree_logic.on_favorite_changed = self._sequencer_browser_panel.update_favorite_indicator + self._sequencer_tree_logic.on_favorite_changed = self._on_favorite_changed self._sequencer_tree_logic.on_search_update_needed = self._sequencer_browser_panel.update_tree_visibility self._sequencer_tree_logic.on_autoplay_error = self._on_preview_error @@ -947,6 +950,9 @@ def repaint(self) -> None: def refresh_browser(self) -> None: self._sequencer_browser_panel.refresh() + def repaint_browser_favorites(self, node: FileSystemNode) -> None: + self._sequencer_browser_panel.update_favorite_indicator(node) + def _on_song_changed(self) -> None: self._sequencer_tracker_logic.push_settings() self._sequencer_tracker_logic.push_tracker() diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index 004735363..e10c4b83d 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -147,17 +147,14 @@ def is_node_favorite(self, node: TreeNode) -> bool: return node.filepath in self._session_manager.favorites def has_favorite_ancestor(self, node: FileSystemNode) -> bool: - current_node = node.parent - while current_node is not None: - if not isinstance(current_node, FileSystemNode): - break + """Whether a favorite directory holds this path, at any depth above it. - if self.is_node_favorite(current_node): - return True - - current_node = current_node.parent - - return False + The answer reads the path rather than the rows above it, so it holds wherever a view puts + the node: a reconstruction listed under the sample it came from sits below groups the + browser invented, and the directory that makes it a favorite child is still on its path. + """ + favorites = self._session_manager.favorites + return any(directory in favorites for directory in node.filepath.parents) def toggle_favorite(self, node: FileSystemNode) -> None: self._session_manager.toggle_favorite(node.filepath) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index cac7306bb..359578535 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -870,8 +870,20 @@ def _context_mark_as_favorite(self, node: TreeNode) -> None: self._logic.toggle_favorite(node) def update_favorite_indicator(self, node: FileSystemNode) -> None: - has_favorite_ancestor = self._logic.has_favorite_ancestor(node) - self._reapply_theme_recursively(node, has_favorite_ancestor) + """Repaints every row standing for the toggled path, and what each of them holds. + + A path reaches the panel as many rows as the views offer it — a reconstruction is listed + both by its configuration and by the sample it came from — and the star belongs to the path, + so each of those rows takes the new theme. + """ + for twin in self._nodes_at(node.filepath): + self._reapply_theme_recursively( + twin, + self._logic.has_favorite_ancestor(twin), + ) + + def _nodes_at(self, filepath: Path) -> Tuple[FileSystemNode, ...]: + return self.tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath) @abstractmethod def set_tree_enabled(self, enabled: bool) -> None: ... diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 774381772..c20fbd944 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -236,12 +236,11 @@ def _collect_subtree_specs( self._pending_specs = [] if self._explorer_logic.is_directory_expanded(node.filepath): for child in node.children: - has_favorite_ancestor = self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(child) self._build_tree_node( child, TreeNodeState( parent=node_tag, - has_favorite_ancestor=has_favorite_ancestor, + has_favorite_ancestor=self._logic.has_favorite_ancestor(child), ), ) @@ -261,8 +260,7 @@ def _build_tree_node( if not isinstance(node, FileSystemNode): return - is_favorite = self._logic.is_node_favorite(node) - state.has_favorite_ancestor |= is_favorite + state.has_favorite_ancestor |= self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(node) if node.node_type == NodeType.DIRECTORY: should_expand = self._should_expand_node(node) or self._explorer_logic.is_directory_expanded(node.filepath) diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 09cb97509..844759580 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -206,8 +206,7 @@ def _build_tree_node( if not isinstance(node, FileSystemNode): return - is_favorite = self._logic.is_node_favorite(node) - state.has_favorite_ancestor |= is_favorite + state.has_favorite_ancestor |= self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(node) if node.node_type == NodeType.DIRECTORY: should_expand = self._should_expand_node(node) self._append_spec( diff --git a/src/sampletones_core/structures/tree/tree.py b/src/sampletones_core/structures/tree/tree.py index 07881c24d..1b8ed0025 100644 --- a/src/sampletones_core/structures/tree/tree.py +++ b/src/sampletones_core/structures/tree/tree.py @@ -1,9 +1,11 @@ -from typing import Callable, Dict, Optional, Sequence +from typing import Callable, Dict, Optional, Sequence, Tuple, Type, TypeVar from anytree import PreOrderIter from .node import TreeNode +TreeNodeT = TypeVar("TreeNodeT", bound=TreeNode) + class Tree: def __init__(self, root: Optional[TreeNode] = None) -> None: @@ -64,6 +66,31 @@ def is_node_visible(self, node: TreeNode) -> bool: return self._node_visibility.get(node, False) + def find_nodes( + self, + node_class: Type[TreeNodeT], + predicate: Callable[[TreeNodeT], bool], + ) -> Tuple[TreeNodeT, ...]: + """Answers every node of ``node_class`` the predicate accepts, in reading order. + + One thing can stand in several places in a tree — a file listed by its configuration and + again by the sample it came from — so a caller acting on a thing rather than on a row asks + for all of its nodes at once. Naming the node class keeps the answer typed, so the caller + reads the fields that class carries. + """ + if self.root is None: + return () + + return tuple( + node + for node in PreOrderIter(self.root) + if isinstance( + node, + node_class, + ) + and predicate(node) + ) + def collect_leaves(self) -> Sequence[TreeNode]: if not self.root: return [] diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py index f12e2fcec..3a7763e86 100644 --- a/tests/unit/sampletones_application/logic/shared/test_tree.py +++ b/tests/unit/sampletones_application/logic/shared/test_tree.py @@ -262,6 +262,48 @@ def test_has_favorite_ancestor_returns_true_when_parent_is_favorite( child.parent = parent assert tree.has_favorite_ancestor(child) is True + def test_has_favorite_ancestor_reads_the_path_rather_than_the_rows_above( + self, + tmp_path: Path, + ) -> None: + """A view may list a file under invented rows, and the favorite directory is still its own.""" + directory_path = tmp_path / "config" + session_manager = MagicMock() + session_manager.favorites = {directory_path} + tree = _tree(session_manager=session_manager) + node = _file_node(directory_path / "song.stn") + node.parent = TreeNode("cw_amen02_165", NodeType.SAMPLE) + assert tree.has_favorite_ancestor(node) is True + + def test_has_favorite_ancestor_reaches_any_depth( + self, + tmp_path: Path, + ) -> None: + session_manager = MagicMock() + session_manager.favorites = {tmp_path} + tree = _tree(session_manager=session_manager) + node = _file_node(tmp_path / "config" / "album" / "song.stn") + assert tree.has_favorite_ancestor(node) is True + + def test_has_favorite_ancestor_returns_false_for_a_favorite_sibling( + self, + tmp_path: Path, + ) -> None: + session_manager = MagicMock() + session_manager.favorites = {tmp_path / "other.wav"} + tree = _tree(session_manager=session_manager) + assert tree.has_favorite_ancestor(_file_node(tmp_path / "audio.wav")) is False + + def test_has_favorite_ancestor_returns_false_for_the_node_itself( + self, + tmp_path: Path, + ) -> None: + filepath = tmp_path / "audio.wav" + session_manager = MagicMock() + session_manager.favorites = {filepath} + tree = _tree(session_manager=session_manager) + assert tree.has_favorite_ancestor(_file_node(filepath)) is False + def test_toggle_favorite_delegates_to_session( self, tmp_path: Path, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py new file mode 100644 index 000000000..2f5f5e36c --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -0,0 +1,217 @@ +from pathlib import Path +from typing import Final, List, Set, Tuple + +import pytest + +from sampletones_application.tags.general import ( + TAG_GLOBAL_THEME_DEFAULT, + TAG_GLOBAL_THEME_FAVORITE_CHILD, +) +from sampletones_application.ui.elements.tree.handler import NodeHandler +from sampletones_application.ui.elements.tree.spec import NodeSpec +from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode + +CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions/sr_44100_nf_30") +SONG_PATH: Final[Path] = CONFIG_DIRECTORY / "song.stn" +OTHER_PATH: Final[Path] = CONFIG_DIRECTORY / "other.stn" + +Repaints = List[Tuple[TreeNode, bool]] + + +class FakeTreeLogic: + def __init__(self, favorites: Set[Path]) -> None: + self._favorites = favorites + + def is_node_favorite(self, node: TreeNode) -> bool: + return isinstance(node, FileSystemNode) and node.filepath in self._favorites + + def has_favorite_ancestor(self, node: FileSystemNode) -> bool: + return any(directory in self._favorites for directory in node.filepath.parents) + + +def browser_tree() -> Tree: + """Builds the shape both browser views give one reconstructions directory. + + The same reconstruction is listed by its configuration and again by the sample it came from, so + one path reaches the panel as two rows. + """ + root = TreeNode("Root", node_type=NodeType.ROOT) + configurations = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root) + directory = FileSystemNode( + "PTN", + node_type=NodeType.DIRECTORY, + filepath=CONFIG_DIRECTORY, + parent=configurations, + ) + FileSystemNode("song", node_type=NodeType.FILE, filepath=SONG_PATH, parent=directory) + FileSystemNode("other", node_type=NodeType.FILE, filepath=OTHER_PATH, parent=directory) + + samples = TreeNode("By sample", node_type=NodeType.GROUP, parent=root) + sample = TreeNode("song", node_type=NodeType.SAMPLE, parent=samples) + FileSystemNode( + "44.1 kHz·30 Hz", + node_type=NodeType.FILE, + filepath=SONG_PATH, + parent=sample, + ) + return Tree(root=root) + + +@pytest.fixture +def repaints() -> Repaints: + return [] + + +def build_panel( + tree: Tree, + favorites: Set[Path], + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, +) -> GUISequencerBrowserPanel: + """Builds a browser panel that records the rows it would repaint. + + Repainting binds themes to widgets, so the theme pass stands in as a recorder here and the + panel keeps only the tree and the logic the favorite pass reads. + """ + panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) + panel.tree = tree + monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) + monkeypatch.setattr( + panel, + "_reapply_theme_recursively", + lambda node, has_favorite_ancestor=False: repaints.append((node, has_favorite_ancestor)), + raising=False, + ) + return panel + + +def node_at(tree: Tree, filepath: Path) -> FileSystemNode: + return tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath)[0] + + +def build_specs( + tree: Tree, + favorites: Set[Path], + monkeypatch: pytest.MonkeyPatch, +) -> List[NodeSpec]: + """Collects the rows a browser refresh would emit for a tree, with the themes it resolves. + + The collecting pass runs off the main thread and touches no widget, so it needs only the tree, + the logic it asks about favorites, and a tag per row. + """ + panel = build_panel(tree, favorites, [], monkeypatch) + panel._pending_specs = [] + panel._node_handlers = { + node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType + } + monkeypatch.setattr(panel, "_generate_node_tag", lambda node: f"row.{node.name}", raising=False) + + panel._build_tree_node(tree.get_root(), TreeNodeState(parent="tree")) + return panel._pending_specs + + +def theme_of(specs: List[NodeSpec], label: str) -> str: + return next(spec.theme_tag for spec in specs if spec.label == label) + + +class TestTwinRepaint: + def test_every_row_standing_for_the_path_repaints( + self, + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = browser_tree() + panel = build_panel(tree, {SONG_PATH}, repaints, monkeypatch) + + panel.update_favorite_indicator(node_at(tree, SONG_PATH)) + + assert [node.name for node, _ in repaints] == ["song", "44.1 kHz·30 Hz"] + + def test_a_path_listed_once_repaints_once( + self, + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = browser_tree() + panel = build_panel(tree, {OTHER_PATH}, repaints, monkeypatch) + + panel.update_favorite_indicator(node_at(tree, OTHER_PATH)) + + assert [node.name for node, _ in repaints] == ["other"] + + def test_a_path_the_tree_states_nowhere_repaints_nothing( + self, + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = browser_tree() + panel = build_panel(tree, set(), repaints, monkeypatch) + elsewhere = FileSystemNode( + "elsewhere", + node_type=NodeType.FILE, + filepath=Path("/elsewhere/song.stn"), + ) + + panel.update_favorite_indicator(elsewhere) + + assert repaints == [] + + def test_a_favorite_directory_repaints_where_each_view_holds_it( + self, + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = browser_tree() + panel = build_panel(tree, {CONFIG_DIRECTORY}, repaints, monkeypatch) + + panel.update_favorite_indicator(node_at(tree, CONFIG_DIRECTORY)) + + assert [node.name for node, _ in repaints] == ["PTN"] + + +class TestFavoriteAncestry: + def test_each_row_repaints_with_the_ancestry_of_its_path( + self, + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A favorite configuration directory tints the reconstruction in both views.""" + tree = browser_tree() + panel = build_panel(tree, {CONFIG_DIRECTORY}, repaints, monkeypatch) + + panel.update_favorite_indicator(node_at(tree, SONG_PATH)) + + assert [has_favorite_ancestor for _, has_favorite_ancestor in repaints] == [True, True] + + def test_a_row_no_favorite_holds_repaints_plainly( + self, + repaints: Repaints, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = browser_tree() + panel = build_panel(tree, {SONG_PATH}, repaints, monkeypatch) + + panel.update_favorite_indicator(node_at(tree, SONG_PATH)) + + assert [has_favorite_ancestor for _, has_favorite_ancestor in repaints] == [False, False] + + +class TestFavoriteAncestryWhileBuilding: + def test_a_directory_below_a_favorite_the_view_omits_is_tinted( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The reconstructions directory holds the row without being a row itself, and still counts.""" + tree = browser_tree() + specs = build_specs(tree, {CONFIG_DIRECTORY.parent}, monkeypatch) + assert theme_of(specs, "PTN") == TAG_GLOBAL_THEME_FAVORITE_CHILD + + def test_a_directory_no_favorite_holds_reads_plainly( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = browser_tree() + specs = build_specs(tree, set(), monkeypatch) + assert theme_of(specs, "PTN") == TAG_GLOBAL_THEME_DEFAULT diff --git a/tests/unit/sampletones_core/structures/tree/test_tree.py b/tests/unit/sampletones_core/structures/tree/test_tree.py index 0fd62e7df..a0d2f2357 100644 --- a/tests/unit/sampletones_core/structures/tree/test_tree.py +++ b/tests/unit/sampletones_core/structures/tree/test_tree.py @@ -1,13 +1,16 @@ from dataclasses import dataclass -from typing import List +from pathlib import Path +from typing import Final, List import pytest -from sampletones_core.structures.tree.node import TreeNode +from sampletones_core.structures.tree.node import FileSystemNode, TreeNode from sampletones_core.structures.tree.tree import Tree from sampletones_core.structures.tree.type import NodeType from tests.suite.case import BaseTestCase +SONG_PATH: Final[Path] = Path("/reconstructions/song.stn") + def name_predicate(node: TreeNode, query: str) -> bool: return query in node.name @@ -171,3 +174,34 @@ def test_filtered_leaves_exclude_hidden(self, tree: Tree) -> None: leaves = tree.collect_leaves() assert len(leaves) == 1 assert leaves[0].name == "leaf_ba" + + +class TestTreeFindNodes: + @staticmethod + def _tree_with_twins() -> Tree: + root = TreeNode("root", NodeType.ROOT) + by_configuration = TreeNode("by_configuration", NodeType.GROUP, parent=root) + by_sample = TreeNode("by_sample", NodeType.GROUP, parent=root) + FileSystemNode("song", NodeType.FILE, SONG_PATH, parent=by_configuration) + FileSystemNode("44.1 kHz", NodeType.FILE, SONG_PATH, parent=by_sample) + FileSystemNode("other", NodeType.FILE, Path("/reconstructions/other.stn"), parent=by_sample) + return Tree(root=root) + + def test_empty_tree_answers_nothing(self) -> None: + assert Tree().find_nodes(TreeNode, lambda node: True) == () + + def test_every_node_standing_for_one_path_is_answered(self) -> None: + tree = self._tree_with_twins() + twins = tree.find_nodes(FileSystemNode, lambda node: node.filepath == SONG_PATH) + assert [twin.name for twin in twins] == ["song", "44.1 kHz"] + + def test_nodes_of_other_classes_stay_out(self) -> None: + tree = self._tree_with_twins() + assert all(isinstance(node, FileSystemNode) for node in tree.find_nodes(FileSystemNode, lambda node: True)) + + def test_the_answer_reads_in_tree_order(self, tree: Tree) -> None: + found = tree.find_nodes(TreeNode, lambda node: node.node_type == NodeType.FILE) + assert [node.name for node in found] == ["leaf_aa", "leaf_ab", "leaf_ba"] + + def test_a_predicate_nothing_answers_gives_nothing(self, tree: Tree) -> None: + assert tree.find_nodes(FileSystemNode, lambda node: True) == () From 0557715b7cce9f97a3e9031b84e5d86016f9774f Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 13:42:28 +0200 Subject: [PATCH 119/152] Added: single-child group collapsing in the browser --- docs/development/bugs-and-todos.md | 2 + .../logic/reconstruction/browser/manager.py | 8 +- .../reconstruction/browser/tree/collapse.py | 57 ++++++++ .../logic/reconstruction/browser/conftest.py | 9 ++ .../reconstruction/browser/test_collapse.py | 127 ++++++++++++++++++ .../reconstruction/browser/test_manager.py | 93 +++++++++++-- 6 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 src/sampletones_application/logic/reconstruction/browser/tree/collapse.py create mode 100644 tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index 398ba1476..f80bda099 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -36,7 +36,9 @@ * Respecting FamiTracker limitations * Per-tab undo routing * In-application console +* Improve performance of browser favorite scan of the entire tree per click ## Bugs * No refreshing after library generation +* Misaligned dialog boxes sizes at initialization diff --git a/src/sampletones_application/logic/reconstruction/browser/manager.py b/src/sampletones_application/logic/reconstruction/browser/manager.py index e8eed1bae..f9d3b8d36 100644 --- a/src/sampletones_application/logic/reconstruction/browser/manager.py +++ b/src/sampletones_application/logic/reconstruction/browser/manager.py @@ -3,6 +3,9 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager +from sampletones_application.logic.reconstruction.browser.tree.collapse import ( + collapse_single_child_containers, +) from sampletones_application.logic.reconstruction.browser.tree.configurations.branch import ( build_configuration_branch, ) @@ -26,8 +29,8 @@ class BrowserManager: """Owns the reconstruction browser tree, rebuilt from one reading of the reconstructions directory. A refresh scans the directory, builds the configuration branch and the sample branch from that - one reading, shapes what came out — empty headings pruned, siblings ordered — and publishes the - result as the tree both browser tabs render. + one reading, shapes what came out — empty headings pruned, lone headings folded into the row they + lead to, siblings ordered — and publishes the result as the tree both browser tabs render. """ def __init__( @@ -73,6 +76,7 @@ def _build_root(self, scan: ReconstructionScan) -> TreeNode: ) prune_empty_containers(container_root) + collapse_single_child_containers(container_root) order_children(container_root) return container_root diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py new file mode 100644 index 000000000..1bb4026c3 --- /dev/null +++ b/src/sampletones_application/logic/reconstruction/browser/tree/collapse.py @@ -0,0 +1,57 @@ +from sampletones_application.logic.reconstruction.browser.tree.containers import ( + ARTIFICIAL_CONTAINERS, +) +from sampletones_core.configs.display import DISPLAY_SEPARATOR +from sampletones_core.structures.tree import NodeType, TreeNode + + +def collapse_single_child_containers(node: TreeNode) -> None: + """Folds every heading the browser invents that stands above a single row into that row. + + A heading leading to one row asks the reader to open a level that tells them nothing new, so the + row takes the heading's name ahead of its own and rises into its place. Working from the deepest + rows upwards folds a whole chain at once, one separator per level: with a single configuration + present the configuration branch reads ``44.1 kHz·30 Hz·FFT·γ0·PTN`` as one row, and it grows back + into groups as soon as a second configuration arrives. + + The row that survives keeps its node type, its path, its configuration and its children, so its + click behaviour, theme, context menu and favorite star carry over from before the fold. The two + branch roots stay in place, since each names a way of reading the whole tree, and a folder the disk + holds stays a folder of its own, since the configuration branch mirrors the disk. + """ + for child in list(node.children): + collapse_single_child_containers(child) + + if _can_fold(node): + _fold_into_child(node) + + +def _can_fold(node: TreeNode) -> bool: + parent = node.parent + if parent is None or parent.node_type == NodeType.ROOT: + return False + + if node.node_type not in ARTIFICIAL_CONTAINERS or len(node.children) != 1: + return False + + return not _siblings_hold(node, _joined_name(node, node.children[0])) + + +def _siblings_hold(node: TreeNode, name: str) -> bool: + """Whether a row beside this heading already reads as the name the fold would produce. + + The folded row joins the siblings of the heading it replaces, and a browser row is addressed by + the names leading to it, so a heading whose fold would repeat a name beside it stays as it is. + """ + return any(sibling.name == name for sibling in node.parent.children if sibling is not node) + + +def _fold_into_child(node: TreeNode) -> None: + child = node.children[0] + child.name = _joined_name(node, child) + child.parent = node.parent + node.parent = None + + +def _joined_name(node: TreeNode, child: TreeNode) -> str: + return DISPLAY_SEPARATOR.join([str(node.name), str(child.name)]) diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py index 04664f09a..41662f905 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -122,6 +122,15 @@ def child_names(node: TreeNode) -> List[str]: return [str(child.name) for child in node.children] +def reconstruction_paths(node: TreeNode) -> List[Path]: + """Answers the reconstructions a branch offers, wherever the rows of that branch put them.""" + return sorted( + descendant.filepath + for descendant in node.descendants + if isinstance(descendant, FileSystemNode) and descendant.node_type == NodeType.FILE + ) + + def directory_children(node: TreeNode) -> Dict[str, FileSystemNode]: return { child.name: child diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py new file mode 100644 index 000000000..80c0c9b97 --- /dev/null +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_collapse.py @@ -0,0 +1,127 @@ +from sampletones_application.logic.reconstruction.browser.tree.collapse import ( + collapse_single_child_containers, +) +from sampletones_core.structures.tree import NodeType + +from .conftest import ( + child_names, + container_root, + directory_node, + file_node, + group_node, + sample_node, +) + + +class TestLoneHeadings: + def test_a_group_leading_to_one_row_folds_into_it(self) -> None: + root = container_root() + branch = group_node("branch", root) + file_node("song", group_node("44.1 kHz·30 Hz", branch)) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz·song"] + + def test_a_chain_folds_into_one_row(self) -> None: + """The deepest heading folds first, so each level it passes adds one separator.""" + root = container_root() + branch = group_node("branch", root) + frequencies = group_node("44.1 kHz·30 Hz", branch) + file_node("song", group_node("FFT·γ0", frequencies)) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz·FFT·γ0·song"] + + def test_a_sample_leading_to_one_variant_folds_into_it(self) -> None: + root = container_root() + branch = group_node("branch", root) + file_node("44.1 kHz·30 Hz·FFT·γ0·PTN", sample_node("cw_amen02_165", branch)) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["cw_amen02_165·44.1 kHz·30 Hz·FFT·γ0·PTN"] + + def test_the_folded_row_keeps_what_it_carries(self) -> None: + root = container_root() + branch = group_node("branch", root) + reconstruction = file_node("song", group_node("44.1 kHz·30 Hz", branch)) + held = reconstruction.filepath + + collapse_single_child_containers(root) + + folded = branch.children[0] + assert folded is reconstruction + assert folded.node_type == NodeType.FILE + assert folded.filepath == held + + def test_a_folded_group_keeps_the_children_it_led_to(self) -> None: + root = container_root() + branch = group_node("branch", root) + directory = directory_node("Amen Breaks", group_node("44.1 kHz·30 Hz", branch)) + file_node("song", directory) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz·Amen Breaks"] + assert child_names(directory) == ["song"] + + +class TestHeadingsThatStay: + def test_a_group_gathering_several_rows_stays(self) -> None: + root = container_root() + branch = group_node("branch", root) + frequencies = group_node("44.1 kHz·30 Hz", branch) + file_node("first", frequencies) + file_node("second", frequencies) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz"] + assert child_names(frequencies) == ["first", "second"] + + def test_a_branch_root_stays(self) -> None: + """Each branch names a way of reading the whole tree, so it heads its rows however few they are.""" + root = container_root() + branch = group_node("branch", root) + file_node("song", branch) + + collapse_single_child_containers(root) + + assert child_names(root) == ["branch"] + assert child_names(branch) == ["song"] + + def test_a_folder_leading_to_one_row_stays(self) -> None: + """The configuration branch mirrors the disk, so a folder holding one file is still a folder.""" + root = container_root() + branch = group_node("branch", root) + directory = directory_node("Amen Breaks", branch) + file_node("song", directory) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["Amen Breaks"] + assert child_names(directory) == ["song"] + + def test_a_group_whose_fold_would_repeat_a_name_beside_it_stays(self) -> None: + root = container_root() + branch = group_node("branch", root) + frequencies = group_node("44.1 kHz·30 Hz", branch) + file_node("song", frequencies) + file_node("44.1 kHz·30 Hz·song", branch) + + collapse_single_child_containers(root) + + assert child_names(branch) == ["44.1 kHz·30 Hz", "44.1 kHz·30 Hz·song"] + assert child_names(frequencies) == ["song"] + + def test_the_container_root_stays(self) -> None: + root = container_root() + file_node("song", group_node("branch", root)) + + collapse_single_child_containers(root) + + assert root.node_type == NodeType.ROOT + assert root.parent is None + assert child_names(root) == ["branch"] diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py index 825eefe24..85f345307 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py @@ -4,9 +4,15 @@ import pytest from sampletones_application.logic.reconstruction.browser.manager import BrowserManager +from sampletones_core.configs.display import ( + DISPLAY_SEPARATOR, + format_frequencies, + format_transformation, +) from .conftest import ( CONFIGURATION_BRANCH_KEY, + HASH_B, SAMPLE_BRANCH_KEY, config_directory, config_fields, @@ -14,6 +20,7 @@ directory_children, file_children, group_children, + reconstruction_paths, sample_branch, sample_children, write_reconstruction, @@ -73,19 +80,12 @@ def test_reconstruction_is_reachable_from_both_branches( browser_manager: BrowserManager, tmp_path: Path, ) -> None: - fields = config_fields() - path = write_reconstruction(config_directory(tmp_path, fields), "song") + path = write_reconstruction(config_directory(tmp_path, config_fields()), "song") browser_manager.refresh_tree() - configurations = configuration_branch(browser_manager) - frequencies = next(iter(group_children(configurations).values())) - methods = next(iter(group_children(frequencies).values())) - generators = next(iter(methods.children)) - assert file_children(generators)["song"].filepath == path - - samples = sample_branch(browser_manager) - assert file_children(sample_children(samples)["song"])[fields.display_name].filepath == path + assert reconstruction_paths(configuration_branch(browser_manager)) == [path] + assert reconstruction_paths(sample_branch(browser_manager)) == [path] def test_reads_every_folder_once( self, @@ -111,6 +111,79 @@ def counting_iterdir(directory_path: Path) -> Iterator[Path]: assert sorted(listed) == sorted(set(listed)) +class TestBranchShape: + def test_a_lone_configuration_reads_as_one_row( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + """One configuration needs no headings to be told apart, so its row carries the whole label.""" + fields = config_fields() + write_reconstruction(config_directory(tmp_path, fields), "song") + + browser_manager.refresh_tree() + + configurations = configuration_branch(browser_manager) + folded = directory_children(configurations)[fields.display_name] + + assert list(directory_children(configurations)) == [fields.display_name] + assert list(file_children(folded)) == ["song"] + + def test_the_heading_telling_two_configurations_apart_stays( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + """Two configurations sharing their rates are gathered by rate and read apart by spectrum.""" + first = config_fields() + second = config_fields(transformation_gamma=1, config_hash=HASH_B) + write_reconstruction(config_directory(tmp_path, first), "song") + write_reconstruction(config_directory(tmp_path, second), "song") + + browser_manager.refresh_tree() + + configurations = configuration_branch(browser_manager) + frequencies = group_children(configurations)[format_frequencies(first.sr, first.nf)] + + assert list(directory_children(frequencies)) == [ + DISPLAY_SEPARATOR.join([format_transformation(first.sm, first.tg), first.gn]), + DISPLAY_SEPARATOR.join([format_transformation(second.sm, second.tg), second.gn]), + ] + + def test_a_sample_reconstructed_once_reads_as_one_row( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + fields = config_fields() + write_reconstruction(config_directory(tmp_path, fields), "song") + + browser_manager.refresh_tree() + + samples = sample_branch(browser_manager) + + assert list(file_children(samples)) == [DISPLAY_SEPARATOR.join(["song", fields.display_name])] + + def test_a_sample_reconstructed_twice_keeps_its_row( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + first = config_fields() + second = config_fields(transformation_gamma=1, config_hash=HASH_B) + write_reconstruction(config_directory(tmp_path, first), "song") + write_reconstruction(config_directory(tmp_path, second), "song") + + browser_manager.refresh_tree() + + samples = sample_branch(browser_manager) + + assert list(file_children(sample_children(samples)["song"])) == [ + first.display_name, + second.display_name, + ] + + class TestSetReconstructionsDirectory: def test_directory_is_taken_over( self, From e533aab6e076d8572ddecb1e19029a1435326031 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 15:22:28 +0200 Subject: [PATCH 120/152] Extracted: the shared file-browser panel base --- src/sampletones_application/application.py | 12 +- .../coordinators/tabs/instructions.py | 7 +- .../coordinators/tabs/main.py | 7 +- .../coordinators/tabs/reconstruction.py | 18 +-- .../coordinators/tabs/sequencer.py | 6 +- .../logic/reconstruction/browser/manager.py | 13 +- .../ui/elements/tree/browser.py | 151 ++++++++++++++++++ .../ui/elements/tree/tags.py | 19 +++ .../ui/elements/tree/tree.py | 22 ++- .../ui/panels/reconstruction/browser.py | 46 +++--- .../ui/panels/sequencer/browser.py | 35 ++-- .../ui/panels/shared/browser.py | 115 +++---------- .../reconstruction/browser/test_manager.py | 42 +++++ .../ui/elements/tree/test_favorites.py | 24 ++- 14 files changed, 343 insertions(+), 174 deletions(-) create mode 100644 src/sampletones_application/ui/elements/tree/browser.py create mode 100644 src/sampletones_application/ui/elements/tree/tags.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index d05cdd636..079ae0365 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -390,12 +390,9 @@ def __init__( export_service=self.export_service, tracker_backends=self.tracker_backends, on_load_reconstruction_with_confirmation=self._reconstruction_coordinator.load_with_confirmation, - on_reconstruct_file=self._reconstruct_file_dialog, - on_reconstruct_directory=self._reconstruct_directory_dialog, on_change_audio_state=self._update_menu, on_favorite_changed=self._repaint_reconstruction_favorites, on_reconstruction_instrument_updated=self._regenerate_instrument, - is_operation_active=self._is_operation_active, original_audio_locator=self._original_audio_locator, layout=ReconstructionTabParameters.from_config(self.layout), language_manager=self.language_manager, @@ -934,11 +931,12 @@ def _refresh_reconstruction_trees(self) -> None: def _repaint_reconstruction_favorites(self, node: FileSystemNode) -> None: """Repaints the toggled path in both browsers, whichever tab the star was clicked in. - The two browsers render one tree and read one set of favorites, so each of them holds a row - for the path that just changed. + The two browsers render one tree and read one set of favorites, so the rows standing for the + toggled path are read once here and handed to each of them. """ - self._reconstructions_tab.repaint_browser_favorites(node) - self._sequencer_tab.repaint_browser_favorites(node) + nodes = self.browser_manager.nodes_at(node.filepath) + self._reconstructions_tab.repaint_browser_favorites(nodes) + self._sequencer_tab.repaint_browser_favorites(nodes) def _navigate_to_reconstructions(self) -> None: self._set_current_tab(Tab.RECONSTRUCTIONS) diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index 697422322..e2bf4b761 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -69,6 +69,7 @@ from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import LibraryGeneratorName from sampletones_core.library import InstructionLibraryKey +from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.exceptions import LibraryDisplayError, SampleToNESError from sampletones_shared.logger import logger from sampletones_shared.types.callback import VoidCallback @@ -142,7 +143,7 @@ def __init__( ) self._library_panel.set_collapse_handler(self._on_library_collapse_changed) self._library_tree_logic.on_lock_state_changed = self._library_panel.set_tree_enabled - self._library_tree_logic.on_favorite_changed = self._library_panel.update_favorite_indicator + self._library_tree_logic.on_favorite_changed = self._repaint_library_favorites self._library_tree_logic.on_search_update_needed = self._library_panel.update_tree_visibility self._library_logic.configure_lock( @@ -352,6 +353,10 @@ def _on_card_collapse_changed(self, card_tag: str, collapsed: bool) -> None: """Persists a centre-column card's collapsed state so it restores on the next launch.""" self._session_manager.set_card_collapsed(card_tag, collapsed) + def _repaint_library_favorites(self, node: FileSystemNode) -> None: + """Repaints the row whose star was toggled: the catalogue lists a library once, so it is one row.""" + self._library_panel.update_favorite_indicators((node,)) + def _on_library_collapse_changed(self, card_tag: str, collapsed: bool) -> None: """Persists the library panel's collapse, then docks or restores the width of the column it fills.""" self._session_manager.set_card_collapsed(card_tag, collapsed) diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index a84eac4fc..6d665e50e 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -62,6 +62,7 @@ ) from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import GeneratorName +from sampletones_core.structures.tree import FileSystemNode from sampletones_shared.logger import logger from sampletones_shared.types.callback import PathCallback, VoidCallback @@ -145,7 +146,7 @@ def __init__( initial_collapsed=session_manager.is_card_collapsed(TAG_MAIN_EXPLORER_PANEL), ) self._explorer_tree_logic.on_lock_state_changed = self._explorer_panel.set_tree_enabled - self._explorer_tree_logic.on_favorite_changed = self._explorer_panel.update_favorite_indicator + self._explorer_tree_logic.on_favorite_changed = self._repaint_explorer_favorites self._explorer_tree_logic.on_search_update_needed = self._explorer_panel.update_tree_visibility self._explorer_tree_logic.on_autoplay_error = self._on_explorer_autoplay_error @@ -252,6 +253,10 @@ def __init__( self._converter_panel.on_convert_requested = self._converter_logic.start_conversion self._converter_panel.on_cancel_requested = self._request_cancel_confirmation + def _repaint_explorer_favorites(self, node: FileSystemNode) -> None: + """Repaints the row whose star was toggled: the explorer mirrors the disk, so a path is one row.""" + self._explorer_panel.update_favorite_indicators((node,)) + def _on_explorer_autoplay_error(self, exception: Exception) -> None: FrameCallbackManager.set_frame_callback(lambda: self._dialogs.show_error(exception)) diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 9221ac1c6..10f805396 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Dict, Optional, Tuple +from typing import Callable, Dict, Optional, Sequence, Tuple import dearpygui.dearpygui as dpg @@ -59,7 +59,9 @@ from sampletones_application.ui.panels.reconstruction.audio import ( GUIReconstructionAudioPanel, ) -from sampletones_application.ui.panels.reconstruction.browser import GUIBrowserPanel +from sampletones_application.ui.panels.reconstruction.browser import ( + GUIReconstructionsBrowserPanel, +) from sampletones_application.ui.panels.reconstruction.instruments.instruments import ( GUIReconstructionInstrumentsPanel, ) @@ -111,12 +113,9 @@ def __init__( export_service: ExportService, tracker_backends: Dict[TrackerFormat, TrackerBackend], on_load_reconstruction_with_confirmation: Callable[[Optional[Path]], None], - on_reconstruct_file: VoidCallback, - on_reconstruct_directory: VoidCallback, on_change_audio_state: VoidCallback, on_favorite_changed: Callable[[FileSystemNode], None], on_reconstruction_instrument_updated: OnReconstructionInstrumentUpdatedCallback, - is_operation_active: Callable[[], bool], original_audio_locator: OriginalAudioLocator, *, layout: ReconstructionTabParameters, @@ -158,14 +157,13 @@ def __init__( audio_device_manager, scheduling=layout.scheduling, ) - self._browser_panel: GUIBrowserPanel = GUIBrowserPanel( + self._browser_panel: GUIReconstructionsBrowserPanel = GUIReconstructionsBrowserPanel( self._browser_logic.tree, self._browser_tree_logic, scheduling=layout.scheduling, language_manager=language_manager, status_bar=status_bar, colors=layout.tree_colors, - is_operation_active=is_operation_active, initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_BROWSER_PANEL), ) self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled @@ -220,8 +218,6 @@ def __init__( ) self._browser_panel.on_refresh_tree = self._browser_logic.refresh_tree - self._browser_panel.on_reconstruct_file = on_reconstruct_file - self._browser_panel.on_reconstruct_directory = on_reconstruct_directory self._browser_panel.on_load_reconstruction = on_load_reconstruction_with_confirmation self._browser_panel.on_reconstruction_remove_requested = self._request_remove_reconstruction self._browser_panel.on_directory_remove_requested = self._request_remove_directory @@ -551,8 +547,8 @@ def unlock(self) -> None: def refresh_browser(self) -> None: self._browser_panel.refresh() - def repaint_browser_favorites(self, node: FileSystemNode) -> None: - self._browser_panel.update_favorite_indicator(node) + def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: + self._browser_panel.update_favorite_indicators(nodes) def display_reconstruction(self) -> None: self._reconstruction_panel_logic.display_reconstruction() diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 1123cf159..5bd939a6e 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Optional, ParamSpec, Tuple, Union +from typing import Callable, Optional, ParamSpec, Sequence, Tuple, Union import dearpygui.dearpygui as dpg @@ -950,8 +950,8 @@ def repaint(self) -> None: def refresh_browser(self) -> None: self._sequencer_browser_panel.refresh() - def repaint_browser_favorites(self, node: FileSystemNode) -> None: - self._sequencer_browser_panel.update_favorite_indicator(node) + def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: + self._sequencer_browser_panel.update_favorite_indicators(nodes) def _on_song_changed(self) -> None: self._sequencer_tracker_logic.push_settings() diff --git a/src/sampletones_application/logic/reconstruction/browser/manager.py b/src/sampletones_application/logic/reconstruction/browser/manager.py index f9d3b8d36..4fd3deef9 100644 --- a/src/sampletones_application/logic/reconstruction/browser/manager.py +++ b/src/sampletones_application/logic/reconstruction/browser/manager.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import List +from typing import List, Tuple from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager @@ -22,7 +22,7 @@ from sampletones_application.logic.reconstruction.browser.tree.scan import ( scan_reconstructions, ) -from sampletones_core.structures.tree import NodeType, Tree, TreeNode +from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode class BrowserManager: @@ -82,3 +82,12 @@ def _build_root(self, scan: ReconstructionScan) -> TreeNode: def get_all_reconstruction_files(self) -> List[Path]: return sorted({entry.path for entry in self._scan.reconstructions}) + + def nodes_at(self, filepath: Path) -> Tuple[FileSystemNode, ...]: + """Answers every row the browser offers for a path, across both views. + + A reconstruction is listed by its configuration and again by the sample it came from, so a + caller acting on the file rather than on one row — repainting a favorite star, for instance — + asks here once and hands the rows to each browser tab. + """ + return self.tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath) diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py new file mode 100644 index 000000000..f5b2a70cd --- /dev/null +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -0,0 +1,151 @@ +from abc import ABC, abstractmethod + +import dearpygui.dearpygui as dpg + +from sampletones_application.categories.manager import LanguageManager +from sampletones_application.layout.behavior.scheduling.scheduling import ( + SchedulingBehavior, +) +from sampletones_application.tags.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON +from sampletones_application.ui.elements.button import GUIButton +from sampletones_application.ui.elements.layout.collapse import CollapseAxis +from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol +from sampletones_application.ui.elements.tree.tags import FileBrowserTags +from sampletones_application.ui.elements.tree.tree import GUITreePanel +from sampletones_application.ui.themes.registry import ThemeRegistry +from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.utils.parallelization.thread import concurrent +from sampletones_core.structures.tree import Tree + + +class GUIFileBrowserPanel(GUITreePanel, ABC): + """Shared skeleton of a panel offering a tree of files as a collapsible, searchable card. + + The card holds a refresh control above the search box and the tree it filters. This base builds + that arrangement, rebuilds the tree off the main thread on demand, and enables or disables the + whole card as the tree locks and unlocks. A subclass names its widgets through + :class:`FileBrowserTags`, states what its card and its refresh control read, answers what + refreshing the model means, and shapes each row. + """ + + def __init__( + self, + tree: Tree, + tree_logic: TreeLogicProtocol, + *, + tags: FileBrowserTags, + scheduling: SchedulingBehavior, + search_label: str, + language_manager: LanguageManager, + status_bar: GUIStatusBar, + colors: TreeColors, + initial_collapsed: bool, + ) -> None: + self._tags = tags + + super().__init__( + tree=tree, + tag=tags.panel, + tree_tag=tags.tree, + tree_logic=tree_logic, + scheduling=scheduling, + search_label=search_label, + language_manager=language_manager, + status_bar=status_bar, + colors=colors, + ) + + self._enable_horizontal_collapse( + initial_collapsed=initial_collapsed, + side=CollapseAxis.HORIZONTAL_LEFT, + ) + + @property + @abstractmethod + def section_label(self) -> str: ... + + @property + @abstractmethod + def section_glyph(self) -> str: ... + + @property + @abstractmethod + def refresh_button_label(self) -> str: ... + + @property + @abstractmethod + def refresh_status_message(self) -> str: ... + + def create_panel(self, parent: str) -> None: + self._setup_handlers() + with ( + dpg.child_window( + tag=self.tag, + width=self.width, + height=self.height, + parent=parent, + border=False, + ), + self._collapsible_section( + self.section_label, + glyph=self.section_glyph, + ), + ): + self._create_controls() + dpg.add_separator() + self._create_tree_window() + + self._create_detail_tooltip(self._tags.window_tree) + self.rebuild_tree() + + def _create_controls(self) -> None: + with dpg.group(tag=self._tags.group_controls): + GUIButton( + tag=self._tags.button_refresh, + label=self.refresh_button_label, + width=-1, + callback=self.rebuild_tree, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), + ) + + self._status_bar.bind_to_item( + self._tags.button_refresh, + self.refresh_status_message, + ) + + def _create_tree_window(self) -> None: + self.create_search(self._body_container) + with ( + dpg.child_window( + tag=self._tags.window_tree, + horizontal_scrollbar=True, + ), + dpg.group(tag=self._tags.group_tree), + ): + self._create_tree_root() + + def _create_tree_root(self) -> None: + """Opens the container every row attaches to, as a group the rows read directly under.""" + with dpg.group(tag=self.tree_tag): + pass + + def refresh(self) -> None: + self.rebuild_tree() + + @concurrent(wait=False, method_bound=True) + def rebuild_tree(self) -> None: + self._launch_rebuild( + self._refresh_model, + lambda: self._collect_specs(self.tree_tag), + root_tag=self.tree_tag, + ) + + @abstractmethod + def _refresh_model(self) -> None: + """Brings the model the tree renders up to date, on the background rebuild worker.""" + + def set_tree_enabled(self, enabled: bool) -> None: + dpg_configure_item(self._tags.group_tree, enabled=enabled) + dpg_configure_item(self._tags.group_controls, enabled=enabled) diff --git a/src/sampletones_application/ui/elements/tree/tags.py b/src/sampletones_application/ui/elements/tree/tags.py new file mode 100644 index 000000000..9dc9c1911 --- /dev/null +++ b/src/sampletones_application/ui/elements/tree/tags.py @@ -0,0 +1,19 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class FileBrowserTags: + """The DearPyGui tags naming one file browser's widgets, stated together where the browser is declared. + + Every browser builds the same arrangement — a panel card holding a controls group with a refresh + button, and a window holding the group the tree attaches to — so the tags naming those widgets + travel as one value the panel is constructed with. Stating them together makes each browser + declare a complete set at one place, checked where it is written. + """ + + panel: str + tree: str + window_tree: str + group_tree: str + group_controls: str + button_refresh: str diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 359578535..597305429 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from functools import partial from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union import dearpygui.dearpygui as dpg @@ -869,22 +869,20 @@ def _context_mark_as_favorite(self, node: TreeNode) -> None: self._logic.toggle_favorite(node) - def update_favorite_indicator(self, node: FileSystemNode) -> None: - """Repaints every row standing for the toggled path, and what each of them holds. + def update_favorite_indicators(self, nodes: Sequence[FileSystemNode]) -> None: + """Repaints the rows a favorite change reaches, and what each of them holds. - A path reaches the panel as many rows as the views offer it — a reconstruction is listed - both by its configuration and by the sample it came from — and the star belongs to the path, - so each of those rows takes the new theme. + A path reaches the panel as many rows as the views offer it — a reconstruction is listed both + by its configuration and by the sample it came from — and the star belongs to the path, so + the caller names every row standing for it and each of them takes the new theme with the + ancestry its own path carries. """ - for twin in self._nodes_at(node.filepath): + for node in nodes: self._reapply_theme_recursively( - twin, - self._logic.has_favorite_ancestor(twin), + node, + self._logic.has_favorite_ancestor(node), ) - def _nodes_at(self, filepath: Path) -> Tuple[FileSystemNode, ...]: - return self.tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath) - @abstractmethod def set_tree_enabled(self, enabled: bool) -> None: ... diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index ebcd61010..f2edfd4b2 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Callable, Optional +from typing import Final, Optional import dearpygui.dearpygui as dpg @@ -18,21 +18,26 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol +from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.ui.panels.shared.browser import ( GUIReconstructionBrowserPanel, ) from sampletones_core.structures.tree import FileSystemNode, Tree from sampletones_shared.types.application import Sender -from sampletones_shared.types.callback import PathCallback, VoidCallback +from sampletones_shared.types.callback import PathCallback + +_TAGS: Final[FileBrowserTags] = FileBrowserTags( + panel=TAG_RECONSTRUCTIONS_BROWSER_PANEL, + tree=TAG_RECONSTRUCTIONS_BROWSER_TREE, + window_tree=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, + group_tree=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE, + group_controls=TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, + button_refresh=TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, +) -class GUIBrowserPanel(GUIReconstructionBrowserPanel): - _panel_tag = TAG_RECONSTRUCTIONS_BROWSER_PANEL - _tree_tag = TAG_RECONSTRUCTIONS_BROWSER_TREE - _button_refresh_tag = TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS - _group_controls_tag = TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS - _group_tree_tag = TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE - _window_tree_tag = TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE +class GUIReconstructionsBrowserPanel(GUIReconstructionBrowserPanel): + """The Reconstructions tab's browser, whose reconstructions open in the tab beside it.""" def __init__( self, @@ -43,39 +48,36 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, - is_operation_active: Callable[[], bool], - initial_collapsed: bool = False, + initial_collapsed: bool, ) -> None: self._language_manager = language_manager + super().__init__( tree=tree, tree_logic=tree_logic, + tags=_TAGS, scheduling=scheduling, language_manager=language_manager, status_bar=status_bar, colors=colors, - refresh_button_label=language_manager["reconstructions.browser.label.refresh_button"], - refresh_status_message=language_manager["reconstructions.browser.message.status_refresh"], initial_collapsed=initial_collapsed, ) - self.on_reconstruct_file: Optional[VoidCallback] = None - self.on_reconstruct_directory: Optional[VoidCallback] = None self.on_load_reconstruction: Optional[PathCallback] = None self.on_reconstruction_remove_requested: Optional[PathCallback] = None self.on_directory_remove_requested: Optional[PathCallback] = None - self._is_operation_active = is_operation_active + @property + def refresh_button_label(self) -> str: + return self._language_manager["reconstructions.browser.label.refresh_button"] + + @property + def refresh_status_message(self) -> str: + return self._language_manager["reconstructions.browser.message.status_refresh"] def _open_reconstruction(self, node: FileSystemNode) -> None: self._load_reconstruction(node) - def _reconstruct_file(self) -> None: - self.call(self.on_reconstruct_file) - - def _reconstruct_directory(self) -> None: - self.call(self.on_reconstruct_directory) - def _add_directory_context_menu_items(self, node: FileSystemNode) -> None: self._add_context_menu_remove_directory_item(node) diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index c3b2aff70..e0ba21e08 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -1,3 +1,5 @@ +from typing import Final + from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, @@ -13,19 +15,24 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol +from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.ui.panels.shared.browser import ( GUIReconstructionBrowserPanel, ) from sampletones_core.structures.tree import FileSystemNode, Tree +_TAGS: Final[FileBrowserTags] = FileBrowserTags( + panel=TAG_SEQUENCER_BROWSER_PANEL, + tree=TAG_SEQUENCER_BROWSER_TREE, + window_tree=TAG_SEQUENCER_BROWSER_WINDOW_TREE, + group_tree=TAG_SEQUENCER_BROWSER_GROUP_TREE, + group_controls=TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, + button_refresh=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, +) + class GUISequencerBrowserPanel(GUIReconstructionBrowserPanel): - _panel_tag = TAG_SEQUENCER_BROWSER_PANEL - _tree_tag = TAG_SEQUENCER_BROWSER_TREE - _button_refresh_tag = TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS - _group_controls_tag = TAG_SEQUENCER_BROWSER_GROUP_CONTROLS - _group_tree_tag = TAG_SEQUENCER_BROWSER_GROUP_TREE - _window_tree_tag = TAG_SEQUENCER_BROWSER_WINDOW_TREE + """The Sequencer tab's browser, whose reconstructions become the song's samples.""" def __init__( self, @@ -36,22 +43,30 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, - initial_collapsed: bool = False, + initial_collapsed: bool, ) -> None: + self._language_manager = language_manager + super().__init__( tree=tree, tree_logic=tree_logic, + tags=_TAGS, scheduling=scheduling, language_manager=language_manager, status_bar=status_bar, colors=colors, - refresh_button_label=language_manager["sequencer.browser.label.refresh_button"], - refresh_status_message=language_manager["sequencer.browser.message.status_refresh"], initial_collapsed=initial_collapsed, ) + @property + def refresh_button_label(self) -> str: + return self._language_manager["sequencer.browser.label.refresh_button"] + + @property + def refresh_status_message(self) -> str: + return self._language_manager["sequencer.browser.message.status_refresh"] + def _open_reconstruction(self, node: FileSystemNode) -> None: - self._logic.cancel_autoplay() self.call(self.on_add_to_sequencer, node.filepath) def _add_reconstruction_context_menu_items(self, node: FileSystemNode) -> None: diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 844759580..5fd9c4c8f 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -10,20 +10,15 @@ from sampletones_application.tags.general import ( TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FILE_WAVE, - TAG_GLOBAL_THEME_SECONDARY_BUTTON, ) -from sampletones_application.ui.elements.button import GUIButton from sampletones_application.ui.elements.context_menu import context_menu -from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.elements.tree.tree import GUITreePanel -from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_configure_item -from sampletones_application.utils.parallelization.thread import concurrent +from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -36,80 +31,53 @@ from sampletones_shared.types.callback import VoidCallback -class GUIReconstructionBrowserPanel(GUITreePanel): +class GUIReconstructionBrowserPanel(GUIFileBrowserPanel): """Shared skeleton of the reconstructions browser in the Sequencer and Reconstruction tabs. - Builds the refresh button and the searchable tree, resolves every node into a spec, and routes - node clicks to the subclass through :meth:`_open_reconstruction`. The subclass supplies its DPG - tags, its displayed labels, and the extra items each context menu offers. + Reads the tree both tabs share into rows, colours the ones the browser invents, and routes node + clicks to the subclass through :meth:`_open_reconstruction`. The subclass names its widgets and + its refresh control, and adds the items its context menus offer. """ _MONOSPACE_CONFIG_NODES: bool = True - _panel_tag: str - _tree_tag: str - _button_refresh_tag: str - _group_controls_tag: str - _group_tree_tag: str - _window_tree_tag: str - def __init__( self, tree: Tree, tree_logic: TreeLogicProtocol, *, + tags: FileBrowserTags, scheduling: SchedulingBehavior, language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, - refresh_button_label: str, - refresh_status_message: str, - initial_collapsed: bool = False, + initial_collapsed: bool, ) -> None: self._language_manager = language_manager - self._browser_label = language_manager["global.browser.label.browser"] - self._refresh_button_label = refresh_button_label - self._refresh_status_message = refresh_status_message self.on_refresh_tree: Optional[VoidCallback] = None super().__init__( tree=tree, - tag=self._panel_tag, - tree_tag=self._tree_tag, tree_logic=tree_logic, + tags=tags, scheduling=scheduling, search_label=language_manager["global.browser.label.search"], language_manager=language_manager, status_bar=status_bar, colors=colors, - ) - - self._enable_horizontal_collapse( initial_collapsed=initial_collapsed, - side=CollapseAxis.HORIZONTAL_LEFT, ) - def create_panel(self, parent: str) -> None: - self._setup_handlers() - with ( - dpg.child_window( - tag=self.tag, - width=self.width, - height=self.height, - parent=parent, - border=False, - ), - self._collapsible_section( - self._browser_label, - glyph=self._glyphs.headers.reconstruction, - ), - ): - self._create_buttons() - dpg.add_separator() - self._create_tree_window() + @property + def section_label(self) -> str: + return self._language_manager["global.browser.label.browser"] + + @property + def section_glyph(self) -> str: + return self._glyphs.headers.reconstruction - self._create_detail_tooltip(self._window_tree_tag) - self.rebuild_tree() + def _refresh_model(self) -> None: + self.call(self.on_refresh_tree) def _setup_handlers(self) -> None: self._node_handlers = { @@ -139,43 +107,6 @@ def _setup_handlers(self) -> None: super()._setup_handlers() - def _create_buttons(self) -> None: - with dpg.group(tag=self._group_controls_tag): - GUIButton( - tag=self._button_refresh_tag, - label=self._refresh_button_label, - width=-1, - callback=self.rebuild_tree, - theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), - ) - self._status_bar.bind_to_item( - self._button_refresh_tag, - self._refresh_status_message, - ) - - def _create_tree_window(self) -> None: - self.create_search(self._body_container) - with ( - dpg.child_window( - tag=self._window_tree_tag, - horizontal_scrollbar=True, - ), - dpg.group(tag=self._group_tree_tag), - dpg.group(tag=self.tree_tag), - ): - pass - - def refresh(self) -> None: - self.rebuild_tree() - - @concurrent(wait=False, method_bound=True) - def rebuild_tree(self) -> None: - self._launch_rebuild( - lambda: self.call(self.on_refresh_tree), - lambda: self._collect_specs(self.tree_tag), - root_tag=self.tree_tag, - ) - def _has_relevant_content(self, node: TreeNode) -> bool: if node.node_type == NodeType.FILE: return True @@ -241,10 +172,6 @@ def _resolve_other_theme_tag(self, node: TreeNode) -> str: return super()._resolve_other_theme_tag(node) - def set_tree_enabled(self, enabled: bool) -> None: - dpg_configure_item(self._group_tree_tag, enabled=enabled) - dpg_configure_item(self._group_controls_tag, enabled=enabled) - def _on_directory_node_clicked( self, _sender: Sender, @@ -276,9 +203,15 @@ def _on_reconstruction_node_double_clicked( app_data: Tuple[int, int], user_data: Tuple[FileSystemNode, str], ) -> None: + """Opens the double-clicked reconstruction, dropping the preview the click before it queued. + + A single click queues an autoplay preview, and the second click of a double click means the + reader wants the file itself, so the preview is dropped before the subclass opens it. + """ mouse_button, _ = app_data node, _ = user_data if mouse_button == dpg.mvMouseButton_Left: + self._logic.cancel_autoplay() self._open_reconstruction(node) def _show_directory_context_menu(self, node: FileSystemNode) -> None: diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py index 85f345307..57d8dc291 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/test_manager.py @@ -9,6 +9,7 @@ format_frequencies, format_transformation, ) +from sampletones_core.structures.tree import NodeType from .conftest import ( CONFIGURATION_BRANCH_KEY, @@ -184,6 +185,47 @@ def test_a_sample_reconstructed_twice_keeps_its_row( ] +class TestNodesAt: + def test_a_reconstruction_is_answered_once_per_view( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + """Both views hold the reconstruction, so a favorite change reaches a row in each of them.""" + path = write_reconstruction(config_directory(tmp_path, config_fields()), "song") + + browser_manager.refresh_tree() + + nodes = browser_manager.nodes_at(path) + assert [node.node_type for node in nodes] == [NodeType.FILE, NodeType.FILE] + assert all(node.filepath == path for node in nodes) + + def test_a_directory_is_answered_where_it_is_listed( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + """The configuration branch mirrors the disk, and it is the branch that lists folders.""" + directory = config_directory(tmp_path, config_fields()) + write_reconstruction(directory, "first") + write_reconstruction(directory, "second") + + browser_manager.refresh_tree() + + assert [node.filepath for node in browser_manager.nodes_at(directory)] == [directory] + + def test_a_path_the_tree_holds_nowhere_is_answered_by_nothing( + self, + browser_manager: BrowserManager, + tmp_path: Path, + ) -> None: + write_reconstruction(config_directory(tmp_path, config_fields()), "song") + + browser_manager.refresh_tree() + + assert browser_manager.nodes_at(tmp_path / "elsewhere.stn") == () + + class TestSetReconstructionsDirectory: def test_directory_is_taken_over( self, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index 2f5f5e36c..488ec6727 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -87,8 +87,9 @@ def build_panel( return panel -def node_at(tree: Tree, filepath: Path) -> FileSystemNode: - return tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath)[0] +def rows_at(tree: Tree, filepath: Path) -> Tuple[FileSystemNode, ...]: + """Answers the rows the tree holds for a path, as the browser's owner hands them to the panel.""" + return tree.find_nodes(FileSystemNode, lambda node: node.filepath == filepath) def build_specs( @@ -116,7 +117,7 @@ def theme_of(specs: List[NodeSpec], label: str) -> str: return next(spec.theme_tag for spec in specs if spec.label == label) -class TestTwinRepaint: +class TestRowRepaint: def test_every_row_standing_for_the_path_repaints( self, repaints: Repaints, @@ -125,7 +126,7 @@ def test_every_row_standing_for_the_path_repaints( tree = browser_tree() panel = build_panel(tree, {SONG_PATH}, repaints, monkeypatch) - panel.update_favorite_indicator(node_at(tree, SONG_PATH)) + panel.update_favorite_indicators(rows_at(tree, SONG_PATH)) assert [node.name for node, _ in repaints] == ["song", "44.1 kHz·30 Hz"] @@ -137,7 +138,7 @@ def test_a_path_listed_once_repaints_once( tree = browser_tree() panel = build_panel(tree, {OTHER_PATH}, repaints, monkeypatch) - panel.update_favorite_indicator(node_at(tree, OTHER_PATH)) + panel.update_favorite_indicators(rows_at(tree, OTHER_PATH)) assert [node.name for node, _ in repaints] == ["other"] @@ -148,13 +149,8 @@ def test_a_path_the_tree_states_nowhere_repaints_nothing( ) -> None: tree = browser_tree() panel = build_panel(tree, set(), repaints, monkeypatch) - elsewhere = FileSystemNode( - "elsewhere", - node_type=NodeType.FILE, - filepath=Path("/elsewhere/song.stn"), - ) - panel.update_favorite_indicator(elsewhere) + panel.update_favorite_indicators(rows_at(tree, Path("/elsewhere/song.stn"))) assert repaints == [] @@ -166,7 +162,7 @@ def test_a_favorite_directory_repaints_where_each_view_holds_it( tree = browser_tree() panel = build_panel(tree, {CONFIG_DIRECTORY}, repaints, monkeypatch) - panel.update_favorite_indicator(node_at(tree, CONFIG_DIRECTORY)) + panel.update_favorite_indicators(rows_at(tree, CONFIG_DIRECTORY)) assert [node.name for node, _ in repaints] == ["PTN"] @@ -181,7 +177,7 @@ def test_each_row_repaints_with_the_ancestry_of_its_path( tree = browser_tree() panel = build_panel(tree, {CONFIG_DIRECTORY}, repaints, monkeypatch) - panel.update_favorite_indicator(node_at(tree, SONG_PATH)) + panel.update_favorite_indicators(rows_at(tree, SONG_PATH)) assert [has_favorite_ancestor for _, has_favorite_ancestor in repaints] == [True, True] @@ -193,7 +189,7 @@ def test_a_row_no_favorite_holds_repaints_plainly( tree = browser_tree() panel = build_panel(tree, {SONG_PATH}, repaints, monkeypatch) - panel.update_favorite_indicator(node_at(tree, SONG_PATH)) + panel.update_favorite_indicators(rows_at(tree, SONG_PATH)) assert [has_favorite_ancestor for _, has_favorite_ancestor in repaints] == [False, False] From 67cce88f3ffb3012574d2dd1b61fd6e1627bb3a6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 16:25:38 +0200 Subject: [PATCH 121/152] Merged: sampletones_core.paths into a sampletones_shared.paths --- .github/workflows/workflow.yml | 5 +- .gitignore | 3 + Makefile | 7 +- docs/development/dependencies.md | 9 + pyproject.toml | 1 + scripts/assets/icons.py | 359 ++++++++++++++++++ scripts/calibration.py | 2 +- scripts/checks/language_keys.py | 2 +- scripts/checks/palette_colors.py | 2 +- scripts/checks/unused_tags.py | 2 +- scripts/linux/build/build.sh | 1 + scripts/linux/build/icons.sh | 12 + scripts/linux/build/sampletones.sh | 2 +- scripts/windows/build/build.bat | 1 + scripts/windows/build/icons.bat | 14 + scripts/windows/build/sampletones.bat | 2 +- src/sampletones/__main__.py | 4 +- src/sampletones_application/application.py | 2 +- .../config/managers/config.py | 2 +- src/sampletones_application/config/profile.py | 2 +- .../config/session/state/paths.py | 2 +- .../coordinators/config.py | 2 +- .../coordinators/project.py | 2 +- .../coordinators/reconstruction.py | 2 +- .../coordinators/tabs/reconstruction.py | 2 +- .../logic/instruction/library_manager.py | 2 +- .../logic/main/explorer_manager.py | 10 +- .../logic/reconstruction/browser/tree/scan.py | 2 +- .../logic/shared/tree.py | 8 +- src/sampletones_application/paths.py | 4 +- .../ui/elements/tree/tree.py | 8 +- .../ui/panels/main/explorer.py | 24 +- .../ui/resources/items.py | 2 +- .../ui/resources/resources.py | 2 +- .../ui/themes/loader.py | 2 +- .../utils/gui/shortcuts/catalog.py | 2 +- .../utils/palette/catalog.py | 2 +- src/sampletones_assets/icons/sampletones.ico | Bin 57991 -> 0 bytes src/sampletones_assets/icons/sampletones.png | Bin 30500 -> 0 bytes src/sampletones_assets/icons/sampletones.svg | 12 + .../audio/writers/capability.py | 2 +- .../calibration/corpus/writer.py | 2 +- src/sampletones_core/calibration/paths.py | 2 +- src/sampletones_core/configs/config.py | 2 +- src/sampletones_core/configs/general.py | 2 +- .../library/filename/fields.py | 2 +- .../library/filename/utils.py | 2 +- src/sampletones_core/library/library.py | 2 +- src/sampletones_core/paths.py | 66 ---- src/sampletones_core/project/container.py | 2 +- .../reconstructions/converter/paths/utils.py | 8 +- .../trackers/implementation/bitphase.py | 2 +- .../trackers/implementation/famitracker.py | 2 +- .../meta/source/packages.py | 2 +- src/sampletones_shared/paths.py | 12 - src/sampletones_shared/paths/__init__.py | 0 src/sampletones_shared/paths/extensions.py | 24 ++ src/sampletones_shared/paths/resources.py | 24 ++ src/sampletones_shared/paths/source.py | 5 + src/sampletones_shared/paths/user.py | 23 ++ .../tooling/test_check_commands.py | 2 +- tests/suite/scripts.py | 2 +- .../config/test_profile.py | 2 +- .../logic/reconstruction/browser/conftest.py | 2 +- .../reconstruction/test_reconstruction.py | 8 +- .../logic/shared/test_tree.py | 6 +- .../ui/elements/tree/test_detail_items.py | 2 +- .../utils/gui/shortcuts/test_catalog.py | 2 +- .../utils/gui/shortcuts/test_scheme.py | 2 +- .../audio/writers/test_spec.py | 2 +- .../formats/bitphase/test_btp.py | 2 +- .../formats/bitphase/test_preset.py | 2 +- .../library/filename/test_fields.py | 2 +- .../converter/paths/test_utils.py | 2 +- .../trackers/test_bitphase.py | 2 +- .../trackers/test_extensions.py | 12 +- .../trackers/test_famitracker.py | 2 +- .../meta/source/test_packages.py | 2 +- .../unit/sampletones_shared/paths/__init__.py | 0 .../paths/test_resources.py | 7 + .../{test_paths.py => paths/test_source.py} | 10 +- .../sampletones_shared/paths/test_user.py | 16 + .../scripts/checks/test_palette_colors.py | 2 +- tests/unit/scripts/checks/test_tag_names.py | 2 +- tests/unit/scripts/checks/test_unused_tags.py | 2 +- uv.lock | 75 ++++ 86 files changed, 695 insertions(+), 185 deletions(-) create mode 100755 scripts/assets/icons.py create mode 100644 scripts/linux/build/icons.sh create mode 100644 scripts/windows/build/icons.bat delete mode 100644 src/sampletones_assets/icons/sampletones.ico delete mode 100644 src/sampletones_assets/icons/sampletones.png create mode 100644 src/sampletones_assets/icons/sampletones.svg delete mode 100644 src/sampletones_core/paths.py delete mode 100644 src/sampletones_shared/paths.py create mode 100644 src/sampletones_shared/paths/__init__.py create mode 100644 src/sampletones_shared/paths/extensions.py create mode 100644 src/sampletones_shared/paths/resources.py create mode 100644 src/sampletones_shared/paths/source.py create mode 100644 src/sampletones_shared/paths/user.py create mode 100644 tests/unit/sampletones_shared/paths/__init__.py create mode 100644 tests/unit/sampletones_shared/paths/test_resources.py rename tests/unit/sampletones_shared/{test_paths.py => paths/test_source.py} (63%) create mode 100644 tests/unit/sampletones_shared/paths/test_user.py diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 3cdb13833..2e5e7fb81 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -37,6 +37,9 @@ jobs: --tag "$GITHUB_REF_NAME" \ --project-version "$(uv version --short)" + - name: Generate the icon suite + run: uv run --only-group assets python scripts/assets/icons.py + - name: Build sdist and wheel run: uv build @@ -126,7 +129,7 @@ jobs: venv_python=.venv-build/bin/python fi "$venv_python" -m pip install --upgrade pip - "$venv_python" -m pip install ".[build]" + "$venv_python" -m pip install ".[build]" --group assets - name: Build the bundle (Linux) if: runner.os == 'Linux' diff --git a/.gitignore b/.gitignore index 88d538082..02a45caf1 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ sampletones !src/sampletones !tests/sampletones +src/sampletones_assets/icons/sampletones.ico +src/sampletones_assets/icons/sampletones.png + **/*.idea **/*.vscode/** **/*.ipynb_checkpoints/** diff --git a/Makefile b/Makefile index 9997def7d..7ab6b93d5 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ .PHONY: help setup install build release system-deps run clean pre-commit test \ - ftm-samples check-import-boundary check-tag-names check-unused-tags \ + ftm-samples icons check-import-boundary check-tag-names check-unused-tags \ check-language-keys check-palette-colors calibration lint pylint mypy format ifeq ($(OS),Windows_NT) @@ -70,6 +70,7 @@ help: @echo $(Q) make release - Compile standalone executable with the release deployment config$(Q) @echo $(Q) make test - Run unit tests with coverage$(Q) @echo $(Q) make ftm-samples - Emit example .ftm files to build/ftm via the integration suite$(Q) + @echo $(Q) make icons - Generate the icon suite into src/sampletones_assets/icons$(Q) @echo $(Q) make calibration - Score the reconstruction corpus; the report lands in Documents/SampleToNES/calibration$(Q) @echo $(Q) make clean - Remove build artifacts and cache files$(Q) @echo $(Q) make lint - Run linting (pylint, mypy)$(Q) @@ -78,6 +79,7 @@ help: setup: $(SETUP_ENV) uv sync --group dev $(if $(GPU_EXTRA),--extra $(GPU_EXTRA),) + $(MAKE) icons $(SETUP_ENV) uv tool install --force $(if $(GPU_EXTRA),".[$(GPU_EXTRA)]",.) install: @@ -109,6 +111,9 @@ ftm-samples: export SAMPLETONES_FTM_OUTPUT_DIR := build/ftm ftm-samples: uv run python -m pytest tests/integration/famitracker +icons: + uv run --group assets python scripts/assets/icons.py + check-import-boundary: uv run scripts/checks/import_boundary.py --all diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index 7d553cb03..1107c943a 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -51,6 +51,15 @@ Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser `jeepney` is declared for Linux alone, so the modules that speak to the portal are imported where it is installed: the application probes for it before reaching them, and the root `conftest.py` keeps them out of collection elsewhere, leaving the Linux runs of the suite to cover them. +## Application icon + +The icon suite in `src/sampletones_assets/icons` is generated: `scripts/assets/icons.py` holds the +mark's geometry and writes the vector `sampletones.svg` together with the rasters the application +ships, `sampletones.png` and the multi-resolution `sampletones.ico`. Rasterization uses Pillow, +declared in the `assets` dependency group. The SVG is committed as the design source, and the +rasters are produced where they are consumed: `make setup` writes them before packaging the wheel, +and the bundle scripts write them before PyInstaller embeds them. + ## Linux (standalone executable) Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. diff --git a/pyproject.toml b/pyproject.toml index 99ca6af68..d5d306021 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,6 +75,7 @@ gpu-cuda11 = [ ] [dependency-groups] +assets = ["pillow>=11,<13"] dev = [ "black==26.5.1", "isort==8.0.1", diff --git a/scripts/assets/icons.py b/scripts/assets/icons.py new file mode 100755 index 000000000..65a99a35d --- /dev/null +++ b/scripts/assets/icons.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 + +""" +Builds the application icon suite into `src/sampletones_assets/icons`. + +One geometry definition on a 64-unit grid draws the mark — a smooth sample entering as a +blue sine wave and leaving as an amber square wave, on the studio palette — and every +shipped icon derives from it: the vector `sampletones.svg`, the raster `sampletones.png`, +and the multi-resolution `sampletones.ico`. The raster filenames match the resources the +application resolves through `sampletones_shared/paths`. + +Usage: + python scripts/assets/icons.py # write the suite into src/sampletones_assets/icons +""" + +import argparse +import itertools +import sys +from pathlib import Path +from typing import Final, List, Sequence, Tuple + +from PIL import ( # TODO: update THIRD-PARTY-* files, revise LICENSE if still holds + Image, + ImageDraw, +) + +Point = Tuple[float, float] +Rectangle = Tuple[float, float, float, float] + +PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2] +ICONS_DIRECTORY: Final[Path] = PROJECT_ROOT / "src" / "sampletones_assets" / "icons" + +# TODO: take the raster filenames from sampletones_shared.paths.resources +VECTOR_FILENAME: Final[str] = "sampletones.svg" +UNIX_ICON_FILENAME: Final[str] = "sampletones.png" +WINDOWS_ICON_FILENAME: Final[str] = "sampletones.ico" + +# TODO: SVG configuration should be a YAML file based on a validated Pydantic class +# not a set hardcoded constants; I suggest a nested structure, organizing fields into +# logical units +GRID: Final[int] = 64 +CORNER_RADIUS: Final[float] = 14.0 +RIM_INSET: Final[float] = 1.0 +RIM_WIDTH: Final[float] = 2.0 +RIM_OPACITY: Final[float] = 0.14 +WAVE_WIDTH: Final[float] = 4.0 + +BACKGROUND_TOP: Final[str] = "#3a3650" +BACKGROUND_BOTTOM: Final[str] = "#211d30" +SINE_COLOR: Final[str] = "#64c8ff" +SQUARE_COLOR: Final[str] = "#ffc864" +RIM_COLOR: Final[str] = "#cdb6ff" + +SINE_START: Final[Point] = (8.0, 32.0) +SINE_CURVES: Final[Tuple[Tuple[Point, Point, Point], ...]] = ( + ((11.0, 16.0), (15.0, 16.0), (18.0, 32.0)), + ((21.0, 48.0), (25.0, 48.0), (28.0, 32.0)), +) +SQUARE_POINTS: Final[Tuple[Point, ...]] = ( + (28.0, 32.0), + (28.0, 20.0), + (38.0, 20.0), + (38.0, 44.0), + (48.0, 44.0), + (48.0, 20.0), + (56.0, 20.0), + (56.0, 32.0), +) + +SUPERSAMPLE: Final[int] = 16 +CURVE_SAMPLES: Final[int] = 96 +RASTER_SIZE: Final[int] = 256 +ICO_SIZES: Final[Tuple[int, ...]] = (256, 128, 64, 48, 32, 24, 16) + + +def _grid_number(value: float) -> str: + return f"{value:g}" + + +def _sine_path() -> str: + commands = [f"M{_grid_number(SINE_START[0])} {_grid_number(SINE_START[1])}"] + for curve in SINE_CURVES: + points = " ".join(f"{_grid_number(x)} {_grid_number(y)}" for x, y in curve) + commands.append(f"C{points}") + + return " ".join(commands) + + +def _square_path() -> str: + start_x, start_y = SQUARE_POINTS[0] + commands = [f"M{_grid_number(start_x)} {_grid_number(start_y)}"] + for (previous_x, _), (x, y) in itertools.pairwise(SQUARE_POINTS): + commands.append(f"V{_grid_number(y)}" if x == previous_x else f"H{_grid_number(x)}") + + return " ".join(commands) + + +# TODO: refactor - this should be a proper template as an asset, not hardcoded +def svg_document() -> str: + """The mark as a standalone vector, with coordinates on the even design grid. + + Grid alignment keeps the wave edges on whole pixels when the icon is rasterized + at 32 px and 16 px. + """ + rim_extent = _grid_number(GRID - 2 * RIM_INSET) + return ( + f'\n' + " \n" + ' \n' + f' \n' + f' \n' + " \n" + " \n" + f' \n' + f' \n' + f' \n' + f' \n' + "\n" + ) + + +def _background(canvas: int) -> Image.Image: + top = Image.new("RGB", (canvas, canvas), BACKGROUND_TOP) + bottom = Image.new("RGB", (canvas, canvas), BACKGROUND_BOTTOM) + blend = Image.linear_gradient("L").resize((canvas, canvas)) + shaded = Image.composite(bottom, top, blend) + + mask = Image.new("L", (canvas, canvas), 0) + ImageDraw.Draw(mask).rounded_rectangle( + (0, 0, canvas - 1, canvas - 1), + radius=CORNER_RADIUS * SUPERSAMPLE, + fill=255, + ) + + background = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) + background.paste(shaded, mask=mask) + return background + + +def _cubic_coordinate( + start: float, + control_one: float, + control_two: float, + end: float, + progress: float, +) -> float: + remainder = 1.0 - progress + return ( + remainder**3 * start + + 3 * remainder**2 * progress * control_one + + 3 * remainder * progress**2 * control_two + + progress**3 * end + ) + + +def _sine_points() -> List[Point]: + points: List[Point] = [SINE_START] + position = SINE_START + for control_one, control_two, end in SINE_CURVES: + for step in range(1, CURVE_SAMPLES + 1): + progress = step / CURVE_SAMPLES + points.append( + ( + _cubic_coordinate( + position[0], + control_one[0], + control_two[0], + end[0], + progress, + ), + _cubic_coordinate( + position[1], + control_one[1], + control_two[1], + end[1], + progress, + ), + ) + ) + position = end + + return points + + +def _draw_sine(draw: ImageDraw.ImageDraw) -> None: + """Sweeps a disk of the stroke's half width along the curve. + + The union of densely stamped disks equals a round-capped stroke of the curve and + keeps the outline smooth, where a single wide polyline call serrates its edges. + """ + radius = WAVE_WIDTH * SUPERSAMPLE / 2 + for x, y in _sine_points(): + center_x, center_y = x * SUPERSAMPLE, y * SUPERSAMPLE + draw.ellipse( + ( + center_x - radius, + center_y - radius, + center_x + radius, + center_y + radius, + ), + fill=SINE_COLOR, + ) + + +def _direction(delta: float) -> float: + if delta > 0: + return 1.0 + + if delta < 0: + return -1.0 + + return 0.0 + + +def _segment_rectangle( + start: Point, + end: Point, + *, + half_width: float, + joined_start: bool, + joined_end: bool, +) -> Rectangle: + """The stroke rectangle of one axis-aligned segment. + + A joined end reaches half the stroke width past its corner, so consecutive + rectangles fill their right-angle miter; an open end keeps a butt cap. + """ + direction_x = _direction(end[0] - start[0]) + direction_y = _direction(end[1] - start[1]) + start_reach = half_width if joined_start else 0.0 + end_reach = half_width if joined_end else 0.0 + + reached_start = ( + start[0] - direction_x * start_reach, + start[1] - direction_y * start_reach, + ) + reached_end = ( + end[0] + direction_x * end_reach, + end[1] + direction_y * end_reach, + ) + across_x = half_width * abs(direction_y) + across_y = half_width * abs(direction_x) + + return ( + min(reached_start[0], reached_end[0]) - across_x, + min(reached_start[1], reached_end[1]) - across_y, + max(reached_start[0], reached_end[0]) + across_x, + max(reached_start[1], reached_end[1]) + across_y, + ) + + +def _square_rectangles() -> List[Rectangle]: + final_segment = len(SQUARE_POINTS) - 2 + return [ + _segment_rectangle( + SQUARE_POINTS[index], + SQUARE_POINTS[index + 1], + half_width=WAVE_WIDTH / 2, + joined_start=index > 0, + joined_end=index < final_segment, + ) + for index in range(len(SQUARE_POINTS) - 1) + ] + + +def _draw_square(draw: ImageDraw.ImageDraw) -> None: + for left, top, right, bottom in _square_rectangles(): + draw.rectangle( + ( + round(left * SUPERSAMPLE), + round(top * SUPERSAMPLE), + round(right * SUPERSAMPLE) - 1, + round(bottom * SUPERSAMPLE) - 1, + ), + fill=SQUARE_COLOR, + ) + + +def _rgba(color: str, opacity: float) -> Tuple[int, int, int, int]: + red, green, blue = (int(color[start : start + 2], 16) for start in (1, 3, 5)) + return red, green, blue, round(opacity * 255) + + +def _rim_overlay(canvas: int) -> Image.Image: + overlay = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) + ImageDraw.Draw(overlay).rounded_rectangle( + (0, 0, canvas - 1, canvas - 1), + radius=CORNER_RADIUS * SUPERSAMPLE, + outline=_rgba(RIM_COLOR, RIM_OPACITY), + width=round(RIM_WIDTH * SUPERSAMPLE), + ) + return overlay + + +def render_master() -> Image.Image: + """The mark rasterized at a supersampled resolution, ready to scale down to each shipped size.""" + canvas = GRID * SUPERSAMPLE + image = _background(canvas) + draw = ImageDraw.Draw(image) + _draw_sine(draw) + _draw_square(draw) + image.alpha_composite(_rim_overlay(canvas)) + return image + + +def write_suite(directory: Path) -> List[Path]: + """Writes the vector, the raster, and the Windows icon into the directory.""" + directory.mkdir(parents=True, exist_ok=True) + master = render_master() + renders = {size: master.resize((size, size), Image.Resampling.LANCZOS) for size in ICO_SIZES} + + vector_path = directory / VECTOR_FILENAME + vector_path.write_text(svg_document(), encoding="utf-8") + + raster_path = directory / UNIX_ICON_FILENAME + renders[RASTER_SIZE].save(raster_path) + + windows_path = directory / WINDOWS_ICON_FILENAME + primary, *appended = (renders[size] for size in ICO_SIZES) + primary.save( + windows_path, + format="ICO", + sizes=[(size, size) for size in ICO_SIZES], + append_images=appended, + ) + + return [vector_path, raster_path, windows_path] + + +# TODO: this file should be only a thin layer, the rest of the code +# should belong to sampletones_assets +# Read guidelines and architecture docs, follow the current code philosophy +def main(argv: Sequence[str]) -> int: + """Writes the icon suite and reports each file it produced.""" + + parser = argparse.ArgumentParser( + description="Build the application icon suite from the mark's geometry.", + ) + parser.add_argument( + "--directory", + type=Path, + default=ICONS_DIRECTORY, + help="directory receiving the icon files", + ) + arguments = parser.parse_args(list(argv)) + + for path in write_suite(arguments.directory): + print(f"Wrote {path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/calibration.py b/scripts/calibration.py index 870735ea6..c129d1800 100644 --- a/scripts/calibration.py +++ b/scripts/calibration.py @@ -15,8 +15,8 @@ GeneratorName, SpectrumMethod, ) -from sampletones_core.paths import USER_PATH_DOCUMENTS from sampletones_shared.logger import logger +from sampletones_shared.paths.user import USER_PATH_DOCUMENTS DEFAULT_OUTPUT_ROOT: Final[Path] = USER_PATH_DOCUMENTS / "calibration" DEFAULT_METHODS: Final[str] = f"{SpectrumMethod.FFT.value},{SpectrumMethod.CQT.value}" diff --git a/scripts/checks/language_keys.py b/scripts/checks/language_keys.py index 89a5f9e65..d6a55601c 100755 --- a/scripts/checks/language_keys.py +++ b/scripts/checks/language_keys.py @@ -39,7 +39,7 @@ from sampletones_shared.meta.source.modules import discover_modules, module_name from sampletones_shared.meta.source.packages import package_directory from sampletones_shared.meta.source.values import EnumMembers, EnumTable -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT EnumPredicate = Callable[[object], bool] diff --git a/scripts/checks/palette_colors.py b/scripts/checks/palette_colors.py index 32f6f8c59..002c9bf84 100755 --- a/scripts/checks/palette_colors.py +++ b/scripts/checks/palette_colors.py @@ -28,7 +28,7 @@ from sampletones_shared.meta.source.modules import SourceModule, discover_modules from sampletones_shared.meta.source.nodes import terminal_name from sampletones_shared.meta.source.packages import package_directory -from sampletones_shared.paths import CONFIG_DIRECTORY +from sampletones_shared.paths.resources import CONFIG_DIRECTORY APPLICATION_PACKAGE: Final[Path] = package_directory("sampletones_application") diff --git a/scripts/checks/unused_tags.py b/scripts/checks/unused_tags.py index 14931ae4d..c2a87b5ce 100755 --- a/scripts/checks/unused_tags.py +++ b/scripts/checks/unused_tags.py @@ -22,7 +22,7 @@ from sampletones_shared.meta.source.modules import SourceModule, discover_modules from sampletones_shared.meta.source.packages import package_directory from sampletones_shared.meta.source.references import count_identifier_loads -from sampletones_shared.paths import REPOSITORY_ROOT, SOURCE_ROOT +from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT TAGS_PACKAGE: Final[Path] = package_directory("sampletones_application", "tags") REFERENCE_ROOTS: Final[Tuple[Path, ...]] = ( diff --git a/scripts/linux/build/build.sh b/scripts/linux/build/build.sh index 75629a663..1271b2027 100755 --- a/scripts/linux/build/build.sh +++ b/scripts/linux/build/build.sh @@ -28,6 +28,7 @@ else fi bash "$SCRIPT_DIR/preflight.sh" "$@" +bash "$SCRIPT_DIR/icons.sh" if [[ -e "${PROJECT_DIR}/bin/sampletones" ]]; then echo "Removing the previous artifact: ./bin/sampletones" diff --git a/scripts/linux/build/icons.sh b/scripts/linux/build/icons.sh new file mode 100644 index 000000000..df7bf35b8 --- /dev/null +++ b/scripts/linux/build/icons.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -e + +SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +. "$SCRIPT_DIR/../lib/root.sh" + +PROJECT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/../../.." && pwd) +VENV_PY="$PROJECT_DIR/.venv-build/bin/python" + +echo "Generating the icon suite..." +"$VENV_PY" scripts/assets/icons.py diff --git a/scripts/linux/build/sampletones.sh b/scripts/linux/build/sampletones.sh index 7b8292351..d5accc984 100755 --- a/scripts/linux/build/sampletones.sh +++ b/scripts/linux/build/sampletones.sh @@ -23,6 +23,6 @@ echo "Installing dependencies..." EXTRAS_STR=$(IFS=,; echo "${EXTRAS[*]}") echo "Installing with extras: $EXTRAS_STR" -"$VENV_PY" -m pip install ".[$EXTRAS_STR]" +"$VENV_PY" -m pip install ".[$EXTRAS_STR]" --group assets echo "sampletones Python package installed successfully." diff --git a/scripts/windows/build/build.bat b/scripts/windows/build/build.bat index 9b8137d70..bbe075f4b 100644 --- a/scripts/windows/build/build.bat +++ b/scripts/windows/build/build.bat @@ -30,6 +30,7 @@ if "%RELEASE%"=="1" ( ) call "%SCRIPT_DIR%preflight.bat" %* || exit /b 1 +call "%SCRIPT_DIR%icons.bat" || exit /b 1 if exist "bin\sampletones.exe" ( echo Removing the previous artifact: bin\sampletones.exe diff --git a/scripts/windows/build/icons.bat b/scripts/windows/build/icons.bat new file mode 100644 index 000000000..d4ee7f14a --- /dev/null +++ b/scripts/windows/build/icons.bat @@ -0,0 +1,14 @@ +@echo off +setlocal EnableExtensions + +set "SCRIPT_DIR=%~dp0" +call "%SCRIPT_DIR%\..\lib\root.bat" || exit /b 1 + +set "PROJECT_DIR=%SCRIPT_DIR%..\..\.." +set "VENV_DIR=%PROJECT_DIR%\.venv-build" +set "VENV_PY=%VENV_DIR%\Scripts\python.exe" + +echo Generating the icon suite... +"%VENV_PY%" scripts\assets\icons.py || exit /b 1 + +exit /b 0 diff --git a/scripts/windows/build/sampletones.bat b/scripts/windows/build/sampletones.bat index 35fed6510..902ea3726 100644 --- a/scripts/windows/build/sampletones.bat +++ b/scripts/windows/build/sampletones.bat @@ -22,7 +22,7 @@ echo Installing dependencies... "%VENV_PY%" -m pip install --upgrade pip echo Installing with extras: !EXTRAS! -"%VENV_PY%" -m pip install ".[!EXTRAS!]" || exit /b 1 +"%VENV_PY%" -m pip install ".[!EXTRAS!]" --group assets || exit /b 1 echo sampletones Python package installed successfully. exit /b 0 diff --git a/src/sampletones/__main__.py b/src/sampletones/__main__.py index 9592c1e9b..b7625bb23 100644 --- a/src/sampletones/__main__.py +++ b/src/sampletones/__main__.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Optional -from sampletones_core.paths import EXT_FILES_AUDIO +from sampletones_shared.paths.extensions import EXT_FILES_AUDIO if TYPE_CHECKING: from sampletones_core.configs import Config @@ -119,7 +119,7 @@ def main() -> None: config_path = Path(args.config) if args.config else None output_path = Path(args.output) if args.output else None - from sampletones_core.paths import ( + from sampletones_shared.paths.extensions import ( EXT_FILE_LIBRARY, EXT_FILE_PROJECT, EXT_FILE_RECONSTRUCTION, diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 079ae0365..6a266a36a 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -141,7 +141,6 @@ from sampletones_core.constants.audio import BufferSize, SampleRate from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.exporters import Features -from sampletones_core.paths import EXT_FILES_AUDIO from sampletones_core.project.instruments.sample import Sample from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode @@ -156,6 +155,7 @@ ) from sampletones_shared.exceptions import PlaybackError from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILES_AUDIO from sampletones_shared.types.application import Sender SEQUENCER_SAMPLE_TITLE_FORMAT: Final[str] = "{ordinal}: {name}" diff --git a/src/sampletones_application/config/managers/config.py b/src/sampletones_application/config/managers/config.py index bd2fadd7e..cd7038317 100644 --- a/src/sampletones_application/config/managers/config.py +++ b/src/sampletones_application/config/managers/config.py @@ -20,9 +20,9 @@ from sampletones_core.data.metadata import Metadata from sampletones_core.fft import Window from sampletones_core.library import InstructionLibraryKey -from sampletones_core.paths import CONFIG_PATH, LIBRARY_DIRECTORY from sampletones_shared.constants.project import RECONSTRUCTIONS_DIRECTORY from sampletones_shared.logger import logger +from sampletones_shared.paths.user import CONFIG_PATH, LIBRARY_DIRECTORY from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.serialization import load_json from sampletones_shared.utils.validation import validate_with_recovery diff --git a/src/sampletones_application/config/profile.py b/src/sampletones_application/config/profile.py index b6fcd9c7f..239bfeafa 100644 --- a/src/sampletones_application/config/profile.py +++ b/src/sampletones_application/config/profile.py @@ -4,7 +4,7 @@ from pathlib import Path from sampletones_application.paths import APPLICATION_STATE_PATH -from sampletones_core.paths import APPLICATION_CONFIG_PATH +from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH @dataclass(frozen=True) diff --git a/src/sampletones_application/config/session/state/paths.py b/src/sampletones_application/config/session/state/paths.py index 3d8d300c0..885909d64 100644 --- a/src/sampletones_application/config/session/state/paths.py +++ b/src/sampletones_application/config/session/state/paths.py @@ -2,7 +2,7 @@ from pydantic import BaseModel, Field, field_serializer -from sampletones_core.paths import ( +from sampletones_shared.paths.user import ( CONFIG_PATH, LIBRARY_DIRECTORY, PROJECTS_DIRECTORY, diff --git a/src/sampletones_application/coordinators/config.py b/src/sampletones_application/coordinators/config.py index 92567fc81..282f1df89 100644 --- a/src/sampletones_application/coordinators/config.py +++ b/src/sampletones_application/coordinators/config.py @@ -26,9 +26,9 @@ from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_core.paths import EXT_FILE_JSON from sampletones_shared.application import SAMPLETONES_VERSION from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_JSON from sampletones_shared.utils.validation import flatten_location _LOAD_FAILURE_MESSAGES: Dict[ConfigLoadFailureReason, GlobalMessageElements] = { diff --git a/src/sampletones_application/coordinators/project.py b/src/sampletones_application/coordinators/project.py index 295d790bd..63f755e37 100644 --- a/src/sampletones_application/coordinators/project.py +++ b/src/sampletones_application/coordinators/project.py @@ -31,7 +31,6 @@ from sampletones_application.utils.file_dialogs.filter import FileFilter from sampletones_application.utils.file_dialogs.result import ignore_none_path from sampletones_application.utils.gui.dialogs import DialogsRenderer -from sampletones_core.paths import EXT_FILE_PROJECT from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.scope import ExportScope @@ -45,6 +44,7 @@ SerializationError, ) from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_PROJECT from sampletones_shared.types.callback import Callback, VoidCallback from sampletones_shared.utils.system.paths import get_directory, get_filename diff --git a/src/sampletones_application/coordinators/reconstruction.py b/src/sampletones_application/coordinators/reconstruction.py index f63b21e2f..d2e5222ad 100644 --- a/src/sampletones_application/coordinators/reconstruction.py +++ b/src/sampletones_application/coordinators/reconstruction.py @@ -30,10 +30,10 @@ from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import FeatureKey, GeneratorName from sampletones_core.exporters import Features -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.types.feature import FeatureValue from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from sampletones_shared.types.callback import Callback, VoidCallback from sampletones_shared.utils.system.paths import get_filename diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 10f805396..bfdfd0f39 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -81,7 +81,6 @@ from sampletones_core.audio import AudioDeviceManager from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters.truncation import EnvelopeTruncation -from sampletones_core.paths import EXT_FILE_WAVE from sampletones_core.structures.tree import FileSystemNode from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.format import TrackerFormat @@ -95,6 +94,7 @@ LoadReconstructionError, ) from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_WAVE from sampletones_shared.types.callback import PathCallback, VoidCallback _LEFT_COLUMN_TAG = compose_tag(TAG_GLOBAL_TAB_RECONSTRUCTION, SUF_PANEL_LEFT) diff --git a/src/sampletones_application/logic/instruction/library_manager.py b/src/sampletones_application/logic/instruction/library_manager.py index 05ca9155c..def2e3973 100644 --- a/src/sampletones_application/logic/instruction/library_manager.py +++ b/src/sampletones_application/logic/instruction/library_manager.py @@ -18,7 +18,6 @@ from sampletones_core.library.creator import InstructionsLibraryCreator from sampletones_core.library.filename.fields import InstructionsFilenameFields from sampletones_core.parallelization import TaskProgress, TaskStatus -from sampletones_core.paths import EXT_FILE_LIBRARY from sampletones_core.structures.tree import ( GeneratorNode, LibraryNode, @@ -27,6 +26,7 @@ TreeNode, ) from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin from sampletones_shared.utils.system.paths import to_path diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py index 5a66fe402..178d8456a 100644 --- a/src/sampletones_application/logic/main/explorer_manager.py +++ b/src/sampletones_application/logic/main/explorer_manager.py @@ -3,11 +3,6 @@ from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager -from sampletones_core.paths import ( - EXT_FILE_LIBRARY, - EXT_FILE_RECONSTRUCTION, - EXT_FILES_AUDIO, -) from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import ( FileSystemNode, @@ -16,6 +11,11 @@ TreeNode, create_directory_node, ) +from sampletones_shared.paths.extensions import ( + EXT_FILE_LIBRARY, + EXT_FILE_RECONSTRUCTION, + EXT_FILES_AUDIO, +) from sampletones_shared.utils.system.system import System diff --git a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py index cd34400ef..56a4bb4c5 100644 --- a/src/sampletones_application/logic/reconstruction/browser/tree/scan.py +++ b/src/sampletones_application/logic/reconstruction/browser/tree/scan.py @@ -11,8 +11,8 @@ from sampletones_application.logic.reconstruction.browser.tree.entries.scan import ( ReconstructionScan, ) -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION def scan_reconstructions(directory: Path) -> ReconstructionScan: diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index e10c4b83d..204c7fe91 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -5,12 +5,12 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.utils.callbacks.queue import CallbackQueue -from sampletones_core import paths from sampletones_core.audio import AudioDeviceManager from sampletones_core.reconstructions import Reconstruction from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode from sampletones_shared.exceptions import SampleToNESError from sampletones_shared.logger import logger +from sampletones_shared.paths import extensions from sampletones_shared.types.callback import VoidCallback from sampletones_shared.utils.callbacks import CallbackMixin @@ -103,7 +103,7 @@ def is_playable_file(self, node: TreeNode) -> bool: return False suffix = node.filepath.suffix.lower() - return suffix == paths.EXT_FILE_RECONSTRUCTION or suffix in paths.EXT_FILES_AUDIO + return suffix == extensions.EXT_FILE_RECONSTRUCTION or suffix in extensions.EXT_FILES_AUDIO def _execute_autoplay(self) -> None: if self._pending_autoplay_node is not None: @@ -119,7 +119,7 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None: return match node.filepath.suffix.lower(): - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: try: reconstruction = Reconstruction.load(node.filepath) self._audio_device_manager.play( @@ -133,7 +133,7 @@ def _play_file(self, node: FileSystemNode, priority: PlaybackPriority) -> None: f"Failed to play reconstruction file: {node.filepath}", ) self.call(self.on_autoplay_error, exception) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: self._audio_device_manager.play_file( node.filepath, update=False, diff --git a/src/sampletones_application/paths.py b/src/sampletones_application/paths.py index 4ecbdcfca..0ae44e42d 100644 --- a/src/sampletones_application/paths.py +++ b/src/sampletones_application/paths.py @@ -1,8 +1,8 @@ from pathlib import Path from typing import Final -from sampletones_core.paths import USER_PATH_CONFIG -from sampletones_shared.paths import CONFIG_DIRECTORY +from sampletones_shared.paths.resources import CONFIG_DIRECTORY +from sampletones_shared.paths.user import USER_PATH_CONFIG APPLICATION_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "application" BEHAVIOR_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "behavior" diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 597305429..1c9360cc2 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -65,7 +65,6 @@ BackgroundWorkCancelled, SingleThreadExecutor, ) -from sampletones_core import paths from sampletones_core.configs.display import ( format_nes_frequency, format_sample_rate, @@ -82,6 +81,7 @@ Tree, TreeNode, ) +from sampletones_shared.paths import extensions from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import ( Callback, @@ -812,11 +812,11 @@ def _resolve_file_theme_tag( return TAG_GLOBAL_THEME_FAVORITE match node.filepath.suffix.lower(): - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: return TAG_GLOBAL_THEME_FILE_RECONSTRUCTION - case paths.EXT_FILE_LIBRARY: + case extensions.EXT_FILE_LIBRARY: return TAG_GLOBAL_THEME_FILE_LIBRARY - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: return TAG_GLOBAL_THEME_FILE_WAVE case _: if has_favorite_ancestor: diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index c20fbd944..84aaa74b4 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -30,7 +30,6 @@ from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.parallelization.thread import concurrent -from sampletones_core import paths from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -39,6 +38,7 @@ TreeTraversal, traverse, ) +from sampletones_shared.paths import extensions from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import MessageCallback, PathCallback @@ -300,19 +300,19 @@ def message_function( node, _ = user_data suffix = node.filepath.suffix.lower() match suffix: - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: return reconstruction_message_function( *args, user_data=user_data, **kwargs, ) - case paths.EXT_FILE_LIBRARY: + case extensions.EXT_FILE_LIBRARY: return library_message_function( *args, user_data=user_data, **kwargs, ) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: return audio_message_function( *args, user_data=user_data, @@ -333,9 +333,9 @@ def _on_file_node_clicked( node, _ = user_data if mouse_button == dpg.mvMouseButton_Left: match node.filepath.suffix.lower(): - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: return self._logic.request_autoplay(node) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: self.call(self.on_wave_file_clicked, node.filepath) return self._logic.request_autoplay(node) @@ -354,12 +354,12 @@ def _on_file_node_double_clicked( node, _ = user_data if mouse_button == dpg.mvMouseButton_Left: match node.filepath.suffix.lower(): - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: self._load_reconstruction(node) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: self._logic.cancel_autoplay() return self._reconstruct_file(node) - case paths.EXT_FILE_LIBRARY: + case extensions.EXT_FILE_LIBRARY: return self._load_library(node) return None @@ -452,17 +452,17 @@ def _add_context_menu_file_actions(self, node: FileSystemNode) -> None: dpg.add_separator() suffix = node.filepath.suffix.lower() match suffix: - case paths.EXT_FILE_RECONSTRUCTION: + case extensions.EXT_FILE_RECONSTRUCTION: dpg.add_menu_item( label=self._language_manager["main.explorer.label.context_load_reconstruction"], callback=lambda: self._load_reconstruction(node), ) - case paths.EXT_FILE_LIBRARY: + case extensions.EXT_FILE_LIBRARY: dpg.add_menu_item( label=self._language_manager["main.explorer.label.context_load_library"], callback=lambda: self._load_library(node), ) - case suffix if suffix in paths.EXT_FILES_AUDIO: + case suffix if suffix in extensions.EXT_FILES_AUDIO: dpg.add_menu_item( label=self._language_manager["main.explorer.label.context_reconstruct_file"], callback=lambda: self._context_reconstruct_file(node), diff --git a/src/sampletones_application/ui/resources/items.py b/src/sampletones_application/ui/resources/items.py index d4a8c2bac..d85b185f3 100644 --- a/src/sampletones_application/ui/resources/items.py +++ b/src/sampletones_application/ui/resources/items.py @@ -1,6 +1,6 @@ from enum import Enum -from sampletones_core.paths import ( +from sampletones_shared.paths.resources import ( FONT_ICON, FONT_MONO_BOLD, FONT_MONO_REGULAR, diff --git a/src/sampletones_application/ui/resources/resources.py b/src/sampletones_application/ui/resources/resources.py index e644d7def..b841967f6 100644 --- a/src/sampletones_application/ui/resources/resources.py +++ b/src/sampletones_application/ui/resources/resources.py @@ -3,7 +3,7 @@ IconResource, ) from sampletones_application.ui.resources.loader import ResourceLoader -from sampletones_core.paths import FONT_DIRECTORY, ICON_DIRECTORY +from sampletones_shared.paths.resources import FONT_DIRECTORY, ICON_DIRECTORY icon_loader = ResourceLoader(ICON_DIRECTORY) font_loader = ResourceLoader(FONT_DIRECTORY) diff --git a/src/sampletones_application/ui/themes/loader.py b/src/sampletones_application/ui/themes/loader.py index e24eb06ef..61ed75738 100644 --- a/src/sampletones_application/ui/themes/loader.py +++ b/src/sampletones_application/ui/themes/loader.py @@ -29,7 +29,7 @@ from sampletones_application.ui.themes.theme import Theme from sampletones_application.utils.palette.colors.written import PALETTE_SOURCE_CONTEXT_KEY from sampletones_application.utils.palette.source import PaletteSource -from sampletones_core.paths import EXT_FILE_YAML +from sampletones_shared.paths.extensions import EXT_FILE_YAML from sampletones_shared.utils.serialization import load_yaml _BASE_THEME_NAME: Final[str] = "default" diff --git a/src/sampletones_application/utils/gui/shortcuts/catalog.py b/src/sampletones_application/utils/gui/shortcuts/catalog.py index 6018b4f3b..73f503a83 100644 --- a/src/sampletones_application/utils/gui/shortcuts/catalog.py +++ b/src/sampletones_application/utils/gui/shortcuts/catalog.py @@ -6,8 +6,8 @@ from sampletones_application.constants.keybindings import DEFAULT_SCHEME_NAME from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme -from sampletones_core.paths import EXT_FILE_YAML from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_YAML @dataclass(frozen=True) diff --git a/src/sampletones_application/utils/palette/catalog.py b/src/sampletones_application/utils/palette/catalog.py index ce04fbb4d..7b1d7c99a 100644 --- a/src/sampletones_application/utils/palette/catalog.py +++ b/src/sampletones_application/utils/palette/catalog.py @@ -5,8 +5,8 @@ from typing import Dict, Final, Tuple from sampletones_application.utils.palette.palette import Palette -from sampletones_core.paths import EXT_FILE_YAML from sampletones_shared.logger import logger +from sampletones_shared.paths.extensions import EXT_FILE_YAML DEFAULT_PALETTE_NAME: Final[str] = "studio" diff --git a/src/sampletones_assets/icons/sampletones.ico b/src/sampletones_assets/icons/sampletones.ico deleted file mode 100644 index 7a82dacedfc1131620e8e554cc93143d3bd6ff43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 57991 zcmafaWl)^K()Hqt1QrP_?k-`0;O_1&!9BQJaCZw%aEIV7!3nZB1PSi$E?@4wf4{1C z{>=1r)lAh>T~D8Lx(5J&0U!cEAi%#t24IH;04)BqBKx1cjR*i_{M%z?{hz)30RZqt z1^|SG|7UM~1OPPg0Dypi|Je+p001}hf7bsQWB_Ux0Kmin0Ekppl=^@|fbwtigN(Ge z>c9CvH-dow&ITkTT5$pZFi$e#BI=&mc1e1+I3l3X%8HKt?gl;`0}fCSuwd)`otoi+ z45iN^(r0Ydr@cegJsI4YohJB6!!PQ3)8@7hDL#M#77zghP+X-2HY_e%*;eN;Md+v0 zS(fWb!H>TaZ&3Y~X2dzyHqtToG7{OTbk!*+T8V=g93&~uK49# zFG$2)GifX$uJ{kTNObqcK$!B8Z; zp6kCmE}guMx4Z^-p03z;t>Hr0#=7h593+o-JEcjA@3ST*?j54pKK}ZVRbppHV17S_ zc&xNPRo>*{Vab6w*0tN;o?$-JX2danI>JO7A^xEBkgk&)H>=M6)GL-bd`S8GeFlY0 z{0Z+?PZ%bp>cHe*FaTsE6vb;qje`C^Aw%}hP4?fAc>$jm0sx2={|lK^&F6TcmJhh} zvg{4#U3|~p`+qz*qyAV276>cjNW6!T{1H97zW#7LQ!!Jx`Z@TC096yQW`ZCOh}jsYz9hprc^$Qw;$Z zE&u_Lp}#9iJhc7@L6&hI%)xTlC4mZzE?K?1X43Wtw%$l1g zv=}ktj0Ks+=g1Z(KiQC)#9+?KB=Eo%oq{gel@f5ne?iWIr~^!5ih4mdA#{fl=MTL( zzsvG|F|8E$RK24W{tl=hoP@-uRcRs?{#@l0RmBYJ)Il1W(>ImVHH!xQI7SG12m=duA{ zkl+J-UQvHch*w8n3fn%Mo|C*?-0%RyH$L=!&4#dBcOA2kf-!HJsXBbuUBuyIKQCu) zZA?M$Vl}TR>=)$L7Zg^A<;Q=#-y}>YG!2^JiWym)zZ51fEyklCmGCWpstP;Zj@JvK zi{M;f&}-afP@kuvl#>l=U5l3tMgNRa>B~kOH&!W^<{jJJ-lNq*BAi1q$^Y}O0!G;d zw)W{n!nca2YIwGkdsea@`+ID6F>7b)9Yb z>3l)p5OhYMdRffG6%kDp5cj>jK6Y9~*N{&qp062dElq;chRnZM{X- z&JJN6g8CK>p-63a7P-?I?#g}_jf&Xc;QI zTpzUavk&P~>6ux0`?6(2Ir`T9)pfDyaA4*#*R+G^Bn5d0f|LB{Xxn40)UFYRQMjX~ zxtSsg#qXB2G3zqSzjxQv^RKHnVeo^|Wz$K=!~2ch-`9Fu^E@_Xy;O5sMd(v^@q?R_ z+hAS0qI!4CoA!MI&1#G?jUA>b?iNtPL1~$5mmj_T~ zQy>e|(qZnr9r|`e0QA!2J74;!3WfmX?=VQvt4;?3+lFqBKK_i!Key8O))O`TCQ*rg z=QP(#CvQ3RM^Eh>VXR>rPlAZ&K{0ps`6W+JMD0-${Bjq9(=axbsZBUf=*^22YhP)$ zMOGSHhLvMV{Z<%8QBaQukIqQnom1xLw;1YT#m(;QH2mE*s1Z3N$?0VUmfiI@G6~dO z4fea(C0p#qBKtHt$`Nkszxw-Em0mk(*sCr|^|W@ryk!mBHQ6sLW>x+Ob^*$^D|q>G z!`!WMEm@gFVbUR!C*bEu=BOH>oCh;I*W;IY)(W{8yr1f@lUU@RTRB&L?SwZp z=;rRQYG9hBkuxYVk?+h{)@QskXtPO6E5nRT;au}lnCr;NiwB)HDQd5?A9qyrZMOv_;13Li;!j6k;D% z&Q`%2W_%Pux;wu`B%9?yx#6uA&!(JXf#`w3c7rw75C-_ShpBsozxsw>zV91xu|J1S zGemw=KndWnDe!;Y7P_tIcH@Xk`r>!*N{^zM>f_wpEW=Ed+K|joN}7SPvGsfVSmJ|E zaI(m*mO{_6*)tNw(U15ZB;7^KON1%RrcK5&}ThGf&m@86J7AUptPCN`0e(stUIyU6C zSke(zXw)VfMbX~xx@5(Dc9;Pk*)7z5lQJ+=#R-ZzZ`nA7vRyi&t_p?47eae z<1|t4@34+$=vd^xtBy665m?J>))1&66J-W`J=0DOyUMFaKU?_DY@QLj78Oy9BDA$5 z3D8kULxX1sVBt{r+C;?91t@H)#V%xFFhfBpSQHH%HR4qDspfh-*rM&c<-{|oh?xqs z1c8*KuNHU4$#%E#Csc<%OI`aBWn5(M^|?H6c+p>#XAv<`cmIzR_aB_a`rj1y$H;!_ zA6m-)Uy7^N(Mcv6cB&a|f6821B1{7WvHm6_A0_`?bc!UTjUN80Mf`^FZ7Q0HQM5!% zBoQr*5&kfkBhaF48*)y*bHHZ7TW9Bt502JP;57-u6rc!6vfkDyhlZ z1{S3p=Ky~382;>LB|eXYXlgWd+3h?er#RzC1ABRp0IJmW{eO=xFKswg=<{!7fOX|? z;zS-b?-pI{Z7%Ak-nXHLo|8{N;)9I11WLv}t1&bX8r4LCm>d2p5=&UZw*n#YP(BBXh33q2c~Yfs)L>&Kf)2|AeHA=v#Sm!$&&N z3rDEX5RhtgrVDG0OA37P%QVHAqd-H)=j-U$6CLg6>2m;1RK485+2=?52L zJ)9k9U&Yw}8cQ=!8E)wV24Zw&NUsPNEz|CgQk2o+IDKD2P2oTHxE|jH3eZ`0q^&(> zwPrS$v~$fi1o=ImIA3K;WTlmP^k7rr*6S*-EOTAPP3sl}hr@C@EOP`SfBXsTMpRXET^&%~4V; zeh!svK!(m9B46vzO!bo;|;vY&=Ot z1~Hb`oYT*+DaZA&#RnM8?#c0$!x-!bpau}z4fFQu9WFa===XBeQ=+1?r*HQ;J&d$! zab9&-Ye>DnKd$%qIku`%6u4bJ74LPeAVEXP5n>bXl+f5RZt2;lB;f+$d0gdw&#?g$ zO?0$OfMGn8j+%?bDX{fgx~TLyP3Ah_YTxlbb={Se3!~C+uB$S<)I1T{87V06)HI|8 zpqG!}cGe|dVxzNKy8J#+f_6}4MG_`WEV7nsZbDrtql$=);Jhg{6LZ80$b~2Pz zQ071knrYLtZ{mIW8<7#{D5(4pmwU+29!EknOY1RNx7NahBf6u^mprd8vgMma(X{|>pGYd-ER5~{~2etqNdPJ!8l}&>=zCKQk?+ATmx7fXkMYX;Tm4jVrdkUWKRRXh-r-FXrlXCyrU|WwtRkj>5ku@)GtJ&J(UP|48 zus8Gb83X>Bh`5rKbRWA!bw%s$$X|@qoOd>m4~4AW5~uMFO+B7;6vrb&a&mrtT`!!2 zc#7ZM#2a>c9bY)2$RCh)`Q66aezsiV-50y)2f}kBGFg^4K@jLtgVIzr-6H8LEI4^O zzq8Y4JCPXBQWbmb7tCeC?Qmz~iKfr!>8YDblB8?$AJ=1%34i2~Ou$iwAO3kJ*m^yp zAj{o(Tf*(T{nePZNrv9|!WMfbRGgTbl|{HhV`Khow}9dCR|Jn`LFQ<^B0aMntXjlj zZ)Eav(YW+ydaM9$!*M*U1XLN8ZEw36Wj6BoHC%<@k<~3KqXCtXxMo#=(G@Xfbn_36FmQbsj!+S=sP?K9x9SAv_Kx`Gu177QuZ0T} zxB_7~RfaGKSs*)09mNrDxcpIXZ_m%X@zax}Yij)X)n8j?be&YKlT>BY1`;x56CYXk zQ+UKgd6jcvhG>#$O0qj`QS3(3e z(<+Ba11ukBOgtsO0wX9OjGwf_Jutn!rey&V=GSqeX(vDJ)1%JJ{5{V%y_89P-TD0=ie27rDkmq;-iD}i z61u|8hqjuW=!MeY@;@u<6Y8Plz;f+`1BKaa->3C!zMk?-!~i)1X*=OV@)fw9jpfI~ zyES6tjy8pY023(u%_)kW zzE~UDPRCOwg5OE!N^sSapvpB1rT`?X;Q*RydxsAYrP$tzE(}dkkE$M{`3+EwpvtF3A1%iH<=5eQ+BU+%jjEa)?TDMmw@)Zd zHh)!RS`ePZ|EJp&twW=fw!ys(NA-vABOhSOT%^v8&N*d+ z-m+N*5oYfs%~JgmN5LYSu^*gfo@2@}k#fUiJ~W;%;!I@2UM()~${h;I`S6#Gyz_Zk zou0UJ65qD}{Kwt_F|du7Pq6KJB|(xCG+frTXX|>1$VV9%!AVNGi-S55S~T-TR(YQ4 zrn($itiFssmBxgVo_d43`~w7J#`BnMdZ3BCbnAzm7qLdWbI>s>C6(a{0P9;#hEp6B z1hSdznpa7efSuQ|IP2(K$^*&w2gU$p)SHDhHX3u~P0J|&DjgjSdY93}k*ERy47n46 z`XS61%;$%@hs(M-g#7_DAi;r=-%Y z)kVG`1AS=0FDpAMn$MUN73eNY9NBdV50n0n&kdZ?)j9D(e$N*|LcaWaBGmaDZ002# zk9%YA1UgK}IBI9hUOnQ^W*J>5#8Aykc%DmU)CiL` z^k>&s`Xd^*DNd}m$|BzP1&vz9qLA_31T&s+5Yi$+yQ$KoH!OasiSPXI!WyJ4YZWBLHY|IyI>8(IvnAz(GU zsOe#%)UY(W5jOf^ItQE78W}l3J`ug!*6vfN`oZZ|FJA9+gOnw0!(VS7|FNQtuA8xx z+9e*)0Hbfu5w!&PZQ3%vkqlRgxSPXD6qeee;#kM}p^KJzno7ZdXKQsbc+VW%4)_f8 za`%-F0qNMhK0+*CP44sbb936IesrMyJv1G>el4vW^ndZS!c+NaCrzB8kAJN^IqP7% z?k0%hglXM<`e(i7TP`CGzSuEre!z8UX=Pz)>MadsF>iGA47K~!Aren%g7bM0fNl07 zJ%)fo82DTgf2Z$r{t@d1Igs1(p0q{l4yAa*tovyz@XPJ)rI%84b}1_-Zy7m4mJMy5 zX}JVWw#wYIJIn(fI@`6Dl5}3+SVco9_Ag775BOo2tlidOHGg-uibA&ACU~OrXKzvR zI+eZreA?eSK@krVQ>cZ_W9b4RWYXsFEEJ!|61Fz3@luQ5t~Rk;SWue6b|gt(C$S^i z9q|$grgfva^BZNznM2b}WCaH#j@h=nj(G_sGf^hFu#kvV)OprZM}a1y90KvVAvkWC6lN%;kWO{3#fr zNQJkTOHqAe7ew2r!4Ttr*xaB}ioPN+_cpm{&9{kcV2=-(?0cOPhMRl1Ke3+o*3d1f zG8G~>cPmE@2b3=g(hp}<-9>BnAxCUV_ufya+fqe5uzXlRei|%r(?QR{J`@9q`$I`g zl(n^ej5ENmI$$h&Z&f{v7@-;>@$Gt#P{Ozhwf{IZ|Ft83+zsnZs5EPPp3e|s;7@AU z`||1c(#(3h1%v7T!R&?5I)qA_0Jp<^N-L^SUz0#Q&IGS4GDZ2&IIOWCP_TTN8DXPWd@4Hoyp*&klfmamL z^;r+W)$2=^LQ+4|du)(s^=%M@^T*`gHNNS~2`{}l(D=~ez+ZtL%eh@EMHT7=G=7B1 zz6Ni%c})LJ|IQZ*!dU-DeR5e$V^j%(N>g$Of2b0eYU}CY^+hx^B@Tbi1UOFoH3#fq zH*!Bcygf(z$jc$4P$IU;BZCWN>yfzb-s|fOy%37$9$mc(J@;ltczOgOKfgT{TyNeF zoJu(4?dvzcFWRlUcvy5p~`s$D7+1F}|=fvBIiZ2(gm(4z0cc+P5cieSo zmzHV8Deag5YogSP7wlg#o1pVUm-uc`Qb2_9bs|<_0KP$uxbINjp zmw@$EUjBWof5gci=JjDrAZ5k{mHSx#%P*bDrNIYpt3{~Lb#EfPrK+PSoh63=yofN+ zveBkfg$U~=*pE$k5L{^Zbh0o*peV!?jv*R}#k9qDj5jrTs((=;xRINC8)Yu{^p`n6 znHMS%HU0T^sPv0UaH6dZ0NDJY+3#-5-&S)$=IVITFEf*zoSqaXxY{-ZzSV&aWHOVa z5o>8sCM5>@5Us`H;{J5&F)jU`8RUYO>+QXLIPXhZ5{mL`AO4WWe|iDMEi@ouuam%MR3di?+Gxk_|;t{ zLIhULiXqh@D|P>iiD(>)p-R6@w>u1==E9FE0DiRJZ(W2L#K438SNDA|jZ^43wI@z} z0F;tPE-oo^B8p-PPqw*uCIgXVPFJaVUv4RmCoRp%2i4k_ssd+r)PYgQ1#F*ygnW&+ z&%5smngZ8XLe^36jp?arViq!ydyBv87!MJGW_|BDO-iWVEqh9+OlP2rm^vcxY)K8<66i`O5nu1kOJb6J5$ zijpF(MM1KyY*t>)%=hQA#dZcBRDsx|1s>ef=^$(TMXZeAthG}l*=fD+@y)q=*C`$;q3D^Ist-4T0j!p(}Fb+^M05iGdx=`)Qv?l&iCF>Hm&ZR1{4SR(Tp|>eNRYt!11%bL zuT?r-!Xdvu`dI7`ky*zOFjRwtLo9iV!KyZTgdmYEA5s=yJ&_pluym}C3>+VK zIl@5M9}91-Y)cwFaDgYp-=iP-HywI?l#n?%SrMVn=_YC`lRdR}SE!W>&GezP)&g05 z2CUmn(+l$^ZvEcxTi1B-(E-wN9lI5(a&bTD!+hP6DP?DF>%2~PPwVeu<;cYus3=e* z3$!(2IT5+UqJ+)5o@;))xKhBbKTY>Geey%_KdpD}&9JThE{#Bkh39>G<@wx~P-r|w zYklt?FeUe?XQ-G0C(fbU&pMPbkS5UtOH_NmC<*k{&6kc=FFQ%xs`0k?vrE0>xnHX` z)405WEQCRG3g&LpJg`GATp zJGt|;D|Qro^QMCjL2voFH#~cB_iSf?M?;&^?yQI({flBV9V=vG85a-gWiXE~se^@u zPw;)WA`61i{efwu>L9F$9wsu*X*`3<66WEhpRgWWf`d;+P4e1{_j3ClgyXwWxqU+V z{yKDLQ8Qo-djn7BT7gPF=zDn-^#-beyItsUm9aO})$`zU{}st_W5xl-X+>Pb4Hl&r zQ!{t1Y1|WqEL6p?j~2~@E#`#;m{72`nn#(T74)r6poK5hO-4E>d;!~~gS38l+1#If z9kKq77jr3^__Dn3K@{3*Dj#Nzy=|oz<0g4K5+m=b0uHtvbrE2qVomHcKaJdAVnz?e zf?%$j{Bd>*6>>sM;UV?;_8quQRUPXBON<7wKbE)|i3l-*h95A^^7P__sK>hP$~dyX zlG6io*1ewhk-_GxJX{1H)iH({lli%%`Au%1B^!<6cTP3TDv>Od*T`0`>D!qiS1vQ- z!HAnGD*)fxR%-Rtv%}iw?IxkOmpfMnF7d0OTfg4K z&0I>C7&lf~V0y1KN?>5l)rKFf0BaIOV2Xo1-Z9?1Begm$3bt1u47qn-B34>-*p?>N z!sXfy^QpUjIuTt{^}H83f*o9}delyoh$0v-#-dQAmbqvEb07)olJt_(LHo1EYp#)e z*7rICZ*DiO`+iTb|2NskU!Xf>S1}yXdNLd=L>4;JweZbiTb)F?V*0c-9r|g7-BD`U z#Qi4bK3cpWHbKKVenJX%Q?LlR#KDFRU9xD^(GXo`fO9cS-j@`bvG)C1DmFc+e6EY# zREgMEC3HHNWXl@6iBTF*`g+&r)y|Wue>Gt=64jH10Dl4e0b*&0N}nH+`qqS(zd40j&v773WCzyn!Q5OJtO^UkpVpR&G0{d@ z%}s54$Ys+}Dl>B*0S8P{=S6k73Ql73FujM<6$ay@T`S)8i1d%m^MeS#O{xrMo z+MU`Z2MwDMYhepuhoKmlRlpdRs}qIHY|qQ+!3>x+%CCt5D;fJKlKJ9JCyY5pntqQm zjAxo%`EnCnb(zu^X$r8j1&!0~syZfbTSV=Qayp^3gJUEqmNH$Yo4>IVK#NiyS%2kj ziR1QbNB{P$^?w#itus08%Ud3!If?$+TqWO#K^6+o+f)r5xgw%NTBNHs_#M8qu~aQ0Akb)Uthw;EH?Q<_ zbdR+A6$Za~+)N-p6~_xg&}BE;Ucm$u%iNukHc^#S*BCS<1YS?X9MF;Ru0Tt! zMOa*iDj%z=jw_g|?M!8_51G>3qMEG<&JB37oMz^tCH`_ihf5eLlLs$1RSdXP&93XM zmo0=V7X?2*K9jbPJX~HfVM$u3iGf);1$aFj_9H$low*qL97`>}UtQ%YS$}(`83>TX zo*mjNnGtg}kq#8!r|8ihI90icbnc}mXBcCRE3pb=?0QC}HxSm5wwY*+SS!>g2x3Eg zqxIHHiHs(YRB*DL=1UHLlYJv3J^CANfC(m!>$e7C*u%d$`xaq<~*Hq(1-zL69Pgb|2PZdmNb&}zz4SY2Z zcB4ivHnT0fmytHWS#JKWiq*;JOo@L99R!k76;qQB@UBw}!e~A5;%>kg}VXyH>7pLrnr?vRFEe$rTc_!Ojn zG5%Tz|uwZUXU}%RA!g%hAnC()Ni7HwlEXj(k++z{sWuU@M6ovG(a_ z9}I9L4&K-2{)bwRwyu>vSkAUh+o&v)p4Fbp*(zvI!W5w`s|wvqt-l?v5iZ^*T6n}l zo^pPy*0Ee7!RnAAN#nt)ZY9w!Uf3_T&XkpS?vGl#D)0$h+y<#q;h_Z+Oh1xQV?nYv zNf?r~H2%gjq(EtG;bB9i@s-vTG_}6+Ae;m7rk7B&D$`hMmMaovRbYM8JT}Wu+ig58 zw=Z1i{;|qA@jgDi0yjqC2`U_Joq^T3X$9s`A}-Q%0=- z(ct@-=>^(j-;2Z|D(WAL5DED?eFMzV1XKw;6CAXt-%Rr~T*?AeV9!F}+H(>{q9 zQ=M34yLA!JW52}aXHp`*)?+JoFAY*U8GM!LTk%JX-(!h`T;Rg z`EwJL2lE=egIhbE?yt|)68mROyY|UH)Y(Nzt>WAZtJQ8q|6%$VbXjGfP2)1=p_d?u zT(6T}kzyUAuTjDle3Ft8PEHKkD09FI@6&gPF7XMLEM-DuoGhv>oVPATP=P5C=_K+Y zLo?2G$JIt$d_euL@{?CvD~VRP`Aw1sPR~*VR$vI52n=SnaW;AcNjjYP;?Z=}coZ1( zLpo?g73(*SLP|#jw*;?(OePb#O8D~7Sfxp$(`L~XnlaNvCeHmj-;!56Xu@TPY@a8Jzb6mmNf}$XL4%IE}ruwgCcUb?ZpL} zUdoZX%m&S%BDxUiU!xM0y)^r*Wh#C+T2so5T%TWIY{6|+@U z2S==W|EcZ6tjJJ-=kbMkx4L@Hwod!G9Z?%RP;df6+rp=kAQx%`#6`idMte7xhGa+? zNcx$R#g6iDU)t^cu%qSt^B&?jd^9O87hbsZX!j5sN#@ixY zT2D0+&LffZbZgX`{0b!OYobL$B3wx&0#kF_#*4aB8?N`wjX#5eZ3*|g92hIOvMR0w z)vwECHRe6fb1-^2=Qm@2q(#s8{0QKv#^#uJGi|(paxGY7YG?%K^+jNmH~I29Fj@HK zpkHWv`5l(Qps2;#FDo!7_5F`wR^f+mtV%DQP% z?Px^Y=;W?6Z>B7>XkRVa;RNs?euO?Q3;`kq`xP=$p`ldM*{mb~sA1FlBIyYob*?fx z?~gud78G9zP}&C zP&7H2e93NJFA@*X&ixp?t;cm#&upr4eu}6gf4*vutKoD(^Klu9_vRRG8kQ;#IhnC( z^Jp!z;7Spt>!sjUo+e(E2|?+d7MVTF|NaHO$Dux^(}VEi*FcT^=j!mma9gwcIIh1v zCtn-i4Au9J7C5Dz;jIP*U0(wFIgUKW{=;1nbnb@@ zrh`}#UX$HfM@ozNv_pd1l;&3z?y-{Xn*KZ=dML-h;V^j{xV4d2ieeXFE-@l+pHbi8`(^YFa)H#aNUV<4WLZuPP}?1jx>|ZL_DW@(h?Vh9q=Ix&0t#wh7<r&d?{2;4Ps7NwzYsdpnxQZ)tzP)BER?Li zVdGhH$-gYKwsR!hRy)4JCg*!I)a~1ZzxOLAAG zEZ*GO`}W8shcnz+#{cGYZRqwZmcz02Zdd=dr9f!|Pu5x2*mn$kLFcmg&P#|Azuu85 zM#1$vtij*+HmXnjPXC9(8}QE`@Lz>jUjHr|01&zSpTc|EWnF8&=tk1K&%Smz@#;-7 zWd#BtJEllSVo7{x3>%^5t4on?9(~OC9r?biWJ0S^E?aR$`MCWjZge+EEj5Z-uYoC5 zIy%J)B?y2;HiS5JowZIp**ZM8yx(1C*t1ARNQUgR2=~xSvB{t|4yA`0_9a&A%Slc!wxe8|JFb~O<2JG!1J zv*|j|Cfg+>LG|dtMg>PshtM>xToPy;Gw^@ZZf?DI^2jb#JdA( zKi|z9F3|qauO0ej5uV7&89ONSvKx;`lrkvg*?}SHg>e_~%!^NngJ?wL+tc|8E&xJ} zl<9`JSgc3>9*fSjsVL6!*SNl9h7o1qO{T*oR7490ad>jGYg3xcC7;QI^FwO_>R=3&HqX*LL<3K!j{WRP4$M z?ta|M8kRmnj8c|OHGljClc&8H3U?tkW|O2g^)@CegU@SO&%|Zn0H|tG9ewVAGgV(a zN>=(L$8nxK%E4w5!~LuANw7C2};2 zV(oNyV&-NxJOZUQbTbNgN$%eQQl!em`f8s@;DV2T)tVw*y%;o7ac@?6ao#VQ07#bP z;ZoO|Owju*@0Y*q|5(jJr($9sie=+jkt?eAXGRoWp#(L0=rz_|cST^arH>3|{5dMw zHYla8hviHUuY)HNjhXE&B2he2WN3U0@TowiE-Rbc7j%D8Q(~4W|NYkD7ooLYy4b&} zWU5H7bDn1Wi^%!QSqGg_6X&@kEcw&~5Ta&!7OXE^o;LbLaHXwwm3POde&bD`CcvKd zxJw9=ZAiBenJ2IP*D@OhqYTj>LpmIBoBo68*LT+DnfoMQA6UjZb&ZxRn=;1mm9S?# zXhg@yi2h+6G9>x??kZI2J@`FVIgIF`Jkd>Mx&0!NG+!i$ptSS-3(l0<qIp>YGj>z&l7y}mukW1lPmw1$1#oKkwAoJfi7e?`wjP0S8bG#aiL46 zad17!k3SjHP=a^9=Aj7acpK>(e)vTk;mr6X)z;Q9W=^|CwNp%k=D7SZ4l3-!S>532 z*V$rq-K^*5#q}Ymd(iG9e13T*-|N9#1_+Z=R`FgBnkZYgaaa4 zRe_Ay!;Kku%Y<~16bFdj+LARUt?b!Qz_|<=yuS@Oyd+m^G__E4?B*WC`ob$N5 zBo|+Py%^Nv{aG=YE>E-E%pDR_P|T=g)~^B{&T~+xBirjm-fH8WEoic9DOj@S9hb+s z;*MJqB>O0yP~y_nxDu%Y8g|NywX4nB#ymG*I(V>Q?If|_E`hQbY_IeOmCSJn0n)j7 zbXHcGWKyihcwN3DY;OwdrCH*Wb9~`9#0jN$*>-$hr4F{{#Me7!qKT7`(Am|A%c-f5 zCsas6FK8Tz=4Xs8_+w*gCzwGOef1S~YaAm0Lc?fdX7lv}>bAi}L)F~&{?;Xx|MA^_ z{)VI1*_#3g_`xvK&zbMZ zFsRt0V&XEU9CX`8$wlM6f-uq`T6Z61Kss7AukBX?5IMIgmAsxi5^^0bSY%S){%4`Y zf?oIYDybTa5iJt(IX7jp{`b^^FR05HIoudu<(=_?1Sq=d%W#WQ!Ym*DJO~6M7zee> zv0siSz2^|ewvc>2vQ7qo95V)o|q_7=m|LfBUamUg^kA|_*}lL^b#`b-BSTq*0c^A*Z85@ zr8b7><%(=}S-W)NJ`B!^7jTo%4Koud|GYEBVA~FA&-SPC+&B5t!s;{)b8NiNIE5A# zTHnpH3Cx?Y=!HbVi7+Vp<}lENsBjUBT5Y_Pa~A&5#XC&zQH4+T6G1j?s>EDVvy7!4l*ifAx!JEGRo4vt53V`t`)^NF$A8Jv6F=5|3)jn)L+IzmqPkyi;s~8Y2TU)UzW|P&cRLHO#Am$+Ujn*k&cpePS9w+M{ zih>H|7-;X{x~B-5wz1W*+XM#H(ZdTB1U z2Rv)*1<#;RZwXv8?2mmFx*xGmHX{%hjZ?aBq28Z%tx5}<>YPTJCD}xZ-RQpF-+6>- zy)rBDS_<72RtfA+v~=OyW@Bfwp-4}^4pz#@T2FP2t9%AiYcTsCme&0H3|~TbpL3dv zOI+4hMbQ9KH6pHaj&1=rhI|`aj0jB8+0; z#8`pGzUqHyc`dR>(*o(+RubkY9w7l(2FXkRXndc4G;==B$rG4OXU09DuV`eBW=rS0 z{NTBP5pC5gRoz(C4uIb>wn{+P>wnuK9jd(n0 zdc+Xoj9ztOIDF;;e39J{kZ~41hD+SqpTAM-JFrIx`anJM5P$b&u7UjSFYFq)fW$X; zFPJVb#0bThs2w^asX5_hs{*c7J?d0PpPh(2t-VTEEX#>nbogNZQwdh&QLs5@c)wQj zPRsu+lH06_gH;p*bZ`ElMR=Xrh)CMXtQwbmTzM3M>~~0jh;mxB+Ws)(y&2@vWZ1sv zR+Xb+Dx?>Lt8J?`-P#Ah2F&&zSWxF5-VCQWRUWO$rzx*(NUJWD{=ED0Wm<`)-MJOB z*{A!C#1w%i3;OA2*QM(73pQ}puNnp@(rIg)#Ms`jbfWRAHOafn&>nr1(y%pyC@}9Y z&dzC~r$?b+PduEkvVpCR+=&OXd<2&!b4+BF#Bz1GUOR&kZ4>9Zwz)CNY+T+v^3!S0 zpR>k7`n|81o2x&;0Lu-mvXffm_3n<)QOx{5pYSdwJ+R#U6r7{nIQu9#o|*AldB8RO zTtD_jB0zN{ps(`W@o0~vmF60s@v=TW!j^?>aL2Z;!@9Gar|2G0p)53Csb~%x(-p71 zTdNVWx&bYC+dbWTJLseq)`6k=SKT2b^RGjBVl?;gm8>-jG!wGRkA~w!w^h~F-EkAa6+N=NV8%)mEHxMmGh}5u^>CI` z0rsH}vI5K4@!|==i+HC5elJT+ET*S*<5bmU)G;M#Ry3?>K*kQ7K|BzV)lpW+VQ}e# zi)PiIL3tYGA!-7;*N?NJ#yEhww|YXg{}>3~Zy9!B6)}&eU!hTy;~-!bNfpY3J{U3tNK2=6ih1AUf%+nX{_Z2$&D%A!X?+xlC`Z#9i=s=cKZC3S{mhlBUM+A z9<5y24bfb{TGf}tE5j ze!R?chZ9qt{0QH;K4NS4a&)s14on&bzDY_1uC>A+AJL0 zT@Bv79LoK8j#tmnv*op9kz$Y5&Q&JvNk8<=Q* zd}B^BI1+ds^DxaOThBUrM1S9>gUUcfIYK~fYl(JQE5(iGL z>64He3UONqReA9Ewq-*HOh+K06a{KDAu>?j+DE<$THDh1hKghbQ>;1x1E>!M11aYS zT#hGN@GMOmqF;?K0&>5|zsJAd6Z$`CdEou@Eo<_P3vRi&DC82vhn?n=3dS6}J}hr@ zb)r!eMR!{p6Cs;=R&}2=Jk9Od8$_{M02j%1-9q_t+@C!BpQ0*hD>8yeh-}(NP{~s) zb&^6()jgXgzOUGh(5+^k-!8|t21BUCZBiVc<%GOKU95VlZW$f!|1}$P)n}+%!K26h z&4qn80nKm2h(w_F&bW*5*$IBuMW|-k)gP-Lxh`k7+&7&HYuvNG&v?J-F?jp4Uywl^ zZ$`wr3ttK)hd#DZTY354a}qsq8zsy5d9XkCl}`S3kQ3U1`IUaquKhG(sVMN=AL6v7 zLMETia((OYl5w95M(Q&wbqH`jfWSfVCAQzl7ORpGFR~ZV>Bcx=SVG^I)84B)V5FVSaM;4-51w8lX zZ8%sT6NamY0Jef?@NSafIT^89KMeie);&J)+fU?zVQ{81EJ=TmDjua7cHQQq5o;J-OsJG4tVV6 z7RIb7iQxj_(coZzZJN`7oGKh!fjE_$QwT1t@ccri2U+zX&ibsQfif+8PzuHCYK=VP za$X(ohLyx-m3tonvrjL6^s$_=#;h6Wiv*wr$(CjY%@GZB8_?ZQJ%F*}Pk|wI8->zxKUd=XCeI zU0tWo|93bZBQ*+5b6-U%(2&Eb^(yy6U+L?tdepn1Wcb^pI%JZ|e zG;N0tI&MlJaS#pRw|$J-%JFYoVm41ROC_8VZFN#cqAIDuK^?WxyoHfj9eCTG<0CK? zm)5I+1Y(JcLiV@S3}2t+2$8}d6EWSjU`w)$pC7edkLNw_lLEfGx&G15K7fDLq$&$f z!J%I`kUy4FhqwVBbUK5w1S;+3F zJ%Cp2N>)}F7?{eV<78-p9~Ps;kbzu?!GBo0x3C8x(54^>PHyr6W!UI}m zNW%Gfz`~_Fu^VX~%qI+BXcf_*Yeer%l(tF@j~W=k43qNRWl~bo_`K9sHa6>m}ZHJWbo4ekdlRsr-o9S2ME}g<_W=Pc%xVQnIK5 zBDk^7qjD;K-wH8csn%2qhnZOi_eqP{(Y^v%_bRh36z|2vgqUTtap~|c#q1)0hroRN(kL@}JZ)4X}=2tf@V z%H}p!pgZrVVk~kD68$-p-;t)Ox~QtzUR6`<+>$n=X;?KjK9(Aqf?`!rS7R+##Nk9*5#2E;G(Fa~IpWyb zsjl<&xiZ(21@7?>mt;0~7$6~`S&|X(=s^(3#b`QI6>dd)hjq)c!xb&6Znt}?;`;1$ z@B=k#`nsp8@TqVrL#sz|F__C_qe<~zZFAi1I7wIB$INXE)wX?%>t!II^#(G zbk*`f6afUoX@*7&B6_*!h*rNwb6*mFW~cc5{*uWUu<}FZ^;Q0JB>roX;X&2ud~4_J z(y*00w}X}DuyxOnKcON32IB!EdocqgvB{JPS@M@cZoEVaEQxSsY7NZ{KXkp8YI2~n z-ECyNr!yKI?_;^gRf;qFwvM#O81Gr{K#`~R9$eKGg%z~R>%!YkT*ACyorN3p1cX2h zD}W#@5j|fAJ(J#)LFFDcG5oNd$cJ&BGxNJ%sWaiC{g7}~OBmf@t=WcWA z_kuZdmSguVm06gTclr>s^wD|UhAuDbc{$%d{u(&VeY-+m0y6`O0|lMxRzChgv_j8S z5X3vxHBbfq5UB%xBthw-9F%~YrxeDjqtrp^q@t4w`mXrWh0HkUpmYzIK?oV);`Gv7 zKOkC~c^iUuGPus*F~jJP3+9EJl=5ig)dGZjwMvV{sNh~@#?TM`S#xhg3+*SMH`_WO zPM>zhcYvE=g79DA_B*d{#sKx_U1O7O=>m8iq&$ zfHDsyt7uBN6f3M~YsFzsqZ}Mz90}1XP$aQC(ND94f0%*N>Yi6Ycie|&aKLv(MY5$4 zF=H*){WJ{7j~A^h9m6AL9ksUAS;^U~r=dj!4bB3A1Xz*@o#@{ zg7ScxDoQ0ds*$e1Y2WT~^>74jK`SV3Gz@X8dg5Xof!e@{OLT`$-MyM!ZwjRWJ|7Le z6~G5LZ8*S)IKh{ps(&UyU-Lvk zb{u5|4ZtWc)~)AVviB8vJ2{L01jpqCxyfTDcZ?vJX_#dzVl@BqX7k;tTJ_l%t0i1> zYWtD&gcb*A(-!OYj2Rt^)~A0zcmLXp*B-~jb(+oBz}Hppp#Q~p_u|B;1OA}7@}32; zOE@fJqqPn?aKIu5^1@Hf8$b%XbXxNn>fW!LJ*y_!Q}8qi7vr|t;5afS6|@&RqwOA6 zyMYy!8*ZH~Ev%MWQAQU`OOl7>yzAP#2|5xe@yLi{I!A{WaSezFalAOCR_vXoxO|kl zI*Gcm%>LU3iG0?z{&CkH$6S(c!rm>RxE~$NGi==&x1Cw)8_TrhS}nbJ<=U2IVB)bRoZ(PZJAPOX!}Trxr8QX=0}z1) zu=qBNItWt1_g+Vt@Y_92E9NuMw`Mg1XHDSjIUcDQUA^|eD{Y?1EZL*NB!@a?+17ZNku#ik^&Y^_qepym{|K^GYb8-mj##XTwhzkLP>Zn z!m)Fc(|;m`GL#%zSsj;`Te9opm(;9pXa0I=O3YCcn{X! zF$BFYM3#8>M}P|;qX)qhQuYErOHKLyLVCWXm@XzB?FoZ7eAIumy zR1G5RKm_c%?J(p`$%r^IQ-lymRanH$gc43z-oTKf!tPJ-Af`NMF-HRnG)TD+$D|@M zI3^gF$fN0|mgyqxG#Wd>U>|sNs)=ua2-zbM1 zKt#s;#%2D+_J@b|=ssutcQY(-N&Bm{>Cf(h9*~vynrx>CBbBaCw(FRC9jKdTBU(uUS% z&VGC|=8@U7|5GW{vt9MJhdFP#x!Ex!gMa9BE_-+$@PG)zb$FDU{1?^nT?N6CnMrg; zifJx6l)o-S?8{3_QEN}{ zF4_%r)=O&3KM>!2or=}-wgyudn~iLB^Ka~Zk46~;{EfZ6ofQz^3pBT@ZEX<4ZA^Vx zV(uzvk1*^uJW{)Tz3G6$;q_HxtCB7C^|RX>C-zVjt+>g}UJaghpZ&F8Q_=3~t*d8a zgUae(7q}ea)@_YGex{}$w|k*F1>dHm&hmO}^5z0jQ8T(XQi19f8j7+ABIDL|(&@BSEFu%d#P zqZv1>MDOYozQGzeBN0zv9C?dd2C?qr?NERLm5_>ekmCD`7Tv_1cAglAGL^ERkVCW; zsDpYvLou#|ca%-w`+kZt2~uDUZ))yMN={)Di(=cH@ye{-5R*H0%I?#%!?3f8Lu2Y{ z`Ry`|dyM#GJ6~@sDnh!@FrfVyXj{{h^>W@)w#BIRT^Qp=c-3~Nu;0uP&A-`-a=BF& zcn^D7$(a{;=ka%YZszgK9=a)s0a0YJgiyr$JGv#07EQLf8UD;}NP2FxPam%p7P6A5 zv~P|XRk`W(zJ;%RoBl>5eiV?sq-n9L1Dm~!+FB%lc3!*(&!0KAXvaBj&yl>Q3YvsKM`A3H z`ue`UQqy7EqvbsZI0-aXD{r+jJJ`1Vn@mUbX;-o~^ryhM*vZv@m!|+8kgRzH%{@#a zi|H5_tw47>5nFK!MKw)JxoNPdHCDlTz7*mjLkd<7=87j)vN?~DtkTrM6|M2PD~rc8 z2@V{Kzun^icn|XEYGOI_U|WKa45Z?T_Sd{tm#CL{dIM(RIsek)q7`u#>8%S<@uj+d zVvOF8nMXpnWUJ2%_Zg39$3?t(LMBxuLJ`$VsBw`gERrBj3pgGnZ}i0cf)g`gPOLq9<_LTAg)MEf7c^>;m~Ts zen8A1Q4p<_oU#%z=Z#LRM6GT%OD6mFd9M>Ex})9Q7TtONa4)GY6(tTiGDj&7&}B2# z62t`4@6n`RBc?+KCi5NSNoIoi;}`=`186R^HMNA{Ku&ODY!Jq6SU0|=>gNA0z7GC< z&EOQ8trJ*_6$ZaAqpkuG4UAGSB`sivsn~bXx81pYwD-Gw`u(x>X)x%{0q_n2OfYH7 zLz2iLk#xJELCWB6=3Pb@XoGb&X6{T;n-k5YLx9B3h2e4^mI?V@I|;R=-te5Gj=+Y} z3fchOQD$x_i|zV_va%35uv>-Qyz-knxbk!LnGW+cz<$!sVB%+>p;7eJynyb`74J=K2ELmFKBg_PSg-HthLM$6YxfvzJnDW}I8n3B-GS$8N2 zV&!j8?R;^D%nddQ3Ob8>AQ;Y@PC`Z??KFcIAaqU9fMQVOVJh!7vCb$fui956MI{(= zR7f&S$z*bj!x|zf&_>ipkP6YlhllpJ;&XA=%)D>0+p$c7BuA(CQI<&hzb1lHNemQVSwUEDmC;T+`4=_cnk;7vNwrpvR zi`;faJIeb$4?Os<#Q}g(zwni%+V1&w{66?HKeg_j0~I41i1nfGUnez?f?P*DRuP6) z`fij4u;)0MJNDaMK>YXzYagHMZTn%(Q2Qu>Zc^G^%OPY-vp8l3DM?Bw2XlFF^w5uB z0@~kEGgo|1A{V*?x$DIYSdJ12C|Z_1A>Q#MTB0l3v(||WX>e+TmSO$!5|?&Jh)nyb zEvCmPF9_8`fw)%Q*3GP93JG(HFqy+Vg))NKP@nU>yqqzd?0dqYJXqxYBsFu^_gSm@ zr=7Vth;@gBV$ouqN)lSim|w3O+R_CF4*TMVRWqJ_)ue0x*V(n-+JYG0)wQ}YC%Juh zW7H9$wpDTgdr9^SpIfbhl)cYus|r8CwwL$A(y|CKD1!4n2uK=|51{BUoL+Du$R8Ld zQl|^zZw|=GbOPHOPzD5vDXP8fTq Td8hvVZnf7gj!vR1eR%_7o5&HIzlB0T2bBC z;ee!qtqxFBRa}V&YeGJ-s;v1Hoe&kUAayFKRvc62Gcm2({4*m@;HDxseBL99gjzBeLWy=YSgpL!zQ4gDxJi!XMIOjxYF-Vo@8JS}WY(@onOB5h7 zz_H#Vf1JY4BAlML9Z5_mv-G+Tt8(ym75)L%-!UBkK_({`>uP?K+Z zsI2>CYyKoKiaMxs`UBcU80eWmKxf{aXqv}qBoo!w>&@O|+*fSQuXmjMX@JtCh4xMr89{^y@cUv9zMsg}n>&T$(Y=B6t_h5KOzupQF%K9E%3UG2?<9K> zNO_|W70gLKVyg}U!RFW@j_cfnV-F<=2+;jI;>3NxKSBf})-N-oot0zTjFE=kHuqG5 zOD?C)-1Vx};X(|GQbf6TMZ@3K8`Iw?@TvdrdC3q#{M|Qsjf;RXL13Q`h`FK(vULro zDc3b7Pi0M)quR<`jfP4*!7uEY!43bC0$Ck)L*&Q?{`-X4-ayWvfWvQ3JRIz~D!|v< z{s$o4WUTr@9FT<(u_qEib;(&8AaVhb73fulW3#vA4PvrO zC+{qXKQ!?~_^W|0X!6org5XZEG>e)8SdK#;as7YNx8Z>y9!CwgHSd2}Jd8Drf~M%n zTs6UiY!Mqpm_3~pba}Q};)*06v;?wJo*WQ}zxW?bSbfgL*jDu})i0<%?z+DxHD#`# zhe@-$I1D#S405kc7AbthYE2Kg2kid~gq>p-rvNIX9`i*(HKEyF3swVFw%)Mx{5j(SI7tQfQmP)o8PmbB) zu(T&sH6M=1*UlYhIgF}wvEXYS19;7K^G@__;7UN`Y^h7ZPpwW?(xGG+lAyw-Z+Q_; zg02j>BI7cAY~bNcUOwQ{L(NM$Wi9|7> z_k;Q9WBF$Bw$@z3xRSPVDBh7bHN_=v=x+&awB=r>2n7AooSSx^4@Vdp=e0cyB`7^? zQ1qzD`O_pp<^ui5Fse!%k;UmX@p)vd6odt>Grj1ei zf12*g@s^Co$-Lj+EFv7kT44~Aasq=W-jM-UK-jpj_7A1UL_(2TG2(RsQ|aU1^!HWE zlJ6geNDyRK)RtI<5x2ks>SYHexZpfB>|PK4Yb2!#g4n)$JK|Y(Q(75q-)PY}&}qiv zhy*IK%JoAxfv=gx@rziuAIQ!)b>PCtK39AoOA(xxQ*=bSB0T)YATK~F8Y@%@qw-MH z3KvzTAztO)d7HjKQqGf~z(0)Oz;x`47ns=|3QE(@t ztCN!ceDK4`$6&A>9VGGb<65&pTTAGKUkN38BThy@kh3H_yCL(M?DMuz#2kEE+X{WqqpDB`Ev6yv(y6e*2=1JwfEIgNP}jF1|)Xv)+UX;mf{~q!^^h)`B$bAkKThGu=;-OpB+7&RBh3FY!J%o=+5?j z!wBI)&B8128&D?+1n~WQ+-{eDC!1>06`w^)Tl!~{8|F7tM|V|8hK`d=H^5PeAmLIH zOCf&N)Kyj5WQW#JRazw-nkt%TF2MyPA;Ib`^Fwi|Zaeo=E;YZMPNRVUUp`9c;A3pZ(TLAKvaVspGuTYonXm z?rFb2cdahPTe=}ec<@Y=ru1GJkrewpVc!GT5>W9y5?og~flOu}mqO8_E@t`cD`Qs2 z+{_5oc5wprDfY{FbB_zX?%SR^+XPO*xV7s0etc_T8|hFX)NPb8uJEEh`XE)oZz?sW z5G99+In9-8vhRDowC{w#g+wFkMap9kISVyvzy_@-Yg|ifc17Q|@aWK++xdjCRTGHu zJ}NYJ@!l(oBo6X{)oNLFniJ=@P+=PHX{j;=hJ??lZYZb@b_L+5ffwoC7Ms=I~?E;zE%Q8Nt5$ zb?Ex5vaC@>I^*lF6y!@&HS6aQwO~6Cg+EIUJUkn~6g#u32abzFUXcN3*EP0}r1aq# z-N$Vli()B}E|wDvZjF){C!TyrAa902ThcK>lsM1Y%~Y`B3YtTo)ARf<9mh`V-UmK* zEV=a-3$!Jk^F~P+>t!~xh&Z=$Nvya5^p2c>YrM~-@Sqv@qbfi-aO*f`jz~YyPsL>L z%{G|$GJbmhY%jw8L3+wTFh+%<7Ve2+eaMWRw$3z=XfD{yU~t^V!qB<2I)sC3G=OW2 zh(i=d*a9qqBZyLy4R@rfNj4X4A#aQTVeFpyVhT~F0MgnZ^fC!9i~mkC`iW4YtH&Tn zNZzX#*C=E^0dcrl3F+C(1fOUE<_VuG*;0RwpFprnlT?{k15AzABou^QRadovib^PT zA)$VN8+67W43~#sko9arv{zVV3<;bUQpRILs(I;=^^3R4buD+mkeRO*TNZZ6;p|dP zXk}yY!~4gI#2T{{XVn)@1OK;Jc#fpK+ZY7DDc5iJ2F7}ys7jA8K5+h#Do`T zFcz<-4c;_s^g(}cKd@ra+}uHp_49ZQU})cdXlphS10h9OqN2Vy^$)@$6*A$206e`1 zjGQW+v-5V&puhzJt9kuU;Fa`cNRuvl4=FVlFe}2Yb1Zq=3uXrr#Mb~&IgCPyD}0@r zxJe$H@_L?EOC3Dek`1}IdBXK-Xy;Q~_P^F&2&Rh;?pu25KJF34(q0`4Lnp97Y-rUx z5m};6n6~PIAoAx@*kQUax*28E<(=0Jt5f^!jzmF!@Q`lmb+vZ0gd6(~_o_+D+1;7= z)wu12uJZQowL*vvC{X0hxN_X+)5oGQZZzrvyXq{v2y)zkDM8~1F+ZUn3!Fc~^I+uN zIrTP|!mMLmuv@N&SZWb@ISbud)(-;;120(;=BsibJx~!~(cy;>>wuxWcJs8W7V39u z&40StpH>r}2x=?^7#@5O85dF~FZ>v-(?k7>ke=NHJkOsTY-!>Fw&4pr!|{JxV;X#; z4RnmbBpiLN{{4u$ed4KgMDFOo+V*3SGWrqibWhKN4n0J1?jEZL6AaKCX4m6;uJXh; zIwcO<#MZ9bgsIM@xPFRJn#~-3W8cqym(CEB8)Zbhy#O5`1A^aHt$ruzxSb;KK=jf^ zg|%~ct{i1=Xl%k1CR{gyxzW8FPa)qAcNGYgBy%dA{fT*&$RTx;3H?y`8`+fy8O0fe zNrrG7m!{gyhh=LQ#nJOZnGt2=eR-tr2X*V<@htwwjpbjU{CGvz<03;^&kIMYco{*; z^w;^gz{3E|dMe0OQ{wB*6s*6WR~>|o7J21DvRI11nmYcDkP_U6s5TbiDGr_ zY8N4f8A-g9XBdBTU7mag#&jjtesN2QvM0)>kF9ij1Cw$An5j3X37(hI=PP{c%*Vvn zp|x&2pZRCYH7-Z~t0i7PKo<8fVy|8E{PC|2$>-voi-s~)d~zEr#geuGWzN=HiT#Zzb(1UJ+HwfHjs#~VkIM!| z=>qrp>t82FGz>FIx3m4fLrkJV2gS2iwIO}4&zc*dz!c}7iMyGx^I^m;thbaU0zm~k z8=>#>dHzUaAf5i0u(x0UA|8|6)~iZ}>cu%Cnc+;I4~!Wa8o}^~N9}>5hv40!x0}?= zDurOj%V3oHlmH?=XIDFXg!xxDWcog$^^Z;%zjwVKP!uO|WdhlCd!4g87{O z(L5vXJv1`oD3OxpNne-~v-Oks3KrN=vh;1a9!2qn0z0fX+CG%U*ce8#-}w|Lb~9cywy*j zFoc`4kcg4wH0$c6eJ_>|Oox3>8*0_m^{&&-Kb(=Pon|ztks<{^9^udX`a%Tajo&If zUgfCSHV@KLH+!^r>VI%wG^CyV%K5Sv2=d=s-lng>==cpxJ3xuB%auOs-ol>GNROsw@bXlA+HJTi?(@&A_T?nneRLrGm%Hu zNZ_(16Dy_`Wd{TJ`wE{IS0v zW`wbUr3?JLN|x|58EJF&Ym?{z?k4%m(*!VzkT8!D5wJSvsw^%@SgQb*9@cz>Y#4Wy zX2>)y#HIG#)apQje6bltIa;_I12L^m=93jrR;I-;Li)vjr#0T4msg8=DuF8^Lw zyvr8CRSP8D1UP;i^Z*2a?Ez=H12}ctKA+y@{L9ArNLV3B+)l)A5ya&@l_wKg;29p= z8UMfBPJ@kL3jwIyZxtqDFa+E5zZ;F2d>;1Y__~ni!jfE7rxTl~?(mJ*ZG`8)#;5~| zs?r8c7lQCe7aUmu!S_P)k%Yn`z9Zbvlp~*RI6h5uyrOGk@3qW%bjh5_Uoh6-URn^w6TGj*g;r-)|9xCrPo40NQ z@qBxGJV6OmPKvK2^E&+iHu7;)lMek_4Snq7*}pqeV9ExF@a~y&DP@5xrwm{aPcr{)~j94s70(; z(s<92I~FZm6Ii*p{Z`l8HfsqX;l*oQ+yr%X*Wo@aWMj;h)paVurp%SJ8$W1WjGj-H z_slbP6`l#&y;422dK>VHIu8$736vlJxu@i9#s*Fk?5|#o(SIh394i_|oYDl0_4FXN{wp+x@`q}59tB!bUATgsvRV=26 z5N>Qy3cb$)8TU|4t-6d9=5bGujoRC76gB?wc{Pey+Z-Pfq=jpVMz!*J>k2#pZ(+Bucd!5g;9h-C4roQWKA=m zE0E8B^z5$>w>HzK(z5#EO$znq6%*Utdi@kzKZzuB&GkE{nr4R%Lu<|ljaJ=3>u7VN z$myL4zx%KSS6B(50tV{tk15mA)76XXRj8Y_RA*V?0SRQ5s#ekz$)_$}sufY6bZdvGZcBaW0`} zhS*}Cz%BN(#Q%?T7V-h?HI_}3g1F19YpDPGI*l>tgX_)y$Lw+krmaqB8#6_3+UyJ6 zdMA$FP$QXiXZNpf$D@7&bfK?4wZpNafPXITd%UM9`CSGo7t7geAa$@&0bu``lQtazK(?iT!suro z4Qs38iV6VO9KRtHL^>;Yf7tI+XMY>aC4o)?p4pO&&7sw6F*V!jkp9QJ6+s&_Nx@Pg z{|DC)k2IV3FZP=L@8XI9ST;v4h-)N*xYfOvoOR#Oh9E)6Wmrsl6K3oS6eNUjbg@1) zFgOvg00|Ogh&jLI5Mf@)B-#PdLinyQlEjFLipshApJWZxj~QfyWRk*d+)6T>czV?p zoNb&DC22;guTe67g;G0 z(;S6A@7k?uR7Il-84->RBqs3aJ;q}Go0zT*{xaxp_g=Z(QCUmslDTfVuiB5!2m%DX zmb^_Wnp07*FEpga?!K7#0|1LE3u>wIZQg;&0N{qBN^9=o#^-on`{IJqOAofW=xNb` z&;V?;*B3)*3_!_9A8fQZSyH!IQU2=4CVSLZ={(IsGwOz4!tYe{eOr)WFmGe;@`2+j z>W0$cYXhYK1cE7xVbOx`a|YZr)J_73LT&E1R&A>sbwE(_tTt^~&-6l5Gfflr+W7v} z;(Rv(a=3P~INe1>9vs|Xe#Pf>?st5C)AMb0cu)acp!v5wmYlOY4n|m|^n_mA!bk&t zm-F&p|AMs{tVL|QtiCPw%C39K$(Wd>kLgO%_rJe^KDvcqF!vww=N369m9QPAEf1jw zevhT=IQHEC-`qlEWK)STzN5bKa!KMrX8DCFeimTBrR+Rw%Zil#qh-FvoxFcx(#7=^Ze>>CqM(eYI zx1Ii9jXeHm>cKUq_*W;Fs}5h&DsM-dTtU60#MB!c%x=^2M(OaY1-zXqTJ>0UzPz5( zv0&MT(ssu1@&Ocj*OhLddfOtF0b>IWH>NzxrJ|DBV^OtowQ5K3`NkPF&0;6bd>}+& zih+)l7`+vj`((fbZ?t{(BpE(Qt2|dU2cWVdekY}{F=$vdGGW$?7|*Y?9!w7d_?POfLJKDHt9I5XbxcPYd) zQ?4`wTnm0+vLRLyks|U&p=<6jbdbicF2REe6pU(&`@sq%jCM*u+z164N%l{>mDq}! zji(*DXF1-tYd2%?f&HS@7FVI^srA1X1&tgOnKD)bHHqt2**-5ckNEJKLSu~vW=Dbuqa-U7X;x@Y=FTb37PQ-Rj__kG~akD-Xss7ud14JP6Xh-WV2`n z%LXoVIF*1~VeQuu+S`cE2sG!M{0%~faCZ%?8 znn@8Ln=DQ0_!9>OLqbIb(g3((m3c7J$tux8exn&Ct13H$ge01)ECcM8x>P|?MFmyb zfs&qLyTMUr>MiJ{#_#^Tr@6#(BSoLvk%#x~CPno@rE*QZyvuVXUB|QVX}PYiKeGa_ z$LZ$`7Wbgl=z4kT%4*gt7*Iu^OBsH3l!K-Bd!~zZE`Qs~Lyfq@S*xS5i9@C!=(72F z_BNd4HvF9&=YQTgxi!Wv1$ei0dRTePxaSRNb>W~6&S*iZqJf2aZZ1ec9Qv(tCbhK% z$f#gJ3ZW_`I)({d?tu$6_&AF>tdox}fHuDYqD)PR>4@yl*q7io81#Zy(k62N!>=mx zo1%4k{!TvHP5$7NmO*A0+Zh(j8!Rb!>qoRmS2z(*@x;)zjmxT-g4Ad_Kg=Z43OVfy z*tpRQ`|Dptrcb%U5HJ2$gPckUCT*hyv1=?cCZd~m>rsY4!KNXe@ zl6P-tAOnvOlT>fmj;uXm2lzl#REPWFgpwj>C;j$qPnI~`Z8v2ciK8lUEC%>=@o~k& zpSF&5WR=g|-E22O0BLxeNBILJKu^yoxWRUdjYeYjE$pi^fzN9H5FE%GNXejfjc|ne zNnIprl)jrj%){14p^}dq*ksMZ5mDL8(QiXn_??_oQScuIUpA3(BSE?;Fp+)Iwjagp zWphid^BI#bpa;9QvWF5b+yuV|?MxuMgPp3HXz@R!M)I4&$6@=zgag zxy$yK3w1ONzR&54@Q_vafon_vBP|k$M|BvixA&z!_El+1CA_$ebHO?r7T$JmX4~ZbhPiJlIyp9X(j`8KG_lCc8WFliu?I#0QzZY54@fH=si5i64DeiQJ}>cz z%g}h>ZlMB%SL$c!ikTF1V_h?TgT$rTQlOX{tDVR=`S8RU9~RH;(6NOv9_Xt7)q9yB zOoWrDXFJl1ig!pn3C-MSC3uU+ckc-#<6ANaI+X2GAEP^D$cDKY5$pRdQKXmq{uV#A z#VQ_t1~)w=4blrk{zG{Ph4j~P{2xGm%@3#PxNZ=zem1=VuzjAbXX}C1t!?k`KfkzQ z5ZSf!5nHoao&8^oQ97sF*LLm3(>h^WJ~=~)^#taK#~+`9@kc%(Lo(~r&1A{J+Q!(rj$k| z@O{`h$2^bU;t0E04=+|j=E_S-Tin( zq+Nx{Vbx(54C}6qav|^I7^l64gRK9N)WD>t$O3D)p=W*hDKujh8Pycy8)|Z9nujSM zOfo9%$4}J8P*U@`ii+rJ4PqVFW?3LSg`lR21>PQ7SaMowfWeAa4`W$nhZR0RSoZ^~ zH%%VjA=Wgg0G7o(^Z|4*Jo9JlIHep?$(`bIGw}TKQsfpnYJc`7MJ&k>EDTp}mQ(I5 z3XT|K?|N_4hd^?~lH2*GbTU6+4( zQL#9Zu-DNuHDGs6FybrbP#X^Dx`!UI0_s{s5@Q90#t0@|-z1hL*O{kzf=;X}Zp--* zMf`7G(;FBoK(mVZy!U8vUjBS1U7&;+Ldnr7I_0L$`=#R!LEZm^G;7Ryg;;1d3+lIt z_=Zl{p$yc`ZTzKn3V{HG4rJEu&n?+!k%+$+Ja4RU0#3BZhmxATj=|*NW0;Ka{Mf=^ zO6)SUu&;Qdj1~G$juECM6OlkMJA4^{s((mc9ZT8?_tp~5c`C2w^fqQ`(OV}1Jy((KAn{k8&^M^8qs|uIF z=+J!@ErPP)xShD^dm7XjHM6fXWK)fF8K5M@V?ZNzy|mfGIL=mP{<0*42wPjG-f3)v z5Q(AIt9~e`;~%t%KdD){-R5UGA?qACQ>2LJ8U72aKvcg)GsuS?2k}IlD57r|7KxG} zjw1*%l}eeyaXcQ6MB^+&CzA<+AY!qYViY%#h_D()Boc{mB!WRKnMi1$#sM1{Tw@86 zBsiXrMOD_IXNpFnsg%c?XLz0_DYJVdLGXMikx10@!-c~kk{~e*q*AG5?;t_U#N)A4 zN+-h+HWW6Cdm_**qXasgz|4 zfBb0aw?6)p^26ZBe}2Kfj5APg?e0pjzwnNoEKP1--+5+M{jJX)+&bO?0rB8HTYm7o zZzNqL5+N%(3`Y|z7m7r~bST6plF25$k~oeNMbS(`PLsH#TvAZKXUA3smSrVLxfWF8{QqKt>)V)3M4REU=a zhyjh_%jtQ~$0Q=qX8rwp?vaGj;>0OMDCUa-o$4(*2$bqkES8~0I=emawqMN=7l-e8 z-yduJ-_^(UGMpwA2d^{rH-u(s)}x8m5H%3nM^*r#FfiR z98*+PmeEiq3?e~Q)674iuB)O@PR}bcg1w`vszNfPViYs~j0|lM3t3r`P?i@Am={~U zR}@W=Wkr^SvZhF?VzuQ2*EOhX^Qx-KLKzy4)97>EWhQm0EXAbHa^vTa6Vm3_{fA4Qm zpG}=$d3ml`$rOOXEK&iPlTDW!BqhL6jyNb*+KajFqj|RxXCbRdQz^~7oX^c(wA?VL=qNS~ z$Ju7GK)8Xr%QECpPh|5*3%*Qi89`5>`flqUwGrQ4^ZtC+XJAo$j|94Uue2-Vk zEn7ElG=+cp2Pa>D(~$RV0Ng%3A3iZNk_r;Dtg|n=9tx6b>4BQgu-Ccy254`)=ovK# zKX5T>jlJ%jriBsedf#;|e=$97PgCeNk)$Fz&V}C|8Xd z$k1#Cfs#*WkSbl4G+FYD z5g}-{l2rgz9vv9>yA6`%RMC>5b*LLub6$ppN?jxyQYEoEjxkIsWNL252RKfo*6qkl zoUq!3QmQj)`;7RY+7pTk5AS_be&#$yfXQdR*S+m#9BHKjTSOH2ft5CgRFo|VN?BAh zd2wo?$eR2&Ex~AV%UBX3datW`0+=WAp}lWH?<^d><2V1J_IH32WY6}S`Od0$gQ*5_ zIV(^hYwS`Xiezhi^m!o7U6@1!zr~zX$a-$*bSS2qV-&4^u=lz<{GK}#P0GbeiVa<7 z;;8_p6Ns))DCGjwHB-BXbT#uA`BV_qg5`rilJVh97oY#3b%Sz#e8X^XXqaihGCy;P z&Ys=3Pw#t-rttlL_q&~2_SpU=exNCwBG6-XZX*!F6$HjC=S0oe{g9}=(joi+%AnxM}u8k_Vnz$H8Hj|(%qUvTTL@) z2WU+TG}slUDMHJMu`r#^iR*@9TgMtFQ@jFWf=LRAW&TUQ30+fc2E|`}->#dtck_5Q zSCm^=7trgs1Y8}$RNQMfh)7bv}WW>H%|+|iwM z`vNVWK9sv~%KlT`cyC{4s59Zd%#654b|nMpoY03&QxxTCT>JZP91$dy4#!lXq-)jg z7Um|YP^6(#bjQZ)5Sd&RP%7p-Htfcl08WyT?!i~S_-D5LVD7-votv)1ng+?Rotti0 zI(;}ljdBVO-f~~_WnC-62OmHxz7|U3KsS~=NmA2U@!7+316|>pw>J(3TH|oP@2<_) zCq_1rC=Lswc!U1mYmzUYJ|yKcruxqadq-K&(LdMe5~6^wiriUK5yvrFIg3H4O65#R zOA8>vyY(SLc1g%qi3Ih;(8Nq3ROd&DP9Ol&6id*Ym?_LpPWY)NUpcj;s5(J08VVmX z2>Xpgh+51BF2?B0XDkXmxv8_&F6mIp%=5`0D_&K>1#p697mRF~rUPto{sI@sM&S;L zz)3n9Kk(3>oqhDH<;Ce(zpq}n9$)^$d%yA6IrH;x|IM>+ylJp2*&vHT9ncd{R85f7 zi}S_1Z|MJpw{HvAy|pU~o~{rTFaLlLL^o4f%UfYA!W7-UYISI~)_OHe0O6up!d><5 z4IRzNhHi>DH2ItBr*kKTdb4^=C#MiPKCP5ss8Et;m&`0u2(nrY{AeCT_27g=As1ij z)fW-w_^-R1L+fIh>l=-^TF?w5s01>`8r*E2N=U;8pmwy`ZUoN0AhEzjRA*Ue{ZNUm zvT9F8*)RU)bv;zJ;haOqZbz4jWd(@&1`H*Z2acurGQWH2*uqK}>}BdB1`cYxzg_5a{aZ=h^@B1FmBns3p*yBmM;{}_z+*_JvT4Kw{+;m>^a(0#WI zJL1C>%|uy zHvbg!X(k+HqD?!c85>d0j=kZYAvzQ#7~W-@55~sf_l`=`O1--z5&D4L|%L008p@xQ}`~-9}>5|^OsKl@XIg#{U@bzfo*>BVA!oYd%yL>xw5GK z(w{u>;*t3uJ~yeVdZy4&j9cqi;UqRxo|X(CNNNa|G1Xz%Y6>+Y`S=pCVKCm%4!oEV zl5y6{X|&pOt|*a8zM-w&v@k4YgpL?%EM`HO$ZW~i!{CjgLz{by^#?|%J5{m4C5(ff z?zGaQeXr%y7m$lNA-f25eY~%+-NS~iLDQ`M$O{){mrCbn@{hkfEmnMKuUs^Ea}ASC zAQ*2%C|j5LY)`ZyWwd9~gIXcCu=;R^sT$U86{!|&eJ(Mm_%?fuPv z9Kx=AG`8Wd>*a1B&s7i5=Gu?K0jkbtwt9oc;O9PZH(z)79>ivEN-or_%FfTej&8Lc zKE4zyPKQf_+cY3zs=9!hjuB8X*pdmL)2P>hL)q%0Ti|w)-nk1^6l6&KQW1HLZz^6D zATXM&(i#oip{RK>0BT{uqc{rIQ% ze*7=FMAwzC0!45y)jSC}h7D;9En~8Vg*$r3Z+H*`$jFN0IKwc2AaR0VXbP$lMUo7| z;N}q+#|u-ZGs#2PE}dlR4KJ_ zH{S^lra-@zB#0fnx+}7bkFOaXQq|6N)Fr%o4*e z8uGTOj;m;h{@=fF(;xoRD`s<%&c0(`|BNma7zX7CAaMM|H~zNgy1O_&R4+(SRP|{X zCqxiVcCwM!w?BMKcH#uXFc1IA{eS+ApG^*o73MAm4-xdpAnZ=^BuVZ4{` zoDcChL6HOz;&}oMUp5>L8~s4bGsDoPcNEX_;V>2oq1BFK(KW0w)WYGABb_8*ji1mX zCMkdchNjIwO;9|KW*Wn=tQrc1ID>kKrfCQfOOLb9EXxrDVb^B7GZOg+k)-QNhM^T5 z4~0SuL-RbRAhsVgCqifpVX_KB;V|WODqvZbp&6dnD2fh+L%M7|H;&^NhQ@S_<9JtF zHm=*;^QZrE3_U1{qR~S@mvKB#7|+veom51Q<8Zth*onfdhob3%^1bI~ts*Q-Gu9*G zc$TJVjw2bG=0hPb2BdJ6Djw}JARG>(xvmIyKXIH0g+kD$+cr3kQxtjgXo_Vyon~-V zB2*Cvfa5uuq0w6=TJQqqdHxcItPPe$4`G!jz*1Su=CccsE-6UXlw?WOEG;$E^L9#5H26{=7bKF}~-zbWT>xe_eb)`|8QY;oF zSvGlfaL6FGoKi{Gp{8l2Vo@wPG^&m!M2Rey%d#vN3q`HM1R<2ml7z;GAXHm-K~>dK zsZ%bKRC8hWWlR0r}Z)6xGlbfQra_lu&S z&}ox92+`(1-N{9R%vBX;8&yS4B>;n}qS$97!Wfa7ieRwW7~DjvisLo>LIz1pZs*3uk$f~CM(cPHc zkkKVjkwo({^h_WsqU*Y9Fb+`^W&Y`GRC3TIq2d)HCKd4%>578hf~+X@tVGTmAqk`r zLvK>o6}kF7#Bg1AKcae26t!+J^4PwqrM$EOCya^_(UWStpjLT)t^i&j1lxa+AaKfZ z8Hf+9!vG+VB*&Y|86!y^kYue}nLK^QI7&JK38I7RrF=#-+&Pg0I$uLWz(f*uOnUm< z;d681w((S#!y}qc^%NJTY$c#lE@BYVsPK(jyIsCFd`GuSn0x7*I2sFDmZJURX1!%m zee}hPKl2tuan3{&E+Tjr*my^z$AgyQ@V|XJF|rA3gw*;S>2pWT&(LxxL6!9-K0Ih2sNhVjlLVtU@G2Rb(~A7j*d{Yv!1Zj9uG!|hA0sx9tW$~*&z5DzV-%^Fr zm)Jl{=HSxvvKeIE|`x7^U5{cE=o<8oOb)&W_ zxHJg>N`eCAbmO7uJLH-fM^$frh%)vWWG85g^DaBo6LI+8x6~QWtK6+XC-p(Il~hp@ zH65q#+dC{NHK%ZfBEIv~`4bb~X>HdHM)z(_m}Y4gR84>QxeI5ed?(N4Kx0-WV{mhh zhg7kipk27~b>*mzq+v}rm7$0_zzNrLt}&ybx?C)SC_by+@M{Er)qPq2YW*@5SnHt? z`~{uIiKVQdm(P>_`3;O>o)$N>1=ti|0kEPb3@m3wQPTEo>7IkolfsB+$@^{@`m3)T zs1_$kr;-%dsn!}cY{_6@w@(UfBXcj(_GztUEkd|46LmQUrI6Sce>>k&`g3>AGCZ%Y{s+XV@Bg09PeJ6;T)-a<-vr?0<#%i}vf}5?v#=-|vd{ z+W3~AVUU)o8b!5m;$=jZShtz*scxro!<5}I-S?$(sa~m=wPjfdv2}(cs!}d=Yzzu- zkMxff4bjcUol%Nhb9=a=F%A&dZ|*|wG{IfeF`n{%H@5Sr{pZ=~+= zA3D8+d?cMf9jTFCf4pxBlYJ?b0gjtwEjG?xM1mK@V6asP;3!LtU+02)ZX6x!jrJAo zD+){tC~llQE#sq!M_#ya^wi?Iff&?upZtOrwv8trdH%wZ$kZosf+jm%6F_Qul5&ug zSvN({j1MW8yIL;UWy{bI=?pkF?k1LNF`(*InE)Y%NHF?YeYgv_g?$9ae17w3Gd0kygiJ#3aYI zTJO*ZxK6Ji z>zdCdI{L1^`^+Q%?)>tl)!Bg&rle1$aQYMF4{jn`E-pA2QfV3 z;N0W?x$~!fC7{m$U`#Bv^Zmc^mY>C=aA`lUa_8X-Qkar)&a%gZ&oru_7| z;{1h?+uzc#_=ErOY{!P{&;IDErVef4_`aEak8OSM7q-9azt>*4_I3b!L9Cyceusf7 z-)qeh)?|MgTxXaH>Vim`-a4&?yw>a3s>7%x;4E%%>ic%jldi(iF7BGl7>51hw~lQY zOZ238BS%49Fpwk66JWqj$>C_y>`pW1X;9y~Sw;HXk@{Sk^}umZO9*K}$SgXOj5vk+ zn&t`oz(apNckmh8iht z6^+y;rjut~L%ori#gYfRTjhgaeAedCxaV{8U8s@r!aZlc{QZ*$PcGhZ^9J8EVdgXggq;<^#MDBu zBRNm0JEaWB8Z>PxhN=d=4_!9WvKugnB&v_V z&^A_?h0GIbp)4mN)hq!xMN9dtQ#+;zB|wl9vxS}OtG>qsNh7C*y5aTxLmqYjvuZZc zEoPUU-yb=<{LH~wT~(M!!q2-n+&hAjBh|jhd{GXu6iMPzAxDLL4N~Fmq0ITZ6fedx z24En2;Z&rj$~WllGmc04Mwd=ioq|j}`oe|lHg!@Yo<4Up<|BWT1iouS$2{IUd*G@1 zU!OZPrSIUGcmf2Wx z__j9(G+n(Bmh0JhYxnk>y0-2y|Ml$Lo7%8z#TgPBOaM(PmzNfh`ZB?Hc0_ee@6@Ku zpXgA8wxUQYR6h{TsENuU2kM$O*cA@3q*BgT?W5>x?Uo&u{SPM@6rNO3-JrbF5SyGW zbS5}E=K{%c#W^bEwQwy2);|yl|2CAvy;D>)LkH`6vF<^H;Quy!dd>j=g@r0)WSd zHk|tYL$2@t*XLfODS~Ioi6_6+f8#x#Wm$%}eRtnW%gF;@LP-f+fDkLpT@-UmiP4}{ z{I1vE{KRvoE>3&s!Dn86Jkmev3Ux9&ZFL})>e#UBnZNi|rOsx&eCF`6Z+`B&5BxUP z1ei#obJO+nN1ng<;v?q2^tq#*o3951b$O3K456zyx2#3_JPfPeTb6{;1cB2A$&M36 zjp^;E#4dTA-gJoB0h2xrSO$Y&(@*7EzfC)c_T3=v^!gQj=b_1|zbK$X-rrgAGC^gH z{nM+rX7UkaNAo+!1LKK$vy$n?q9qq|!PG1OV5cXm1ikJ`6uJI+c2|>_0I8T;c5Gkv z=~ETUizoKGTuA)-!BcQ&JRRm|fAFHUkt(G^|JFU&C8Lqz0)R!BI;-~AT3QYG96bvf zw`lUY*1nZFIcm#l?nhS5mgjDxJIS55oGh#sks+(@y3HeriJ3yf31I0K{q0#$(>#?q zXkT*V;&zrW|4-K|-b7wN5}Vg4df$nKt)odDS#yJ5gzscF#eKgi4Um1t(MwP0jfv7- z3sX&LO>-H8fB4*FPm15OrMoIS1uRC5GDMjeOM!Q76?d&qVylfNgkTS`v&lm4@2dJE z_xtvJK|D)k3l$e!<2kyu=EfuLh;#W;?J3jip+`e>p(NuJ?U&Qilk2=A&e0Tsh$}oD z=#9(KwDEtJE>Bn+b4%rpI2&L^hJiBxtp!Li1?h-4&24j88=yCxWeb(mJh5}r`MKqC zcM5F+I6;t10EZk(aS$6=mzzQ-()x`swQjrlp>rg$RKlJ-Fy|~wM-$2MZHmJ|bp@y= z5U>9N*tD@mDhlI?p%-TON#jP$jHwbe2xuw}H%L_lDmk|+t^|l*-LQ-xMU4Hcon;8)=iq#0|VwYhU1S_kDb#S4FhC*icPZ5~a8 zI12H=Lhn+jlQ@A>h$GDFAp<%Tb@sXXgP8^%B?=(Sd$g~q#E4d&nDKBtsy4QONY7CH zujn=^&YpHX?&1L1gIy23u9Jur(f0!50OtC2ZnoR4e~90L1f3l_wns-t^>S&)j&1Q|lqQMo+qU)e_e)%8czCEcjwj;rG)X4n ziEUfAq(--9PrkHc2b#o)aEj$eMuvCnKrL?i=%)d|j6bczqE?o-?z*A7o8Pu=n@I?c zW00BL-??{W{e}%}LID6|ANx|rP4^Fujdi4wX0@p1!A*&Hlw;XVn>U9$s^RJ+UXDci z?8{6O^n7UY}4qFKIj&*`+GZHf9IZ4=brC*+lQ{Z`Bt@D4ciWf zBgJAN8Kt{6?T($o)(>>UKDYB(XM(zK@A%_e^_ zw{h-oKKk~*efZEH{*?2}zP>&Q#CPN(TYl{yCcgHm&ErVN6$>+Ap4z-|fDLDM?64F^ z6aVpl`$xBo{^qBqzW-07qn%@YkvHWnWm?~xKCEGQ=^|2el0sRZncOrsh-hXjNI(3K zRHBb;B|&w)&$@d8EQ@-!n41^%V;8mFWVcw%cT-Et%V|vuV+3~Kz`=epHKZs94jdc~ z5BAE^!9$1U@9vFb*yQ9SD6>>JqN-XtlR0wq=&&R$FD)N9fPyfJ#k+YOUYMLbZ~*aw z^klQT)^Yy)`2zM9Xe#va9^+K2M!!qe0}J|@e^lfN&tXZ3Ba)% zv!~Cbx6)>{=q(EPV8^&1l#d-d?u^A|BJo0@+rG?1p)h{z_=&;HlIwdkn7YFA*D=eX zrE}*dCTo9BNxWfx>f+%; zhwEkN(ZqN$$sRaxls|KP>hzhZLkDerc`D8>+{7O_dK7di!(AH=9Xh1e`7E%+E<&C^ zaNtz$;=KKM$cW$B5bJJLn5!bZfjM3R6?l zeiz^bxlRyVzfSES&Yn*nI55$>Fni#sADIDb+s0GL!5F55&Yzz+aG>%S4;<{?c60v? zcON)#aDB(R4Lw7&Y(*V(!kP5MiOivAF@PP;OOfrpu2%R{rzJ^hi5FFro(a$mfydqljZ>AQw61CM8L2bQk%X1r9VO3lJJ2Jy;X|jF?%6Zw zauY!MP4~rSBpXWZS6y~$e# zWse`+G!Y`{>sQ~qD_f8bonHRR4^MgeQ#^fOcJoMb`#Q_WGm_GCBH2~$_{dLg|DS(_ z{D>|G{Dol06W1{;%0PSn@KuGl;TYq_q;b6B2A&c2mo?MGUtMS%Q+@kiZMEWn@7-T( zntC;X#GWcsxw~4ch(|$Sg#XriAKCS~sRO*K_w6iV>9Z7Q^?D?u>BgjmAW#JhFzn?M zOXWJ^lZKJ?<}-1oZpap-Pks47L8_l@rl&bRyHxt$|L~%HBez7}GqW!}@zjg=-ZFIg zJcI(YZ>Z5Iqe*mPp+v}v#?-Hjm9gSlyB0G7N@wjHlMq{ksT;TS_>cd(CD21xiH4zP zXBn#gMm*J^zMf%@Qa(+f0A(*tt*Mo2a1d?uvyp?^6MnY2ddDoUYR#ksj9&+!QSgoA zlfLyE!?DpR+xMYSDB!q3X_UEa=iY7a`k49WhBv)`+q*ut?Oh);cmQgSBCp;A062d9 zo1cde<5*gwLfqJO#Z1P$6lK^n4x<3TP-idVh!o4mzWKR|TP$Q^9VJ2KS(1q)%gdJD z3PK$j!n!O&Sp8C3T)4=_I_&!lcMX)&i>@O_?&9fiXTQt;ijAdiS;xdf)P!v=Fd&x- z6d$1-sVnZkb)+cD$@RPEUjC8a^gzjOJ_VOMHOtLcgx(8N>U>DhgU>sKSGFt+J|4=o;h z$w7fp0|=3y-%qT|zxk(s$}>FjuHXH9=A4HUhZE2L&2Mdc*GKB+GkC`rUO4v6zZWu# zMsJMoy^0rS&Y%C$*JJ(Tdw=Esku-b#hksWs71dJl)OWu$@zi%b6yL9=9l+in>*x4` zo5Wtc!XRSqgDyX4FO{kv3?EO&3jjbVyGM+l+c3be7mqkK#NNOGHaSzkK+?|$Q0cn6 ziMQQ3esWP14aX$U62|$)=Oo}^L3EV0fAcuQSkjBGs+!bL)$Or?m^x`qmYl+|S3y7# z`p?`uk~!4Jp9WASxHvRHr^_^%f4RzCK zcmS;&P3i{C<_;wVtS4)wx~>+o^C?+hn7))j^BC3+Zh&iwLEPZ*gg4ymyn^TeHjTXe zJ>a|^DN<2#`kEBqb-mE)Wq>e8K6iAXo)WZjuP_oqb}moh!L&U$e0j!R=kstNw7He) z+pd17d$#7F(KSu+pFcXpKJxDE1O_(`g-z3yyAnSxFn2#{y>_j#gna}EfUd~-ne(N^ z88c_NuB-W(^X9)wCwod6LgD>%R#x~+q&4Z91zVX=ERFt*WC(mbV zStiTRb4MBY+0p2o(YSK9Yyjt{h z88GapoHM?5zfG&p=piIQ3<%kc<9@ex<|NCJQ3O@q%JSs(&Av#d6J zZLz)&P0^rXK}Kckxf!>wOEEOb#e>}akDp5$u_XkOmA&2$$FmWxvA>LGWIayOwppX$ z8UQgE?jGvD{;qnr5rZ?cp_*ZZ@Nq#w`D>8?N&uYz(98>uNaDjtrZi7G$dQ4hF@RAt z8ygs}ygxHG49SHAP1x=+sw5z9{>nm;TDQX$qa^0ibhzqO+q3ibaQC3=!D*_ZYZ_(n z(%E3538;vk1_^>@5h131tHu}(_YCI@#&%CEoj&B5b}l-&e&N_lex)wX73E@grsv5H^+!B4DTeq`UVc^Cby3&pv~Xy2$SXZ`r=-Z8!J z$MtMnepo(zXyMok(Sh;jKZ7!XziRMv@Ic8>(JKUsf{{wOkjv(2BO0c94ZwQI%v*08 z#t5S3<{^f(KpbiC*jf)2YO1J$c*J_aT%y~xplY(!>57=O>@~&dk2YOwErImz74V!P$z( zN=1lnZ0f>n7?fQK|DDvS&-}*iRv#T$$Os*AQ=+m3SgfG)gFV%lDfg+#rQz|pW8;B> zx~Z!A?~gTdhY?WUj8q32y0+H#!8mYhK3M`#2OAiqbX8FuY>n>DG&)J^s;bus%0{Pf zS!l2ZL$r#h-Avg-1^_@2N&&vmCNx=+OS$^CU0UOY4}JN}k>#^TJQTi${sg|Nb^zPW z9m6$f(Ad{So~B|Pff+la%LT!zp#j{(5apO?9Or`MEhEgkS`Ps4yKmzs{`PsVHfrAK z|NWKQ{`PA}8jdGq+jn$i`?^#tOqWEpo_eUhs4F<#lH>!Wj)s%I2`if~kf(kVVyIq5 zysmfO(4UCb2oi?#Z=l2!&~!bo*_uRSVl2PE#s}lHkva*%9p6%2_?-ySf?krYq^T!u zM28qVK=9?YHtBc2e!U>7i&?R8BXyn{l}X-|D&1cO_T92vif6B~jVAy!XpVCt_`v(F z8|keLYrcfJ*UZM%Qe~G34KxqNO@O7-ra1#fx(5lGF;g2zfGtDTP_%&R_FXy2m;?{y zW$WCmie;r-vd~feFW>17r4|G#>$8lrowux+F!C;dMnag9BY4NXluNHMAuJ zpXgM4P6ei;gj_B-)6M}L{NTAsHkK+aPGf-(bIT~bfCC{T&|=sL2hD7`moPg>3hh8>?RtBr?DS%N|VwzBW66quG<+{gU|8n*PM}yYT}XRaXKe0?^3rU5#=Gdiy2ax!V}Zy|o$cIoz4M!%6b~>g zF}^i>@pN!mF`oex>r~^!!gNoHU-!oM%xfS8%5_%KO3ELp}Xgp=f%hyN|O z=}-LabD#YC=a3=+C!9yd{yq0hKK(sSLW!A!p)2ym=O5nq)(_aIDDAKAkCq?rZ?up#8qvu>B`PD}K zc^zOtF6Oq5H5G=d$%6C1!{F)z*WnvKzSma%G88e=7nH{!2oQVUn>Kv+=?U!BodD1| z@{L8vWG1wxqOsCjGaKq=rbnwJLz6)Fi24l@`1}sBt^=@#)2LzDZNd~ypnTCrFhH;f zP-_e}B?kb4(jS=@qPTd6V?B<8@&4#PeDmne+wo>*UB!9FLWHMJO=i_GltjC6QRptd zaX9fWk4(edevl6 zXnIpPD=YaBKeNX+EH#B9Mi0gSka6~ZJ@eU)VZ^xPRJNjjP&xlE466hI%Uv5!R%1KFa?#u5O>s=iRh^L5IvF85%hNOn9yWQtZjUOa(# z=|ax;gzP53cZGG%mQ-hmiKRlFHF9b_N{iFZxW}F(AL<@VjBdpmLqGcU&$_-EzWpt+ zp^eRGkeFC%wF<;bKuxN()o< zjNX-2(SLMZOO9K^ebY$d#;x5?zcQOSf3!ZWr%UF^O`W~&gP*AT5nnp_vQlz)+XY>f zbCV~XW8LTrC@oGe9C;zQtf8yXmQTH6^CSHAZ=kGuqj$dT04`08U5?lXP45RDgrZorTMwHBXnjeCo8&C%r^S0&0KoPg zpRd?rn{bP-^#(-;Ys3i`G!Ck{#`T(b{Plw|)cHm$FoRB@xJ5)@e4t4@%#ye5>_$>) zpa-54x~6?6e_ghKcnQ$o7AsK;QWG+#8IKO-AGs2SzPAv}lYnK|5DP*qQsg6BIKUW& zAxY9Cs4_n=3~i4@jIqhmPYoMN;OK6h#7b8HK*2 ztuLLRX*85*iXzbcSQpSVZI?owf$s4{)3nh^oFqw#L?!K-QG_7?Bthu}QQw>eKy<`; zmSrF!`zQ2Dah@f|2I9{@Tc90xS`SGDJWI#IoT8zJNwXZoFf>D880d(xGX&v$!ogr@ z8mX`Vpc!;W6h)CF*_q(ceNmLAX=LsG>ye z_d8ph+5fn02gM1Jpc&JGvUHvE-o&ko!RaGCl?X5HWANMP=m=U5O+6>0cP8{pM;dSQs zy&Zfa%AN!3P6wK?W)@2%fybk0JS`k~e&qHCaEd`I7Qng&`VSO(gFRTCFs$Ps+-*IDNn}_C08FUF^=hhyBs_pi z5~@~H`pR9aMIMO8H&BV!!ZnFbQ0Zu zxsXGG5>ABG{<)hTD?^iQB;^z-~7=R{mNS1 zR#jC|BuSEHCfJT%RnvirJljN_K0B2wrWb;XS0PC=`=2Cli6};Gk>O2|sr{#CmoLuc zLZOhND9tRi)BAqhwe{vwW_jVHZzwS8YLi`R1cD%1CB~|U@4jXp{X$r&V7Nms=R_VW zxf7;fSawe!}U+Y2IU#E;fT*R30<^7B;OC>rblHn)Jw z?bm$+1fuN^@RTyg$IaWjzwy{vP&3hLkjaZYOR}^vGmZZ9EP4N}!~gtU6hGOVFN1Lx zu6qx5i?4kKc2usVLTzyrqYTwZ3A0ikX6Rg@&?fX+WNv@8QI~qna-Txy3L#U?bAe=D zBfPC+{Z0&E58gfg&8Mb>>@vh46HVx<0x=j5vqe##*BCC@gN#F!$H&mb)MDYnT;6;$ zh(L-9L8azAEo2r9yA?WaQ4C9QyeV|zz*Jva&=koTIf=p-{r&q@Su*@9fh7b(jO4?} zIPY`4te_+px~3v$Y=hlPinIWDVv$pPO(JqnMI)z@lh6z9Iq(WYVuflf;Ix9Tlf$cmA@5@hES& zX{a;Kef)j9q9Gckg9qs7^-J&E@u{yI%w0U=$%yE>$LbBDCB}NTVj=5FG1v@_ee-jZ z&wl@fzx%yo-~3!hoOL>fUajzBg|MD1399E0`$%Y}smRz3Okww zPl7nKUsvZFz?^kDo!V=|4gnzi!n-PAmcZ6cS$VbtTzahLEH@B)%IcbG?tTFUt-H|F z+R#QI1Zv_BpIQFSljrZ)-Fx%49)=>25(!6w_}D<~xx;f(J{?S^f??RNfBKnc|M8tp zT04MYWl{ae_u^8a<$$b(<1go?&P>1bqeS`aja$3^{Sl8`n%9sJ+|LyiNSSXSotKE> ztjpaNojcP)f)rgtAybekDeD;KwI+QZ>V!(FB{u?29>Fwk_EpYL4Rw;%91hN$*j#hX zXpR9aAk*J89U_AN>aAPR7rs8;{xX1B+*F|&kq;|5z<|n!>4lQ>@OiZ&C{Qg%$r8qx zFF}-4+|ml8L1Ias5X+Ypj&QH3f~1m~5e{G$uqd+vN0k5Da{gdMwYS2UVD2QTmBTm1bHK{Hg51!P*ANn0K-mOYve)?=PeRiVysLgHU z%JFGiwFyvOn$;nc3mKDB+qvEX9IbA+;m*_F9bV1~Dn>?A9rihdV@Q;8$8e@addEt5 zV1IXbDJ$xVWdM&3jC(D_P%30~4IREIKEgz;1uu-_Wm{%2R-Mr?^Z}R8*Y~#~M-mS>?&~{i|if`J|bz(A8!a^Zjv`-<)7@I4~7FmR2-M(pS_lb#g!_~yiE@qf- z`#SiV1Y5OR4RRU{I6GNKn%X0f|F-_nf1Ui}kL@*>ebJvGmi+1?X9biVwZRz(X$9S)xkNi{a*5;#s0I71_wF#v#LNHhmDRc>Siuo`qE%0Bs>FNm3WRZ%ZK^{r;g2ICqy z0R_qc!2ekob|nw{tHa-+`co^*NyP?gAQGm_qN=*0y=q+znt4eMPlg6V+2Kgwbct=j zC|Yab4v1B#To19G8#*E(daOTc#4v+h8#*4}=Nkm$S;K0s*Z}>9AaL3%Va(Ns`uu(l zb*1YITMcUN__aoYnh{g`Mw$%lt{-Fo0|!qpBPUKj1k$`21EZp#H~$knPz?31v+~M( zZaj`_YH^s}nmI;);4PbCN$&M~1^_^YzxTXxJz{#a{Y)CS=X9uBffJAiXcCX`Rg$7> zXab12WdSnU@&#R$yTV!#@|ui}p`xq-83o9hBLsOUD59oF3Py7P1;U}>Yg=S+3R5Mn zGN5q``Kwte-H}}xc%BP1#q?Z2?^BbJCuhAXP*-HH4PcCaw>GZRTP}m$dxgk#qw3JQ3E=~ zDTw>Jj}d$EJ=|DdkBnWCtjt{icTtir%r-mcuV{E=LqTr1<}&XP zf}M%3pbk?pwvH~k=0u)_5nJ9go&PJ|5U`=))~w3@kLx0*X2?IQ3c}oCF&RNy%1}>) zCJ7CRr+Bb~F*^8G?%7PKgyTd#6@|M~HP#7hDL}yzlP8-Y$TtsKW(6m{{ZGgY4)Dbj z`YX3@3U+A#*M={&w-9W<%o>e};rC#F9v_IK2UKe|a&fnVkg*7JXUqYS#k?(stbYf<_2iY;YlH7@jv>{9f2>}pd1Vh&PeWU5ynmXLR z^Ogitmo(KxIDCy3$nj^ftTbfwZ^MS?3JNN+phFGn7@#zTV-$XVCjYE@{eLM{M%#FV zHr1e3EtR*rXG{0V$&9Fx7^q>8^m3-RJ{tkYXe1GhNmYF-wAW<_G1Zo^{W~D5`WL=& zg zPRJIdi?IKc)@fg28p4d_(y0)_209~)$ZAGlm>lTe;@0O~zq#w&bk0e7<@Elw0&9X; zgCU>l0yu#*wuVz*sIxENYE9F>{KL~XZ0SUzGX~ZT#!&KY?<$zY`?khx;xXSEIx5_KS`fIjzO3h-jH0kUG-8gq{g?0y9NwjcK*WC-_=z`(;ab` z22@c%$*@d$nxu41n||?8zk7oaBAMGz6G05);+?82D0SvGR}oCtgzQpk{Z8|npo^_LmRsi#I`r{J?LZ#OQzgksCIQ_4UW` zPk;7%FMQ(!W!yN{93FjXVgJ9rcceGG{e9o!I5wV0uqq<mWREyCJw3HPl6wYrLYOa=NsUPyiO#A|fJ`=fQN;Gh*+M?w!AM0& zK@3Wgq-$zf);X+!Ns=VXmK1@ygp&|Lqh@q*{RGf; z4aYG_F}ZUz*&r@5EAgYIk&OCPL~x{Qx{g{g$b$T?w|Rl=Yh+ngHC2)&QN`0`bURJH z302ip6+I;K%?qkUtP3a-CrQf`fXY|g3WjVP`{!wwf9+*t6r->p%2+ zbST>Ds%br)6)&{_?B3XUW-6D-w-5^K26C;tB6yuIZ>IRFI%fIokqSYyMPiePGBb;% zngs5Ks7DgGtmuWg3+DH#VLt*G){)@Tc~Q(RX)v;G*Din)OUFI3uA^W@<7nQoakohb z2Gx;%EH~sH#{ZD8>|FBRxauvquA7R!LA6X6F(ko6Ku^w%@#b z=5X-iI6IX)c0SXsOnl-Uy`TB&A(D^kieeaLn?-)=>~nwp8!vtSlS?P}=PtGol7fO8 zYf~V%f*N|VO96x^#e9P_QdbfD%}id8v&Pa3m*8)mLf~*Z_0|CH{(P`L<^Ty<2uDmoy)Kl6us^qp^Ju9ZQ!f`-TCK ziaTNYfL!ZGAdfF=vj+I0V(JR80->Lf3J3Z$YL(nHk~lx@&e2e1qHc31pK+j!OhG2{(KqhEEr~csQZ~W}n8l2PWvH!%vLPj`pWd4H>SUHYXjL&}U@V|cl9VpPT^`p+Uv>>3M4LzKm~SsR$OXA258 zZ#4d$-#Ou!AC+)ef0+B05z*4z4qz4{3}emUZTA~)5sfZ)Mba4Y(@(ZOhE}}n=8N*o zVo8$qRq^8dU!QvsyJk=q5R+Yc^mkFRK>$FxhzKBTI1>z+PubK$A{~3x;BREjK+I$X zXmKN2b*Pe{>yTo35@inX!1*Ws(>&e~G=a1|UIK$s&Lh4-!}K2;ShsL&U-0co`E}9XaIjX8JWs&oORfF+gn#2!Obf%MVs2eQWf@Tn>xsOM&Q|dKz z=E2f9S{ZR1%r04ZBAOXnFCCkI;P%mrMQT}SR%KOR>B>3ER|=F!!T8V&pIjTwOaQkU z&!8uKOFU)(0cbW!ELX}k$`D`MGe(b`Wb@7rO4Wo4PR%S={=&jqlZ`j;bKe}uZ zL4c#3qAS4-^+bY|dU+$=Il8_H$L3?_(_KmKhi4f{XB%4g2Y*KNEPQMQR2|r_a)|*6#-M1=dfN= z`CdIpXp)5SXl!{}Es-vTq)yG_DUA!n@SB*liL+%g*{<}*mV z_PY@_n#2HLB5|c$XvRC=yq)sWY*C$3%EgAsA(8z%UGDD;rDE)I4o%md6hF`vUNF;( zw1V$FJ+YW=A*+`SO^36KB}LOWjnrh~X=B9`0C5f%W-lnA>Hs&l1u0)v3K?@!Tzv7- z9Y6i?njl(;rOzHMFV1I=z08agJVS=qs#T?&SptcOu2ksL-8z1aV)+|B{QE*?p*Vj5 zgfPX6owzVNfdigoc}$Re~6Z_Kz=}cv&trnfe^e9(nNx-}+*c z(ZDYqofjl^%V_ec14#9G{pPN8Ufh4eKXHK%KJW*RuIvP_s`_95$Dy87=6k^4n4Kh$Ggs z@|w#V9i@<7&^TMPI<{Jhg+D)2a6XK)Q$cZOmjlxaMa2luXo~==VF=3zdw2Fe@9=oT zFl?$wj}gezGsKbR*mWM{ozER&SXt7~&lJb{qqBC5sxWCNfAnHyhACMlv3VhTdiIul>tt$(Igx1u!MF0!8S2l=)*3|DzQ-4 z07^jA1PUd2a<(wF;7cIa1YR>|vP%jlxF!*32hicnD^J%zbAS19yNIIdi&-==PJZu;<;58k8U`?oB3?LZ(W)I=W@4at?$Q6yRV7!H7i-1D z^74FnX*M}FE~MuQa~J$ZwwPJOC?+tC=GV|6v)NDh`%ivnGD*?_H;4~e(NiQ7ES+dNHH z>KpRX)dz_?ElqAqY9A!n%#98FG>;sd>7Z4%4I)@z@dFa}pSzgD0Fy>#S@(us*o4V!!$D?S2w!drP+Q?Mpbz|)*-lA6>vBPf_C zX|`JG{cqm*$O|4U;j)UUkm-n9>4Ub6CQo1Vl{l?r_C|L3%gXgvW9#sdUM@GL7fS#3 z@aa~k?llhY2|$rJ#}KYn%m`VkxJcNtuipv8;Px}edi{z-P`HOq25lVG>PUj{r1;Y{ zU4SfNj57wAk$Z4bG zUdLC%4iMs~SeVg~Z-QIr7+`ES4f57!BUjXZ?a)?^Nd75W27l z4o2H2E91e3VOX37#ts+(7!U<*Z;?Z6Gn@3Y( zNntt8tl`vGKOMbc$H%Tqmopkc5Pao(t1Fr$=TXr5nQwe191fEt!J!L~A(p0T002cH zuV!iP9Mtu#5B}Wo{g3esO*0h7@!_!dfMYoh!}LhsC=Mc&u2gO$B+ILFP6&lU=2pgW z97WOLa5%)1BuRxrVGV1oiBKp+5X5S^vhh5>T0|y1k8WwTY72+MtJpD$qE?v~=**e3 z>2!J^wEVFL?>&BYZg!~}*3A%_E=wmK{km}LJ!M2tWr)AUVrJoc=malHIz&uTWl=2_ zk;3uZ6!*H_-IA=9%4L%a>Ez?zCQCgx_Xy{vq|OBACY&l3%lQmImwm^~8(FGcK+}j0 z5s_-4tIlYO+O_3|$Nv2*`CL|2)KaPB$F=mmCr@RT_I~-JZ^IDfp(U@=G`(8fOg7C^ zxy&dA>lTjDih_u-ipAF0Zyfp7U6%1}^luWx&r>}6<%CnT8GSL~H&$DFku=(a==YoE2Z3Sd@0wm z-{tC~GFMH$i}q4}r6ADh#XUrA-6w3u`fqs)d0p#syuv1C3h~)zF%0WYaPw)Qev2^i z;(-gHg;xr5lif)!Taed6E;on{fgzH>BO%(hQM7TTszK4{Byd-7w8`8Dnog-;rg?P7 zRbr5#k%ht2)8XPvKf2h1d=LNt7b;rCdc5+*KOKM5_Nf;iU3Yzd4)IvM_AotXx35d} zr1KcA_ms&3;-ngB&XUQP>Tp|xeqH4T8Eq?W#Qj=@2W5b)kU9sAFjYX@TmB zoSMwI%%8drRaJlX;GEM{Ay{IwsSt3Ml+MCA`Y|AHh}wL_D$d7MR!{@!Pw_dF+5a`XoJu9;32 zhOOW=2|d9AC;?Df)iu+lHdZ_V{FX5zw94cAddD@y)Bu5Nl$-PoD6@;#=wHf;d$#q2 zd1|05WQH7gVks*Pc858J+%}dvKhtEG?`4OrBxoQ1uOO_1MWe~}&39?`Ki(WWpH4)W z?j(Q5?!GVo;G|#8$0yQZp6Q5l?|<{gA3ks8<9N-XSN5egu7-dZPZ%noW(l~Fb7~H?nv)*H? z>0LfwQhxBvq{~nRF>L>#x$iwa;a92}Q4m(&g_w zb^a2^oTTW#|G5`kqX9zX?{MtAuYhajr?d&EeJ|5>97oAt)|ARc(=j88sEw;v2<}R7 z5grAmG=uuJkS1}SC4-q9jF_>;9>r@=;9NWBu{JK(1PJlur6l4k3JOY^)L5I2H>2No zQPQpfw5`Bb7+M}cstV7~bQH~`SE9uGB z0@jXEu8qqw0gTi6<#oS#`S`+(Tf0SBQv)Z!LR#3iF145u*Axo~()_mPz}4ysVNzW= z6*ZRwW}2X5=az$s6(G{Ff`g}8W?;Uo`1q%uS_{DmZCplQpv#Wts8P%*xYkzNcKGEL zhqWd|BIOSbn(CFwUV8a^)W)^618m&50mo4i(#grmT&{+4FwBz#0XUAufEtZP8&(|- zhZV)`Q;qm@NHQFb_$4*sIG&OEQ4|1xVX7&683Pc)W_sd6C=^l+D*a1G zBodUKtR-+9N0O_SqCFfAuU1vu8txcP)0fDk zx;o%mHViZH3bpR5sHHc`WJR%|HQlti^CC~e=HHePi#XbZ{aR*I(UZ!@s9@v2S$gS6+edtn-BO`F2i zFEh5Wrepw&C08NrwNVXCQEQr!Wo;3ljaO{~An!C4hB->O1b3IEjQGrfnT9fM8*QvP zUm!tJFHUC%y2EBJ)Rp1Dsl@}0JlH<@_b;57@C_L=F||-Qe5QfJTpMkyvNN2icD&rH zcR<9jja*Uw!E=*s3eGlOU2jk`^Xb|+zx?BW9+Q;SvCp@0&7w6ekZL&VuMxCIe;cp9 z9l)6*xQ#Zhu?e6=~=C+QmNExRTYg!QKrpR zLPtl(YE>1B#a3|wtYXI^k;tWYtVJQXtpsi3>cX0%MQNjrR}=0Xq^qkl9*_B5z3;4g zdOkNk-qbNJonCJ8%7)-L4#)A;sx6brtX5UBEUi{m1c46@4qPJ1hYe8_SF2+rNem41 zuNIMvAe2|DV-!X8_V#vmUecKg^!Hb#rFc9xI%@riSV<)5k~`T(8*2!PqR!6F`|bc7 z#}5sSn4eFaI8i7x@JVi?jVlZFBtkViKpSmbYiJ9>ZCul6Pk=VAX|yLm8`m_*RWysX e(FS&<;r|Eo^Mj5%1RhHO0000z74ad(HJ#a)Y2ytum;DQ?BBxJz-T$U%xjad(G%-tW8j z&z-fh;>k{CCfWPRlL+Nc(&#AfPyhfxmz9xF1pp8%2m-)xFmDDT%=TZ*SyfsLsG1<& zheZ%9MHNK>pe7FW$ruq9M|PCab_M{9-v1nE(4oW(mPqI#spX>PVBz9!>|_oo8(Z1C zFn^L%C*x#hV`gKE1mjJ>3N@Q+$y&(D(gR>v1QB=#Lj0e;CcCQeff#9ynM_aQ{nxO;WwqBQ9-Y-A&f;-PGhp_7Fova>q!R7pYwN7+F1 zH@sGh9v+@sYR75IX@O-?@o806+eP%})1KvxQoM^PE3?+d^E1)b>x-78 zz*L+`Nvg@>cXxvOLjn&IWd_%unZ>3R?$5Tlq9)MVUa^lNlA6k?58At^E&BiE{@=n? zXdtaAY;E?Wb@4Wr79&`6p`Filt5Ha(K0-zYZJ2`eZ=pP^W+-q~*z3M3kS_!0*E5Rm zdwLVs@%m1vUOx4|DnK7>YMfM>Kf>Ou(Xs&;0&MrTx>)rmlLO7)4<`5IL{bYK&A}qY zWWa2)KePOLBqpHJXD1CebYCo=I`LmU@`aiz>AhUiS-aOZ#s~&+;9(k57DpoQv;9m& zJR>r`jiOy@DspAn>~`U89L#J{K~RLd0;sJYqnA11i;2 zW>=3UT5)j(1W~}h&T^8Onb~ubIl+a`CqUEjZYgfH^_%MhQQe<)VQP!_E@F=s|`@i#Q|vjdJxO`aQua*g3YzP|EOwpP)s1{Q_D@%(%R3aISMzyqWZS-l4fs^1DD)x`c@o zZptW$<@-_<^Lkb(9{eY2DchCAlqoqi!L&kMAunk&h2-fMg;cLeIRSo_J8}#i5p>v(1J1{bPju$s#`JqdIm2PCv6I1U9keJ)sF#-kmq& zNJ<@t*N6Ac%(cUgRVDfrqGqVf_vo$d;jmWXBSzI){zgP>4oq?QO@uIRIzgNNHDH6t z>mNZL!;Xkxpw6i%H7xn9lzzrxDvqW<0489|(mWJp9SEP6U*)1iNOG&^$s5J}jY|f^ zEUvgUoJf926OUXFm1@;vgLU8!(E6xYyYpkIeETWvb{bS{@D&R2s~SxnP9_7uH4j~1 ze?rL!$aDWq%MFVtCBZ$-S-yW>7u3CT)~`~i0zl2ahusP1{iXNTh98LIhMxBMf&`}) z`~Mc6WdB1+H+MBlnct3FM}hdece<^_<)ra^%&r4p{e&xS<%+fVr0|c$3Mv5vW0@pW zP3_CSF#urQN9q%Kwshw(gBPFuR@!;oKH`6Bdg(G-M_f-`t-V&P)*45AxyqXm4ru@K zaF4&stZzW<0bA&(^bC$O{4tMz&gpw1cY|36#uoCge@dh>gnR(yrh4GC5G&LCB`IR+ z_z6{2k%;z(Blv)svZ??kXfl{v-tHgV^~uGM$aUZP%;44$_*S1{!{s!T`>AK-&|o~r z5%be^-}i`@%K5r&RwgGrjEFolrPCt$J)$c?ciRPLTLb7=*W<&BdxtN)Un>dh$GzxU z^^2KxGIvhJC+td{%1?n3`fLkkoZORYFt(3xroM2RQP~QuDfpWCp*8Fj^PZN|A@ESZ0_`Fw&~ufY!Ld;icA(Q(>Jeo-m? zp^Wb=!R*`27mLeA^unPb$C5j}PNgiq{y|5~xIwx??=MJ4ofdmf?Ob(VY;~8g(HW#C zQnt={{FQvYTU_TOImL$^gx%i959NH{{=yN-wj-W8n)q=pJu16q8`qb8>WexAK{oLY z`}fb8eUqsz=7>~ev|xyMcp>|tdkaY!+S*;KW9uX@P7)Z8+C4acr0L4ObB?5EsA`SZ zbJQ+j4Dbt#U+AU}uE`BXpzg^j$Ck6e#1kuZq~!;G3;EG1lj9&V&f;0ZKER9O4n}BN zbZ^lVhkO(pmZOnHAOT_YV7@BqsnqDi0<}5eiMx(t%RS;IqDb`^zJ<~$ze~L`lp)!= z1*0&xjC1!7hr(z*Jv5R5u7R;!)sCuEqkP+GkwXzhS?x2;@^21BKn~f}Fs=8aE0^P} zda{;A!w#vM8s`iLR8F!Q2Yu$Bl9w@|F1=|Git=F^JodKJ{XYDzrWvkDS+ZOKf=->M z$iBSnt+3Eb_I&luWb){?Q_H2iYtKdYRH|H|6XvBGCVL}V^WPInfaOA~^0=c%hCwv* zt(^%O(J(G_J6eIih=wrx2h%PJJut>Jmv3CtP-hUEt?rSRE9B>5zx5?lcz(MW=@o?y z<`R0t3|gG!?yI-?k?&*+)xvcaHYQ(iqH4()CU~h6 zBG4;FqV4P?kixGk70_ZpB0t^#kWAFmf{hC@@x_hlj!wTQ#ttNFIa#b>2Lo7+hkP`W zn>c?Wd5*QaAyNq6uq{Cjyvov)ykqs7dn~-a&i5cuk*bw%P=^#SFAg9yRuu1g_vo0 zAq0(6k%|7n|Iqh^{%BgF1-a1E)JHt5>-z01T=0@D7V)PW2uj8SEhmR)#948NUYFfE zdQ+?%#YEMbN_JZKb%Wv7;dR*bPVY7 zr4u$N+4{dT_2&^qR3dg~|qiMICjJZ_Gz8j>B2`YzCovkcxr_!>>6_ZQ&0-c;=S zo;)|n<$@}bk?ZOUPKx#XQJ+Ib175vf6|;QbDd+SAzK=UG8{}^*`^E`t{b^vGigkv3 zAom&p?K(!;B}*4{CW>fCDg;B7ce&LD5mAmV8lC#ynS^~A|4>KGQ140ZJ0PZ|-gClQ zrva6_siIGVX28^HvFkT<)9gm`bPgo_iBvK$OX4Lt8yncKVe-+fs%H8`)LKO~{o+Xw zOpmIDM}GT!voz?|g)1MxOuj(IpE=kdvFyhOI4t7frhTGJw-_`ZitCKA3P7f+=+9aox^DfC;HwVazEV)zYZ68 zeZY@AsP1y6AuWKPtCaKEopJZMvuw#VAp$)7^;OBnZ1NM73@? z%sNQ1Aq<-25VtcOowGV(qyR+jma*4P|3!ho3e^v_MGYTKwnYHv`*)8WuXh4|t)`K< zE56NhaSMf}9-wZ50NJO`WA6knKAIRq?|M9C0K}PeI8N3C{n|z!$p$FP(FJlbP-1o~ zE+I*4P~phZW(rHvS}~jvbYG=k5LuLOTM>hE%l^W#r)ui%FhdY>4_tF3Mq9;FExfHC z_OVfkq|z-JSY#7MRi;itAGccQsDG}|#!t`Lq+Gu^GtcJOTWCCVeH<_tX7wwzY!;Q(wDDxp6aSCtFbp=tW< zb~9oAJ*d*VQXlQE)M~;j2>)G~<*~@o<%w9)j1o0RMj^_6;r*~-^|Sc6ubF!2-jcWd z_@=?ZyPq}#@pO{~NSe+@f1*{Cl@J?#e?w%92_$*YC)KA;n9bSt5|mvgZx9J$8w0ti z8>Lj!J4|^c}k}~Xvl$e`5}2jVltTJoIX2y2IMJLWu^e2l?3Sl zjLJBrk#NCy(K;b;WYK;8X+|pDvF|XALC9iE+~UY|nf&jEG0tlA$)cAxi~Cd;8cghl@ulc}*dMDQ9$g_O_|tvH+2c$_FR4 zjkzk(N%0SCIq`4}SzKQ7`l3bAjGvomsYRJ6h%D7m0FrA}phOZpuwYnJh7=XSEPq6>dfm1>oIn(a#u)5*l1S?k#Ou_0T;%LUYPv zMSe-lV(gI_q~gVm{o(vF-AZX~V!{2h9cbFr@4FZ5OK^Zyf|;H{s3mi;qV}heR9Ydp zUq^5kmBUHOa!XLGwWRCL6mc8Z4pZALm26LA{s~k#izZv|J-)h7#~>imdOc>D%wKNY zk)=S`$zp14M*u*`C7d&nCCZ^E;Ynau%x5;o>k+pky-j^91gHENoluPtglFfImg|v~ zNQr<$%NAkf4&?iY`x|X6ysu(LP1H*l&>kI^OZb6x`@`Er)M!Lntioex?XPrXU!fy{ zKbAgjvU1cxC^eyJ0s&tk1U<@0V0#T!%pdZQ++^$Fayna|+`$anCcq#O`zJ>e$D}x7 zg~?Wu%fZ{vctzOarU}^^K}c^5cki}hMSx({sCeqgGNgcewhGcVCsGJ0unYB)loMt0WCow9pB!wLLZtvlr2zvAp>yxCD zokK6qPXZ$5p6%Cr_a*_E3FS&47LYcUMzzec zul^&V2n+UIYVl!;n)5s1r-%1LQ1D{Rcj)r>M)zi$Hy}jR4R> zbw@u26ihzK0W$ianU!t;iTIm@jgMz8JCZI)BZ;i9D%e<`Za}AsqS3IY%VV!RgY~1Z z%orh98%vI^)DJDi36wGUbi;7+bh_rdCEeL(<@x}(i*4}n%;8zh(APIl`Sus#Q;N5e zaxN48iDx)jgB%XZLKWoYw3nL|JYTu=vj70VoM<#Dd|)X8f1P0aJ%=26;>1L@xCv>z zuT=HHrT!l3Jp{-{M3NxT)z2RJdlSx*S+wT$$su{6NX+bWK%%1Xq9i3n5kwYkx!8Fa!QV^OKW0#yFuso*&~h>~snMdss-YyyBsBOQ z-v4@+gCXd0dpKWxP@09N00wTCJ3UW_DX{|1>O$f=!_4{C;AA!*PXnvr zRGuC)Os7^YRj1SR57bdx%(Hr@qmsej+LSa|2N}%=l*=`e)Z@OF=~YFy9~?(v7t0r7 zA^xE=*+6U1fR2PXmFnsJ_$+@f2mQ2NbXvBgpg*tP5TAjhLV;5Tr6+~Wc(RQsK@ z_N`E-#~tP5<=3jZ+l^FV)Z>v{PIIeN z6aRWYA2&IcN^fU>;LO>}O1~}FpabO?=MH2UEBI8Z{+I9F^O3{aP>umb)>;$OrvbjO z@?+SN+T5%)Hkn%fImq8Qy|wDF)t=xD@FS%kgO0pNr+mi(&8^~4;f>-91b2${NWI9a zPqR`}{+s>`00F7T{a5^5sQF%N-eE(T`4PJ{DycSnxSHg;*~IR#5- z2|8128AoB6tcs%-G3!y^YgUt<4s4KI^(Q}2$?2?gApSRYCOJkh3>gFaxB2a9zgt_L zzqXfgiM#4UI$fN`iJ9wC7Fwfg%E)RQJySWPBM#17YU(R}}vI4P6)0cqC&F7JF*W4iqGC( zuqR219kYnf1in3)LM6zHQqmeX0Lc7(CO!COWX3Zu%la0de?%I`M{=XiE-KZ zjxYzl+ezSiUTdt$mJI#uFf&8|3<}2i3Kj95*~79;W46>u7Ta3k!sRqqY&(2Lte*^D z9KO$~hu>E*z02~|wdZa9CI=#af}WE4Twj;p!4!LcOt&}Uk8ujI7P{zlwx=&iaDUP* zYAx5_7bA9LROM;-cqnR1+9t^w=zglv;C`?iX*doI3g4a*I@oq*nWzn zANv8hydX(6H%POihXxC99xF*jy#bNS(J%GPAR{VcSrzEy_nG_^1ZXESqs)R*r)GsU zwQ$rZi6#ZV38N-mZKhvSR;Gs5AT!?ZXP%HOxE|hHk|t3aOo3JnRg#vExMdrWqhUVe^*f=I6zuwHdcgh}(S{XA%Y4d+VHF^>+7*eB54 zDRFt_0%QpNJtZ}1T!@gs%25AtPfl?iw}5xPD-bpgn!DGR%T=alyZ1h1L|-SFG>mS zPKNJfQ7{`D&YQfcD-&Enf$n{X0;lRAWBBd*&yW#C=&a7lrF0laV|oDu{n0&S<+jNG zr+;>NF=c>A(oYueqnm2)3(gaso2aJnqKv)gxpx_-tKWJXU%(UiBcA0P#9&@{Juxe;?c2|V zeGNZC;jkzp_ps4L&?J|A%&2iL#*y=WYH&v4!XM}tkqUdggxWm={?xof@@+(n;69gO z-a-@FqmO@W#ND2&LZR4ja@7{3C)``KZnmjXU_ zAzqvkeEXepzo%9Qp)YY1FT0kce#h6nK7@X2guj&mR)zASv|WplwrawM!Y11o&W;8K zR0BXuGwAsXn`&S9WfC3%d$^e%9d|t)$i)+C!Kr{676q)c|5|M5ELOxKT9qU|)+*e4 zNmQLKeLr)o244I`0vHWrgv#OiSZ5zoAOg7=F(IT`lpK7Yl2lg>50b;<01hMSf4ZL* z3SDc850{|4EVcQw3IVnKv`G*2`adFXH;o5`bcORPMS28a&Wg=CEJ7wj$u>VSZ%_CMP`PDpLPRGpuY^Go>kgwt?qY@JU2BzxovNJ zy6;B=IP>`n7c$#<`&iIgSbDx(TEvs_#s#Ii9Q3NDG|bCl6FWPxPrJ0G$fhLdiUC3_Gl$a^42?=RL_h|?AkC;a6%XrX96vHcjAt>#Z2JKt?%_Z zNF{5C?G140LJEouOm9n>Cw>ic31NxQu9R!)dy%QJ()UwPsjh~E+b2nc@=8j8YcQe< zp-B=%F@X);AXI z-Rd2x9QXeWUzv%#Q!OVnc0TZ5?QFVWHMJj$51zgru=~9tSL&d+G7A9Qrvy)Py~Sl^ z_j$rTcl+?{x58fsIEGD1k6BNg!tnhu$djym{q1lu`}uxUMO4O~&Hn4X?0GQm>Y&Jy zS2fBmXQ!+FPO>-6tJ0B9&98E^VXCZT&f+T};#^4OplYa!TFG!|&2IP+e2h+mU~ zR}*>Of@=A|?Nf}<$4(x;A7BQo2JxlM7Tb=GZi#<#+}Piu6VdovB#RUeQOqg!j6~`n z0O0i%emnOylHWF6>vgZ4lg{4e)2&wla1|tIhLqWGCGNS}OG;UV1|n&h$}l#_-mY*X z|Mcwr`_R%u_PT71#pjGSgstD0yzfwyky_B11C&453Z=qpW|?;}ilIl50U%6mTN-#b zycPxJiG7l=0rH+s zTGT!>sicZ0`?=D~^mW)k)Di1y+vXqcm(W?cNEnt$Yvlbe!;eVrXKz9YKdyMpC~ANCUOTEP{BZp0u=xpCRU@x_>(q~s^4Tviv)od2c>2$k(OK}l9Aui6qfbw zcVPFbA;&xPFfo8^)JdjBjW<}#SQL_3-fzMti-ib^%HNB?kD`ktMhVHEg1y%SnIGRC z6R7CkMPQ-2U~^vVQsbb|)nY{iQ_IG-W>XMjFhrvDp@dOpBQRI} z3D}U4=;py{IGu3nq1NM{{?0(S=4CSS2@8cTQeR?2MkZw&#snF1qI#Ef_y+vna1caU ztv`_sh}8$x!z6tlXQz{e9@3LoVWftwfJ=!J8*DCla)yF29@k=<;(4gWH~@7c1^uYj zxi|4`R4*d{fb^=gYuBKpco=xWoT@m(n95KDqm}5;C%fodVqc{fVMmFqcqtXCf>1ac z2A&QGinN`+ea!W%F>mNT+gcRw`{){d2jnSA))s zYlmv-m-L+s%iF?{?OJo~Z71y+!4rRQ4L!&=WjSedyUm{1dt>kD#Z;};c-9)~R4PGR zsZsu*`h?1s@~#UJCb<^lMIO_Jbz2-T%c#R%pI$_W2Rph7XGd$GHP6-plc!hdrkLv# zRUHOH5RxEr;JiM#+#)O1CMF#Jdpq(XP*QV8vobJP7h{!<2SS*2k^BX2U>yCZIK#2% z1M22bl(Tykz$lUgpEsj@RFLl$p}X#zajo;^l>1%pP#v`+&PwZtrQO=_STm%NI_y}iKP4#vfzw|uH9lVk6i+_)=w|>TvOjsH&xrHXmAtzLnfhP+o)tV@C`tcHxUJkQ zmj)eiH=v_{jN3hLb?-`Cm&H#4j=2>*cGn}Zi+|C|8RxLHW!Tx&k!uro?qhW5v6jVX zux1{Uw(fjp+Q}n<_$LnOy)wL7Ug5g4Bc|ZQu3i!Ok<+(ZJ>Z6Q0X&bXT1BGh=<&#a z6JwusMB)&fGC&`i52h6Rl7zEkc?tN_hO`FMWDem?=I9U$qXFPA^~Gy;U&v%4)oh^4 zN9ViQ0*+4PSoS~oqBfdUCd8i~>pXVAHAeYZ6R8}ZT**W8t?dUYk@z@;zN?Q}Jj+q~ zCytEqHm+UQg;_LJ3~QyxDs)_5xUu16@t!4VH?2F$RO+mWk?ivf#okmn;xVxn;!)4- zhjrneUK%cKL%)k!yA-I<)vno6ruJ~Gb4Vnmcif^v?b64E+^qzX>nD@ zrFCwH5N`u5XU4M(1?u`|6X&1mWuP*MD zM$cn8-}G12nObiUD?59xu_igxQmMqIU!kKs)*ma1)tq*dc_O*4O@a|@?8SJR zrToHIVe&1H<4ZAIo1lc2qf>V)_T~M%G-0NPVmSV;l6|#avc9jNIXEvUjF-yz@AK!n zs+HxpNe`{8k->fQYt0AMosEOYqJevj5+;+;{7a09wbwqY7cSL%U(m$>FzRYn_U`Bz zOWfEgKa8^BJ@wq=#&aL4>#cD0?~6d@e%E$uLoa2};6!q>y7VBv=9QGY;fxuEbHq8F zC-(`WS4CET;uznEwJ~|-2?*$h!$Smqp^Z;lJ1w7RP(+tl>4XXrZ0qIjZ%bPKZo`RS zV{x$$`m-9N$!)s^`k}~)SGaS_6k=-cyf-&azjT3ynr~yrFA0bQ5O~%*3=z$TnlomO zaq*o4-5Qs(S=J5j6)ooN3-G>LJ1Vl%>@qwrI=UM3Z8X)Y;M7o|D3SacA6lF?w$v1R zQ=^dkGo)7_Oql|klz?vX+YR;~21HQ|cZ$|UGnx0R_WNb#95{6Mb%Q&>d89(@-(J64 zuK0y1apSS_gy#+YX=sY;xEPOsE){KaK9(`GsNV{IyICLZj;UlOu|pu^ti@C@3gwpp zG?x05aR3x-8W`D!BDjVYInfp~KQ+qJij9xeL>RHBNP``UVpD}M%>Gt0op-l=!m-ui zYL34JYCv@IfJmL@>Z5OTGvpjd99R@5OSN>WVgIX!KVF1MnTNk5`Eo)lMOBMG6AmcQ zIavH-{msk!OND@Q>|4Yif_E;qMxRm3EdZ^KN2cBEmw>jZtRGC2A}wl2Z>n3zv2pzc z^Nn>T>Z-k-+i0>M0_^1UUt=LNS^_>EMuX#3E&>%Ln?a^>6bf6GQE z2uKv-Hx4HR9t3IN0mp+u!_yTH*E*K8ok_0m-5W#(nxiaV3MHu%v|mGWc1?l1>mU|h zI|QJc&hDj#mb~Jb5=U0ukkLAZheR;UfoiRrF9|MpLFz=&o z?P|CcmX~*rVH)Kb<2)C45QJXID>w`i$(B!YHvnU5s|%cdjc%~#=}+mun{Vc;rIP5y z>KSrmNrTa+XD9;yOxb@22dI>*?-VZw^s$cVm?qQ^t*KTpO=S7)NQcZI$*XPIMIA~O zbO0QJsD{gUMiCkl3Lnw49_^YF8>hH(J+IwD&TozNy_!AMa{64;GpZDjC8%EAdk^yY zGiWC9V>7kuV{+1~`+vI@tRQt(uORPaN;p$PIbwG=64jb3H)xk$JEJ|+PbQ^+%a?XQ zgk5I0ytU-=E&iO#ZP8=1aq4|({q;-zH(yx^%tf7_`v^|+G$;T#zHQY0M+(_GxKa_C z@3YV9!GCpbCZ-|~^}ZuW8wH5#Wc{|XBcw_8w(#HH;%4X8CB^MuNY4$_d6b$X!{s>JX*+NSDwec?nn-5HY^b{>K_uU#y? z>KH-p>k)-IY_VJ%-#MYheK|k^wE_G)|r^+j}Jo+o)#@Oo*7Gew_;^->=99OWoB= z6`BuH?WvDPt;ey!NfSAERO_8l8wy<-IxdJXc;S${^Sq<4Ek!D#7!A6$74y3h{lVZ|>!NXw++RT=pYWKe_td=a_5dOS@9Hd9Xs`d*^V{ZHY12 zzOe&6FY!Jj%F%sHcW+h_^`S#6cY>W)$e~s8Uhds4W6~lz2;EkY3`F)4_CII02Ub0LUAj{r z-Xk`C7$Byqkd|M{}zEZ(X8|)6mcotu`RlLt{f^Q^Ze`oP_mouVS7uHuv0ukDG8T^Dg zZrSE=;JQ2gp2wyhUlvJrizU||-E#fY(NMkRmJ`CH%sS;fVmmlPxkB#5Fw0AYZg17OjX_?G<{T$F@UBD!Xs>T10vSvb0nt||_ zWRwzmg-tM@ixPU{+kg*z3McxD%ix`vH%lmb^nq(>@U?6HkIgT=rH9!c8t%!T4l0yz zUGT$|;2Qs?dPX6iH>7Pad>a0tS9Sp2D0<<5xLLiDlUx;vSr(5HJVuKgjd6Td<^~(D zSP=*umsN^q9;dI>5(J5MIDmM624VARzF&+1}kG#~Hu6ph<_s8#==2>9N3`}91Tt^A%kmFEibN|ojJx7XK`L(aC6v{7Nq-z4t=cU9gtM!$+x;d4anDO zjI}kGN>v6TeDyA?H`VXG(Sn$pq>n=nJAH&Mfurd>m#%a}$1qZ!ma~ObI+J&D0wiYo z#6-l@p*ue;*(1#b-z7?5+7t%jpzFy)4h0xE0F~sk2dvb**v+=P;yk)M@XM zEH|D?#}@D=egw9r!?EEIF1wZ+&^6yCoBXimOd5+`zIemDjL(Mie&MV0YvbW#qu%Jt z8@J^o{jR!(zpdC$h~3j=(0grQPgdH~3#*T!V-A6zT6(Ltqp|knAZ%KeUH|l-vWa%D zPt6w@js6((&NgPm#VZE zL#W?Rz3qB&^9W*fbShf{p7-WGETaK`NIp?ss^n=sbW*b3@vCd#TxR>*uu5Nqg>cI#6zjA@J>FpR~3M zW^2KgK&imVR6E%_RR16f2zD5Lg@>5+9Q{QMvpBpqeXLIwe(=tho1eV@vaAq)8I~x8%4>MU-aB7+ zCR+(^L__Z=Op_b-F@SnuY#}=$?FILrzGF;x{m|+?xpxZ%C#GgpOo>>m4%GgPq zF)9PC^EHAUesQQF<1t_xIG76&nI!(1SRU?PMtX}v z@svN;AD|$oGc3zkS49EZ=ZMLHzu!^t8CDI74D)F6ol5gfWpBzyfc4YEb{gCi>fgWK z1>wB~``@3T_@-u3SM*+aH_+f(vqdT7B_k-i8Om)pzrx{YJl-k4KRBJJ{e*;np0rSU z*}orR?Z-9t#w&liCyZD!aPRKM`UL{h&`te13T$>6&b|xIRDDremrqKybVdEtnCVzn+1i_U*9< zP;?^-hKRWpTH?uEAM&uVy^$;7k1w@ZCMVcRem>t1DZ`2V*w8`5zZC4j9K$x?F>~O; z!D#gX?jQ%nH24>p42pXS$p7D$z)^Pi8oPB&GEt++1)dNs6^a}GAzhy6R>%0e?y?;< z0#iNi8?$+<*dPcb>WrqHEE@B=g&&^Q%C~kug#M8{l){M-`DC|DlPU$If?V0Y(J+z6 zozt^$>_FP~Y&l^6nwC6&lucK#8@H3`3_wT-{=1ICC>G{-t+V2PT52iALnx0%e?CPY z)1RjqX&I7zk90D~{Q)p~ys?XbmdiHO`aI65e;|+QX{^*m3JtAybTKo78le=cm1fcw zsqI(la#?CR43qj;z$yTcnEs#2W-cyfk8YR%m+u`r`1ju4(9N-%IuRZ`WB7C=QB0Le-X-6*??~?|J6%@&Y0-ZY3IG-ZaD$+(P|H__6Gc>@fU+YkxZaf# z@9N^(y`9?Kq^p2izW--uXD5>`SH?b67MZKcKr-}R^yZJzWVu7z%|a;+((2Poxqb6u z#oS{gP@~nZ$83(}v^JnWZ2HUITv-1%QO?#VdDL`(u+(+v)2Qv3m+8=~CzU z+l9d^8_Rewv}rKdyykJP3Bz3Qs+e1>Zv5X)lxW$G+xhzps_AF}bBS8^mZbyF=W> zE!p^JvCf@}OQ6KY>-IQfQSb*GkMj;%S?Eu#Wsb__&CP%yBxw#iV_#!fc|e)@G#27{ zGnOXkg#p(sBBWH2JyWc7x@c-++Ne1?7)!dw$@9x$O=u_d(K-s&u;mGKiZU!NGqZv$ zI1yK3uxgZ4IAMvRZWK<$0sZZ5MJco_41+|-wR2q{97`!#?)ejkRB8RrpAE|1s7pN_ z0Cu!!QKgEPe_8w~Cg(gmrZ=*4$jIDh`z){b+k90r5HCYDk@a)!akTAa2PrJn1pe{n z*rw5Ce%8j~!^H7gQ`a}N4!(|}OyuK5XI7`pv%Yg|?8h6vhYMSJ4lkd7-M@oMCJFX+ zVN`6Px?u})sw12$*wA9pC&o{N9Yq+ps&W)Qpoa7;D67symzI`x_!7eVFLBAY>|ZLE za^Zsu)*`=H+nH>7DZd<`*l) zoi3`e3X{J+XN?w^#_p?WZKBN{rQ<+< zbocaI(Izl$@eF3cH5ndWPzFMjBhwY67xQ4>g&3&rVSPF9t)vJoA{7Ubdh5C(86gZ+ zDopR!``(m4Ocl_UZuhvqy({z*RFf<#+TH1FwGn@Jq`7)NV%W)Tga86^5AX_qhH!P@ zvz(mpKC;}BY%9&4&x`V2Bh}G~$w+LkdCyPix>YT>dvDm>dcDIeiEDem!tQF{+HPm} z_E9&3n&?fRosE~!ZCjfrI>JPa9+{%HyJIpBNQO41cW2WU17yTfLoU0j?fhbgs2Akf z_w!qlI?~n$rewpIQlf#o%-Yh)&2#1Ml8w^Lg6uvTayS@yqL@}ddDYebtx&4DvQ7^5 z&v8#gH=7)doPujrYJ{`pg!{6Q+e6|fT=?scqRd3;-m;HZnz!EfUFnuaKMHxX(_xI4 zD5cjaVWH8N&SsjMv`43$r_C7}8WbickASnnIe;9Hp-uTvY{?L4uO+9(oY}g`8)Rf( zGnawk_4HBsN5SGD*SuGaV(NtzoR3TLtSR!bSVB5!u+s7!*~-*Rj#bU4{>^G!g=2T5!@HEb~?x8-^m z4P^vle(gNF(%}{QpM#-ToYN16FBE_D9Q*}uHoUq-=z_&-sU>?&HIwo2wbdEr#{BWf z-yM@6h!p7vx}5GFTGXh^-l$t!P*KsJE4coi(^ZihN%^>7sT9l!wWXcRMy2-3+>O4j ztk~IUW|rPqKG!;hSo-10SvcsG|ID9mX4i+oAD)F&lAEW&lCK-TJ}b9)(Nwl8k+yIK;|9 z!>`wNFue^smH!RW9E%vR73XbfG|$jn2d2#Zb$)U?N#Z%M4c7}__$LFU!^-X)UHG=H zbClBX-pf!+VB55BV`q5sIdR5Oa0;o| z|EcOMqpE1XHa;9cj-YgdG}7Hjr*wCBhjbshL%Kmgx;vCEX(UBTxe@0`&|w@MqSGGyaZaoORHB&FM8%nbZI&W(6B3%PE+x zf~TqLC)_dlI~dAx8<&V@3d;dh^kTt3)~@B!X#Yq&2wnjRW@e8Ns=R=KyqS%bH~wDC z<6+JZu@v!j7vz@#wh+2KhJEJDTRR(gDYXF7j2Q746dralo#$ZdQ4X6}Ye7646DIM_ zRHD1I+}aU9Oms|vc9V;^s=$685n}Yx25s+_FjSvl)VNaDPo((7>{}RUhuBVg9@NxvEeMa}KGkv}lT6fsP>_4(Z6YP&z;Nvk^ z81oiIjWTps_=vIQxBLr_WV|x$F3lCHJT?`<{?sLhanEgZbWGn}bkNpu z>Yl6C2b5*PR*vHsBEz;Gz%xkIZr>g0zB71UDIPEa0#&*7u*JgR3RReJ{z(!5--@w% zzqH*B=}C#83!o_#sB%!6U~CL&rRtX07OJlV^lrEbFMiSWnP9rFe>So5kHSj8jM06y z_zRk3Ptb8DO^A{-ye=#Ts7FyZe^0RaAa0POmi1-~ERKED>J0eF8(E{HTRqZ(L4cds z$5!38{}AWGS|Ld#t1trhpVOszP)BGE5mLr9X)5F4YcvQ&hz)R&uide1YIym(8Xhch zMO(p@K=I0BjbiFpP#}+oDSy))RIt?3x)PO^NbyXP_+@1XjWQV7wr6sHZ?At#NSV9C zaYi?%g&1UjJB2dcEEJH>$&D_k;GwjRa65Ji*wb{@0=Xa=231#Ie??XV1dByUgd1FV z`_)Fx{zVXAV{`A)5~rhn4{Lu_AT%14-^aqCv)iWPwoJc~TfCI*haw){>^2QKaXM#sZh+D7>;^?36Mdb}(0s5xJ7XZ?WAf5jMLDDQt?frf7*{l&EL~sA zPda$=%hN@t+h6NK2T@wWdy~U2PoT5x`UvUw0*$O-*FsD6#CtOrNv>=IG_Kwc{H@ae zn55r1gms1Jw1|BE!;$^#O}Q=LcEcrp5Nbsh#O9cJqsd8Bs-Jg@A_9L#*_N>Irqo=$ z7HiFO(aXijW&Jwwrzw3CJU_in=%G>I!p~0EnKQ4ejx5{Boq6u^V|_1@Lgwvv0y-$! zkAIZX80MxLh5|agGc7;K$KOy@!*CR(xY1YB2D+@Zp)rO zWauFRQKum7H9&~0VFM)xLJ9NFe3ZiZyvmq=Jz1(%6V5kmiR56;I`jR01yHBSRuQ|R z?tJ|I{DUN&J*~!tQc@)fUEC}No)1CAMQMTp0g967R*NysU>uI=)4aXrfogL3L^Bzh zv=qJA9>8I160~ecMwkYWB^@jx2Nj`)mutesoGSW##NAZk<7jCXaIFcuB4MB|(!|;M z+?ZQ{70@5?;^A#{Rv#e-}J~k(N_dK!46^!t6 zrkUF;A>PLI0fq7-AkZp!zsl063c8qbiQ}?$-v(0rrX{Fu`hkf)D*{#dQdm`2(D|+) z19UpPLquhBuin?O%)&S1aJzEcW1ZpHXSnn!9(RbQ9Zs?=-q!nQO51mGPy3O*_-ool zX6`3HCH1}1EYI(9Rr()1Z~2%%7x8{-@Z6d|{TyVE0QYs#ekA|HDXNnc6Eg+fw&oU< z**_8PhU@0D9G~ZnFK|s4khwtuI!r8!MR{BZ5Gr{-fcD&su|GWgd>y6q_=J^K0FS$3 zFjQdYBn&3285JCsRs z6Zc}hMlre656<}MP%N764RL$x5h#2w)y3tmgCd()f68M{-| zgy4x*qp+7lN*@2~qSpHEmxm*sv)?^D47p+(tDaSR@GO{22!VnL1{ZYX?o**Hv=pJ% z{=FpB_NCGviYa1D)&!D$BumN_Z?%p~Q0o$Y(t2=(5$9zD2hx$rJ_$1NIw$wqZwG$v zc`p?#8tV1th)v2a91~5z9hH0~YA(o=o|_~wA$KGjxBjukbJwK!NVdy_>S;E!uI9{<*_fpCEuU_I?NZ1AC~`g<9~Ml3vF`h z-cv}g@gcqYuJ`5XPd@C<`*~pSOBlGy8&GbR?|;ix#?vLcqdsIca^csN7F)jx9I~vn zdbCooiZ-^yM^WQh=l_)*V;7iS91eO%wQas4jx^$p4m85;^H$%O&|RH!tY9wWU+IIs z>Fu$1k)(mc9-JMgZBK6FrAKo(A50XZ=hIP!Dhh z1~a~X4@FO98#?Tx@>LiwqcU0$AK~nqt_v#^fm_+qniSBk#aZ_f5xekUZ6I;T*%xL97u{7qCRK6)ff0$sFj{aNAFDG;wAGhUxo2s}!t5;W(@O|Dy zgeuwnm$=pF=MHFEUBeWo>EQP{S_MSjC?q*kq*j#kbIjAr$8#uz6;}eBN$)=QsUUp8 z5in93UXlEapn8JpvunNQZuah~Jbz0*?$m_oBq5m)K2FTOImp;yM$A+-vkX z=o_&nNID(R0f1c7GR|O?C%&Y1dFEvwMJ=WjjeRwp#eQ5ZgV%fhL1-u+UI2Ef z9g^X*F|OiiV08ksIDI@0DqUgoFGy>4K1Q@c8SZ}0T?UM=9GryiS6_yu-x_b0BZ-yg z-K<%Bi;TH^gWv4py{oS`Vxf*_NpfF=hHo22x2!05+rM@5B8RdwzZ238ppTg+r>CGV z_+Xe~jLFB@8}ANK45`wZXW3K4@{e^!Stv(;{g~rzw)4t49xxUqTcch})9f!C9_n36 z___K$J8SfcJ$#{NY--O>1Ht}5_S(0O#>ceq3y}oF-tu>RS#fvxhCFqAGe3_KeS;ha z!B-+sux#eM#}xP3$IIJsW-i3i+X2*ah22@=E&CK}=QZc5a&HZk3{*@iaabgh{o(0$ zu{UQ;BVJjLWX7PT#`m^8uZdtnBJp!hY}gV?lDLVain-(~ z5y2634}&_vhFC;6bkiLcnIzii&VxwW=p-tcB&8^>5H?hrBv~p#b$vX%QcX8ht!00N zz@RTb=dwFc0SYNhDOvVcP|IuKAX@TJ_SK-WP@=a?4Of}v8b?4-WxjZ%1)t$zoUQwF znRF%B-J|%V04=VBsOGOXs5dffwm6fAtP9^LtdmI@8B|x-g@-aAsarB~)lH5`b|@Sh z_Y(NCz2TSyMY^_j)0%oYG1qUyl3HHqB>PHaG*|x(jqH(b7A<7zRZ$nR>&0f13Uj(m zRBGQED^cY_bH(fuT62c7aw;PsiqzOZhTz}r{c2A$8PPyMbbYiyu()>tx5_bAmjD>N zcVIcu>V7ffafnn?gIzl_h^xm_uHVftbzf-_ojv!AkhY9oTOhIk8p#z|x+?RatuPRs zn+pY0F_znq+e6tiN_29BD(wi1Qy^hk1aTj6$xCc>(`aS!7&9;)WoSt>@G^puYo@`OS8f{+|fEqGc!N_@@=DYKdbt@asvadA_*8< z{si~&Mq&0}@>W5f=qyD?4V-f74=`b~v%;897N}b+aQCP*@NtC|9J5JDwDtRL%L;c= z0q;$}agYPw%W(oj3`+aCS)=sL-#unH#p-?H|FcNKAKgn9s1{-X+O%aD*0jt^kJykO zEM*}}b+f9CPd*ot3JWd@TS2eJN2lLylDyPoRG%r&m%2tl7H?I3HQ-G>tJVt?N*9*? zz#l7#WZyErE=tSZ*!9<|{fIEdGs$uN<+wXg+U`#lKIeSJ$zW)(zXfGp3AoPh16tlfyQ1PEGCs60f`NWFD+Hy!V z-w_v&^ZL^)TdY0(_B#X=eMz@$U$am+^z0$&xagxlOJ9g6P*q{u`m*w*a8eO`yh#!rZ>7q z4f?{)F*OW^NOePf)FBQyI4p>dh(OPtKTrHoHEKl!zb1NA-M)~U_P2(O{D@BD8t3e8 z;EKp+ohqNM2U3#akiUmkNy=k?^s*a5lPEncz=%8l9}X}0!YlE!*WLu>%h|Vx0uB_4 z2f8*ZCkNa!CU`3i<_9#UdR#SQ*$j{M95Mk}?NEOIeFh>!;BZ$h5>r*r{@7I%T2O)t zfKUJ+*w>@B{U$L=Hy{Y2foWq(h;2=eOJ8X|q}rDW=bX7h+rY}E#s9&;+(}Bte84${ zFweXA_huDY#DFsbKt)XzMrl7Cg`sBjTRVK$kgt4T7Ttc`>5ve&*ar`)hHOeD6@`Os z!~-3Q)Gi^8;$T_bN|v0Z+HfOPo~a;Nr2EgcXn)2J1?tGc%t{EYfh~PDf@Fne`oZIq zj+sw7%7s7Ia89hIq3)0Qih?i7>$`TDpJO)g$u<9^35EnmkQQI?3l zDGH(K+T1`4MAussWpq8iGy#l931lO|wL#rR%}v9rLQU;5=1GW%ABbM6Ja)h|g@|~p zqT1@j=R3!uqwg+6LquG5bVcFDzUekOpN3*8g(V4A=|;@@%L5V^M>RN0Qq9|jUU~Ss zZ$od1AocD;E|LCoI`0yTUKeSio@yzJ{FrG5K~yK}{8ohg~= z$r)u=!u^EE@J|!$26M_n+0FDa;@Coei2JhYN>`?>^_ zg9!)#aHG}dE*n@AsVRHCL#H%Yj182yG&lV!Bd(%nOmmYN`&QBJDZ5_!EN%eXbght_ zDr(MdXKLavqO>Xgo~C4mjR2kSFm7VEqQgegR}1`VxjtY6lwY-?Re8e^KV2#RJB;%mmrHN#=;qqX zYjZNzGaIub#}>T$)0u~_Fzo{$1faqmNVU!aVgfM=J{xowrRDQzz!JVYeq7!AsyVJA zTkHSwf~odYQUXe_w~JbeROXI3W_oG%WkEaE;Uji|Itvqf{_oRkT$4}?K2 ze?v#?3I^~#S0!<+#InV*A8}dxqRRBk2|@$0KV*^jrIbQ%tomat=2@eN zG{ zu)?VEc(`YJk{)t((8F5$hTk*V^(#6r(kg`Mtn^i@+oQw@b6hJ5HvYS=e@4T{ol}9r0X7e}g z!@g(NSLVr#aPQMZr}OhP#Y(Gy@@X+ZU;@y>#ebOU?l^u&SeiDIQ`hDZ?ImARQ8H3} z{ZF6S*>dr%oajk@wd!1L++p{LM6Ob_k5${<>oZH*uy;qkn=0P+v_Sy67^wLXxU~O6 zkzx5gx*p{foSp{dg9HA>Owj?k!K%o&HD0)Q!W94W&SZou22IX2VpqfV*oH#t6)yLd10c zls=VYbwGdmPE6k5GfwNSbo_wc@%*5r22zN!me(;vev_-JhrJ5oeOVH=A-$( zaB3+JZc@3pWdT0zRjJ4tk)dCH)e#~B07cl_e)(+oiPsRZlQ^0>D$3ZyYh038Sry}G z;m~H&$yRa>TMM;n{#IT8&*- z)=wWt2lqc+xpq)N$zZ$IG^qb{q+<;J{Ahi_fRlnwz4Pvp)M@|Z#Ehdk;CaICpcq_MC0^XH+TpRw8ifR2H&^I$p z8MWeCNS?m&-h!0uiUdRhTV*Z8RO&aFLV`MeE8p+CW5cDF)%7iHmBT587adQ9eJoX) zB}7Qjl8EQ3-KbOMsR}HJ(5BTr^?6U&i;ejykN>>ce}6Z_gZBO9bfW5C*L}yv@Qnh~ zDplc6f;U~MNR~zBq?|KuY4|Jbo&~(E_T|h0GC%b@e^H2SbDB&@GDPCjf>{|pez);M z_rkg;R%V9-(yb)e7u0o#=V_ugz!QE9SDaG%fncpX7x20J|AWo|w9dFevQ0lCoJqTTv(KO5BW{3ox3;^>mnL!Rb zRc(hYD{Ra8ybpQXilnb(XDN?3>_ENQVc2n&Lw#2&Ko&+}Mf$|d_BN(%!30wGi7Cxo zuYgK+pkY$@Deyxukt|)BdCB`iU_-sOd@;7!Af+m4u8vYdF6??Zc?$79>CYEzfvh@|@;2=I z8IuExB6vh#jdt%acX{#BT-ve2e~gma+!_c(Bu^d*wZKj~`SH^k8VJ&sWH902fHG-WyGKd9eI(Gh4uJ-clO*2KgbL1ns|xgszxmvC)O}1 z6QN?f%d@L+xP3Gk(WOe1~0~(|!|8bP-Xjt=2@n0*U zo6gcMZwUyr!-fM*faBq@`@5YRYs<;%I&y?~xf{Q^Y~kXpA&oqqNb6sambJGrdw0if zNdS%Luc+{Nmb6t6ATg2}@_QZ8-%+F2iz2`u^a9Bt6n=0MVlKnln^LNZ!LzhxDON65 zcZoqP(jBG10yq^A#m06TY_fna;&U_f6&1QA#T6AM_yDVPqn+x!d1%1n(=x_q+v36s zdZ4TVIR!Q9LKwD3sG%{&3@r$qwgpFLm6U@9LN$$M%l|-Gpf^yr+O|WHjwyXC)izZX z1aosLxEc+MR+o#&G=rl-OjENe$J^ER6qb403C=gWaD(jCF75XCQq8(sTi$F6jhc;H zbGBRtJRkzfuzxG2NBXKrojzUG!hpxN*|xf5;rrms>jyW`u0zIr}g5xZoYmQP>`&n0>!&cBuP2Obn`t9L&*l3r`59PLjA=AW z=INPAJd&F%0lq!Pvx|mNQlfZ!U{HomPMOlOskO{Wo)6 zVajz$pS?o?EgABEOUr7}Y$9Yg&;E-%er$G9|kN}8pIpiW1}G_S~0eK5As7`E}CQ7%D1 zu_n^p`bEZF`8Ew1(P&~_4i^SjHwFh^;QaXT()YLkrhk9;T~QvCqH-RF7SCVGM$OM1 z+#PPC2%8uI?xzA`fo#E%ZZ3tNKe+$-%I7!iyUy}sBx|~MAM9fmb>!+FXX+l zLNmojWTL(yB+VT~68rN8dM+~$TYkZg#6f~z9{qh$PkvM^U#3bi`4h%&-&xA z5L*&iIGd;W>+B0`8bzV=#qNu7JRvsN*gBQCj*dv6@KgGg{JI&cs^HKKX@_;cfkO-f6~XV(XGa z6vW$Gq#C`ow-`eZ2zFwQRVTVwL)+ZXu~ZZmpz2UV!<7sRuD4N1OQoMKrIkok>o#xj z(tgdcG?(9?B=Pwxqg)}CY1ubT@<0sbP?I{E!<)QAa`%yq*7>?xrwFo^9W&Mx2sr|| zeRlI}F`Bbf)#yM|(J$CDltj8CpK@zgJhvyak0r-rr(SATBNX@2n&>vqV9$5f68HUR zhBxiCtp}xC7mtZcAY?uWp5yG>^4ga5lHcjk!^=)r5t^{@NWNE89|~Z`Y_we8g(NYy ziY086CVej9sE-h=jCL*HU<6C8h3B`A~pb86Zbkqz$oxr|4Q3- zQ5P{c{`IO@i;W5r1OP-t(Io-=t{p|u>m`k#5I$L#2(s9KhD-iAlhZ7^w5yT!=#u0Z z)4@BWd{`Em&p{tbU{*dTClUe)hXuBL`PEJ|m8C^2!+F&li@3{_^+^3P7yT1jmN|7UH;qc!i-pOyG_E zw@(|}a6;jvx;84c*giZ)4APrQu+y8{Sq0vEHl$c|j%Zy%ISf^vs)Kr3(I2q-pGn$^ zleO9)7TPuyOZb+MT)oFy#m;{Dhlo8N6~t1{(zBQD{1B80WMqX0l3DZqutIf1R2jgG zgusk|AG!QL?ukAEC3SoL#~|!2Tq09aO;yK=QqTxBVUPWO`^a+uZht_Y57d|(i4*!&P#}b! zczp60vhuRhX;QWGnjgT2hrsnO*?jmXATywh)3dgeSlg*UdS=Ic#kI&h97Wa#QJzUh zttE_f{j_2n8*Q-~zaC_AQ83%yC4c*zEIYC7c@7Q6>{)Xn)cwuxLnOPhI!@h_rqyFtid{$h_862c>*%)sN=Ba8vD={+rUv6A(0N;k9c>#H_L*^3!sKq6J4I5;FRYQ5tIhlA%gOF%fHk)g^-8KyW;t~aEao?`aF4a%=^(HBx;+-` z@&7~UHNoKL(gLq50x>`MWG0vl?hAgw%$!cqLfN0=#xFRPQZ|XN2ir?YSvi%|6zR4V zNvb9%#z}k>qo5d)vEhV`^z(z>2r2mPHwWM2*OSB&?S%e5G!HKQ8`lZ#{d=LPpG7$Q z5nUW(Y}$neO}S2byGt2)ZjScLz;xCf<Hg>7>?MIm`0u%Avvbkr-|^ne zEK5hjT$2bbr^7Lv`Mm^$j(q55C+xPz$^TwOs0m&^`SGgz>|rWG(Q})oDh34x2PV~& zo#q_QUjdTuNGC8~7-~r-QBEFXJIdC!M5Vh`dhkV~ML(xtwMshbU%bHf!QToWUgf*#e243i z&>_JBQ@5Oq4IM|PKfaCSLe#ljd>RC}Q^+ifNK^t@=YT)=4)8VmBm&D0w^!kGK^V=XtJK zc!S7){-1Ujy%|PTaDloR!Y|_?iu7W17u0DV>WymC7-Pspv_;geSVqJYVhay9Z)L!F zWtu$CP^rXkmZhCHQ)iGW{xMO_kFSekLZ0#?R8{cWSg@@jPAOHhXNiuI;6$&THy~8_ zM7q2XeAX4lTh8tfwl049ezLCA+wa?80X*R4_qS0Jc!wNjAM#n}8w_(2w8KJ5ahK-i zv0!%?G*Ko=?S5~8_<*zg3fJU;1_173pBj=wt8D~s3Kf06&!6=u8|=~h9M24GgN*V? zC5xejIhWbugGNyNf&{*YcF?jvR^ww|<8!;V2RB z&V@=kt8s|TGY*^d_^#D$L@3>PykJSe{whWw-=x|#i8i;4T&H_bQ0qjh1B7rw}Xl8 z^6xx5o~zY|9|(OE-0qaZ-~2VKEu6e)iJqR-)T}IR8yJc1me4xI#>E^tZM8!KE(#h4 z=@XA4Apu(x4DZ9wd>&`Jizbu;{^m`rd%G1@sp_QlZ`2ji29tq>>!|YrPkr~cy&4YR zyxjGRzuAsN-kUwawt=qic#fplg)e^BKa4GkQ29R>IGLJ`HspYO5u248vB*JFkfQU? z*vSt(J*x!fp~6v+@-N`cDxKIf>?p&C+mA1oGyt}rQXFE=vF4g0}#(^BUy1MI$|{zDfh*ZTA;%j6BLo9C724_%kV zN-NJOu)mZFH?(Z)*N5)i*Vhi7k2g*qMkj=Cr;}){y(e1UKd*+D!~D)qFGtTON*+>x@%m!Gbv5bJ^Lb;;v7~8irX3T&`*Qj6 z9DKO}dl+zf_k1nGlM@=$bAI|TsvIGE};GCX`@tTezHwM>JgM?)QT`)5Ro zi8P;{9WUlCNhp7>+Je@`&AM%;I;ekCQ;>`-G~iYZ4Qtl^MAUkTw|!IUW$c|Z3(Ndi zN)LTKwildnQ=2mt)dFYZ-NJd~SE7N{o8nORR@zyV7Cs@6W?@J13R!t}Ts66?&mSyR zBCEddNZ*qdGp#&^tUUao6DpHS=YNI(DZeYbAOE5uPAg0emkB8xMJdve#YXPD`13i& z>C+q|1}c~lj8Qc5J!AEH$uD>4@J_Bs{h-KWxs~=+DIcT1@WjxTGGOKa-$}jQgZK4D zs}6Zjr9mG2Kf3SXW0lfW)e_)EVEh8fm0U0ie5Er!qFnz1PX7{NtjYAtF%uRZWY%9; zIEcgvqm#sv>0_iYI1puO?D&cEVg+w*$8k+H$l>CLh8sjZ@q{4UlAQNuJYiDs&L0qg z=2qL`WP5MqKifZU_|oJB0OaarwdYyEOid=3vIlu)&ktVwMav7joJ~Sz z;opy#U>zB_7Gq_4k&fwOZ((p`*rT?ysv^ix(T}*cS<5Cl6ZIT1N~D)nq-3F4@4fxb znwHfIlot?U<;7tC`pg?&!xztS)7hO0wEu0}d=r07Ax0?m<9Mo|{d0F>Zq&%V3 zXY~2?cL_-@2Xd5H2%ub^d5!<40VA;c_Hb?4qZ%yoJ{_SYT@b`=dMJ7<#!%!%&DHE6g-Dh_ zjz@dWIbI+x*e>rpav^a}1MMSSQ$nh{3y=gy>9;jQ8w$pHRa7{@pLAx0gb5aV2o zdy{eT&B&2-S_Gu-shht{E=TV7Sm5sKB<6H3xf+oY86_p$^wqg{f{nlUPqa=y+~dBB|Oy!0ACj8>6uH(isvn`SmhgPh@7 zSX-l;!2y#weEsdUkt4gs&;LfUY}X!~AF83V2bY@^wP&#YiGE?&aEIs9bBJg* zp+R@?4OX|;RWg$0^LG9<|0~xdD)Lab&Y!EWhf1xpE}ux%dxm_Us>C)9=0dA@F9Ab( z#2S4)%nfI4jro{jNuNw}`Pn8*Bx)vnBBHI8!oLvZ@)VtFi)WP;m1MS^NB^^jW+g%W z^30cKk%y&cd+u*_JuH6S7YxGz=M#FTcPD3ex*?Drg%6sKBN0wsZd2Pd-M3X+K9LtF z2!OEXaQLEr_bAmieEy>f zV?VT|1A-XGK;pS{9j%3)gkdD!D-DkR=)jO~ccGuHwN=d%$ACqMNi6Fw{0DRIa=-Sr z^o~faZ}#nxQ(h{}gD4_;rY0jxDDDK>2viuY8H4`!f5y`$o`xs4LxeD5Gxe_=Jne7$ zr#v;74VWXTp@CN9oBwQF3B>kS+}Nx`|I}&Eje1{+?SB2{rjrG*>RuZ12YEP$g?&Gq zg}YEynQ)n~7A#*WmeWgP1nNR3cGKjB7oF@_7mMZW(%u8V3^6=x$lqxlDQ>wRVD?~w zi%^k|_KUECKo70`AIh@qWx8^iZWIp3zKb z2V;CpXYK+AX>Y;2{eTX)b||<3MU?cob~&5biMFzVh|tAJLvCu0z%4Tyi1S}_MUJX} zh}t0@2QE7GoNyUgER|V9yG2!4ie++b=tqF@vh**rXl$-1fhEkeJdF z6Nh{{P|Tg3m9bfV-yTv}R@K&?QCQmB+sC5mGW+}cM;HDrkTWlb)FI#~!(hUsP5Ri9 zQ4$n>`z{3d_<&P4R2c;i?jHIGOjhJ!p{{S}TGr9l( diff --git a/src/sampletones_assets/icons/sampletones.svg b/src/sampletones_assets/icons/sampletones.svg new file mode 100644 index 000000000..631ddf7de --- /dev/null +++ b/src/sampletones_assets/icons/sampletones.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/sampletones_core/audio/writers/capability.py b/src/sampletones_core/audio/writers/capability.py index aa8ad78f8..924a95a33 100644 --- a/src/sampletones_core/audio/writers/capability.py +++ b/src/sampletones_core/audio/writers/capability.py @@ -2,7 +2,7 @@ from typing import Final, Mapping, Tuple from sampletones_core.constants.audio import SAMPLE_RATES -from sampletones_core.paths import EXT_FILE_MP3, EXT_FILE_WAVE +from sampletones_shared.paths.extensions import EXT_FILE_MP3, EXT_FILE_WAVE from .bitrate import MP3_SAMPLE_RATES from .format import AUDIO_DEPTHS, AudioDepth, AudioFormat diff --git a/src/sampletones_core/calibration/corpus/writer.py b/src/sampletones_core/calibration/corpus/writer.py index f7367a6b7..43ecee392 100644 --- a/src/sampletones_core/calibration/corpus/writer.py +++ b/src/sampletones_core/calibration/corpus/writer.py @@ -2,7 +2,7 @@ from typing import Dict, List from sampletones_core.audio.io import write_wave -from sampletones_core.paths import EXT_FILE_WAVE +from sampletones_shared.paths.extensions import EXT_FILE_WAVE from sampletones_shared.utils.system.paths import get_filename from .item import CorpusItem diff --git a/src/sampletones_core/calibration/paths.py b/src/sampletones_core/calibration/paths.py index 27aed45aa..751bbb533 100644 --- a/src/sampletones_core/calibration/paths.py +++ b/src/sampletones_core/calibration/paths.py @@ -1,7 +1,7 @@ from pathlib import Path from typing import Final -from sampletones_shared.paths import CONFIG_DIRECTORY +from sampletones_shared.paths.resources import CONFIG_DIRECTORY CALIBRATION_CONFIG_DIRECTORY: Final[Path] = CONFIG_DIRECTORY / "calibration" REFEREE_CONFIG_PATH: Final[Path] = CALIBRATION_CONFIG_DIRECTORY / "referee.yaml" diff --git a/src/sampletones_core/configs/config.py b/src/sampletones_core/configs/config.py index 5390791f6..2dafc7968 100644 --- a/src/sampletones_core/configs/config.py +++ b/src/sampletones_core/configs/config.py @@ -8,7 +8,7 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.data import DataModel from sampletones_core.data.metadata import Metadata -from sampletones_core.paths import CONFIG_PATH +from sampletones_shared.paths.user import CONFIG_PATH from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import load_json, save_json from sampletones_shared.utils.system.paths import to_path diff --git a/src/sampletones_core/configs/general.py b/src/sampletones_core/configs/general.py index 210696a6c..b95c8a5f9 100644 --- a/src/sampletones_core/configs/general.py +++ b/src/sampletones_core/configs/general.py @@ -10,7 +10,7 @@ ) from sampletones_core.constants.general import MAX_PITCH, MIN_PITCH from sampletones_core.data import DataModel -from sampletones_core.paths import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY +from sampletones_shared.paths.user import LIBRARY_DIRECTORY, RECONSTRUCTIONS_DIRECTORY class GeneralConfig(DataModel): diff --git a/src/sampletones_core/library/filename/fields.py b/src/sampletones_core/library/filename/fields.py index 5e050b776..0733a5326 100644 --- a/src/sampletones_core/library/filename/fields.py +++ b/src/sampletones_core/library/filename/fields.py @@ -7,7 +7,7 @@ from sampletones_core.constants.enums import SpectrumMethod from sampletones_core.constants.field_aliases import ALIASES -from sampletones_core.paths import EXT_FILE_LIBRARY +from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import HASH_PATTERN from sampletones_shared.utils.system.paths import get_filename diff --git a/src/sampletones_core/library/filename/utils.py b/src/sampletones_core/library/filename/utils.py index 26b0927bc..5088d498c 100644 --- a/src/sampletones_core/library/filename/utils.py +++ b/src/sampletones_core/library/filename/utils.py @@ -7,7 +7,7 @@ ) from sampletones_core.library.filename.fields import InstructionsFilenameFields from sampletones_core.library.key import InstructionLibraryKey -from sampletones_core.paths import EXT_FILE_LIBRARY +from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.system.paths import get_filename diff --git a/src/sampletones_core/library/library.py b/src/sampletones_core/library/library.py index 496d16913..76b0b5eec 100644 --- a/src/sampletones_core/library/library.py +++ b/src/sampletones_core/library/library.py @@ -7,8 +7,8 @@ from sampletones_core.configs import Config from sampletones_core.fft import Window -from sampletones_core.paths import LIBRARY_DIRECTORY from sampletones_shared.logger import logger +from sampletones_shared.paths.user import LIBRARY_DIRECTORY from .data import InstructionLibraryData from .key import InstructionLibraryKey diff --git a/src/sampletones_core/paths.py b/src/sampletones_core/paths.py deleted file mode 100644 index f05647616..000000000 --- a/src/sampletones_core/paths.py +++ /dev/null @@ -1,66 +0,0 @@ -from pathlib import Path -from typing import Final, Tuple - -from platformdirs import user_config_dir, user_data_dir, user_documents_path - -from sampletones_shared.application import ( - SAMPLETONES_GROUP, - SAMPLETONES_NAME, -) - -# User paths -USER_PATH_DOCUMENTS: Final[Path] = Path(user_documents_path()) / SAMPLETONES_NAME -USER_PATH_DATA: Final[Path] = Path(user_data_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP)) -USER_PATH_CONFIG: Final[Path] = Path(user_config_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP)) - -# Application paths -LIBRARY_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "instructions" -RECONSTRUCTIONS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "reconstructions" -PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects" -CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json" -APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml" - -# File extensions -EXT_FILE_JSON: Final[str] = ".json" -EXT_FILE_YAML: Final[str] = ".yaml" -EXT_FILE_LIBRARY: Final[str] = ".ins" -EXT_FILE_INSTRUMENT: Final[str] = ".fti" -EXT_FILE_RECONSTRUCTION: Final[str] = ".stn" -EXT_FILE_PROJECT: Final[str] = ".stp" -EXT_FILE_MODULE: Final[str] = ".ftm" -EXT_FILE_BITPHASE: Final[str] = ".btp" -EXT_FILE_WAVE: Final[str] = ".wav" -EXT_FILE_MP3: Final[str] = ".mp3" -EXT_FILE_FLAC: Final[str] = ".flac" -EXT_FILE_OGG: Final[str] = ".ogg" -EXT_FILE_AIFF: Final[str] = ".aiff" -EXT_FILE_AU: Final[str] = ".au" -EXT_FILES_AUDIO: Final[Tuple[str, ...]] = ( - EXT_FILE_WAVE, - EXT_FILE_MP3, - EXT_FILE_FLAC, - EXT_FILE_OGG, - EXT_FILE_AIFF, - EXT_FILE_AU, -) - -# Assets -ASSETS_DIRECTORY: Final[str] = "assets" - -# Icon filenames -ICON_DIRECTORY: Final[str] = "icons" -ICON_WIN_FILENAME: Final[str] = "sampletones.ico" -ICON_UNIX_FILENAME: Final[str] = "sampletones.png" - -# Font paths -FONT_DIRECTORY: Final[str] = "fonts" -FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf" -FONT_SANS_BOLD: Final[str] = "SourceSans3-Bold.ttf" -FONT_SANS_ITALIC: Final[str] = "SourceSans3-Italic.ttf" -FONT_MONO_REGULAR: Final[str] = "RobotoMono-Regular.ttf" -FONT_MONO_BOLD: Final[str] = "RobotoMono-Bold.ttf" -FONT_ICON: Final[str] = "DejaVuSans.ttf" - -PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True) -LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True) -RECONSTRUCTIONS_DIRECTORY.mkdir(parents=True, exist_ok=True) diff --git a/src/sampletones_core/project/container.py b/src/sampletones_core/project/container.py index c5186a2c3..375b93e4b 100644 --- a/src/sampletones_core/project/container.py +++ b/src/sampletones_core/project/container.py @@ -4,7 +4,6 @@ from pydantic import ValidationError -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.project.document import ProjectDocument from sampletones_core.project.instruments.record import SampleRecord from sampletones_core.project.instruments.sample import Sample @@ -27,6 +26,7 @@ NotAValidArchiveError, UnhandledProjectError, ) +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from sampletones_shared.types.path import Pathlike from sampletones_shared.utils.serialization import JSON_INDENT from sampletones_shared.utils.system.paths import get_filename diff --git a/src/sampletones_core/reconstructions/converter/paths/utils.py b/src/sampletones_core/reconstructions/converter/paths/utils.py index 481ccc323..3069f11b1 100644 --- a/src/sampletones_core/reconstructions/converter/paths/utils.py +++ b/src/sampletones_core/reconstructions/converter/paths/utils.py @@ -2,13 +2,13 @@ from typing import List, Tuple from sampletones_core.configs import Config -from sampletones_core.paths import ( - EXT_FILE_RECONSTRUCTION, - EXT_FILES_AUDIO, -) from sampletones_core.reconstructions.converter.paths.fields import ( ConfigDirectoryFields, ) +from sampletones_shared.paths.extensions import ( + EXT_FILE_RECONSTRUCTION, + EXT_FILES_AUDIO, +) from sampletones_shared.utils.system.paths import to_path diff --git a/src/sampletones_core/trackers/implementation/bitphase.py b/src/sampletones_core/trackers/implementation/bitphase.py index 83e40cf50..38b3561eb 100644 --- a/src/sampletones_core/trackers/implementation/bitphase.py +++ b/src/sampletones_core/trackers/implementation/bitphase.py @@ -8,11 +8,11 @@ sample_to_bitphase, ) from sampletones_core.formats.bitphase.preset import instrument_to_preset, write_preset -from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON from sampletones_core.trackers.artifact import ExportArtifact from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.request import InstrumentExport, ProjectExport, SampleExport from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON from sampletones_shared.utils.system.paths import get_filename DOCUMENT_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) diff --git a/src/sampletones_core/trackers/implementation/famitracker.py b/src/sampletones_core/trackers/implementation/famitracker.py index ee84198eb..dfe9bee04 100644 --- a/src/sampletones_core/trackers/implementation/famitracker.py +++ b/src/sampletones_core/trackers/implementation/famitracker.py @@ -11,7 +11,6 @@ from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) -from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from sampletones_core.trackers.artifact import ExportArtifact from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.request import ( @@ -20,6 +19,7 @@ SampleExport, ) from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from sampletones_shared.utils.system.paths import get_filename SUPPORTED_SCOPES: FrozenSet[ExportScope] = frozenset(ExportScope) diff --git a/src/sampletones_shared/meta/source/packages.py b/src/sampletones_shared/meta/source/packages.py index 772c40f53..f19a3899a 100644 --- a/src/sampletones_shared/meta/source/packages.py +++ b/src/sampletones_shared/meta/source/packages.py @@ -1,6 +1,6 @@ from pathlib import Path -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT def package_directory(name: str, *parts: str) -> Path: diff --git a/src/sampletones_shared/paths.py b/src/sampletones_shared/paths.py deleted file mode 100644 index 534644823..000000000 --- a/src/sampletones_shared/paths.py +++ /dev/null @@ -1,12 +0,0 @@ -import sys -from importlib.resources import files -from pathlib import Path -from typing import Final, Optional - -_BUNDLE_ROOT: Final[Optional[str]] = getattr(sys, "_MEIPASS", None) - -CONFIG_DIRECTORY: Final[Path] = ( - Path(_BUNDLE_ROOT) / "config" if _BUNDLE_ROOT is not None else Path(str(files("sampletones_config"))) -) -SOURCE_ROOT: Final[Path] = Path(__file__).resolve().parents[1] -REPOSITORY_ROOT: Final[Path] = SOURCE_ROOT.parent diff --git a/src/sampletones_shared/paths/__init__.py b/src/sampletones_shared/paths/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_shared/paths/extensions.py b/src/sampletones_shared/paths/extensions.py new file mode 100644 index 000000000..857c1c911 --- /dev/null +++ b/src/sampletones_shared/paths/extensions.py @@ -0,0 +1,24 @@ +from typing import Final, Tuple + +EXT_FILE_JSON: Final[str] = ".json" +EXT_FILE_YAML: Final[str] = ".yaml" +EXT_FILE_LIBRARY: Final[str] = ".ins" +EXT_FILE_INSTRUMENT: Final[str] = ".fti" +EXT_FILE_RECONSTRUCTION: Final[str] = ".stn" +EXT_FILE_PROJECT: Final[str] = ".stp" +EXT_FILE_MODULE: Final[str] = ".ftm" +EXT_FILE_BITPHASE: Final[str] = ".btp" +EXT_FILE_WAVE: Final[str] = ".wav" +EXT_FILE_MP3: Final[str] = ".mp3" +EXT_FILE_FLAC: Final[str] = ".flac" +EXT_FILE_OGG: Final[str] = ".ogg" +EXT_FILE_AIFF: Final[str] = ".aiff" +EXT_FILE_AU: Final[str] = ".au" +EXT_FILES_AUDIO: Final[Tuple[str, ...]] = ( + EXT_FILE_WAVE, + EXT_FILE_MP3, + EXT_FILE_FLAC, + EXT_FILE_OGG, + EXT_FILE_AIFF, + EXT_FILE_AU, +) diff --git a/src/sampletones_shared/paths/resources.py b/src/sampletones_shared/paths/resources.py new file mode 100644 index 000000000..fa596d931 --- /dev/null +++ b/src/sampletones_shared/paths/resources.py @@ -0,0 +1,24 @@ +import sys +from importlib.resources import files +from pathlib import Path +from typing import Final, Optional + +_BUNDLE_ROOT: Final[Optional[str]] = getattr(sys, "_MEIPASS", None) + +CONFIG_DIRECTORY: Final[Path] = ( + Path(_BUNDLE_ROOT) / "config" if _BUNDLE_ROOT is not None else Path(str(files("sampletones_config"))) +) + +ASSETS_DIRECTORY: Final[str] = "assets" + +ICON_DIRECTORY: Final[str] = "icons" +ICON_WIN_FILENAME: Final[str] = "sampletones.ico" +ICON_UNIX_FILENAME: Final[str] = "sampletones.png" + +FONT_DIRECTORY: Final[str] = "fonts" +FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf" +FONT_SANS_BOLD: Final[str] = "SourceSans3-Bold.ttf" +FONT_SANS_ITALIC: Final[str] = "SourceSans3-Italic.ttf" +FONT_MONO_REGULAR: Final[str] = "RobotoMono-Regular.ttf" +FONT_MONO_BOLD: Final[str] = "RobotoMono-Bold.ttf" +FONT_ICON: Final[str] = "DejaVuSans.ttf" diff --git a/src/sampletones_shared/paths/source.py b/src/sampletones_shared/paths/source.py new file mode 100644 index 000000000..51dd98fa8 --- /dev/null +++ b/src/sampletones_shared/paths/source.py @@ -0,0 +1,5 @@ +from pathlib import Path +from typing import Final + +SOURCE_ROOT: Final[Path] = Path(__file__).resolve().parents[2] +REPOSITORY_ROOT: Final[Path] = SOURCE_ROOT.parent diff --git a/src/sampletones_shared/paths/user.py b/src/sampletones_shared/paths/user.py new file mode 100644 index 000000000..6a34673d3 --- /dev/null +++ b/src/sampletones_shared/paths/user.py @@ -0,0 +1,23 @@ +from pathlib import Path +from typing import Final + +from platformdirs import user_config_dir, user_data_dir, user_documents_path + +from sampletones_shared.application import ( + SAMPLETONES_GROUP, + SAMPLETONES_NAME, +) + +USER_PATH_DOCUMENTS: Final[Path] = Path(user_documents_path()) / SAMPLETONES_NAME +USER_PATH_DATA: Final[Path] = Path(user_data_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP)) +USER_PATH_CONFIG: Final[Path] = Path(user_config_dir(SAMPLETONES_NAME, SAMPLETONES_GROUP)) + +LIBRARY_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "instructions" +RECONSTRUCTIONS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "reconstructions" +PROJECTS_DIRECTORY: Final[Path] = USER_PATH_DOCUMENTS / "projects" +CONFIG_PATH: Final[Path] = USER_PATH_DOCUMENTS / "config.json" +APPLICATION_CONFIG_PATH: Final[Path] = USER_PATH_CONFIG / "config.yaml" + +PROJECTS_DIRECTORY.mkdir(parents=True, exist_ok=True) +LIBRARY_DIRECTORY.mkdir(parents=True, exist_ok=True) +RECONSTRUCTIONS_DIRECTORY.mkdir(parents=True, exist_ok=True) diff --git a/tests/integration/tooling/test_check_commands.py b/tests/integration/tooling/test_check_commands.py index fee708dc0..a265cea4b 100644 --- a/tests/integration/tooling/test_check_commands.py +++ b/tests/integration/tooling/test_check_commands.py @@ -3,7 +3,7 @@ import yaml -from sampletones_shared.paths import REPOSITORY_ROOT +from sampletones_shared.paths.source import REPOSITORY_ROOT PRE_COMMIT_CONFIG: Final[Path] = REPOSITORY_ROOT / ".pre-commit-config.yaml" MAKEFILE: Final[Path] = REPOSITORY_ROOT / "Makefile" diff --git a/tests/suite/scripts.py b/tests/suite/scripts.py index 97a5263ec..413fa9d7a 100644 --- a/tests/suite/scripts.py +++ b/tests/suite/scripts.py @@ -1,7 +1,7 @@ import importlib.util from types import ModuleType -from sampletones_shared.paths import REPOSITORY_ROOT +from sampletones_shared.paths.source import REPOSITORY_ROOT def load_script(relative_path: str) -> ModuleType: diff --git a/tests/unit/sampletones_application/config/test_profile.py b/tests/unit/sampletones_application/config/test_profile.py index f1d3e5aad..4a123e290 100644 --- a/tests/unit/sampletones_application/config/test_profile.py +++ b/tests/unit/sampletones_application/config/test_profile.py @@ -2,7 +2,7 @@ from sampletones_application.config.profile import UserProfile from sampletones_application.paths import APPLICATION_STATE_PATH -from sampletones_core.paths import APPLICATION_CONFIG_PATH +from sampletones_shared.paths.user import APPLICATION_CONFIG_PATH class TestUserProfile: diff --git a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py index 41662f905..8a1cae3e5 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py +++ b/tests/unit/sampletones_application/logic/reconstruction/browser/conftest.py @@ -16,9 +16,9 @@ ReconstructionScan, ) from sampletones_core.constants.enums import SpectrumMethod -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from tests.suite.language import FakeLanguageManager HASH_A: Final[str] = "6edf7c948606917a78b45d153c7ca7e0" diff --git a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py index 8c9f1c40d..0d426cfbc 100644 --- a/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py +++ b/tests/unit/sampletones_application/logic/reconstruction/test_reconstruction.py @@ -19,15 +19,15 @@ from sampletones_core.configs import Config from sampletones_core.constants.enums import AudioSourceType, GeneratorName from sampletones_core.instructions import TriangleInstruction -from sampletones_core.paths import ( +from sampletones_core.reconstructions import Reconstruction +from sampletones_core.trackers.format import TrackerFormat +from sampletones_core.trackers.registry import build_tracker_backends +from sampletones_shared.paths.extensions import ( EXT_FILE_BITPHASE, EXT_FILE_INSTRUMENT, EXT_FILE_JSON, EXT_FILE_MODULE, ) -from sampletones_core.reconstructions import Reconstruction -from sampletones_core.trackers.format import TrackerFormat -from sampletones_core.trackers.registry import build_tracker_backends from tests.suite.case import BaseRegularTestCase NO_EXTENSION: Final[str] = "" diff --git a/tests/unit/sampletones_application/logic/shared/test_tree.py b/tests/unit/sampletones_application/logic/shared/test_tree.py index 3a7763e86..afc741b38 100644 --- a/tests/unit/sampletones_application/logic/shared/test_tree.py +++ b/tests/unit/sampletones_application/logic/shared/test_tree.py @@ -10,9 +10,9 @@ from sampletones_application.layout.behavior.scheduling.scheduling import SchedulingBehavior from sampletones_application.logic.shared.playback_priority import PlaybackPriority from sampletones_application.logic.shared.tree import TreeLogic -from sampletones_core import paths from sampletones_core.structures.tree import FileSystemNode, NodeType, TreeNode from sampletones_shared.exceptions import InvalidReconstructionError +from sampletones_shared.paths import extensions def _tree( @@ -360,7 +360,7 @@ def test_load_failure_reports_autoplay_error( audio_device_manager = MagicMock() tree = _tree(audio_device_manager=audio_device_manager) tree.on_autoplay_error = MagicMock() - node = _file_node(tmp_path / f"sample{paths.EXT_FILE_RECONSTRUCTION}") + node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") with patch( "sampletones_application.logic.shared.tree.Reconstruction.load", @@ -374,7 +374,7 @@ def test_load_failure_reports_autoplay_error( def test_unexpected_failure_propagates(self, tmp_path: Path) -> None: tree = _tree() tree.on_autoplay_error = MagicMock() - node = _file_node(tmp_path / f"sample{paths.EXT_FILE_RECONSTRUCTION}") + node = _file_node(tmp_path / f"sample{extensions.EXT_FILE_RECONSTRUCTION}") with ( patch( diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py index d74612507..1c3c57ffb 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_detail_items.py @@ -7,10 +7,10 @@ from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_core.configs import Config from sampletones_core.configs.display import format_sample_rate, short_hash -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields from sampletones_core.structures.tree.node import ConfigNode, FileSystemNode, TreeNode from sampletones_core.structures.tree.type import NodeType +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION from tests.suite.language import FakeLanguageManager CONFIG_FIELDS: Final[ConfigDirectoryFields] = ConfigDirectoryFields.from_config(Config()) diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py index 4c314e7e5..75325efe8 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_catalog.py @@ -6,7 +6,7 @@ from sampletones_application.paths import KEYBINDINGS_DIRECTORY from sampletones_application.utils.gui.shortcuts.catalog import ShortcutCatalog from sampletones_application.utils.gui.shortcuts.ids import ShortcutId -from sampletones_core.paths import EXT_FILE_YAML +from sampletones_shared.paths.extensions import EXT_FILE_YAML SHIPPED_FILE = KEYBINDINGS_DIRECTORY / f"{DEFAULT_SCHEME_NAME}{EXT_FILE_YAML}" SHIPPED_SCHEME_NAMES = ShortcutCatalog.load(KEYBINDINGS_DIRECTORY).names diff --git a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py index 4a690e0e5..868689bf6 100644 --- a/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py +++ b/tests/unit/sampletones_application/utils/gui/shortcuts/test_scheme.py @@ -12,7 +12,7 @@ from sampletones_application.utils.gui.shortcuts.ids import ShortcutCategory, ShortcutId from sampletones_application.utils.gui.shortcuts.scheme import ShortcutScheme from sampletones_application.utils.gui.shortcuts.written import WrittenShortcut -from sampletones_core.paths import EXT_FILE_YAML +from sampletones_shared.paths.extensions import EXT_FILE_YAML from tests.unit.sampletones_application.utils.gui.shortcuts.conftest import ( PROBE_SCHEME_NAME, RebindScheme, diff --git a/tests/unit/sampletones_core/audio/writers/test_spec.py b/tests/unit/sampletones_core/audio/writers/test_spec.py index cf3961be4..cdbc328f8 100644 --- a/tests/unit/sampletones_core/audio/writers/test_spec.py +++ b/tests/unit/sampletones_core/audio/writers/test_spec.py @@ -15,7 +15,7 @@ mp3_bitrates, ) from sampletones_core.constants.audio import SAMPLE_RATES -from sampletones_core.paths import EXT_FILE_MP3, EXT_FILE_WAVE +from sampletones_shared.paths.extensions import EXT_FILE_MP3, EXT_FILE_WAVE from tests.suite.base import BaseTestSuite MPEG_1_RATE: Final[int] = 44100 diff --git a/tests/unit/sampletones_core/formats/bitphase/test_btp.py b/tests/unit/sampletones_core/formats/bitphase/test_btp.py index 10485b189..dc7b3abaa 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_btp.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_btp.py @@ -13,7 +13,7 @@ CHIP_TYPE_NES, TUNING_TABLE_LENGTH, ) -from sampletones_core.paths import EXT_FILE_BITPHASE +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE from .conftest import build_features, build_instrument, build_sample diff --git a/tests/unit/sampletones_core/formats/bitphase/test_preset.py b/tests/unit/sampletones_core/formats/bitphase/test_preset.py index 322f0e3ff..ed3b8e11f 100644 --- a/tests/unit/sampletones_core/formats/bitphase/test_preset.py +++ b/tests/unit/sampletones_core/formats/bitphase/test_preset.py @@ -19,7 +19,7 @@ MIN_TONE_ADD, NO_TONE_OFFSET, ) -from sampletones_core.paths import EXT_FILE_JSON +from sampletones_shared.paths.extensions import EXT_FILE_JSON from .conftest import REFERENCE_PITCH, build_features, build_instrument diff --git a/tests/unit/sampletones_core/library/filename/test_fields.py b/tests/unit/sampletones_core/library/filename/test_fields.py index 466cfe036..1dbc7b072 100644 --- a/tests/unit/sampletones_core/library/filename/test_fields.py +++ b/tests/unit/sampletones_core/library/filename/test_fields.py @@ -7,7 +7,7 @@ FILENAME_SEPARATOR, InstructionsFilenameFields, ) -from sampletones_core.paths import EXT_FILE_LIBRARY +from sampletones_shared.paths.extensions import EXT_FILE_LIBRARY from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.errors import expect_error diff --git a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py index 4c38eec05..6955b77ce 100644 --- a/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py +++ b/tests/unit/sampletones_core/reconstructions/converter/paths/test_utils.py @@ -4,13 +4,13 @@ import pytest from sampletones_core.configs import Config -from sampletones_core.paths import EXT_FILE_RECONSTRUCTION from sampletones_core.reconstructions.converter.paths import ( filter_files, get_audio_files, get_output_path, get_relative_path, ) +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION @pytest.fixture(scope="module") diff --git a/tests/unit/sampletones_core/trackers/test_bitphase.py b/tests/unit/sampletones_core/trackers/test_bitphase.py index 1bbd52a3f..4c9aa41fd 100644 --- a/tests/unit/sampletones_core/trackers/test_bitphase.py +++ b/tests/unit/sampletones_core/trackers/test_bitphase.py @@ -9,7 +9,6 @@ from sampletones_core.constants.enums import GeneratorName from sampletones_core.exporters import Features -from sampletones_core.paths import EXT_FILE_BITPHASE, EXT_FILE_JSON from sampletones_core.project.project import Project from sampletones_core.project.settings import ProjectSettings from sampletones_core.trackers.format import TrackerFormat @@ -23,6 +22,7 @@ SampleExport, ) from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import EXT_FILE_BITPHASE, EXT_FILE_JSON NES_FREQUENCY: Final[int] = 60 REFERENCE_PITCH: Final[int] = 60 diff --git a/tests/unit/sampletones_core/trackers/test_extensions.py b/tests/unit/sampletones_core/trackers/test_extensions.py index 10ac16516..b9cc907d1 100644 --- a/tests/unit/sampletones_core/trackers/test_extensions.py +++ b/tests/unit/sampletones_core/trackers/test_extensions.py @@ -3,17 +3,17 @@ import pytest -from sampletones_core.paths import ( - EXT_FILE_BITPHASE, - EXT_FILE_INSTRUMENT, - EXT_FILE_JSON, - EXT_FILE_MODULE, -) from sampletones_core.trackers.backend import TrackerBackend from sampletones_core.trackers.extensions import format_for_extension from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.registry import build_tracker_backends from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import ( + EXT_FILE_BITPHASE, + EXT_FILE_INSTRUMENT, + EXT_FILE_JSON, + EXT_FILE_MODULE, +) UNKNOWN_EXTENSION: Final[str] = ".xm" NO_EXTENSION: Final[str] = "" diff --git a/tests/unit/sampletones_core/trackers/test_famitracker.py b/tests/unit/sampletones_core/trackers/test_famitracker.py index 5aedf2c43..3f036b020 100644 --- a/tests/unit/sampletones_core/trackers/test_famitracker.py +++ b/tests/unit/sampletones_core/trackers/test_famitracker.py @@ -10,11 +10,11 @@ from sampletones_core.formats.famitracker.specification.sequences import ( MAX_SEQUENCE_ITEMS, ) -from sampletones_core.paths import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE from sampletones_core.trackers.format import TrackerFormat from sampletones_core.trackers.implementation.famitracker import FamiTrackerBackend from sampletones_core.trackers.request import InstrumentExport, SampleExport from sampletones_core.trackers.scope import ExportScope +from sampletones_shared.paths.extensions import EXT_FILE_INSTRUMENT, EXT_FILE_MODULE NES_FREQUENCY: Final[int] = 60 diff --git a/tests/unit/sampletones_shared/meta/source/test_packages.py b/tests/unit/sampletones_shared/meta/source/test_packages.py index bc2333ad7..e18e8b853 100644 --- a/tests/unit/sampletones_shared/meta/source/test_packages.py +++ b/tests/unit/sampletones_shared/meta/source/test_packages.py @@ -1,7 +1,7 @@ import pytest from sampletones_shared.meta.source.packages import package_directory -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT SHARED_PACKAGE = "sampletones_shared" APPLICATION_PACKAGE = "sampletones_application" diff --git a/tests/unit/sampletones_shared/paths/__init__.py b/tests/unit/sampletones_shared/paths/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_shared/paths/test_resources.py b/tests/unit/sampletones_shared/paths/test_resources.py new file mode 100644 index 000000000..53ca661a4 --- /dev/null +++ b/tests/unit/sampletones_shared/paths/test_resources.py @@ -0,0 +1,7 @@ +from sampletones_shared.paths.resources import CONFIG_DIRECTORY + + +class TestConfigDirectory: + def test_the_configuration_directory_holds_the_shipped_files(self) -> None: + """Read as a package resource, so the bundle finds it beside the executable.""" + assert list(CONFIG_DIRECTORY.rglob("*.yaml")) diff --git a/tests/unit/sampletones_shared/test_paths.py b/tests/unit/sampletones_shared/paths/test_source.py similarity index 63% rename from tests/unit/sampletones_shared/test_paths.py rename to tests/unit/sampletones_shared/paths/test_source.py index ae1c2fe2c..6d5f1ec83 100644 --- a/tests/unit/sampletones_shared/test_paths.py +++ b/tests/unit/sampletones_shared/paths/test_source.py @@ -1,4 +1,4 @@ -from sampletones_shared.paths import CONFIG_DIRECTORY, REPOSITORY_ROOT, SOURCE_ROOT +from sampletones_shared.paths.source import REPOSITORY_ROOT, SOURCE_ROOT PROJECT_FILE = "pyproject.toml" SHARED_PACKAGE = "sampletones_shared" @@ -10,7 +10,7 @@ def test_the_source_root_holds_the_packages(self) -> None: def test_the_source_root_is_where_this_package_lives(self) -> None: """Reading the root off the package keeps it right wherever the packages are installed.""" - assert (SOURCE_ROOT / SHARED_PACKAGE / "paths.py").is_file() + assert (SOURCE_ROOT / SHARED_PACKAGE / "paths" / "source.py").is_file() class TestRepositoryRoot: @@ -19,9 +19,3 @@ def test_the_repository_root_holds_the_project_file(self) -> None: def test_the_repository_root_holds_the_scripts_the_checks_run_from(self) -> None: assert (REPOSITORY_ROOT / "scripts" / "checks").is_dir() - - -class TestConfigDirectory: - def test_the_configuration_directory_holds_the_shipped_files(self) -> None: - """Read as a package resource, so the bundle finds it beside the executable.""" - assert list(CONFIG_DIRECTORY.rglob("*.yaml")) diff --git a/tests/unit/sampletones_shared/paths/test_user.py b/tests/unit/sampletones_shared/paths/test_user.py new file mode 100644 index 000000000..d26807b2d --- /dev/null +++ b/tests/unit/sampletones_shared/paths/test_user.py @@ -0,0 +1,16 @@ +from sampletones_shared.paths.user import ( + LIBRARY_DIRECTORY, + PROJECTS_DIRECTORY, + RECONSTRUCTIONS_DIRECTORY, +) + + +class TestUserDirectories: + def test_the_user_directories_exist_after_import(self) -> None: + """Importing the module creates the directories the application saves into.""" + for directory in ( + LIBRARY_DIRECTORY, + PROJECTS_DIRECTORY, + RECONSTRUCTIONS_DIRECTORY, + ): + assert directory.is_dir() diff --git a/tests/unit/scripts/checks/test_palette_colors.py b/tests/unit/scripts/checks/test_palette_colors.py index 0d51b8a72..2056eb0d4 100644 --- a/tests/unit/scripts/checks/test_palette_colors.py +++ b/tests/unit/scripts/checks/test_palette_colors.py @@ -5,7 +5,7 @@ from sampletones_application.paths import PALETTES_DIRECTORY from sampletones_shared.meta.source.modules import SourceModule, source_paths -from sampletones_shared.paths import CONFIG_DIRECTORY +from sampletones_shared.paths.resources import CONFIG_DIRECTORY from scripts.checks.palette_colors import dpg_module_helper from tests.suite.scripts import load_script from tests.suite.source import parse_source diff --git a/tests/unit/scripts/checks/test_tag_names.py b/tests/unit/scripts/checks/test_tag_names.py index a79a294fa..3b03f4fcb 100644 --- a/tests/unit/scripts/checks/test_tag_names.py +++ b/tests/unit/scripts/checks/test_tag_names.py @@ -7,7 +7,7 @@ from sampletones_application.categories.hierarchy import Page, Panel, Widget from sampletones_application.categories.key.tag import TagName from sampletones_shared.meta.source.modules import SourceModule, source_paths -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT from tests.suite.base import BaseTestSuite from tests.suite.case import BaseRegularTestCase from tests.suite.scripts import load_script diff --git a/tests/unit/scripts/checks/test_unused_tags.py b/tests/unit/scripts/checks/test_unused_tags.py index 4d8456f06..167e4a90a 100644 --- a/tests/unit/scripts/checks/test_unused_tags.py +++ b/tests/unit/scripts/checks/test_unused_tags.py @@ -4,7 +4,7 @@ import pytest from sampletones_shared.meta.source.modules import SourceModule, source_paths -from sampletones_shared.paths import SOURCE_ROOT +from sampletones_shared.paths.source import SOURCE_ROOT from tests.suite.scripts import load_script from tests.suite.source import parse_source diff --git a/uv.lock b/uv.lock index d8dc1be31..ac66c6208 100644 --- a/uv.lock +++ b/uv.lock @@ -1236,6 +1236,77 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/16/12b82f791c7f50ddec566873d5bdd245baa1491bac11d15ffb98aecc8f8b/pefile-2024.8.26-py3-none-any.whl", hash = "sha256:76f8b485dcd3b1bb8166f1128d395fa3d87af26360c2358fb75b80019b957c6f", size = 74766, upload-time = "2024-08-26T21:01:02.632Z" }, ] +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, +] + [[package]] name = "platformdirs" version = "4.9.6" @@ -1754,6 +1825,9 @@ gpu-cuda11 = [ ] [package.dev-dependencies] +assets = [ + { name = "pillow" }, +] dev = [ { name = "black" }, { name = "isort" }, @@ -1796,6 +1870,7 @@ requires-dist = [ provides-extras = ["build", "gpu", "gpu-cuda11"] [package.metadata.requires-dev] +assets = [{ name = "pillow", specifier = ">=11,<13" }] dev = [ { name = "black", specifier = "==26.5.1" }, { name = "isort", specifier = "==8.0.1" }, From a2f2eadd5682ecd770abf63d65d6d6cfd660bb1b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 16:58:12 +0200 Subject: [PATCH 122/152] Moved: icon generation into the sampletones_assets --- .github/workflows/workflow.yml | 5 +- .gitignore | 11 +- CHANGELOG.md | 1 + docs/development/dependencies.md | 15 +- pyproject.toml | 9 + scripts/assets/icons.py | 333 +----------------- src/sampletones_assets/mark/__init__.py | 0 src/sampletones_assets/mark/geometry.py | 128 +++++++ src/sampletones_assets/mark/mark.yaml | 43 +++ src/sampletones_assets/mark/paths.py | 7 + src/sampletones_assets/mark/raster.py | 116 ++++++ .../mark/specification/__init__.py | 38 ++ .../mark/specification/colors.py | 29 ++ .../mark/specification/frame.py | 43 +++ .../mark/specification/point.py | 16 + .../mark/specification/render.py | 28 ++ .../mark/specification/waves.py | 59 ++++ src/sampletones_assets/mark/suite.py | 67 ++++ src/sampletones_assets/mark/template.svg | 12 + src/sampletones_assets/mark/vector.py | 71 ++++ src/sampletones_shared/paths/resources.py | 1 + tests/unit/sampletones_assets/__init__.py | 0 .../unit/sampletones_assets/mark/__init__.py | 0 .../sampletones_assets/mark/test_geometry.py | 69 ++++ .../sampletones_assets/mark/test_raster.py | 47 +++ .../mark/test_specification.py | 176 +++++++++ .../sampletones_assets/mark/test_suite.py | 55 +++ .../sampletones_assets/mark/test_vector.py | 47 +++ uv.lock | 2 + 29 files changed, 1090 insertions(+), 338 deletions(-) create mode 100644 src/sampletones_assets/mark/__init__.py create mode 100644 src/sampletones_assets/mark/geometry.py create mode 100644 src/sampletones_assets/mark/mark.yaml create mode 100644 src/sampletones_assets/mark/paths.py create mode 100644 src/sampletones_assets/mark/raster.py create mode 100644 src/sampletones_assets/mark/specification/__init__.py create mode 100644 src/sampletones_assets/mark/specification/colors.py create mode 100644 src/sampletones_assets/mark/specification/frame.py create mode 100644 src/sampletones_assets/mark/specification/point.py create mode 100644 src/sampletones_assets/mark/specification/render.py create mode 100644 src/sampletones_assets/mark/specification/waves.py create mode 100644 src/sampletones_assets/mark/suite.py create mode 100644 src/sampletones_assets/mark/template.svg create mode 100644 src/sampletones_assets/mark/vector.py create mode 100644 tests/unit/sampletones_assets/__init__.py create mode 100644 tests/unit/sampletones_assets/mark/__init__.py create mode 100644 tests/unit/sampletones_assets/mark/test_geometry.py create mode 100644 tests/unit/sampletones_assets/mark/test_raster.py create mode 100644 tests/unit/sampletones_assets/mark/test_specification.py create mode 100644 tests/unit/sampletones_assets/mark/test_suite.py create mode 100644 tests/unit/sampletones_assets/mark/test_vector.py diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 2e5e7fb81..8663dcc89 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -37,8 +37,11 @@ jobs: --tag "$GITHUB_REF_NAME" \ --project-version "$(uv version --short)" + - name: Install PortAudio + run: sudo apt-get update && sudo apt-get install -y portaudio19-dev + - name: Generate the icon suite - run: uv run --only-group assets python scripts/assets/icons.py + run: uv run --group assets python scripts/assets/icons.py - name: Build sdist and wheel run: uv build diff --git a/.gitignore b/.gitignore index 02a45caf1..5622d69b2 100644 --- a/.gitignore +++ b/.gitignore @@ -4,15 +4,15 @@ __pycache__/ .ipynb_checkpoints/ .mypy_cache/ .pytest_cache/ +.ruff_cache/ .venv/ .venv-build/ .vscode/ -bin/ -build/ dist/ wheels/ -!scripts/**/build/ +/bin/ +/build/ sampletones !src/sampletones @@ -21,11 +21,6 @@ sampletones src/sampletones_assets/icons/sampletones.ico src/sampletones_assets/icons/sampletones.png -**/*.idea -**/*.vscode/** -**/*.ipynb_checkpoints/** -**/*__pycache__/** - *.pyc *.pyo *.coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 7114660cd..70f9edbdd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ * Improved Sequencer module playback. * Added song export to WAV/MP3. * Added tracker selection operations. +* Added a _SampleToNES_ logo. ## v0.3.0 [2026-07-31] diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index 1107c943a..d6b4035c7 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -53,12 +53,15 @@ Dialogs open through the XDG desktop portal (`org.freedesktop.portal.FileChooser ## Application icon -The icon suite in `src/sampletones_assets/icons` is generated: `scripts/assets/icons.py` holds the -mark's geometry and writes the vector `sampletones.svg` together with the rasters the application -ships, `sampletones.png` and the multi-resolution `sampletones.ico`. Rasterization uses Pillow, -declared in the `assets` dependency group. The SVG is committed as the design source, and the -rasters are produced where they are consumed: `make setup` writes them before packaging the wheel, -and the bundle scripts write them before PyInstaller embeds them. +The icon suite in `src/sampletones_assets/icons` is generated from the mark declared beside it in +`src/sampletones_assets/mark`: `mark.yaml` carries the geometry, colours and rasterization +settings, validated as a `Mark`, and `template.svg` is the vector the rendered geometry fills. The +package writes the whole suite — the vector `sampletones.svg` and the rasters the application +ships, `sampletones.png` and the multi-resolution `sampletones.ico` — and `scripts/assets/icons.py` +points it at the directory the icons are shipped from. Rasterization uses Pillow, declared in the +`assets` dependency group. The vector is committed, so the mark reads as a picture in a browser or +an editor, and the rasters are produced where they are consumed: `make setup` writes them before +packaging the wheel, and the bundle scripts write them before PyInstaller embeds them. ## Linux (standalone executable) diff --git a/pyproject.toml b/pyproject.toml index d5d306021..8e54812e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,6 +77,7 @@ gpu-cuda11 = [ [dependency-groups] assets = ["pillow>=11,<13"] dev = [ + { include-group = "assets" }, "black==26.5.1", "isort==8.0.1", "mypy==2.1.0", @@ -96,6 +97,12 @@ build-backend = "hatchling.build" [tool.uv] conflicts = [[{ extra = "gpu" }, { extra = "gpu-cuda11" }]] +[tool.hatch.build] +artifacts = [ + "src/sampletones_assets/icons/sampletones.png", + "src/sampletones_assets/icons/sampletones.ico", +] + [tool.hatch.build.targets.wheel] packages = [ "src/sampletones", @@ -130,6 +137,7 @@ addopts = "--import-mode=importlib" [tool.coverage.run] source = [ "sampletones_application", + "sampletones_assets", "sampletones_core", "sampletones_shared", "sampletones_synthesis", @@ -144,6 +152,7 @@ python_version = "3.12" files = [ "src/sampletones", "src/sampletones_application", + "src/sampletones_assets", "src/sampletones_core", "src/sampletones_shared", "src/sampletones_synthesis", diff --git a/scripts/assets/icons.py b/scripts/assets/icons.py index 65a99a35d..c6484eb94 100755 --- a/scripts/assets/icons.py +++ b/scripts/assets/icons.py @@ -1,345 +1,32 @@ #!/usr/bin/env python3 """ -Builds the application icon suite into `src/sampletones_assets/icons`. +Writes the application icon suite from the packaged mark definition. -One geometry definition on a 64-unit grid draws the mark — a smooth sample entering as a -blue sine wave and leaving as an amber square wave, on the studio palette — and every -shipped icon derives from it: the vector `sampletones.svg`, the raster `sampletones.png`, -and the multi-resolution `sampletones.ico`. The raster filenames match the resources the -application resolves through `sampletones_shared/paths`. +The mark, its template and the code drawing them live in `sampletones_assets/mark`; this +script points them at the directory the icons are shipped from. Usage: python scripts/assets/icons.py # write the suite into src/sampletones_assets/icons """ import argparse -import itertools import sys from pathlib import Path -from typing import Final, List, Sequence, Tuple +from typing import Final, Sequence -from PIL import ( # TODO: update THIRD-PARTY-* files, revise LICENSE if still holds - Image, - ImageDraw, -) +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.suite import write_icon_suite -Point = Tuple[float, float] -Rectangle = Tuple[float, float, float, float] +REPOSITORY_ROOT: Final[Path] = Path(__file__).resolve().parents[2] +ICONS_DIRECTORY: Final[Path] = REPOSITORY_ROOT / "src" / "sampletones_assets" / "icons" -PROJECT_ROOT: Final[Path] = Path(__file__).resolve().parents[2] -ICONS_DIRECTORY: Final[Path] = PROJECT_ROOT / "src" / "sampletones_assets" / "icons" -# TODO: take the raster filenames from sampletones_shared.paths.resources -VECTOR_FILENAME: Final[str] = "sampletones.svg" -UNIX_ICON_FILENAME: Final[str] = "sampletones.png" -WINDOWS_ICON_FILENAME: Final[str] = "sampletones.ico" - -# TODO: SVG configuration should be a YAML file based on a validated Pydantic class -# not a set hardcoded constants; I suggest a nested structure, organizing fields into -# logical units -GRID: Final[int] = 64 -CORNER_RADIUS: Final[float] = 14.0 -RIM_INSET: Final[float] = 1.0 -RIM_WIDTH: Final[float] = 2.0 -RIM_OPACITY: Final[float] = 0.14 -WAVE_WIDTH: Final[float] = 4.0 - -BACKGROUND_TOP: Final[str] = "#3a3650" -BACKGROUND_BOTTOM: Final[str] = "#211d30" -SINE_COLOR: Final[str] = "#64c8ff" -SQUARE_COLOR: Final[str] = "#ffc864" -RIM_COLOR: Final[str] = "#cdb6ff" - -SINE_START: Final[Point] = (8.0, 32.0) -SINE_CURVES: Final[Tuple[Tuple[Point, Point, Point], ...]] = ( - ((11.0, 16.0), (15.0, 16.0), (18.0, 32.0)), - ((21.0, 48.0), (25.0, 48.0), (28.0, 32.0)), -) -SQUARE_POINTS: Final[Tuple[Point, ...]] = ( - (28.0, 32.0), - (28.0, 20.0), - (38.0, 20.0), - (38.0, 44.0), - (48.0, 44.0), - (48.0, 20.0), - (56.0, 20.0), - (56.0, 32.0), -) - -SUPERSAMPLE: Final[int] = 16 -CURVE_SAMPLES: Final[int] = 96 -RASTER_SIZE: Final[int] = 256 -ICO_SIZES: Final[Tuple[int, ...]] = (256, 128, 64, 48, 32, 24, 16) - - -def _grid_number(value: float) -> str: - return f"{value:g}" - - -def _sine_path() -> str: - commands = [f"M{_grid_number(SINE_START[0])} {_grid_number(SINE_START[1])}"] - for curve in SINE_CURVES: - points = " ".join(f"{_grid_number(x)} {_grid_number(y)}" for x, y in curve) - commands.append(f"C{points}") - - return " ".join(commands) - - -def _square_path() -> str: - start_x, start_y = SQUARE_POINTS[0] - commands = [f"M{_grid_number(start_x)} {_grid_number(start_y)}"] - for (previous_x, _), (x, y) in itertools.pairwise(SQUARE_POINTS): - commands.append(f"V{_grid_number(y)}" if x == previous_x else f"H{_grid_number(x)}") - - return " ".join(commands) - - -# TODO: refactor - this should be a proper template as an asset, not hardcoded -def svg_document() -> str: - """The mark as a standalone vector, with coordinates on the even design grid. - - Grid alignment keeps the wave edges on whole pixels when the icon is rasterized - at 32 px and 16 px. - """ - rim_extent = _grid_number(GRID - 2 * RIM_INSET) - return ( - f'\n' - " \n" - ' \n' - f' \n' - f' \n' - " \n" - " \n" - f' \n' - f' \n' - f' \n' - f' \n' - "\n" - ) - - -def _background(canvas: int) -> Image.Image: - top = Image.new("RGB", (canvas, canvas), BACKGROUND_TOP) - bottom = Image.new("RGB", (canvas, canvas), BACKGROUND_BOTTOM) - blend = Image.linear_gradient("L").resize((canvas, canvas)) - shaded = Image.composite(bottom, top, blend) - - mask = Image.new("L", (canvas, canvas), 0) - ImageDraw.Draw(mask).rounded_rectangle( - (0, 0, canvas - 1, canvas - 1), - radius=CORNER_RADIUS * SUPERSAMPLE, - fill=255, - ) - - background = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) - background.paste(shaded, mask=mask) - return background - - -def _cubic_coordinate( - start: float, - control_one: float, - control_two: float, - end: float, - progress: float, -) -> float: - remainder = 1.0 - progress - return ( - remainder**3 * start - + 3 * remainder**2 * progress * control_one - + 3 * remainder * progress**2 * control_two - + progress**3 * end - ) - - -def _sine_points() -> List[Point]: - points: List[Point] = [SINE_START] - position = SINE_START - for control_one, control_two, end in SINE_CURVES: - for step in range(1, CURVE_SAMPLES + 1): - progress = step / CURVE_SAMPLES - points.append( - ( - _cubic_coordinate( - position[0], - control_one[0], - control_two[0], - end[0], - progress, - ), - _cubic_coordinate( - position[1], - control_one[1], - control_two[1], - end[1], - progress, - ), - ) - ) - position = end - - return points - - -def _draw_sine(draw: ImageDraw.ImageDraw) -> None: - """Sweeps a disk of the stroke's half width along the curve. - - The union of densely stamped disks equals a round-capped stroke of the curve and - keeps the outline smooth, where a single wide polyline call serrates its edges. - """ - radius = WAVE_WIDTH * SUPERSAMPLE / 2 - for x, y in _sine_points(): - center_x, center_y = x * SUPERSAMPLE, y * SUPERSAMPLE - draw.ellipse( - ( - center_x - radius, - center_y - radius, - center_x + radius, - center_y + radius, - ), - fill=SINE_COLOR, - ) - - -def _direction(delta: float) -> float: - if delta > 0: - return 1.0 - - if delta < 0: - return -1.0 - - return 0.0 - - -def _segment_rectangle( - start: Point, - end: Point, - *, - half_width: float, - joined_start: bool, - joined_end: bool, -) -> Rectangle: - """The stroke rectangle of one axis-aligned segment. - - A joined end reaches half the stroke width past its corner, so consecutive - rectangles fill their right-angle miter; an open end keeps a butt cap. - """ - direction_x = _direction(end[0] - start[0]) - direction_y = _direction(end[1] - start[1]) - start_reach = half_width if joined_start else 0.0 - end_reach = half_width if joined_end else 0.0 - - reached_start = ( - start[0] - direction_x * start_reach, - start[1] - direction_y * start_reach, - ) - reached_end = ( - end[0] + direction_x * end_reach, - end[1] + direction_y * end_reach, - ) - across_x = half_width * abs(direction_y) - across_y = half_width * abs(direction_x) - - return ( - min(reached_start[0], reached_end[0]) - across_x, - min(reached_start[1], reached_end[1]) - across_y, - max(reached_start[0], reached_end[0]) + across_x, - max(reached_start[1], reached_end[1]) + across_y, - ) - - -def _square_rectangles() -> List[Rectangle]: - final_segment = len(SQUARE_POINTS) - 2 - return [ - _segment_rectangle( - SQUARE_POINTS[index], - SQUARE_POINTS[index + 1], - half_width=WAVE_WIDTH / 2, - joined_start=index > 0, - joined_end=index < final_segment, - ) - for index in range(len(SQUARE_POINTS) - 1) - ] - - -def _draw_square(draw: ImageDraw.ImageDraw) -> None: - for left, top, right, bottom in _square_rectangles(): - draw.rectangle( - ( - round(left * SUPERSAMPLE), - round(top * SUPERSAMPLE), - round(right * SUPERSAMPLE) - 1, - round(bottom * SUPERSAMPLE) - 1, - ), - fill=SQUARE_COLOR, - ) - - -def _rgba(color: str, opacity: float) -> Tuple[int, int, int, int]: - red, green, blue = (int(color[start : start + 2], 16) for start in (1, 3, 5)) - return red, green, blue, round(opacity * 255) - - -def _rim_overlay(canvas: int) -> Image.Image: - overlay = Image.new("RGBA", (canvas, canvas), (0, 0, 0, 0)) - ImageDraw.Draw(overlay).rounded_rectangle( - (0, 0, canvas - 1, canvas - 1), - radius=CORNER_RADIUS * SUPERSAMPLE, - outline=_rgba(RIM_COLOR, RIM_OPACITY), - width=round(RIM_WIDTH * SUPERSAMPLE), - ) - return overlay - - -def render_master() -> Image.Image: - """The mark rasterized at a supersampled resolution, ready to scale down to each shipped size.""" - canvas = GRID * SUPERSAMPLE - image = _background(canvas) - draw = ImageDraw.Draw(image) - _draw_sine(draw) - _draw_square(draw) - image.alpha_composite(_rim_overlay(canvas)) - return image - - -def write_suite(directory: Path) -> List[Path]: - """Writes the vector, the raster, and the Windows icon into the directory.""" - directory.mkdir(parents=True, exist_ok=True) - master = render_master() - renders = {size: master.resize((size, size), Image.Resampling.LANCZOS) for size in ICO_SIZES} - - vector_path = directory / VECTOR_FILENAME - vector_path.write_text(svg_document(), encoding="utf-8") - - raster_path = directory / UNIX_ICON_FILENAME - renders[RASTER_SIZE].save(raster_path) - - windows_path = directory / WINDOWS_ICON_FILENAME - primary, *appended = (renders[size] for size in ICO_SIZES) - primary.save( - windows_path, - format="ICO", - sizes=[(size, size) for size in ICO_SIZES], - append_images=appended, - ) - - return [vector_path, raster_path, windows_path] - - -# TODO: this file should be only a thin layer, the rest of the code -# should belong to sampletones_assets -# Read guidelines and architecture docs, follow the current code philosophy def main(argv: Sequence[str]) -> int: """Writes the icon suite and reports each file it produced.""" parser = argparse.ArgumentParser( - description="Build the application icon suite from the mark's geometry.", + description="Write the application icon suite from the mark definition.", ) parser.add_argument( "--directory", @@ -349,7 +36,7 @@ def main(argv: Sequence[str]) -> int: ) arguments = parser.parse_args(list(argv)) - for path in write_suite(arguments.directory): + for path in write_icon_suite(arguments.directory, Mark.load()): print(f"Wrote {path}") return 0 diff --git a/src/sampletones_assets/mark/__init__.py b/src/sampletones_assets/mark/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/sampletones_assets/mark/geometry.py b/src/sampletones_assets/mark/geometry.py new file mode 100644 index 000000000..5fd855bdd --- /dev/null +++ b/src/sampletones_assets/mark/geometry.py @@ -0,0 +1,128 @@ +import itertools +from dataclasses import dataclass +from typing import List + +from sampletones_assets.mark.specification.point import CubicCurve, Point +from sampletones_assets.mark.specification.waves import MarkSine, MarkSquare + + +@dataclass(frozen=True) +class Rectangle: + """An axis-aligned box in grid units, the shape one segment of the stepped half fills.""" + + left: float + top: float + right: float + bottom: float + + +def _cubic_coordinate( + start: float, + control_start: float, + control_end: float, + end: float, + progress: float, +) -> float: + remainder = 1.0 - progress + return ( + remainder**3 * start + + 3 * remainder**2 * progress * control_start + + 3 * remainder * progress**2 * control_end + + progress**3 * end + ) + + +def _cubic_point( + start: Point, + curve: CubicCurve, + progress: float, +) -> Point: + return Point( + x=_cubic_coordinate(start.x, curve.control_start.x, curve.control_end.x, curve.end.x, progress), + y=_cubic_coordinate(start.y, curve.control_start.y, curve.control_end.y, curve.end.y, progress), + ) + + +def sine_points(sine: MarkSine, samples: int) -> List[Point]: + """The smooth half as a polyline, sampled evenly along every segment. + + Each segment contributes ``samples`` points, ending on its own end point, so the next + segment starts where the previous one arrived and the polyline runs unbroken from the + wave's start to its handover. + """ + points = [sine.start] + position = sine.start + for curve in sine.curves: + for step in range(1, samples + 1): + points.append(_cubic_point(position, curve, step / samples)) + + position = curve.end + + return points + + +def _direction(delta: float) -> float: + if delta > 0: + return 1.0 + + if delta < 0: + return -1.0 + + return 0.0 + + +def _segment_rectangle( + start: Point, + end: Point, + *, + half_width: float, + joined_start: bool, + joined_end: bool, +) -> Rectangle: + """The stroke rectangle of one axis-aligned segment. + + A joined end reaches half the stroke width past its corner, so consecutive rectangles + fill their right-angle miter; an open end keeps a butt cap. + """ + direction_x = _direction(end.x - start.x) + direction_y = _direction(end.y - start.y) + start_reach = half_width if joined_start else 0.0 + end_reach = half_width if joined_end else 0.0 + + reached_start = ( + start.x - direction_x * start_reach, + start.y - direction_y * start_reach, + ) + reached_end = ( + end.x + direction_x * end_reach, + end.y + direction_y * end_reach, + ) + across_x = half_width * abs(direction_y) + across_y = half_width * abs(direction_x) + + return Rectangle( + left=min(reached_start[0], reached_end[0]) - across_x, + top=min(reached_start[1], reached_end[1]) - across_y, + right=max(reached_start[0], reached_end[0]) + across_x, + bottom=max(reached_start[1], reached_end[1]) + across_y, + ) + + +def square_rectangles(square: MarkSquare, width: float) -> List[Rectangle]: + """The stepped half as filled rectangles, one per segment between its corners. + + The rectangles meet at every corner the wave turns at, so the sequence covers the + stroke a vector renderer draws with square joins. + """ + segments = list(itertools.pairwise(square.points)) + final_segment = len(segments) - 1 + return [ + _segment_rectangle( + start, + end, + half_width=width / 2, + joined_start=index > 0, + joined_end=index < final_segment, + ) + for index, (start, end) in enumerate(segments) + ] diff --git a/src/sampletones_assets/mark/mark.yaml b/src/sampletones_assets/mark/mark.yaml new file mode 100644 index 000000000..34c72068a --- /dev/null +++ b/src/sampletones_assets/mark/mark.yaml @@ -0,0 +1,43 @@ +frame: + grid: 64 + corner_radius: 14 + rim: + inset: 1 + width: 2 + opacity: 0.14 + +colors: + background: + top: "#3a3650" + bottom: "#211d30" + sine: "#64c8ff" + square: "#ffc864" + rim: "#cdb6ff" + +waves: + width: 4 + sine: + start: {x: 8, y: 32} + curves: + - control_start: {x: 11, y: 16} + control_end: {x: 15, y: 16} + end: {x: 18, y: 32} + - control_start: {x: 21, y: 48} + control_end: {x: 25, y: 48} + end: {x: 28, y: 32} + square: + points: + - {x: 28, y: 32} + - {x: 28, y: 20} + - {x: 38, y: 20} + - {x: 38, y: 44} + - {x: 48, y: 44} + - {x: 48, y: 20} + - {x: 56, y: 20} + - {x: 56, y: 32} + +render: + supersample: 16 + curve_samples: 96 + raster_size: 256 + windows_sizes: [256, 128, 64, 48, 32, 24, 16] diff --git a/src/sampletones_assets/mark/paths.py b/src/sampletones_assets/mark/paths.py new file mode 100644 index 000000000..4b9decd97 --- /dev/null +++ b/src/sampletones_assets/mark/paths.py @@ -0,0 +1,7 @@ +from importlib.resources import files +from pathlib import Path +from typing import Final + +MARK_DIRECTORY: Final[Path] = Path(str(files("sampletones_assets.mark"))) +MARK_PATH: Final[Path] = MARK_DIRECTORY / "mark.yaml" +TEMPLATE_PATH: Final[Path] = MARK_DIRECTORY / "template.svg" diff --git a/src/sampletones_assets/mark/raster.py b/src/sampletones_assets/mark/raster.py new file mode 100644 index 000000000..c9547af06 --- /dev/null +++ b/src/sampletones_assets/mark/raster.py @@ -0,0 +1,116 @@ +from typing import Final, Tuple + +from PIL import Image, ImageDraw # TODO: update THIRD-PARTY-* files, revise LICENSE if still holds + +from sampletones_assets.mark.geometry import sine_points, square_rectangles +from sampletones_assets.mark.specification import Mark +from sampletones_shared.types.application import ColorRGBA +from sampletones_shared.utils.color import parse_hex_color, with_alpha_fraction + +TRANSPARENT: Final[ColorRGBA] = (0, 0, 0, 0) +OPAQUE: Final[int] = 255 + + +class MarkRaster: + """Draws the mark into one supersampled image, ready to scale down to each shipped size. + + Drawing happens at the render factor times the design grid and the result is resampled + down, which is what keeps the curve edges and the rounded corners smooth at 16 px. + """ + + def __init__(self, mark: Mark) -> None: + self.mark = mark + + @property + def scale(self) -> int: + """Factor the design grid is drawn at.""" + return self.mark.render.supersample + + @property + def canvas(self) -> int: + """Edge length in pixels of the image the mark is drawn into.""" + return self.mark.frame.grid * self.scale + + @property + def corner_radius(self) -> float: + """Corner radius of the frame, in the pixels of the drawn image.""" + return self.mark.frame.corner_radius * self.scale + + @property + def frame_box(self) -> Tuple[int, int, int, int]: + """The whole image, as the box the frame and its rim are drawn in.""" + return (0, 0, self.canvas - 1, self.canvas - 1) + + def render(self) -> Image.Image: + """The mark drawn at the supersampled resolution, on a transparent ground.""" + image = self._background() + draw = ImageDraw.Draw(image) + self._draw_sine(draw) + self._draw_square(draw) + image.alpha_composite(self._rim()) + return image + + def _background(self) -> Image.Image: + """The frame: a vertical gradient between the two background colours, rounded at its corners.""" + size = (self.canvas, self.canvas) + top = Image.new("RGB", size, self.mark.colors.background.top) + bottom = Image.new("RGB", size, self.mark.colors.background.bottom) + shaded = Image.composite(bottom, top, Image.linear_gradient("L").resize(size)) + + background = Image.new("RGBA", size, TRANSPARENT) + background.paste(shaded, mask=self._frame_mask()) + return background + + def _frame_mask(self) -> Image.Image: + mask = Image.new("L", (self.canvas, self.canvas), 0) + ImageDraw.Draw(mask).rounded_rectangle( + self.frame_box, + radius=self.corner_radius, + fill=OPAQUE, + ) + return mask + + def _draw_sine(self, draw: ImageDraw.ImageDraw) -> None: + """Sweeps a disk of the stroke's half width along the curve. + + The union of densely stamped disks equals a round-capped stroke of the curve, which is + what holds the outline smooth along its whole sweep. + """ + radius = self.mark.waves.width * self.scale / 2 + for point in sine_points(self.mark.waves.sine, self.mark.render.curve_samples): + center_x, center_y = point.x * self.scale, point.y * self.scale + draw.ellipse( + ( + center_x - radius, + center_y - radius, + center_x + radius, + center_y + radius, + ), + fill=self.mark.colors.sine, + ) + + def _draw_square(self, draw: ImageDraw.ImageDraw) -> None: + for rectangle in square_rectangles(self.mark.waves.square, self.mark.waves.width): + draw.rectangle( + ( + round(rectangle.left * self.scale), + round(rectangle.top * self.scale), + round(rectangle.right * self.scale) - 1, + round(rectangle.bottom * self.scale) - 1, + ), + fill=self.mark.colors.square, + ) + + def _rim(self) -> Image.Image: + """The hairline along the frame's edge, as a layer to composite over the drawn mark.""" + overlay = Image.new("RGBA", (self.canvas, self.canvas), TRANSPARENT) + ImageDraw.Draw(overlay).rounded_rectangle( + self.frame_box, + radius=self.corner_radius, + outline=self._rim_color(), + width=round(self.mark.frame.rim.width * self.scale), + ) + return overlay + + def _rim_color(self) -> ColorRGBA: + return with_alpha_fraction(parse_hex_color(self.mark.colors.rim), self.mark.frame.rim.opacity) diff --git a/src/sampletones_assets/mark/specification/__init__.py b/src/sampletones_assets/mark/specification/__init__.py new file mode 100644 index 000000000..fa600fe11 --- /dev/null +++ b/src/sampletones_assets/mark/specification/__init__.py @@ -0,0 +1,38 @@ +from typing import Self + +from pydantic import BaseModel, Field + +from sampletones_assets.mark.paths import MARK_PATH +from sampletones_assets.mark.specification.colors import MarkColors +from sampletones_assets.mark.specification.frame import MarkFrame +from sampletones_assets.mark.specification.render import MarkRender +from sampletones_assets.mark.specification.waves import MarkWaves +from sampletones_shared.utils.serialization import load_yaml_model + + +class Mark(BaseModel, extra="forbid", frozen=True): + """The design definition of the application mark. + + Every shipped icon derives from this one definition — the vector, the raster the + application loads, and the multi-resolution Windows icon — so the mark is drawn from a + single source and stays the same shape at every size. Coordinates are written on the + frame's grid, which keeps the wave edges on whole pixels once the grid is scaled to an + icon size. + """ + + frame: MarkFrame = Field(description="The rounded square the mark sits on.") + colors: MarkColors = Field(description="The colours the mark is drawn in.") + waves: MarkWaves = Field(description="The wave crossing the frame.") + render: MarkRender = Field(description="How the mark is rasterized.") + + @classmethod + def load(cls) -> Self: + """Load the packaged mark definition. + + Returns: + The mark validated from `sampletones_assets/mark/mark.yaml`. + + Raises: + TypeError: If the definition file holds anything other than a mapping. + """ + return load_yaml_model(MARK_PATH, cls) diff --git a/src/sampletones_assets/mark/specification/colors.py b/src/sampletones_assets/mark/specification/colors.py new file mode 100644 index 000000000..1063ea2b7 --- /dev/null +++ b/src/sampletones_assets/mark/specification/colors.py @@ -0,0 +1,29 @@ +from typing import Annotated + +from pydantic import AfterValidator, BaseModel, Field + +from sampletones_shared.utils.color import parse_hex_color + + +def _validate_hex_color(value: str) -> str: + parse_hex_color(value) + return value + + +HexColor = Annotated[str, AfterValidator(_validate_hex_color)] + + +class MarkBackground(BaseModel, extra="forbid", frozen=True): + """The vertical gradient filling the frame.""" + + top: HexColor = Field(description="Colour at the top edge of the frame.") + bottom: HexColor = Field(description="Colour at the bottom edge of the frame.") + + +class MarkColors(BaseModel, extra="forbid", frozen=True): + """The mark's colours, written as the hex strings the vector carries.""" + + background: MarkBackground = Field(description="Gradient behind the wave.") + sine: HexColor = Field(description="Colour of the smooth half of the wave.") + square: HexColor = Field(description="Colour of the stepped half of the wave.") + rim: HexColor = Field(description="Colour of the hairline inside the frame's edge.") diff --git a/src/sampletones_assets/mark/specification/frame.py b/src/sampletones_assets/mark/specification/frame.py new file mode 100644 index 000000000..4c9111a16 --- /dev/null +++ b/src/sampletones_assets/mark/specification/frame.py @@ -0,0 +1,43 @@ +from typing import Self + +from pydantic import BaseModel, Field, PositiveFloat, PositiveInt, model_validator + + +class MarkRim(BaseModel, extra="forbid", frozen=True): + """The hairline drawn just inside the frame's edge, lifting it off a dark desktop.""" + + inset: PositiveFloat = Field(description="Distance the hairline keeps from the frame's edge.") + width: PositiveFloat = Field(description="Stroke width of the hairline.") + opacity: float = Field(gt=0.0, le=1.0, description="Share of full opacity the hairline is drawn at.") + + +class MarkFrame(BaseModel, extra="forbid", frozen=True): + """The rounded square the mark sits on. + + ``grid`` is the edge length every other coordinate is expressed in, so the whole design + follows from this one number and scales to any icon size. + """ + + grid: PositiveInt = Field(description="Edge length of the design grid.") + corner_radius: PositiveFloat = Field(description="Radius the frame's corners are rounded to.") + rim: MarkRim = Field(description="The hairline inside the frame's edge.") + + @property + def rim_radius(self) -> float: + """Corner radius the rim follows, keeping it concentric with the frame.""" + return self.corner_radius - self.rim.inset + + @property + def rim_extent(self) -> float: + """Edge length of the rim's square, inset on both sides.""" + return self.grid - 2 * self.rim.inset + + @model_validator(mode="after") + def _validate_rounding(self) -> Self: + if 2 * self.corner_radius > self.grid: + raise ValueError(f"The corner radius {self.corner_radius} must be at most half the grid {self.grid}") + + if self.rim.inset >= self.corner_radius: + raise ValueError(f"The rim inset {self.rim.inset} must stay inside the corner radius {self.corner_radius}") + + return self diff --git a/src/sampletones_assets/mark/specification/point.py b/src/sampletones_assets/mark/specification/point.py new file mode 100644 index 000000000..db8689f0f --- /dev/null +++ b/src/sampletones_assets/mark/specification/point.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, Field + + +class Point(BaseModel, extra="forbid", frozen=True): + """A position on the mark's design grid, in grid units.""" + + x: float = Field(description="Distance from the left edge of the grid.") + y: float = Field(description="Distance from the top edge of the grid.") + + +class CubicCurve(BaseModel, extra="forbid", frozen=True): + """One cubic Bézier segment, starting where the segment before it ended.""" + + control_start: Point = Field(description="Control point steering the segment away from its start.") + control_end: Point = Field(description="Control point steering the segment into its end.") + end: Point = Field(description="Point the segment reaches.") diff --git a/src/sampletones_assets/mark/specification/render.py b/src/sampletones_assets/mark/specification/render.py new file mode 100644 index 000000000..2e77b129f --- /dev/null +++ b/src/sampletones_assets/mark/specification/render.py @@ -0,0 +1,28 @@ +from typing import Tuple + +from pydantic import BaseModel, Field, PositiveInt, field_validator + + +class MarkRender(BaseModel, extra="forbid", frozen=True): + """How the mark is turned into pixels. + + Drawing happens at ``supersample`` times the design grid and the result is resampled + down to each shipped size, which is what keeps the curve edges and the rounded corners + smooth at 16 px. + """ + + supersample: PositiveInt = Field(description="Factor the design grid is drawn at before it is scaled down.") + curve_samples: PositiveInt = Field(description="Points each cubic segment of the smooth half is stamped along.") + raster_size: PositiveInt = Field(description="Edge length of the raster the application loads.") + windows_sizes: Tuple[PositiveInt, ...] = Field( + min_length=1, + description="Edge lengths the multi-resolution Windows icon carries.", + ) + + @field_validator("windows_sizes") + @classmethod + def _validate_windows_sizes(cls, windows_sizes: Tuple[int, ...]) -> Tuple[int, ...]: + if list(windows_sizes) != sorted(set(windows_sizes), reverse=True): + raise ValueError("Windows icon sizes must be listed once each, in descending order") + + return windows_sizes diff --git a/src/sampletones_assets/mark/specification/waves.py b/src/sampletones_assets/mark/specification/waves.py new file mode 100644 index 000000000..b97a1c35f --- /dev/null +++ b/src/sampletones_assets/mark/specification/waves.py @@ -0,0 +1,59 @@ +import itertools +from typing import Self, Tuple + +from pydantic import BaseModel, Field, PositiveFloat, model_validator + +from sampletones_assets.mark.specification.point import CubicCurve, Point + + +class MarkSine(BaseModel, extra="forbid", frozen=True): + """The smooth half of the wave, as cubic segments running on from the start point.""" + + start: Point = Field(description="Point the wave enters the frame at.") + curves: Tuple[CubicCurve, ...] = Field(min_length=1, description="Segments the wave follows, in drawing order.") + + @property + def end(self) -> Point: + """Point the last segment reaches, where the stepped half takes over.""" + return self.curves[-1].end + + +class MarkSquare(BaseModel, extra="forbid", frozen=True): + """The stepped half of the wave, as corners joined by axis-aligned segments.""" + + points: Tuple[Point, ...] = Field(min_length=2, description="Corners the wave turns at, in drawing order.") + + @model_validator(mode="after") + def _validate_segments_run_along_one_axis(self) -> Self: + for start, end in itertools.pairwise(self.points): + if start.x != end.x and start.y != end.y: + raise ValueError( + f"A square wave segment runs along one axis, " + f"where ({start.x}, {start.y}) to ({end.x}, {end.y}) turns on both" + ) + + return self + + +class MarkWaves(BaseModel, extra="forbid", frozen=True): + """The single wave the mark carries: one sample entering smooth and leaving stepped. + + Both halves are stroked at the same width, which is what reads them as one continuous + wave crossing the frame. + """ + + width: PositiveFloat = Field(description="Stroke width both halves of the wave are drawn at.") + sine: MarkSine = Field(description="The smooth half, entering from the left.") + square: MarkSquare = Field(description="The stepped half, leaving to the right.") + + @model_validator(mode="after") + def _validate_the_halves_meet(self) -> Self: + handover = self.square.points[0] + if handover != self.sine.end: + raise ValueError( + f"The stepped half starts where the smooth half ends, " + f"where it starts at ({handover.x}, {handover.y}) " + f"and the smooth half ends at ({self.sine.end.x}, {self.sine.end.y})" + ) + + return self diff --git a/src/sampletones_assets/mark/suite.py b/src/sampletones_assets/mark/suite.py new file mode 100644 index 000000000..a464b50a0 --- /dev/null +++ b/src/sampletones_assets/mark/suite.py @@ -0,0 +1,67 @@ +from pathlib import Path +from typing import List, Tuple + +from PIL import Image + +from sampletones_assets.mark.raster import MarkRaster +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.vector import render_vector +from sampletones_shared.paths.resources import ( + ICON_UNIX_FILENAME, + ICON_VECTOR_FILENAME, + ICON_WIN_FILENAME, +) + + +def _resized(master: Image.Image, size: int) -> Image.Image: + return master.resize((size, size), Image.Resampling.LANCZOS) + + +def _write_vector(path: Path, mark: Mark) -> Path: + path.write_text(render_vector(mark), encoding="utf-8") + return path + + +def _write_raster(path: Path, master: Image.Image, size: int) -> Path: + _resized(master, size).save(path) + return path + + +def _write_windows_icon( + path: Path, + master: Image.Image, + sizes: Tuple[int, ...], +) -> Path: + """Writes the multi-resolution icon, rendering one frame per declared size. + + Every frame is resampled from the supersampled master, so a 16 px frame carries the + detail the design grid puts there. + """ + primary, *appended = (_resized(master, size) for size in sizes) + primary.save( + path, + format="ICO", + sizes=[(size, size) for size in sizes], + append_images=appended, + ) + return path + + +def write_icon_suite(directory: Path, mark: Mark) -> List[Path]: + """Writes the vector, the raster and the Windows icon the application ships. + + Args: + directory (Path): Directory receiving the icon files, created where it is missing. + mark (Mark): Design definition every file is drawn from. + + Returns: + List[Path]: The files written, in the order they were produced. + """ + directory.mkdir(parents=True, exist_ok=True) + master = MarkRaster(mark).render() + + return [ + _write_vector(directory / ICON_VECTOR_FILENAME, mark), + _write_raster(directory / ICON_UNIX_FILENAME, master, mark.render.raster_size), + _write_windows_icon(directory / ICON_WIN_FILENAME, master, mark.render.windows_sizes), + ] diff --git a/src/sampletones_assets/mark/template.svg b/src/sampletones_assets/mark/template.svg new file mode 100644 index 000000000..3be72f01e --- /dev/null +++ b/src/sampletones_assets/mark/template.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/sampletones_assets/mark/vector.py b/src/sampletones_assets/mark/vector.py new file mode 100644 index 000000000..f9ba412c9 --- /dev/null +++ b/src/sampletones_assets/mark/vector.py @@ -0,0 +1,71 @@ +import itertools +from string import Template +from typing import Dict + +from sampletones_assets.mark.paths import TEMPLATE_PATH +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.specification.point import Point +from sampletones_assets.mark.specification.waves import MarkSine, MarkSquare + + +def _number(value: float) -> str: + return f"{value:g}" + + +def _coordinates(point: Point) -> str: + return f"{_number(point.x)} {_number(point.y)}" + + +def _sine_path(sine: MarkSine) -> str: + commands = [f"M{_coordinates(sine.start)}"] + for curve in sine.curves: + controls = f"{_coordinates(curve.control_start)} {_coordinates(curve.control_end)}" + commands.append(f"C{controls} {_coordinates(curve.end)}") + + return " ".join(commands) + + +def _square_path(square: MarkSquare) -> str: + """The stepped half as vertical and horizontal commands, one per segment. + + Each segment turns on a single axis, so it is written as the one coordinate it moves + along and the renderer holds the other. + """ + commands = [f"M{_coordinates(square.points[0])}"] + for previous, point in itertools.pairwise(square.points): + commands.append(f"V{_number(point.y)}" if point.x == previous.x else f"H{_number(point.x)}") + + return " ".join(commands) + + +def _placeholders(mark: Mark) -> Dict[str, str]: + return { + "grid": _number(mark.frame.grid), + "corner_radius": _number(mark.frame.corner_radius), + "background_top": mark.colors.background.top, + "background_bottom": mark.colors.background.bottom, + "sine_path": _sine_path(mark.waves.sine), + "sine_color": mark.colors.sine, + "square_path": _square_path(mark.waves.square), + "square_color": mark.colors.square, + "wave_width": _number(mark.waves.width), + "rim_inset": _number(mark.frame.rim.inset), + "rim_extent": _number(mark.frame.rim_extent), + "rim_radius": _number(mark.frame.rim_radius), + "rim_color": mark.colors.rim, + "rim_opacity": _number(mark.frame.rim.opacity), + "rim_width": _number(mark.frame.rim.width), + } + + +def render_vector(mark: Mark) -> str: + """The mark as a standalone vector, filling the packaged template with its own geometry. + + Coordinates stay on the design grid, which keeps the wave edges on whole pixels when the + icon is rasterized at 32 px and 16 px. + + Raises: + KeyError: If the template names a placeholder the mark leaves unfilled. + """ + template = Template(TEMPLATE_PATH.read_text(encoding="utf-8")) + return template.substitute(_placeholders(mark)) diff --git a/src/sampletones_shared/paths/resources.py b/src/sampletones_shared/paths/resources.py index fa596d931..9da9292dd 100644 --- a/src/sampletones_shared/paths/resources.py +++ b/src/sampletones_shared/paths/resources.py @@ -14,6 +14,7 @@ ICON_DIRECTORY: Final[str] = "icons" ICON_WIN_FILENAME: Final[str] = "sampletones.ico" ICON_UNIX_FILENAME: Final[str] = "sampletones.png" +ICON_VECTOR_FILENAME: Final[str] = "sampletones.svg" FONT_DIRECTORY: Final[str] = "fonts" FONT_SANS_REGULAR: Final[str] = "SourceSans3-Regular.ttf" diff --git a/tests/unit/sampletones_assets/__init__.py b/tests/unit/sampletones_assets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_assets/mark/__init__.py b/tests/unit/sampletones_assets/mark/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_assets/mark/test_geometry.py b/tests/unit/sampletones_assets/mark/test_geometry.py new file mode 100644 index 000000000..78279edb4 --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_geometry.py @@ -0,0 +1,69 @@ +import itertools +from typing import Final + +import pytest + +from sampletones_assets.mark.geometry import Rectangle, sine_points, square_rectangles +from sampletones_assets.mark.specification import Mark + +SAMPLES: Final[int] = 5 + + +def _overlap(first: Rectangle, second: Rectangle) -> float: + width = min(first.right, second.right) - max(first.left, second.left) + height = min(first.bottom, second.bottom) - max(first.top, second.top) + return min(width, height) + + +class TestSinePoints: + def test_the_polyline_runs_from_the_start_to_the_handover(self) -> None: + sine = Mark.load().waves.sine + points = sine_points(sine, SAMPLES) + + assert points[0] == sine.start + assert points[-1].x == pytest.approx(sine.end.x) + assert points[-1].y == pytest.approx(sine.end.y) + + def test_every_segment_contributes_its_samples(self) -> None: + sine = Mark.load().waves.sine + assert len(sine_points(sine, SAMPLES)) == len(sine.curves) * SAMPLES + 1 + + def test_the_polyline_stays_within_the_curve_the_definition_draws(self) -> None: + """The wave swings between the extremes its control points reach, keeping it inside the frame.""" + sine = Mark.load().waves.sine + controls = [sine.start] + [ + point for curve in sine.curves for point in (curve.control_start, curve.control_end, curve.end) + ] + lowest = min(point.y for point in controls) + highest = max(point.y for point in controls) + + for point in sine_points(sine, SAMPLES): + assert lowest <= point.y <= highest + + +class TestSquareRectangles: + def test_one_rectangle_covers_each_segment(self) -> None: + square = Mark.load().waves.square + assert len(square_rectangles(square, width=4.0)) == len(square.points) - 1 + + def test_every_rectangle_reads_left_to_right_and_top_to_bottom(self) -> None: + square = Mark.load().waves.square + for rectangle in square_rectangles(square, width=4.0): + assert rectangle.left < rectangle.right + assert rectangle.top < rectangle.bottom + + def test_a_segment_carries_the_stroke_width_across_its_run(self) -> None: + width = 4.0 + square = Mark.load().waves.square + for (start, end), rectangle in zip( + itertools.pairwise(square.points), + square_rectangles(square, width=width), + ): + across = rectangle.bottom - rectangle.top if start.y == end.y else rectangle.right - rectangle.left + assert across == pytest.approx(width) + + def test_consecutive_rectangles_meet_at_the_corner_they_turn_on(self) -> None: + """Overlapping rectangles fill the right-angle miter, so the stepped half draws as one stroke.""" + square = Mark.load().waves.square + for first, second in itertools.pairwise(square_rectangles(square, width=4.0)): + assert _overlap(first, second) > 0.0 diff --git a/tests/unit/sampletones_assets/mark/test_raster.py b/tests/unit/sampletones_assets/mark/test_raster.py new file mode 100644 index 000000000..5ae61ddf3 --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_raster.py @@ -0,0 +1,47 @@ +from typing import Final, Tuple + +import pytest + +from sampletones_assets.mark.raster import MarkRaster +from sampletones_assets.mark.specification import Mark +from sampletones_shared.utils.color import parse_hex_color + +CORNER: Final[Tuple[int, int]] = (0, 0) +ALPHA: Final[int] = 3 +CHANNELS: Final[int] = 3 + + +@pytest.fixture(name="mark", scope="module") +def mark_fixture() -> Mark: + return Mark.load() + + +class TestMarkRaster: + def test_the_image_covers_the_supersampled_grid(self, mark: Mark) -> None: + image = MarkRaster(mark).render() + edge = mark.frame.grid * mark.render.supersample + assert image.size == (edge, edge) + + def test_the_image_corner_stays_clear_of_the_rounded_frame(self, mark: Mark) -> None: + image = MarkRaster(mark).render() + assert image.getpixel(CORNER)[ALPHA] == 0 + + def test_the_frame_centre_carries_the_background(self, mark: Mark) -> None: + """The frame reaches the top edge between its rounded corners, so the ground there is opaque.""" + image = MarkRaster(mark).render() + centre = image.size[0] // 2 + assert image.getpixel((centre, 1))[ALPHA] == 255 + + def test_the_smooth_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None: + image = MarkRaster(mark).render() + scale = mark.render.supersample + start = mark.waves.sine.start + pixel = image.getpixel((round(start.x * scale), round(start.y * scale))) + assert pixel[:CHANNELS] == parse_hex_color(mark.colors.sine)[:CHANNELS] + + def test_the_stepped_half_is_drawn_in_its_own_colour(self, mark: Mark) -> None: + image = MarkRaster(mark).render() + scale = mark.render.supersample + corner = mark.waves.square.points[1] + pixel = image.getpixel((round(corner.x * scale), round(corner.y * scale))) + assert pixel[:CHANNELS] == parse_hex_color(mark.colors.square)[:CHANNELS] diff --git a/tests/unit/sampletones_assets/mark/test_specification.py b/tests/unit/sampletones_assets/mark/test_specification.py new file mode 100644 index 000000000..a3f51d363 --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_specification.py @@ -0,0 +1,176 @@ +from dataclasses import dataclass +from typing import Any, Dict, Final + +import pytest +from pydantic import ValidationError + +from sampletones_assets.mark.specification import Mark +from tests.suite.case import BaseRegularTestCase + +VALID_FRAME: Final[Dict[str, Any]] = { + "grid": 64, + "corner_radius": 14, + "rim": {"inset": 1, "width": 2, "opacity": 0.14}, +} + +VALID_COLORS: Final[Dict[str, Any]] = { + "background": {"top": "#3a3650", "bottom": "#211d30"}, + "sine": "#64c8ff", + "square": "#ffc864", + "rim": "#cdb6ff", +} + +VALID_SINE: Final[Dict[str, Any]] = { + "start": {"x": 8, "y": 32}, + "curves": [ + { + "control_start": {"x": 11, "y": 16}, + "control_end": {"x": 15, "y": 16}, + "end": {"x": 18, "y": 32}, + }, + ], +} + +VALID_SQUARE: Final[Dict[str, Any]] = { + "points": [ + {"x": 18, "y": 32}, + {"x": 18, "y": 20}, + {"x": 28, "y": 20}, + ], +} + +VALID_WAVES: Final[Dict[str, Any]] = { + "width": 4, + "sine": VALID_SINE, + "square": VALID_SQUARE, +} + +VALID_RENDER: Final[Dict[str, Any]] = { + "supersample": 16, + "curve_samples": 96, + "raster_size": 256, + "windows_sizes": [256, 128, 64], +} + +VALID_FIELDS: Final[Dict[str, Any]] = { + "frame": VALID_FRAME, + "colors": VALID_COLORS, + "waves": VALID_WAVES, + "render": VALID_RENDER, +} + + +class TestMark: + @dataclass(frozen=True, kw_only=True) + class InvalidFieldCase(BaseRegularTestCase): + field: str + value: Any + + test_cases = ( + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "grid": 0}, + label="empty_grid", + ), + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "corner_radius": 33}, + label="corner_radius_over_half_the_grid", + ), + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "inset": 14}}, + label="rim_inset_outside_the_corner_radius", + ), + InvalidFieldCase( + field="frame", + value={**VALID_FRAME, "rim": {**VALID_FRAME["rim"], "opacity": 1.5}}, + label="rim_opacity_over_full", + ), + InvalidFieldCase( + field="colors", + value={**VALID_COLORS, "sine": "64c8ff"}, + label="color_without_a_hash", + ), + InvalidFieldCase( + field="colors", + value={**VALID_COLORS, "sine": "#64c8"}, + label="color_of_four_hex_digits", + ), + InvalidFieldCase( + field="waves", + value={**VALID_WAVES, "width": 0}, + label="wave_without_width", + ), + InvalidFieldCase( + field="waves", + value={**VALID_WAVES, "sine": {**VALID_SINE, "curves": []}}, + label="smooth_half_without_curves", + ), + InvalidFieldCase( + field="waves", + value={**VALID_WAVES, "square": {"points": [{"x": 18, "y": 32}]}}, + label="stepped_half_without_a_segment", + ), + InvalidFieldCase( + field="waves", + value={ + **VALID_WAVES, + "square": {"points": [{"x": 18, "y": 32}, {"x": 28, "y": 20}]}, + }, + label="stepped_segment_turning_on_both_axes", + ), + InvalidFieldCase( + field="waves", + value={ + **VALID_WAVES, + "square": {"points": [{"x": 40, "y": 32}, {"x": 40, "y": 20}]}, + }, + label="halves_meeting_apart", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "supersample": 0}, + label="drawing_below_the_design_grid", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "windows_sizes": []}, + label="windows_icon_without_a_frame", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "windows_sizes": [64, 128, 256]}, + label="windows_sizes_in_ascending_order", + ), + InvalidFieldCase( + field="render", + value={**VALID_RENDER, "windows_sizes": [256, 256, 128]}, + label="repeated_windows_size", + ), + ) + + def test_the_packaged_definition_loads(self) -> None: + mark = Mark.load() + assert isinstance(mark, Mark) + + def test_the_sample_of_the_packaged_definition_leaves_as_it_entered(self) -> None: + """The mark draws one wave, so the stepped half carries on from where the smooth half arrives.""" + mark = Mark.load() + assert mark.waves.square.points[0] == mark.waves.sine.end + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_an_invalid_field_is_rejected(self, case: InvalidFieldCase) -> None: + fields = {**VALID_FIELDS, case.field: case.value} + with pytest.raises(ValidationError): + Mark.model_validate(fields) + + @pytest.mark.parametrize("field", sorted(VALID_FIELDS)) + def test_a_missing_field_is_rejected(self, field: str) -> None: + fields = {key: value for key, value in VALID_FIELDS.items() if key != field} + with pytest.raises(ValidationError): + Mark.model_validate(fields) + + def test_an_unknown_field_is_rejected(self) -> None: + with pytest.raises(ValidationError): + Mark.model_validate({**VALID_FIELDS, "shadow": {"blur": 4}}) diff --git a/tests/unit/sampletones_assets/mark/test_suite.py b/tests/unit/sampletones_assets/mark/test_suite.py new file mode 100644 index 000000000..b0a8f3e3c --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_suite.py @@ -0,0 +1,55 @@ +from pathlib import Path + +import pytest +from PIL import Image + +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.suite import write_icon_suite +from sampletones_shared.paths.resources import ( + ICON_UNIX_FILENAME, + ICON_VECTOR_FILENAME, + ICON_WIN_FILENAME, +) + +RGBA_MODE = "RGBA" +ICO_SIZES_KEY = "sizes" + + +@pytest.fixture(name="mark", scope="module") +def mark_fixture() -> Mark: + return Mark.load() + + +class TestWriteIconSuite: + def test_the_suite_holds_every_file_the_application_ships(self, tmp_path: Path, mark: Mark) -> None: + paths = write_icon_suite(tmp_path, mark) + assert [path.name for path in paths] == [ + ICON_VECTOR_FILENAME, + ICON_UNIX_FILENAME, + ICON_WIN_FILENAME, + ] + assert all(path.is_file() for path in paths) + + def test_the_directory_is_created_where_it_is_missing(self, tmp_path: Path, mark: Mark) -> None: + directory = tmp_path / "icons" + write_icon_suite(directory, mark) + assert directory.is_dir() + + def test_the_raster_is_the_size_the_definition_declares(self, tmp_path: Path, mark: Mark) -> None: + write_icon_suite(tmp_path, mark) + with Image.open(tmp_path / ICON_UNIX_FILENAME) as image: + assert image.size == (mark.render.raster_size, mark.render.raster_size) + assert image.mode == RGBA_MODE + + def test_the_windows_icon_carries_every_declared_size(self, tmp_path: Path, mark: Mark) -> None: + write_icon_suite(tmp_path, mark) + with Image.open(tmp_path / ICON_WIN_FILENAME) as image: + carried = {width for width, _ in image.info[ICO_SIZES_KEY]} + + assert carried == set(mark.render.windows_sizes) + + def test_the_same_definition_writes_the_same_files(self, tmp_path: Path, mark: Mark) -> None: + """One definition produces one suite, so a rebuild leaves the shipped files as they were.""" + first = write_icon_suite(tmp_path / "first", mark) + second = write_icon_suite(tmp_path / "second", mark) + assert [path.read_bytes() for path in first] == [path.read_bytes() for path in second] diff --git a/tests/unit/sampletones_assets/mark/test_vector.py b/tests/unit/sampletones_assets/mark/test_vector.py new file mode 100644 index 000000000..081cb682d --- /dev/null +++ b/tests/unit/sampletones_assets/mark/test_vector.py @@ -0,0 +1,47 @@ +from importlib.resources import files +from pathlib import Path + +from sampletones_assets.mark.specification import Mark +from sampletones_assets.mark.vector import render_vector +from sampletones_shared.paths.resources import ICON_VECTOR_FILENAME + +PLACEHOLDER_PREFIX = "$" +REPLACEMENT_COLOR = "#010203" + + +class TestRenderVector: + def test_the_shipped_vector_is_what_the_definition_renders(self) -> None: + """The committed vector is the mark's design source, so it stays in step with the definition.""" + shipped = Path(str(files("sampletones_assets.icons"))) / ICON_VECTOR_FILENAME + assert render_vector(Mark.load()) == shipped.read_text(encoding="utf-8") + + def test_the_template_is_filled_throughout(self) -> None: + assert PLACEHOLDER_PREFIX not in render_vector(Mark.load()) + + def test_every_colour_reaches_the_document(self) -> None: + mark = Mark.load() + document = render_vector(mark) + colors = ( + mark.colors.background.top, + mark.colors.background.bottom, + mark.colors.sine, + mark.colors.square, + mark.colors.rim, + ) + + for color in colors: + assert color in document + + def test_the_document_follows_the_definition(self) -> None: + """A colour changed in the definition is the colour the vector is drawn with.""" + mark = Mark.load() + recolored = mark.model_copy(update={"colors": mark.colors.model_copy(update={"sine": REPLACEMENT_COLOR})}) + document = render_vector(recolored) + + assert REPLACEMENT_COLOR in document + assert mark.colors.sine not in document + + def test_the_wave_starts_where_the_definition_places_it(self) -> None: + mark = Mark.load() + start = mark.waves.sine.start + assert f'd="M{start.x:g} {start.y:g}' in render_vector(mark) diff --git a/uv.lock b/uv.lock index ac66c6208..4699d98d5 100644 --- a/uv.lock +++ b/uv.lock @@ -1832,6 +1832,7 @@ dev = [ { name = "black" }, { name = "isort" }, { name = "mypy" }, + { name = "pillow" }, { name = "pre-commit" }, { name = "pylint" }, { name = "pylint-pydantic" }, @@ -1875,6 +1876,7 @@ dev = [ { name = "black", specifier = "==26.5.1" }, { name = "isort", specifier = "==8.0.1" }, { name = "mypy", specifier = "==2.1.0" }, + { name = "pillow", specifier = ">=11,<13" }, { name = "pre-commit", specifier = "==4.6.0" }, { name = "pylint", specifier = "==4.0.6" }, { name = "pylint-pydantic", specifier = "==0.4.1" }, From e9ef7b4804dcb7b15d90dc0e0a75898f80439123 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 17:22:11 +0200 Subject: [PATCH 123/152] Excluded: build-time Pillow from the standalone bundles --- LICENSE | 4 ++- THIRD-PARTY-NOTICES.md | 15 +++++++++++ docs/development/dependencies.md | 6 +++++ scripts/ci/checks/bundle.py | 21 ++++++++++++++- scripts/linux/build/build.sh | 1 + scripts/windows/build/build.bat | 1 + src/sampletones_assets/mark/raster.py | 2 +- tests/unit/scripts/ci/checks/test_bundle.py | 29 +++++++++++++++++++++ 8 files changed, 76 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index aa5410942..5918803cf 100644 --- a/LICENSE +++ b/LICENSE @@ -22,7 +22,9 @@ SOFTWARE. --- -The MIT license above covers the SampleToNES source code only. +The MIT license above covers the SampleToNES source code and the application +icons under `src/sampletones_assets/icons/`, which are drawn from the mark +declared in `src/sampletones_assets/mark/`. Font files bundled under `src/sampletones_assets/fonts/` are the work of third parties and remain under their own licenses (SIL Open Font License 1.1 and the diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 65d37c0fe..99cd24b32 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -109,3 +109,18 @@ The published bundles are **CPU-only**: CuPy, the CUDA runtime and the NVIDIA li are proprietary, and their EULA reserves redistribution to NVIDIA. GPU acceleration comes from installing _SampleToNES_ from PyPI with the `gpu` extra, which fetches CuPy and the CUDA components from their publishers straight to your machine. + +## Build-time tooling + +The application icons are drawn by `sampletones_assets.mark` and rasterized with +[Pillow](https://pypi.org/project/Pillow/), which is under the +[MIT-CMU license](https://github.com/python-pillow/Pillow/blob/main/LICENSE). Pillow belongs +to the `assets` dependency group alone, so `pip`/`uv` installs it on the machine that +generates the icons: it stays out of the wheel's dependency set, and PyInstaller is told to +leave it out of the bundles. Both distributions carry the finished icon files, so Pillow's +attribution clause — a condition on redistributing Pillow itself — rests with the build +environment. + +The icons (`sampletones.svg`, `sampletones.png` and the multi-resolution `sampletones.ico`) +are original _SampleToNES_ artwork and fall under the MIT License together with the rest of +the source. diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index d6b4035c7..9dd7da6ac 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -63,6 +63,12 @@ points it at the directory the icons are shipped from. Rasterization uses Pillow an editor, and the rasters are produced where they are consumed: `make setup` writes them before packaging the wheel, and the bundle scripts write them before PyInstaller embeds them. +Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that: +`pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is +installed, and PyInstaller follows that import into the bundle. The application reads its icons as +files, so the exclusion spares every bundle Pillow's extension modules and the imaging libraries +that come with them. `scripts/ci/checks/bundle.py` holds the release bundles to it. + ## Linux (standalone executable) Building a standalone executable on Linux needs the PortAudio, Tk and OpenGL/X11 system packages. Install them with `make system-deps` (or run `scripts/linux/build/dependencies.sh`), which holds the full list. diff --git a/scripts/ci/checks/bundle.py b/scripts/ci/checks/bundle.py index 843e3efd9..08fd64dbb 100644 --- a/scripts/ci/checks/bundle.py +++ b/scripts/ci/checks/bundle.py @@ -16,6 +16,9 @@ "THIRD-PARTY-LICENSES.txt", ) +INTERNAL_DIRECTORY: Final[str] = "_internal" +BUILD_TOOLS: Final[Sequence[str]] = ("PIL",) + def launcher_path(bundle: Path, *, system: str) -> Path: """The executable a built bundle offers on the platform it was built for.""" @@ -28,8 +31,19 @@ def missing_notices(bundle: Path) -> List[str]: return [name for name in REQUIRED_NOTICES if not (bundle / name).is_file()] +def carried_build_tools(bundle: Path) -> List[str]: + """The build-time packages found in a bundle, which the notices place on the build machine. + + A bundle carries the application and its runtime dependencies. Tooling that only draws the + assets belongs to the machine that builds it, so finding it here means the notices describe + a different set of components than the bundle ships. + """ + directories = (bundle, bundle / INTERNAL_DIRECTORY) + return [name for name in BUILD_TOOLS if any((directory / name).is_dir() for directory in directories)] + + def main(argv: Sequence[str]) -> int: - """Confirm a built bundle ships its notices and that its launcher starts.""" + """Confirm a built bundle ships its notices, holds to them, and that its launcher starts.""" parser = argparse.ArgumentParser( description="Verify a built bundle before it is archived.", ) @@ -46,6 +60,11 @@ def main(argv: Sequence[str]) -> int: print(f"::error::Bundle {bundle} is missing {', '.join(absent)}") return 1 + carried = carried_build_tools(bundle) + if carried: + print(f"::error::Bundle {bundle} carries build-time tooling its notices leave out: {', '.join(carried)}") + return 1 + launcher = launcher_path(bundle, system=platform.system()) if not launcher.is_file(): print(f"::error::Bundle {bundle} offers no launcher at {launcher}") diff --git a/scripts/linux/build/build.sh b/scripts/linux/build/build.sh index 1271b2027..3fd6327e8 100755 --- a/scripts/linux/build/build.sh +++ b/scripts/linux/build/build.sh @@ -45,6 +45,7 @@ echo "Building executable..." --add-data "src/sampletones_assets/fonts:assets/fonts" \ --add-data "src/sampletones_config:config" \ --copy-metadata sampletones \ + --exclude-module PIL \ "${RELEASE_HOOK_ARGS[@]}" \ "src/sampletones/__main__.py" diff --git a/scripts/windows/build/build.bat b/scripts/windows/build/build.bat index bbe075f4b..915dc4713 100644 --- a/scripts/windows/build/build.bat +++ b/scripts/windows/build/build.bat @@ -52,6 +52,7 @@ echo Building executable... --add-data "src\sampletones_assets\fonts;assets\fonts" ^ --add-data "src\sampletones_config;config" ^ --copy-metadata sampletones ^ + --exclude-module PIL ^ %RELEASE_HOOK% ^ "src\sampletones\__main__.py" || exit /b diff --git a/src/sampletones_assets/mark/raster.py b/src/sampletones_assets/mark/raster.py index c9547af06..7c437fe08 100644 --- a/src/sampletones_assets/mark/raster.py +++ b/src/sampletones_assets/mark/raster.py @@ -1,6 +1,6 @@ from typing import Final, Tuple -from PIL import Image, ImageDraw # TODO: update THIRD-PARTY-* files, revise LICENSE if still holds +from PIL import Image, ImageDraw from sampletones_assets.mark.geometry import sine_points, square_rectangles from sampletones_assets.mark.specification import Mark diff --git a/tests/unit/scripts/ci/checks/test_bundle.py b/tests/unit/scripts/ci/checks/test_bundle.py index 28793dfae..06e339dcb 100644 --- a/tests/unit/scripts/ci/checks/test_bundle.py +++ b/tests/unit/scripts/ci/checks/test_bundle.py @@ -111,6 +111,21 @@ def test_a_notice_directory_counts_as_absent(self, bundle: Path) -> None: assert check_bundle.missing_notices(bundle) == ["LICENSE"] +class TestCarriedBuildTools: + def test_an_application_bundle_holds_to_its_notices(self, bundle: Path) -> None: + assert check_bundle.carried_build_tools(bundle) == [] + + def test_a_build_tool_beside_the_application_is_reported(self, bundle: Path) -> None: + (bundle / check_bundle.INTERNAL_DIRECTORY / "PIL").mkdir(parents=True) + + assert check_bundle.carried_build_tools(bundle) == ["PIL"] + + def test_a_build_tool_beside_the_launcher_is_reported(self, bundle: Path) -> None: + (bundle / "PIL").mkdir() + + assert check_bundle.carried_build_tools(bundle) == ["PIL"] + + class TestMain: def test_a_complete_bundle_passes( self, @@ -137,6 +152,20 @@ def test_a_missing_notice_is_annotated_as_an_error( assert output.startswith("::error::") assert "THIRD-PARTY-NOTICES.md" in output + def test_bundled_build_tooling_is_annotated_as_an_error( + self, + bundle: Path, + capsys: pytest.CaptureFixture[str], + ) -> None: + _install_launcher(bundle) + (bundle / check_bundle.INTERNAL_DIRECTORY / "PIL").mkdir(parents=True) + + assert check_bundle.main([str(bundle)]) == 1 + + output = capsys.readouterr().out + assert output.startswith("::error::") + assert "PIL" in output + def test_a_missing_launcher_is_annotated_as_an_error( self, bundle: Path, From fafc6715f27f6914ebcdce5c3c7864754dd07180 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 18:21:49 +0200 Subject: [PATCH 124/152] Added: logo to README --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 9688052a6..c088a0076 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ [![Python](https://img.shields.io/pypi/pyversions/sampletones.svg)](https://pypi.org/project/sampletones/) [![License](https://img.shields.io/pypi/l/sampletones.svg)](https://github.com/JakimPL/SampleToNES/blob/main/LICENSE) +

+ SampleToNES +
+ ## Overview _SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/). From c14429b461783500aa66222aca321fd2669eb4ce Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 19:07:31 +0200 Subject: [PATCH 125/152] Tracked: the generated icon suite --- .github/workflows/ci.yml | 5 +++++ .github/workflows/workflow.yml | 6 ------ .gitignore | 3 --- README.md | 6 +++--- docs/development/dependencies.md | 9 ++++++--- pyproject.toml | 6 ------ src/sampletones_assets/icons/sampletones.ico | Bin 0 -> 32381 bytes src/sampletones_assets/icons/sampletones.png | Bin 0 -> 14609 bytes 8 files changed, 14 insertions(+), 21 deletions(-) create mode 100644 src/sampletones_assets/icons/sampletones.ico create mode 100644 src/sampletones_assets/icons/sampletones.png diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c8772a42..99ace251e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,11 @@ jobs: - name: Run every pre-commit hook run: uv run pre-commit run --all-files --show-diff-on-failure --color always + - name: Check the committed icons match the mark + run: | + uv run --group assets python scripts/assets/icons.py + git diff --exit-code -- src/sampletones_assets/icons + tests: name: Tests (${{ matrix.os }}, py${{ matrix.python }}) runs-on: ${{ matrix.os }} diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index 8663dcc89..f94d55538 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -37,12 +37,6 @@ jobs: --tag "$GITHUB_REF_NAME" \ --project-version "$(uv version --short)" - - name: Install PortAudio - run: sudo apt-get update && sudo apt-get install -y portaudio19-dev - - - name: Generate the icon suite - run: uv run --group assets python scripts/assets/icons.py - - name: Build sdist and wheel run: uv build diff --git a/.gitignore b/.gitignore index 5622d69b2..585ea73dc 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,6 @@ sampletones !src/sampletones !tests/sampletones -src/sampletones_assets/icons/sampletones.ico -src/sampletones_assets/icons/sampletones.png - *.pyc *.pyo *.coverage diff --git a/README.md b/README.md index c088a0076..9d505bfb6 100644 --- a/README.md +++ b/README.md @@ -4,15 +4,15 @@ [![Python](https://img.shields.io/pypi/pyversions/sampletones.svg)](https://pypi.org/project/sampletones/) [![License](https://img.shields.io/pypi/l/sampletones.svg)](https://github.com/JakimPL/SampleToNES/blob/main/LICENSE) -
- SampleToNES +
+ SampleToNES
## Overview _SampleToNES_ (`sampletones`) is a desktop tool for people writing music for the NES 2A03 sound chip, mainly in [_FamiTracker_](http://famitracker.com/). -SampleToNES +SampleToNES The core idea is to approximate an audio sample using only the chip's basic oscillators — two pulse channels, a triangle, and noise — **without any DPCM samples**. diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index 9dd7da6ac..924d7cce9 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -59,9 +59,12 @@ settings, validated as a `Mark`, and `template.svg` is the vector the rendered g package writes the whole suite — the vector `sampletones.svg` and the rasters the application ships, `sampletones.png` and the multi-resolution `sampletones.ico` — and `scripts/assets/icons.py` points it at the directory the icons are shipped from. Rasterization uses Pillow, declared in the -`assets` dependency group. The vector is committed, so the mark reads as a picture in a browser or -an editor, and the rasters are produced where they are consumed: `make setup` writes them before -packaging the wheel, and the bundle scripts write them before PyInstaller embeds them. +`assets` dependency group. + +The whole suite is committed, so a plain checkout carries the icons the application opens its window +with, and every wheel, bundle and test run finds them without a generation step. `make icons` writes +them again from the mark, and CI regenerates them on each change to confirm the committed files are +the ones the mark describes. Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that: `pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is diff --git a/pyproject.toml b/pyproject.toml index 8e54812e1..ac47fab5a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,12 +97,6 @@ build-backend = "hatchling.build" [tool.uv] conflicts = [[{ extra = "gpu" }, { extra = "gpu-cuda11" }]] -[tool.hatch.build] -artifacts = [ - "src/sampletones_assets/icons/sampletones.png", - "src/sampletones_assets/icons/sampletones.ico", -] - [tool.hatch.build.targets.wheel] packages = [ "src/sampletones", diff --git a/src/sampletones_assets/icons/sampletones.ico b/src/sampletones_assets/icons/sampletones.ico new file mode 100644 index 0000000000000000000000000000000000000000..5d37a4a25e8883ab82f4ac76d0ae472a7daddcbf GIT binary patch literal 32381 zcmag^Ra6~a(=~u@++ky31HlOp+}+*XU4mP1cXxMp3l@UA2X}Y3z{Xt;&-b3oGtS>P zU9)O-k2OZGlCD|+00aOI00aU)FJb^96ae7*DS<%$!;mllfd8kCnD~Eq6czvoga-f^ z8UKfq5CH&LGyp(Q@P8N*6#&@6{d~s%e>jQ&0NA1YPyX*B2B32R05)O(K!l>a1PUVF z=hFZbDM?Y~&-;HX0K$L1A3${TVFUm`&Pa(0sk&#oBu!ZBnt}%Xrd~EPyujl*^0QW~ zh`k{YQZ$KH*f47;Re=x%>DnQPjoRqQ^v-JK z#;az}Oa>2=D(5eFm@c~w zb8|ng7Wt>Eedt;?b`xh#E09`P`Bm2zGw}EC^viwW@ve~cl)+$M{Ro;Hh>0f&CKh@> z$3U-KuMi-0aL;qI-X>rDU}F}x86u>$!NcQj7XPM@;-FelHi1`a`ub|&%?9_?c+kAI zZR9Mvshrtr=tF*GI&O8TNOnRTq_On=ZtxoZ*4#$R-^ar*LPkqIwv#_ zS)o3V4_{oL@GWu<81ET9fKVDRQ7g~#ac?vL*=gYG?>YK5m-~Pw&@(osvPN70ADM~q z-3pJINlkZk|2d@L^?pa*v->?-%_Oua98KQ9+>CqoUkUvY!=2=*_$N7&_QInVwx6C9 z6&jlAg-Uia|jr| zPi)UcpVm&+l{!7CM-r&cc}22yF}4vs0ds!tae$PVyl9QEVc`D{Px#OGK>z0n+irZ1 z008vJ|2!clRi-@k2z}V^h8KL$p1pTaY)cDk61@PO__dssYxWOHFl`_?$udI=5*Fkj zylc{|AYMJb+XfaLYZfhxLxM2PAU&(A11eiO2(w6$Pd}+#f^X}slb1!SW9uklHU@S4 z@E`~G{pMq8oUB@XjEM6mvCYQyJyK;y7S2kvb~<$M3yae(k5u*~_i%2|$Ff)Pf|Un^ z``y90eTrCD?UKEO2h+g#1&f<0sW8Tp1$%0WQnt;8-|4>Vnn+h*2xG-yMty@98E)8jjyO`dL^(J^m| zu@{PP4YdI-#`Y>X((QLNvPw1mqb`N?ot#Hnu#P^v8L3Kj6*X}<=BN;w17R9@IyI@V z7GBV+ohGTG0)c!!2V)@K-lQkxyz+qRSwco(DW^knKgooP0*0qv=$8IKnM>^51d6ucqB&^M`56my<91NP6;M z->lcwZ?IzHhiptQn_eKS@?Z(qh96@@H9xB`AK#j=c^fJaGK5b#pQPe!{hsj{vonke zL=%%GHXrHUq73#DgNeGIDU3J)`Lrl%k2hgR9q;W?uQyw^*+GS08^sOZ|HT`0J7pl5 zhlTrF4Hk(6J5HC?yOLp^`3;o3`RzRqhiN_iyAd5z$B0Dk2ZA|0?EZEiw&`}IVoC&7 z7FLQ9dh#7yUeV{+^>`^RRQf|`|B$uGrr00aO&iwYWs_h|QS|#kK!Q)|uc)jLZHht? zE1rp4QBAt2x#&-h-eGdP@zX34f~M4*$`1PnN9|mfU0gylU2^vC$Vq2;LxTu+C<3NC zuF^-RrUPEGF04rEDEZOEhP19S^OZYQoS?gz5*PAnBmdAqoxBNU1HQq5A<>L}x2Vw?Y%~d@ zHZ_E1neUy-v%1_ZMaV=^z@_GQ-rws;*l2L8^OsR+JDU1=QuOxgV}iYgfM=w670gfG&PQLZuBYK!n}ibI^`nU*e*b!Ni5u?;6(=XHv--=gL7? ziP~D@+b{i;Lb1V+s9;yhpQojWBA3d(!0xRu2S!EMxE8vY{!E=0CqB>&1_wQ?8f*6F~ zpri+OgCq$N#YM}PDeuHY2iZ|Xl~>`a(1d%a2dr32zDKaj{kCgfo4A-9pPU@$J9_oz ze3+cHnT<5_`{@d9y=gys0KdFAK^wZdF6Q$4tP(w~r-9`WOZG?}`zK%Y%D(`1CMD6O zFs*BGO$=Nu!L1G5Zzl`7rO3eow<%LmYyziuUMe|vUna})-Ke-J0|vg7tK%g{mWbW$ z;F}w~jntZ(n^yzAdK_0NfUFJN{SNfnHrVW=*+?DBQsd$)t>F2S$JkyHt_YyPdAu*D zaS9_lA_l7=JK(B`659=ZO3f@y=dx}tt19)ShTcE#3Gc+ zM&nUgN~20g>4hRlju8!?A__%a_?V<5t~lHCUe18GDmqZ&5d{)+hSf`zihz_!w_o9C z4CQLx;BK^v$`J!sMcMKZDJQaR^A6bUBbHSBD3}@QkK}R35n&BphRPM5`wRT;s z_AcU8GW)%wt*FKd?do@>vwpWS_4B4t;A(Qp57bE-&uv;r|9Z+Z$9npuC=#8d97EKi zkE6$>KTYF=|NzA4Mb?~;lKRUn#9p>FT8|375e zj$r&7*ekkGS8c>dN~IKGi_$N;@gr2!{=hq2aB{8=T^~=x?ivW4W^9d8O6ZQYgxK*h zEU>K|Pyt6cxQGXx1}P1n&^>p}`#%0Ptji;4I4`4U?)obGwd0>(8G-x10TeL zd?5s(5fqL}CeE&QdE2Q@Rkx|>9E0uCDPKW1-+wGA}q#&vm{um!Ps`&zs6BlkL4NHB`9AQ1ne?0zfmZ|V~*g|!% z7+GX~{>8ncOja?5F+WC8ZURPVl0h=IQzG>vbiJ}}8L#OHS!`)2sK2yH0ZFR11h^4AV=Q(376sm3)-zwOS ztKF37W((0()Zb$&MHbdcY2l2$akdzmd8u$?3ndbD=cd84KK8Mjvyf*_8cZp88xM!a z^t8&kxrK~uA`jJm+pfNeNq68m9bMz<2Q2YENj}h!U>66)uHpRvn`%MhMg5R?ZF%T& ziS8)=)(bajpxbwf@9|CQ$GRVsFwD*uK19!*(7rV_65+t!Qr) ztB&Vp(GgH#MP&_f$D0th@ON#AL_=WZ-COFlhLfX*<9S0{6*|}G5*PcZ{%t!FT%TTr zmx0~`8s4yeHI0NJiz`|VUf?hoZVah^dr-<|1t{`+#(*2~rZ zm`^=q;)KhD!f`72J79I$ z!ev$x5x(=KHkncNL3j>(z7_j-U?|_uGeF^{Q5DLezDMq<#6#WNw$P!UUu>&J?yx#O zG}vF$)xPN~A!C~odKTTV)fhT#Q0(R|1eg1VlAgAH4_ugypfS8622!ldcX`=a1AV+d z#}>FC^i#E<@MusXcr8az@mro#ia#4d66RbhTpv86`1b)Wzk`*DWXX4R*J&k|KD~ey6J6>sHIcu{amfGC9;dc`a}NSj1BD4+NW0)gaC=DDCU8SO0| zO;U}fm{?c{9;=VWLHirMV{pXov_ArB?&hk*?kJ0T1UAO zalUU)ztNS|-|xF$*N2N3pF+duaqt?P+iZ_ISxiM3m@1e8YRMN2y`2%&Z0~Rnh`vm#Q;S!DteD2IcN+z~0i9sJ&fkYaU;y8?N!WViPkgcZK8e1~w{T z_*I4Rq9zzDMD2ZEF z8Df{C>VQLF*)LRy985Y4>cgB**q3$CchUTb^MAwWTyFYy%dE#XY?6W)0fN9 zHv`jQgI8=ajm{*)!oGn!qWypUTNGajBytQ~nY58H@niF>ZWy+*O{{41vpij+Vb(pI za*Em0i`v>!et_Q=zXBfC8-s$tRktCq9lvkaZX1*YXoWXzRX%R4C0*s9gt?ctV}`q? zUKwIZNGxFYmV5y9B&U2L*KIa3Y#6T5coY)INkySo38ZJqM$H-jVwFVL5(~Oa!4E9U zKuIyhG?x2|vjl)FG>)GSN zK6L#R?J9>2>9E)=@->BaMsO6#y`}j>W11l-_=~tHuTx!gGMVY%CVRE3s+SWeanz{s z^IVLPcyX5dhLm1yUc~f4|KBl)f9e@s)l>8jZove6Ml}ikw)X_>pF#y_?uxRRzm}|y z+!Yc(G>7O->)xB_?AhxU0MK}4d32GKwZLHKsZMcw-&l9gNDJdc_vdO>1J79A=<;;> z;o3*m+G9H6QKTF=Nq54sz4B+Z3^UU&+7-UJG?+!9_5w{kLBsTr@%PD|#(y0ZN(44% zx$`H{RjsqVcG1bD8HIXU^WK3v6$SJXf&ShO0T*ZHk~QhVzZ8Ae`0>gVFx=zhsG+@3 zr+=ye4)uKzOn%^UpOJazydS{-X%p*4?*&PlSXrVbGY=B^y#mY$+lX9h(v#orHt&=( zYjedR1@nCmAXKr_?!rr5l{Q+VdT82~DfnIxjf=V-IHrU}?u2mn3YP&k0CDG(^~?ij zO(L41E4vk*N<-u*hFoDErUZ!^PqZd&N_&n9gC}|$K4F4C1_B~(7hi`$^Rx1ftIt1X zA7Dpp)Sy+d!HykcVR;^L2xOTI0t#K}fnld>_sSk)`ns#8ubKD|K%}U$yg`>$!|}X2 zRAd@~@KLj2x>EAr4k~fM=s8Y)#ZT8alKIP?JZ%b|nyNH|Z-yTqgT=hp9 zjPajV;^Y|8NJ!&7+ib&PpqnG)t;YkZRqFNUJ?^|ddb5V}C^8TchH1As-|RnRk75Lf zl~iec$6U#J_QMBbwf0JZ7?HV2tu@+=&swYeS-g>N+om8ZJ*{!dQ~M82aQ4}Twz zlM!U&&%TUqos+657zTQKJCAaL3IAsYB${^CmOtO`FUuEI`d+I0zVLNr<90rjS28%? zp3|QBzyMiTSWM)zd9Q^t?=GZwD>HJ}O`{Xm?;fDm_S1iUR*K13@-urrk!bEwIZo^muc>HAf{j=(XVQNuayELy5L;QZcpbs)3_9I zz`FZot$h=QHwIHPV~d@WWkri!(R+mMhMPR^&EL!=+jq1NrcyDq>i6pZM@cI9X&3yz zlC;v&Ve@kU^Z#uxA7p)&qznI*q+q8>pUH6o6XP&Eo9KXnZ~n3}#*#lOEW;Bur>XI< zzFCqsBKmw&E>VA*R(Dh)B?m@WUlAC)3XjE7aiDny3{fqFpF?CPB9Y<~^Q$iU6g zNaW#vhcg&B)`(8$TZDB7JJ1h$p=DFfJ`jUU>ezcgNp~u5QWdkMTOt7 zMvbGtNpZ(n|GV%!Hu!k|sWIeR{dEH_@Aa;oA3LNMDgdQa`&@k0VnL454!@RgWJF9I zH0c2`aa3FgV!|pctWBoyuFynKPNN@?dTu3pAM^R(Er$;1S^MY>#Ep4Ja3f0t{sGVQC}&5^kmc-^z-pH0zfo7}mN`?zluzKssApl3hy zDQYzoL(Yhu}c4^`1}^}3IPhf|7xbv3U>wAbncv8uVj0k zFt&VWfJSrshktZ4;k6xJntdPlUM|wI?AmD7xzEu;jCR@@oXUJC?eJxtJbGX%5RgGz(s^SQpKuFoB5%0x2BO^<+0l7tI)@o{kaquWF#QdV{uTm?C-sX z?y31Z4yd}zzPaqji>)2JsPsDjSj9j|cI7#>u{Xc2kcdjLjb|fu>;c5PZ^K93u92*!%3c@}9ma7=~FcHUZG*zoi>TSij%uQ@DXi^+pM8ThH00*IXU;5_LU^CsC-nNVLJp9%blu!(@EQE@mBvF0RB9OP0{NfFL|*e z)4BxVlRgzQff8Y+KJngd(N>wqKloBbyd5;=u2!`3bD@Q_Yp(A;%;@E*vT;p$X~&PR zps*=HtYhx$15~}}?cH>lhx6f7@3`EQ3J{9RZ7^Hc&AHsJD(u|!LPQpe(6GdjPS1qP~xRA-Uvp8kMYo*eoYea}$$ zciO90txpM7Wjr?`WHrW12`qkzmt#a9$6s|sQsiDeWsgNfV01#J*X${wSfjk*Lck9( z(uA7fcX34+^v~&joKo>qC)CQFAB+Sg?lf`BM%f~t)wf7PrRU1UuTBmxd4?dvKqMXr zL=aBZ=a>&Ctjb0Fhb|JQMw`piz8?*pAavf4YLh_-9X_i| zI(#Ojev!{lrGdG*-|t_ydDU#q!*>wKKioE=?OTvc>T8|c0#;#7TiKj+gK zYmdW+Q6G2^jPHQhuHiUoWzEx)3jp(WAc-r#A6p*76{P##U>=UH!#+M z3bMXJz+zYv(p(c=)fAOwG(@NyAcqaAoP~!DOv(eFUdlKi@{>XPh2!%hiJ_m*`u2F`Yn@7;E1jq5s++u}1ykcA> zuq-QwlS8U;{W5; zFn_H{=6+tAL$2~s2NTXl9s?7*@v$`EKA=7O%zK_xYXaHB zV*`C)%X4L!=zw!+Df<+u_a_qji(ZDf=FTmC*~$3Z*LpqQprSVKKSmxzOGRU}#HOxH zG&gsDj-XQ03|pByZYp33CX>e{u<=@fG#F!CUP*1Zs1u>#G@UTLFJf zWq!1}_#uRzFc=-5SZEs4*CU7`+JaCZ(%O@}?h!Pa1}<)K!OtYONK{;Ji@84Fxnhzu zbdXcHc~p`GRVV)LYG6%u7j_`iddWy2UGhR*ji1InDYl9lk=p5kJlOr&QRf zS~~)TFsmF>!nEfZ^aMv7nNhH3vo!3I!EtR4LXASRzK6tck1?HPgZ$euQaYnbI=n3S z=_i9x=H|@~ZNW7mCiPtrLp=Lgc;gwzCB2~_M3t$ob@F=EIR68<9A`jN7s+bLFW$lm zb7r36QvxnQGJUzjwe{N(s6;mLcweTYsh@c_*L0Dv_*(vuE*ufkKQ+V8CGp1+kEkrU zNws354#$5smHJ9IG6Hgy@<@~ORriAYExx0wk*G{M`+iGV>KaV*{9A~tEx>AzQe>

d|?fcrrqntOKE>hFhlb7H}%3f9P&%^~xS-08dxO<7I|uf71RS zbzyo{NKmCj`}fz^(^&n*#dFW5lZ-qBXy(OC;}FE_CiNeI_;jZPO`2v`e{Zs%N63Oz zVDl&*=a0UTWHbMVX$d_Db!d;WD5@@XCWYcBh3`?HNSrd>%%JF zEgNTObonscFc6f~aSeciKq5v?e_v%JJPy_#%=&7bqQ4x{a3*~aU9mGT%Fbn)D-&W~tzs*;NZR9Zg;s(fbc&nv4fxJ-B)8B(gYFSRl>Q}kmpU-nu8r{<2UKwtWH?)XQ z_90s7ZZ|QXKm6a@4J#pz1F=;w$t+G8%IVIX0uIr~B86r`1o?(-4Xqb7^DG=4y2yC} zYIQ@NCcmWSZd#L5J#dF`B0m6XV&*i7yVlrh^~M;%3)wD>0Uo=`m?k$O$` zNgn8j&ioR4esx&}zj|Ow4UOoxB^!nnq`Sy*&&$*AI8`;k6xqZ9hNioT;qd~IFpPlO zC{TrZ>3ofQ?~ZYRaZHJJhB7)Cq=A6*u_EbzMSK_43h;k9FuwG7~V(nt4+-6B$V7 z_nM0u;09}_Buq99It8uTQ?;bGIDwa+AFH~}(htl#n^|@r$>=wNhW!=lWf}BrhjQ(6 z%$sfsA^ROUs2efp3+rn*(p}mWPs#;fi|Ms4PX!+K3&Pa(v;P&oY#-v=ZxKK{8dwzR zsPUb*l$^`Y4Lj&$I~ATz$zX?r8W*jwup2e0vW!(R&_#B4hv!tmp3ee=+Ha>eI6-M; z_A5@qI85Gy;VUN5gH^-kX^C5Ey+a}BLG=*g$eZo2*Q{IayXI)IZXRuMP_Umh4-3nE zszy?r=PLoJw`4Lwf13UO?+>t_-)IZ~AW;6_P~tqtD}H0pgc$B7=-- z(y(mPvAw+-y>#VC)0jow5vw<{T60}$wMQlG+U%Mm{m*5^VWna|)!JZEzDZbcGINl@ zh`6c_@}@2j8lFTj4n5k;+5F<{;f()f80;o-)579(^etxKtx}upt6t-#CPog|)2kY9}>E$;SMEg`Ssw}|4$Y}U*v5^H>_j&Lsp^CUr zZo!#B^VhYkhJjfSR1{;QO&Mw?7ey9_Mu&pj^b#}>I~az17^e5Nn98-+0Kme+u5WgF z@yUSnqQ3a|_6qT^vhG7%wC*i&w0iH%<#(G^x8tFAD8Tm?Rr1^Fi;0q-%cKhL=^!V8 zCDqjCW~#%(*@ld(q0~AQV*R}-E0(&Rw&ULak;@)U7<*c`Uu(`PnBg@Bw6lOHm+jkt0+EY~Q`kdAIU2FLF&U66pu<@2jsNPc8$Sn zSr(FB=T=PN`uUybg!xSyld+_?YzeziwSF&L+>g_$pp>x6LaIeg#3zgrVVp^EXq0j* zz>Ig7;aT0CGv)}LbMc{cc;3+u21i&ir^C9e_ zy*Ki$p6UMgG6#lG_K%|X@kdKM2LdvJp*#g>l>I{9&W*F5?4zana->;KB#)T-Z*cdo zdG-PVX}~`~|M~6qqgaD23&)uv@%2WzLwqLHGwjLeJaILiO5d3Lk*LiJ7p$8SXK4$> z01Ads@z<`;c7=I=HFAH(*W}Nw$HLno;(0ynm+17gwp*MU&*WFAUCPTOKKg)qv-AZ^ z?fB%hsA^ec8eoCZ8fZ;^H#~Yb0p4o4@}JZtm7m*!f@=RaJsl4oA$aNcuKB4$Rx>=8 zAuw93P!_}%I{!$o5Y4PaXRgxFBCZ>E)7odt*J|RIUUTTWa*{VXolVB?xa9NkcXd6N z+>+cMlWG1GduWu5AjL00Q0?SQOM%3|s%;zd$Z_&zXakM+_8ctN2&a3GyHalw=6fAv zaEy0n-i4|!qG%FLB99lHqL9Q*Jh`NCYUltQ8ZE0|EEyXBMnC8WE!FycvL8p92sYH1 z^BMwF?>=`2Pqx|Z&8NGa0*x60l~6Kzm$KQp4}`y~cu)o+klg=gnTz)I{dDsJE^Jx( zJ*jcR0`29sh&7gfuZA6|Gq)j4rwj`#d8juwm$mpqXFr% z7_14;=1#4zo@zwVOuIh9CES}v?=Z+@6i)NsKqjIKVY>9){GSvE0?@K4Onf`It5%Zl zEAx=vg-_v-Cd)?VzinZ$X+e$pu-qD7;mclaN)qznzgg#A_ulQNfCX(wb1dv8*4;K7 z^fM9JM)m|dF<07dB)mYyYB@z}R}y2{U6Z`1Yn@%~bNTH6XZNUAL{jR&(y=aT5?bn- z{2;byAaA25HhK_zWH{+H4Z!iN`lGO{dyLmUrgJ?^GzP@Q_Y51;vZ?i8K(46fsFdsS zmIh=kXOs8792TeSLow4+a@9_lAaQ3)#Pf5&a7*_?7ZRlia*75a^j2Z7bktDs=rMLI ztw?(7K9CL#IY2Ftqlo|8E)1xojKyDe9%clkHAF0Kor$hgkZec_7NB!*6Lv=dGq0NB z!NS83V}2LZWnVCfb76AW|d?jz7d*U>ZoQD)cEamJI4nhiir5Sj`04gqgg_w!y)AH$?8 zWZkfm{CRrdf))hvD@R_0CY$E@OQ|`#pI(*K7{!35@OsZuRZFg~ZUMl*c0qEqDBT+! z^I#hLo;y;|FHUGu48xJVZ>Z|Lth1KdMslSqSyHaL$8yM4kp`B>no;4OxL$rsO{1=-zxZ1=**84 zHbAkDB@3z_$*M+N*ZoO`kLuKKHh(Lt^`}wjPL4OoCGY3v8BSoLDwUb=uWzi7L%nQy z@3wd*WXWi}5p<~(F@q(u5EFZ9k_zNnVpTe&f3{+$YuJ6FO3ES*ULc_7y=*F?s`H)+ zWU)%d^NxDpnL^|JBS@QkPu=QDYMk+ogGsWTXmtRhv{I(oWBjiOkvi<#RlfR%3ciyd z?**0h-vR$=6Gu5o8`y@VO)6`PavWtkQ!GmUbIP`Q^gdJ1bQ5K1BO6}+9@*Nmb7w) zJUkz|!#6GxVM$&kgWUgg8eKOMqOE<2g^8;LI@m*F{7$NL^M>4It~e7NPA=_a&MUXo zKt4L&0GY5jlR_7KEx$3i%e%ptcZva!x88q<^S;NgcZpiY%ACKvl+4CJls&Slk7esfWe59Q`B=;>UU0k9t5y33)m5 zz$vL8V)~nHq@22O-By3)Yj|pB)uRy(Kf_|z6MAsrz&K@=v8{rY6#}h1z{e!0u!_qw z{RX4U3Z;8Ewtz&+kv5h#B#q{9XFXh?%K zx$gevI^#@(D}YbB96V5yb$hIf0)e{9tSZvA!CN2sqo*@CejeMLLP`rF*`gmmKvH_2Fw>hzk%COm|~~L)lUu8 zW`qIG@1*$-F?|ObW~bdDOA6R~hPM^N%2oV4&+ALn(foHH3ceJ>Rf$o7QxxR9eu0Z4MTMbBK0;@Eal=HY-3wy*|Q%K(a)gB_e@G*0FAP8L;Be#{ai4N ztp4l~%i5ROCZ6YyY{M&dLHKmxgbQ1a(kyBP%PCxLktDqN0KF)vxde=3a&zow9dg-^|+o2Of*Tfny@7C0X?2Di*` zRs2Y)hd$zSw)gQGGq6;YB^)yFj$IwmNT3qfSk(|KN&&?xk|&kcOiEpJ zjZ3|=Gw=HT@OVoj)!oUvsn+l@D^4xM7XA@wxiV=U)!xr|K$CR$6E4=D_sSI`cEQvB zpQOK^+{asdNvw%${P7{ukRkKymdn_T-6_ z?*pMI7~EG%)8IGt*jRe@5kUrbwJ`c?*}np4av=@Ct~0JS(etm92j9~n3xBB;E>6@-Ft60+!|NFEs)JZjAl(CVb#=-i61 zv9uXZ<6_!Na=r|JkYOIV7`gx5UH!c1I+A@}UE+D-bOi0dOn0P?^)t|kdFrUF$>;vz zTnsuQe{-+RGyIwTk1j;GR7~^d^3XXH{=oQC?|#Ls?GOyl=+p*^gcrE4sO_`c6uMr! z$kGc~SEjE}3^5r!Ga+)Dc2xO8mt&F2p0IMea=)C)fS8ZaD=i1&!z@XRDlH|oT5%Ox z;9Dxdf-E~0TrO}hQVm`@>cF5{7_0u_9?m{qD7hd~`l*40AXf2F(az5!oc-m<5Nij2 z?b`xJttDn93tPLgK^CgpkvXqn?|u^HPq>7mtl89><(V&xJ1BvZ-M=*}XBzR&D+1Ys zTQMG&!JkcMi5ur)!nrG}9=w7S9KGL7rKrvKtp3y(V)GL6Ob+`%d^-=0n0nA&;q8)C z;F)7|_bc_CiX7C>_ZjL*^gP^>t3w3GGZu(+;Y`>;(doOU^XS)eg+mT#F~vN^wr5ys zr37)9(h?9=Issu}+bNl|R=ogyH$_zppGl`swZArAplrAFo*hdoG2W z7QfNgQbuQJKK9NYJ=s)Fkp83%!X={#2sD#`@|_PB$IBQMyl`zxXli<)2P1|Hk_N4W z>6NL&a>;Rt#+nM(UN(`6HeA(9J1dxiSY+^6S~xvd^VaOD^tRub6#Qd9F^27X;3iCUSbKYNxJYk76+vx9bNnHZ%+r zBxlK6Ef>2HsfF+M&&*%C1nUv2{KcFJA#3L4p0?+BZ%X{6uC8g&O)3ceVy4S?+dNk{ z`{d63!>`g%|HM*b2x0!UU6!H|v0V8Hgj?$QiY;r`L=bO$NoX{_1 z=r7|g>EtuyYDVqRUf(?TQBmhX+5yZ4or>(-jE>3f!4XSUXNc#+$>ODMWjj~Of6?$X zFTGJ)aiDKtY-$g`QQo^3`By~w*tHCtHa`woF!!zQgM4IGt*ALg^belo!3m97rwtw! zokF&tqtm_W*c)m<61uR20eMC6mxR7Rihyzkg{6fhOuRX=;MwXuSiR3rGoL1r)MJBI zLQ(-uEn$$2T`&)kJ~;HJ+5&+P^8i=CF+PGUOh+8Cs+79p=s63Xe*`(h%o`s%Z`$ci zE^YA;{8S%GI(j1P`-i_TW_kVrbi1jUtxN>I8_9WQTmWQO9z}B&%gv zu8>yZ!|~6iwT!V<#N!s&r5=JU&(jU66W*7! znKG-kcD(7THwJ$|@tO063}~|5AQVxSy>qV%$}FQ7(akej@uq=wFq+*r_pFPIGHggv zSyL=LMD;;~N3b={Bd-W4Cbf$EkF-r<#XslhI^H+H!(R}~ zJu|Nm&bHp(xiD32{!4%*j{j1=;|oTWIFMw6?^BmP3g`MY+KMG5ZcfUG^7@J5?bzJx z*QBTJvo7!Oy7Y_8U*tYxb7XDEms0^YLIr_0P7z1@KX zh-YdRylYJ3a=O&A5i*-NAq_3A)P3&a8L?j$_3fvi_u0xJSIbEe%U(t9%LtlM_Bl7T zshQrq)Pc;TbZ*jF#9N%q5rQPKWPJVmuzZ=DWj;q7NRj*62ASsfr~&b}mS!51<->_x zj6mHV=N%W%TBocX2>A0rm`DyZM=ft{vmm@qCO-!iR6V_$-?*t=AZa41LNzT9{#6ku z@qA@%b$M&(8yL{sW!80=5`ZG6XUTf1O#iFM=bM>O<{L@pTAg{9m-~^|CEVOoVIoy{ z`_RHx2BUs3n*L2_4vO5WpAzJI`_gf4(k;!~^f!o7YN4A(JCx{^zi|h1|9P>Ix4#_4 z@z9h?|Ab!qeThg{ZaSnB84(n4Q5fQJfvEXwrUhit@b-ObF&fr?9`q-KbeIrt_ZrQ| zS=!OEy`N%Cp0L{CUjfNyJkuOxffda!Gf$}f*;}MPMVE?&R%ay!MUhwLcncHD=Pghp zQ`+i_6Zq_ZhVdUvDPl0Zo>06#m~IrzTUq%yPi5~Kf;@!OlP5GV6oXC;JdSq0P6uWdBJ=OX| zwD6{iz5k+fY5!tm_B{lKSK_FT1=KN|(uV7C%JhCg(_WX*D+()@_)A$`30n?B_D5a9 z4!G-gR6-eHe@YuAO83FE1OHZUWurjQmh~{&bg`i_JVxQBOZMZ{TX0J+O7~s+{*ud{ zH8Al{tV7_r%e+F|%?-l`e}CLNn-bVFP@%N3k(GP)VVWApzJ!UT+5Rq2-R#Cqt{T!z zn1ux7`|m|n-zS33%`e-<4~+A-wi{Wr9GU3ocJ3o>>A^egs)zx2S0fW7p;74DMBp){CAnflpa-paBJHm^pva7K%G&)Y5Mn?8@6hQ%<1j=R^BE>(WKzfdJO<0 zJ?ZIL7*PG(H(>5yKU*$bX5?IlKGny5yVW1wGTI*&IO2#tt0#CT#a+_4iKXl5m-((+ zOy&rapX5{9L3#O4D?m;cgqJT>Yvy;d?^mj-{q#P0#L21QQ_CkH2zP|_Uu(f+;t7IV zQ|`|-)DPHs)!bSp{JNl&zWo9z+3R&#{=dGi>3&znl{lSGb5yGq>cHY8J}-0;(Ncnm zDvN%Ej_-YDY74YmkPFl3!BNPSk>L~TE0v_ARQ7lwM(85cfWUg3VC(##Z#|l6`o{&8 zq_81IWR(nNMS3v3TFr;N0R*%}m5~iKTrlO~#FY%(V)s>}0(KWT67(FnIMcm@P~Y~> zf7tlv|MkTYeHRxlBt3Z(Qc50<%s6DY*zOC%7Qs4D!a|pYkAO|+X0qCQtK4Y`$6m;v z%>bW0vLZb-v7P7c~OTy zshDH~gp}H(I|=B1_L332>6L7jDiK9H1f~mYbo(io8XEQ>u6*hB+H?XxcZ`xS%@PR% zt$!s`4uNRm47tN-^y1-3rk5IWb8Gm$FRh}LP`I^mHr@v?AL0btaEqa!pzb3mFZ@(O zYPs=ofkdp+YNsT5XW7+GB*JX*QwJy1cR!rE-Ob60`ZV&~ci%bSU}0(VlPFoh#%a91 zb9;kUHD_EV6Kfq0R?$pS7*1CEoaPTe9?cg?50{hY2~HezVfaOD1pW?X8`+M%MRU9A z3$td()%kNz!%KhO*YSQ#{eJWRPhW2x7v&TEi$A;6F1aAx9g>15(%lLIA|bFe5`u)J z?9yF|gh+|fDJ8w6NF&nSB`n>rcR%0z`@LTGpL_qCGjrzInK@_bocEl?<0z(3)crCz zI_aU&F9k^1x}?C8TeGy>9;F^W4Tg6)UHt1DmX>}CKS0^w_SUe;h=?qmVVxRtjmI1o zzg5BzR6M7&oT5D$1oqdv$z>EldE~wZPIDR1>^=gPqHgLcz zGZ51g$Kd7Q;4tH5UiQ-gXjxD3f27(j;~3lUNaKStu6=B-UMBoxfZ^K%oJ`x5%ZQvE z@&_m9=88pAMJzTlfpduA`LtK%5B0^j z5RjAL{CC+#O_@P?GI?uzk0i1?-7gmjcYDLk5+iHtugV6aHVC};o1Hv@R=mq%-XyI0 z`3XlvKIlp0e6adS%)sfM54!rJh-CPRqrAAxzdt5gRU<0iMfD8>O8WM3s?Qg$I0L__ zo~~Y>%XtRKZYVJjUE5*R4M!+_3-jZ_nMZN-xh$v_>pda=z?o}Up}`az6QlT9uoSuAzi<4(NKEb7TSHZ zxf$=a`~xeIi4|vMp+52k#vzsIlyZtcVjjT{dW;#C-v_9R2OJ*WlHF0UR%*e|gN5e0 z50vl;2nMl!gvBcBoTj$F=e`QE)-E-p*P*cH;qc}MR?3M2*4}+2R7}@7f)6{7ZAG=E z%r@{yK3}clS(nyMEWej7-FmFbZ;c38LM2|xmqDuGpKd%m)f7UIocRwKXU4s zD#3$Ty4O5@k)U3DZEEg2*Y2t9t5!Vd;RujdsS@`XQ9{~To{ytL{Vq0IH<_iOW4_gC zcPYx7C=}AgFZtbma}#AzLqBc9OYs1Cy9&!}*bQr@IzFA5h+-C`h*OLn|?Q`s7sIqL`R7T8Dd z?{h$Sl-KZHWKmANWXG(Losq?fI|)V{KD|La91HQdsakvc|8~WKu*D$Ye_XMz+zagh zKw|3ubH%p!C%+o^+QiIne(~QO9oNx-aEg3LB8OB~c6Y+9EeVWa3?jy_H^s&HKVAm4 z{IkXUyIilj$r!jVdHM85&u^QfwBONH_D81tLs#9~60yK+`$Z7`@jD!fAMz`=iJ^&q zclL|RCuKJOEwY>&mTf^#`>turFts3$`joOq%`W*;6Hft5jPG&b?j)EdZQGeYR)Z;D0`k&su&9jnPr#MJ_K}LV>)zJiCp>H+L)4 zg8zg~HY~uanmWFG?4W+?33?+Z)WF$a8&MEIPA<9T|0IQJVEmKagom24A_U52^OLno zB`1U(G7SBSFfx-32yEIwU zQgN-%(Bp-$J%{#%M`m$$v{bIJ?BAxg`APYa zk&q!&1-`QzQLxc7GpCr=IWOsUG>U-Qd4>{|DB-4RD0Kd zBM0NmeTp|Q0d28vc#uaN8LIau!WO6hjLqumV=+5iWInScgL=&Jrx92cyH|+i4>8pT zSQ3a~NTF3B>a37H>SepW|LS(;q6yc0q3zD?6SB&ln&hFTtIuP^zFO?%>~&DTr^5lV z=MEeCKT%qIR9pamRX>P|V$En1Q!xj?pe2X$v}qL(qf3xFl6~@CP_Yi9Vm(FXHr%2QTReu=YXrY?oT`oG1`!|o-wYc-c zbr?>``L1Wcv^13IoteshTCaI2#jo&NRE(X#!b~}N>mIIca-T|a2t_GLn=6j1w}fPP zF^eW7Dl}DDG%0wK%P#Bia&b5%a>-vK*K56I4B;R*;@-RKwnc?bte!qUOx?d^rk!{=H|5h%xQ~@f_3>4{{;%QyN z(TJ_xgt|Y%BZPP&_g0$r?q#>=-#?ukD2*KrkKEZmjp~rmdvSnc_I9JidRlLu+ov!4 zIqnO(w9WUbnp{!p3}teNMY7hrIKwbPn#L}@M;YHoN1uCK4`yHgFj$b8ptp^!I}OWb zRnMVFe(Sy?!F{|sB8g0+vU@9$!l1nDQQ?LBeIxPg8Iu|puUb-AL$*KrhaLx~vgdlf z=W~I_o2}B4l8!&7{{;`X2&@f?@ng!Q0N49$XGCRMBI?4Q`nOk0+5DD0=5$f|&9v9pVF1rB|wj|&5=}z*mk-qP7 zO5e+s-Cyr`ypPz*YGVJjWZdnsk@uSK%i$SfrCgZjCJSRyObIToKDrlnc=P+_xA8So zMCWs&e(Kg#d4wHIueebY8N|9>(Bf`;#ea@j%92aC$K;?1GO$j}Zr;ZH96SDSPJ7P* z@Zdg&P!w*7a_2uond}zQe-Wl)U@W9)yUhhjB&QP~|B3xWMGjs+FQXZHr+*?{}7OGEfBzCQg;R zL4{zB5ABlW^Yr+DgR=U&CXd3eXB1K5Jq}s?O*)D4xyio_0z06somD^@-TQRVw~y&h zJ^^B;d{hr+t^e(wFCs7)ADUd};oYas4ob4p(%L|p))D&K$xzhF&9e)p_(w|0X?;35 zx_!gjavbcIv2}WWn{FC+OhCNNJM)eIz3yyn6<)5zOQD}#{AMyqJ$!bG0G}0!2LF1n|bh2O15Ejs7XytZSZVw+IQ?=S95!y6_ei-sNlPxWJ2(+Jg-?f zRQ{!#`;m{5Mc=a9!(lka5v%TEk|^T5WFAl0KcA(D^BV_QguDz9vd($BGp{o+Kp zN3v3z)zki<<6>i{mX{G<^oN}8SThErM@NU#GFh(Y>@<$*`9FOCZ7iH`66zP?+Sa4J zC1!}pb19OO__fD1iaCsnl4Ch9XQkxM8V2+7vL8%4*KNe?NJm=kD{rbm@G$OAB!cH0e=`p`6kel`UZ1!@R zLFR)T)KkQsM9wbe-o0OrB&cP#8ano%7dTEE0$)0bSNU}Con_DF3EJ-51S6J=(Xn6f zS)G2ITHyxc9)*12C*wf4=+aXNVGjGUEe@wcb!R zd4p?KP_;^KvUfgKm)KzBgx_zz4EeAdvEla4G<6{8WKXM&5l2G|`;x<2hYCnPJ5jQs ziyW(>B|}6+UZ;dD-Zv)~0ttb!-(w0%LV*WK{_(6IeDQ#B3JcNih)(@&Z7$vkp~LRj zZ7ZRxl9)>=#>_6y2N5>x#{3KsD8p;>`_}qli@-s_i*y>mZ8fe^-Z1gIMcoAQ()n8U zlQ5T1(0H@BzqHam;&#$3z9I4$rKqe-c<)^D@Z zB*~3|HyE^==ly|LW%j6`))%#TAWMlH-f8DXbOh$O5&#BQ9I*#PyGMmpn8&IdX`KyA zZW59N?20f0xb~o!WY<8CzPV=gPx?rTAayG#DCleT3;gF>W;GL;7akIgASV~vvXUzJ zy$2s*tWkSF4zd*KdmUnEA~I%-W(%#oqouA{vg^Pq;k<|d*i1n*2|qiD3Kotd2)_uC zH!RF}Bl(AD$;#V33 z8}!!*JDf7{;@Ue#Qndo55tB}n5PGVc-#=^Fm&ZiMNlnCJ-vqn zRv~Ngd(vi%gRqeci*?W1U&5dw+3(M?VJX+L9xTh)4m03Ktamy&&!Ie<0&IlBt4U)Q9b@ODhJP}Ik^s~3J!xrI^)6l&4 z#H!A3ww13|8${_O9gP_K9^xm(qALLashT%=-li<>Fh(P2PK|DT1LnUcqLsSt6`eItBzpbYk2hI;))DMqD$+e~K;MU6d-@tHkn4 zZX}u6gCrRFX$t_xc&gZ9SB`_yer#iDj{7UB|0hcyApJ$y!v{oII6JF9}<5~7xk z+9mLo+)CjFEdp}y(vhTUFU7L|HA-WdrbXLsid7i2q|!^w+eJmpQvCVC-(jK~-`~y1 z$eEP^>goV-$?3G13T`eX0QP=s?BP-wKq3OR$!O6vlR?CY$e9&knJUFcF8PoUa|~;xc2!#b3q0x_{v{-vck%vome~7dDgJuO?KP6VX_eyLL{%Vp`;96 z`oH$i35(G|WabekVHs=`+hdeSK^k43Re(FomvJ=#L+j^9SX}VfIel)~6o*~jDMyau z0CW4Rw$}#N$h+-{X&492C%B!pn21oJBkVV-J+w!i?@8eB((MJm0A0r z7BHl#T9Awu3FfSS$=meqj{0>VQs_Cr$)#;czpzB#ylL^CxkUEpK_w_yg9zqGBPfn6 z7eUF}jdA^4JICPi1YX^1zAS4~bvvT9X(Vu#L}=xZpyvIGIaSHQXP?ak|vlZRP*C(`S+X+v|J^xeYvpsyraYS$?e@cp! zYCa}^ezK%@E{%$lk2!hY=ygZ!6xg+0)iKnluDNx?DN57El7T?Wbu!Qf578yUSVw>Q z*IfB!iXGsGS6-gqiT%eQDF^-F{=3b7&*`09xR9SagWXwR*_GM+P3G76<9p^-@9Hdg z%wQA>vJofYgHt!-6$<&HA}S0}=K;P?6!{Kp`gG2BJClhEm2?WS1dxWcMeiYBqm7@J zB|1%;1sua*tO(Xc4f0vgw>M}dTTu~Pv5Mb|BQ4*Nd<=!!+>PFH&JAKN8G^Od zORrB{gS_k`aGBx_#~u(OLqmr)E3fkE;iqb@k!QDiEX*1p8JDW7yvP6L1PyI|RZ=DW zbyQ$|1WY;hdeZO8-e$mCE)E$cV!g8Oo?>&Hy|h~Ooyynav7M{&4wUQwLt2iWls4Yo zQ+G9D>A9wPEBs6mMJf1)VZwU+G#i;URQqtJGWn;(b%)XZ@fdj#$`Ct_auW+j{L4FS zl*o46_4p}-Js|CIWbB?xN+K_(yoAQ9@bDL0FHdh5y9s5Oh+Qzfht!W>S zb|9z(;g$0s(2^6^`S(}*RlKdH8^>6=%hiL61By)g8o@MgJGkFWhx@Ks5wSdMn_4 zy+4v7=~z|Lh8S-8ddamnNRq#%K#hDBvPQF$F5aC<+lW$>`WhQ)qdGP#GB)frr~qg% zA63${CtY}X3n;($@N8j#6`E!R1z7bpDiy|3Exo=8FRo%bX`$MagIKDV-lh&8+ByFb~A`Fen4+Z)s?UZ_vB$s3THCwl$a zm&@J!OJhAecy0LP@#?=3FlNT~S@3BdI=TcqqnPWQSD&CEzys3x z>w35uXqWK61kgR_LEfIynFwE`dfA?muMnJK6F>4nqK;wlPrPf9T-#l(jV~~;?AWxC zmraO~8j>juJHU%}W$wB7C>O4t&-#H6D_m>JMQP&Y7$ftW9ixeW6Ma^;M#Ya(jBkzT z@#JwQK+Wyq|BY&$sG3>ZB|KamSk<|ut$X#Q=*GHVC06d#I#T)K^G1YPB^7J+wx~~t z6H3xeEsUxE1J`pP7x^i%!GWkbm$@?D@=i?Y0{lJip2St2KN|&KWo_<@MD^vZgngCm zE!wfWz}6so6CVEZ8b+T5*%b(tUW0mqmtn z`LfW@JajWJ;6#M`T$&{Po6juJ4+|FY+!F>E;wY2S;>FCQeA02hcDqwb|$a* zVl-gRD2clZC8Pm-Lx0r<|69l5FP4ta46e~%dKa-{;D41;jcWOl6W-Ag5e{F9e;QX~j ze&Ly+eQ7gt_(w3U_o5NGT|xX6F$-u)q_yv;JoW|lN_r$=py3ipsUn8zWv7AFXUqq9 zU+nK}jDTpr{UkC0PIM%#eRpFovW~#}@Sq+8XFKHBjNojC+2=!A2x}D_#X^nLgnvJpf!sCYSE*+$v`v0OPXvIY)-H8fQx)L zyYCRpPq;;tSiI2%X-!x4(-a_-Il4712mi)P!w}N4%#!HyO$suo>Y)t!{-g9F+ei}S zF)1aAcq`aG&{Y)@A_bLr$+n4vqP`<_Y4Nrm;uBV|gr0o#8uY89{-g8t z;7WmV(Fdo)RhB2`GgX@3uqx+}F#+lQAwo9+2I{9EgHA;H)6{mBzOLcBW2ALL0GXuR zxGwerP9Z55_8-aq>>(>5!Ie_Fh|6>s4pV<*cP$XBa25QNmE09$@$@b|a8G_!6z-!QV<6tP^D7_lh?+e~8O`-F7^lnRRs)Lr5ifbc_nzV< z2_Ly#xUQ@wx;YB|1JHXgjE^giWmDw*03Tca#LN_r|NIq?Veb-e_dCmYVG3~mE5$1r z8n)`!V%lLD@fb0&Z#un9Q!z(X-mH2uaeJG2HA~nucKEPw*qTTsm+}WLL|BkX(=~KO zL<=z;85Q!}JJ&4Kn*1B@&)%D4JMQ*O{?2A;VUX5gZeKA1Mz`C7`YXcv3f(Y^0?#Gh zT#esWN;>o<^C(nGqE1S#J4J`zb_C5R+EU^RwNcmUxz7T>e!!no0%A()VFO&Is}UI7 zc6<~z5Ls}t@V#MK`UwtaL!XjMAcXG3{3bA!Y{`gPARZ4+thXx^n~f2M z)LA`$;_IwX)}*0s@QgX6Y)aoO^mC4%5Nqwe;{R~hSb&tr5iPkME8?2iZY!>L;YLT8 z6Zpn;V}@HvLZn%`l`8Vwt-#-Svsh5zg^?$gv}2bbMzd%B*I*dj3mBJ+4B{`3^15ql z#AN&2VHJdjHTg4^jEWHq)q`;Bz*jDLRxUwbuK=8vuW-iYASEG%s_G1zZtXQ5&Tj%T zF_)(mbtb_0E#m6tyu(7Aw(YbY2tLdkeLELM%u9I%zj2VB<#A-&ub@}Pa!AS{^LAgm zKG(&cqU|-nUxOMk4M3}1bmxZ7KSQb*-7KKGZ;ZeRM zNe!FhxN3VxHSf8)J2x!n!sz8tL574llUxbi0-?B{-{>$vO>LzU=Gt3efmRAo2}@ad za6^dybGl)ruHOWe#B4#WT5>?$5c$^Do0(!@%Ba_X>`J(e&@k`v$YKB{tLt+tp1dgI zS$u~02U3y2S&#|Es;atCd_#Xd8Gk`BalHf7;Uw50HsMQ_n>7VXcRArbz4eyPK<*IB^Oq z@fiww?6q%{{v|SKu=Zw#3Z`0#!_tpi6;UZ#5Okv+G zo}c)$3ll;j4WqHFswebT3T$15+Zf53VFK9qg+8162EbtJya|M?x9!GbYYtcakugrr z!q0EPLr+=H|Hwkfs-EG0MkdK(6geld$i~V)2KC6h1HnN^d~qKrEsWqm8t+rN;WPKy zPt*%+QQANICw|xK!j?15H;907Fry&B@{8gZ?UdsRJR2Qa=ec z16MXSY(sd7-`agpFMmSTU2lQIMV*r8wgF5#^xk>sYL|soa)~_e?6hG|gzD~_#dI|g z25cF#R{3PcF|iGxl!s6Ne2}Dqd;s866tbVW8IW3A24n3i%ByP=mSc>NRuW4 zv(&O}6T!&|H~n%Ed})=I>ec7*?3CDBS!L~VeDRCbd*H(4oCR-^yPxO%BMq8k1*~+` zA2{mW)rL?!(2C1^x5^t->kB~b|&c0Cx zmDO)4ioIdZ5Y#^hc^n$CULI{}4CRSuxy&f=T{lzj( zf{^dP`mu1PXbWRIP8iTj2RMttn&0zu4NHE}G7DzS$x&l$a%_9Q1@ExnGBDxoQokdY zr~$4B*goDj7yDBf{5EZm7giFe*g2{Adh%zZZGe`4?YRg{EHo9xzj(u;y)OkI)Pri{ z4Yg#CyWwSQ{+ymd2_c9(malgRK|;(peMWZINr`ZdsBvgy5_#`T$F3IKZ8xIB98}i( z0tT+r>sXo~Yv0v+XrYuurwBFRj_GatfoSL9W$Mp3%f>s9-r8n7!;GfBUHM(GM8eyf z9NB#HuE&#Fww=L%mpPC@4$~>j;SDFVX>8w@dmDv~V3grwdBm$X>VG(~)vQE>8%Otz z(@%JTepVC&Dvfi3ucW1(*@9IK&Z`>}f8AkDY)fx1TqHJEvlXvyp6aG^Mgsbxyv?`V z&!i9UG2ZRh=N{kFZMn5v9;_n~(1i5q|%Mp*&_O5WU<%-@` zIr4_Lz@Y1^=um2d5{g2`{ttEh0M^^`jZ&Sj`8Wj8F`7M>@V8+%Ny1(x64|#V5$)PW zYSyci@AuW?Y){9Miw~x)W9_&&w$x!Q>%fGr|1aFj?!@WAT5hOZr`yLx*-aZucwO5IEc7{Yf>ve z-T#Bz{&}Q9e;D-e^GZV&t z3Ms~Xs};?f2~uW0hu_MJ{&v#swN1P5t!BO!0 zv0cRq-zHo`qo(;;rLA76I^L>s8j^Gv{Jeyjd&(ENZHd1u85xB=1Brx$Dr0LspAy7A z{<;F+jnJbWnwr^At9G)O;B3ciXcgi>Odqi6*$siV(z2;4fMU8J?AEOtKg$mgL*QFD zEYMrJst?|f0cs_d3-uGz*Y3oOXS%!zn@QM}y+fz*w$0kyuo*1B1`%lSm-t7OC_fgg zgk6z>uyAfNTY*lQB9Ys7@uxrI8P(G??1VM!MDrzwK%2JcMQtrYSFT-ZS^ zDMu&8HDxI9!6`g(x`wONaw?W12~zh;(Qb^IbCNi0uiyGyH%+;e-Z`EsN*_QCj4l=7 z3=8JG1VW^9>{q|T$C~j_-16A#mBIp8)&kHc$ch zOqSM;g;_B@T8S|^K*uK7$SC$p9&yu7wje#DBqAV2>BT!UX4DM?WGck&jfHn8mIeR} zMn1PP>Y3hb&bqJF6FYHKsJ;y~!ORh4&M<4tFtMKeKFXtV-O5Rnf{7n|{%s%}w-3!6 zkID(WszE4 zf_NR7TCr9EVjY=BZa?`1f+iBgS#CL<2T1!k@lHIEMKtF>!8!5yZiKAC>1_9)+AMxl zmy;Gj+Iq*=4s3AE*n#l4Ugu`dg%rDSYTNXoMLB zG1c$KP^xg;#<{7U4mjdr#$c=D5UcUmt)Q%Tg3JVRuoP{xyXlzaF9+=WmOtErYX4&i zQP2N!iFSy_&-(ej!mQPS#uTEHD|#sOpU$MqH~Tc&LRe^juH3H_Qrqsf51_G8K7 z-WykQx6a@a6!wt#Jl%(;L*K!b3~4f{XuQ2@I)g?FAjOs|Gq-=+2hwAF+-y?LEdo#H zW|6%&=(d}ki`lrX3-o_w+i8Z<2l1am^#OYrGubmX%nlK+j`lA}X2#Fw)VHQWolBo4 zm1qid#L%7DI@Sk$yQ6LEqFYwl$BXx{d>mMISgRO@X|Q@rML~+$E^#x@?Se$ROG7QF(~q zul`$_Ecr`i=G9?Y{3La$<3(~JY<2R}ho#z#YWqvF5SA+3Slsqxx{``nP`dVTvO?Ce@?}bSnb`Kx?0?ZOI3a-i@}r#14lg5* zThb{#@;ZaA&%56LWmxXG4s=T4E^X<&1C{TkT7x3LC+NT1=$=r_|0B_``F70$!5~Ur zV6;{Ax^*cP{bf7Br(Jk4YN|1nPLwSXaqIGB?pVU+zD&uy-)46@!-XP$0QL|fXzq$C z4^hx$OJn>MRi&2Lur=9X{{F`kZy6{^zjCW(E1-1#P6Loft88+l)l@xW{80;oM%}Yc zU`?Zsn3VfFJ}Af0=Agx&R=b+jPgMZy=nB#W{wrAgb(wvo-K=LB834lT=|LPLKoI}C z?&)iso-jKy`afRc!5Gh=CAUsJ!Ghh?r>*~;5 zW1{;+p!|adA*xaz*JDW(w8`d)f3||Pg(dp0KRtQn19JY|Bjw$eSTJJJOUZWLmw_F2 zXs>~AX>Hk|M>q1x)fQfWEcwXntevRmPCPi<6K2cQuJcLcjGVgw--Uipst zop5=d(0Vk6I=Comgl;;FY%RTVqs~13N&;^Lu$*s|ZNBb3PxQZh+Dj?;X1---2XmC+ zbDo4&u)S)WIhM1 zaT@FKkH5#W3;o6bNk@B;i^{JHhfCIQ=+`*Z07XF(eiD=c-mzki+UmomJAHuC!d zb_lK{a-$P|f1J=Bg#Wzhr2;;A2u@sT>*X(zb@!W(A*5;~b7OyTodpea@IPK>^(?c@ z5xB~C?3@{c3{8D-C?XJUEZpZZB;y~qcYe0z9b=`Tp~y87Gue7dgWajvn`3b^CWyEg zh=e&FRGq6dpOv*4odmPQe+tqE20Ms95Z1lwT9CT{M@J;c=c{Oa0vL$Wsd<@YgIf>> zcvJ4=oD2>RcibLgH`E6ZvNcX}?aZz_%+ZUj08-IudM;EC)^6=78*z(SFM;Gg!NYMnd2muGehEplRgAr3A3-{xmIyzy5uj+GEe*Z`l@SbKqf9oTjRfzvQ1m$ zZ<_3&NhzR2YIwIzFDs_J0xB^)jNSIYLXYUp7XfYy#?Gv!w#dFNXdyFQ`1WUCle9dM zJZ0Mz+_Sk`7423D{0djV`Q!s)_-fMKzG$<)C3O}o0GpsUM+5n!`|ZQ6M+|9G^)z=m zxO~x?$QPg5={h=y>+qcg)yZ!ZqN3jWA68e~;A2_;YsQ41MytNqmj*?ldZ&ifXE=4h zBmDcH6l{+q&TQmjh_La+O0T<^)XQZEl#ylFVA2ei5OoM7fi(bXm*aV2)_k{zJ;=9U zgiE10dg3@yWT! z@yP&683rM2Gawa@p@0Zi%*tq9THvY*BVq@!dZAC2=YAw%pu^o9u}8XWkI(H+j}Ppw zd#t5ky(GQsCqL@J4%c3t9)T)$G1fc_pr*#-r}C_9Sc8)OePra)DG6$qMbUBZ5&(Vr z$fGNL^~m#$sN+FWxb8yfOyE3-JW)e%@hiiCe5*sFAruBJHTDuJe70Ye3nKD`ehzXcxzU_}bx+RciW+&LFh%cu7JYK8%PsYUN%u&QV#67q!7 zksW%(#hX-k$LkJ$*JfKgcm8|6@#=B3J`e+rz?g>XjEUZ8@)N#53_p>w+j5g=jJaH& z9LM#PNO@4sozuCTcJF`tum3O3>Yx9=XIhfz|L|SQ?6#Wp_Sk|Yu|a9sid}R!#fQLd z5{4qhhZFn!!&P&G4(kk_o@sJrS{E z53W6{1TT}YoBXn@jUFonu}AN;G`s>L@|qwqH5B9bl{>YSaXT2mOMgGa4r6_i557!(}ZGmKx;`ipWxIb8|PMqHlEml~} zThPWP9d+$j&;as7aXvM+X_-Xi3K}oQ{CL={jF6YUppDenT6*yrDG^f$ADc?^n|f?V zUMDIlsy|V~P3Sc$X}|gK+0IMEHpDvkuWv0oD3~);cSK>@7)+{0gY{Qs1+yCWgW>G# zPN!Au{si4qQz?v961-u!le#p)%4-nMENn{|;C4P)7Gea*^fF3{u9YP$(-*L+z=75< zENbt$#b2=nfb#}7p9tBHhebrNeATscs+jJ2UV*YllJkG{{zpoKC|@Rk^R6pFyx;UL zZ{df+bRC6CGNY!!%1uFA4|AZ+1+~y8AqfVC6-+_Q>~3K?L7u z85qO>cyg;wMvh|WXT5zsvM6Q32^Sp3`1r0LD(p$YkJa#5$-g~P=;nRT0>s9}(ScBQ zSSa|E^?8d;>3c&>jaM$tazu8lnSD9tGg!ohkt8J4^S-`E${wGJe+)#v-9d;<3vc3G5&jS`Z8Zze6{RD4Bm1!Fr~g>pW*>VJ$7zd zm8O<}DC(@qOLOq+!W)x2e@Cqf32eD}!X`|JNlR6Ae$-Dq%2a~>dT9LWrgy4E4fZ?W zMYJy#fGsQf4o!}~|MCxq-IrRDO6Mfa6O?y)5GgjI%vsR$?3P%fw`8XHkxoK~!MJ{Z-ao@4N0`83c|7xv)h>mBu3$kN#W|0S1iOU? z{~<$rqt&u+hB|xj$@sUUOort9sf%pLC<6cRfg-GiDR?0nHPGBg=KK=>s1;1&F7s$7!1t6k1_i>_t2uDr)$z-NzBf*_9c9zMDX z_n9X8T5y`+hbYQFMFWgbg8xuLMovl3Y!kJ=Ir`mgn%)M{%Ob~00r*g>;)JN57>>s} zqZDI&9YxmY_8>88=E<7yhk0yEa>BuBE_e=Qh@5XcPR1ZW2^!Yg%C<@5CP7VSr)seX zOD=2y)Jod^nqc7N<1A?rS8ASO4niLEl|oL5&KQC+>bAUCSpZ$@^G;?oiuKl+Je(9igd3+ z>ZNP>K9H<+!3ARH26Ux z4=dk2TgLt!ZkoJv&Fw#Zo!eW3wTldW)?MzCg!uT+d$aW=zq02~Ijm1ueD1TQ=Q@!l z&_U&b?pj|A@ol-)(AudY7K;lf7GLGd)f75+c-Vnb%M+W-8G z40dE#m}b4abNI>l){ADI2Z;zxf9!+jxYqKsY`E1x>|%mi$G3_;dUp<}^6B6;9*yK# zH|;ZHp8+&^qstqNhVw@U8v)d$dEo97^S)UkcwB9`(n< zzlSW9|H^7WGn^_0);i2QT5NsaG}Gc=e|PYr9x;7Pff0vjg5*8w*vi5oJlqL+ulO1i z*-6!yN^8|GG>A_s%F%lSfZLL8|;H#$bT?m%%44i>+q+3oWJypV_^0 zpDctuSD^#@-)9txg}Ah_MRV8%{3F2o1AlG;wG65MO$}hRI-zwT=m2KHv}_-KCS!r8 zu+ESWzt_l5ap!0P@9Z*;_r(Sk)qcI3r`qa!EIs@ZAn+hd0pa6cg^NrfNWUdM z-@z_|l@s!WQSEmgA|sD+Md*z#-&1YrUQ|z)MsnPsI_8caVnwftofKZPZ`yvHQ+OK& ze_%^2^tMm{&p56f-NOR7m*ZYtRa)ceQ5|{Szn4`?<=Fb;dH}xbuVD=BTl5KVo-`ZH zl6%NvRhdZg1gn!X*p;7|W!5WoV!c9rSp)XGY+vz(fT{Wi+J6>tFG8H`KhyqH53>G_ z8W0V@qS}7 zgwBLd$G9Yc_ED!!>^#N+YXW;!<5^VJ!|FO!cPy<{)b>el?{A}3I-Q3t$q@G!N>wi2 zT;0CBS3(^|IAFVV*w0SY_-E^XtA9JUL;O#E%{HA(Jo<{ulXXXsQr1e66}76rmg}PF9)P=ggf#fpPHuSMAH{{x0;}l z=VoThCBuvg5D=G)k2$zC6^Xt~{6n+{vU3Zec6#wB`N&e+ZNwyC`_TTKOqz;eEF9NC zq@|v2=04weOJAMKj;FxaLB z_V$7&;)S2xU2N8%YP#SB9A$L;p8K{b0rRiv8hMs>WB?vo2C+odf-yU;9%CLxppth&nw-UDx;pFsl+iOJ9f z?=r4yjr4LQ$No+S4l->zIUzo!qu~d&nwLk(qLth=j9(PpJUPX{EV6luhGFKr(QG3h zl|3scR#@K2d7)=V{h!U!3*#}dDL-#>AIOSKc;zumU3U_ba+wMV_?~DU%`5$gE{mxW zm~yAI&P_L2E5}dIb8)Ms#cU5VLD@lU%E>eRmE63dTTc{DhB+_%<=*eK-i~Md^;<}d zes(X}u`FmT{?UTl_#TZz&!fbaptSOu0n%jNKBN+gJ z`oY9G@(5o5wTld+oi>nZ4Ftd+!y$6RWSQPDac`3;+O`riO|E0Dy2!5C9>>{XF+9 zaR30xDNPkcBmWP3twCwe$9*=j^P3-nc1OqcG$CA~Z&E2CH8nrF5VqDtrZ8qv(-)f( z;sS3kLRPf4R;cxGQz>_-oHEyTi<1abFw{%>{-oe{4%80J9zEp@c`icvN2% zR&G)vQvU4hmsL&5ZvI_lJ2Nile){a$eAQ0Xj$H1#3)E-4(y$n_;F#NbHl00^Z&z#j z@1P6T?3c^{$Xq!#F5V)^^-KLr53bKlY~>L`6vUt01}4I4ax|)J8%R;<4#!FMgw#-* z2hkmpDqIovcc{a3iTQKt71tl@7FkZ!?~3pY`Lnq#=Dd5j0Ff3Gv&pboIvTYGch)sq>+hm#v{dK|z7T z#^TG{6*{56B4!&_;8iWX0DcZoKiveAi3@t*^pBkw2%w~tS_^uV&N?vu&SAn!T}253 zWw-kV|Du`~&H)*QenJ`>2fH6z-8v&rcw~9MVU(?9lh$vt9WZ;}6VE(l%5*}f+ zO0Olu^x!BZ>qb|{PLZAnU8PDq_+{2TrlAy%IyyCpzodXxK#G#0)}tegwFVP#G!XV@ z0+UtIryta2I9tdi@YNJyXV<;ZcH||kxFRCEZuK0hg@}6b z{DA-WH&R1k}`kypy)jbnQOZ+V!!ANI7k;eL}zu?=gJ3*U?z{ zqQ}W7Osr3lQc)lJHuzJlpVIyom!#PV6rUYmW>~#byW@;1#Q;?MW_+WSc8YJg^g#aa zJa*UWHW1%wI6d#Pff4J{&_}>q&87g9_VVm3zIsYyehtp%0f;97e);lOs7<&ldNZMTWWk;L>(-3AY`KaY++@wyt!z4~gjAUnZi zpU`j;nG4s*qe^?_xg*JQv^pY%%A|F8C7I5wvg}pugZgzXsjJJX&dsNu8u>jph~rI< zlS}0jgMhQSkfY5u87V2}uhW0ShFb;K2E_%imD2KldcXA+Oz+YU1_xZKfG3>R;vdmy zkBzoJ%J-{~DHA~6oetM2qgN^m@HFW>!U?REe)qHc9Cm4Ou3ldoUbXfFH_6mg%Ann$ z;rZ2|U(y2aYyfom8IL0PhbQR!wkAWlqfHt4PpzJ>ku=1uvNsyszZ1_1!HaqU@8CvY z>r9WrfBvh2D}1P}=SMMZK0hta&SQ^1zSGLDs!fgo9M*^p`Dl)cZwHq>07-kYLPN&m zqAOIuYrKlr@|AbjJ0I>Nw{n^}elD5*@Y*PN!T;gkDRQMsg!eiJYgR@BE~`DHj{JB1 z>-v}J6>D_Y6Ow+qwhRTN1I(bTNedMU-!5+Tw7(QM!!G5>CsVUJX@QJvQ*v9jvERmy z-kdQ|I{{uiXAr8AEis-VU9{P*@$2XUP$r`ig!H&FteWbi0iP7Dv0V!1n?IRn3prvQ z+<@K}OdV%NI&ZpL@bdpFsZTGsE8|6s{Bl3BbQ`J?tB@Fy*7aqjv`@!s7%=#i6Oaac zfrXJ~$X}zwu}A+L(i92|_<@7U#@lAEl24~pu@XH_IRedkDGK>%KaD~

17XfK0~M zS)jtVS&!ZU;^zFc_hxPX?w&0ou~>ileAnUK$F5GwaxyYHK&JK~rto+ucI8_4oHglz zvPx#3UY>s6@U}cBhjl`OLExr`<}E9bWan$S@xSNJ)>g^IT9PzI_xu;DS;on8O*!jK z_Zet=*QipNR-Q>tRWW|yg#E{Zx6*RoSBIL_)zt@2_of5J4tBM+2imYj%^`{b3(96h zzEuS+DiI3LJUkEmm96@gJ?;-9u+BJh7neel7Nqfd!~Xg&MW00)140& zuYbr<9s4*GIdAXPS~5gl;#7V=aBUR#FjU;uW(PlE)JM>@s7CPK>zj@2_Nhku{`4j? z&kZA>s|(AgrlwXQ`g!rFWlhz(HyK~tW5S-1`ukf^9+?xPBZ+;_jAlCTNC`1P9#7tm z^v%FTQJ#^Uj24Z|-X@8LX_MH@A zaqLpF32-XDcg0WmHsGIrTaZT=v_d#&;vSv%DRZQ%CE}@c59s%uy%WN6qdt1g?+WJU z{5qYzm}ZuJBM@)7a$a|dEc3%(ufnf3eIZ-K^WLFgSq zAf?_9%C2B^1qaot=BN1;;cST=MoIkn`ooYvhY34=?@V(il2PuY))aX-#Jn#xto=_B z73d&FF?611Q@Uh~j4tSsv?nNZaU+rx9Q!qOeMWW}ZtHOK zO$h({k+5wed|4iUAjS)^1L> z4ooN~;SULHGPO2xZ;d+5FH+re8E0c z(zT_b(MZ}>5GeYPLic575^cY%d)qZ1&p$gTok%odk)jlPBvSm>>@$uYCvI*qk z#!y-Q1wno9EesyJ2jrnDPytur#%7{prWp2!`dbFNx+RBBoDt583W6;Z#Zn1#Q)yvQ zctVKta0TO%?3Yr%NtSF}?w3SGm^WCvj{dl1O{zk{Hvax0CZJL@DFAi;csTD}_>BJt zeOHXcgxLnu719B(QlhN>mW5osSb4;(%PgFU_WIXX`xKJ`E-OKe*)u3R?Ia9SGgBQ} zPu9rfC5bc0dcvN}8PiY%ol=?pX~#1dR5bVXX)Y}NO3sUI87D9^Vf1>Ji|c$=Y%D(h zIWb5eYG?Yi$yc&t*~44X$3BCx#zw>BlS^krXl-_gC_||9^{R(AtM##PW>=u2jRS57 zpOT*bwKvXmezC87ve_WXBI|6zI`)u0Did1?2FTU@DEnSk-c?kAJK#CA=|dEEKlxiH z!ty>AQ)G25Z>f~sNNw^_tECG?h5-3b;vRk=+LmCI2G#W? z>=iM3*`z~~V9BEbVbm%p@0*1pSAQm+`?pC3$22Y0eqE-@tSy~YZrLFwYLOnqALWaU zYkGYLAt0aGf>&{xY}ScbHZ|`J>p-U#)t|d<4r^OTt^e z&n-HBw}K(fwL%mOC@@#!GrnfuTe=q^DB&jn7q^Zz)4~!{%cj-qJLPhR_i8|4nj|o1 zdLao^l_*-lVT}9R+8Gv~H{_DK<)X4(&Et^4u8GK13aOn(hGw#;4;xgkhkmw<*v9l+ zm=9cR`x7V_C~oqkOt;WtuFjAUMaTEM6j<)V+H2B)sas}+mHL{*ugt^@qmz6xw*zD+lz_Xi&y_z9BKWG;%6?=;c4=dcl|z>rn>AQ zNH2vceNFVJ;Rd9GCx$%6LDRa-{mZf$Z6P`{szi!zjTC!aOm*t6g~dbH42u} z1;LiCX|`n#)Pxty_TodbWIRbtB_i3V6Cvu?Qq21+Hj9G7Ui6 zWc0R!Kiw5Y?G?&={$nrZ3gZu#U~4D9jdZ0EzZ6(9dEz}cHe_ZyUyLLXWAEUJTdX4k zGl%JDR9qdqhx#~1B5&%=pVm58nqnnvh1^MvjANiHg9sQ&&=P1-lft4@>sqcO@-v@uSP@)HY( z0?Rw?G|Q44H4YW6>(4w^{;_pB*9v|L;~H(dup@u_C;cAD`*z{XT<&V92JVfX3{9u2 zThsm^oe)qt(kJg;h&30!>#t9as{~uk*Uky@7pn*72UH)K>Vz`=91wvsou0cErKAe5 zk+;vM0)jRS7wg{*Pa6zd6%PytW3~Yj6nesDYlj&G46tCVD~BXKE=QLfUQ!NDjEp~A z>0Xsj@Ow*+VuY(^4l!T%^-}6=kfnV}hnn~=$KW*x?H)B*)E2yaV-|4~t9GYnZ1>lC7HHu^D3jW5Zd)|9wy{=89kq<4Sba>DW zBoDkK5QIF%LOouDZ5(wwE)ekDQR7PH)9K`Jn!BW-mb= z-k5dW4;Q;d7p4XTi2A6>hcTg*lO5@lE|}p7B_4tzE7^!u`5Srw9gutJQ#fQrVd%V& z3%jENCTy@Is&Ycg+{BjMh^_UXSCG`)k9S?F;ACaxT}qwX}gu0Fy* zfP3WgSB(e@&@S?dPue+@_-=D{Z7yMTlpxBBKRAA+~V5OEXt5B$D3GQ@0d&k9~;8io0Q&4 zv%E54B2d7e0JU^T{HN6hF?Eaf3q+I#u&Q^%(D3|2>9uXYYJ&WUZH&tK`;BPz8d`Ym zwwQmo3tGxUJ(9Km4fhiuAN4NfyAw%E{+*g6>sxW{wDH_XL z$@{9?Tlf(o@1KEH^2I$$h_d1v(8gL8dqf_R8#bCW0H^m`a*HbxSeQ-Zw9+P5~r6<90Wsn;FRB8T@Q{Dt;Z7d+L1aUe3(aaRfc=C5#e8F2_@56wQC6AC`P&VUi189xg%XzbS(lN` zd+ZxTU&60kte{xG<0L8>L2@Xgb9?P5x{f4xbFUFZn}>}nUnZ*{){fHrt}%-;{+Vi? z$ZX|^3^{2%F*PDV#UT1sk1Yea-)xeG3fvpSMkw5(L2FjQbYh3MlOYzw7xcNRxSn2> z0XKyo905bHK#^84Qi&!vlr7_zZ{`58?BR`h75EoHCYG3iZI;ZCe^Q89%>ZrG_a9{t z(@vJ6fK4w~B3Qx6!1$$v7$v04N3l&J9Oq+3Q9>+{B3(mNxbd$~JUlIWE>w2ems2-U zk)sCmoBla|eoK`sT3$^!*PpvX9wfWlq5fWFkMI68N84PTo z`>prs;8Kxh(I2nVU5+>JJ#A*-uo~BpDG~YIAz}|fX1d29qb_9DfQx>U{*b(@aFW9^UmQ4b_2UWPq}Q@UfV9^Yn#>?y2j zJ{o`zcOAPBA@Gvk(R2^ML4_hy_z!wafh7B`PyE0GI*wEo4EM7zyl%4_O(_0bg0zJI zYNZP@eoBWZeK{>mODy6mU~q4k5MKeuro{CIF}D2f4r>zOljpp~y-R#Qe65p2sK7qKTJVa3G@^?F&S;t#+0!3|^+_cjaamT+yH`smB6n#rY>iU!U_*-)v| zb&RDX3-O)V)e1a2S8Q}z0voSSUz_DR@Agdo%4KV1meIS@v0?&@ZnuZ_S4Z}hcwiR= zpGdyE9KWfNa_URtRjiRhAD3Tsi4DK%44qN3ry&$>r)x0qoCSV@zk+3x9OFKgT$n8nX2r^hdg+3XPgYYylC99mLWYfnVUjPPx(n!y0phAl< z0K9I?n;NuE0Fj?g1H{B5a#i-<(d*0kQmy?szykozYx423WODV4TFCsvf^+P99}TM- zcHe8OOZ3{29y_o*Z&r0AiVIj_c$^(UMRu+x!_&70DZay@SZ)#8<&TahnJ{nFmHNv)OoqS z4E~6{IH_(h1IBNVm)B>VRuT;DCyhYZzk<J!Yu6y-f04M=LDEMiDA$ zEvEqf9v<|Baag(g7f}r2s|Mc=4W&lr}@}5%sPix zZqDVTQe(#H=Ru(X-{`q3c%S3iE{FveDshZHAP^nHVp=mgclA0RWP?m(vGPxQx)*qr ztX+QmGk0OX=HfY(3q_R81-}M3Ox%`Ficq+82Y&NGi>w^n12t9&omS411dhq zkf#8%baL$zVQIgokOsG3buOuj6Ae1(&rEFMa02#cb>}*#gHsLkc)j#Og?Z<m3Z1w*7Ty#?NkZo_GiK=kZ!t{kbAi5b`A>#BLOFBtbXdDQ``*H^9X5PsR)SqR zUrNb3;F5^_?OjXp-z8zMGWYmkCg&GxrGclwM{8MZ?VVr6*uQ@lAEi!N|)D<^|QEQ07Egp zmKz>jnSazQxBHFxN7VYQH`dF84P=5^kUqWq;A__N@a)cw{6XRb0;3{%(y{v96)yIC zv70Z>d{M11==v%qg3hR%s)VKgO@jb{tF{!CqNY@T_6 zSDtEyw4VCGr%U2PptBdm)Feq(?e6hp3_Y(e;aM>d%6v5+DIHC)F*^d&-z zPUZ3bZ~Tt;Bi}Xud~%Dmo&LS>9#y~3SgE#lfy)D2sz-#iPYKZC!J!tt>SWrse=~%X z{S^Yc99WQty!mYMr@@i@f40f8*=5i)3{FyE^isS&>}(VIuKzIAasBvz$SlUT6h7EU`gBUqEQy?t(2UkR+ z$R85c6h9n-_(a2Hly28^IWwi8<72Zudq(A}e=Cc|el{`;h_q`vg1m{J?6@tDxcn0X z$0F_~beAa>PPj+JP7A7jJlXV(>yMS4GE0lU{Njxhb8HMSBM8hLga8Es% z63SsyxCGyhFrn|8TiDU5b+MV@ZO3nDm*7Fn@39*=41u;Xb7`xAGR9Ec)vcd2D*%u} z5LO}tZ~Pzwbjob!8pq}@JV{wj_4$%FQ*kGIr(V-dyRD^h3s_+dBG?)v`Ik0D zVJu7;cOr%2VqcDXb#xCP?+wAYFbUN?Vn*uPN>{|zf z>7 zmrC)5h4P*O;WBxStDg~LEd*#D1zdQgxB!;30t|_A;)fnc66M*3d;lwCmI+?t1lC862H zf&pd|{~KA2kACc~`Y$w6y6{wK3x}FvmPiU$m@Rgg)Ieb$?NzgG<03}IDgZt!9Eig2 z!`zAWA*ENKiQ}{rZo;o%{!5Ds*=hf4cgAPMkpgtfQ`xdIYbK-Lia*EI!z{suBKx5^ zTW?cn3>qg7^07IS8hHBuOAj;(*I# zh<-tm{wUyuUvCT(q`*c9;yVNy^O5~eM}8;`x&cs-g$H3w|_Z?Fk$^Y?9$Jy zLQdvpQN7og_UoPV*~G1L%ztCsnZ_~)N$(;I0Y}&!3SD;W4hf&0&QGa3Ebq_gZp?+d zmflS&(--TBW4dznY!CW&N88uMwrq5c7O8Q3oH%w^n>dzruy#vTQTmQU%4UJbIhjuD zxs0*Z8T(f7hY-;6ZScv7^sDP%Y^%pM`OE(n$0son2hbv?Y~kBqEDwN>fu{j9m?xQjL60#a(ni*|3U#7o}7mfo?KhMz%W(eKX_CGrMvhH#@d!qAltk ztiBbuU2{qh{`ZrTq8M<7(M@XDUQhqjm!quJf>F9obJV_KoxrYC54+-(ZD230u6^tt zRD}!u?7yMUQMgdKvpOtCn5rRtv`9&Udz|v*euWN;`u>s}gzXD{0)9stV|n!~C`)HJ zO)=xwePgReyrO|ThwRk6onpX~=qz9C6-%ef;rW*?%4He_*@TYK+`n;8xgdbT@`Jq1 zP9GDm8}caw$_AtE_q%@oby)AX4|GZ6FKy}hf~xj1Y(X)flMQ`0eoQD8{g(W``D)Dy z$t*@$Y_e7QqHQSy^I<#Lze8j(cB(0ZQH(tWdE@qB?nu(^u59^y;O37k=5r;1V4M>o zWa*Bs3Rl!(&t&--`$awF`_^QqB9JTTMU#qq@nNS@%VkVJSiDkB^`GgIs_0Nc**?6pxto(y*WP zW#iNi?KKjqsIUCz^#k?ja*H5Xj&fvn)324S5oWbskSRqKY zPm!}>7ec{5q7j3o3oDHsVVsVnSj(!}Xs}GWlq47dtmoV0TCRG}Qi3iX_tFTxoNwLP z!5(J&pQU0H?Jt{Vj^wka?_BvlPrCdjgCeZ1IoRM@2w5_R$qAL6$tI}5{v$@l-aff+ zv%P6&nh8Jp`sZkNq2ClB>+DfDIbNQhKeIjDBw`=)d?It;Dr9G7gQNcK?_cEyrVsC6 zOaecEox*BJJQzh@A0>B$5B$g2~0EnYhtCxV*KuT=Wfgy&O`c zi%V0zJn^d|cfNWx@j^=-! zvR!-3FZ$fjNok;5dU&_pASb@68Y($FjJx*0!Vj1%7XcnCmad%U_L#nIXvrPMsO|Rw zW|;+|1uFI{_@{F>syc0wgw^hV>+u`p@a3eZW9epNYsM^C5H`VNi2;hp_dAB$4w*Bj z8tHHI@cHAkP*2}=Fm`s5HW0cBX;5A(#>T!5`d3?hO^9RtZyFPUnr(*SAHFLIH@bXZ z)x~Q79uVGrr)YmDd1@ygPqKghxZ618xxX!NNey)>r1{4Qhy-u4xD{kQ_UC9bF@M}9G8Bg?~yM%67qO5 z5dypGUTf*NDoOA9@z+MM)0Iz`SBR=Zye;nnsJZFru>zbOmrydii;7t~Aw%!7DLL<5 z0HAkodG%#3A9%kMb3RCo(qE{U37H2`rf3Q+eqtU_Xme^ZhQgo~rarmO4t!o*bLPp=u78t;vQR*9yGery0 z@JBSx9MD5Ks-1aYaXRHCU&hQK>QRr{7Bki%R{m$ z{$hP{9N$|q{azJMUe|Ia_5aqd|3Ag*Z~wP4Ek*MG)UIWB+st}nj^F-`rZu*-N zLtqa{W6^Qpx48QIdPkVk_X)38k}M~14yuWnu6%OIpkfgX1+8_hutzKwgtd`r#Gf1< zi8^qE)t}aYm&rKHep=VZja7g+;&xiUKL?@3eg4q2tpiCQHY*RT=Md)82<}Hu~e$k=a$pN-#DyJV&GS8 zI4u4ZXk(L+uKp8f0QIJWkk0g|enoq|9c{yxMP!|E9jf{kPCW#qoQF91CyK2kJ zMw}ooQk0Z59;xFe_nMSL1#ucGIqBv~|CReA&)vI#C;HLdx z1P6!9$rp|wq90RJ=`3F)`6BTr_36V@)*#+FxS28|9Q<>v#EDQ@l{8e{D@!=04`4OH z1MOj0?A{ZrKjI4j*EN0-396p}hlpVNr0?KTJ>C7J8tsUp6!_%#mz*A1wM>NITUUno zz3g4yB8-6PI}4Y8jGYFnG>2~8&x5uU*F)n}xL!?&4eJ}=kekBXQ_EW(nL@!LAZ}V` zlfODD2HAN|V9W~uHtlNs*WdR@8nGZ??k zGR+qsaWK+uJPH}Ox~PrfF{>g~{WUNVW%dJ?pidf7BZe+&qbL5#JQmadt1L2h#JpY# zB`RcKW)=q!X>EGhc}fxQ4fgpdVpWJI-0)bElDfaDa-@bmR40T}7Cun?!S|XCNJva% z1fd;pQ1B_+vsSx`*T!0!&)r<*NgUuG`|>PjaEJ>NDM*C(T|=+(p#e25&7K)YwxGXl z8DFO>0GJx+PRRN4fjk6zfAT@z&hvwfV__+lq|ael%lz$0wQ~2d1k1U=l*Wc*x)&Jz z(6xD0hE5Wqq_?IZ!zrMTXiDq)6}u`dxaHvun=m6KuTazb+BosxqcZg8ebeXHy;H5~ zuwTJX;{tF1Y&kJs3?5#}6@@urjEnHUF#N#ZuXS7p|s*vCOplI7zYX`1tE`wj+jgb$u0z0Rc zmj8#>)b7XD)>gvv+#<143Jiq+K>R4|;5p)PhJ31myF=b*tL|lw?t+KnzCPr2{NbqW@4rCN8PE_9;4r&Vjd^<~N~Ca;UKi05R02G$9r! zj_0+`BF&Q2K$SDPJxGe4d9)_-W*#?_yeM#{8-Y_LGOv)=#S{c6LnGVT*f&W$Bu(3bReTb;$6u@Aa}w2gY`80qYh3VL@s1Q*Wa7x z)~hD0CYX*3uTpf2tiKn{-J?WQmWmE!00X_pSp!*<(lRoF|DhhEtK0VEFYaC-pM`zp z;dpu_96`N`$y@1*dc@+!op8Zh0uNQgx9rUx9b3AZYB+L% z&yuuZE$YR0y}c0F=^mMazht6Mx`174yING`^FFHTOw8G?$HDnme!V_pt8Y5ouGu@# zSv;t9;M=v|QaZb(&y_N8ZWP>ojrUDs1le@vaBbl6ng5*B@TH&p> zm&6B;8zT2+!+-_Z8K=jn54&dWZdB`{=HFc(su*DrG#E)Pv1341Ft& zsMg)j;Y(FNbG~DkPn1IHon{^^w!LniX$@+;J$TxPoIaw$NNU#Go z*}XIW9Hb+6i4(^kT`Hv#e1?S5IotvM7U26zI5&Y_hBW@718`QI+_n&U0JCCUc8ofe zwIWblXHHJqYZ9Qkbv8qEb(6`;HUYW(b^YKPs9n}ao0&*#pv{3iWZ!Sz1 zZV90BBb}rl%ZLt@XJ6jba6P&kA*HvQSLIa`2Ln&!7~qFi4X^~`oSct4phtQa;$c>= zH#Wl=&G_|9%aa)%bm=9`W1Vm*u;+E&rIkH!_o;>>8ST>ccLsZZnryNdz3j<`cs|gm zar5Qt_Z2)B?li#zJFLULb*UxkuK%h1<=O!WI^m|`FMP{*Zb_x2A0CGLdLb`OBw>_- zSon5Uz2}b1Z`Tj)sg#Xr(|9enWqOVTqw@eFVPZ5Lu`pag+ZCb7QFq>(N{K8)i?ox z?g7-=E1WLXjZ8QUxUxfOAwd3AkwskKqv!FdEwJ<)Tk!1Fi~iZdvQfQrJN~haPr& zYKnr_{QF|H>g>8*!?fr8)7+L1Ca)h82arHw3Lu?U1d);I5OJh2g`TLF5%1u#lTesl z9qiRPZ}ihZhueglLA5NQa|GJt=rzwxb29dC^A+ke^V;#N3CX%n2@H^H*-K*8xt0!d zQujATs8RlT%;CJUy2KKhH1YeZV_a8e2;cc~*bfTIv0ND`UyL$3VM#=?dlC}w%I`?v z@$lJf@-tcxywDzMrvKX?zkh0%qE7?V<9 zihZ-M>P-ytrN;hDhYYfAy0{?UWnmBp^;#E)X<{`zbu1s0JiNKY!EAB`O2(0vyK(Fz zZ&kdjsaDv0<$W--qd~f}Od?%*%JqL1PlAn9c(ls2%r(a}1fKtWXXRyGq(je+>_x*w!P(<6*AzAo*si#CU=CY{1~@?x{v@y5et z2?zYuVSA1%o9GovRfeE7$L59iH>YO9rv_s$h!J$AUtV8zNen-7UktobfDe35Ig|yE z=&!6?BM%4#(Ypk(KF Date: Mon, 17 Aug 2026 19:23:48 +0200 Subject: [PATCH 126/152] Improved: workflow --- .github/workflows/ci.yml | 4 +--- .pre-commit-config.yaml | 10 ++++++++++ docs/development/dependencies.md | 6 +++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99ace251e..8a9cc8760 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,9 +42,7 @@ jobs: run: uv run pre-commit run --all-files --show-diff-on-failure --color always - name: Check the committed icons match the mark - run: | - uv run --group assets python scripts/assets/icons.py - git diff --exit-code -- src/sampletones_assets/icons + run: uv run pre-commit run icons --all-files --hook-stage pre-push --color always tests: name: Tests (${{ matrix.os }}, py${{ matrix.python }}) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 570b44966..0f67e8714 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -91,6 +91,16 @@ repos: require_serial: true exclude: ^(tests/) + - id: icons + name: icons + entry: uv run python scripts/assets/icons.py + language: system + files: ^src/sampletones_assets/(icons|mark)/ + pass_filenames: false + verbose: true + stages: + - pre-push + - id: pytest name: pytest entry: make test diff --git a/docs/development/dependencies.md b/docs/development/dependencies.md index 924d7cce9..e0de82837 100644 --- a/docs/development/dependencies.md +++ b/docs/development/dependencies.md @@ -62,9 +62,9 @@ points it at the directory the icons are shipped from. Rasterization uses Pillow `assets` dependency group. The whole suite is committed, so a plain checkout carries the icons the application opens its window -with, and every wheel, bundle and test run finds them without a generation step. `make icons` writes -them again from the mark, and CI regenerates them on each change to confirm the committed files are -the ones the mark describes. +with, and every wheel, bundle and test run finds them where they lie. `make icons` writes them again +from the mark, and the `icons` pre-push hook writes them for a push that touches either directory, +holding the committed files to what the mark describes. CI runs that same hook. Pillow is a build-time tool, and the bundle scripts pass `--exclude-module PIL` to hold it to that: `pygments`, which arrives with `rich`, offers an image formatter that imports Pillow where it is From ef37024805cdcc5da1e4d220c441cf1735f6df88 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 20:26:04 +0200 Subject: [PATCH 127/152] Refactored: explorer and library onto the file-browser base --- .../ui/elements/tree/browser.py | 104 ++++++++++-- .../ui/elements/tree/tags.py | 4 +- .../ui/panels/instruction/library.py | 142 +++++++--------- .../ui/panels/main/explorer.py | 151 ++++++------------ .../ui/panels/reconstruction/browser.py | 21 ++- .../ui/panels/sequencer/browser.py | 21 ++- .../ui/panels/shared/browser.py | 22 +-- 7 files changed, 224 insertions(+), 241 deletions(-) diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index f5b2a70cd..d759daad0 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -1,4 +1,5 @@ from abc import ABC, abstractmethod +from typing import Dict import dearpygui.dearpygui as dpg @@ -11,13 +12,16 @@ from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol +from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.ui.elements.tree.tree import GUITreePanel from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.parallelization.thread import concurrent -from sampletones_core.structures.tree import Tree +from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree +from sampletones_shared.types.callback import Callback, MessageCallback class GUIFileBrowserPanel(GUITreePanel, ABC): @@ -25,17 +29,18 @@ class GUIFileBrowserPanel(GUITreePanel, ABC): The card holds a refresh control above the search box and the tree it filters. This base builds that arrangement, rebuilds the tree off the main thread on demand, and enables or disables the - whole card as the tree locks and unlocks. A subclass names its widgets through + whole card as the tree locks and unlocks. A subclass declares its widgets as a :class:`FileBrowserTags`, states what its card and its refresh control read, answers what refreshing the model means, and shapes each row. """ + _REBUILD_ON_CREATE: bool = True + def __init__( self, tree: Tree, tree_logic: TreeLogicProtocol, *, - tags: FileBrowserTags, scheduling: SchedulingBehavior, search_label: str, language_manager: LanguageManager, @@ -43,12 +48,10 @@ def __init__( colors: TreeColors, initial_collapsed: bool, ) -> None: - self._tags = tags - super().__init__( tree=tree, - tag=tags.panel, - tree_tag=tags.tree, + tag=self._tags.panel, + tree_tag=self._tags.tree, tree_logic=tree_logic, scheduling=scheduling, search_label=search_label, @@ -62,6 +65,11 @@ def __init__( side=CollapseAxis.HORIZONTAL_LEFT, ) + @property + @abstractmethod + def _tags(self) -> FileBrowserTags: + """The tags naming this browser's widgets, which a panel states as a class attribute.""" + @property @abstractmethod def section_label(self) -> str: ... @@ -79,6 +87,11 @@ def refresh_button_label(self) -> str: ... def refresh_status_message(self) -> str: ... def create_panel(self, parent: str) -> None: + """Builds the card, and fills the tree where the panel is the one reading its model. + + A browser reading the filesystem shows its rows as it appears, while a catalogue filled by + the owner that gathers it waits for that reading to arrive. + """ self._setup_handlers() with ( dpg.child_window( @@ -98,23 +111,34 @@ def create_panel(self, parent: str) -> None: self._create_tree_window() self._create_detail_tooltip(self._tags.window_tree) - self.rebuild_tree() + if self._REBUILD_ON_CREATE: + self.rebuild_tree() def _create_controls(self) -> None: with dpg.group(tag=self._tags.group_controls): - GUIButton( - tag=self._tags.button_refresh, - label=self.refresh_button_label, - width=-1, - callback=self.rebuild_tree, - theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), - ) + self._create_refresh_button() + + self._bind_refresh_message() + + def _create_refresh_button(self) -> None: + GUIButton( + tag=self._tags.button_refresh, + label=self.refresh_button_label, + width=-1, + callback=self._on_refresh_clicked, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), + ) + def _bind_refresh_message(self) -> None: self._status_bar.bind_to_item( self._tags.button_refresh, self.refresh_status_message, ) + def _on_refresh_clicked(self) -> None: + """Answers the refresh control, by default with a rebuild of the tree as the model stands.""" + self.rebuild_tree() + def _create_tree_window(self) -> None: self.create_search(self._body_container) with ( @@ -131,6 +155,52 @@ def _create_tree_root(self) -> None: with dpg.group(tag=self.tree_tag): pass + def _create_tree_root_heading(self, label: str) -> None: + """Opens the root container as a labelled row the whole tree folds under.""" + with dpg.tree_node( + label=label, + tag=self.tree_tag, + default_open=True, + ): + pass + + def _create_file_system_handlers( + self, + *, + on_directory_clicked: Callback, + on_file_clicked: Callback, + on_file_double_clicked: Callback, + file_status_message: MessageCallback, + ) -> Dict[NodeType, NodeHandler]: + """The two rows a browser of files offers: a folder that expands, and a file it opens. + + A folder row reads the same wherever it appears — the status bar says it expands — so the pair + is shaped here, and each browser states what a click on one of its own rows means. + """ + return { + NodeType.DIRECTORY: NodeHandler( + tag=self._get_node_handler_tag(NodeType.DIRECTORY), + node_type=NodeType.DIRECTORY, + item_click_callback=on_directory_clicked, + status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), + ), + NodeType.FILE: NodeHandler( + tag=self._get_node_handler_tag(NodeType.FILE), + node_type=NodeType.FILE, + item_click_callback=on_file_clicked, + item_double_click_callback=on_file_double_clicked, + status_bar_callback=file_status_message, + ), + } + + def _mark_favorite_ancestry( + self, + node: FileSystemNode, + state: TreeNodeState, + ) -> None: + """Carries a favorite down the branch, so every row under one reads as part of it.""" + state.has_favorite_ancestor |= self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(node) + def refresh(self) -> None: self.rebuild_tree() @@ -140,12 +210,16 @@ def rebuild_tree(self) -> None: self._refresh_model, lambda: self._collect_specs(self.tree_tag), root_tag=self.tree_tag, + on_finished=self._on_rebuild_finished, ) @abstractmethod def _refresh_model(self) -> None: """Brings the model the tree renders up to date, on the background rebuild worker.""" + def _on_rebuild_finished(self) -> None: + """Runs on the main thread with the rows on screen, where a browser reads something out.""" + def set_tree_enabled(self, enabled: bool) -> None: dpg_configure_item(self._tags.group_tree, enabled=enabled) dpg_configure_item(self._tags.group_controls, enabled=enabled) diff --git a/src/sampletones_application/ui/elements/tree/tags.py b/src/sampletones_application/ui/elements/tree/tags.py index 9dc9c1911..d9c65ea75 100644 --- a/src/sampletones_application/ui/elements/tree/tags.py +++ b/src/sampletones_application/ui/elements/tree/tags.py @@ -7,8 +7,8 @@ class FileBrowserTags: Every browser builds the same arrangement — a panel card holding a controls group with a refresh button, and a window holding the group the tree attaches to — so the tags naming those widgets - travel as one value the panel is constructed with. Stating them together makes each browser - declare a complete set at one place, checked where it is written. + are one value the panel declares beside its class. Stating them together makes each browser name + a complete set at one place, checked where it is written. """ panel: str diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index af186d27b..e32d0f751 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Callable, Dict, Optional, Protocol, Tuple +from typing import Any, Callable, Optional, Protocol, Tuple import dearpygui.dearpygui as dpg @@ -7,10 +7,7 @@ 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, -) +from sampletones_application.tags.general import TAG_GLOBAL_THEME_PRIMARY_BUTTON from sampletones_application.tags.instructions import ( TAG_INSTRUCTIONS_LIBRARY_BUTTON_CANCEL_GENERATION, TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, @@ -31,17 +28,16 @@ from sampletones_application.ui.elements.context_menu import context_menu from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry -from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.elements.tree.tree import GUITreePanel +from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_configure_item, dpg_set_value from sampletones_application.utils.gui.tooltip import attach_disabled_tooltip -from sampletones_application.utils.parallelization.thread import concurrent from sampletones_application.view_model.instruction.library import LibraryPanelViewModel from sampletones_core.constants.enums import LibraryGeneratorName from sampletones_core.library import InstructionLibraryKey @@ -81,9 +77,20 @@ def update_status(self) -> None: ... def get_path(self, key: InstructionLibraryKey) -> Path: ... -class GUIInstructionsLibraryPanel(GUITreePanel): +class GUIInstructionsLibraryPanel(GUIFileBrowserPanel): + """The Instructions tab's catalogue of instruction libraries and the generators inside them.""" + _NAME_FONT: Font = Font.REGULAR_SMALL _MONOSPACE_CONFIG_NODES: bool = True + _REBUILD_ON_CREATE: bool = False + _tags: FileBrowserTags = FileBrowserTags( + panel=TAG_INSTRUCTIONS_LIBRARY_PANEL, + tree=TAG_INSTRUCTIONS_LIBRARY_TREE, + window_tree=TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE, + group_tree=TAG_INSTRUCTIONS_LIBRARY_GROUP_TREE, + group_controls=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS, + button_refresh=TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES, + ) def __init__( self, @@ -91,7 +98,7 @@ def __init__( tree_logic: TreeLogicProtocol, *, scheduling: SchedulingBehavior, - initial_collapsed: bool = False, + initial_collapsed: bool, language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, @@ -109,25 +116,33 @@ def __init__( self.on_generator_selected: Optional[Callable[[InstructionLibraryKey, LibraryGeneratorName], None]] = None self.on_library_remove_requested: Optional[Callable[[InstructionLibraryKey], None]] = None - self._node_handlers: Dict[NodeType, NodeHandler] - super().__init__( - self._library_logic.tree, - tag=TAG_INSTRUCTIONS_LIBRARY_PANEL, - tree_tag=TAG_INSTRUCTIONS_LIBRARY_TREE, + tree=library_logic.tree, tree_logic=tree_logic, scheduling=scheduling, search_label=language_manager["global.browser.label.search"], language_manager=language_manager, status_bar=status_bar, colors=colors, - ) - - self._enable_horizontal_collapse( initial_collapsed=initial_collapsed, - side=CollapseAxis.HORIZONTAL_LEFT, ) + @property + def section_label(self) -> str: + return self._language_manager["instructions.library.label.libraries_text"] + + @property + def section_glyph(self) -> str: + return self._glyphs.headers.instruction_data + + @property + def refresh_button_label(self) -> str: + return self._language_manager["instructions.library.label.refresh_libraries_button"] + + @property + def refresh_status_message(self) -> str: + return self._language_manager["instructions.library.message.status_refresh"] + def _setup_handlers(self) -> None: self._node_handlers = { NodeType.LIBRARY: NodeHandler( @@ -146,41 +161,16 @@ def _setup_handlers(self) -> None: super()._setup_handlers() - def create_panel(self, parent: str) -> None: - self._setup_handlers() - with ( - dpg.child_window( - tag=self.tag, - width=self.width, - height=self.height, - parent=parent, - border=False, - ), - self._collapsible_section( - self._language_manager["instructions.library.label.libraries_text"], - glyph=self._glyphs.headers.instruction_data, - ), - ): - self._create_library_status() - self._create_library_controls() - self._create_library_tree() - - self._create_detail_tooltip(TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE) - - def _create_library_status(self) -> None: - text = dpg.add_text("", tag=TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS) - FontRegistry.bind_to_item(text, Font.MONO_SMALL) + def _create_controls(self) -> None: + """Reads out what the catalogue holds, and offers what can be done to it. - def _create_library_controls(self) -> None: - with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS): + The controls come in two sets: the ones a reader picks from while the catalogue sits still, + and the progress bar and cancel button a generation replaces them with. + """ + self._create_library_status() + with dpg.group(tag=self._tags.group_controls): with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_CONTROLS_IDLE): - GUIButton( - tag=TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES, - label=self._language_manager["instructions.library.label.refresh_libraries_button"], - width=-1, - callback=self._on_refresh_clicked, - theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), - ) + self._create_refresh_button() with dpg.group(tag=TAG_INSTRUCTIONS_LIBRARY_GROUP_GENERATE): GUIButton( tag=TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, @@ -214,10 +204,7 @@ def _create_library_controls(self) -> None: width=-1, callback=self._on_cancel_clicked, ) - self._status_bar.bind_to_item( - TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES, - self._language_manager["instructions.library.message.status_refresh"], - ) + self._bind_refresh_message() self._status_bar.bind_to_item( TAG_INSTRUCTIONS_LIBRARY_BUTTON_GENERATE_LIBRARY, self._language_manager["instructions.library.message.status_generate"], @@ -227,26 +214,15 @@ def _create_library_controls(self) -> None: self._language_manager["instructions.library.message.status_cancel_generation"], ) - def _create_library_tree(self) -> None: - dpg.add_separator() - self.create_search(self._body_container) - with ( - dpg.child_window( - tag=TAG_INSTRUCTIONS_LIBRARY_WINDOW_TREE, - width=-1, - height=-1, - horizontal_scrollbar=True, - ), - 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, - ), - ): - pass + def _create_library_status(self) -> None: + text = dpg.add_text("", tag=TAG_INSTRUCTIONS_LIBRARY_TEXT_STATUS) + FontRegistry.bind_to_item(text, Font.MONO_SMALL) + + def _create_tree_root(self) -> None: + self._create_tree_root_heading(self._language_manager["instructions.library.label.available_libraries_text"]) def _on_refresh_clicked(self) -> None: + """Answers the refresh control by reading the libraries again, which rebuilds the tree.""" self.call(self.on_refresh_requested) def _on_generate_clicked(self) -> None: @@ -282,12 +258,13 @@ def update_view(self, view_model: LibraryPanelViewModel) -> None: ) def set_tree_enabled(self, enabled: bool) -> None: + """Locks the tree and the control reading it again, leaving a running generation cancellable.""" dpg_configure_item( - TAG_INSTRUCTIONS_LIBRARY_GROUP_TREE, + self._tags.group_tree, enabled=enabled, ) dpg_configure_item( - TAG_INSTRUCTIONS_LIBRARY_BUTTON_REFRESH_LIBRARIES, + self._tags.button_refresh, enabled=enabled, ) self._apply_action_button_states() @@ -312,14 +289,11 @@ def _apply_action_button_states(self) -> None: show=operation_active, ) - @concurrent(wait=False, method_bound=True) - def rebuild_tree(self) -> None: - self._launch_rebuild( - self._library_logic.rebuild_tree, - lambda: self._collect_specs(self.tree_tag), - root_tag=self.tree_tag, - on_finished=self._library_logic.update_status, - ) + def _refresh_model(self) -> None: + self._library_logic.rebuild_tree() + + def _on_rebuild_finished(self) -> None: + self._library_logic.update_status() def _has_relevant_content(self, node: TreeNode) -> bool: return True diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 84aaa74b4..7fd945ff1 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Dict, List, Optional, Protocol, Tuple +from typing import Any, List, Optional, Protocol, Tuple import dearpygui.dearpygui as dpg @@ -7,7 +7,6 @@ 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, TAG_MAIN_EXPLORER_BUTTON_REFRESH, @@ -19,16 +18,13 @@ ) from sampletones_application.ui.elements.button import GUIButton from sampletones_application.ui.elements.context_menu import context_menu -from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel from sampletones_application.ui.elements.tree.colors import TreeColors -from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.spec import NodeSpec from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.elements.tree.tree import GUITreePanel -from sampletones_application.ui.themes.registry import ThemeRegistry -from sampletones_application.utils.gui.dpg import dpg_configure_item +from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_application.utils.parallelization.thread import concurrent from sampletones_core.structures.tree import ( FileSystemNode, @@ -66,7 +62,18 @@ def is_directory_expanded(self, filepath: Path) -> bool: ... def has_relevant_content(self, filepath: Path) -> bool: ... -class GUIExplorerPanel(GUITreePanel): +class GUIExplorerPanel(GUIFileBrowserPanel): + """The Main tab's browser of the filesystem, whose rows are the folders and files on disk.""" + + _tags: FileBrowserTags = FileBrowserTags( + panel=TAG_MAIN_EXPLORER_PANEL, + tree=TAG_MAIN_EXPLORER_TREE, + window_tree=TAG_MAIN_EXPLORER_WINDOW_TREE, + group_tree=TAG_MAIN_EXPLORER_GROUP_TREE, + group_controls=TAG_MAIN_EXPLORER_GROUP_CONTROLS, + button_refresh=TAG_MAIN_EXPLORER_BUTTON_REFRESH, + ) + def __init__( self, explorer_logic: ExplorerLogicProtocol, @@ -76,14 +83,11 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, - initial_collapsed: bool = False, + initial_collapsed: bool, ) -> None: self._language_manager = language_manager self._explorer_logic = explorer_logic - self._lbl_section = language_manager["main.explorer.label.section"] - self._node_handlers: Dict[NodeType, NodeHandler] - self.on_wave_file_clicked: Optional[PathCallback] = None self.on_directory_clicked: Optional[PathCallback] = None self.on_reconstruct_directory: Optional[PathCallback] = None @@ -94,104 +98,64 @@ def __init__( self.on_set_as_library_directory: Optional[PathCallback] = None super().__init__( - tree=self._explorer_logic.tree, - tag=TAG_MAIN_EXPLORER_PANEL, - tree_tag=TAG_MAIN_EXPLORER_TREE, + tree=explorer_logic.tree, tree_logic=tree_logic, scheduling=scheduling, search_label=language_manager["global.browser.label.filter"], language_manager=language_manager, status_bar=status_bar, colors=colors, - ) - - self._enable_horizontal_collapse( initial_collapsed=initial_collapsed, - side=CollapseAxis.HORIZONTAL_LEFT, ) - 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, - ), - self._collapsible_section( - self._lbl_section, - glyph=self._glyphs.headers.filesystem, - ), - ): - self._create_buttons() - dpg.add_separator() - self._create_tree_window() - - self._create_detail_tooltip(TAG_MAIN_EXPLORER_WINDOW_TREE) - self.rebuild_tree() + @property + def section_label(self) -> str: + return self._language_manager["main.explorer.label.section"] + + @property + def section_glyph(self) -> str: + return self._glyphs.headers.filesystem + + @property + def refresh_button_label(self) -> str: + return self._language_manager["main.explorer.label.refresh_button"] + + @property + def refresh_status_message(self) -> str: + return self._language_manager["main.explorer.message.status_refresh"] def _setup_handlers(self) -> None: - self._node_handlers = { - NodeType.DIRECTORY: NodeHandler( - tag=self._get_node_handler_tag(NodeType.DIRECTORY), - node_type=NodeType.DIRECTORY, - item_click_callback=self._on_directory_node_clicked, - status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), - ), - NodeType.FILE: NodeHandler( - tag=self._get_node_handler_tag(NodeType.FILE), - node_type=NodeType.FILE, - item_click_callback=self._on_file_node_clicked, - item_double_click_callback=self._on_file_node_double_clicked, - status_bar_callback=self._create_status_bar_message_function_for_file_node(), - ), - } + self._node_handlers = self._create_file_system_handlers( + on_directory_clicked=self._on_directory_node_clicked, + on_file_clicked=self._on_file_node_clicked, + on_file_double_clicked=self._on_file_node_double_clicked, + file_status_message=self._create_status_bar_message_function_for_file_node(), + ) super()._setup_handlers() - def _create_buttons(self) -> None: - with dpg.group(tag=TAG_MAIN_EXPLORER_GROUP_CONTROLS): - GUIButton( - tag=TAG_MAIN_EXPLORER_BUTTON_REFRESH, - label=self._language_manager["main.explorer.label.refresh_button"], - parent=self._body_container, - width=-1, - callback=self.refresh, - theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), - ) + def _create_controls(self) -> None: + """Offers the refresh control and, beside it, the one folding every folder away at once.""" + with dpg.group(tag=self._tags.group_controls): + self._create_refresh_button() GUIButton( tag=TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, label=self._language_manager["main.explorer.label.collapse_all_button"], - parent=self._body_container, width=-1, callback=self.collapse_all, ) - self._status_bar.bind_to_item( - TAG_MAIN_EXPLORER_BUTTON_REFRESH, - self._language_manager["main.explorer.message.status_refresh"], - ) + + self._bind_refresh_message() self._status_bar.bind_to_item( TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, self._language_manager["main.explorer.message.status_collapse_all"], ) - def _create_tree_window(self) -> None: - self.create_search(self._body_container) - with ( - dpg.child_window( - tag=TAG_MAIN_EXPLORER_WINDOW_TREE, - horizontal_scrollbar=True, - ), - dpg.group(tag=TAG_MAIN_EXPLORER_GROUP_TREE), - dpg.tree_node( - label=self._lbl_section, - tag=self.tree_tag, - default_open=True, - ), - ): - pass + def _create_tree_root(self) -> None: + self._create_tree_root_heading(self.section_label) + + def _refresh_model(self) -> None: + self._explorer_logic.refresh_tree() def collapse_all( self, @@ -205,17 +169,6 @@ def collapse_all( for node_tag in children: dpg.set_value(node_tag, False) - def refresh(self) -> None: - self.rebuild_tree() - - @concurrent(wait=False, method_bound=True) - def rebuild_tree(self) -> None: - self._launch_rebuild( - self._explorer_logic.refresh_tree, - lambda: self._collect_specs(self.tree_tag), - root_tag=self.tree_tag, - ) - @concurrent(wait=False, method_bound=True) def _rebuild_node_subtree( self, @@ -260,7 +213,7 @@ def _build_tree_node( if not isinstance(node, FileSystemNode): return - state.has_favorite_ancestor |= self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(node) + self._mark_favorite_ancestry(node, state) if node.node_type == NodeType.DIRECTORY: should_expand = self._should_expand_node(node) or self._explorer_logic.is_directory_expanded(node.filepath) @@ -419,10 +372,6 @@ def _has_relevant_content(self, node: TreeNode) -> bool: return True - def set_tree_enabled(self, enabled: bool) -> None: - dpg_configure_item(TAG_MAIN_EXPLORER_GROUP_TREE, enabled=enabled) - dpg_configure_item(TAG_MAIN_EXPLORER_GROUP_CONTROLS, enabled=enabled) - def _reconstruct_file(self, node: FileSystemNode) -> None: if not isinstance(node, FileSystemNode) or node.node_type != NodeType.FILE: return diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index f2edfd4b2..748397b15 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Final, Optional +from typing import Optional import dearpygui.dearpygui as dpg @@ -26,19 +26,19 @@ from sampletones_shared.types.application import Sender from sampletones_shared.types.callback import PathCallback -_TAGS: Final[FileBrowserTags] = FileBrowserTags( - panel=TAG_RECONSTRUCTIONS_BROWSER_PANEL, - tree=TAG_RECONSTRUCTIONS_BROWSER_TREE, - window_tree=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, - group_tree=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE, - group_controls=TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, - button_refresh=TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, -) - class GUIReconstructionsBrowserPanel(GUIReconstructionBrowserPanel): """The Reconstructions tab's browser, whose reconstructions open in the tab beside it.""" + _tags: FileBrowserTags = FileBrowserTags( + panel=TAG_RECONSTRUCTIONS_BROWSER_PANEL, + tree=TAG_RECONSTRUCTIONS_BROWSER_TREE, + window_tree=TAG_RECONSTRUCTIONS_BROWSER_WINDOW_TREE, + group_tree=TAG_RECONSTRUCTIONS_BROWSER_GROUP_TREE, + group_controls=TAG_RECONSTRUCTIONS_BROWSER_GROUP_CONTROLS, + button_refresh=TAG_RECONSTRUCTIONS_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, + ) + def __init__( self, tree: Tree, @@ -55,7 +55,6 @@ def __init__( super().__init__( tree=tree, tree_logic=tree_logic, - tags=_TAGS, scheduling=scheduling, language_manager=language_manager, status_bar=status_bar, diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index e0ba21e08..685b16410 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -1,5 +1,3 @@ -from typing import Final - from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, @@ -21,19 +19,19 @@ ) from sampletones_core.structures.tree import FileSystemNode, Tree -_TAGS: Final[FileBrowserTags] = FileBrowserTags( - panel=TAG_SEQUENCER_BROWSER_PANEL, - tree=TAG_SEQUENCER_BROWSER_TREE, - window_tree=TAG_SEQUENCER_BROWSER_WINDOW_TREE, - group_tree=TAG_SEQUENCER_BROWSER_GROUP_TREE, - group_controls=TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, - button_refresh=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, -) - class GUISequencerBrowserPanel(GUIReconstructionBrowserPanel): """The Sequencer tab's browser, whose reconstructions become the song's samples.""" + _tags: FileBrowserTags = FileBrowserTags( + panel=TAG_SEQUENCER_BROWSER_PANEL, + tree=TAG_SEQUENCER_BROWSER_TREE, + window_tree=TAG_SEQUENCER_BROWSER_WINDOW_TREE, + group_tree=TAG_SEQUENCER_BROWSER_GROUP_TREE, + group_controls=TAG_SEQUENCER_BROWSER_GROUP_CONTROLS, + button_refresh=TAG_SEQUENCER_BROWSER_BUTTON_REFRESH_RECONSTRUCTIONS, + ) + def __init__( self, tree: Tree, @@ -50,7 +48,6 @@ def __init__( super().__init__( tree=tree, tree_logic=tree_logic, - tags=_TAGS, scheduling=scheduling, language_manager=language_manager, status_bar=status_bar, diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 5fd9c4c8f..5e1ee81ee 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -18,7 +18,6 @@ from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.elements.tree.tags import FileBrowserTags from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -46,7 +45,6 @@ def __init__( tree: Tree, tree_logic: TreeLogicProtocol, *, - tags: FileBrowserTags, scheduling: SchedulingBehavior, language_manager: LanguageManager, status_bar: GUIStatusBar, @@ -59,7 +57,6 @@ def __init__( super().__init__( tree=tree, tree_logic=tree_logic, - tags=tags, scheduling=scheduling, search_label=language_manager["global.browser.label.search"], language_manager=language_manager, @@ -90,18 +87,11 @@ def _setup_handlers(self) -> None: node_type=NodeType.SAMPLE, status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), ), - NodeType.DIRECTORY: NodeHandler( - tag=self._get_node_handler_tag(NodeType.DIRECTORY), - node_type=NodeType.DIRECTORY, - item_click_callback=self._on_directory_node_clicked, - status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), - ), - NodeType.FILE: NodeHandler( - tag=self._get_node_handler_tag(NodeType.FILE), - node_type=NodeType.FILE, - item_click_callback=self._on_reconstruction_node_clicked, - item_double_click_callback=self._on_reconstruction_node_double_clicked, - status_bar_callback=self._create_status_bar_message_function_for_reconstruction_node(), + **self._create_file_system_handlers( + on_directory_clicked=self._on_directory_node_clicked, + on_file_clicked=self._on_reconstruction_node_clicked, + on_file_double_clicked=self._on_reconstruction_node_double_clicked, + file_status_message=self._create_status_bar_message_function_for_reconstruction_node(), ), } @@ -137,7 +127,7 @@ def _build_tree_node( if not isinstance(node, FileSystemNode): return - state.has_favorite_ancestor |= self._logic.is_node_favorite(node) or self._logic.has_favorite_ancestor(node) + self._mark_favorite_ancestry(node, state) if node.node_type == NodeType.DIRECTORY: should_expand = self._should_expand_node(node) self._append_spec( From d436b211a20c59351eb95b07e5ac0b2d8dd67c54 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 21:11:49 +0200 Subject: [PATCH 128/152] Added: context menu on browser container rows --- .../ui/elements/tree/tree.py | 56 ++- .../ui/panels/shared/browser.py | 95 ++++- src/sampletones_config/lang/en.yaml | 7 +- .../ui/elements/tree/test_status_messages.py | 31 ++ .../ui/panels/shared/__init__.py | 0 .../shared/test_container_context_menu.py | 398 ++++++++++++++++++ 6 files changed, 570 insertions(+), 17 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py create mode 100644 tests/unit/sampletones_application/ui/panels/shared/__init__.py create mode 100644 tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 1c9360cc2..521e135e3 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -282,7 +282,11 @@ def _append_spec( ) ) - def _finish_emit(self, root_tag: str, on_finished: Optional[VoidCallback]) -> None: + def _finish_emit( + self, + root_tag: str, + on_finished: Optional[VoidCallback], + ) -> None: """Complete a rebuild on the main thread: show the empty state, run the hook, unlock. The emitter runs this once its last batch has attached. When a filtered tree @@ -291,7 +295,10 @@ def _finish_emit(self, root_tag: str, on_finished: Optional[VoidCallback]) -> No an active search, and releasing the lock hands control back to interactive rebuilds. """ if root_tag == self.tree_tag and self.tree.is_filtered() and self.tree.get_root() is None: - dpg.add_text(self._language_manager["global.dialog.message.tree_no_results"], parent=root_tag) + dpg.add_text( + self._language_manager["global.dialog.message.tree_no_results"], + parent=root_tag, + ) if on_finished is not None: on_finished() @@ -380,6 +387,7 @@ def single_click_callback( user_data = dpg.get_item_user_data(app_data[1]) if item_click_callback is not None: item_click_callback(sender, app_data, user_data=user_data) + if status_bar_callback is not None: self._status_bar.set(status_bar_callback, user_data=user_data) @@ -479,8 +487,8 @@ def _create_status_bar_message_function_for_expandable_node( ) -> MessageCallback: """Builds the hover message of a row the reader opens, naming what that row holds. - A folder and a sample are both opened the same way and hold different things, so the message - follows the node it is asked about: the sample names the reconstructions it gathers. + A folder, a group and a sample are all opened the same way and hold different things, so the + message follows the node it is asked about: the sample names the reconstructions it gathers. """ def message_function( @@ -494,15 +502,19 @@ def message_function( if dpg_get_value(node_tag) else self._language_manager["global.dialog.template.expand"] ) - message = ( - self._language_manager["global.status.message.node_sample"] - if node.node_type == NodeType.SAMPLE - else self._language_manager["global.status.message.node_directory"] - ) - return message.format(expand_or_collapse=expand_or_collapse) + return self._expandable_node_message(node).format(expand_or_collapse=expand_or_collapse) return self._create_status_bar_message_function(message_function) + def _expandable_node_message(self, node: TreeNode) -> str: + match node.node_type: + case NodeType.SAMPLE: + return self._language_manager["global.status.message.node_sample"] + case NodeType.GROUP: + return self._language_manager["global.status.message.node_group"] + + return self._language_manager["global.status.message.node_directory"] + def _generate_node_tag(self, node: TreeNode) -> str: return compose_node_tag(node, panel_tag=self.tag) @@ -560,7 +572,10 @@ def _node_detail_items(self, node: TreeNode) -> List[Tuple[str, str]]: return [] - def _library_detail_items(self, key: InstructionLibraryKey) -> List[Tuple[str, str]]: + def _library_detail_items( + self, + key: InstructionLibraryKey, + ) -> List[Tuple[str, str]]: nes_frequency = round(key.sample_rate / key.frame_length) return [ (self._lbl_detail_sample_rate, format_sample_rate(key.sample_rate)), @@ -571,8 +586,13 @@ def _library_detail_items(self, key: InstructionLibraryKey) -> List[Tuple[str, s (self._lbl_detail_configuration, short_hash(key.config_hash)), ] - def _reconstruction_detail_items(self, fields: ConfigDirectoryFields) -> List[Tuple[str, str]]: - generators = ", ".join(generator.capitalized for generator in fields.generators) + def _reconstruction_detail_items( + self, + fields: ConfigDirectoryFields, + ) -> List[Tuple[str, str]]: + generators = ", ".join( + generator.capitalized for generator in fields.generators + ) # TODO: operation deserves a helper function return [ (self._lbl_detail_sample_rate, format_sample_rate(fields.sr)), (self._lbl_detail_nes_frequency, format_nes_frequency(fields.nf)), @@ -583,7 +603,10 @@ def _reconstruction_detail_items(self, fields: ConfigDirectoryFields) -> List[Tu ] def _add_context_menu_details(self, node: TreeNode) -> None: - add_detail_items(self._node_detail_items(node), color=self._colors.muted) + add_detail_items( + self._node_detail_items(node), + color=self._colors.muted, + ) def _add_context_menu_play_item(self, node: FileSystemNode) -> None: if not self._logic.is_playable_file(node): @@ -869,7 +892,10 @@ def _context_mark_as_favorite(self, node: TreeNode) -> None: self._logic.toggle_favorite(node) - def update_favorite_indicators(self, nodes: Sequence[FileSystemNode]) -> None: + def update_favorite_indicators( + self, + nodes: Sequence[FileSystemNode], + ) -> None: """Repaints the rows a favorite change reaches, and what each of them holds. A path reaches the panel as many rows as the views offer it — a reconstruction is listed both diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 5e1ee81ee..a5349f240 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -11,13 +11,14 @@ TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FILE_WAVE, ) -from sampletones_application.ui.elements.context_menu import context_menu +from sampletones_application.ui.elements.context_menu import add_detail_items, context_menu from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.utils.gui.dpg import dpg_set_value from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -81,10 +82,13 @@ def _setup_handlers(self) -> None: NodeType.GROUP: NodeHandler( tag=self._get_node_handler_tag(NodeType.GROUP), node_type=NodeType.GROUP, + item_click_callback=self._on_container_node_clicked, + status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), ), NodeType.SAMPLE: NodeHandler( tag=self._get_node_handler_tag(NodeType.SAMPLE), node_type=NodeType.SAMPLE, + item_click_callback=self._on_container_node_clicked, status_bar_callback=self._create_status_bar_message_function_for_expandable_node(), ), **self._create_file_system_handlers( @@ -162,6 +166,17 @@ def _resolve_other_theme_tag(self, node: TreeNode) -> str: return super()._resolve_other_theme_tag(node) + def _on_container_node_clicked( + self, + _sender: Sender, + app_data: Tuple[int, int], + user_data: Tuple[TreeNode, str], + ) -> None: + mouse_button, _ = app_data + node, _ = user_data + if mouse_button == dpg.mvMouseButton_Right: + self._show_container_context_menu(node) + def _on_directory_node_clicked( self, _sender: Sender, @@ -204,6 +219,84 @@ def _on_reconstruction_node_double_clicked( self._logic.cancel_autoplay() self._open_reconstruction(node) + def _show_container_context_menu(self, node: TreeNode) -> None: + """Offers what a row the browser invents can answer: what it gathers, and how it folds. + + A group or a sample stands for a facet of the reconstructions below it rather than for a path + on disk, so its menu reads the subtree — how many reconstructions it gathers, the rows folding + under it, the label the tree shows it by, and for a sample the audio its reconstructions were + made from. + """ + if node.node_type not in (NodeType.GROUP, NodeType.SAMPLE): + return + + with context_menu(): + self._add_context_menu_text(node) + self._add_context_menu_reconstruction_count(node) + self._add_context_menu_expansion_items(node) + self._add_context_menu_copy_name_item(node) + self._add_context_menu_sample_audio_item(node) + + def _add_context_menu_reconstruction_count(self, node: TreeNode) -> None: + """States how many reconstructions the row gathers, which is what the row stands for.""" + count = sum(1 for descendant in node.descendants if descendant.node_type == NodeType.FILE) + add_detail_items( + [(self._language_manager["global.context.label.detail_reconstructions"], str(count))], + color=self._colors.muted, + ) + + def _add_context_menu_expansion_items(self, node: TreeNode) -> None: + dpg.add_separator() + dpg.add_menu_item( + label=self._language_manager["global.context.label.expand_all"], + callback=lambda: self._set_subtree_expanded(node, expanded=True), + ) + dpg.add_menu_item( + label=self._language_manager["global.context.label.collapse_all"], + callback=lambda: self._set_subtree_expanded(node, expanded=False), + ) + + def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: + """Folds or unfolds the row together with every row below it holding something. + + Whether a row stands open is a fact of the widget alone, so each row is reached by the tag it + was built under and set directly. + """ + for container in (node, *node.descendants): + if container.children: + dpg_set_value(self._generate_node_tag(container), expanded) + + def _add_context_menu_copy_name_item(self, node: TreeNode) -> None: + """Offers the label the tree reads the row by, which for a folded chain names every level.""" + dpg.add_separator() + dpg.add_menu_item( + label=self._language_manager["global.context.label.copy_name"], + callback=lambda: dpg.set_clipboard_text(str(node.name)), + ) + + def _add_context_menu_sample_audio_item(self, node: TreeNode) -> None: + """Offers the audio behind a sample row, through any one reconstruction gathered under it. + + Every reconstruction under one sample was made from the same audio, so the first of them + answers for the row. + """ + if node.node_type != NodeType.SAMPLE: + return + + reconstruction = self._first_reconstruction_below(node) + if reconstruction is None: + return + + dpg.add_separator() + self._add_context_menu_locate_audio_item(reconstruction) + + def _first_reconstruction_below(self, node: TreeNode) -> Optional[FileSystemNode]: + for descendant in node.descendants: + if isinstance(descendant, FileSystemNode) and descendant.node_type == NodeType.FILE: + return descendant + + return None + def _show_directory_context_menu(self, node: FileSystemNode) -> None: if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY: return diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 0dc0d2640..ea606aadf 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -145,7 +145,10 @@ global.context.label.mark_as_favorite: "Mark as favorite" global.context.label.unmark_as_favorite: "Unmark as favorite" global.context.label.copy_filename: "Copy filename to clipboard" global.context.label.copy_path: "Copy path to clipboard" +global.context.label.copy_name: "Copy name to clipboard" global.context.label.open_in_explorer: "Open in explorer" +global.context.label.expand_all: "Expand all" +global.context.label.collapse_all: "Collapse all" global.context.label.add_to_sequencer: "Add to Sequencer" global.context.template.replace_sample: "Replace {sample}" global.context.label.locate_original_audio: "Locate original audio" @@ -160,6 +163,7 @@ global.context.label.detail_spectrum_method: "Generation method" global.context.label.detail_transformation_gamma: "Transformation gamma" global.context.label.detail_window_size: "Window size" global.context.label.detail_configuration: "Configuration" +global.context.label.detail_reconstructions: "Reconstructions" global.context.label.instrument_size: "Instrument size" global.context.label.sample_size: "Sample size" global.context.template.size_bytes: "{bytes} B" @@ -237,7 +241,8 @@ global.status.message.clear_search: "Clear the search filter." global.status.message.input: "Ctrl + click to type value." global.status.message.combo: "Click to select a value from the list." global.status.message.node_directory: "Click to {expand_or_collapse}. Right-click to open context menu." -global.status.message.node_sample: "Click to {expand_or_collapse} the reconstructions of this sample." +global.status.message.node_group: "Click to {expand_or_collapse} this group. Right-click to open context menu." +global.status.message.node_sample: "Click to {expand_or_collapse} the reconstructions of this sample. Right-click to open context menu." global.status.message.retuning_samples: "Retuning samples..." # ============================================================================= diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py b/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py new file mode 100644 index 000000000..6b9d9e893 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_status_messages.py @@ -0,0 +1,31 @@ +import pytest + +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_core.structures.tree import NodeType, TreeNode +from tests.suite.language import FakeLanguageManager + + +def _panel() -> GUISequencerBrowserPanel: + panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) + panel._language_manager = FakeLanguageManager() + return panel + + +class TestExpandableNodeMessage: + @pytest.mark.parametrize( + ("node_type", "key"), + [ + (NodeType.GROUP, "global.status.message.node_group"), + (NodeType.SAMPLE, "global.status.message.node_sample"), + (NodeType.DIRECTORY, "global.status.message.node_directory"), + ], + ) + def test_the_message_names_what_the_row_holds( + self, + node_type: NodeType, + key: str, + ) -> None: + """Each row the reader opens holds something of its own, and its hover message says so.""" + panel = _panel() + + assert panel._expandable_node_message(TreeNode("row", node_type=node_type)) == key diff --git a/tests/unit/sampletones_application/ui/panels/shared/__init__.py b/tests/unit/sampletones_application/ui/panels/shared/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py new file mode 100644 index 000000000..482eaecb1 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py @@ -0,0 +1,398 @@ +import contextlib +from pathlib import Path +from typing import Any, Dict, Final, Iterator, List, Optional, Sequence, Tuple + +import pytest + +from sampletones_application.ui.elements.tree import tree as tree_module +from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.tag import compose_node_tag +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.ui.panels.shared import browser as shared_browser_module +from sampletones_application.utils.palette.colors.literal import LiteralColor +from sampletones_core.structures.tree.node import FileSystemNode, NodeType, TreeNode +from tests.suite.language import FakeLanguageManager + +PANEL_TAG = "sequencer_browser" + +TEXT_COLOR = LiteralColor((128, 128, 128, 255)) + +EXPAND_LABEL = "Expand all" +COLLAPSE_LABEL = "Collapse all" +COPY_NAME_LABEL = "Copy name" +LOCATE_AUDIO_LABEL = "Locate original audio" +RECONSTRUCTIONS_LABEL = "Reconstructions" + +TEXTS: Final[Dict[str, str]] = { + "global.context.label.expand_all": EXPAND_LABEL, + "global.context.label.collapse_all": COLLAPSE_LABEL, + "global.context.label.copy_name": COPY_NAME_LABEL, + "global.context.label.locate_original_audio": LOCATE_AUDIO_LABEL, + "global.context.label.detail_reconstructions": RECONSTRUCTIONS_LABEL, +} + +CONTAINER_BUILDERS: Final[Tuple[str, ...]] = ( + "_add_context_menu_text", + "_add_context_menu_reconstruction_count", + "_add_context_menu_expansion_items", + "_add_context_menu_copy_name_item", + "_add_context_menu_sample_audio_item", +) + + +def _panel() -> GUISequencerBrowserPanel: + """Builds a panel without its DearPyGui-dependent constructor. + + The container menu reads the tree, the language manager and the panel tag its node tags are + composed under, so a running GUI context is unnecessary. + """ + panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) + panel.tag = PANEL_TAG + panel._language_manager = FakeLanguageManager(TEXTS) + panel._colors = TreeColors( + favorite=TEXT_COLOR, + node=TEXT_COLOR, + muted=TEXT_COLOR, + accent=TEXT_COLOR, + ) + panel.on_locate_original_audio = None + return panel + + +def _sample_tree() -> Tuple[TreeNode, TreeNode, Sequence[FileSystemNode]]: + """One sample gathering two configuration variants, under a frequency group.""" + root = TreeNode("root", node_type=NodeType.ROOT) + group = TreeNode("44.1 kHz", node_type=NodeType.GROUP, parent=root) + sample = TreeNode("kick.wav", node_type=NodeType.SAMPLE, parent=group) + variants = [ + FileSystemNode( + name, + node_type=NodeType.FILE, + filepath=Path("/reconstructions") / name, + parent=sample, + ) + for name in ("fft.stn", "cqt.stn") + ] + return group, sample, variants + + +class _MenuItemRecorder: + """Captures the keyword arguments of every menu item the builders register.""" + + def __init__(self) -> None: + self.items: List[Dict[str, Any]] = [] + self.separators = 0 + self.clipboard: List[str] = [] + + def add_menu_item(self, **kwargs: Any) -> int: + self.items.append(kwargs) + return 0 + + def add_separator(self, **kwargs: Any) -> int: + self.separators += 1 + return 0 + + def set_clipboard_text(self, text: str) -> None: + self.clipboard.append(text) + + @property + def labels(self) -> List[str]: + return [item["label"] for item in self.items] + + def item(self, label: str) -> Dict[str, Any]: + return next(item for item in self.items if item["label"] == label) + + +@pytest.fixture +def recorder(monkeypatch: pytest.MonkeyPatch) -> _MenuItemRecorder: + instance = _MenuItemRecorder() + monkeypatch.setattr(tree_module.dpg, "add_menu_item", instance.add_menu_item) + monkeypatch.setattr(tree_module.dpg, "add_separator", instance.add_separator) + monkeypatch.setattr(tree_module.dpg, "set_clipboard_text", instance.set_clipboard_text) + return instance + + +@pytest.fixture +def expanded(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]: + """Records the tag and open state of every row the expansion items reach.""" + calls: List[Tuple[str, bool]] = [] + monkeypatch.setattr( + shared_browser_module, + "dpg_set_value", + lambda tag, value: calls.append((tag, value)), + ) + return calls + + +@pytest.fixture +def details(monkeypatch: pytest.MonkeyPatch) -> List[Sequence[Tuple[str, str]]]: + """Records each block of read-only lines the menu states.""" + blocks: List[Sequence[Tuple[str, str]]] = [] + monkeypatch.setattr( + shared_browser_module, + "add_detail_items", + lambda items, **_kwargs: blocks.append(items), + ) + return blocks + + +@pytest.fixture +def built(monkeypatch: pytest.MonkeyPatch) -> List[str]: + """Replaces every container-menu builder with a record of its name, in call order.""" + names: List[str] = [] + + @contextlib.contextmanager + def _menu() -> Iterator[None]: + yield + + monkeypatch.setattr(shared_browser_module, "context_menu", _menu) + return names + + +def _record_builders( + panel: GUISequencerBrowserPanel, + built: List[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + for builder in CONTAINER_BUILDERS: + monkeypatch.setattr(panel, builder, lambda _argument, name=builder: built.append(name)) + + +class TestContainerMenuComposition: + def test_group_row_states_what_it_holds_before_what_it_offers( + self, + built: List[str], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = _panel() + group, _, _ = _sample_tree() + _record_builders(panel, built, monkeypatch) + + panel._show_container_context_menu(group) + + assert built == list(CONTAINER_BUILDERS) + + def test_sample_row_offers_the_same_items( + self, + built: List[str], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = _panel() + _, sample, _ = _sample_tree() + _record_builders(panel, built, monkeypatch) + + panel._show_container_context_menu(sample) + + assert built == list(CONTAINER_BUILDERS) + + @pytest.mark.parametrize("node_type", [NodeType.FILE, NodeType.DIRECTORY, NodeType.ROOT]) + def test_row_standing_for_a_path_opens_no_container_menu( + self, + built: List[str], + monkeypatch: pytest.MonkeyPatch, + node_type: NodeType, + ) -> None: + """The rows with a path of their own have menus of their own, offering the path items.""" + panel = _panel() + node = FileSystemNode("kick.stn", node_type=node_type, filepath=Path("/kick.stn")) + _record_builders(panel, built, monkeypatch) + + panel._show_container_context_menu(node) + + assert built == [] + + +class TestReconstructionCount: + def test_sample_row_counts_the_variants_it_gathers( + self, + details: List[Sequence[Tuple[str, str]]], + ) -> None: + panel = _panel() + _, sample, _ = _sample_tree() + + panel._add_context_menu_reconstruction_count(sample) + + assert details == [[(RECONSTRUCTIONS_LABEL, "2")]] + + def test_group_row_counts_every_reconstruction_below_it( + self, + details: List[Sequence[Tuple[str, str]]], + ) -> None: + """A group reports the whole subtree, so the containers between it and the files add nothing.""" + panel = _panel() + group, sample, _ = _sample_tree() + second_sample = TreeNode("snare.wav", node_type=NodeType.SAMPLE, parent=group) + FileSystemNode( + "fft.stn", + node_type=NodeType.FILE, + filepath=Path("/reconstructions/snare/fft.stn"), + parent=second_sample, + ) + + panel._add_context_menu_reconstruction_count(group) + + assert details == [[(RECONSTRUCTIONS_LABEL, "3")]] + + def test_row_gathering_nothing_reports_no_reconstruction( + self, + details: List[Sequence[Tuple[str, str]]], + ) -> None: + panel = _panel() + group = TreeNode("44.1 kHz", node_type=NodeType.GROUP) + + panel._add_context_menu_reconstruction_count(group) + + assert details == [[(RECONSTRUCTIONS_LABEL, "0")]] + + +class TestExpansionItems: + def test_both_directions_are_offered(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + group, _, _ = _sample_tree() + + panel._add_context_menu_expansion_items(group) + + assert recorder.labels == [EXPAND_LABEL, COLLAPSE_LABEL] + + def test_expanding_reaches_the_row_and_every_container_below_it( + self, + recorder: _MenuItemRecorder, + expanded: List[Tuple[str, bool]], + ) -> None: + panel = _panel() + group, sample, _ = _sample_tree() + + panel._add_context_menu_expansion_items(group) + recorder.item(EXPAND_LABEL)["callback"]() + + assert expanded == [ + (compose_node_tag(group, panel_tag=PANEL_TAG), True), + (compose_node_tag(sample, panel_tag=PANEL_TAG), True), + ] + + def test_collapsing_closes_the_same_rows( + self, + recorder: _MenuItemRecorder, + expanded: List[Tuple[str, bool]], + ) -> None: + panel = _panel() + group, sample, _ = _sample_tree() + + panel._add_context_menu_expansion_items(group) + recorder.item(COLLAPSE_LABEL)["callback"]() + + assert expanded == [ + (compose_node_tag(group, panel_tag=PANEL_TAG), False), + (compose_node_tag(sample, panel_tag=PANEL_TAG), False), + ] + + def test_leaf_rows_are_left_alone( + self, + recorder: _MenuItemRecorder, + expanded: List[Tuple[str, bool]], + ) -> None: + """A reconstruction row holds nothing to fold, so no expansion state is stated for it.""" + panel = _panel() + _, sample, variants = _sample_tree() + + panel._add_context_menu_expansion_items(sample) + recorder.item(EXPAND_LABEL)["callback"]() + + variant_tags = [compose_node_tag(variant, panel_tag=PANEL_TAG) for variant in variants] + assert [tag for tag, _ in expanded] == [compose_node_tag(sample, panel_tag=PANEL_TAG)] + assert all(tag not in variant_tags for tag, _ in expanded) + + +class TestCopyNameItem: + def test_clicking_copies_the_label_the_tree_reads(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + group, _, _ = _sample_tree() + + panel._add_context_menu_copy_name_item(group) + recorder.item(COPY_NAME_LABEL)["callback"]() + + assert recorder.clipboard == ["44.1 kHz"] + + def test_a_folded_chain_copies_every_level_of_its_label(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + folded = TreeNode("44.1 kHz·30 Hz·FFT", node_type=NodeType.GROUP) + + panel._add_context_menu_copy_name_item(folded) + recorder.item(COPY_NAME_LABEL)["callback"]() + + assert recorder.clipboard == ["44.1 kHz·30 Hz·FFT"] + + +class TestSampleAudioItem: + def test_sample_row_delegates_to_a_reconstruction_below_it(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + _, sample, variants = _sample_tree() + + panel._add_context_menu_sample_audio_item(sample) + + assert recorder.labels == [LOCATE_AUDIO_LABEL] + assert recorder.item(LOCATE_AUDIO_LABEL)["user_data"] is variants[0] + + def test_clicking_reports_the_reconstruction_path(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + _, sample, variants = _sample_tree() + located: List[Path] = [] + panel.on_locate_original_audio = located.append + + panel._add_context_menu_sample_audio_item(sample) + item = recorder.item(LOCATE_AUDIO_LABEL) + item["callback"](0, None, item["user_data"]) + + assert located == [variants[0].filepath] + + def test_group_row_offers_no_audio(self, recorder: _MenuItemRecorder) -> None: + """A group gathers reconstructions of many samples, so no one audio stands behind it.""" + panel = _panel() + group, _, _ = _sample_tree() + + panel._add_context_menu_sample_audio_item(group) + + assert recorder.labels == [] + + def test_sample_row_holding_no_reconstruction_offers_no_audio(self, recorder: _MenuItemRecorder) -> None: + panel = _panel() + sample = TreeNode("kick.wav", node_type=NodeType.SAMPLE) + + panel._add_context_menu_sample_audio_item(sample) + + assert recorder.labels == [] + + +class TestFirstReconstructionBelow: + def test_the_nearest_reconstruction_answers_for_the_row(self) -> None: + panel = _panel() + _, sample, variants = _sample_tree() + + assert panel._first_reconstruction_below(sample) is variants[0] + + def test_a_row_gathering_none_names_nothing(self) -> None: + panel = _panel() + sample = TreeNode("kick.wav", node_type=NodeType.SAMPLE) + + assert panel._first_reconstruction_below(sample) is None + + def test_containers_below_the_row_are_passed_over(self) -> None: + """A mirrored source folder under a group is not itself a reconstruction.""" + panel = _panel() + group = TreeNode("44.1 kHz", node_type=NodeType.GROUP) + directory = FileSystemNode( + "drums", + node_type=NodeType.DIRECTORY, + filepath=Path("/reconstructions/drums"), + parent=group, + ) + reconstruction = FileSystemNode( + "kick.stn", + node_type=NodeType.FILE, + filepath=Path("/reconstructions/drums/kick.stn"), + parent=directory, + ) + + found: Optional[FileSystemNode] = panel._first_reconstruction_below(group) + + assert found is reconstruction From faef770c434cdc6a0947f359e7fa215980a0c6b1 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 21:43:10 +0200 Subject: [PATCH 129/152] Added: reconstruction browser document --- docs/development/architecture.md | 4 +- docs/development/browser.md | 144 +++++++++++++++++++++++++++++ docs/development/bugs-and-todos.md | 1 - docs/guide/interface.md | 7 +- docs/index.md | 1 + 5 files changed, 153 insertions(+), 4 deletions(-) create mode 100644 docs/development/browser.md diff --git a/docs/development/architecture.md b/docs/development/architecture.md index 68ecab450..fceecb7c5 100644 --- a/docs/development/architecture.md +++ b/docs/development/architecture.md @@ -2,7 +2,7 @@ This document describes the design of `sampletones_application` — the GUI front-end of _SampleToNES_. It is prescriptive: it states the contracts each layer must honour, in the form they are enforced, and the rationale behind them. Use it as the reference when deciding where new code belongs. -Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, and the YAML configuration package has `docs/development/config-organization.md`. +Concrete classes and modules appear throughout as **examples** that anchor a rule; the rules bind every instance, named or not. Known deviations from these contracts are tracked in `docs/development/bugs-and-todos.md`. Coding-level rules live in `docs/development/guidelines.md`; the undo subsystem has its own design document, `docs/development/undo.md`, the audio transport has `docs/development/playback.md`, the reconstruction browser has `docs/development/browser.md`, and the YAML configuration package has `docs/development/config-organization.md`. --- @@ -259,6 +259,8 @@ They read the source as an AST through the shared layer in `sampletones_shared/m `logic/history/` implements the session-scoped undo engine (`HistoryManager`); its invariants and mechanics are documented in `docs/development/undo.md`. +`logic/reconstruction/browser/` builds the tree of reconstructions both browser tabs render (`BrowserManager`); its pipeline, node vocabulary and shaping rules are documented in `docs/development/browser.md`. + **Contracts:** - Logic classes produce view models and may therefore import `view_model/`; they import neither `ui/` nor `coordinators/`. - Logic classes never call DPG. diff --git a/docs/development/browser.md b/docs/development/browser.md new file mode 100644 index 000000000..3eaf1fc95 --- /dev/null +++ b/docs/development/browser.md @@ -0,0 +1,144 @@ +# The Reconstruction Browser + +This document governs the tree of reconstructions the **Reconstructions** and **Sequencer** tabs +share: how a reconstructions directory becomes rows, what a row stands for, and what it answers. +Consult it when changing what the browser lists, how a row reads, or what a click on one does. It +complements `docs/development/architecture.md` (layering and ownership) and +`docs/development/guidelines.md` (coding rules). + +--- + +## Principles + +1. **One reading of the disk feeds every view.** A refresh walks the reconstructions directory once + into a `ReconstructionScan`, and every branch is built from that record. The views therefore agree + about what exists by construction, and a folder name is parsed into its configuration fields once + per refresh. +2. **The model carries the shape; the panel carries the widgets.** Which rows exist, what they are + called, which of them fold together and in what order they sit are decided on the tree. Both tabs + render one model, so they show one shape, and each rule is exercised without a window. +3. **A row's identity is its path; its name is a label.** Favorites, the context menus, copy-path, + playback and opening a reconstruction all key on `filepath`. That is what frees a name to be + rewritten — a configuration directory renamed to its generator abbreviation, a chain of headings + joined into one row, a colliding label marked with its configuration hash. +4. **The browser writes the headings the disk states rather than holds.** A frequency pair, a + transformation, a source folder, one source audio: each becomes a row that carries no path of its + own. What such a row offers follows from the subtree beneath it. +5. **One thing may stand in several places.** A reconstruction is listed by the configuration that + produced it and again by the audio it was made from, so an action on the thing rather than on the + row asks for every row standing for it (`Tree.find_nodes`, `BrowserManager.nodes_at`) and hands + them to both tabs. +6. **Per-row work happens off the main thread.** A rebuild resolves each row into a `NodeSpec` on the + background worker — tag, label, font, theme, handler, open state — and the main thread creates the + widgets from those specs, spread across frames. + +--- + +## The pipeline + +`BrowserManager` (`logic/reconstruction/browser/manager.py`) owns the tree and runs a refresh in four +steps: **scan** the directory, **build** each branch from that one scan, **shape** what came out, and +**publish** it through `Tree.set_root`. `BrowserLogic` sits above it as the surface the coordinators +drive, and `get_all_reconstruction_files` reads the scan. + +| Stage | Module | What it does | +|---|---|---| +| Scan | `tree/scan.py` | `scan_reconstructions` walks the directory once, recording each folder with the configuration its name states and each `.stn` file beneath it | +| Records | `tree/entries/` | `DirectoryEntry`, `ReconstructionEntry`, `ReconstructionScan` — frozen, path-only, no widgets and no tree | +| Configuration branch | `tree/configurations/` | `branch.py` lays the scanned folders out as they sit; `grouping.py` lifts a top-level configuration directory under frequency ▶ transformation groups and names it by its generators; `naming.py` gives the remaining configuration directories friendly names, unique among their siblings | +| Sample branch | `tree/samples/` | `variants.py` regroups every top-level configuration directory's reconstructions by the audio they mirror (`SampleSource` → `SampleVariant`); `branch.py` rebuilds the mirrored folders as groups and gathers each audio's variants under one sample row, each labelled by its configuration | +| Shaping | `tree/prune.py`, `tree/collapse.py`, `tree/order.py` | Run in that order over each branch, deepest rows first | +| Containers | `tree/containers.py` | `find_or_create_group` and `find_or_create_sample` extend the heading of that name a parent already holds; each node type is looked up among the siblings of its own kind, so a folder and an audio sharing a name stay two rows | + +The policy the two branches share: a configuration directory sitting at the top level of the +reconstructions directory is the one lifted under groups and transposed into the sample view. A +configuration directory nested inside a plain folder keeps its friendly name where it sits, and a +reconstruction outside every configuration directory appears in the configuration branch, that being +the branch which follows the disk. + +## The node vocabulary + +`sampletones_core/structures/tree/` holds the nodes, all anytree-backed: + +* `TreeNode(name, node_type)` — a row and its kind. `NodeType.ROOT` for the container both branches + hang from, `GROUP` and `SAMPLE` for the headings the browser writes, `DIRECTORY` and `FILE` for what + the disk holds. +* `FileSystemNode(filepath)` — a row standing for a path. Favorites, playability, themes and the path + items all test for this class. +* `ConfigNode(config)` — a filesystem row belonging to a reconstruction configuration, carrying the + parsed `ConfigDirectoryFields`. It subclasses `FileSystemNode` so every reader of a path keeps + working, and the fields travel with the row, which is what lets a label, a tooltip and a font state + the configuration from the node already in hand. + +`create_directory_node` chooses between the last two from the fields the scan read. Which row carries +the configuration follows the branch: in the configuration branch it is the directory that names it, +and in the sample branch it is the variant leaf, since there the configuration is what distinguishes +one row from the next. + +## The shaping rules + +* **Prune** (`prune_empty_containers`) — a heading the browser wrote that gathers nothing leaves, + deepest first, so a whole chain of them goes at once and a reconstructions directory with nothing to + show stays silent. A folder the disk holds stays, since the configuration branch mirrors the disk. +* **Collapse** (`collapse_single_child_containers`) — a heading standing above a single row folds into + that row, which takes the joined name (`DISPLAY_SEPARATOR` between levels) and rises into its place. + The surviving row keeps its node type, path, configuration and children, so its click behaviour, + theme, context menu and favorite star carry over. A fold that would repeat a name already beside it + stays open instead, and the branch roots stay in place. With a single configuration present the + configuration branch reads as one row per reconstruction, and it grows back into groups as soon as a + second configuration arrives. +* **Order** (`order_children`) — containers ahead of leaves, then `natural_sort_key` over the label, so + a row sits where its displayed name puts it and `8 kHz` precedes `44.1 kHz`. The pass runs once every + label is final; the branches directly under the container root keep the order the builder states them + in. +* **Unique sibling labels** (`unique_display_names`, `sampletones_core/configs/display.py`) — where + siblings would read alike, every member of that label takes its short configuration hash. One rule + serves the generator directories under a transformation group, the nested configuration directories, + and the variants under a sample. + +## The panels + +The browsers form one line of inheritance, each level owning what it shares: + +* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the search box, the rebuild handshake, + spec collection, themes and fonts per row, the detail tooltip, the status-bar messages, and the + context-menu items every browser can offer. +* `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the + refresh control, the tree window, the folder-and-file handler pair, and enabling the card as the tree + locks and unlocks. A subclass declares its widgets as a `FileBrowserTags` class attribute and states + what its card and refresh control read. +* `GUIReconstructionBrowserPanel` (`ui/panels/shared/browser.py`) — the reconstruction browser: the + rows the two branches hold, the colour a group and a sample read in, and the context menus. The + Reconstructions and Sequencer panels below it name their widgets, their refresh control, and what + opening a reconstruction means in that tab. + +The Main tab's filesystem explorer and the Instructions tab's library catalogue sit on +`GUIFileBrowserPanel` as well, so the card, the search and the rebuild machinery are shared with them. + +**A rebuild** starts on the tree worker: `_launch_rebuild` takes the tree lock, brings the model up to +date, collects the rows into specs, and hands them to `TreeEmitter`, which clears the old rows and +stages the new ones in budget-sized batches so interactive callbacks run between slices. The +completion callback shows the empty state where one is called for, runs the panel's hook, and releases +the lock. Because a browser is asked to rebuild from either tab and from several places in the +application, exactly one rebuild is in flight at a time. + +**A row's tag** (`compose_node_tag`, `ui/elements/tree/tag.py`) joins the names above it, which reads +the row back to whoever inspects the widget tree, and appends a digest over the exact path of +`(node_type, name)` pairs. Rows the names alone spell alike — a folder and the audio beside it, two +labels differing only in spacing or case — therefore keep tags of their own. A tag is composed rather +than stored, so any holder of a node can address its row: this is how expanding a subtree, repainting a +star and applying a filter reach the widgets. + +**What a row answers** follows its kind. A reconstruction plays on a click, opens on a double click, +and offers its path items, the tab's own actions and the favorite mark. A directory offers its path +items and the favorite mark. A group or a sample stands for no path, so its menu reads the subtree: how +many reconstructions it gathers, expanding and collapsing everything below it, the label the tree shows +it by, and — on a sample — the audio its reconstructions were made from, answered through any one of +them. + +**Favorites are paths.** `TreeLogic.is_node_favorite` tests the row's path against the session's set, +and `has_favorite_ancestor` tests the path's parents, so a reconstruction reads as part of a favorite +folder wherever a view puts it — including the sample branch, whose headings carry no path. Since one +path reaches the panel as several rows, `application.py` resolves the toggled path into every row +standing for it and hands them to both tabs, and each row repaints with the ancestry its own path +carries. diff --git a/docs/development/bugs-and-todos.md b/docs/development/bugs-and-todos.md index f80bda099..198ab3bc4 100644 --- a/docs/development/bugs-and-todos.md +++ b/docs/development/bugs-and-todos.md @@ -4,7 +4,6 @@ * Interface scale * Tree navigation using keys -* Transposed topology of Reconstruction browser view into sample breakdown * Waveform LOD for zooming * Alt for scrolling graphs * Drag and drop diff --git a/docs/guide/interface.md b/docs/guide/interface.md index f77ba6578..a893dad51 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -37,8 +37,11 @@ reveals. [Configuration](configuration.md) explains each one. The **Reconstructions** tab is where you audition a reconstruction against the original, fine-tune it, and export it. -Open a saved reconstruction from the list on the left; if the current one has -unsaved edits, you are asked whether to save it first. You can play it back and +Open a saved reconstruction from the **Browser** on the left, which offers the +same files two ways: **By configuration** groups them by the settings they were +made with, and **By sample** gathers every version of one source audio together. +If the current reconstruction has unsaved edits, you are asked whether to save it +first. You can play it back and switch **Play audio source:** between **Reconstruction** and **Original audio** to compare the two, and **Locate original audio** re-links the source file if it has moved. diff --git a/docs/index.md b/docs/index.md index 3abf6df15..a2357a273 100644 --- a/docs/index.md +++ b/docs/index.md @@ -58,6 +58,7 @@ The [**development**](development/) section is for contributors. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. - [Sequencer blocks](development/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids. - [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file. +- [Reconstruction browser](development/browser.md) — how a reconstructions directory becomes the tree both browser tabs render. - [Configuration](development/config-organization.md) — how the YAML configuration package is laid out. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. - [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on. From 91e148391a6e90a37ad6f7d25dd2df1703047aa6 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 22:01:34 +0200 Subject: [PATCH 130/152] Refactored: tree filtering --- .../ui/elements/tree/filter.py | 28 +++ .../ui/elements/tree/tree.py | 96 ++++++---- .../structures/tree/__init__.py | 3 + src/sampletones_core/structures/tree/tree.py | 64 +------ .../structures/tree/visibility.py | 42 ++++ .../ui/elements/tree/test_favorites.py | 3 + .../ui/elements/tree/test_filter.py | 181 ++++++++++++++++++ .../structures/tree/test_tree.py | 122 +----------- .../structures/tree/test_visibility.py | 123 ++++++++++++ 9 files changed, 458 insertions(+), 204 deletions(-) create mode 100644 src/sampletones_application/ui/elements/tree/filter.py create mode 100644 src/sampletones_core/structures/tree/visibility.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_filter.py create mode 100644 tests/unit/sampletones_core/structures/tree/test_visibility.py diff --git a/src/sampletones_application/ui/elements/tree/filter.py b/src/sampletones_application/ui/elements/tree/filter.py new file mode 100644 index 000000000..c1615fc71 --- /dev/null +++ b/src/sampletones_application/ui/elements/tree/filter.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Final + + +@dataclass(frozen=True) +class TreeFilter: + """What a browser is currently asked to show, held by the panel showing it. + + Several browsers render one tree, so what each of them narrows to belongs to the panel: a query + typed in one tab leaves the other reading as it was. A filter is stated whole and replaced whole, + so the panel resolves what it shows in one place. + """ + + query: str + + @property + def is_active(self) -> bool: + """Whether the filter narrows what the browser shows.""" + return bool(self.query) + + def with_query(self, query: str) -> TreeFilter: + """The filter reading a new query, keeping everything else it states.""" + return replace(self, query=query) + + +NO_FILTER: Final[TreeFilter] = TreeFilter(query="") diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 521e135e3..54c543627 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -43,6 +43,7 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.emitter import TreeEmitter +from sampletones_application.ui.elements.tree.filter import NO_FILTER, TreeFilter from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.spec import NodeSpec @@ -80,6 +81,8 @@ NodeType, Tree, TreeNode, + TreeVisibility, + resolve_visibility, ) from sampletones_shared.paths import extensions from sampletones_shared.types.application import Sender @@ -122,6 +125,9 @@ def __init__( self._pending_specs: List[NodeSpec] = [] self._emitter = TreeEmitter(scheduling=scheduling) + self._filter: TreeFilter = NO_FILTER + self._search_visibility: Optional[TreeVisibility] = None + self._selected_node_tag: Optional[Union[str, int]] = None self._search_input_tag: Optional[str] = None self._search_button_tag: Optional[str] = None @@ -170,9 +176,9 @@ def _launch_rebuild( 1. A rebuild already in flight holds the lock, so return and let it finish. 2. Acquire the lock; responsibility for releasing it passes to the emit pipeline. - 3. ``refresh`` updates the model, then ``collect`` resolves it into a flat - :class:`NodeSpec` list -- every per-node decision, including the filesystem - content check, happens here on the worker. + 3. ``refresh`` updates the model and the filter is resolved against it, then + ``collect`` resolves it into a flat :class:`NodeSpec` list -- every per-node + decision, including the filesystem content check, happens here on the worker. 4. Post the specs to :class:`TreeEmitter` through the queue. This crosses back to the main thread, where the emitter clears the old tree and stages the new nodes across frames. @@ -188,12 +194,18 @@ def _launch_rebuild( handed_off = False try: refresh() + self._resolve_filter() specs = collect() CallbackQueue.add( self._emitter.emit, tuple(specs), root_tag, - partial(self._finish_emit, root_tag, on_finished), + partial( + self._finish_emit, + root_tag, + on_finished, + len(specs), + ), priority=self._scheduling.emit.priority, ) handed_off = True @@ -286,15 +298,16 @@ def _finish_emit( self, root_tag: str, on_finished: Optional[VoidCallback], + drawn_rows: int, ) -> None: """Complete a rebuild on the main thread: show the empty state, run the hook, unlock. - The emitter runs this once its last batch has attached. When a filtered tree - resolved to an empty model, the no-results message fills the cleared tree so the - filter outcome is visible. Applying the filter here lets late-emitted nodes honour + The emitter runs this once its last batch has attached. A filtered rebuild that drew no + row fills the cleared tree with the no-results message, so the filter's outcome is + legible where the rows would be. Applying the filter here lets late-emitted nodes honour an active search, and releasing the lock hands control back to interactive rebuilds. """ - if root_tag == self.tree_tag and self.tree.is_filtered() and self.tree.get_root() is None: + if root_tag == self.tree_tag and self._filter.is_active and not drawn_rows: dpg.add_text( self._language_manager["global.dialog.message.tree_no_results"], parent=root_tag, @@ -303,7 +316,7 @@ def _finish_emit( if on_finished is not None: on_finished() - if self.tree.is_filtered(): + if self._filter.is_active: self.update_tree_visibility() self.unlock() @@ -451,14 +464,11 @@ def _build_tree_node( def _has_relevant_content(self, node: TreeNode) -> bool: ... def _should_expand_node(self, node: TreeNode) -> bool: - if not self.tree.is_filtered(): + """Whether the row is emitted standing open, which the rows leading to a search result are.""" + if self._search_visibility is None: return False - for descendant in node.descendants: - if self.tree.is_node_visible(descendant): - return True - - return False + return self._search_visibility.should_expand(node) def _create_status_bar_message_function( self, @@ -714,21 +724,42 @@ def _on_replace_in_sequencer( self.call(self.on_replace_in_sequencer, user_data.filepath) def _on_search_changed(self, _sender: Sender, query: str) -> None: - if query: - self.apply_filter(query, self._default_search_predicate) - else: - self.clear_filter() - + self._set_filter(self._filter.with_query(query)) self._logic.schedule_search_update(query) def _on_clear_search_clicked(self) -> None: if self._search_input_tag is not None: dpg.set_value(self._search_input_tag, "") - self.clear_filter() - + self._set_filter(self._filter.with_query("")) self._logic.schedule_search_update("") + def _set_filter(self, tree_filter: TreeFilter) -> None: + """Take the filter the browser is now asked to show, and resolve what it leaves on screen.""" + self._filter = tree_filter + self._resolve_filter() + + def _resolve_filter(self) -> None: + """Resolve the filter against the model as it stands, which a rebuild does once per pass. + + Reading the model rather than the rows lets the resolution run on the rebuild worker, and + keeps a filter typed before a refresh answering for the rows that refresh brings. + """ + self._search_visibility = self._resolve_search_visibility() + + def _resolve_search_visibility(self) -> Optional[TreeVisibility]: + """The rows the search query names, and nothing to narrow by while no query is typed.""" + query = self._filter.query + if not query: + return None + + return resolve_visibility( + self.tree.find_nodes( + TreeNode, + lambda node: self._default_search_predicate(node, query), + ) + ) + def _default_search_predicate(self, node: TreeNode, query: str) -> bool: return query.lower() in node.name.lower() @@ -736,6 +767,11 @@ def _default_search_predicate(self, node: TreeNode, query: str) -> bool: def rebuild_tree(self) -> None: ... def update_tree_visibility(self) -> None: + """Show the rows the search names and hide the rest, over the rows already on screen. + + Runs on the main thread once the typing settles, so a query narrows what is drawn in place + of asking for a rebuild. + """ root = self.tree.get_root() if root is None: return @@ -748,21 +784,17 @@ def _update_node_visibility_recursive(self, node: TreeNode) -> None: if not dpg.does_item_exist(node_tag): return - is_visible = self.tree.is_node_visible(node) - dpg.configure_item(node_tag, show=is_visible) + dpg.configure_item(node_tag, show=self._is_node_visible(node)) for child in node.children: self._update_node_visibility_recursive(child) - def apply_filter( - self, - query: str, - predicate: Callable[[TreeNode, str], bool], - ) -> None: - self.tree.apply_filter(query, predicate) + def _is_node_visible(self, node: TreeNode) -> bool: + """Whether the search shows the row, which every row on screen reads as while none is typed.""" + if self._search_visibility is None: + return True - def clear_filter(self) -> None: - self.tree.clear_filter() + return self._search_visibility.is_visible(node) def _apply_node_theme( self, diff --git a/src/sampletones_core/structures/tree/__init__.py b/src/sampletones_core/structures/tree/__init__.py index 3c1dacf67..94a9bdcfd 100644 --- a/src/sampletones_core/structures/tree/__init__.py +++ b/src/sampletones_core/structures/tree/__init__.py @@ -4,6 +4,7 @@ from .traversal import TreeTraversal, traverse from .tree import Tree from .type import NodeType +from .visibility import TreeVisibility, resolve_visibility __all__ = [ "Arguments", @@ -15,6 +16,8 @@ "Tree", "TreeNode", "TreeTraversal", + "TreeVisibility", "create_directory_node", + "resolve_visibility", "traverse", ] diff --git a/src/sampletones_core/structures/tree/tree.py b/src/sampletones_core/structures/tree/tree.py index 1b8ed0025..143b6e522 100644 --- a/src/sampletones_core/structures/tree/tree.py +++ b/src/sampletones_core/structures/tree/tree.py @@ -1,4 +1,4 @@ -from typing import Callable, Dict, Optional, Sequence, Tuple, Type, TypeVar +from typing import Callable, Optional, Sequence, Tuple, Type, TypeVar from anytree import PreOrderIter @@ -8,64 +8,22 @@ class Tree: + """The rows a view renders, held as one root the whole shape hangs from. + + The tree states which rows exist, what they are called and how they nest, and every view reading + it shows that one shape. What a view narrows to is the view's own, so several views share a tree + and each of them filters on its own. + """ + def __init__(self, root: Optional[TreeNode] = None) -> None: self.root = root - self._filter_query: Optional[str] = None - self._node_visibility: Dict[TreeNode, bool] = {} def set_root(self, root: Optional[TreeNode]) -> None: self.root = root - self.clear_filter() def get_root(self) -> Optional[TreeNode]: return self.root - def apply_filter( - self, - query: str, - predicate: Callable[[TreeNode, str], bool], - ) -> None: - if not self.root: - self._filter_query = query - self._node_visibility = {} - return - - if not query: - self.clear_filter() - return - - self._filter_query = query - matching_nodes = {node for node in PreOrderIter(self.root) if predicate(node, query)} - - if not matching_nodes: - self._node_visibility = {node: False for node in PreOrderIter(self.root)} - return - - nodes_to_show = set(matching_nodes) - for node in matching_nodes: - current = node.parent - while current is not None: - nodes_to_show.add(current) - current = current.parent - - for descendant in PreOrderIter(node): - nodes_to_show.add(descendant) - - self._node_visibility = {node: node in nodes_to_show for node in PreOrderIter(self.root)} - - def clear_filter(self) -> None: - self._filter_query = None - self._node_visibility = {} - - def is_filtered(self) -> bool: - return self._filter_query is not None - - def is_node_visible(self, node: TreeNode) -> bool: - if not self.is_filtered(): - return True - - return self._node_visibility.get(node, False) - def find_nodes( self, node_class: Type[TreeNodeT], @@ -95,8 +53,4 @@ def collect_leaves(self) -> Sequence[TreeNode]: if not self.root: return [] - leaves = [node for node in PreOrderIter(self.root) if node.is_leaf] - if self.is_filtered(): - return [leaf for leaf in leaves if self.is_node_visible(leaf)] - - return leaves + return [node for node in PreOrderIter(self.root) if node.is_leaf] diff --git a/src/sampletones_core/structures/tree/visibility.py b/src/sampletones_core/structures/tree/visibility.py new file mode 100644 index 000000000..7c9fbbc1a --- /dev/null +++ b/src/sampletones_core/structures/tree/visibility.py @@ -0,0 +1,42 @@ +from dataclasses import dataclass +from typing import FrozenSet, Iterable + +from .node import TreeNode + + +@dataclass(frozen=True) +class TreeVisibility: + """The rows a criterion keeps on screen, held as the rows it named and the rows standing above them. + + A named row stays, and so do the rows leading down to it and the rows it holds: a named file is + read under the folders it sits in, and a named folder shows what it gathers. Keeping the named + rows and their ancestors alone holds the memory to the size of what was found, and a row below a + match is answered from its own path upwards. + """ + + matches: FrozenSet[TreeNode] + ancestors: FrozenSet[TreeNode] + + def is_visible(self, node: TreeNode) -> bool: + """Whether the row stays on screen: it was named, it leads to a named row, or one holds it.""" + if node in self.matches or node in self.ancestors: + return True + + return any(ancestor in self.matches for ancestor in node.ancestors) + + def should_expand(self, node: TreeNode) -> bool: + """Whether the row stands open, which a named row does and so does every row above one.""" + return node in self.matches or node in self.ancestors + + +def resolve_visibility(matches: Iterable[TreeNode]) -> TreeVisibility: + """The visibility a set of named rows resolves to, read once per pass over the tree. + + Args: + matches: The rows a criterion named, in any order. + """ + matched = frozenset(matches) + return TreeVisibility( + matches=matched, + ancestors=frozenset(ancestor for node in matched for ancestor in node.ancestors), + ) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index 488ec6727..411f89b54 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -7,6 +7,7 @@ TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FAVORITE_CHILD, ) +from sampletones_application.ui.elements.tree.filter import NO_FILTER from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.spec import NodeSpec from sampletones_application.ui.elements.tree.state import TreeNodeState @@ -77,6 +78,8 @@ def build_panel( """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tree = tree + panel._filter = NO_FILTER + panel._search_visibility = None monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( panel, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py new file mode 100644 index 000000000..e7a60b19b --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py @@ -0,0 +1,181 @@ +from typing import Dict, List, Set, Type + +from sampletones_application.ui.elements.tree.filter import NO_FILTER, TreeFilter +from sampletones_application.ui.panels.reconstruction.browser import GUIReconstructionsBrowserPanel +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.ui.panels.shared.browser import GUIReconstructionBrowserPanel +from sampletones_core.structures.tree import NodeType, Tree, TreeNode + + +class FakeTreeLogic: + """Stands in for the logic a panel schedules the search on, recording what it was asked for.""" + + def __init__(self) -> None: + self.scheduled_queries: List[str] = [] + + def schedule_search_update(self, query: str) -> None: + self.scheduled_queries.append(query) + + +def browser_tree() -> Tree: + """Builds the shape both browser views give one reconstructions directory, a row per label.""" + root = TreeNode("Root", node_type=NodeType.ROOT) + configurations = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root) + TreeNode("song", node_type=NodeType.FILE, parent=configurations) + TreeNode("other", node_type=NodeType.FILE, parent=configurations) + samples = TreeNode("By sample", node_type=NodeType.GROUP, parent=root) + sample = TreeNode("sample", node_type=NodeType.SAMPLE, parent=samples) + TreeNode("variant", node_type=NodeType.FILE, parent=sample) + return Tree(root=root) + + +def rows_of(tree: Tree) -> Dict[str, TreeNode]: + """The rows a tree holds, read by the label each of them carries.""" + root = tree.get_root() + assert root is not None + return {node.name: node for node in (root, *root.descendants)} + + +def build_panel( + tree: Tree, + panel_class: Type[GUIReconstructionBrowserPanel] = GUISequencerBrowserPanel, +) -> GUIReconstructionBrowserPanel: + """Builds a browser panel holding a filter, with the tree it reads and the logic it schedules on. + + Resolving a filter reads the model alone, so the panel needs neither widgets nor a search box. + """ + panel = panel_class.__new__(panel_class) + panel.tree = tree + panel._logic = FakeTreeLogic() + panel._search_input_tag = None + panel._filter = NO_FILTER + panel._search_visibility = None + return panel + + +def visible_rows(panel: GUIReconstructionBrowserPanel, tree: Tree) -> Set[str]: + return {name for name, node in rows_of(tree).items() if panel._is_node_visible(node)} + + +class TestFilterComposition: + def test_a_filter_stating_nothing_narrows_nothing(self) -> None: + assert not NO_FILTER.is_active + + def test_a_filter_carrying_a_query_narrows(self) -> None: + assert NO_FILTER.with_query("song").is_active + + def test_dropping_the_query_leaves_the_filter_narrowing_nothing(self) -> None: + assert not NO_FILTER.with_query("song").with_query("").is_active + + def test_the_filter_a_new_one_was_taken_from_reads_as_it_did(self) -> None: + original = TreeFilter(query="song") + original.with_query("other") + assert original.query == "song" + + +class TestPanelOwnedFilter: + def test_a_query_shows_the_rows_it_names_and_the_rows_above_them(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + panel._on_search_changed(None, "other") + + assert visible_rows(panel, tree) == {"Root", "By configuration", "other"} + + def test_a_query_naming_a_container_shows_what_it_gathers(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + panel._on_search_changed(None, "sample") + + assert visible_rows(panel, tree) == {"Root", "By sample", "sample", "variant"} + + def test_no_query_shows_every_row(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + assert visible_rows(panel, tree) == set(rows_of(tree)) + + def test_clearing_the_search_shows_every_row_again(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + panel._on_search_changed(None, "other") + panel._on_clear_search_clicked() + + assert visible_rows(panel, tree) == set(rows_of(tree)) + + def test_the_search_is_scheduled_as_it_is_typed_and_as_it_is_cleared(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + logic = panel._logic + + panel._on_search_changed(None, "oth") + panel._on_clear_search_clicked() + + assert logic.scheduled_queries == ["oth", ""] + + +class TestTwoPanelsOverOneTree: + """Both reconstruction browsers render one tree, and each of them narrows to its own filter.""" + + def test_a_query_in_one_panel_leaves_the_other_reading_as_it_was(self) -> None: + tree = browser_tree() + searching = build_panel(tree, GUISequencerBrowserPanel) + untouched = build_panel(tree, GUIReconstructionsBrowserPanel) + + searching._on_search_changed(None, "other") + + assert visible_rows(untouched, tree) == set(rows_of(tree)) + assert not untouched._filter.is_active + + def test_each_panel_narrows_to_the_query_it_was_given(self) -> None: + tree = browser_tree() + first = build_panel(tree, GUISequencerBrowserPanel) + second = build_panel(tree, GUIReconstructionsBrowserPanel) + + first._on_search_changed(None, "other") + second._on_search_changed(None, "variant") + + assert visible_rows(first, tree) == {"Root", "By configuration", "other"} + assert visible_rows(second, tree) == {"Root", "By sample", "sample", "variant"} + + +class TestFilterAcrossARebuild: + def test_a_query_answers_for_the_rows_a_refresh_brings(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + panel._on_search_changed(None, "arrival") + + root = TreeNode("Root", node_type=NodeType.ROOT) + group = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root) + arrival = TreeNode("arrival", node_type=NodeType.FILE, parent=group) + tree.set_root(root) + panel._resolve_filter() + + assert panel._is_node_visible(arrival) + assert visible_rows(panel, tree) == {"Root", "By configuration", "arrival"} + + +class TestExpandedRows: + def test_a_row_leading_to_a_result_is_emitted_open(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + panel._on_search_changed(None, "other") + + assert panel._should_expand_node(rows_of(tree)["By configuration"]) + + def test_a_row_beside_the_way_in_is_emitted_folded(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + panel._on_search_changed(None, "other") + + assert not panel._should_expand_node(rows_of(tree)["By sample"]) + + def test_no_query_leaves_every_row_as_it_stands(self) -> None: + tree = browser_tree() + panel = build_panel(tree) + + assert not any(panel._should_expand_node(node) for node in rows_of(tree).values()) diff --git a/tests/unit/sampletones_core/structures/tree/test_tree.py b/tests/unit/sampletones_core/structures/tree/test_tree.py index a0d2f2357..0ec7dad6a 100644 --- a/tests/unit/sampletones_core/structures/tree/test_tree.py +++ b/tests/unit/sampletones_core/structures/tree/test_tree.py @@ -1,4 +1,3 @@ -from dataclasses import dataclass from pathlib import Path from typing import Final, List @@ -7,15 +6,10 @@ from sampletones_core.structures.tree.node import FileSystemNode, TreeNode from sampletones_core.structures.tree.tree import Tree from sampletones_core.structures.tree.type import NodeType -from tests.suite.case import BaseTestCase SONG_PATH: Final[Path] = Path("/reconstructions/song.stn") -def name_predicate(node: TreeNode, query: str) -> bool: - return query in node.name - - @pytest.fixture def all_nodes() -> List[TreeNode]: root = TreeNode("root", NodeType.ROOT) @@ -48,110 +42,10 @@ def test_get_root_returns_root( ) -> None: assert tree.get_root() is all_nodes[0] - 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]) - assert not tree.is_filtered() - - -class TestTreeFilter: - @dataclass(frozen=True, kw_only=True) - class TestCase(BaseTestCase): - label: str - query: str - expected_visible_names: frozenset[str] - expected_hidden_names: frozenset[str] - - test_cases = ( - TestCase( - label="match_leaf", - query="leaf_ba", - expected_visible_names=frozenset({"root", "child_b", "leaf_ba"}), - expected_hidden_names=frozenset({"child_a", "leaf_aa", "leaf_ab"}), - ), - TestCase( - label="match_internal", - query="child_a", - expected_visible_names=frozenset( - { - "root", - "child_a", - "leaf_aa", - "leaf_ab", - } - ), - expected_hidden_names=frozenset({"child_b", "leaf_ba"}), - ), - TestCase( - label="no_match", - query="xyz", - expected_visible_names=frozenset(), - expected_hidden_names=frozenset( - { - "root", - "child_a", - "child_b", - "leaf_aa", - "leaf_ab", - "leaf_ba", - } - ), - ), - ) - - def test_no_filter_all_nodes_visible( - self, - tree: Tree, - all_nodes: List[TreeNode], - ) -> None: - for node in all_nodes: - assert tree.is_node_visible(node) - - def test_is_filtered_false_initially(self, tree: Tree) -> None: - assert not tree.is_filtered() - - def test_is_filtered_true_after_apply(self, tree: Tree) -> None: - tree.apply_filter("root", name_predicate) - assert tree.is_filtered() - - def test_filter_empty_query_clears_filter(self, tree: Tree) -> None: - tree.apply_filter("child_a", name_predicate) - tree.apply_filter("", name_predicate) - assert not tree.is_filtered() - - def test_clear_filter_makes_all_nodes_visible( - self, - tree: Tree, - all_nodes: List[TreeNode], - ) -> None: - tree.apply_filter("leaf_ba", name_predicate) - tree.clear_filter() - for node in all_nodes: - assert tree.is_node_visible(node) - - def test_filter_on_empty_tree_is_active(self) -> None: - t = Tree() - t.apply_filter("x", name_predicate) - assert t.is_filtered() - - @pytest.mark.parametrize("case", 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: - assert tree.is_node_visible(node), f"{node.name!r} should be visible for query {case.query!r}" - elif node.name in case.expected_hidden_names: - assert not tree.is_node_visible(node), f"{node.name!r} should be hidden for query {case.query!r}" + def test_set_root_replaces_the_shape(self, tree: Tree) -> None: + replacement = TreeNode("replacement", NodeType.ROOT) + tree.set_root(replacement) + assert tree.get_root() is replacement class TestTreeCollectLeaves: @@ -165,16 +59,10 @@ def test_singleton_root_is_its_own_leaf(self) -> None: assert len(leaves) == 1 assert leaves[0] is root - def test_returns_all_leaves_without_filter(self, tree: Tree) -> None: + def test_every_leaf_the_shape_holds_is_answered(self, tree: Tree) -> None: leaf_names = {leaf.name for leaf in tree.collect_leaves()} assert leaf_names == {"leaf_aa", "leaf_ab", "leaf_ba"} - def test_filtered_leaves_exclude_hidden(self, tree: Tree) -> None: - tree.apply_filter("leaf_ba", name_predicate) - leaves = tree.collect_leaves() - assert len(leaves) == 1 - assert leaves[0].name == "leaf_ba" - class TestTreeFindNodes: @staticmethod diff --git a/tests/unit/sampletones_core/structures/tree/test_visibility.py b/tests/unit/sampletones_core/structures/tree/test_visibility.py new file mode 100644 index 000000000..abd278307 --- /dev/null +++ b/tests/unit/sampletones_core/structures/tree/test_visibility.py @@ -0,0 +1,123 @@ +from dataclasses import dataclass +from typing import Dict, List + +import pytest + +from sampletones_core.structures.tree.node import TreeNode +from sampletones_core.structures.tree.type import NodeType +from sampletones_core.structures.tree.visibility import TreeVisibility, resolve_visibility +from tests.suite.case import BaseTestCase + + +@pytest.fixture +def nodes() -> Dict[str, TreeNode]: + root = TreeNode("root", NodeType.ROOT) + child_a = TreeNode("child_a", NodeType.DIRECTORY, parent=root) + child_b = TreeNode("child_b", NodeType.DIRECTORY, parent=root) + leaf_aa = TreeNode("leaf_aa", NodeType.FILE, parent=child_a) + leaf_ab = TreeNode("leaf_ab", NodeType.FILE, parent=child_a) + leaf_ba = TreeNode("leaf_ba", NodeType.FILE, parent=child_b) + return { + node.name: node + for node in ( + root, + child_a, + child_b, + leaf_aa, + leaf_ab, + leaf_ba, + ) + } + + +def visibility_of( + nodes: Dict[str, TreeNode], + matched_names: List[str], +) -> TreeVisibility: + return resolve_visibility(nodes[name] for name in matched_names) + + +class TestVisibleRows: + @dataclass(frozen=True, kw_only=True) + class TestCase(BaseTestCase): + label: str + matched_names: List[str] + expected_visible_names: frozenset[str] + + test_cases = ( + TestCase( + label="a_named_leaf_is_read_under_the_rows_holding_it", + matched_names=["leaf_ba"], + expected_visible_names=frozenset({"root", "child_b", "leaf_ba"}), + ), + TestCase( + label="a_named_row_shows_what_it_gathers", + matched_names=["child_a"], + expected_visible_names=frozenset({"root", "child_a", "leaf_aa", "leaf_ab"}), + ), + TestCase( + label="two_named_rows_each_keep_their_own_way_in", + matched_names=["leaf_aa", "leaf_ba"], + expected_visible_names=frozenset( + { + "root", + "child_a", + "leaf_aa", + "child_b", + "leaf_ba", + } + ), + ), + TestCase( + label="nothing_named_keeps_nothing", + matched_names=[], + expected_visible_names=frozenset(), + ), + ) + + @pytest.mark.parametrize("case", test_cases, ids=lambda case: case.label) + def test_the_rows_a_match_keeps( + self, + nodes: Dict[str, TreeNode], + case: TestCase, + ) -> None: + visibility = visibility_of(nodes, case.matched_names) + visible_names = {name for name, node in nodes.items() if visibility.is_visible(node)} + assert visible_names == case.expected_visible_names + + +class TestOpenRows: + def test_every_row_above_a_match_stands_open(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, ["leaf_ba"]) + open_names = {name for name, node in nodes.items() if visibility.should_expand(node)} + assert open_names == {"root", "child_b", "leaf_ba"} + + def test_a_row_beside_the_way_in_stays_folded(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, ["leaf_ba"]) + assert not visibility.should_expand(nodes["child_a"]) + + def test_a_row_below_a_match_stays_folded(self, nodes: Dict[str, TreeNode]) -> None: + """A match shows what it gathers as it stands, so its own rows keep the shape they had.""" + visibility = visibility_of(nodes, ["child_a"]) + assert visibility.is_visible(nodes["leaf_aa"]) + assert not visibility.should_expand(nodes["leaf_aa"]) + + def test_nothing_named_leaves_every_row_folded(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, []) + assert not any(visibility.should_expand(node) for node in nodes.values()) + + +class TestResolvedSets: + def test_the_named_rows_are_held_as_they_were_given(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, ["leaf_aa", "leaf_ab"]) + assert visibility.matches == frozenset({nodes["leaf_aa"], nodes["leaf_ab"]}) + + def test_only_the_rows_above_a_match_are_held_beside_them(self, nodes: Dict[str, TreeNode]) -> None: + """What a match holds is answered from a path, so the sets stay the size of what was found.""" + visibility = visibility_of(nodes, ["child_a"]) + assert visibility.ancestors == frozenset({nodes["root"]}) + + def test_a_match_above_another_is_held_in_both_sets(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, ["child_a", "leaf_aa"]) + assert nodes["child_a"] in visibility.matches + assert nodes["child_a"] in visibility.ancestors From 3ea5bd09b4f8621c0dc070c2234ff9534ac1b231 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 22:29:37 +0200 Subject: [PATCH 131/152] Added: favorites-only tree filter --- .../categories/elements/global_.py | 1 + .../ui/elements/tree/browser.py | 15 +- .../ui/elements/tree/filter.py | 15 +- .../ui/elements/tree/tree.py | 81 ++++- src/sampletones_config/lang/en.yaml | 1 + .../ui/elements/tree/test_favorites.py | 1 + .../ui/elements/tree/test_favorites_filter.py | 288 ++++++++++++++++++ .../ui/elements/tree/test_filter.py | 13 +- 8 files changed, 402 insertions(+), 13 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 7d1fbec7c..1325284d6 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -160,6 +160,7 @@ class GraphElements(AbstractElement): class GlobalMessageElements(AbstractElement): TREE_NO_RESULTS = "tree_no_results" + TREE_NO_FAVORITES = "tree_no_favorites" INVALID_METADATA_ERROR = "invalid_metadata_error" RECONSTRUCTION_NO_DATA = "reconstruction_no_data" RECONSTRUCTION_SAVED_SUCCESSFULLY = "reconstruction_saved_successfully" diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index d759daad0..097a349d7 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -21,7 +21,7 @@ from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.parallelization.thread import concurrent from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree -from sampletones_shared.types.callback import Callback, MessageCallback +from sampletones_shared.types.callback import Callback, MessageCallback, VoidCallback class GUIFileBrowserPanel(GUITreePanel, ABC): @@ -206,8 +206,16 @@ def refresh(self) -> None: @concurrent(wait=False, method_bound=True) def rebuild_tree(self) -> None: + self._launch_tree_rebuild(self._refresh_model) + + @concurrent(wait=False, method_bound=True) + def redraw_tree(self) -> None: + self._launch_tree_rebuild(self._keep_model) + + def _launch_tree_rebuild(self, refresh: VoidCallback) -> None: + """Fills the whole tree from the model ``refresh`` leaves behind, off the main thread.""" self._launch_rebuild( - self._refresh_model, + refresh, lambda: self._collect_specs(self.tree_tag), root_tag=self.tree_tag, on_finished=self._on_rebuild_finished, @@ -217,6 +225,9 @@ def rebuild_tree(self) -> None: def _refresh_model(self) -> None: """Brings the model the tree renders up to date, on the background rebuild worker.""" + def _keep_model(self) -> None: + """Leaves the model as the last refresh brought it, which is what a redraw reads.""" + def _on_rebuild_finished(self) -> None: """Runs on the main thread with the rows on screen, where a browser reads something out.""" diff --git a/src/sampletones_application/ui/elements/tree/filter.py b/src/sampletones_application/ui/elements/tree/filter.py index c1615fc71..310ea91a6 100644 --- a/src/sampletones_application/ui/elements/tree/filter.py +++ b/src/sampletones_application/ui/elements/tree/filter.py @@ -11,18 +11,29 @@ class TreeFilter: Several browsers render one tree, so what each of them narrows to belongs to the panel: a query typed in one tab leaves the other reading as it was. A filter is stated whole and replaced whole, so the panel resolves what it shows in one place. + + The two criteria answer different questions: the query decides which of the rows on screen are + shown, while showing favorites alone decides which rows are drawn at all. """ query: str + favorites_only: bool @property def is_active(self) -> bool: """Whether the filter narrows what the browser shows.""" - return bool(self.query) + return bool(self.query) or self.favorites_only def with_query(self, query: str) -> TreeFilter: """The filter reading a new query, keeping everything else it states.""" return replace(self, query=query) + def with_favorites_only(self, favorites_only: bool) -> TreeFilter: + """The filter showing the favorites alone or the whole tree, keeping the query it states.""" + return replace(self, favorites_only=favorites_only) + -NO_FILTER: Final[TreeFilter] = TreeFilter(query="") +NO_FILTER: Final[TreeFilter] = TreeFilter( + query="", + favorites_only=False, +) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 54c543627..f2d66f697 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -127,6 +127,7 @@ def __init__( self._filter: TreeFilter = NO_FILTER self._search_visibility: Optional[TreeVisibility] = None + self._favorites_visibility: Optional[TreeVisibility] = None self._selected_node_tag: Optional[Union[str, int]] = None self._search_input_tag: Optional[str] = None @@ -269,10 +270,17 @@ def _append_spec( Runs on the background traversal worker, so the theme and handler tags — including the directory content check that touches the filesystem — are chosen here, off the main thread. A shutdown request raises to unwind the traversal promptly. + + Which rows are recorded is the favorites mode's to state, and it shows a row together with + every row above it: a row it holds back therefore stands above rows it holds back too, so one + decision covers the whole subtree and the traversal walks on. """ if SingleThreadExecutor.is_shutting_down(): raise BackgroundWorkCancelled + if not self._is_node_drawn(node): + return + theme_tag = self._resolve_node_theme_tag( node, has_favorite_ancestor=has_favorite_ancestor, @@ -303,24 +311,34 @@ def _finish_emit( """Complete a rebuild on the main thread: show the empty state, run the hook, unlock. The emitter runs this once its last batch has attached. A filtered rebuild that drew no - row fills the cleared tree with the no-results message, so the filter's outcome is + row fills the cleared tree with the message naming that outcome, so the filter's answer is legible where the rows would be. Applying the filter here lets late-emitted nodes honour an active search, and releasing the lock hands control back to interactive rebuilds. """ if root_tag == self.tree_tag and self._filter.is_active and not drawn_rows: dpg.add_text( - self._language_manager["global.dialog.message.tree_no_results"], + self._empty_filter_message(), parent=root_tag, ) if on_finished is not None: on_finished() - if self._filter.is_active: + if self._filter.query: self.update_tree_visibility() self.unlock() + def _empty_filter_message(self) -> str: + """Names the filter a rebuild came back empty from: the favorites mode, or the search.""" + return self._language_manager[ + ( + "global.dialog.message.tree_no_favorites" + if self._filter.favorites_only + else "global.dialog.message.tree_no_results" + ) + ] + def _create_hover_callback( self, status_bar_callback: Optional[MessageCallback], @@ -464,11 +482,16 @@ def _build_tree_node( def _has_relevant_content(self, node: TreeNode) -> bool: ... def _should_expand_node(self, node: TreeNode) -> bool: - """Whether the row is emitted standing open, which the rows leading to a search result are.""" - if self._search_visibility is None: - return False + """Whether the row is emitted standing open, which a row leading to a match is. - return self._search_visibility.should_expand(node) + A search result and a favorite are both matches the reader is looking for, so the way down to + either one opens and the filter's answer reads at a glance. + """ + return any( + visibility.should_expand(node) + for visibility in (self._search_visibility, self._favorites_visibility) + if visibility is not None + ) def _create_status_bar_message_function( self, @@ -746,6 +769,7 @@ def _resolve_filter(self) -> None: keeps a filter typed before a refresh answering for the rows that refresh brings. """ self._search_visibility = self._resolve_search_visibility() + self._favorites_visibility = self._resolve_favorites_visibility() def _resolve_search_visibility(self) -> Optional[TreeVisibility]: """The rows the search query names, and nothing to narrow by while no query is typed.""" @@ -760,12 +784,38 @@ def _resolve_search_visibility(self) -> Optional[TreeVisibility]: ) ) + def _resolve_favorites_visibility(self) -> Optional[TreeVisibility]: + """The rows the favorites mode names, and nothing to narrow by while the whole tree shows. + + One walk of the model answers the whole mode, and what it keeps is the starred rows together + with the rows above them, so a corpus of any size resolves into a pair of sets. + """ + if not self._filter.favorites_only: + return None + + return resolve_visibility(self.tree.find_nodes(TreeNode, self._is_node_starred)) + + def _is_node_starred(self, node: TreeNode) -> bool: + """Whether the favorites mode names the row: it carries a star, or a starred folder holds it. + + Being held by a starred folder is a fact about the path, so a reconstruction listed under the + sample it came from answers the same as the row standing for it beside its configuration. + """ + if self._logic.is_node_favorite(node): + return True + + return isinstance(node, FileSystemNode) and self._logic.has_favorite_ancestor(node) + def _default_search_predicate(self, node: TreeNode, query: str) -> bool: return query.lower() in node.name.lower() @abstractmethod def rebuild_tree(self) -> None: ... + @abstractmethod + def redraw_tree(self) -> None: + """Draws the rows again from the model in hand, which a change of filter asks for.""" + def update_tree_visibility(self) -> None: """Show the rows the search names and hide the rest, over the rows already on screen. @@ -796,6 +846,13 @@ def _is_node_visible(self, node: TreeNode) -> bool: return self._search_visibility.is_visible(node) + def _is_node_drawn(self, node: TreeNode) -> bool: + """Whether the favorites mode draws the row, which it does for every row while it is off.""" + if self._favorites_visibility is None: + return True + + return self._favorites_visibility.is_visible(node) + def _apply_node_theme( self, node_tag: str, @@ -928,13 +985,21 @@ def update_favorite_indicators( self, nodes: Sequence[FileSystemNode], ) -> None: - """Repaints the rows a favorite change reaches, and what each of them holds. + """Follows a favorite change through the rows it reaches, and what each of them holds. A path reaches the panel as many rows as the views offer it — a reconstruction is listed both by its configuration and by the sample it came from — and the star belongs to the path, so the caller names every row standing for it and each of them takes the new theme with the ancestry its own path carries. + + While the mode shows the favorites alone the star decides which rows exist, so the change is + answered by drawing the tree again from the model in hand: starring a row brings it in, and + unstarring one takes it out along with what it held. """ + if self._filter.favorites_only: + self.redraw_tree() + return + for node in nodes: self._reapply_theme_recursively( node, diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index ea606aadf..3b66d81cd 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -59,6 +59,7 @@ global.dialog.filter.mp3: "MP3 audio" # Global — Dialog messages global.dialog.message.tree_no_results: "No results found." +global.dialog.message.tree_no_favorites: "No favorites found." global.dialog.message.invalid_metadata_error: "Invalid file metadata." global.dialog.message.reconstruction_no_data: "No reconstruction loaded." global.dialog.message.reconstruction_saved_successfully: "Reconstruction saved successfully." diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index 411f89b54..fb713bec5 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -80,6 +80,7 @@ def build_panel( panel.tree = tree panel._filter = NO_FILTER panel._search_visibility = None + panel._favorites_visibility = None monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( panel, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py new file mode 100644 index 000000000..e0d501951 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -0,0 +1,288 @@ +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Final, FrozenSet, List, Set + +import pytest + +from sampletones_application.ui.elements.tree.filter import TreeFilter +from sampletones_application.ui.elements.tree.handler import NodeHandler +from sampletones_application.ui.elements.tree.spec import NodeSpec +from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode +from tests.suite.language import FakeLanguageManager + +PANEL_TAG: Final[str] = "sequencer.browser" + +CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions/sr_44100_nf_30") +STARRED_PATH: Final[Path] = CONFIG_DIRECTORY / "starred.stn" +PLAIN_PATH: Final[Path] = CONFIG_DIRECTORY / "plain.stn" +VARIANT_LABEL: Final[str] = "44.1 kHz·30 Hz" + +STARRED_ROWS: Final[FrozenSet[str]] = frozenset( + { + "configurations", + "directory", + "starred", + "samples", + "starred_sample", + "starred_variant", + } +) +SAMPLE_VIEW_ROWS: Final[FrozenSet[str]] = frozenset( + { + "samples", + "starred_sample", + "starred_variant", + "plain_sample", + "plain_variant", + } +) + + +class FakeTreeLogic: + """Answers the favorite questions a browser asks of its logic while it collects its rows.""" + + def __init__(self, favorites: Set[Path]) -> None: + self._favorites = favorites + + def is_node_favorite(self, node: TreeNode) -> bool: + return isinstance(node, FileSystemNode) and node.filepath in self._favorites + + def has_favorite_ancestor(self, node: FileSystemNode) -> bool: + return any(directory in self._favorites for directory in node.filepath.parents) + + +@dataclass(frozen=True) +class BrowserTree: + """The shape both browser views give one configuration directory, with a handle on every row.""" + + tree: Tree + rows: Dict[str, TreeNode] + + +@pytest.fixture +def browser() -> BrowserTree: + """Two reconstructions of one configuration, listed by that configuration and by their samples. + + A sample row carries the name of the reconstruction it gathers, the way the builder names it, so + each row is held by a key of its own rather than by the label it reads under. + """ + root = TreeNode("Root", node_type=NodeType.ROOT) + configurations = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root) + directory = FileSystemNode( + "PTN", + node_type=NodeType.DIRECTORY, + filepath=CONFIG_DIRECTORY, + parent=configurations, + ) + starred = FileSystemNode("starred", node_type=NodeType.FILE, filepath=STARRED_PATH, parent=directory) + plain = FileSystemNode("plain", node_type=NodeType.FILE, filepath=PLAIN_PATH, parent=directory) + + samples = TreeNode("By sample", node_type=NodeType.GROUP, parent=root) + starred_sample = TreeNode("starred", node_type=NodeType.SAMPLE, parent=samples) + starred_variant = FileSystemNode( + VARIANT_LABEL, + node_type=NodeType.FILE, + filepath=STARRED_PATH, + parent=starred_sample, + ) + plain_sample = TreeNode("plain", node_type=NodeType.SAMPLE, parent=samples) + plain_variant = FileSystemNode( + VARIANT_LABEL, + node_type=NodeType.FILE, + filepath=PLAIN_PATH, + parent=plain_sample, + ) + + return BrowserTree( + tree=Tree(root=root), + rows={ + "configurations": configurations, + "directory": directory, + "starred": starred, + "plain": plain, + "samples": samples, + "starred_sample": starred_sample, + "starred_variant": starred_variant, + "plain_sample": plain_sample, + "plain_variant": plain_variant, + }, + ) + + +def build_panel( + browser: BrowserTree, + favorites: Set[Path], + *, + favorites_only: bool, + query: str = "", +) -> GUISequencerBrowserPanel: + """Builds a browser panel showing the tree under a filter, with the favorites its logic answers. + + Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box. + """ + panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) + panel.tag = PANEL_TAG + panel.tree = browser.tree + panel._logic = FakeTreeLogic(favorites) + panel._language_manager = FakeLanguageManager() + panel._filter = TreeFilter(query=query, favorites_only=favorites_only) + panel._resolve_filter() + return panel + + +def collect_specs(panel: GUISequencerBrowserPanel) -> List[NodeSpec]: + """Collects the rows a rebuild would emit, which is the pass running off the main thread.""" + panel._pending_specs = [] + panel._node_handlers = { + node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType + } + + root = panel.tree.get_root() + assert root is not None + panel._build_tree_node(root, TreeNodeState(parent="tree")) + return panel._pending_specs + + +def drawn_keys( + browser: BrowserTree, + specs: List[NodeSpec], +) -> Set[str]: + drawn = {spec.node for spec in specs} + return {key for key, node in browser.rows.items() if node in drawn} + + +def open_keys( + browser: BrowserTree, + specs: List[NodeSpec], +) -> Set[str]: + standing_open = {spec.node for spec in specs if spec.should_expand} + return {key for key, node in browser.rows.items() if node in standing_open} + + +class TestDrawnRows: + def test_a_starred_reconstruction_is_drawn_under_the_rows_holding_it_in_both_views( + self, + browser: BrowserTree, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + assert drawn_keys(browser, collect_specs(panel)) == STARRED_ROWS + + def test_a_starred_directory_brings_the_reconstructions_it_holds( + self, + browser: BrowserTree, + ) -> None: + panel = build_panel(browser, {CONFIG_DIRECTORY}, favorites_only=True) + assert {"directory", "starred", "plain"} <= drawn_keys(browser, collect_specs(panel)) + + def test_a_starred_directory_reaches_the_view_holding_no_row_for_it( + self, + browser: BrowserTree, + ) -> None: + """The sample view lists reconstructions under their samples, and no row stands for a folder. + + Being held by a starred folder is read from the path, so each variant answers for itself and + the sample gathering it comes along. + """ + panel = build_panel(browser, {CONFIG_DIRECTORY}, favorites_only=True) + assert SAMPLE_VIEW_ROWS <= drawn_keys(browser, collect_specs(panel)) + + def test_nothing_starred_draws_no_row(self, browser: BrowserTree) -> None: + panel = build_panel(browser, set(), favorites_only=True) + assert collect_specs(panel) == [] + + def test_the_mode_off_draws_every_row(self, browser: BrowserTree) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + assert drawn_keys(browser, collect_specs(panel)) == set(browser.rows) + + +class TestOpenRows: + def test_the_rows_leading_to_a_favorite_stand_open(self, browser: BrowserTree) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + assert open_keys(browser, collect_specs(panel)) == { + "configurations", + "directory", + "samples", + "starred_sample", + } + + def test_the_mode_off_leaves_every_row_as_it_stands(self, browser: BrowserTree) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + assert open_keys(browser, collect_specs(panel)) == set() + + +class TestSearchInsideTheMode: + def test_the_mode_states_the_drawn_rows_while_the_query_states_the_shown_ones( + self, + browser: BrowserTree, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True, query="starred") + specs = collect_specs(panel) + + assert drawn_keys(browser, specs) == STARRED_ROWS + assert panel._is_node_visible(browser.rows["starred"]) + assert not panel._is_node_visible(browser.rows["plain"]) + + def test_a_query_naming_a_row_the_mode_leaves_out_shows_nothing_of_it( + self, + browser: BrowserTree, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True, query="plain") + assert "plain" not in drawn_keys(browser, collect_specs(panel)) + + +class TestEmptyAnswer: + """A rebuild drawing no row names the filter that answered so, where the rows would be.""" + + def test_the_mode_finding_no_favorite_names_the_favorites(self, browser: BrowserTree) -> None: + panel = build_panel(browser, set(), favorites_only=True) + assert panel._empty_filter_message() == "global.dialog.message.tree_no_favorites" + + def test_a_query_finding_nothing_names_the_results(self, browser: BrowserTree) -> None: + panel = build_panel(browser, set(), favorites_only=False, query="nothing") + assert panel._empty_filter_message() == "global.dialog.message.tree_no_results" + + +class TestFavoriteChange: + def test_a_change_draws_the_tree_again_while_the_mode_is_on( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + redraws: List[bool] = [] + repaints: List[TreeNode] = [] + monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False) + monkeypatch.setattr( + panel, + "_reapply_theme_recursively", + lambda node, has_favorite_ancestor=False: repaints.append(node), + raising=False, + ) + + panel.update_favorite_indicators([browser.rows["starred"]]) + + assert redraws == [True] + assert repaints == [] + + def test_a_change_repaints_the_rows_while_the_mode_is_off( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + redraws: List[bool] = [] + repaints: List[TreeNode] = [] + monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False) + monkeypatch.setattr( + panel, + "_reapply_theme_recursively", + lambda node, has_favorite_ancestor=False: repaints.append(node), + raising=False, + ) + + panel.update_favorite_indicators([browser.rows["starred"], browser.rows["starred_variant"]]) + + assert redraws == [] + assert repaints == [browser.rows["starred"], browser.rows["starred_variant"]] diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py index e7a60b19b..5849f65ab 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py @@ -50,6 +50,7 @@ def build_panel( panel._search_input_tag = None panel._filter = NO_FILTER panel._search_visibility = None + panel._favorites_visibility = None return panel @@ -67,10 +68,20 @@ def test_a_filter_carrying_a_query_narrows(self) -> None: def test_dropping_the_query_leaves_the_filter_narrowing_nothing(self) -> None: assert not NO_FILTER.with_query("song").with_query("").is_active + def test_a_filter_showing_the_favorites_alone_narrows(self) -> None: + assert NO_FILTER.with_favorites_only(True).is_active + + def test_the_query_and_the_favorites_mode_are_stated_side_by_side(self) -> None: + tree_filter = NO_FILTER.with_query("song").with_favorites_only(True) + assert tree_filter.query == "song" + assert tree_filter.favorites_only + def test_the_filter_a_new_one_was_taken_from_reads_as_it_did(self) -> None: - original = TreeFilter(query="song") + original = TreeFilter(query="song", favorites_only=False) original.with_query("other") + original.with_favorites_only(True) assert original.query == "song" + assert not original.favorites_only class TestPanelOwnedFilter: From 19f883cac68b1e5839b8c066db86547173219db0 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 23:01:03 +0200 Subject: [PATCH 132/152] Added: favorites-only browser control --- .../categories/elements/global_.py | 2 + .../config/managers/session.py | 6 + .../config/managers/state.py | 6 + .../config/session/state/state.py | 4 + .../coordinators/tabs/reconstruction.py | 10 ++ .../coordinators/tabs/sequencer.py | 6 + src/sampletones_application/tags/general.py | 8 + .../ui/elements/tree/browser.py | 8 + .../ui/elements/tree/tree.py | 98 ++++++++++- .../ui/panels/reconstruction/browser.py | 2 + .../ui/panels/sequencer/browser.py | 2 + .../ui/panels/shared/browser.py | 7 + src/sampletones_config/lang/en.yaml | 2 + .../theme/input/checkbox_muted.yaml | 9 + .../config/managers/test_session.py | 8 + .../config/managers/test_state.py | 42 +++++ .../ui/elements/tree/test_favorites_filter.py | 159 +++++++++++++++++- 17 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 src/sampletones_config/theme/input/checkbox_muted.yaml diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index 1325284d6..c5c3f308c 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -29,6 +29,7 @@ class TreeElements(AbstractElement): SEARCH = "search" FILTER = "filter" CLEAR_SEARCH = "clear_search" + FAVORITES_ONLY = "favorites_only" class ContextElements(AbstractElement): @@ -129,6 +130,7 @@ class StatusElements(AbstractElement): NODE_LIBRARY = "node_library" TREE_SEARCH = "tree_search" CLEAR_SEARCH = "clear_search" + FAVORITES_ONLY = "favorites_only" INPUT = "input" COMBO = "combo" NODE_DIRECTORY = "node_directory" diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index 7725786bd..a6cb8852e 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -53,6 +53,12 @@ def is_card_collapsed(self, card_tag: str) -> bool: def set_card_collapsed(self, card_tag: str, collapsed: bool) -> None: self._state_manager.set_card_collapsed(card_tag, collapsed) + def is_favorites_filter_active(self, panel_tag: str) -> bool: + return self._state_manager.is_favorites_filter_active(panel_tag) + + def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None: + self._state_manager.set_favorites_filter_active(panel_tag, active) + def toggle_autoplay(self) -> bool: return self._config_manager.toggle_autoplay() diff --git a/src/sampletones_application/config/managers/state.py b/src/sampletones_application/config/managers/state.py index 9d12c5f2e..eb90f83d7 100644 --- a/src/sampletones_application/config/managers/state.py +++ b/src/sampletones_application/config/managers/state.py @@ -94,6 +94,12 @@ def is_card_collapsed(self, card_tag: str) -> bool: def set_card_collapsed(self, card_tag: str, collapsed: bool) -> None: self.state.collapsed_cards[card_tag] = collapsed + def is_favorites_filter_active(self, panel_tag: str) -> bool: + return self.state.favorites_filters.get(panel_tag, False) + + def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None: + self.state.favorites_filters[panel_tag] = active + def load_current_tab(self) -> Tab: return self.state.current.tab diff --git a/src/sampletones_application/config/session/state/state.py b/src/sampletones_application/config/session/state/state.py index 00853aece..818fbb38b 100644 --- a/src/sampletones_application/config/session/state/state.py +++ b/src/sampletones_application/config/session/state/state.py @@ -20,6 +20,10 @@ class ApplicationState(BaseModel): default_factory=dict, description="Collapsed state of each card, keyed by the card's tag.", ) + favorites_filters: Dict[str, bool] = Field( + default_factory=dict, + description="Whether each browser shows its favorites alone, keyed by the panel's tag.", + ) current: Current = Field( default_factory=Current, description="The current state of application elements, e.g. selected tab.", diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index bfdfd0f39..4ad395a76 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -165,12 +165,14 @@ def __init__( status_bar=status_bar, colors=layout.tree_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_BROWSER_PANEL), + initial_favorites_only=session_manager.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL), ) self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled self._browser_tree_logic.on_favorite_changed = on_favorite_changed self._browser_tree_logic.on_search_update_needed = self._browser_panel.update_tree_visibility self._browser_tree_logic.on_autoplay_error = self._on_browser_autoplay_error self._browser_panel.set_collapse_handler(self._on_browser_collapse_changed) + self._browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed self._reconstruction_player_logic = PlayerLogic( audio_device_manager, on_change_audio_state, @@ -494,6 +496,14 @@ def _on_browser_collapse_changed( self._session_manager.set_card_collapsed(card_tag, collapsed) self._sync_browser_width() + def _on_browser_favorites_filter_changed( + self, + panel_tag: str, + favorites_only: bool, + ) -> None: + """Persists the browser's favorites filter so it opens in the same mode on the next launch.""" + self._session_manager.set_favorites_filter_active(panel_tag, favorites_only) + def _on_instruments_collapse_changed( self, card_tag: str, diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 5bd939a6e..af1ba9933 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -207,6 +207,7 @@ def __init__( status_bar=status_bar, colors=layout.tree_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL), + initial_favorites_only=session_manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL), ) self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller) self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller) @@ -629,6 +630,7 @@ def _wire_samples_callbacks(self) -> None: def _wire_browser_callbacks(self) -> None: self._sequencer_browser_panel.set_collapse_handler(self._on_browser_collapse_changed) + self._sequencer_browser_panel.on_favorites_filter_changed = self._on_browser_favorites_filter_changed self._sequencer_browser_panel.on_add_to_sequencer = self.import_reconstruction self._sequencer_browser_panel.can_add_to_sequencer = self._is_project_open self._sequencer_browser_panel.on_replace_in_sequencer = self.replace_reconstruction @@ -662,6 +664,10 @@ def _on_browser_collapse_changed(self, card_tag: str, collapsed: bool) -> None: self._session_manager.set_card_collapsed(card_tag, collapsed) self._sync_browser_width() + def _on_browser_favorites_filter_changed(self, panel_tag: str, favorites_only: bool) -> None: + """Persists the browser's favorites filter so it opens in the same mode on the next launch.""" + self._session_manager.set_favorites_filter_active(panel_tag, favorites_only) + def sync_responsive_layout(self) -> None: """Refits this tab's side column to the current viewport, the entry the resize handler calls.""" self._sync_browser_width() diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index da52f4903..3927b3188 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -308,6 +308,12 @@ Widget.THEME, "file_not_expanded_directory", ) +TAG_GLOBAL_THEME_CHECKBOX_MUTED = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.THEME, + "checkbox_muted", +) TAG_GLOBAL_THEME_INPUT_INVALID = TagName( Page.GLOBAL, Panel.IMPLICIT, @@ -690,9 +696,11 @@ SUF_LABEL = "label" SUF_PATH = "path" SUF_TEXT = "text" +SUF_TEXT_FAVORITES = compose_tag(SUF_TEXT, "favorites") SUF_INPUT = "input" SUF_INPUT_SEARCH = compose_tag(SUF_INPUT, "search") SUF_CHECKBOX = "checkbox" +SUF_CHECKBOX_FAVORITES = compose_tag(SUF_CHECKBOX, "favorites") SUF_TABLE = "table" SUF_TOOLTIP = "tooltip" SUF_TOOLTIP_DETAIL = compose_tag(SUF_TOOLTIP, "detail") diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index 097a349d7..ad105e1b5 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -32,9 +32,13 @@ class GUIFileBrowserPanel(GUITreePanel, ABC): whole card as the tree locks and unlocks. A subclass declares its widgets as a :class:`FileBrowserTags`, states what its card and its refresh control read, answers what refreshing the model means, and shapes each row. + + A browser whose rows carry favorites states ``_OFFERS_FAVORITES_FILTER``, which adds the control + showing those favorites alone to the card. """ _REBUILD_ON_CREATE: bool = True + _OFFERS_FAVORITES_FILTER: bool = False def __init__( self, @@ -141,6 +145,9 @@ def _on_refresh_clicked(self) -> None: def _create_tree_window(self) -> None: self.create_search(self._body_container) + if self._OFFERS_FAVORITES_FILTER: + self.create_favorites_filter(self._body_container) + with ( dpg.child_window( tag=self._tags.window_tree, @@ -234,3 +241,4 @@ def _on_rebuild_finished(self) -> None: def set_tree_enabled(self, enabled: bool) -> None: dpg_configure_item(self._tags.group_tree, enabled=enabled) dpg_configure_item(self._tags.group_controls, enabled=enabled) + self.set_favorites_filter_enabled(enabled) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index f2d66f697..633893ac5 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -12,10 +12,13 @@ from sampletones_application.tags.compose import compose_tag from sampletones_application.tags.general import ( SUF_BUTTON_SEARCH, + SUF_CHECKBOX_FAVORITES, SUF_HANDLER_DETAIL_TOOLTIP, SUF_HANDLER_NODE, SUF_INPUT_SEARCH, + SUF_TEXT_FAVORITES, SUF_TOOLTIP_DETAIL, + TAG_GLOBAL_THEME_CHECKBOX_MUTED, TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FAVORITE, TAG_GLOBAL_THEME_FAVORITE_CHILD, @@ -132,6 +135,8 @@ def __init__( self._selected_node_tag: Optional[Union[str, int]] = None self._search_input_tag: Optional[str] = None self._search_button_tag: Optional[str] = None + self._favorites_checkbox_tag: Optional[str] = None + self._favorites_glyph_tag: Optional[str] = None self._detail_tooltip_tag = compose_tag(tag, SUF_TOOLTIP_DETAIL) self._detail_tooltip_handler_tag = compose_tag(tag, SUF_HANDLER_DETAIL_TOOLTIP) @@ -151,6 +156,7 @@ def __init__( self._lbl_detail_generators = language_manager["global.context.label.detail_generators"] self._lbl_detail_configuration = language_manager["global.context.label.detail_configuration"] + self.on_favorites_filter_changed: Optional[Callable[[str, bool], None]] = None self.on_add_to_sequencer: Optional[PathCallback] = None self.can_add_to_sequencer: Optional[Callable[[], bool]] = None self.on_replace_in_sequencer: Optional[PathCallback] = None @@ -250,6 +256,79 @@ def create_search(self, parent: str) -> None: self._language_manager["global.status.message.clear_search"], ) + def create_favorites_filter(self, parent: str) -> None: + """Builds the control showing the favorites alone, as a row of its own under the search box. + + The checkbox carries the label, so the words are part of what the reader clicks, and the star + beside it reads in the colour the mode it stands for is drawn in. + """ + self._favorites_checkbox_tag = compose_tag(self.tag, SUF_CHECKBOX_FAVORITES) + self._favorites_glyph_tag = compose_tag(self.tag, SUF_TEXT_FAVORITES) + + with dpg.group(horizontal=True, parent=parent): + dpg.add_checkbox( + tag=self._favorites_checkbox_tag, + label=self._language_manager["global.browser.label.favorites_only"], + default_value=self._filter.favorites_only, + callback=self._on_favorites_only_changed, + ) + dpg.add_text( + self._glyphs.common.favorite, + tag=self._favorites_glyph_tag, + ) + + ThemeRegistry.get(TAG_GLOBAL_THEME_CHECKBOX_MUTED).bind_to_item(self._favorites_checkbox_tag) + FontRegistry.bind_to_item(self._favorites_glyph_tag, Font.ICON) + self._apply_favorites_glyph_color() + self._status_bar.bind_to_item( + self._favorites_checkbox_tag, + self._language_manager["global.status.message.favorites_only"], + ) + + def _on_favorites_only_changed( + self, + _sender: Sender, + favorites_only: bool, + ) -> None: + """Takes the mode the control now reads, and draws the rows that mode names. + + The rebuild resolves the filter against the model as it collects the rows, so the mode is + stated here and answered there, and turning it on walks the model once. + """ + self._filter = self._filter.with_favorites_only(favorites_only) + self._apply_favorites_glyph_color() + self.call( + self.on_favorites_filter_changed, + self.tag, + favorites_only, + ) + self.redraw_tree() + + def _apply_favorites_glyph_color(self) -> None: + """Colours the star by the mode the control reads, wherever the browser offers one.""" + if self._favorites_glyph_tag is None: + return + + dpg_set_palette_color(self._favorites_glyph_tag, self._favorites_glyph_color()) + + def _favorites_glyph_color(self) -> BaseColor: + """The colour the star takes: the favorite colour while the mode is on, muted while it is off.""" + if self._filter.favorites_only: + return self._colors.favorite + + return self._colors.muted + + def set_favorites_filter_enabled(self, enabled: bool) -> None: + """Follows the tree's lock through to the control, which asks for a rebuild of that tree.""" + if self._favorites_checkbox_tag is None: + return + + dpg_configure_item(self._favorites_checkbox_tag, enabled=enabled) + + def _restore_favorites_only(self, favorites_only: bool) -> None: + """Takes the mode a session left the browser in, which its first rebuild then draws by.""" + self._filter = self._filter.with_favorites_only(favorites_only) + def _get_node_handler_tag(self, node_type: NodeType) -> str: return compose_tag(self.tag, node_type.value, SUF_HANDLER_NODE) @@ -747,20 +826,23 @@ def _on_replace_in_sequencer( self.call(self.on_replace_in_sequencer, user_data.filepath) def _on_search_changed(self, _sender: Sender, query: str) -> None: - self._set_filter(self._filter.with_query(query)) - self._logic.schedule_search_update(query) + self._set_query(query) def _on_clear_search_clicked(self) -> None: if self._search_input_tag is not None: dpg.set_value(self._search_input_tag, "") - self._set_filter(self._filter.with_query("")) - self._logic.schedule_search_update("") + self._set_query("") - def _set_filter(self, tree_filter: TreeFilter) -> None: - """Take the filter the browser is now asked to show, and resolve what it leaves on screen.""" - self._filter = tree_filter - self._resolve_filter() + def _set_query(self, query: str) -> None: + """Take the query the browser is now asked to show, and resolve the rows it names. + + The rows already drawn are the favorites mode's to state, so a keystroke resolves the search + alone and the tree on screen answers the one after it. + """ + self._filter = self._filter.with_query(query) + self._search_visibility = self._resolve_search_visibility() + self._logic.schedule_search_update(query) def _resolve_filter(self) -> None: """Resolve the filter against the model as it stands, which a rebuild does once per pass. diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index 748397b15..57d6d337e 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -49,6 +49,7 @@ def __init__( status_bar: GUIStatusBar, colors: TreeColors, initial_collapsed: bool, + initial_favorites_only: bool, ) -> None: self._language_manager = language_manager @@ -60,6 +61,7 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_favorites_only=initial_favorites_only, ) self.on_load_reconstruction: Optional[PathCallback] = None diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index 685b16410..1053e17a3 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -42,6 +42,7 @@ def __init__( status_bar: GUIStatusBar, colors: TreeColors, initial_collapsed: bool, + initial_favorites_only: bool, ) -> None: self._language_manager = language_manager @@ -53,6 +54,7 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_favorites_only=initial_favorites_only, ) @property diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index a5349f240..387d60095 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -37,9 +37,13 @@ class GUIReconstructionBrowserPanel(GUIFileBrowserPanel): Reads the tree both tabs share into rows, colours the ones the browser invents, and routes node clicks to the subclass through :meth:`_open_reconstruction`. The subclass names its widgets and its refresh control, and adds the items its context menus offer. + + Reconstructions carry favorites, so this browser offers the control showing them alone and opens + in the mode the session left it in. """ _MONOSPACE_CONFIG_NODES: bool = True + _OFFERS_FAVORITES_FILTER: bool = True def __init__( self, @@ -51,6 +55,7 @@ def __init__( status_bar: GUIStatusBar, colors: TreeColors, initial_collapsed: bool, + initial_favorites_only: bool, ) -> None: self._language_manager = language_manager self.on_refresh_tree: Optional[VoidCallback] = None @@ -66,6 +71,8 @@ def __init__( initial_collapsed=initial_collapsed, ) + self._restore_favorites_only(initial_favorites_only) + @property def section_label(self) -> str: return self._language_manager["global.browser.label.browser"] diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 3b66d81cd..6b079f011 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -133,6 +133,7 @@ global.browser.label.by_sample: "By sample" global.browser.label.search: "Search" global.browser.label.filter: "Filter" global.browser.label.clear_search: "Clear" +global.browser.label.favorites_only: "Favorites only" # ============================================================================= # Global — Context menu @@ -239,6 +240,7 @@ global.status.message.node_reconstruction: "Click to play reconstruction. Double global.status.message.node_library: "Double-click to open instructions library. Right-click to open context menu." global.status.message.tree_search: "Type query to filter nodes." global.status.message.clear_search: "Clear the search filter." +global.status.message.favorites_only: "Show the favorites alone, or the whole tree." global.status.message.input: "Ctrl + click to type value." global.status.message.combo: "Click to select a value from the list." global.status.message.node_directory: "Click to {expand_or_collapse}. Right-click to open context menu." diff --git a/src/sampletones_config/theme/input/checkbox_muted.yaml b/src/sampletones_config/theme/input/checkbox_muted.yaml new file mode 100644 index 000000000..5921e68ab --- /dev/null +++ b/src/sampletones_config/theme/input/checkbox_muted.yaml @@ -0,0 +1,9 @@ +name: input_checkbox_muted +tag: global.theme.checkbox_muted + +components: + - item_type: Checkbox + entries: + - type: color + key: Text + value: .text_inactive diff --git a/tests/unit/sampletones_application/config/managers/test_session.py b/tests/unit/sampletones_application/config/managers/test_session.py index 13c1aff01..03e31c704 100644 --- a/tests/unit/sampletones_application/config/managers/test_session.py +++ b/tests/unit/sampletones_application/config/managers/test_session.py @@ -5,6 +5,7 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.config.managers.session import SessionManager from sampletones_application.config.profile import UserProfile +from sampletones_application.tags.sequencer import TAG_SEQUENCER_BROWSER_PANEL @pytest.fixture @@ -149,3 +150,10 @@ def test_toggle_favorite_twice_removes_path(self, session: SessionManager, tmp_p def test_favorites_returns_set(self, session: SessionManager) -> None: assert isinstance(session.favorites, set) + + def test_a_browser_reads_the_favorites_filter_it_was_given(self, session: SessionManager) -> None: + session.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True) + assert session.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is True + + def test_a_browser_a_first_run_finds_shows_the_whole_tree(self, session: SessionManager) -> None: + assert session.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is False diff --git a/tests/unit/sampletones_application/config/managers/test_state.py b/tests/unit/sampletones_application/config/managers/test_state.py index 21aae3e8a..e583a25c2 100644 --- a/tests/unit/sampletones_application/config/managers/test_state.py +++ b/tests/unit/sampletones_application/config/managers/test_state.py @@ -8,6 +8,8 @@ from sampletones_application.categories.hierarchy import Tab from sampletones_application.config.managers.state import ApplicationStateManager from sampletones_application.config.session.state.state import ApplicationState +from sampletones_application.tags.reconstructions import TAG_RECONSTRUCTIONS_BROWSER_PANEL +from sampletones_application.tags.sequencer import TAG_SEQUENCER_BROWSER_PANEL @pytest.fixture @@ -91,6 +93,34 @@ def test_toggle_show_advanced_settings_changes_value(self, manager: ApplicationS assert manager.advanced_settings == (not initial) +class TestApplicationStateManagerCardsAndFilters: + """The per-panel state a card keeps: whether it is collapsed, and what its browser narrows to.""" + + def test_a_card_no_run_has_touched_reads_expanded(self, manager: ApplicationStateManager) -> None: + assert manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL) is False + + def test_a_card_reads_the_collapse_it_was_given(self, manager: ApplicationStateManager) -> None: + manager.set_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL, True) + assert manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL) is True + + def test_a_browser_no_run_has_touched_shows_the_whole_tree(self, manager: ApplicationStateManager) -> None: + assert manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is False + + def test_a_browser_reads_the_filter_it_was_given(self, manager: ApplicationStateManager) -> None: + manager.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True) + assert manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is True + + def test_each_browser_keeps_the_filter_of_its_own_panel(self, manager: ApplicationStateManager) -> None: + manager.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True) + + assert manager.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL) is False + + def test_the_filter_and_the_collapse_of_one_panel_stand_apart(self, manager: ApplicationStateManager) -> None: + manager.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True) + + assert manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL) is False + + class TestApplicationStateManagerCurrentPaths: def test_set_current_reconstruction_updates_property( self, @@ -207,6 +237,18 @@ def test_save_and_reload_preserves_advanced_settings(self, tmp_path: Path) -> No assert reloaded.advanced_settings == manager.advanced_settings + def test_save_and_reload_preserves_each_browser_filter(self, tmp_path: Path) -> None: + """The mode a browser was left in returns on the next launch, for that browser alone.""" + path = tmp_path / "state.yaml" + manager = ApplicationStateManager(path) + manager.set_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL, True) + manager.save() + + reloaded = ApplicationStateManager(path) + + assert reloaded.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is True + assert reloaded.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL) is False + @pytest.mark.parametrize("exception_type", [PermissionError, IsADirectoryError, OSError]) def test_save_recovers_from_file_error(self, tmp_path: Path, exception_type: Type[OSError]) -> None: """State persistence degrades to logging when the disk rejects the write.""" diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index e0d501951..73835a6fd 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -1,18 +1,31 @@ from dataclasses import dataclass from pathlib import Path -from typing import Dict, Final, FrozenSet, List, Set +from typing import Any, Dict, Final, FrozenSet, List, Set, Tuple import pytest +from sampletones_application.ui.elements.tree import tree as tree_module +from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.filter import TreeFilter from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.spec import NodeSpec from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.utils.palette.colors.base import BaseColor +from sampletones_application.utils.palette.colors.literal import LiteralColor from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode from tests.suite.language import FakeLanguageManager PANEL_TAG: Final[str] = "sequencer.browser" +CHECKBOX_TAG: Final[str] = "sequencer.browser.checkbox.favorites" +GLYPH_TAG: Final[str] = "sequencer.browser.text.favorites" + +TREE_COLORS: Final[TreeColors] = TreeColors( + favorite=LiteralColor((240, 200, 80, 255)), + node=LiteralColor((200, 200, 200, 255)), + muted=LiteralColor((120, 120, 120, 255)), + accent=LiteralColor((80, 160, 240, 255)), +) CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions/sr_44100_nf_30") STARRED_PATH: Final[Path] = CONFIG_DIRECTORY / "starred.stn" @@ -120,13 +133,18 @@ def build_panel( ) -> GUISequencerBrowserPanel: """Builds a browser panel showing the tree under a filter, with the favorites its logic answers. - Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box. + Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box, + and the control stands where a browser that has yet to build one leaves it. """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = PANEL_TAG panel.tree = browser.tree panel._logic = FakeTreeLogic(favorites) panel._language_manager = FakeLanguageManager() + panel._colors = TREE_COLORS + panel._favorites_checkbox_tag = None + panel._favorites_glyph_tag = None + panel.on_favorites_filter_changed = None panel._filter = TreeFilter(query=query, favorites_only=favorites_only) panel._resolve_filter() return panel @@ -244,6 +262,143 @@ def test_a_query_finding_nothing_names_the_results(self, browser: BrowserTree) - assert panel._empty_filter_message() == "global.dialog.message.tree_no_results" +class TestControl: + """What the checkbox beside the search box answers for: the mode, the memory of it, the rows.""" + + def test_the_mode_the_control_reads_reaches_the_filter( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False) + + panel._on_favorites_only_changed(None, True) + + assert panel._filter.favorites_only + + def test_a_change_is_handed_to_the_hook_remembering_it( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + remembered: List[Tuple[str, bool]] = [] + panel.on_favorites_filter_changed = lambda panel_tag, favorites_only: remembered.append( + (panel_tag, favorites_only) + ) + monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False) + + panel._on_favorites_only_changed(None, True) + + assert remembered == [(PANEL_TAG, True)] + + def test_a_change_draws_the_rows_the_new_mode_names( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + redraws: List[bool] = [] + monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False) + + panel._on_favorites_only_changed(None, True) + + assert redraws == [True] + + def test_the_mode_a_session_left_on_stands_before_the_first_rebuild( + self, + browser: BrowserTree, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + + panel._restore_favorites_only(True) + + assert panel._filter.favorites_only + + def test_a_query_typed_earlier_survives_a_change_of_mode( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False, query="starred") + monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False) + + panel._on_favorites_only_changed(None, True) + + assert panel._filter.query == "starred" + + +class TestStarColor: + """The star beside the label reads in the colour of the mode it stands for.""" + + def test_the_star_reads_favorite_while_the_mode_is_on(self, browser: BrowserTree) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + assert panel._favorites_glyph_color() == TREE_COLORS.favorite + + def test_the_star_reads_muted_while_the_mode_is_off(self, browser: BrowserTree) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + assert panel._favorites_glyph_color() == TREE_COLORS.muted + + def test_the_star_is_coloured_with_the_token_the_mode_names( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The colour reaches the star as a token, so the star follows a palette swapped in place.""" + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + panel._favorites_glyph_tag = GLYPH_TAG + coloured: List[Tuple[str, BaseColor]] = [] + monkeypatch.setattr( + tree_module, + "dpg_set_palette_color", + lambda item, color: coloured.append((item, color)), + ) + + panel._apply_favorites_glyph_color() + + assert coloured == [(GLYPH_TAG, TREE_COLORS.favorite)] + + +class TestControlLock: + """A rebuild is what the control asks for, so the tree's lock reaches it.""" + + def test_the_lock_reaches_the_control( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + panel._favorites_checkbox_tag = CHECKBOX_TAG + configured: List[Tuple[str, Any]] = [] + monkeypatch.setattr( + tree_module, + "dpg_configure_item", + lambda tag, **kwargs: configured.append((tag, kwargs["enabled"])), + ) + + panel.set_favorites_filter_enabled(False) + + assert configured == [(CHECKBOX_TAG, False)] + + def test_a_browser_offering_no_control_answers_the_lock_as_it_stands( + self, + browser: BrowserTree, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + configured: List[Tuple[str, Any]] = [] + monkeypatch.setattr( + tree_module, + "dpg_configure_item", + lambda tag, **kwargs: configured.append((tag, kwargs["enabled"])), + ) + + panel.set_favorites_filter_enabled(False) + + assert configured == [] + + class TestFavoriteChange: def test_a_change_draws_the_tree_again_while_the_mode_is_on( self, From c0f3df7dcfff7f6a2b782a4def942ac7185e698e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Mon, 17 Aug 2026 23:17:36 +0200 Subject: [PATCH 133/152] Documented: favorites-only browser filter --- docs/development/browser.md | 55 +++++++++++++++++++++++++++++++++++-- docs/guide/interface.md | 5 ++++ docs/index.md | 2 +- 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index 3eaf1fc95..a2bdb0107 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -31,6 +31,9 @@ complements `docs/development/architecture.md` (layering and ownership) and 6. **Per-row work happens off the main thread.** A rebuild resolves each row into a `NodeSpec` on the background worker — tag, label, font, theme, handler, open state — and the main thread creates the widgets from those specs, spread across frames. +7. **What a browser narrows to is its own.** Both tabs render one model, so which rows a browser shows + is decided by the panel showing it: a search typed in one tab leaves the other reading as it was, + and each browser opens in the mode a session left it in. --- @@ -100,9 +103,9 @@ one row from the next. The browsers form one line of inheritance, each level owning what it shares: -* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the search box, the rebuild handshake, - spec collection, themes and fonts per row, the detail tooltip, the status-bar messages, and the - context-menu items every browser can offer. +* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the filter + they compose, the rebuild handshake, spec collection, themes and fonts per row, the detail tooltip, the + status-bar messages, and the context-menu items every browser can offer. * `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the refresh control, the tree window, the folder-and-file handler pair, and enabling the card as the tree locks and unlocks. A subclass declares its widgets as a `FileBrowserTags` class attribute and states @@ -142,3 +145,49 @@ folder wherever a view puts it — including the sample branch, whose headings c path reaches the panel as several rows, `application.py` resolves the toggled path into every row standing for it and hands them to both tabs, and each row repaints with the ancestry its own path carries. + +## Filtering + +`TreeFilter` (`ui/elements/tree/filter.py`) holds what a browser is currently asked to show, and the +panel showing it owns the filter. It is stated whole and replaced whole — `with_query`, +`with_favorites_only` — so one place resolves what the browser shows, and `NO_FILTER` is the filter a +browser showing its whole tree holds. + +The two criteria answer different questions, so each lands in a different place: + +| Criterion | What it decides | Where it lands | What a change costs | +|---|---|---|---| +| `favorites_only` | which rows the browser **draws** | `_append_spec` records the rows the mode shows, so `TreeEmitter` creates widgets for those alone | `redraw_tree` collects the rows again from the model in hand, on the tree worker | +| `query` | which of the drawn rows are **shown** | `update_tree_visibility` flips `show` over the rows already on screen, once the typing settles | a resolution of the query, debounced | + +One rule serves both. `TreeVisibility` (`sampletones_core/structures/tree/visibility.py`) takes the +rows a criterion named and answers which rows stay: a named row, a row leading down to one, and a row +one holds. `resolve_visibility` keeps the named rows and the rows above them, so what a pass holds in +memory follows the size of what was found, and a row beneath a match is answered from its own path +upwards. The same two sets state which rows stand open, which is what makes a filter legible: the +starred rows come up with their headings open. + +**A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on +the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so +declining a row declines its subtree, and one decision covers it while the traversal walks on. + +**What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing +each row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents`. +What it materialises is the starred rows and the rows above them, and what reaches DearPyGui is the +drawn rows alone: on a directory holding hundreds of thousands of reconstructions, a favorites-only +browser creates widgets for the starred ones and their headings. A keystroke resolves the query alone, +the drawn rows being the mode's to state. A favorite toggled while the mode is on redraws the browser, +so starring a row brings it in and unstarring one takes it out along with what it held. + +A rebuild that drew no row fills the cleared tree with the message naming the criterion that came back +empty (`global.dialog.message.tree_no_favorites`, `global.dialog.message.tree_no_results`), so the +filter's answer reads where the rows would be. + +**The control** is a checkbox under the search box carrying the favorite glyph, which reads in the +favorite colour while the mode is on and muted while it is off. `_OFFERS_FAVORITES_FILTER` states +which cards hold it: the reconstruction browsers, whose rows stand for the paths a session stars. It +follows the tree's lock, a rebuild being what it asks for. + +Each browser opens in the mode it was left in. The panel raises `on_favorites_filter_changed` with its +own tag, and the tab coordinator writes it to `ApplicationState.favorites_filters` under that tag, +which is how a collapsed card is remembered too. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index a893dad51..154147f84 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -46,6 +46,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 keep the reconstructions you return to within reach, right-click one — or a +whole folder — and choose **Mark as favorite**, which highlights it in both views. +Tick **Favorites only** under the search box to narrow the browser to your +favorites and everything inside them. + 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 diff --git a/docs/index.md b/docs/index.md index a2357a273..1b02957d0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -58,7 +58,7 @@ The [**development**](development/) section is for contributors. - [Undo engine](development/undo.md) — the design of the undo/redo subsystem. - [Sequencer blocks](development/sequencer-blocks.md) — the rules copy, cut, paste and delete follow on both grids. - [Playback](development/playback.md) — the audio transport shared by every view, and rendering the song to a file. -- [Reconstruction browser](development/browser.md) — how a reconstructions directory becomes the tree both browser tabs render. +- [Reconstruction browser](development/browser.md) — how a reconstructions directory becomes the tree both browser tabs render, and what narrows it. - [Configuration](development/config-organization.md) — how the YAML configuration package is laid out. - [Coding guidelines](development/guidelines.md) — conventions for the codebase. - [Dependencies](development/dependencies.md) — the libraries _SampleToNES_ builds on. From 7249ab1126229447eb1630fe239207add59ff32d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 00:54:11 +0200 Subject: [PATCH 134/152] Fixed: favorites filter label reading as disabled --- src/sampletones_application/tags/general.py | 6 ------ src/sampletones_application/ui/elements/tree/tree.py | 6 +++--- src/sampletones_config/theme/input/checkbox_muted.yaml | 9 --------- 3 files changed, 3 insertions(+), 18 deletions(-) delete mode 100644 src/sampletones_config/theme/input/checkbox_muted.yaml diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 3927b3188..baa6030ea 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -308,12 +308,6 @@ Widget.THEME, "file_not_expanded_directory", ) -TAG_GLOBAL_THEME_CHECKBOX_MUTED = TagName( - Page.GLOBAL, - Panel.IMPLICIT, - Widget.THEME, - "checkbox_muted", -) TAG_GLOBAL_THEME_INPUT_INVALID = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 633893ac5..b167853e3 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -18,7 +18,6 @@ SUF_INPUT_SEARCH, SUF_TEXT_FAVORITES, SUF_TOOLTIP_DETAIL, - TAG_GLOBAL_THEME_CHECKBOX_MUTED, TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FAVORITE, TAG_GLOBAL_THEME_FAVORITE_CHILD, @@ -260,7 +259,9 @@ def create_favorites_filter(self, parent: str) -> None: """Builds the control showing the favorites alone, as a row of its own under the search box. The checkbox carries the label, so the words are part of what the reader clicks, and the star - beside it reads in the colour the mode it stands for is drawn in. + beside it reads in the colour the mode it stands for is drawn in. The label reads in the pair + every checkbox reads — the text colour while the control is live, the muted one while a + rebuild holds it — so the shade states whether the control can be acted on. """ self._favorites_checkbox_tag = compose_tag(self.tag, SUF_CHECKBOX_FAVORITES) self._favorites_glyph_tag = compose_tag(self.tag, SUF_TEXT_FAVORITES) @@ -277,7 +278,6 @@ def create_favorites_filter(self, parent: str) -> None: tag=self._favorites_glyph_tag, ) - ThemeRegistry.get(TAG_GLOBAL_THEME_CHECKBOX_MUTED).bind_to_item(self._favorites_checkbox_tag) FontRegistry.bind_to_item(self._favorites_glyph_tag, Font.ICON) self._apply_favorites_glyph_color() self._status_bar.bind_to_item( diff --git a/src/sampletones_config/theme/input/checkbox_muted.yaml b/src/sampletones_config/theme/input/checkbox_muted.yaml deleted file mode 100644 index 5921e68ab..000000000 --- a/src/sampletones_config/theme/input/checkbox_muted.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: input_checkbox_muted -tag: global.theme.checkbox_muted - -components: - - item_type: Checkbox - entries: - - type: color - key: Text - value: .text_inactive From e94921a4f836ff42f058708d877ecd1e3c0d9414 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 01:00:59 +0200 Subject: [PATCH 135/152] Added: browser view-state test fixture --- tests/suite/browser.py | 342 ++++++++++++++++++ .../ui/elements/tree/conftest.py | 11 + .../ui/elements/tree/test_browser_view.py | 64 ++++ 3 files changed, 417 insertions(+) create mode 100644 tests/suite/browser.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/conftest.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py diff --git a/tests/suite/browser.py b/tests/suite/browser.py new file mode 100644 index 000000000..14f2b3b2f --- /dev/null +++ b/tests/suite/browser.py @@ -0,0 +1,342 @@ +from collections import defaultdict +from dataclasses import dataclass +from pathlib import Path +from textwrap import dedent +from typing import Dict, Final, List, Mapping, Sequence, Set, Tuple + +from sampletones_application.logic.reconstruction.browser.manager import BrowserManager +from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.filter import TreeFilter +from sampletones_application.ui.elements.tree.handler import NodeHandler +from sampletones_application.ui.elements.tree.spec import NodeSpec +from sampletones_application.ui.elements.tree.state import TreeNodeState +from sampletones_application.ui.elements.tree.tree import GUITreePanel +from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel +from sampletones_application.utils.palette.colors.literal import LiteralColor +from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.reconstructions.converter.paths import ConfigDirectoryFields +from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode +from sampletones_shared.paths.extensions import EXT_FILE_RECONSTRUCTION +from tests.suite.language import FakeLanguageManager + +PANEL_TAG: Final[str] = "sequencer.browser" +TREE_TAG: Final[str] = "sequencer.browser.tree" + +HASH_A: Final[str] = "aaaaaaaa11111111aaaaaaaa11111111" +HASH_B: Final[str] = "bbbbbbbb22222222bbbbbbbb22222222" +HASH_C: Final[str] = "cccccccc33333333cccccccc33333333" +HASH_D: Final[str] = "dddddddd44444444dddddddd44444444" +HASH_E: Final[str] = "eeeeeeee55555555eeeeeeee55555555" +HASH_F: Final[str] = "ffffffff66666666ffffffff66666666" + +ARCHIVE: Final[str] = "archive" +STRAY: Final[str] = "stray" + +BROWSER_TEXTS: Final[Mapping[str, str]] = { + "global.browser.label.root": "Root", + "global.browser.label.by_configuration": "By configuration", + "global.browser.label.by_sample": "By sample", +} + +TREE_COLORS: Final[TreeColors] = TreeColors( + favorite=LiteralColor((240, 200, 80, 255)), + node=LiteralColor((200, 200, 200, 255)), + muted=LiteralColor((120, 120, 120, 255)), + accent=LiteralColor((80, 160, 240, 255)), +) + +OPEN_MARKER: Final[str] = "v" +CLOSED_MARKER: Final[str] = ">" +LEAF_MARKER: Final[str] = "-" +HIDDEN_MARKER: Final[str] = " [hidden]" +INDENT: Final[str] = " " + + +def config_fields( + *, + sample_rate: int, + nes_frequency: int, + spectrum_method: SpectrumMethod, + transformation_gamma: int, + generators: str, + config_hash: str, +) -> ConfigDirectoryFields: + return ConfigDirectoryFields( + sr=sample_rate, + nf=nes_frequency, + sm=spectrum_method, + tg=transformation_gamma, + gn=generators, + ch=config_hash, + ) + + +CONFIG_A: Final[ConfigDirectoryFields] = config_fields( + sample_rate=44100, + nes_frequency=30, + spectrum_method=SpectrumMethod.FFT, + transformation_gamma=0, + generators="PTN", + config_hash=HASH_A, +) +CONFIG_B: Final[ConfigDirectoryFields] = config_fields( + sample_rate=44100, + nes_frequency=30, + spectrum_method=SpectrumMethod.FFT, + transformation_gamma=0, + generators="PTN", + config_hash=HASH_B, +) +CONFIG_C: Final[ConfigDirectoryFields] = config_fields( + sample_rate=44100, + nes_frequency=30, + spectrum_method=SpectrumMethod.FFT, + transformation_gamma=0, + generators="PT", + config_hash=HASH_C, +) +CONFIG_D: Final[ConfigDirectoryFields] = config_fields( + sample_rate=44100, + nes_frequency=30, + spectrum_method=SpectrumMethod.CQT, + transformation_gamma=0, + generators="PTN", + config_hash=HASH_D, +) +CONFIG_E: Final[ConfigDirectoryFields] = config_fields( + sample_rate=8000, + nes_frequency=60, + spectrum_method=SpectrumMethod.CQT, + transformation_gamma=2, + generators="P", + config_hash=HASH_E, +) +CONFIG_F: Final[ConfigDirectoryFields] = config_fields( + sample_rate=48000, + nes_frequency=50, + spectrum_method=SpectrumMethod.LOG_SPACED_FFT, + transformation_gamma=1, + generators="TN", + config_hash=HASH_F, +) + +TOP_LEVEL_CONFIGURATIONS: Final[Mapping[str, ConfigDirectoryFields]] = { + "A": CONFIG_A, + "B": CONFIG_B, + "C": CONFIG_C, + "D": CONFIG_D, + "E": CONFIG_E, +} +RECONSTRUCTIONS: Final[Mapping[str, Tuple[str, ...]]] = { + "A": ("beat", "melody", "drums/kick", "drums/snare"), + "B": ("beat", "melody", "drums/kick"), + "C": ("beat", "takes/alt"), + "D": ("beat", "solo"), + "E": ("sweep",), +} + + +class FakeConfigManager: + """Answers the one thing the browser manager asks of the configuration: where to read.""" + + def __init__(self, reconstructions_directory: Path) -> None: + self._reconstructions_directory = reconstructions_directory + + def get_reconstructions_directory(self) -> Path: + return self._reconstructions_directory + + +class FakeTreeLogic: + """Answers the favorite questions a browser asks of its logic while it collects its rows.""" + + def __init__(self, favorites: Set[Path]) -> None: + self._favorites = favorites + + def is_node_favorite(self, node: TreeNode) -> bool: + return isinstance(node, FileSystemNode) and node.filepath in self._favorites + + def has_favorite_ancestor(self, node: FileSystemNode) -> bool: + return any(directory in self._favorites for directory in node.filepath.parents) + + +@dataclass(frozen=True) +class BrowserCorpus: + """A reconstructions directory read into the tree both browser views render. + + ``paths`` names every place a test can star: a configuration directory by its key, a + reconstruction by ``"/"``, and the folders standing beside them. + """ + + tree: Tree + paths: Mapping[str, Path] + + +def write_corpus(root: Path) -> Dict[str, Path]: + """Writes the corpus the browser tests read, and answers where each part of it landed. + + The layout carries what the browser has to tell apart: two configurations differing by hash + alone, a frequency holding several methods beside one holding a single chain, audio shared by + every configuration and audio held by one, a configuration directory nested in a plain folder, + and a reconstruction sitting outside every configuration directory. + """ + paths: Dict[str, Path] = {} + for key, fields in TOP_LEVEL_CONFIGURATIONS.items(): + directory = root / fields.directory_name + paths[key] = directory + for relative in RECONSTRUCTIONS[key]: + paths[f"{key}/{relative}"] = _write_reconstruction(directory / relative) + + archive = root / ARCHIVE + paths[ARCHIVE] = archive + paths[f"{ARCHIVE}/F"] = archive / CONFIG_F.directory_name + paths[f"{ARCHIVE}/F/song"] = _write_reconstruction(paths[f"{ARCHIVE}/F"] / "song") + paths[STRAY] = _write_reconstruction(root / STRAY) + return paths + + +def _write_reconstruction(path: Path) -> Path: + reconstruction = path.with_suffix(EXT_FILE_RECONSTRUCTION) + reconstruction.parent.mkdir(parents=True, exist_ok=True) + reconstruction.touch() + return reconstruction + + +def build_corpus(root: Path) -> BrowserCorpus: + """Writes the corpus and reads it through the real pipeline, so the labels are the real ones.""" + paths = write_corpus(root) + manager = BrowserManager( + FakeConfigManager(root), # type: ignore[arg-type] + language_manager=FakeLanguageManager(texts=dict(BROWSER_TEXTS)), + ) + manager.refresh_tree() + return BrowserCorpus( + tree=manager.tree, + paths=paths, + ) + + +def build_browser_panel( + corpus: BrowserCorpus, + favorites: Set[Path], + *, + favorites_only: bool, + query: str = "", +) -> GUISequencerBrowserPanel: + """Builds a browser panel showing the corpus under a filter, with the favorites its logic answers. + + Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box, + and the control stands where a browser that has yet to build one leaves it. + """ + panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) + panel.tag = PANEL_TAG + panel.tree_tag = TREE_TAG + panel.tree = corpus.tree + panel._logic = FakeTreeLogic(favorites) # type: ignore[assignment] + panel._language_manager = FakeLanguageManager() + panel._colors = TREE_COLORS + _state_detail_labels(panel) + panel._favorites_checkbox_tag = None + panel._favorites_glyph_tag = None + panel.on_favorites_filter_changed = None + panel._filter = TreeFilter(query=query, favorites_only=favorites_only) + panel._resolve_filter() + return panel + + +def _state_detail_labels(panel: GUITreePanel) -> None: + """States the labels a row's details read under, which a configuration row asks for by name.""" + panel._lbl_detail_sample_rate = "sample_rate" + panel._lbl_detail_nes_frequency = "nes_frequency" + panel._lbl_detail_spectrum_method = "spectrum_method" + panel._lbl_detail_transformation_gamma = "transformation_gamma" + panel._lbl_detail_window_size = "window_size" + panel._lbl_detail_generators = "generators" + panel._lbl_detail_configuration = "configuration" + + +def collect_specs(panel: GUITreePanel) -> List[NodeSpec]: + """Collects the rows a rebuild would emit, which is the pass running off the main thread.""" + panel._pending_specs = [] + panel._node_handlers = { + node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType + } + + root = panel.tree.get_root() + assert root is not None + panel._build_tree_node(root, TreeNodeState(parent=panel.tree_tag)) + return panel._pending_specs + + +def render_view(panel: GUITreePanel) -> str: + """Renders the view a rebuild would leave on screen: the rows, their nesting and their state. + + Each row reads as its marker and its label, indented under the row holding it: ``v`` a container + standing open, ``>`` one standing closed, ``-`` a leaf. A row the search hides is marked, since + its widget stands there either way, and a row under a closed container is rendered where it is. + """ + children: Dict[str, List[NodeSpec]] = defaultdict(list) + for spec in collect_specs(panel): + children[spec.parent_tag].append(spec) + + lines: List[str] = [] + _render_rows( + panel, + children, + parent_tag=panel.tree_tag, + depth=0, + lines=lines, + ) + return "\n".join(lines) + + +def _render_rows( + panel: GUITreePanel, + children: Mapping[str, Sequence[NodeSpec]], + *, + parent_tag: str, + depth: int, + lines: List[str], +) -> None: + for spec in children.get(parent_tag, ()): + lines.append(f"{INDENT * depth}{_row_marker(spec)} {spec.label}{_row_state(panel, spec)}") + _render_rows( + panel, + children, + parent_tag=spec.node_tag, + depth=depth + 1, + lines=lines, + ) + + +def _row_marker(spec: NodeSpec) -> str: + if spec.leaf: + return LEAF_MARKER + + return OPEN_MARKER if spec.should_expand else CLOSED_MARKER + + +def _row_state(panel: GUITreePanel, spec: NodeSpec) -> str: + return "" if panel._is_node_visible(spec.node) else HIDDEN_MARKER + + +def as_view(text: str) -> str: + """Reads a view written as an indented block in a test, so the expected rows read as they draw.""" + return dedent(text).strip("\n") + + +def view( + corpus: BrowserCorpus, + favorites: Set[Path], + *, + favorites_only: bool, + query: str = "", +) -> str: + """The view a browser showing the corpus under this filter leaves on screen.""" + return render_view( + build_browser_panel( + corpus, + favorites, + favorites_only=favorites_only, + query=query, + ) + ) diff --git a/tests/unit/sampletones_application/ui/elements/tree/conftest.py b/tests/unit/sampletones_application/ui/elements/tree/conftest.py new file mode 100644 index 000000000..79551b950 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/conftest.py @@ -0,0 +1,11 @@ +from pathlib import Path + +import pytest + +from tests.suite.browser import BrowserCorpus, build_corpus + + +@pytest.fixture +def corpus(tmp_path: Path) -> BrowserCorpus: + """The reconstructions directory the browser tests read, as both views shape it.""" + return build_corpus(tmp_path) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py b/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py new file mode 100644 index 000000000..78360a420 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py @@ -0,0 +1,64 @@ +from typing import Final + +from tests.suite.browser import BrowserCorpus, as_view, view + +WHOLE_TREE: Final[str] = as_view(""" + > By configuration + > 8 kHz·60 Hz·CQT·γ2·P + - sweep + > 44.1 kHz·30 Hz + > CQT·γ0·PTN + - beat + - solo + > FFT·γ0 + > PT + > takes + - alt + - beat + > PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + > PTN·#bbbbbbb + > drums + - kick + - beat + - melody + > archive + > 48 kHz·50 Hz·LogFFT·γ1·TN + - song + - stray + > By sample + > beat + - 44.1 kHz·30 Hz·CQT·γ0·PTN + - 44.1 kHz·30 Hz·FFT·γ0·PT + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + - sweep·8 kHz·60 Hz·CQT·γ2·P + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) + + +class TestWholeTree: + """What a reconstructions directory reads as with nothing to narrow it, in both views. + + The corpus states what the browser has to tell apart, and this is the shape it gives it: two + configurations differing by hash alone marked with that hash, a frequency holding two methods + beside one whose whole chain folded into a single row, an audio gathering the configurations + that reconstructed it, a sample of one variant folded into that variant, a configuration + directory nested in a plain folder, and a reconstruction outside every configuration directory. + """ + + def test_the_whole_tree_is_drawn_with_every_row_folded(self, corpus: BrowserCorpus) -> None: + assert view(corpus, set(), favorites_only=False) == WHOLE_TREE From c273054afe747ede21ad67a9a988066a8c0ff478 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 01:08:26 +0200 Subject: [PATCH 136/152] Fixed: favorites filter opening a starred folder's whole subtree --- .../ui/elements/tree/tree.py | 53 +- tests/suite/browser.py | 60 +- .../ui/elements/tree/test_browser_view.py | 51 +- .../ui/elements/tree/test_favorites.py | 1 + .../ui/elements/tree/test_favorites_filter.py | 586 ++++++++++-------- .../ui/elements/tree/test_filter.py | 1 + 6 files changed, 432 insertions(+), 320 deletions(-) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index b167853e3..d55922095 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -130,6 +130,7 @@ def __init__( self._filter: TreeFilter = NO_FILTER self._search_visibility: Optional[TreeVisibility] = None self._favorites_visibility: Optional[TreeVisibility] = None + self._favorites_anchors: Optional[TreeVisibility] = None self._selected_node_tag: Optional[Union[str, int]] = None self._search_input_tag: Optional[str] = None @@ -561,14 +562,16 @@ def _build_tree_node( def _has_relevant_content(self, node: TreeNode) -> bool: ... def _should_expand_node(self, node: TreeNode) -> bool: - """Whether the row is emitted standing open, which a row leading to a match is. + """Whether the row is emitted standing open, which a row leading to a named row is. - A search result and a favorite are both matches the reader is looking for, so the way down to - either one opens and the filter's answer reads at a glance. + A search result and a favorite are both what the reader is looking for, so the way down to + either one opens and the filter's answer reads at a glance. What each criterion names is the + row the reader is pointed at rather than everything that row brings along, so a folder opens + to show what it holds while the rows inside it stand as they are. """ return any( visibility.should_expand(node) - for visibility in (self._search_visibility, self._favorites_visibility) + for visibility in (self._search_visibility, self._favorites_anchors) if visibility is not None ) @@ -851,7 +854,10 @@ def _resolve_filter(self) -> None: keeps a filter typed before a refresh answering for the rows that refresh brings. """ self._search_visibility = self._resolve_search_visibility() - self._favorites_visibility = self._resolve_favorites_visibility() + ( + self._favorites_visibility, + self._favorites_anchors, + ) = self._resolve_favorites() def _resolve_search_visibility(self) -> Optional[TreeVisibility]: """The rows the search query names, and nothing to narrow by while no query is typed.""" @@ -866,16 +872,24 @@ def _resolve_search_visibility(self) -> Optional[TreeVisibility]: ) ) - def _resolve_favorites_visibility(self) -> Optional[TreeVisibility]: - """The rows the favorites mode names, and nothing to narrow by while the whole tree shows. + def _resolve_favorites( + self, + ) -> Tuple[Optional[TreeVisibility], Optional[TreeVisibility]]: + """The rows the favorites mode keeps, and the rows it points the reader at. - One walk of the model answers the whole mode, and what it keeps is the starred rows together - with the rows above them, so a corpus of any size resolves into a pair of sets. + The two answer different questions — which rows the browser draws, and which of them stand + open — so each is resolved from a set of its own, the second being a part of the first. One + walk of the model finds the rows the star reaches, and the anchors are read out of that + answer, so a corpus of any size resolves into a walk and a pair of sets. """ if not self._filter.favorites_only: - return None + return None, None - return resolve_visibility(self.tree.find_nodes(TreeNode, self._is_node_starred)) + reached = self.tree.find_nodes(TreeNode, self._is_node_starred) + return ( + resolve_visibility(reached), + resolve_visibility([node for node in reached if self._is_node_anchored(node)]), + ) def _is_node_starred(self, node: TreeNode) -> bool: """Whether the favorites mode names the row: it carries a star, or a starred folder holds it. @@ -888,6 +902,23 @@ def _is_node_starred(self, node: TreeNode) -> bool: return isinstance(node, FileSystemNode) and self._logic.has_favorite_ancestor(node) + def _is_node_anchored(self, node: TreeNode) -> bool: + """Whether the mode points the reader at the row, which is what opens the way down to it. + + A star sits on a row the reader marked, so the way to that row opens wherever it sits — + inside another starred folder among the rest. A row a starred folder merely holds is where + the star first reaches only while no row above it is reached, which is how the sample branch + answers: its headings carry no path, so the variants are where the star arrives. + + Asked of the rows the star reaches, so a row it declines stands under a row it named, and + the reader is pointed at the folder rather than at everything inside it. + """ + if self._logic.is_node_favorite(node): + return True + + parent = node.parent + return parent is None or not self._is_node_starred(parent) + def _default_search_predicate(self, node: TreeNode, query: str) -> bool: return query.lower() in node.name.lower() diff --git a/tests/suite/browser.py b/tests/suite/browser.py index 14f2b3b2f..a8e74ce3b 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -52,6 +52,59 @@ INDENT: Final[str] = " " +def as_view(text: str) -> str: + """Reads a view written as an indented block in a test, so the expected rows read as they draw.""" + return dedent(text).strip("\n") + + +WHOLE_TREE: Final[str] = as_view(""" + > By configuration + > 8 kHz·60 Hz·CQT·γ2·P + - sweep + > 44.1 kHz·30 Hz + > CQT·γ0·PTN + - beat + - solo + > FFT·γ0 + > PT + > takes + - alt + - beat + > PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + > PTN·#bbbbbbb + > drums + - kick + - beat + - melody + > archive + > 48 kHz·50 Hz·LogFFT·γ1·TN + - song + - stray + > By sample + > beat + - 44.1 kHz·30 Hz·CQT·γ0·PTN + - 44.1 kHz·30 Hz·FFT·γ0·PT + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + - sweep·8 kHz·60 Hz·CQT·γ2·P + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) + + def config_fields( *, sample_rate: int, @@ -319,9 +372,10 @@ def _row_state(panel: GUITreePanel, spec: NodeSpec) -> str: return "" if panel._is_node_visible(spec.node) else HIDDEN_MARKER -def as_view(text: str) -> str: - """Reads a view written as an indented block in a test, so the expected rows read as they draw.""" - return dedent(text).strip("\n") +def nodes_at(corpus: BrowserCorpus, key: str) -> Tuple[FileSystemNode, ...]: + """Every row standing for one path, which is what a favorite reaches across the two views.""" + path = corpus.paths[key] + return corpus.tree.find_nodes(FileSystemNode, lambda node: node.filepath == path) def view( diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py b/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py index 78360a420..b483a703c 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_browser_view.py @@ -1,53 +1,4 @@ -from typing import Final - -from tests.suite.browser import BrowserCorpus, as_view, view - -WHOLE_TREE: Final[str] = as_view(""" - > By configuration - > 8 kHz·60 Hz·CQT·γ2·P - - sweep - > 44.1 kHz·30 Hz - > CQT·γ0·PTN - - beat - - solo - > FFT·γ0 - > PT - > takes - - alt - - beat - > PTN·#aaaaaaa - > drums - - kick - - snare - - beat - - melody - > PTN·#bbbbbbb - > drums - - kick - - beat - - melody - > archive - > 48 kHz·50 Hz·LogFFT·γ1·TN - - song - - stray - > By sample - > beat - - 44.1 kHz·30 Hz·CQT·γ0·PTN - - 44.1 kHz·30 Hz·FFT·γ0·PT - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - > drums - > kick - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - - snare·44.1 kHz·30 Hz·FFT·γ0·PTN - > melody - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - - solo·44.1 kHz·30 Hz·CQT·γ0·PTN - - sweep·8 kHz·60 Hz·CQT·γ2·P - - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT - """) +from tests.suite.browser import WHOLE_TREE, BrowserCorpus, view class TestWholeTree: diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index fb713bec5..0b0bccd33 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -81,6 +81,7 @@ def build_panel( panel._filter = NO_FILTER panel._search_visibility = None panel._favorites_visibility = None + panel._favorites_anchors = None monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( panel, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index 73835a6fd..c995c1ab0 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -1,264 +1,339 @@ -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Dict, Final, FrozenSet, List, Set, Tuple +from typing import Any, Final, List, Tuple import pytest from sampletones_application.ui.elements.tree import tree as tree_module -from sampletones_application.ui.elements.tree.colors import TreeColors -from sampletones_application.ui.elements.tree.filter import TreeFilter -from sampletones_application.ui.elements.tree.handler import NodeHandler -from sampletones_application.ui.elements.tree.spec import NodeSpec -from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_application.utils.palette.colors.base import BaseColor -from sampletones_application.utils.palette.colors.literal import LiteralColor -from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode -from tests.suite.language import FakeLanguageManager +from sampletones_core.structures.tree import TreeNode +from tests.suite.browser import ( + PANEL_TAG, + TREE_COLORS, + WHOLE_TREE, + BrowserCorpus, + as_view, + build_browser_panel, + nodes_at, + view, +) -PANEL_TAG: Final[str] = "sequencer.browser" CHECKBOX_TAG: Final[str] = "sequencer.browser.checkbox.favorites" GLYPH_TAG: Final[str] = "sequencer.browser.text.favorites" -TREE_COLORS: Final[TreeColors] = TreeColors( - favorite=LiteralColor((240, 200, 80, 255)), - node=LiteralColor((200, 200, 200, 255)), - muted=LiteralColor((120, 120, 120, 255)), - accent=LiteralColor((80, 160, 240, 255)), -) +STARRED_RECONSTRUCTION: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#aaaaaaa + - beat + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + """) +STARRED_LONE_AUDIO: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v CQT·γ0·PTN + - solo + v By sample + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + """) +STARRED_IN_SUBFOLDER: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#aaaaaaa + v drums + - kick + v By sample + v drums + v kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + """) +STARRED_CONFIGURATION: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PT + > takes + - alt + - beat + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PT + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) +STARRED_PLAIN_FOLDER: Final[str] = as_view(""" + v By configuration + v archive + > 48 kHz·50 Hz·LogFFT·γ1·TN + - song + """) +STARRED_FOLDER_AND_WHAT_IT_HOLDS: Final[str] = as_view(""" + v By configuration + v archive + v 48 kHz·50 Hz·LogFFT·γ1·TN + - song + """) +STARRED_STRAY: Final[str] = as_view(""" + v By configuration + - stray + """) +STARRED_OF_TWO_ALIKE: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + v drums + v kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + v melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + """) +STARRED_FOLDED_CONFIGURATION: Final[str] = as_view(""" + v By configuration + v 8 kHz·60 Hz·CQT·γ2·P + - sweep + v By sample + - sweep·8 kHz·60 Hz·CQT·γ2·P + """) +STARRED_CONFIGURATION_B: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#bbbbbbb + > drums + - kick + - beat + - melody + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + v drums + v kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + v melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + """) +STARRED_FOLDER_HOLDING_A_STAR: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#bbbbbbb + v drums + - kick + - beat + - melody + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + v drums + v kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + v melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + """) +QUERY_INSIDE_THE_MODE: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#bbbbbbb + > drums [hidden] + - kick [hidden] + - beat [hidden] + - melody + v By sample + v beat [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden] + v drums [hidden] + v kick [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden] + v melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + """) +QUERY_PAST_THE_MODE: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PTN·#aaaaaaa + - beat [hidden] + v By sample + v beat [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa [hidden] + """) +QUERY_ALONE: Final[str] = as_view(""" + v By configuration + > 8 kHz·60 Hz·CQT·γ2·P [hidden] + - sweep [hidden] + v 44.1 kHz·30 Hz + > CQT·γ0·PTN [hidden] + - beat [hidden] + - solo [hidden] + v FFT·γ0 + > PT [hidden] + > takes [hidden] + - alt [hidden] + - beat [hidden] + v PTN·#aaaaaaa + v drums + - kick + - snare [hidden] + - beat [hidden] + - melody [hidden] + v PTN·#bbbbbbb + v drums + - kick + - beat [hidden] + - melody [hidden] + > archive [hidden] + > 48 kHz·50 Hz·LogFFT·γ1·TN [hidden] + - song [hidden] + - stray [hidden] + v By sample + > beat [hidden] + - 44.1 kHz·30 Hz·CQT·γ0·PTN [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PT [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden] + v drums + v kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN [hidden] + > melody [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa [hidden] + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden] + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN [hidden] + - sweep·8 kHz·60 Hz·CQT·γ2·P [hidden] + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT [hidden] + """) -CONFIG_DIRECTORY: Final[Path] = Path("/reconstructions/sr_44100_nf_30") -STARRED_PATH: Final[Path] = CONFIG_DIRECTORY / "starred.stn" -PLAIN_PATH: Final[Path] = CONFIG_DIRECTORY / "plain.stn" -VARIANT_LABEL: Final[str] = "44.1 kHz·30 Hz" - -STARRED_ROWS: Final[FrozenSet[str]] = frozenset( - { - "configurations", - "directory", - "starred", - "samples", - "starred_sample", - "starred_variant", - } -) -SAMPLE_VIEW_ROWS: Final[FrozenSet[str]] = frozenset( - { - "samples", - "starred_sample", - "starred_variant", - "plain_sample", - "plain_variant", - } -) +class TestDrawnRows: + """Which rows the mode draws: what the star reaches, and the rows leading down to it.""" -class FakeTreeLogic: - """Answers the favorite questions a browser asks of its logic while it collects its rows.""" - - def __init__(self, favorites: Set[Path]) -> None: - self._favorites = favorites - - def is_node_favorite(self, node: TreeNode) -> bool: - return isinstance(node, FileSystemNode) and node.filepath in self._favorites - - def has_favorite_ancestor(self, node: FileSystemNode) -> bool: - return any(directory in self._favorites for directory in node.filepath.parents) - - -@dataclass(frozen=True) -class BrowserTree: - """The shape both browser views give one configuration directory, with a handle on every row.""" - - tree: Tree - rows: Dict[str, TreeNode] - - -@pytest.fixture -def browser() -> BrowserTree: - """Two reconstructions of one configuration, listed by that configuration and by their samples. - - A sample row carries the name of the reconstruction it gathers, the way the builder names it, so - each row is held by a key of its own rather than by the label it reads under. - """ - root = TreeNode("Root", node_type=NodeType.ROOT) - configurations = TreeNode("By configuration", node_type=NodeType.GROUP, parent=root) - directory = FileSystemNode( - "PTN", - node_type=NodeType.DIRECTORY, - filepath=CONFIG_DIRECTORY, - parent=configurations, - ) - starred = FileSystemNode("starred", node_type=NodeType.FILE, filepath=STARRED_PATH, parent=directory) - plain = FileSystemNode("plain", node_type=NodeType.FILE, filepath=PLAIN_PATH, parent=directory) - - samples = TreeNode("By sample", node_type=NodeType.GROUP, parent=root) - starred_sample = TreeNode("starred", node_type=NodeType.SAMPLE, parent=samples) - starred_variant = FileSystemNode( - VARIANT_LABEL, - node_type=NodeType.FILE, - filepath=STARRED_PATH, - parent=starred_sample, - ) - plain_sample = TreeNode("plain", node_type=NodeType.SAMPLE, parent=samples) - plain_variant = FileSystemNode( - VARIANT_LABEL, - node_type=NodeType.FILE, - filepath=PLAIN_PATH, - parent=plain_sample, - ) - - return BrowserTree( - tree=Tree(root=root), - rows={ - "configurations": configurations, - "directory": directory, - "starred": starred, - "plain": plain, - "samples": samples, - "starred_sample": starred_sample, - "starred_variant": starred_variant, - "plain_sample": plain_sample, - "plain_variant": plain_variant, - }, - ) - - -def build_panel( - browser: BrowserTree, - favorites: Set[Path], - *, - favorites_only: bool, - query: str = "", -) -> GUISequencerBrowserPanel: - """Builds a browser panel showing the tree under a filter, with the favorites its logic answers. - - Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box, - and the control stands where a browser that has yet to build one leaves it. - """ - panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) - panel.tag = PANEL_TAG - panel.tree = browser.tree - panel._logic = FakeTreeLogic(favorites) - panel._language_manager = FakeLanguageManager() - panel._colors = TREE_COLORS - panel._favorites_checkbox_tag = None - panel._favorites_glyph_tag = None - panel.on_favorites_filter_changed = None - panel._filter = TreeFilter(query=query, favorites_only=favorites_only) - panel._resolve_filter() - return panel - - -def collect_specs(panel: GUISequencerBrowserPanel) -> List[NodeSpec]: - """Collects the rows a rebuild would emit, which is the pass running off the main thread.""" - panel._pending_specs = [] - panel._node_handlers = { - node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType - } - - root = panel.tree.get_root() - assert root is not None - panel._build_tree_node(root, TreeNodeState(parent="tree")) - return panel._pending_specs - - -def drawn_keys( - browser: BrowserTree, - specs: List[NodeSpec], -) -> Set[str]: - drawn = {spec.node for spec in specs} - return {key for key, node in browser.rows.items() if node in drawn} - - -def open_keys( - browser: BrowserTree, - specs: List[NodeSpec], -) -> Set[str]: - standing_open = {spec.node for spec in specs if spec.should_expand} - return {key for key, node in browser.rows.items() if node in standing_open} + def test_a_starred_reconstruction_is_drawn_in_both_views(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION + def test_a_starred_reconstruction_of_an_audio_one_configuration_holds(self, corpus: BrowserCorpus) -> None: + """A sample of a single variant folded into that variant, and the fold carries the star.""" + assert view(corpus, {corpus.paths["D/solo"]}, favorites_only=True) == STARRED_LONE_AUDIO -class TestDrawnRows: - def test_a_starred_reconstruction_is_drawn_under_the_rows_holding_it_in_both_views( - self, - browser: BrowserTree, - ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) - assert drawn_keys(browser, collect_specs(panel)) == STARRED_ROWS + def test_a_starred_reconstruction_in_a_mirrored_subfolder(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["A/drums/kick"]}, favorites_only=True) == STARRED_IN_SUBFOLDER - def test_a_starred_directory_brings_the_reconstructions_it_holds( - self, - browser: BrowserTree, - ) -> None: - panel = build_panel(browser, {CONFIG_DIRECTORY}, favorites_only=True) - assert {"directory", "starred", "plain"} <= drawn_keys(browser, collect_specs(panel)) + def test_a_starred_configuration_directory_brings_what_it_holds(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["C"]}, favorites_only=True) == STARRED_CONFIGURATION - def test_a_starred_directory_reaches_the_view_holding_no_row_for_it( - self, - browser: BrowserTree, - ) -> None: - """The sample view lists reconstructions under their samples, and no row stands for a folder. + def test_a_starred_plain_folder_reaches_the_configuration_nested_in_it(self, corpus: BrowserCorpus) -> None: + """The sample branch reads the top-level configurations, so a nested one stands there alone.""" + assert view(corpus, {corpus.paths["archive"]}, favorites_only=True) == STARRED_PLAIN_FOLDER - Being held by a starred folder is read from the path, so each variant answers for itself and - the sample gathering it comes along. - """ - panel = build_panel(browser, {CONFIG_DIRECTORY}, favorites_only=True) - assert SAMPLE_VIEW_ROWS <= drawn_keys(browser, collect_specs(panel)) + def test_a_starred_reconstruction_outside_every_configuration(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["stray"]}, favorites_only=True) == STARRED_STRAY - def test_nothing_starred_draws_no_row(self, browser: BrowserTree) -> None: - panel = build_panel(browser, set(), favorites_only=True) - assert collect_specs(panel) == [] + def test_a_star_on_one_of_two_configurations_reading_alike(self, corpus: BrowserCorpus) -> None: + """The star belongs to a path, so the sibling marked with the other hash stays out.""" + assert view(corpus, {corpus.paths["A"]}, favorites_only=True) == STARRED_OF_TWO_ALIKE - def test_the_mode_off_draws_every_row(self, browser: BrowserTree) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) - assert drawn_keys(browser, collect_specs(panel)) == set(browser.rows) + def test_a_starred_configuration_whose_chain_folded_into_one_row(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["E"]}, favorites_only=True) == STARRED_FOLDED_CONFIGURATION + def test_nothing_starred_draws_no_row(self, corpus: BrowserCorpus) -> None: + assert view(corpus, set(), favorites_only=True) == "" -class TestOpenRows: - def test_the_rows_leading_to_a_favorite_stand_open(self, browser: BrowserTree) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) - assert open_keys(browser, collect_specs(panel)) == { - "configurations", - "directory", - "samples", - "starred_sample", - } + def test_the_mode_off_draws_every_row(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=False) == WHOLE_TREE - def test_the_mode_off_leaves_every_row_as_it_stands(self, browser: BrowserTree) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) - assert open_keys(browser, collect_specs(panel)) == set() +class TestOpenRows: + """Which rows stand open: the way down to a star, and a starred folder showing what it holds.""" -class TestSearchInsideTheMode: - def test_the_mode_states_the_drawn_rows_while_the_query_states_the_shown_ones( + def test_a_starred_folder_opens_and_a_subfolder_holding_no_star_stays_closed( self, - browser: BrowserTree, + corpus: BrowserCorpus, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True, query="starred") - specs = collect_specs(panel) + assert view(corpus, {corpus.paths["C"]}, favorites_only=True) == STARRED_CONFIGURATION - assert drawn_keys(browser, specs) == STARRED_ROWS - assert panel._is_node_visible(browser.rows["starred"]) - assert not panel._is_node_visible(browser.rows["plain"]) + def test_a_starred_folder_opens_one_level(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["A"]}, favorites_only=True) == STARRED_OF_TWO_ALIKE - def test_a_query_naming_a_row_the_mode_leaves_out_shows_nothing_of_it( + def test_a_star_inside_a_starred_folder_opens_the_way_down_to_itself(self, corpus: BrowserCorpus) -> None: + favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} + assert view(corpus, favorites, favorites_only=True) == STARRED_FOLDER_HOLDING_A_STAR + + def test_a_starred_folder_inside_a_starred_folder_opens(self, corpus: BrowserCorpus) -> None: + favorites = {corpus.paths["archive"], corpus.paths["archive/F"]} + assert view(corpus, favorites, favorites_only=True) == STARRED_FOLDER_AND_WHAT_IT_HOLDS + + def test_the_rows_above_a_starred_reconstruction_open(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION + + def test_the_sample_branch_opens_the_way_to_the_variants_a_starred_folder_holds( self, - browser: BrowserTree, + corpus: BrowserCorpus, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True, query="plain") - assert "plain" not in drawn_keys(browser, collect_specs(panel)) + """No row stands for the folder there, so the variants are where the star arrives.""" + assert view(corpus, {corpus.paths["B"]}, favorites_only=True) == STARRED_CONFIGURATION_B + + +class TestSearchInsideTheMode: + """The mode states which rows are drawn, and the query states which of them are shown.""" + + def test_a_query_hides_the_drawn_rows_it_leaves_out(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["B"]}, + favorites_only=True, + query="melody", + ) + == QUERY_INSIDE_THE_MODE + ) + + def test_a_query_naming_a_row_the_mode_leaves_out_shows_nothing_of_it(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=True, + query="melody", + ) + == QUERY_PAST_THE_MODE + ) + + def test_a_query_cleared_shows_the_rows_the_mode_draws(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["B"]}, + favorites_only=True, + query="", + ) + == STARRED_CONFIGURATION_B + ) + + def test_a_query_alone_draws_every_row_and_shows_the_matches(self, corpus: BrowserCorpus) -> None: + assert view(corpus, set(), favorites_only=False, query="kick") == QUERY_ALONE class TestEmptyAnswer: """A rebuild drawing no row names the filter that answered so, where the rows would be.""" - def test_the_mode_finding_no_favorite_names_the_favorites(self, browser: BrowserTree) -> None: - panel = build_panel(browser, set(), favorites_only=True) + def test_the_mode_finding_no_favorite_names_the_favorites(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=True) assert panel._empty_filter_message() == "global.dialog.message.tree_no_favorites" - def test_a_query_finding_nothing_names_the_results(self, browser: BrowserTree) -> None: - panel = build_panel(browser, set(), favorites_only=False, query="nothing") + def test_a_query_finding_nothing_names_the_results(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False, query="nothing") assert panel._empty_filter_message() == "global.dialog.message.tree_no_results" @@ -267,10 +342,10 @@ class TestControl: def test_the_mode_the_control_reads_reaches_the_filter( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False) panel._on_favorites_only_changed(None, True) @@ -279,10 +354,10 @@ def test_the_mode_the_control_reads_reaches_the_filter( def test_a_change_is_handed_to_the_hook_remembering_it( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) remembered: List[Tuple[str, bool]] = [] panel.on_favorites_filter_changed = lambda panel_tag, favorites_only: remembered.append( (panel_tag, favorites_only) @@ -295,10 +370,10 @@ def test_a_change_is_handed_to_the_hook_remembering_it( def test_a_change_draws_the_rows_the_new_mode_names( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) redraws: List[bool] = [] monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False) @@ -306,11 +381,8 @@ def test_a_change_draws_the_rows_the_new_mode_names( assert redraws == [True] - def test_the_mode_a_session_left_on_stands_before_the_first_rebuild( - self, - browser: BrowserTree, - ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + def test_the_mode_a_session_left_on_stands_before_the_first_rebuild(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) panel._restore_favorites_only(True) @@ -318,35 +390,35 @@ def test_the_mode_a_session_left_on_stands_before_the_first_rebuild( def test_a_query_typed_earlier_survives_a_change_of_mode( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False, query="starred") + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False, query="beat") monkeypatch.setattr(panel, "redraw_tree", lambda: None, raising=False) panel._on_favorites_only_changed(None, True) - assert panel._filter.query == "starred" + assert panel._filter.query == "beat" class TestStarColor: """The star beside the label reads in the colour of the mode it stands for.""" - def test_the_star_reads_favorite_while_the_mode_is_on(self, browser: BrowserTree) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + def test_the_star_reads_favorite_while_the_mode_is_on(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=True) assert panel._favorites_glyph_color() == TREE_COLORS.favorite - def test_the_star_reads_muted_while_the_mode_is_off(self, browser: BrowserTree) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + def test_the_star_reads_muted_while_the_mode_is_off(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) assert panel._favorites_glyph_color() == TREE_COLORS.muted def test_the_star_is_coloured_with_the_token_the_mode_names( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: """The colour reaches the star as a token, so the star follows a palette swapped in place.""" - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + panel = build_browser_panel(corpus, set(), favorites_only=True) panel._favorites_glyph_tag = GLYPH_TAG coloured: List[Tuple[str, BaseColor]] = [] monkeypatch.setattr( @@ -365,10 +437,10 @@ class TestControlLock: def test_the_lock_reaches_the_control( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + panel = build_browser_panel(corpus, set(), favorites_only=True) panel._favorites_checkbox_tag = CHECKBOX_TAG configured: List[Tuple[str, Any]] = [] monkeypatch.setattr( @@ -383,10 +455,10 @@ def test_the_lock_reaches_the_control( def test_a_browser_offering_no_control_answers_the_lock_as_it_stands( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + panel = build_browser_panel(corpus, set(), favorites_only=False) configured: List[Tuple[str, Any]] = [] monkeypatch.setattr( tree_module, @@ -402,10 +474,10 @@ def test_a_browser_offering_no_control_answers_the_lock_as_it_stands( class TestFavoriteChange: def test_a_change_draws_the_tree_again_while_the_mode_is_on( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=True) + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=True) redraws: List[bool] = [] repaints: List[TreeNode] = [] monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False) @@ -416,17 +488,18 @@ def test_a_change_draws_the_tree_again_while_the_mode_is_on( raising=False, ) - panel.update_favorite_indicators([browser.rows["starred"]]) + panel.update_favorite_indicators(nodes_at(corpus, "A/beat")) assert redraws == [True] assert repaints == [] - def test_a_change_repaints_the_rows_while_the_mode_is_off( + def test_a_change_repaints_every_row_standing_for_the_path_while_the_mode_is_off( self, - browser: BrowserTree, + corpus: BrowserCorpus, monkeypatch: pytest.MonkeyPatch, ) -> None: - panel = build_panel(browser, {STARRED_PATH}, favorites_only=False) + """One path reaches the panel as a row in each view, and each takes its own ancestry.""" + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) redraws: List[bool] = [] repaints: List[TreeNode] = [] monkeypatch.setattr(panel, "redraw_tree", lambda: redraws.append(True), raising=False) @@ -436,8 +509,9 @@ def test_a_change_repaints_the_rows_while_the_mode_is_off( lambda node, has_favorite_ancestor=False: repaints.append(node), raising=False, ) + rows = nodes_at(corpus, "A/beat") - panel.update_favorite_indicators([browser.rows["starred"], browser.rows["starred_variant"]]) + panel.update_favorite_indicators(rows) assert redraws == [] - assert repaints == [browser.rows["starred"], browser.rows["starred_variant"]] + assert repaints == list(rows) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py index 5849f65ab..d538bc63e 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py @@ -51,6 +51,7 @@ def build_panel( panel._filter = NO_FILTER panel._search_visibility = None panel._favorites_visibility = None + panel._favorites_anchors = None return panel From ec466212bfcdecac83c337630756a61132fab12d Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 01:32:25 +0200 Subject: [PATCH 137/152] Added: browser remembering which rows stand open --- .../ui/elements/tree/tree.py | 81 ++++- .../ui/panels/shared/browser.py | 12 +- tests/suite/browser.py | 40 ++- .../ui/elements/tree/test_expansion_memory.py | 277 ++++++++++++++++++ .../ui/elements/tree/test_favorites.py | 1 + .../shared/test_container_context_menu.py | 36 +++ 6 files changed, 433 insertions(+), 14 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index d55922095..bccbfdd87 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -1,7 +1,7 @@ from abc import ABC, abstractmethod from functools import partial from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union +from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Union import dearpygui.dearpygui as dpg @@ -101,6 +101,7 @@ class GUITreePanel(GUIPanel, ABC): _NAME_FONT: Font = Font.REGULAR_SMALL _CONFIG_FONT: Font = Font.MONO_SMALL _MONOSPACE_CONFIG_NODES: bool = False + _REMEMBERS_EXPANSION: bool = False def __init__( self, @@ -125,6 +126,7 @@ def __init__( self.tree_tag = tree_tag self._pending_specs: List[NodeSpec] = [] + self._expanded_rows: Set[str] = set() self._emitter = TreeEmitter(scheduling=scheduling) self._filter: TreeFilter = NO_FILTER @@ -227,8 +229,21 @@ def _collect_specs(self, root_tag: str) -> List[NodeSpec]: if root is not None: self._build_tree_node(root, state=TreeNodeState(parent=root_tag)) + self._forget_rows_the_model_dropped() return self._pending_specs + def _forget_rows_the_model_dropped(self) -> None: + """Holds the memory of open rows to the rows a pass over the whole tree found. + + A pass showing everything states which rows exist, so a row it left out belongs to a folder + the disk no longer holds and its place in the memory goes with it. A pass narrowed to the + favorites speaks for those rows alone, and leaves the memory of the rest as it stands. + """ + if not self._REMEMBERS_EXPANSION or self._filter.favorites_only: + return + + self._expanded_rows &= {spec.node_tag for spec in self._pending_specs} + def create_search(self, parent: str) -> None: self._search_input_tag = compose_tag(self.tag, SUF_INPUT_SEARCH) self._search_button_tag = compose_tag(self.tag, SUF_BUTTON_SEARCH) @@ -366,6 +381,11 @@ def _append_spec( has_favorite_ancestor=has_favorite_ancestor, is_node_expanded=is_node_expanded, ) + stands_open = self._stands_open( + node, + node_tag, + should_expand=should_expand, + ) self._pending_specs.append( NodeSpec( node=node, @@ -376,12 +396,40 @@ def _append_spec( leaf=leaf, open_on_arrow=open_on_arrow, open_on_double_click=open_on_double_click, - should_expand=should_expand, + should_expand=stands_open, theme_tag=theme_tag, handler_tag=self._node_handlers[node.node_type].tag, ) ) + def _stands_open( + self, + node: TreeNode, + node_tag: str, + *, + should_expand: bool, + ) -> bool: + """Whether the row is created standing open: the filter points at it, or the memory holds it. + + The shape the reader built is theirs to keep, so a row they opened comes back open and the + filter adds the way down to what it names. Recording the answer here is what carries that + shape into the pass after this one. + """ + if not self._REMEMBERS_EXPANSION: + return should_expand + + stands_open = should_expand or node_tag in self._expanded_rows + self._set_row_expanded(node_tag, stands_open and bool(node.children)) + return stands_open + + def _set_row_expanded(self, node_tag: str, expanded: bool) -> None: + """Holds whether a row stands open, which is what a later pass brings it back by.""" + if expanded: + self._expanded_rows.add(node_tag) + return + + self._expanded_rows.discard(node_tag) + def _finish_emit( self, root_tag: str, @@ -496,6 +544,7 @@ def single_click_callback( app_data: Tuple[int, int], ) -> None: user_data = dpg.get_item_user_data(app_data[1]) + self._remember_clicked_row(user_data) if item_click_callback is not None: item_click_callback(sender, app_data, user_data=user_data) @@ -522,6 +571,34 @@ def double_click_callback( return double_click_callback + def _remember_clicked_row(self, user_data: Any) -> None: + """Follows a click through to what it left the row standing as, a frame after it landed. + + A click on a row the reader can open is how that row folds and unfolds, and the row states + its own answer once the frame carrying the click has drawn. Reading it the frame after + therefore reports what the reader did, whichever button they pressed, and a row holding + nothing has nothing to remember. + """ + if not self._REMEMBERS_EXPANSION or not isinstance(user_data, tuple): + return + + node, node_tag = user_data + if not node.children: + return + + CallbackQueue.add( + self._read_row_expansion, + node_tag, + delay=1, + ) + + def _read_row_expansion(self, node_tag: str) -> None: + """Takes the state a row stands in into the memory, on the main thread that owns the row.""" + if not dpg.does_item_exist(node_tag): + return + + self._set_row_expanded(node_tag, bool(dpg_get_value(node_tag))) + def _setup_handlers(self) -> None: for handler in self._node_handlers.values(): with dpg.item_handler_registry(tag=handler.tag): diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 387d60095..d3805a338 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -39,11 +39,13 @@ class GUIReconstructionBrowserPanel(GUIFileBrowserPanel): its refresh control, and adds the items its context menus offer. Reconstructions carry favorites, so this browser offers the control showing them alone and opens - in the mode the session left it in. + in the mode the session left it in. It holds the shape the reader unfolded as well, so a rebuild + — a refresh, a change of mode — brings the rows back standing as they were left. """ _MONOSPACE_CONFIG_NODES: bool = True _OFFERS_FAVORITES_FILTER: bool = True + _REMEMBERS_EXPANSION: bool = True def __init__( self, @@ -266,12 +268,14 @@ def _add_context_menu_expansion_items(self, node: TreeNode) -> None: def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: """Folds or unfolds the row together with every row below it holding something. - Whether a row stands open is a fact of the widget alone, so each row is reached by the tag it - was built under and set directly. + Each row is reached by the tag it was built under and set directly, and the browser is told + what it now stands as, so a rebuild brings the whole subtree back the way this left it. """ for container in (node, *node.descendants): if container.children: - dpg_set_value(self._generate_node_tag(container), expanded) + node_tag = self._generate_node_tag(container) + dpg_set_value(node_tag, expanded) + self._set_row_expanded(node_tag, expanded) def _add_context_menu_copy_name_item(self, node: TreeNode) -> None: """Offers the label the tree reads the row by, which for a folded chain names every level.""" diff --git a/tests/suite/browser.py b/tests/suite/browser.py index a8e74ce3b..cbb9cff66 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -9,7 +9,6 @@ from sampletones_application.ui.elements.tree.filter import TreeFilter from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.spec import NodeSpec -from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.elements.tree.tree import GUITreePanel from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_application.utils.palette.colors.literal import LiteralColor @@ -274,6 +273,7 @@ def build_browser_panel( *, favorites_only: bool, query: str = "", + panel_tag: str = PANEL_TAG, ) -> GUISequencerBrowserPanel: """Builds a browser panel showing the corpus under a filter, with the favorites its logic answers. @@ -281,7 +281,8 @@ def build_browser_panel( and the control stands where a browser that has yet to build one leaves it. """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) - panel.tag = PANEL_TAG + panel.tag = panel_tag + panel._expanded_rows = set() panel.tree_tag = TREE_TAG panel.tree = corpus.tree panel._logic = FakeTreeLogic(favorites) # type: ignore[assignment] @@ -309,15 +310,10 @@ def _state_detail_labels(panel: GUITreePanel) -> None: def collect_specs(panel: GUITreePanel) -> List[NodeSpec]: """Collects the rows a rebuild would emit, which is the pass running off the main thread.""" - panel._pending_specs = [] panel._node_handlers = { node_type: NodeHandler(tag=f"handler.{node_type.value}", node_type=node_type) for node_type in NodeType } - - root = panel.tree.get_root() - assert root is not None - panel._build_tree_node(root, TreeNodeState(parent=panel.tree_tag)) - return panel._pending_specs + return panel._collect_specs(panel.tree_tag) def render_view(panel: GUITreePanel) -> str: @@ -378,6 +374,34 @@ def nodes_at(corpus: BrowserCorpus, key: str) -> Tuple[FileSystemNode, ...]: return corpus.tree.find_nodes(FileSystemNode, lambda node: node.filepath == path) +def row_named(corpus: BrowserCorpus, label: str) -> TreeNode: + """The row reading under this label, which is how a test names a heading the browser wrote.""" + rows = corpus.tree.find_nodes(TreeNode, lambda node: str(node.name) == label) + assert len(rows) == 1 + return rows[0] + + +def set_row_expanded( + panel: GUITreePanel, + node: TreeNode, + *, + expanded: bool, +) -> None: + """Leaves a row standing the way the reader would leave it, which the browser then remembers.""" + panel._set_row_expanded(panel._generate_node_tag(node), expanded) + + +def set_filter( + panel: GUITreePanel, + *, + favorites_only: bool, + query: str = "", +) -> None: + """States what the browser is now asked to show, as a change of the control or the search box.""" + panel._filter = TreeFilter(query=query, favorites_only=favorites_only) + panel._resolve_filter() + + def view( corpus: BrowserCorpus, favorites: Set[Path], diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py new file mode 100644 index 000000000..d18fa5ab7 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -0,0 +1,277 @@ +from pathlib import Path +from typing import Any, Dict, Final, List, Tuple + +import pytest + +from sampletones_application.ui.elements.tree import tree as tree_module +from tests.suite.browser import ( + WHOLE_TREE, + BrowserCorpus, + as_view, + build_browser_panel, + build_corpus, + nodes_at, + render_view, + row_named, + set_filter, + set_row_expanded, +) + +STARRED_CONFIGURATION: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PT + > takes + - alt + - beat + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PT + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) +SUBFOLDER_THE_READER_OPENED: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + v PT + v takes + - alt + - beat + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PT + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) +WHOLE_TREE_AFTER_THE_MODE: Final[str] = as_view(""" + v By configuration + > 8 kHz·60 Hz·CQT·γ2·P + - sweep + v 44.1 kHz·30 Hz + > CQT·γ0·PTN + - beat + - solo + v FFT·γ0 + > PT + > takes + - alt + - beat + v PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + > PTN·#bbbbbbb + > drums + - kick + - beat + - melody + > archive + > 48 kHz·50 Hz·LogFFT·γ1·TN + - song + - stray + v By sample + v beat + - 44.1 kHz·30 Hz·CQT·γ0·PTN + - 44.1 kHz·30 Hz·FFT·γ0·PT + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + - sweep·8 kHz·60 Hz·CQT·γ2·P + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) + +WHOLE_TREE_WITHOUT_THE_ARCHIVE: Final[str] = as_view(""" + > By configuration + > 8 kHz·60 Hz·CQT·γ2·P + - sweep + > 44.1 kHz·30 Hz + > CQT·γ0·PTN + - beat + - solo + > FFT·γ0 + > PT + > takes + - alt + - beat + > PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + > PTN·#bbbbbbb + > drums + - kick + - beat + - melody + - stray + > By sample + > beat + - 44.1 kHz·30 Hz·CQT·γ0·PTN + - 44.1 kHz·30 Hz·FFT·γ0·PT + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + - sweep·8 kHz·60 Hz·CQT·γ2·P + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) + + +class TestTheReadersShape: + """A row stands where the reader left it, and a later pass brings it back that way.""" + + def test_a_row_the_reader_opened_is_drawn_open(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, {corpus.paths["C"]}, favorites_only=True) + assert render_view(panel) == STARRED_CONFIGURATION + + set_row_expanded(panel, row_named(corpus, "takes"), expanded=True) + + assert render_view(panel) == SUBFOLDER_THE_READER_OPENED + + def test_a_row_the_reader_closed_is_drawn_closed(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, {corpus.paths["C"]}, favorites_only=True) + set_row_expanded(panel, row_named(corpus, "takes"), expanded=True) + render_view(panel) + + set_row_expanded(panel, row_named(corpus, "takes"), expanded=False) + + assert render_view(panel) == STARRED_CONFIGURATION + + def test_the_rows_the_mode_opened_stand_open_once_it_goes_off(self, corpus: BrowserCorpus) -> None: + """What the browser unfolded to show a favorite is part of the shape the reader is left with.""" + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=True) + render_view(panel) + + set_filter(panel, favorites_only=False) + + assert render_view(panel) == WHOLE_TREE_AFTER_THE_MODE + + def test_a_row_the_mode_never_drew_keeps_the_state_it_had(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) + set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + render_view(panel) + + set_filter(panel, favorites_only=True) + render_view(panel) + set_filter(panel, favorites_only=False) + + assert "v archive" in render_view(panel) + + def test_a_refresh_brings_the_rows_back_standing_as_they_were( + self, + corpus: BrowserCorpus, + tmp_path: Path, + ) -> None: + """A rebuilt model states the same rows, and a row is remembered by the ancestry it reads.""" + panel = build_browser_panel(corpus, set(), favorites_only=False) + set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + render_view(panel) + + panel.tree = build_corpus(tmp_path).tree + + assert "v archive" in render_view(panel) + + def test_a_pass_over_the_whole_tree_forgets_the_rows_the_model_dropped( + self, + corpus: BrowserCorpus, + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + archive = row_named(corpus, "archive") + set_row_expanded(panel, archive, expanded=True) + render_view(panel) + + archive.parent = None + + assert render_view(panel) == WHOLE_TREE_WITHOUT_THE_ARCHIVE + assert panel._expanded_rows == set() + + def test_two_browsers_over_one_tree_remember_their_own_shape(self, corpus: BrowserCorpus) -> None: + """A row is remembered under the tag of the browser showing it, so neither reaches the other.""" + sequencer = build_browser_panel(corpus, set(), favorites_only=False, panel_tag="sequencer.browser") + reconstruction = build_browser_panel(corpus, set(), favorites_only=False, panel_tag="reconstruction.browser") + + set_row_expanded(sequencer, row_named(corpus, "archive"), expanded=True) + + assert "v archive" in render_view(sequencer) + assert render_view(reconstruction) == WHOLE_TREE + + +class TestFollowingTheReader: + """A click on a row is how it folds, and the browser reads what it stands as afterwards.""" + + def test_a_click_reads_the_row_the_frame_after_it_landed( + self, + corpus: BrowserCorpus, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + scheduled: List[Tuple[Any, Tuple[Any, ...], Dict[str, Any]]] = [] + monkeypatch.setattr( + tree_module.CallbackQueue, + "add", + lambda callback, *args, **kwargs: scheduled.append((callback, args, kwargs)), + ) + + panel._remember_clicked_row((row_named(corpus, "archive"), "row.tag")) + + assert scheduled == [(panel._read_row_expansion, ("row.tag",), {"delay": 1})] + + def test_a_row_holding_nothing_has_nothing_to_remember( + self, + corpus: BrowserCorpus, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + scheduled: List[Tuple[Any, Tuple[Any, ...], Dict[str, Any]]] = [] + monkeypatch.setattr( + tree_module.CallbackQueue, + "add", + lambda callback, *args, **kwargs: scheduled.append((callback, args, kwargs)), + ) + + panel._remember_clicked_row((nodes_at(corpus, "stray")[0], "row.tag")) + + assert scheduled == [] + + def test_the_reading_takes_the_state_the_row_stands_in( + self, + corpus: BrowserCorpus, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + monkeypatch.setattr(tree_module.dpg, "does_item_exist", lambda tag: True) + monkeypatch.setattr(tree_module, "dpg_get_value", lambda tag: True) + + panel._read_row_expansion("row.tag") + + assert panel._expanded_rows == {"row.tag"} + + def test_a_row_that_left_the_tree_is_read_no_further( + self, + corpus: BrowserCorpus, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + monkeypatch.setattr(tree_module.dpg, "does_item_exist", lambda tag: False) + + panel._read_row_expansion("row.tag") + + assert panel._expanded_rows == set() diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index 0b0bccd33..476683570 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -82,6 +82,7 @@ def build_panel( panel._search_visibility = None panel._favorites_visibility = None panel._favorites_anchors = None + panel._expanded_rows = set() monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( panel, diff --git a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py index 482eaecb1..ddf2d1f76 100644 --- a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py @@ -48,6 +48,7 @@ def _panel() -> GUISequencerBrowserPanel: """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = PANEL_TAG + panel._expanded_rows = set() panel._language_manager = FakeLanguageManager(TEXTS) panel._colors = TreeColors( favorite=TEXT_COLOR, @@ -286,6 +287,41 @@ def test_collapsing_closes_the_same_rows( (compose_node_tag(sample, panel_tag=PANEL_TAG), False), ] + def test_the_browser_remembers_the_shape_the_item_left( + self, + recorder: _MenuItemRecorder, + expanded: List[Tuple[str, bool]], + ) -> None: + """A rebuild brings the subtree back the way the item left it, so what it set is recorded.""" + panel = _panel() + group, sample, _ = _sample_tree() + rows = { + compose_node_tag(group, panel_tag=PANEL_TAG), + compose_node_tag(sample, panel_tag=PANEL_TAG), + } + + panel._add_context_menu_expansion_items(group) + recorder.item(EXPAND_LABEL)["callback"]() + + assert panel._expanded_rows == rows + + def test_the_browser_forgets_the_shape_the_item_folded( + self, + recorder: _MenuItemRecorder, + expanded: List[Tuple[str, bool]], + ) -> None: + panel = _panel() + group, sample, _ = _sample_tree() + panel._expanded_rows = { + compose_node_tag(group, panel_tag=PANEL_TAG), + compose_node_tag(sample, panel_tag=PANEL_TAG), + } + + panel._add_context_menu_expansion_items(group) + recorder.item(COLLAPSE_LABEL)["callback"]() + + assert panel._expanded_rows == set() + def test_leaf_rows_are_left_alone( self, recorder: _MenuItemRecorder, From d53af227d035d5d222e1d3510eeb03ccf705f374 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 01:37:16 +0200 Subject: [PATCH 138/152] Documented: the favorites filter's rules --- docs/development/browser.md | 44 +++++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 11 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index a2bdb0107..67fd025d0 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -34,6 +34,9 @@ complements `docs/development/architecture.md` (layering and ownership) and 7. **What a browser narrows to is its own.** Both tabs render one model, so which rows a browser shows is decided by the panel showing it: a search typed in one tab leaves the other reading as it was, and each browser opens in the mode a session left it in. +8. **The reader's shape survives a rebuild.** Which rows stand open is what the reader made of the + tree, so a browser records it and brings it back: a refresh, a change of filter and a repaint leave + the tree standing as it was, and a filter adds the way down to what it names. --- @@ -104,8 +107,9 @@ one row from the next. The browsers form one line of inheritance, each level owning what it shares: * `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the filter - they compose, the rebuild handshake, spec collection, themes and fonts per row, the detail tooltip, the - status-bar messages, and the context-menu items every browser can offer. + they compose, the shape it holds across rebuilds, the rebuild handshake, spec collection, themes and + fonts per row, the detail tooltip, the status-bar messages, and the context-menu items every browser + can offer. * `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the refresh control, the tree window, the folder-and-file handler pair, and enabling the card as the tree locks and unlocks. A subclass declares its widgets as a `FileBrowserTags` class attribute and states @@ -164,20 +168,36 @@ One rule serves both. `TreeVisibility` (`sampletones_core/structures/tree/visibi rows a criterion named and answers which rows stay: a named row, a row leading down to one, and a row one holds. `resolve_visibility` keeps the named rows and the rows above them, so what a pass holds in memory follows the size of what was found, and a row beneath a match is answered from its own path -upwards. The same two sets state which rows stand open, which is what makes a filter legible: the -starred rows come up with their headings open. +upwards. + +**What a criterion names and what it keeps are two sets.** A criterion points the reader at some rows +and brings others along with them, and only the first kind is worth unfolding to: a search names the +rows whose label matched, and the favorites mode names its **anchors** — a row the star sits on, and, +where no row stands for the starred path, the shallowest rows that path reaches. So a starred folder +comes up open showing what it holds, a folder inside it stays as it was, and a star nested deeper +opens the way down to itself, since a starred row anchors wherever it sits. In the sample branch the +headings carry no path, which makes the variants the rows the star arrives at, and the way down to +them opens. **A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so declining a row declines its subtree, and one decision covers it while the traversal walks on. +**The shape the reader built is theirs to keep.** A browser holding `_REMEMBERS_EXPANSION` records +the rows standing open, by the tag those rows are addressed under, and a later pass creates them open +again: the filter adds the way down to what it names, and everything else comes back as it was left. +A row is recorded as it is collected, so what the filter unfolded is part of that shape too; a click +is read a frame later, once the row has answered it, and the expansion items record what they set. A +pass over the whole tree states which rows exist, so the rows it left out leave the memory with them. + **What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing -each row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents`. -What it materialises is the starred rows and the rows above them, and what reaches DearPyGui is the -drawn rows alone: on a directory holding hundreds of thousands of reconstructions, a favorites-only -browser creates widgets for the starred ones and their headings. A keystroke resolves the query alone, -the drawn rows being the mode's to state. A favorite toggled while the mode is on redraws the browser, -so starring a row brings it in and unstarring one takes it out along with what it held. +each row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents` — +and the anchors are read out of that one answer. What it materialises is the starred rows and the rows +above them, and what reaches DearPyGui is the drawn rows alone: on a directory holding hundreds of +thousands of reconstructions, a favorites-only browser creates widgets for the starred ones and their +headings. A keystroke resolves the query alone, the drawn rows being the mode's to state. A favorite +toggled while the mode is on redraws the browser, so starring a row brings it in and unstarring one +takes it out along with what it held. A rebuild that drew no row fills the cleared tree with the message naming the criterion that came back empty (`global.dialog.message.tree_no_favorites`, `global.dialog.message.tree_no_results`), so the @@ -186,7 +206,9 @@ filter's answer reads where the rows would be. **The control** is a checkbox under the search box carrying the favorite glyph, which reads in the favorite colour while the mode is on and muted while it is off. `_OFFERS_FAVORITES_FILTER` states which cards hold it: the reconstruction browsers, whose rows stand for the paths a session stars. It -follows the tree's lock, a rebuild being what it asks for. +follows the tree's lock, a rebuild being what it asks for, and its label reads in the pair every +checkbox reads — the text colour while it can be clicked, the muted one while a rebuild holds it — so +the shade states whether the control is live. Each browser opens in the mode it was left in. The panel raises `on_favorites_filter_changed` with its own tag, and the tab coordinator writes it to `ApplicationState.favorites_filters` under that tag, From 192f924c820024af8398048073bd769ee4e04151 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 11:44:45 +0200 Subject: [PATCH 139/152] Added: Collapse all control in every file browser card --- .../categories/elements/main.py | 2 - src/sampletones_application/tags/general.py | 1 + src/sampletones_application/tags/main.py | 6 - .../ui/elements/tree/browser.py | 49 ++++++++- .../ui/elements/tree/tree.py | 13 +++ .../ui/panels/main/explorer.py | 36 ++---- .../ui/panels/shared/browser.py | 13 --- src/sampletones_config/lang/en.yaml | 4 +- .../ui/elements/tree/test_collapse_all.py | 74 +++++++++++++ .../ui/panels/main/test_explorer_controls.py | 104 ++++++++++++++++++ .../shared/test_container_context_menu.py | 2 +- 11 files changed, 245 insertions(+), 59 deletions(-) create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py create mode 100644 tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py diff --git a/src/sampletones_application/categories/elements/main.py b/src/sampletones_application/categories/elements/main.py index c6823a69b..496e1b82d 100644 --- a/src/sampletones_application/categories/elements/main.py +++ b/src/sampletones_application/categories/elements/main.py @@ -4,7 +4,6 @@ class ExplorerElements(AbstractElement): SECTION = "section" REFRESH_BUTTON = "refresh_button" - COLLAPSE_ALL_BUTTON = "collapse_all_button" CONTEXT_LOAD_RECONSTRUCTION = "context_load_reconstruction" CONTEXT_LOAD_LIBRARY = "context_load_library" CONTEXT_RECONSTRUCT_FILE = "context_reconstruct_file" @@ -12,7 +11,6 @@ class ExplorerElements(AbstractElement): CONTEXT_SET_LIBRARY_DIRECTORY = "context_set_library_directory" CONTEXT_SET_OUTPUT_DIRECTORY = "context_set_output_directory" STATUS_REFRESH = "status_refresh" - STATUS_COLLAPSE_ALL = "status_collapse_all" STATUS_NODE_AUDIO_NO_AUTOPLAY = "status_node_audio_no_autoplay" STATUS_NODE_AUDIO = "status_node_audio" STATUS_NODE_LIBRARY = "status_node_library" diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index baa6030ea..88c526d7c 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -676,6 +676,7 @@ SUF_BUTTON_SAVE = compose_tag(SUF_BUTTON, "save") SUF_BUTTON_CANCEL = compose_tag(SUF_BUTTON, "cancel") SUF_BUTTON_SEARCH = compose_tag(SUF_BUTTON, "search") +SUF_BUTTON_COLLAPSE_ALL = compose_tag(SUF_BUTTON, "collapse_all") SUF_BUTTON_SHOW_TRACEBACK = compose_tag(SUF_BUTTON, "show_traceback") SUF_BUTTON_DECREMENT = compose_tag(SUF_BUTTON, "decrement") SUF_BUTTON_INCREMENT = compose_tag(SUF_BUTTON, "increment") diff --git a/src/sampletones_application/tags/main.py b/src/sampletones_application/tags/main.py index 164febdf1..f50d7bea7 100644 --- a/src/sampletones_application/tags/main.py +++ b/src/sampletones_application/tags/main.py @@ -61,12 +61,6 @@ Widget.BUTTON, "refresh", ) -TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL = TagName( - Page.MAIN, - Panel.EXPLORER, - Widget.BUTTON, - "collapse_all", -) TAG_MAIN_CONFIG_PANEL = TagName( Page.MAIN, Panel.CONFIG, diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index ad105e1b5..9d2410f6b 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -7,7 +7,11 @@ from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, ) -from sampletones_application.tags.general import TAG_GLOBAL_THEME_SECONDARY_BUTTON +from sampletones_application.tags.compose import compose_tag +from sampletones_application.tags.general import ( + SUF_BUTTON_COLLAPSE_ALL, + TAG_GLOBAL_THEME_SECONDARY_BUTTON, +) from sampletones_application.ui.elements.button import GUIButton from sampletones_application.ui.elements.layout.collapse import CollapseAxis from sampletones_application.ui.elements.status import GUIStatusBar @@ -27,11 +31,11 @@ class GUIFileBrowserPanel(GUITreePanel, ABC): """Shared skeleton of a panel offering a tree of files as a collapsible, searchable card. - The card holds a refresh control above the search box and the tree it filters. This base builds - that arrangement, rebuilds the tree off the main thread on demand, and enables or disables the - whole card as the tree locks and unlocks. A subclass declares its widgets as a - :class:`FileBrowserTags`, states what its card and its refresh control read, answers what - refreshing the model means, and shapes each row. + The card holds the controls bringing the tree up to date and folding it away, above the search box + and the tree it filters. This base builds that arrangement, rebuilds the tree off the main thread + on demand, and enables or disables the whole card as the tree locks and unlocks. A subclass + declares its widgets as a :class:`FileBrowserTags`, states what its card and its refresh control + read, answers what refreshing the model means, and shapes each row. A browser whose rows carry favorites states ``_OFFERS_FAVORITES_FILTER``, which adds the control showing those favorites alone to the card. @@ -52,6 +56,9 @@ def __init__( colors: TreeColors, initial_collapsed: bool, ) -> None: + self._lbl_collapse_all = language_manager["global.browser.label.collapse_all"] + self._msg_collapse_all = language_manager["global.status.message.collapse_all"] + super().__init__( tree=tree, tag=self._tags.panel, @@ -119,8 +126,10 @@ def create_panel(self, parent: str) -> None: self.rebuild_tree() def _create_controls(self) -> None: + """Offers the two controls every browser of files carries: bring it up to date, fold it away.""" with dpg.group(tag=self._tags.group_controls): self._create_refresh_button() + self._create_collapse_all_button() self._bind_refresh_message() @@ -133,6 +142,21 @@ def _create_refresh_button(self) -> None: theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), ) + def _create_collapse_all_button(self) -> None: + """Offers the control folding the whole tree away, reading as the utility the refresh one does.""" + collapse_all_tag = compose_tag(self.tag, SUF_BUTTON_COLLAPSE_ALL) + GUIButton( + tag=collapse_all_tag, + label=self._lbl_collapse_all, + width=-1, + callback=self._on_collapse_all_clicked, + theme=ThemeRegistry.get(TAG_GLOBAL_THEME_SECONDARY_BUTTON), + ) + self._status_bar.bind_to_item( + collapse_all_tag, + self._msg_collapse_all, + ) + def _bind_refresh_message(self) -> None: self._status_bar.bind_to_item( self._tags.button_refresh, @@ -143,6 +167,19 @@ def _on_refresh_clicked(self) -> None: """Answers the refresh control, by default with a rebuild of the tree as the model stands.""" self.rebuild_tree() + def _on_collapse_all_clicked(self) -> None: + """Folds every row of the tree away, leaving the reader the level the tree opens at. + + The rows are reached through the model rather than the widget tree, so one pass covers a + branch however deep it runs, and the browser is told what each row now stands as. + """ + root = self.tree.get_root() + if root is None: + return + + for child in root.children: + self._set_subtree_expanded(child, expanded=False) + def _create_tree_window(self) -> None: self.create_search(self._body_container) if self._OFFERS_FAVORITES_FILTER: diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index bccbfdd87..be2115401 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -57,6 +57,7 @@ dpg_configure_item, dpg_get_value, dpg_is_item_hovered, + dpg_set_value, ) from sampletones_application.utils.gui.palette.dpg import dpg_set_palette_color from sampletones_application.utils.gui.tooltip import ( @@ -430,6 +431,18 @@ def _set_row_expanded(self, node_tag: str, expanded: bool) -> None: self._expanded_rows.discard(node_tag) + def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: + """Folds or unfolds the row together with every row below it holding something. + + Each row is reached by the tag it was built under and set directly, and the browser is told + what it now stands as, so a rebuild brings the whole subtree back the way this left it. + """ + for container in (node, *node.descendants): + if container.children: + node_tag = self._generate_node_tag(container) + dpg_set_value(node_tag, expanded) + self._set_row_expanded(node_tag, expanded) + def _finish_emit( self, root_tag: str, diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 7fd945ff1..4adbe398a 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -8,7 +8,6 @@ SchedulingBehavior, ) from sampletones_application.tags.main import ( - TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, TAG_MAIN_EXPLORER_BUTTON_REFRESH, TAG_MAIN_EXPLORER_GROUP_CONTROLS, TAG_MAIN_EXPLORER_GROUP_TREE, @@ -16,7 +15,6 @@ TAG_MAIN_EXPLORER_TREE, TAG_MAIN_EXPLORER_WINDOW_TREE, ) -from sampletones_application.ui.elements.button import GUIButton from sampletones_application.ui.elements.context_menu import context_menu from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.browser import GUIFileBrowserPanel @@ -134,40 +132,20 @@ def _setup_handlers(self) -> None: super()._setup_handlers() - def _create_controls(self) -> None: - """Offers the refresh control and, beside it, the one folding every folder away at once.""" - with dpg.group(tag=self._tags.group_controls): - self._create_refresh_button() - GUIButton( - tag=TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, - label=self._language_manager["main.explorer.label.collapse_all_button"], - width=-1, - callback=self.collapse_all, - ) - - self._bind_refresh_message() - self._status_bar.bind_to_item( - TAG_MAIN_EXPLORER_BUTTON_COLLAPSE_ALL, - self._language_manager["main.explorer.message.status_collapse_all"], - ) - def _create_tree_root(self) -> None: self._create_tree_root_heading(self.section_label) def _refresh_model(self) -> None: self._explorer_logic.refresh_tree() - def collapse_all( - self, - _sender: Sender, - _app_data: int, - _user_data: Any, - ) -> None: + def _on_collapse_all_clicked(self) -> None: + """Folds every folder away and drops the children it had loaded, so opening one reads it again. + + The rows fold while the model still states them, and the folders the model held go afterwards, + which is what makes a later open list the folder as it stands on disk. + """ + super()._on_collapse_all_clicked() self._explorer_logic.collapse_all() - children = dpg.get_item_children(self.tree_tag, 1) - assert children is not None, "Explorer tree has no children." - for node_tag in children: - dpg.set_value(node_tag, False) @concurrent(wait=False, method_bound=True) def _rebuild_node_subtree( diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index d3805a338..ff6acb73d 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -18,7 +18,6 @@ from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState -from sampletones_application.utils.gui.dpg import dpg_set_value from sampletones_core.structures.tree import ( FileSystemNode, NodeType, @@ -265,18 +264,6 @@ def _add_context_menu_expansion_items(self, node: TreeNode) -> None: callback=lambda: self._set_subtree_expanded(node, expanded=False), ) - def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: - """Folds or unfolds the row together with every row below it holding something. - - Each row is reached by the tag it was built under and set directly, and the browser is told - what it now stands as, so a rebuild brings the whole subtree back the way this left it. - """ - for container in (node, *node.descendants): - if container.children: - node_tag = self._generate_node_tag(container) - dpg_set_value(node_tag, expanded) - self._set_row_expanded(node_tag, expanded) - def _add_context_menu_copy_name_item(self, node: TreeNode) -> None: """Offers the label the tree reads the row by, which for a folded chain names every level.""" dpg.add_separator() diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index 6b079f011..e2643b087 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -134,6 +134,7 @@ global.browser.label.search: "Search" global.browser.label.filter: "Filter" global.browser.label.clear_search: "Clear" global.browser.label.favorites_only: "Favorites only" +global.browser.label.collapse_all: "Collapse all" # ============================================================================= # Global — Context menu @@ -241,6 +242,7 @@ global.status.message.node_library: "Double-click to open instructions library. global.status.message.tree_search: "Type query to filter nodes." global.status.message.clear_search: "Clear the search filter." global.status.message.favorites_only: "Show the favorites alone, or the whole tree." +global.status.message.collapse_all: "Fold every row of the tree away." global.status.message.input: "Ctrl + click to type value." global.status.message.combo: "Click to select a value from the list." global.status.message.node_directory: "Click to {expand_or_collapse}. Right-click to open context menu." @@ -278,7 +280,6 @@ global.graph.message.waveform_regenerating: "Regenerating reconstruction..." # ============================================================================= main.explorer.label.section: "Filesystem" main.explorer.label.refresh_button: "Refresh" -main.explorer.label.collapse_all_button: "Collapse all" main.explorer.label.context_load_reconstruction: "Load reconstruction" main.explorer.label.context_load_library: "Load instructions library" main.explorer.label.context_reconstruct_file: "Reconstruct file" @@ -288,7 +289,6 @@ main.explorer.label.context_set_output_directory: "Set as output directory" main.explorer.message.status_node_audio_no_autoplay: "Double-click to reconstruct audio. Right-click to open context menu." main.explorer.message.status_node_audio: "Click to play audio. Double-click to reconstruct audio. Right-click to open context menu." main.explorer.message.status_refresh: "Rescan the filesystem for audio files." -main.explorer.message.status_collapse_all: "Collapse every folder in the tree." main.explorer.message.converter_running_msg: "A conversion is already running. Please wait for it to complete or cancel the current operation before starting a new one." main.explorer.title.converter_running_dialog: "Conversion in progress" diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py b/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py new file mode 100644 index 000000000..6dc8aca81 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py @@ -0,0 +1,74 @@ +from typing import List, Tuple + +import pytest + +from sampletones_application.ui.elements.tree import tree as tree_module +from sampletones_core.structures.tree import NodeType +from tests.suite.browser import ( + WHOLE_TREE, + BrowserCorpus, + build_browser_panel, + render_view, + row_named, + set_row_expanded, +) + + +@pytest.fixture +def folded(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]: + """Records the tag and open state of every row the control reaches.""" + calls: List[Tuple[str, bool]] = [] + monkeypatch.setattr( + tree_module, + "dpg_set_value", + lambda tag, value: calls.append((tag, value)), + ) + return calls + + +class TestCollapseAllControl: + """The control folds the whole tree away, and the browser is left holding that shape.""" + + def test_every_row_holding_something_is_folded( + self, + corpus: BrowserCorpus, + folded: List[Tuple[str, bool]], + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + + panel._on_collapse_all_clicked() + + containers = {panel._generate_node_tag(node) for node in corpus.tree.get_root().descendants if node.children} + assert {tag for tag, _ in folded} == containers + assert all(not expanded for _, expanded in folded) + + def test_a_row_holding_nothing_is_left_alone( + self, + corpus: BrowserCorpus, + folded: List[Tuple[str, bool]], + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + + panel._on_collapse_all_clicked() + + leaves = { + panel._generate_node_tag(node) + for node in corpus.tree.get_root().descendants + if node.node_type == NodeType.FILE + } + assert not leaves & {tag for tag, _ in folded} + + def test_the_shape_the_control_left_is_what_the_next_pass_draws( + self, + corpus: BrowserCorpus, + folded: List[Tuple[str, bool]], + ) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + set_row_expanded(panel, row_named(corpus, "takes"), expanded=True) + render_view(panel) + + panel._on_collapse_all_clicked() + + assert panel._expanded_rows == set() + assert render_view(panel) == WHOLE_TREE diff --git a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py new file mode 100644 index 000000000..88c435e57 --- /dev/null +++ b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py @@ -0,0 +1,104 @@ +from pathlib import Path +from typing import List, Tuple + +import pytest + +from sampletones_application.ui.elements.tree import tree as tree_module +from sampletones_application.ui.panels.main.explorer import GUIExplorerPanel +from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode + +PANEL_TAG = "main_explorer" +ROOT = Path("/") +MUSIC = ROOT / "music" + + +class FakeExplorerLogic: + """Answers what the panel asks of its model, recording the folders it is told to drop.""" + + def __init__(self, tree: Tree) -> None: + self.tree = tree + self.cleared: List[Tuple[str, ...]] = [] + + def collapse_all(self) -> None: + root = self.tree.get_root() + assert root is not None + self.cleared.append(tuple(str(node.name) for node in root.descendants)) + for filesystem_node in list(root.children): + for child in list(filesystem_node.children): + child.parent = None + + +def explorer_tree() -> Tree: + """A filesystem root holding a folder that holds a file, as the explorer lists them.""" + root = TreeNode("Root", node_type=NodeType.ROOT) + filesystem = FileSystemNode( + str(ROOT), + node_type=NodeType.DIRECTORY, + filepath=ROOT, + parent=root, + ) + music = FileSystemNode( + MUSIC.name, + node_type=NodeType.DIRECTORY, + filepath=MUSIC, + parent=filesystem, + ) + FileSystemNode( + "song.wav", + node_type=NodeType.FILE, + filepath=MUSIC / "song.wav", + parent=music, + ) + return Tree(root=root) + + +@pytest.fixture +def folded(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]: + calls: List[Tuple[str, bool]] = [] + monkeypatch.setattr( + tree_module, + "dpg_set_value", + lambda tag, value: calls.append((tag, value)), + ) + return calls + + +def build_panel(tree: Tree) -> GUIExplorerPanel: + """Builds an explorer panel holding a tree, which is all folding its rows away reads.""" + panel = GUIExplorerPanel.__new__(GUIExplorerPanel) + panel.tag = PANEL_TAG + panel.tree = tree + panel._expanded_rows = set() + panel._explorer_logic = FakeExplorerLogic(tree) # type: ignore[assignment] + return panel + + +class TestCollapseAll: + def test_the_rows_fold_while_the_model_still_states_them( + self, + folded: List[Tuple[str, bool]], + ) -> None: + """A folder is reached through the model, so the fold runs before its children are dropped.""" + tree = explorer_tree() + panel = build_panel(tree) + music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0] + music_tag = panel._generate_node_tag(music) + + panel._on_collapse_all_clicked() + + assert music_tag in {tag for tag, _ in folded} + assert all(not expanded for _, expanded in folded) + + def test_the_folders_the_model_held_are_dropped_afterwards( + self, + folded: List[Tuple[str, bool]], + ) -> None: + tree = explorer_tree() + panel = build_panel(tree) + + panel._on_collapse_all_clicked() + + assert panel._explorer_logic.cleared == [(str(ROOT), MUSIC.name, "song.wav")] + root = tree.get_root() + assert root is not None + assert [str(node.name) for node in root.descendants] == [str(ROOT)] diff --git a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py index ddf2d1f76..7807c7b60 100644 --- a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py @@ -118,7 +118,7 @@ def expanded(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]: """Records the tag and open state of every row the expansion items reach.""" calls: List[Tuple[str, bool]] = [] monkeypatch.setattr( - shared_browser_module, + tree_module, "dpg_set_value", lambda tag, value: calls.append((tag, value)), ) From 14ad477f38b802e09f4d51fe1c95d5e70b3cec4b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 12:06:40 +0200 Subject: [PATCH 140/152] Added: Auto-expand favorites preference per kind of favorite --- src/sampletones_application/application.py | 28 ++ .../categories/elements/global_.py | 3 + .../categories/elements/settings.py | 2 + .../config/managers/application.py | 14 + .../config/managers/session.py | 14 + .../config/session/application/browser.py | 12 + .../config/session/application/config.py | 5 + .../coordinators/tabs/reconstruction.py | 4 + .../coordinators/tabs/sequencer.py | 4 + .../logic/shared/tree.py | 8 + src/sampletones_application/shell.py | 6 + src/sampletones_application/tags/general.py | 12 + .../ui/elements/tree/protocol.py | 6 + .../ui/elements/tree/tree.py | 58 ++- src/sampletones_application/ui/menu.py | 36 ++ .../utils/gui/shortcuts/ids.py | 8 + .../view_model/shared/menu.py | 2 + .../keybindings/default.yaml | 2 + src/sampletones_config/keybindings/macos.yaml | 2 + src/sampletones_config/lang/en.yaml | 5 + .../structures/tree/visibility.py | 8 + tests/suite/browser.py | 34 +- .../ui/elements/tree/test_expansion_memory.py | 31 +- .../ui/elements/tree/test_favorites_filter.py | 342 ++++++++++++++++-- .../sampletones_application/ui/test_menu.py | 73 ++++ .../view_model/shared/test_menu.py | 4 + .../structures/tree/test_visibility.py | 22 ++ 27 files changed, 686 insertions(+), 59 deletions(-) create mode 100644 src/sampletones_application/config/session/application/browser.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 6a266a36a..227a05e3e 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -619,6 +619,8 @@ def _create_shortcut_bindings(self) -> ShortcutBindings: display_settings=self._display_coordinator.open, keyboard_settings=self._keybindings_coordinator.open, toggle_advanced_settings=self._toggle_advanced_settings, + toggle_auto_expand_favorite_reconstructions=self._toggle_auto_expand_favorite_reconstructions, + toggle_auto_expand_favorite_directories=self._toggle_auto_expand_favorite_directories, toggle_fullscreen=self._shell.toggle_fullscreen, about=self._open_about_dialog, next_tab=self._next_tab, @@ -715,6 +717,8 @@ def _build_initial_menu_state(self) -> MenuBarViewModel: channels=self._sequencer_tab.channels, fullscreen=self.session_manager.fullscreen, advanced_settings=self.session_manager.advanced_settings, + auto_expand_favorite_reconstructions=self.session_manager.auto_expand_favorite_reconstructions, + auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories, ) def _is_sequencer_tab_current(self) -> bool: @@ -754,6 +758,8 @@ def _build_menu_bar_viewmodel(self) -> MenuBarViewModel: channels=self._sequencer_tab.channels, fullscreen=self.session_manager.fullscreen, advanced_settings=self.session_manager.advanced_settings, + auto_expand_favorite_reconstructions=self.session_manager.auto_expand_favorite_reconstructions, + auto_expand_favorite_directories=self.session_manager.auto_expand_favorite_directories, ) def _on_history_changed(self) -> None: @@ -806,6 +812,28 @@ def _toggle_advanced_settings( self._main_tab.toggle_advanced_settings() self._update_menu() + def _toggle_auto_expand_favorite_reconstructions(self) -> None: + self.session_manager.set_auto_expand_favorite_reconstructions( + not self.session_manager.auto_expand_favorite_reconstructions + ) + self._redraw_browsers() + + def _toggle_auto_expand_favorite_directories(self) -> None: + self.session_manager.set_auto_expand_favorite_directories( + not self.session_manager.auto_expand_favorite_directories + ) + self._redraw_browsers() + + def _redraw_browsers(self) -> None: + """Marks the choice in the menu and draws both browsers again from the model each holds. + + What the favorites mode opens is decided as a rebuild collects the rows, so a change of the + preference is answered by collecting them again rather than by reaching into the tree. + """ + self._update_menu() + self._reconstructions_tab.redraw_browser() + self._sequencer_tab.redraw_browser() + def _reconstruct_file_dialog(self) -> None: if self._is_operation_active(): logger.warning("A conversion or library generation is already in progress; cannot start a new one") diff --git a/src/sampletones_application/categories/elements/global_.py b/src/sampletones_application/categories/elements/global_.py index c5c3f308c..c032172ea 100644 --- a/src/sampletones_application/categories/elements/global_.py +++ b/src/sampletones_application/categories/elements/global_.py @@ -113,6 +113,9 @@ class MenuElements(AbstractElement): GROUP_VIEW = "group_view" ITEM_VIEW_SHOW_ADVANCED_SETTINGS = "item_view_show_advanced_settings" ITEM_VIEW_FULLSCREEN = "item_view_fullscreen" + GROUP_VIEW_AUTO_EXPAND_FAVORITES = "group_view_auto_expand_favorites" + ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = "item_view_auto_expand_favorite_reconstructions" + ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES = "item_view_auto_expand_favorite_directories" ITEM_VIEW_DISPLAY_SETTINGS = "item_view_display_settings" ITEM_VIEW_KEYBOARD_SETTINGS = "item_view_keyboard_settings" GROUP_HELP = "group_help" diff --git a/src/sampletones_application/categories/elements/settings.py b/src/sampletones_application/categories/elements/settings.py index 6fa1ac892..ec4b1af23 100644 --- a/src/sampletones_application/categories/elements/settings.py +++ b/src/sampletones_application/categories/elements/settings.py @@ -78,6 +78,8 @@ class KeybindingActionElements(AbstractElement): DISPLAY_SETTINGS = "display_settings" KEYBOARD_SETTINGS = "keyboard_settings" TOGGLE_ADVANCED_SETTINGS = "toggle_advanced_settings" + TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = "toggle_auto_expand_favorite_reconstructions" + TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES = "toggle_auto_expand_favorite_directories" TOGGLE_FULLSCREEN = "toggle_fullscreen" ABOUT_DIALOG = "about_dialog" NEXT_TAB = "next_tab" diff --git a/src/sampletones_application/config/managers/application.py b/src/sampletones_application/config/managers/application.py index 206b541de..20f912569 100644 --- a/src/sampletones_application/config/managers/application.py +++ b/src/sampletones_application/config/managers/application.py @@ -132,6 +132,20 @@ def toggle_autoplay(self) -> bool: self.config.playback.autoplay = not self.config.playback.autoplay return self.config.playback.autoplay + @property + def auto_expand_favorite_reconstructions(self) -> bool: + return self.config.browser.auto_expand_favorite_reconstructions + + def set_auto_expand_favorite_reconstructions(self, value: bool) -> None: + self.config.browser.auto_expand_favorite_reconstructions = value + + @property + def auto_expand_favorite_directories(self) -> bool: + return self.config.browser.auto_expand_favorite_directories + + def set_auto_expand_favorite_directories(self, value: bool) -> None: + self.config.browser.auto_expand_favorite_directories = value + @property def follow_mode(self) -> FollowMode: return self.config.playback.follow_mode diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index a6cb8852e..5693f3097 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -62,6 +62,12 @@ def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None: def toggle_autoplay(self) -> bool: return self._config_manager.toggle_autoplay() + def set_auto_expand_favorite_reconstructions(self, value: bool) -> None: + self._config_manager.set_auto_expand_favorite_reconstructions(value) + + def set_auto_expand_favorite_directories(self, value: bool) -> None: + self._config_manager.set_auto_expand_favorite_directories(value) + def set_follow_mode(self, value: FollowMode) -> None: self._config_manager.set_follow_mode(value) @@ -232,6 +238,14 @@ def advanced_settings(self) -> bool: def autoplay(self) -> bool: return self._config_manager.autoplay + @property + def auto_expand_favorite_reconstructions(self) -> bool: + return self._config_manager.auto_expand_favorite_reconstructions + + @property + def auto_expand_favorite_directories(self) -> bool: + return self._config_manager.auto_expand_favorite_directories + @property def follow_mode(self) -> FollowMode: return self._config_manager.follow_mode diff --git a/src/sampletones_application/config/session/application/browser.py b/src/sampletones_application/config/session/application/browser.py new file mode 100644 index 000000000..73c18da2c --- /dev/null +++ b/src/sampletones_application/config/session/application/browser.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel, Field + + +class BrowserConfig(BaseModel): + auto_expand_favorite_reconstructions: bool = Field( + default=False, + description="If showing the favorites alone opens the rows above a favorite reconstruction.", + ) + auto_expand_favorite_directories: bool = Field( + default=False, + description="If showing the favorites alone opens the rows above a favorite directory.", + ) diff --git a/src/sampletones_application/config/session/application/config.py b/src/sampletones_application/config/session/application/config.py index 35ca10caf..0c3fc0c9f 100644 --- a/src/sampletones_application/config/session/application/config.py +++ b/src/sampletones_application/config/session/application/config.py @@ -1,6 +1,7 @@ from pydantic import BaseModel, ConfigDict, Field from sampletones_application.config.session.application.audio import AudioConfig +from sampletones_application.config.session.application.browser import BrowserConfig from sampletones_application.config.session.application.display import DisplayConfig from sampletones_application.config.session.application.favorites import Favorites from sampletones_application.config.session.application.history import HistoryConfig @@ -20,6 +21,10 @@ class ApplicationConfig(BaseModel): default_factory=AudioConfig, description="The audio configuration settings.", ) + browser: BrowserConfig = Field( + default_factory=BrowserConfig, + description="How the browsers of reconstructions read what they narrow to.", + ) display: DisplayConfig = Field( default_factory=DisplayConfig, description="The palette and frame pacing preferences.", diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 4ad395a76..3907c008d 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -557,6 +557,10 @@ def unlock(self) -> None: def refresh_browser(self) -> None: self._browser_panel.refresh() + def redraw_browser(self) -> None: + """Draws the browser again from the model it holds, which a change of filter asks for.""" + self._browser_panel.redraw_tree() + def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: self._browser_panel.update_favorite_indicators(nodes) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index af1ba9933..26b1255a9 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -956,6 +956,10 @@ def repaint(self) -> None: def refresh_browser(self) -> None: self._sequencer_browser_panel.refresh() + def redraw_browser(self) -> None: + """Draws the browser again from the model it holds, which a change of filter asks for.""" + self._sequencer_browser_panel.redraw_tree() + def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: self._sequencer_browser_panel.update_favorite_indicators(nodes) diff --git a/src/sampletones_application/logic/shared/tree.py b/src/sampletones_application/logic/shared/tree.py index 204c7fe91..c42332c9a 100644 --- a/src/sampletones_application/logic/shared/tree.py +++ b/src/sampletones_application/logic/shared/tree.py @@ -176,3 +176,11 @@ def _execute_search_update(self) -> None: @property def autoplay_enabled(self) -> bool: return self._session_manager.autoplay + + @property + def auto_expand_favorite_reconstructions(self) -> bool: + return self._session_manager.auto_expand_favorite_reconstructions + + @property + def auto_expand_favorite_directories(self) -> bool: + return self._session_manager.auto_expand_favorite_directories diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index 46bc618c0..e3e21f06a 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -103,6 +103,8 @@ class ShortcutBindings: display_settings: Callback keyboard_settings: Callback toggle_advanced_settings: Callback + toggle_auto_expand_favorite_reconstructions: Callback + toggle_auto_expand_favorite_directories: Callback toggle_fullscreen: Callback about: Callback next_tab: Callback @@ -246,6 +248,10 @@ def _shortcut_callbacks(bindings: ShortcutBindings) -> Dict[ShortcutId, Callback ShortcutId.DISPLAY_SETTINGS: bindings.display_settings, ShortcutId.KEYBOARD_SETTINGS: bindings.keyboard_settings, ShortcutId.TOGGLE_ADVANCED_SETTINGS: bindings.toggle_advanced_settings, + ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS: ( + bindings.toggle_auto_expand_favorite_reconstructions + ), + ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES: bindings.toggle_auto_expand_favorite_directories, ShortcutId.TOGGLE_FULLSCREEN: bindings.toggle_fullscreen, ShortcutId.ABOUT_DIALOG: bindings.about, ShortcutId.NEXT_TAB: bindings.next_tab, diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 88c526d7c..99533da87 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -560,6 +560,18 @@ Widget.MENU, "item_view_fullscreen", ) +TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "item_view_auto_expand_favorite_reconstructions", +) +TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.MENU, + "item_view_auto_expand_favorite_directories", +) TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/elements/tree/protocol.py b/src/sampletones_application/ui/elements/tree/protocol.py index eea8262d5..83aa4dd1d 100644 --- a/src/sampletones_application/ui/elements/tree/protocol.py +++ b/src/sampletones_application/ui/elements/tree/protocol.py @@ -15,6 +15,12 @@ class TreeLogicProtocol(Protocol): @property def autoplay_enabled(self) -> bool: ... + @property + def auto_expand_favorite_reconstructions(self) -> bool: ... + + @property + def auto_expand_favorite_directories(self) -> bool: ... + @property def locked(self) -> bool: ... diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index be2115401..b0f86d44e 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -654,16 +654,14 @@ def _has_relevant_content(self, node: TreeNode) -> bool: ... def _should_expand_node(self, node: TreeNode) -> bool: """Whether the row is emitted standing open, which a row leading to a named row is. - A search result and a favorite are both what the reader is looking for, so the way down to - either one opens and the filter's answer reads at a glance. What each criterion names is the - row the reader is pointed at rather than everything that row brings along, so a folder opens - to show what it holds while the rows inside it stand as they are. + A search names the rows whose label matched and shows what each of them gathers, so a folder + it named opens. The favorites mode points the reader at a star and opens the way down to it + alone, which leaves the starred row standing as the reader left it. """ - return any( - visibility.should_expand(node) - for visibility in (self._search_visibility, self._favorites_anchors) - if visibility is not None - ) + if self._search_visibility is not None and self._search_visibility.should_expand(node): + return True + + return self._favorites_anchors is not None and self._favorites_anchors.leads_to(node) def _create_status_bar_message_function( self, @@ -965,7 +963,7 @@ def _resolve_search_visibility(self) -> Optional[TreeVisibility]: def _resolve_favorites( self, ) -> Tuple[Optional[TreeVisibility], Optional[TreeVisibility]]: - """The rows the favorites mode keeps, and the rows it points the reader at. + """The rows the favorites mode keeps, and the rows it opens the way down to. The two answer different questions — which rows the browser draws, and which of them stand open — so each is resolved from a set of its own, the second being a part of the first. One @@ -978,9 +976,36 @@ def _resolve_favorites( reached = self.tree.find_nodes(TreeNode, self._is_node_starred) return ( resolve_visibility(reached), - resolve_visibility([node for node in reached if self._is_node_anchored(node)]), + resolve_visibility(self._auto_expanded_anchors(reached)), ) + def _auto_expanded_anchors( + self, + reached: Sequence[TreeNode], + ) -> List[TreeNode]: + """The anchors whose star the reader asked the browser to open the way down to. + + Which stars are followed is a preference stated per kind and read once per pass: a starred + reconstruction answers for itself, and a starred folder answers for itself together with the + rows it brings in where no row stands for the folder. + """ + reconstructions = self._logic.auto_expand_favorite_reconstructions + directories = self._logic.auto_expand_favorite_directories + return [ + node + for node in reached + if self._is_node_anchored(node) + and (reconstructions if self._is_starred_reconstruction(node) else directories) + ] + + def _is_starred_reconstruction(self, node: TreeNode) -> bool: + """Whether the star the mode reaches this row through sits on a reconstruction. + + A row the reader starred answers by its own kind. A row a starred folder brings in answers by + that folder, a folder being the only thing that holds another row. + """ + return node.node_type == NodeType.FILE and self._logic.is_node_favorite(node) + def _is_node_starred(self, node: TreeNode) -> bool: """Whether the favorites mode names the row: it carries a star, or a starred folder holds it. @@ -993,12 +1018,13 @@ def _is_node_starred(self, node: TreeNode) -> bool: return isinstance(node, FileSystemNode) and self._logic.has_favorite_ancestor(node) def _is_node_anchored(self, node: TreeNode) -> bool: - """Whether the mode points the reader at the row, which is what opens the way down to it. + """Whether the mode points the reader at the row, which is what the way down opens to. - A star sits on a row the reader marked, so the way to that row opens wherever it sits — - inside another starred folder among the rest. A row a starred folder merely holds is where - the star first reaches only while no row above it is reached, which is how the sample branch - answers: its headings carry no path, so the variants are where the star arrives. + A star sits on a row the reader marked, so that row is pointed at wherever it sits — inside + another starred folder among the rest, which is what lets an explicit favorite open the folder + above it. A row a starred folder merely holds is where the star first reaches only while no + row above it is reached, which is how the sample branch answers: its headings carry no path, + so the variants are where the star arrives. Asked of the rows the star reaches, so a row it declines stands under a row it named, and the reader is pointed at the folder rather than at everything inside it. diff --git a/src/sampletones_application/ui/menu.py b/src/sampletones_application/ui/menu.py index 0894f5337..0ef75f5d0 100644 --- a/src/sampletones_application/ui/menu.py +++ b/src/sampletones_application/ui/menu.py @@ -52,6 +52,8 @@ TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_RECONSTRUCT_FILE, TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_SAVE, TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_SAVE_AS, + TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES, + TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS, TAG_GLOBAL_MENU_ITEM_VIEW_FULLSCREEN, TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS, TAG_GLOBAL_PANEL_PLAYER, @@ -537,6 +539,8 @@ def _create_view_menu(self) -> None: check=True, ) dpg.add_separator() + self._create_auto_expand_favorites_menu() + dpg.add_separator() self._shortcut_manager.add_menu_item( ShortcutId.DISPLAY_SETTINGS, label=self._label(MenuElements.ITEM_VIEW_DISPLAY_SETTINGS), @@ -546,6 +550,26 @@ def _create_view_menu(self) -> None: label=self._label(MenuElements.ITEM_VIEW_KEYBOARD_SETTINGS), ) + def _create_auto_expand_favorites_menu(self) -> None: + """Offers, per kind of favorite, whether showing the favorites alone opens the way down to one. + + A browser showing its favorites alone decides which rows it draws; whether it also unfolds the + rows above a star is the reader's, and a reconstruction and a directory are answered apart. + """ + with dpg.menu(label=self._label(MenuElements.GROUP_VIEW_AUTO_EXPAND_FAVORITES)): + self._shortcut_manager.add_menu_item( + ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS, + tag=TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS, + label=self._label(MenuElements.ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS), + check=True, + ) + self._shortcut_manager.add_menu_item( + ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES, + tag=TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES, + label=self._label(MenuElements.ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES), + check=True, + ) + def _create_help_menu(self) -> None: with dpg.menu(label=self._label(MenuElements.GROUP_HELP)): self._shortcut_manager.add_menu_item( @@ -661,6 +685,18 @@ def update(self, state: MenuBarViewModel) -> None: TAG_GLOBAL_MENU_ITEM_VIEW_SHOW_ADVANCED_SETTINGS, state.advanced_settings, ) + self._update_auto_expand_favorites(state) + + def _update_auto_expand_favorites(self, state: MenuBarViewModel) -> None: + """Shows, per kind of favorite, whether the browsers open the way down to one.""" + dpg_set_value( + TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS, + state.auto_expand_favorite_reconstructions, + ) + dpg_set_value( + TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES, + state.auto_expand_favorite_directories, + ) def _update_follow_mode(self, state: MenuBarViewModel) -> None: """Marks the reach the view follows the playhead at, the one mode carrying the check.""" diff --git a/src/sampletones_application/utils/gui/shortcuts/ids.py b/src/sampletones_application/utils/gui/shortcuts/ids.py index a4defd277..dde05a076 100644 --- a/src/sampletones_application/utils/gui/shortcuts/ids.py +++ b/src/sampletones_application/utils/gui/shortcuts/ids.py @@ -85,6 +85,14 @@ def __new__(cls, value: str, category: ShortcutCategory) -> Self: DISPLAY_SETTINGS = ("DisplaySettings", ShortcutCategory.APPLICATION) KEYBOARD_SETTINGS = ("KeyboardSettings", ShortcutCategory.APPLICATION) TOGGLE_ADVANCED_SETTINGS = ("ToggleAdvancedSettings", ShortcutCategory.APPLICATION) + TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS = ( + "ToggleAutoExpandFavoriteReconstructions", + ShortcutCategory.APPLICATION, + ) + TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES = ( + "ToggleAutoExpandFavoriteDirectories", + ShortcutCategory.APPLICATION, + ) TOGGLE_FULLSCREEN = ("ToggleFullscreen", ShortcutCategory.APPLICATION) ABOUT_DIALOG = ("AboutDialog", ShortcutCategory.APPLICATION) NEXT_TAB = ("NextTab", ShortcutCategory.APPLICATION) diff --git a/src/sampletones_application/view_model/shared/menu.py b/src/sampletones_application/view_model/shared/menu.py index b14d4d154..d93d2d93c 100644 --- a/src/sampletones_application/view_model/shared/menu.py +++ b/src/sampletones_application/view_model/shared/menu.py @@ -27,6 +27,8 @@ class MenuBarViewModel(BaseModel, frozen=True): loop_song: bool fullscreen: bool advanced_settings: bool + auto_expand_favorite_reconstructions: bool + auto_expand_favorite_directories: bool @property def undo_enabled(self) -> bool: diff --git a/src/sampletones_config/keybindings/default.yaml b/src/sampletones_config/keybindings/default.yaml index 86914813e..e373d87a1 100644 --- a/src/sampletones_config/keybindings/default.yaml +++ b/src/sampletones_config/keybindings/default.yaml @@ -55,6 +55,8 @@ bindings: KeyboardSettings: {combination: "Ctrl+K"} ToggleAdvancedSettings: {combination: "Ctrl+Alt+T"} ToggleFullscreen: {combination: "F11"} + ToggleAutoExpandFavoriteReconstructions: {combination: ~} + ToggleAutoExpandFavoriteDirectories: {combination: ~} AboutDialog: {combination: ~} NextTab: {combination: "Ctrl+PgDn", field_transparent: true} PreviousTab: {combination: "Ctrl+PgUp", field_transparent: true} diff --git a/src/sampletones_config/keybindings/macos.yaml b/src/sampletones_config/keybindings/macos.yaml index a4ede06aa..207731372 100644 --- a/src/sampletones_config/keybindings/macos.yaml +++ b/src/sampletones_config/keybindings/macos.yaml @@ -55,6 +55,8 @@ bindings: KeyboardSettings: {combination: "Cmd+K"} ToggleAdvancedSettings: {combination: "Cmd+Alt+T"} ToggleFullscreen: {combination: "Cmd+Ctrl+F"} + ToggleAutoExpandFavoriteReconstructions: {combination: ~} + ToggleAutoExpandFavoriteDirectories: {combination: ~} AboutDialog: {combination: ~} NextTab: {combination: "Cmd+Alt+Right", aliases: ["Cmd+PgDn"], field_transparent: true} PreviousTab: {combination: "Cmd+Alt+Left", aliases: ["Cmd+PgUp"], field_transparent: true} diff --git a/src/sampletones_config/lang/en.yaml b/src/sampletones_config/lang/en.yaml index e2643b087..8c1dc25e1 100644 --- a/src/sampletones_config/lang/en.yaml +++ b/src/sampletones_config/lang/en.yaml @@ -222,6 +222,9 @@ global.menu.label.item_playback_audio_settings: "Audio settings..." global.menu.label.group_view: "View" global.menu.label.item_view_show_advanced_settings: "Show advanced settings" global.menu.label.item_view_fullscreen: "Fullscreen" +global.menu.label.group_view_auto_expand_favorites: "Auto-expand favorites" +global.menu.label.item_view_auto_expand_favorite_reconstructions: "Reconstructions" +global.menu.label.item_view_auto_expand_favorite_directories: "Directories" global.menu.label.item_view_display_settings: "Display settings..." global.menu.label.item_view_keyboard_settings: "Keyboard shortcuts..." global.menu.label.group_help: "Help" @@ -788,6 +791,8 @@ settings.keybindings.label.audio_settings: "Audio settings" settings.keybindings.label.display_settings: "Display settings" settings.keybindings.label.keyboard_settings: "Keyboard shortcuts" settings.keybindings.label.toggle_advanced_settings: "Advanced settings" +settings.keybindings.label.toggle_auto_expand_favorite_reconstructions: "Auto-expand favorite reconstructions" +settings.keybindings.label.toggle_auto_expand_favorite_directories: "Auto-expand favorite directories" settings.keybindings.label.toggle_fullscreen: "Fullscreen" settings.keybindings.label.about_dialog: "About" settings.keybindings.label.next_tab: "Next tab" diff --git a/src/sampletones_core/structures/tree/visibility.py b/src/sampletones_core/structures/tree/visibility.py index 7c9fbbc1a..815de44cf 100644 --- a/src/sampletones_core/structures/tree/visibility.py +++ b/src/sampletones_core/structures/tree/visibility.py @@ -28,6 +28,14 @@ def should_expand(self, node: TreeNode) -> bool: """Whether the row stands open, which a named row does and so does every row above one.""" return node in self.matches or node in self.ancestors + def leads_to(self, node: TreeNode) -> bool: + """Whether the row stands on the way down to a named row, being none of the named rows itself. + + Answers the reader who is pointed at what was named rather than at what it holds, so opening + by this leaves a named row standing as it was while the rows above it show where it sits. + """ + return node in self.ancestors + def resolve_visibility(matches: Iterable[TreeNode]) -> TreeVisibility: """The visibility a set of named rows resolves to, read once per pass over the tree. diff --git a/tests/suite/browser.py b/tests/suite/browser.py index cbb9cff66..1d57bbef0 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -201,8 +201,16 @@ def get_reconstructions_directory(self) -> Path: class FakeTreeLogic: """Answers the favorite questions a browser asks of its logic while it collects its rows.""" - def __init__(self, favorites: Set[Path]) -> None: + def __init__( + self, + favorites: Set[Path], + *, + auto_expand_reconstructions: bool, + auto_expand_directories: bool, + ) -> None: self._favorites = favorites + self._auto_expand_reconstructions = auto_expand_reconstructions + self._auto_expand_directories = auto_expand_directories def is_node_favorite(self, node: TreeNode) -> bool: return isinstance(node, FileSystemNode) and node.filepath in self._favorites @@ -210,6 +218,14 @@ def is_node_favorite(self, node: TreeNode) -> bool: def has_favorite_ancestor(self, node: FileSystemNode) -> bool: return any(directory in self._favorites for directory in node.filepath.parents) + @property + def auto_expand_favorite_reconstructions(self) -> bool: + return self._auto_expand_reconstructions + + @property + def auto_expand_favorite_directories(self) -> bool: + return self._auto_expand_directories + @dataclass(frozen=True) class BrowserCorpus: @@ -274,18 +290,26 @@ def build_browser_panel( favorites_only: bool, query: str = "", panel_tag: str = PANEL_TAG, + auto_expand_reconstructions: bool = False, + auto_expand_directories: bool = False, ) -> GUISequencerBrowserPanel: """Builds a browser panel showing the corpus under a filter, with the favorites its logic answers. Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box, - and the control stands where a browser that has yet to build one leaves it. + and the control stands where a browser that has yet to build one leaves it. The pair of + auto-expand answers states which stars the mode opens the way down to, as the reader's preference + does. """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = panel_tag panel._expanded_rows = set() panel.tree_tag = TREE_TAG panel.tree = corpus.tree - panel._logic = FakeTreeLogic(favorites) # type: ignore[assignment] + panel._logic = FakeTreeLogic( # type: ignore[assignment] + favorites, + auto_expand_reconstructions=auto_expand_reconstructions, + auto_expand_directories=auto_expand_directories, + ) panel._language_manager = FakeLanguageManager() panel._colors = TREE_COLORS _state_detail_labels(panel) @@ -408,6 +432,8 @@ def view( *, favorites_only: bool, query: str = "", + auto_expand_reconstructions: bool = False, + auto_expand_directories: bool = False, ) -> str: """The view a browser showing the corpus under this filter leaves on screen.""" return render_view( @@ -416,5 +442,7 @@ def view( favorites, favorites_only=favorites_only, query=query, + auto_expand_reconstructions=auto_expand_reconstructions, + auto_expand_directories=auto_expand_directories, ) ) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index d18fa5ab7..c0fe1658b 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -18,28 +18,28 @@ ) STARRED_CONFIGURATION: Final[str] = as_view(""" - v By configuration - v 44.1 kHz·30 Hz - v FFT·γ0 - v PT + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PT > takes - alt - beat - v By sample - v beat + > By sample + > beat - 44.1 kHz·30 Hz·FFT·γ0·PT - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT """) SUBFOLDER_THE_READER_OPENED: Final[str] = as_view(""" - v By configuration - v 44.1 kHz·30 Hz - v FFT·γ0 - v PT + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PT v takes - alt - beat - v By sample - v beat + > By sample + > beat - 44.1 kHz·30 Hz·FFT·γ0·PT - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT """) @@ -157,7 +157,12 @@ def test_a_row_the_reader_closed_is_drawn_closed(self, corpus: BrowserCorpus) -> def test_the_rows_the_mode_opened_stand_open_once_it_goes_off(self, corpus: BrowserCorpus) -> None: """What the browser unfolded to show a favorite is part of the shape the reader is left with.""" - panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=True) + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) render_view(panel) set_filter(panel, favorites_only=False) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index c995c1ab0..66c0a654a 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -6,6 +6,8 @@ from sampletones_application.utils.palette.colors.base import BaseColor from sampletones_core.structures.tree import TreeNode from tests.suite.browser import ( + CLOSED_MARKER, + OPEN_MARKER, PANEL_TAG, TREE_COLORS, WHOLE_TREE, @@ -19,7 +21,23 @@ CHECKBOX_TAG: Final[str] = "sequencer.browser.checkbox.favorites" GLYPH_TAG: Final[str] = "sequencer.browser.text.favorites" + +def rows_of(rendered: str) -> List[str]: + """The rows a view holds, read apart from the state each of them stands in.""" + return [line.replace(OPEN_MARKER, CLOSED_MARKER, 1) for line in rendered.splitlines()] + + STARRED_RECONSTRUCTION: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PTN·#aaaaaaa + - beat + > By sample + > beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + """) +STARRED_RECONSTRUCTION_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v FFT·γ0 @@ -30,6 +48,14 @@ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa """) STARRED_LONE_AUDIO: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > CQT·γ0·PTN + - solo + > By sample + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + """) +STARRED_LONE_AUDIO_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v CQT·γ0·PTN @@ -38,6 +64,18 @@ - solo·44.1 kHz·30 Hz·CQT·γ0·PTN """) STARRED_IN_SUBFOLDER: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PTN·#aaaaaaa + > drums + - kick + > By sample + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + """) +STARRED_IN_SUBFOLDER_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v FFT·γ0 @@ -50,10 +88,23 @@ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa """) STARRED_CONFIGURATION: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PT + > takes + - alt + - beat + > By sample + > beat + - 44.1 kHz·30 Hz·FFT·γ0·PT + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) +STARRED_CONFIGURATION_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v FFT·γ0 - v PT + > PT > takes - alt - beat @@ -63,26 +114,56 @@ - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT """) STARRED_PLAIN_FOLDER: Final[str] = as_view(""" + > By configuration + > archive + > 48 kHz·50 Hz·LogFFT·γ1·TN + - song + """) +STARRED_PLAIN_FOLDER_OPENED: Final[str] = as_view(""" v By configuration - v archive + > archive > 48 kHz·50 Hz·LogFFT·γ1·TN - song """) -STARRED_FOLDER_AND_WHAT_IT_HOLDS: Final[str] = as_view(""" +STARRED_FOLDER_IN_STARRED_FOLDER_OPENED: Final[str] = as_view(""" v By configuration v archive - v 48 kHz·50 Hz·LogFFT·γ1·TN + > 48 kHz·50 Hz·LogFFT·γ1·TN - song """) STARRED_STRAY: Final[str] = as_view(""" + > By configuration + - stray + """) +STARRED_STRAY_OPENED: Final[str] = as_view(""" v By configuration - stray """) STARRED_OF_TWO_ALIKE: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + > By sample + > beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + """) +STARRED_OF_TWO_ALIKE_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v FFT·γ0 - v PTN·#aaaaaaa + > PTN·#aaaaaaa > drums - kick - snare @@ -99,17 +180,42 @@ - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa """) STARRED_FOLDED_CONFIGURATION: Final[str] = as_view(""" + > By configuration + > 8 kHz·60 Hz·CQT·γ2·P + - sweep + > By sample + - sweep·8 kHz·60 Hz·CQT·γ2·P + """) +STARRED_FOLDED_CONFIGURATION_OPENED: Final[str] = as_view(""" v By configuration - v 8 kHz·60 Hz·CQT·γ2·P + > 8 kHz·60 Hz·CQT·γ2·P - sweep v By sample - sweep·8 kHz·60 Hz·CQT·γ2·P """) STARRED_CONFIGURATION_B: Final[str] = as_view(""" + > By configuration + > 44.1 kHz·30 Hz + > FFT·γ0 + > PTN·#bbbbbbb + > drums + - kick + - beat + - melody + > By sample + > beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + """) +STARRED_CONFIGURATION_B_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v FFT·γ0 - v PTN·#bbbbbbb + > PTN·#bbbbbbb > drums - kick - beat @@ -123,7 +229,7 @@ v melody - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb """) -STARRED_FOLDER_HOLDING_A_STAR: Final[str] = as_view(""" +STARRED_FOLDER_HOLDING_A_STAR_OPENED: Final[str] = as_view(""" v By configuration v 44.1 kHz·30 Hz v FFT·γ0 @@ -133,11 +239,29 @@ - beat - melody v By sample - v beat + > beat - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb v drums v kick - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + """) +STARRED_FOLDER_HOLDING_A_STAR_BY_FOLDER: Final[str] = as_view(""" + v By configuration + v 44.1 kHz·30 Hz + v FFT·γ0 + > PTN·#bbbbbbb + > drums + - kick + - beat + - melody + v By sample + v beat + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb v melody - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb """) @@ -151,10 +275,10 @@ - beat [hidden] - melody v By sample - v beat [hidden] + > beat [hidden] - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden] - v drums [hidden] - v kick [hidden] + > drums [hidden] + > kick [hidden] - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb [hidden] v melody - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb @@ -166,7 +290,7 @@ v PTN·#aaaaaaa - beat [hidden] v By sample - v beat [hidden] + > beat [hidden] - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa [hidden] """) QUERY_ALONE: Final[str] = as_view(""" @@ -218,7 +342,11 @@ class TestDrawnRows: - """Which rows the mode draws: what the star reaches, and the rows leading down to it.""" + """Which rows the mode draws: what the star reaches, and the rows leading down to it. + + What is drawn is the star's to state and nothing else, so every row stands folded here — which is + what a browser opening with the preference off comes back as. + """ def test_a_starred_reconstruction_is_drawn_in_both_views(self, corpus: BrowserCorpus) -> None: assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION @@ -253,36 +381,196 @@ def test_nothing_starred_draws_no_row(self, corpus: BrowserCorpus) -> None: def test_the_mode_off_draws_every_row(self, corpus: BrowserCorpus) -> None: assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=False) == WHOLE_TREE + def test_the_rows_drawn_are_the_same_whichever_stars_are_followed(self, corpus: BrowserCorpus) -> None: + """Opening the way down to a star is a separate answer, so it moves no row in or out.""" + favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} + assert rows_of( + view( + corpus, + favorites, + favorites_only=True, + auto_expand_reconstructions=True, + auto_expand_directories=True, + ) + ) == rows_of(view(corpus, favorites, favorites_only=True)) + class TestOpenRows: - """Which rows stand open: the way down to a star, and a starred folder showing what it holds.""" + """Which rows stand open: the way down to a star the reader asked the browser to follow.""" + + def test_the_preference_off_opens_nothing(self, corpus: BrowserCorpus) -> None: + assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION - def test_a_starred_folder_opens_and_a_subfolder_holding_no_star_stays_closed( + def test_the_rows_above_a_starred_reconstruction_open(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_RECONSTRUCTION_OPENED + ) + + def test_the_sample_row_above_a_starred_reconstruction_of_a_lone_audio_opens( self, corpus: BrowserCorpus, ) -> None: - assert view(corpus, {corpus.paths["C"]}, favorites_only=True) == STARRED_CONFIGURATION + assert ( + view( + corpus, + {corpus.paths["D/solo"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_LONE_AUDIO_OPENED + ) - def test_a_starred_folder_opens_one_level(self, corpus: BrowserCorpus) -> None: - assert view(corpus, {corpus.paths["A"]}, favorites_only=True) == STARRED_OF_TWO_ALIKE + def test_the_subfolder_above_a_starred_reconstruction_opens(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["A/drums/kick"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_IN_SUBFOLDER_OPENED + ) - def test_a_star_inside_a_starred_folder_opens_the_way_down_to_itself(self, corpus: BrowserCorpus) -> None: - favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} - assert view(corpus, favorites, favorites_only=True) == STARRED_FOLDER_HOLDING_A_STAR + def test_the_branch_above_a_starred_reconstruction_outside_every_configuration_opens( + self, + corpus: BrowserCorpus, + ) -> None: + assert ( + view( + corpus, + {corpus.paths["stray"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_STRAY_OPENED + ) + + def test_a_starred_folder_is_left_folded_while_reconstructions_alone_are_followed( + self, + corpus: BrowserCorpus, + ) -> None: + assert ( + view( + corpus, + {corpus.paths["A"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_OF_TWO_ALIKE + ) + + def test_a_starred_reconstruction_is_left_folded_while_directories_alone_are_followed( + self, + corpus: BrowserCorpus, + ) -> None: + assert ( + view( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_RECONSTRUCTION + ) + + def test_the_rows_above_a_starred_configuration_open_and_it_stays_folded(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["C"]}, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_CONFIGURATION_OPENED + ) + + def test_the_rows_above_a_starred_plain_folder_open_and_it_stays_folded(self, corpus: BrowserCorpus) -> None: + assert ( + view( + corpus, + {corpus.paths["archive"]}, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_PLAIN_FOLDER_OPENED + ) - def test_a_starred_folder_inside_a_starred_folder_opens(self, corpus: BrowserCorpus) -> None: + def test_a_starred_folder_holding_a_starred_folder_opens_the_way_down_to_it( + self, + corpus: BrowserCorpus, + ) -> None: + """The folder above stands on the way to the star below, which is what opens it.""" favorites = {corpus.paths["archive"], corpus.paths["archive/F"]} - assert view(corpus, favorites, favorites_only=True) == STARRED_FOLDER_AND_WHAT_IT_HOLDS + assert ( + view( + corpus, + favorites, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_FOLDER_IN_STARRED_FOLDER_OPENED + ) - def test_the_rows_above_a_starred_reconstruction_open(self, corpus: BrowserCorpus) -> None: - assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION + def test_a_starred_configuration_whose_chain_folded_keeps_the_folded_row_closed( + self, + corpus: BrowserCorpus, + ) -> None: + assert ( + view( + corpus, + {corpus.paths["E"]}, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_FOLDED_CONFIGURATION_OPENED + ) def test_the_sample_branch_opens_the_way_to_the_variants_a_starred_folder_holds( self, corpus: BrowserCorpus, ) -> None: """No row stands for the folder there, so the variants are where the star arrives.""" - assert view(corpus, {corpus.paths["B"]}, favorites_only=True) == STARRED_CONFIGURATION_B + assert ( + view( + corpus, + {corpus.paths["B"]}, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_CONFIGURATION_B_OPENED + ) + + def test_a_star_inside_a_starred_folder_opens_that_folder(self, corpus: BrowserCorpus) -> None: + """A reconstruction answers by its own preference, so following those opens the folder above.""" + favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} + assert ( + view( + corpus, + favorites, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_FOLDER_HOLDING_A_STAR_OPENED + ) + + def test_a_star_inside_a_starred_folder_takes_its_own_preference(self, corpus: BrowserCorpus) -> None: + """Following folders alone opens the way to the folder, leaving the star inside it folded away.""" + favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} + assert ( + view( + corpus, + favorites, + favorites_only=True, + auto_expand_directories=True, + ) + == STARRED_FOLDER_HOLDING_A_STAR_BY_FOLDER + ) class TestSearchInsideTheMode: diff --git a/tests/unit/sampletones_application/ui/test_menu.py b/tests/unit/sampletones_application/ui/test_menu.py index e63f59d3f..a040aac83 100644 --- a/tests/unit/sampletones_application/ui/test_menu.py +++ b/tests/unit/sampletones_application/ui/test_menu.py @@ -11,6 +11,8 @@ TAG_GLOBAL_MENU_GROUP_EDIT_MARKER, TAG_GLOBAL_MENU_ITEM_PLAYBACK_UNMUTE_ALL_CHANNELS, TAG_GLOBAL_MENU_ITEM_RECONSTRUCTION_EXPORT_INSTRUMENTS, + TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES, + TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS, ) from sampletones_application.ui import menu as menu_module from sampletones_application.ui.menu import MenuBar @@ -119,6 +121,8 @@ def _state( *, reconstruction_loaded: bool = False, follow_mode: FollowMode = FollowMode.OFF, + auto_expand_favorite_reconstructions: bool = False, + auto_expand_favorite_directories: bool = False, ) -> MenuBarViewModel: return MenuBarViewModel( project_open=True, @@ -143,6 +147,8 @@ def _state( channels=SequencerChannelsViewModel(muted=muted), fullscreen=False, advanced_settings=False, + auto_expand_favorite_reconstructions=auto_expand_favorite_reconstructions, + auto_expand_favorite_directories=auto_expand_favorite_directories, ) @@ -420,6 +426,73 @@ def _edit_bar(build_edit_actions: Callable[[], bool]) -> MenuBar: return instance +class TestAutoExpandFavoritesMenu: + """Each kind of favorite is answered on its own, so the submenu offers one item per kind.""" + + def test_both_kinds_are_offered( + self, + menu_bar: MenuBar, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_auto_expand_favorites_menu() + + assert shortcuts.labels == ["Reconstructions", "Directories"] + + def test_each_kind_carries_its_own_action( + self, + menu_bar: MenuBar, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_auto_expand_favorites_menu() + + assert [item["shortcut_id"] for item in shortcuts.items] == [ + ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS, + ShortcutId.TOGGLE_AUTO_EXPAND_FAVORITE_DIRECTORIES, + ] + + def test_each_kind_is_offered_as_a_check( + self, + menu_bar: MenuBar, + shortcuts: _ShortcutManagerRecorder, + ) -> None: + menu_bar._create_auto_expand_favorites_menu() + + assert all(item["check"] for item in shortcuts.items) + + def test_the_submenu_is_named_by_what_it_governs( + self, + menu_bar: MenuBar, + framework: _DearPyGuiRecorder, + ) -> None: + menu_bar._create_auto_expand_favorites_menu() + + assert [entry["label"] for entry in framework.menus] == ["Auto-expand favorites"] + + +class TestAutoExpandFavoritesUpdate: + @pytest.mark.parametrize("reconstructions", [True, False]) + @pytest.mark.parametrize("directories", [True, False]) + def test_each_check_reads_the_preference_in_place( + self, + menu_bar: MenuBar, + framework: _DearPyGuiRecorder, + reconstructions: bool, + directories: bool, + ) -> None: + menu_bar._update_auto_expand_favorites( + _state( + frozenset(), + auto_expand_favorite_reconstructions=reconstructions, + auto_expand_favorite_directories=directories, + ) + ) + + assert framework.values == { + TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_RECONSTRUCTIONS: reconstructions, + TAG_GLOBAL_MENU_ITEM_VIEW_AUTO_EXPAND_FAVORITE_DIRECTORIES: directories, + } + + class TestEditActionsSection: """The Edit menu carries the actions of the grid holding the cursor, and names them itself while no grid holds one.""" 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 0b0dd2e31..f85be234a 100644 --- a/tests/unit/sampletones_application/view_model/shared/test_menu.py +++ b/tests/unit/sampletones_application/view_model/shared/test_menu.py @@ -83,6 +83,8 @@ def test_enablement_follows_project_and_history_state( channels=EVERY_CHANNEL_AUDIBLE, fullscreen=False, advanced_settings=False, + auto_expand_favorite_reconstructions=False, + auto_expand_favorite_directories=False, ) assert view_model.undo_enabled is case.undo_enabled @@ -120,6 +122,8 @@ def test_save_flag_is_carried_verbatim( channels=EVERY_CHANNEL_AUDIBLE, fullscreen=False, advanced_settings=False, + auto_expand_favorite_reconstructions=False, + auto_expand_favorite_directories=False, ) assert view_model.reconstruction_saveable is reconstruction_saveable diff --git a/tests/unit/sampletones_core/structures/tree/test_visibility.py b/tests/unit/sampletones_core/structures/tree/test_visibility.py index abd278307..4f3ec4d6e 100644 --- a/tests/unit/sampletones_core/structures/tree/test_visibility.py +++ b/tests/unit/sampletones_core/structures/tree/test_visibility.py @@ -107,6 +107,28 @@ def test_nothing_named_leaves_every_row_folded(self, nodes: Dict[str, TreeNode]) assert not any(visibility.should_expand(node) for node in nodes.values()) +class TestTheWayDownToARow: + """What ``leads_to`` answers: the rows above a named row, and none of the named rows.""" + + def test_every_row_above_a_match_leads_to_it(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, ["leaf_ba"]) + names = {name for name, node in nodes.items() if visibility.leads_to(node)} + assert names == {"root", "child_b"} + + def test_a_match_leads_to_nothing_of_its_own(self, nodes: Dict[str, TreeNode]) -> None: + """The reader is pointed at the match, so opening by this leaves it standing as it was.""" + visibility = visibility_of(nodes, ["child_a"]) + assert not visibility.leads_to(nodes["child_a"]) + + def test_a_match_above_another_leads_to_the_one_below_it(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, ["child_a", "leaf_aa"]) + assert visibility.leads_to(nodes["child_a"]) + + def test_nothing_named_leads_nowhere(self, nodes: Dict[str, TreeNode]) -> None: + visibility = visibility_of(nodes, []) + assert not any(visibility.leads_to(node) for node in nodes.values()) + + class TestResolvedSets: def test_the_named_rows_are_held_as_they_were_given(self, nodes: Dict[str, TreeNode]) -> None: visibility = visibility_of(nodes, ["leaf_aa", "leaf_ab"]) From 3fc640427459ecb2b7da2ff56abd6d73e0e98592 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 12:19:05 +0200 Subject: [PATCH 141/152] Added: browser shape surviving between application runs --- src/sampletones_application/application.py | 11 +++++ .../config/managers/session.py | 6 +++ .../config/managers/state.py | 9 +++- .../config/session/state/state.py | 6 ++- .../coordinators/tabs/instructions.py | 8 ++++ .../coordinators/tabs/reconstruction.py | 8 ++++ .../coordinators/tabs/sequencer.py | 8 ++++ .../ui/elements/tree/browser.py | 6 ++- .../ui/elements/tree/tree.py | 42 +++++++++++++---- .../ui/panels/instruction/library.py | 5 +- .../ui/panels/reconstruction/browser.py | 4 +- .../ui/panels/sequencer/browser.py | 4 ++ .../ui/panels/shared/browser.py | 7 ++- tests/suite/browser.py | 5 +- .../config/managers/test_state.py | 30 ++++++++++++ .../ui/elements/tree/test_expansion_memory.py | 47 +++++++++++++++++++ 16 files changed, 188 insertions(+), 18 deletions(-) diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 227a05e3e..82b0666c8 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1364,10 +1364,21 @@ def _get_active_source(self) -> Optional[AudioPlayerProtocol]: def _persist_application_state(self) -> None: self.session_manager.set_current_audio_device(self.audio_device_manager) self._viewport_manager.save_window_state() + self._save_browser_shapes() current_tab = self._shell.get_current_tab() self.session_manager.set_current_tab(current_tab) self.session_manager.save_config() + def _save_browser_shapes(self) -> None: + """Asks every tab holding a tree to write down which of its rows stand open. + + The shape belongs to the browser showing it, and it is read the once here rather than followed + row by row, a pass over the rows running on the tree worker. + """ + self._reconstructions_tab.save_browser_shape() + self._sequencer_tab.save_browser_shape() + self._instructions_tab.save_browser_shape() + def _build_edit_actions(self) -> bool: """States the actions of the grid holding the cursor into the Edit menu being built.""" return self._edit_router.build_menu_actions() diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index 5693f3097..980ea540b 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -59,6 +59,12 @@ def is_favorites_filter_active(self, panel_tag: str) -> bool: def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None: self._state_manager.set_favorites_filter_active(panel_tag, active) + def expanded_rows(self, panel_tag: str) -> Set[str]: + return self._state_manager.expanded_rows(panel_tag) + + def set_expanded_rows(self, panel_tag: str, rows: Set[str]) -> None: + self._state_manager.set_expanded_rows(panel_tag, rows) + def toggle_autoplay(self) -> bool: return self._config_manager.toggle_autoplay() diff --git a/src/sampletones_application/config/managers/state.py b/src/sampletones_application/config/managers/state.py index eb90f83d7..d69722430 100644 --- a/src/sampletones_application/config/managers/state.py +++ b/src/sampletones_application/config/managers/state.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import Optional, Set from sampletones_application.categories.hierarchy import Tab from sampletones_application.config.session.state.state import ApplicationState @@ -100,6 +100,13 @@ def is_favorites_filter_active(self, panel_tag: str) -> bool: def set_favorites_filter_active(self, panel_tag: str, active: bool) -> None: self.state.favorites_filters[panel_tag] = active + def expanded_rows(self, panel_tag: str) -> Set[str]: + return set(self.state.expanded_rows.get(panel_tag, ())) + + def set_expanded_rows(self, panel_tag: str, rows: Set[str]) -> None: + """Writes the rows a browser stands open, in a settled order so the file reads the same twice.""" + self.state.expanded_rows[panel_tag] = sorted(rows) + def load_current_tab(self) -> Tab: return self.state.current.tab diff --git a/src/sampletones_application/config/session/state/state.py b/src/sampletones_application/config/session/state/state.py index 818fbb38b..7747b9f59 100644 --- a/src/sampletones_application/config/session/state/state.py +++ b/src/sampletones_application/config/session/state/state.py @@ -1,4 +1,4 @@ -from typing import Dict +from typing import Dict, List from pydantic import BaseModel, Field @@ -24,6 +24,10 @@ class ApplicationState(BaseModel): default_factory=dict, description="Whether each browser shows its favorites alone, keyed by the panel's tag.", ) + expanded_rows: Dict[str, List[str]] = Field( + default_factory=dict, + description="The rows each browser stands open, keyed by the panel's tag.", + ) current: Current = Field( default_factory=Current, description="The current state of application elements, e.g. selected tab.", diff --git a/src/sampletones_application/coordinators/tabs/instructions.py b/src/sampletones_application/coordinators/tabs/instructions.py index e2bf4b761..fb6d0b4fa 100644 --- a/src/sampletones_application/coordinators/tabs/instructions.py +++ b/src/sampletones_application/coordinators/tabs/instructions.py @@ -136,6 +136,7 @@ def __init__( self._library_tree_logic, scheduling=layout.scheduling, initial_collapsed=session_manager.is_card_collapsed(TAG_INSTRUCTIONS_LIBRARY_PANEL), + initial_expanded_rows=session_manager.expanded_rows(TAG_INSTRUCTIONS_LIBRARY_PANEL), language_manager=language_manager, status_bar=status_bar, colors=layout.tree_colors, @@ -485,6 +486,13 @@ def load_library_safely(self, filepath: Path) -> None: except (SampleToNESError, OSError) as exception: logger.warning(f"Could not load library from {logger.format_path(filepath)}: {exception}") + def save_browser_shape(self) -> None: + """Writes down the rows the catalogue stands open, so a later run brings them back.""" + self._session_manager.set_expanded_rows( + self._library_panel.tag, + self._library_panel.expanded_rows, + ) + def is_library_generating(self) -> bool: return self._library_logic.is_library_generating() diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 3907c008d..9846cc554 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -166,6 +166,7 @@ def __init__( colors=layout.tree_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_RECONSTRUCTIONS_BROWSER_PANEL), initial_favorites_only=session_manager.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL), + initial_expanded_rows=session_manager.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL), ) self._browser_tree_logic.on_lock_state_changed = self._browser_panel.set_tree_enabled self._browser_tree_logic.on_favorite_changed = on_favorite_changed @@ -557,6 +558,13 @@ def unlock(self) -> None: def refresh_browser(self) -> None: self._browser_panel.refresh() + def save_browser_shape(self) -> None: + """Writes down the rows the browser stands open, so a later run brings them back.""" + self._session_manager.set_expanded_rows( + self._browser_panel.tag, + self._browser_panel.expanded_rows, + ) + def redraw_browser(self) -> None: """Draws the browser again from the model it holds, which a change of filter asks for.""" self._browser_panel.redraw_tree() diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index 26b1255a9..faa630e09 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -208,6 +208,7 @@ def __init__( colors=layout.tree_colors, initial_collapsed=session_manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL), initial_favorites_only=session_manager.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL), + initial_expanded_rows=session_manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL), ) self._sequencer_tracker_logic: SequencerTrackerLogic = SequencerTrackerLogic(project_controller) self._sequencer_order_logic: SequencerOrderLogic = SequencerOrderLogic(project_controller) @@ -956,6 +957,13 @@ def repaint(self) -> None: def refresh_browser(self) -> None: self._sequencer_browser_panel.refresh() + def save_browser_shape(self) -> None: + """Writes down the rows the browser stands open, so a later run brings them back.""" + self._session_manager.set_expanded_rows( + self._sequencer_browser_panel.tag, + self._sequencer_browser_panel.expanded_rows, + ) + def redraw_browser(self) -> None: """Draws the browser again from the model it holds, which a change of filter asks for.""" self._sequencer_browser_panel.redraw_tree() diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index 9d2410f6b..eae1cdab1 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import Dict +from typing import AbstractSet, Dict import dearpygui.dearpygui as dpg @@ -20,7 +20,7 @@ from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.elements.tree.tags import FileBrowserTags -from sampletones_application.ui.elements.tree.tree import GUITreePanel +from sampletones_application.ui.elements.tree.tree import NO_EXPANDED_ROWS, GUITreePanel from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.parallelization.thread import concurrent @@ -55,6 +55,7 @@ def __init__( status_bar: GUIStatusBar, colors: TreeColors, initial_collapsed: bool, + initial_expanded_rows: AbstractSet[str] = NO_EXPANDED_ROWS, ) -> None: self._lbl_collapse_all = language_manager["global.browser.label.collapse_all"] self._msg_collapse_all = language_manager["global.status.message.collapse_all"] @@ -69,6 +70,7 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=colors, + initial_expanded_rows=initial_expanded_rows, ) self._enable_horizontal_collapse( diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index b0f86d44e..412b6da1b 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -1,7 +1,20 @@ from abc import ABC, abstractmethod from functools import partial from pathlib import Path -from typing import Any, Callable, Dict, List, Optional, Sequence, Set, Tuple, Union +from typing import ( + AbstractSet, + Any, + Callable, + Dict, + Final, + FrozenSet, + List, + Optional, + Sequence, + Set, + Tuple, + Union, +) import dearpygui.dearpygui as dpg @@ -97,6 +110,8 @@ ) from sampletones_shared.utils.system.paths import open_path_in_explorer +NO_EXPANDED_ROWS: Final[FrozenSet[str]] = frozenset() + class GUITreePanel(GUIPanel, ABC): _NAME_FONT: Font = Font.REGULAR_SMALL @@ -118,6 +133,7 @@ def __init__( language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, + initial_expanded_rows: AbstractSet[str] = NO_EXPANDED_ROWS, ) -> None: self._language_manager = language_manager self._logic = tree_logic @@ -127,7 +143,7 @@ def __init__( self.tree_tag = tree_tag self._pending_specs: List[NodeSpec] = [] - self._expanded_rows: Set[str] = set() + self._expanded_rows: Set[str] = set(initial_expanded_rows) self._emitter = TreeEmitter(scheduling=scheduling) self._filter: TreeFilter = NO_FILTER @@ -234,16 +250,21 @@ def _collect_specs(self, root_tag: str) -> List[NodeSpec]: return self._pending_specs def _forget_rows_the_model_dropped(self) -> None: - """Holds the memory of open rows to the rows a pass over the whole tree found. + """Holds the memory of open rows to the rows the model states, read afresh on every pass. - A pass showing everything states which rows exist, so a row it left out belongs to a folder - the disk no longer holds and its place in the memory goes with it. A pass narrowed to the - favorites speaks for those rows alone, and leaves the memory of the rest as it stands. + A row the memory holds that the model no longer states belongs to a folder the disk has lost, + so its place in the memory goes with it. Reading the model rather than the rows a pass drew is + what lets a browser opening in the favorites mode — or opening on a session written before the + reconstructions directory moved — drop what is gone. """ - if not self._REMEMBERS_EXPANSION or self._filter.favorites_only: + if not self._REMEMBERS_EXPANSION: return - self._expanded_rows &= {spec.node_tag for spec in self._pending_specs} + root = self.tree.get_root() + if root is None: + return + + self._expanded_rows &= {self._generate_node_tag(node) for node in root.descendants if node.children} def create_search(self, parent: str) -> None: self._search_input_tag = compose_tag(self.tag, SUF_INPUT_SEARCH) @@ -423,6 +444,11 @@ def _stands_open( self._set_row_expanded(node_tag, stands_open and bool(node.children)) return stands_open + @property + def expanded_rows(self) -> Set[str]: + """The rows the browser stands open, which is the shape a session writes down.""" + return set(self._expanded_rows) + def _set_row_expanded(self, node_tag: str, expanded: bool) -> None: """Holds whether a row stands open, which is what a later pass brings it back by.""" if expanded: diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index e32d0f751..4196ef4c1 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Any, Callable, Optional, Protocol, Tuple +from typing import AbstractSet, Any, Callable, Optional, Protocol, Tuple import dearpygui.dearpygui as dpg @@ -83,6 +83,7 @@ class GUIInstructionsLibraryPanel(GUIFileBrowserPanel): _NAME_FONT: Font = Font.REGULAR_SMALL _MONOSPACE_CONFIG_NODES: bool = True _REBUILD_ON_CREATE: bool = False + _REMEMBERS_EXPANSION: bool = True _tags: FileBrowserTags = FileBrowserTags( panel=TAG_INSTRUCTIONS_LIBRARY_PANEL, tree=TAG_INSTRUCTIONS_LIBRARY_TREE, @@ -99,6 +100,7 @@ def __init__( *, scheduling: SchedulingBehavior, initial_collapsed: bool, + initial_expanded_rows: AbstractSet[str], language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, @@ -125,6 +127,7 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_expanded_rows=initial_expanded_rows, ) @property diff --git a/src/sampletones_application/ui/panels/reconstruction/browser.py b/src/sampletones_application/ui/panels/reconstruction/browser.py index 57d6d337e..8f618d03e 100644 --- a/src/sampletones_application/ui/panels/reconstruction/browser.py +++ b/src/sampletones_application/ui/panels/reconstruction/browser.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Optional +from typing import AbstractSet, Optional import dearpygui.dearpygui as dpg @@ -50,6 +50,7 @@ def __init__( colors: TreeColors, initial_collapsed: bool, initial_favorites_only: bool, + initial_expanded_rows: AbstractSet[str], ) -> None: self._language_manager = language_manager @@ -62,6 +63,7 @@ def __init__( colors=colors, initial_collapsed=initial_collapsed, initial_favorites_only=initial_favorites_only, + initial_expanded_rows=initial_expanded_rows, ) self.on_load_reconstruction: Optional[PathCallback] = None diff --git a/src/sampletones_application/ui/panels/sequencer/browser.py b/src/sampletones_application/ui/panels/sequencer/browser.py index 1053e17a3..9bfe14e39 100644 --- a/src/sampletones_application/ui/panels/sequencer/browser.py +++ b/src/sampletones_application/ui/panels/sequencer/browser.py @@ -1,3 +1,5 @@ +from typing import AbstractSet + from sampletones_application.categories.manager import LanguageManager from sampletones_application.layout.behavior.scheduling.scheduling import ( SchedulingBehavior, @@ -43,6 +45,7 @@ def __init__( colors: TreeColors, initial_collapsed: bool, initial_favorites_only: bool, + initial_expanded_rows: AbstractSet[str], ) -> None: self._language_manager = language_manager @@ -55,6 +58,7 @@ def __init__( colors=colors, initial_collapsed=initial_collapsed, initial_favorites_only=initial_favorites_only, + initial_expanded_rows=initial_expanded_rows, ) @property diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index ff6acb73d..5e69b11d2 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -1,5 +1,5 @@ from abc import abstractmethod -from typing import Any, Optional, Tuple +from typing import AbstractSet, Any, Optional, Tuple import dearpygui.dearpygui as dpg @@ -39,7 +39,8 @@ class GUIReconstructionBrowserPanel(GUIFileBrowserPanel): Reconstructions carry favorites, so this browser offers the control showing them alone and opens in the mode the session left it in. It holds the shape the reader unfolded as well, so a rebuild - — a refresh, a change of mode — brings the rows back standing as they were left. + — a refresh, a change of mode — brings the rows back standing as they were left, and so does the + next run of the application. """ _MONOSPACE_CONFIG_NODES: bool = True @@ -57,6 +58,7 @@ def __init__( colors: TreeColors, initial_collapsed: bool, initial_favorites_only: bool, + initial_expanded_rows: AbstractSet[str], ) -> None: self._language_manager = language_manager self.on_refresh_tree: Optional[VoidCallback] = None @@ -70,6 +72,7 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_expanded_rows=initial_expanded_rows, ) self._restore_favorites_only(initial_favorites_only) diff --git a/tests/suite/browser.py b/tests/suite/browser.py index 1d57bbef0..e82be92ad 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -2,7 +2,7 @@ from dataclasses import dataclass from pathlib import Path from textwrap import dedent -from typing import Dict, Final, List, Mapping, Sequence, Set, Tuple +from typing import Dict, Final, List, Mapping, Optional, Sequence, Set, Tuple from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.ui.elements.tree.colors import TreeColors @@ -292,6 +292,7 @@ def build_browser_panel( panel_tag: str = PANEL_TAG, auto_expand_reconstructions: bool = False, auto_expand_directories: bool = False, + expanded_rows: Optional[Set[str]] = None, ) -> GUISequencerBrowserPanel: """Builds a browser panel showing the corpus under a filter, with the favorites its logic answers. @@ -302,7 +303,7 @@ def build_browser_panel( """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = panel_tag - panel._expanded_rows = set() + panel._expanded_rows = set() if expanded_rows is None else set(expanded_rows) panel.tree_tag = TREE_TAG panel.tree = corpus.tree panel._logic = FakeTreeLogic( # type: ignore[assignment] diff --git a/tests/unit/sampletones_application/config/managers/test_state.py b/tests/unit/sampletones_application/config/managers/test_state.py index e583a25c2..43ee49554 100644 --- a/tests/unit/sampletones_application/config/managers/test_state.py +++ b/tests/unit/sampletones_application/config/managers/test_state.py @@ -120,6 +120,24 @@ def test_the_filter_and_the_collapse_of_one_panel_stand_apart(self, manager: App assert manager.is_card_collapsed(TAG_SEQUENCER_BROWSER_PANEL) is False + def test_a_browser_no_run_has_touched_stands_open_nowhere(self, manager: ApplicationStateManager) -> None: + assert manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL) == set() + + def test_a_browser_reads_the_rows_it_was_given(self, manager: ApplicationStateManager) -> None: + manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.a", "row.b"}) + assert manager.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL) == {"row.a", "row.b"} + + def test_each_browser_keeps_the_rows_of_its_own_panel(self, manager: ApplicationStateManager) -> None: + manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.a"}) + + assert manager.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL) == set() + + def test_the_rows_are_written_in_a_settled_order(self, manager: ApplicationStateManager) -> None: + """The file reads the same twice, whichever order the browser answered its rows in.""" + manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.b", "row.a"}) + + assert manager.state.expanded_rows[TAG_SEQUENCER_BROWSER_PANEL] == ["row.a", "row.b"] + class TestApplicationStateManagerCurrentPaths: def test_set_current_reconstruction_updates_property( @@ -247,6 +265,18 @@ def test_save_and_reload_preserves_each_browser_filter(self, tmp_path: Path) -> reloaded = ApplicationStateManager(path) assert reloaded.is_favorites_filter_active(TAG_SEQUENCER_BROWSER_PANEL) is True + + def test_save_and_reload_preserves_the_rows_each_browser_stands_open(self, tmp_path: Path) -> None: + """The shape the reader unfolded returns on the next launch, for that browser alone.""" + path = tmp_path / "state.yaml" + manager = ApplicationStateManager(path) + manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.a", "row.b"}) + manager.save() + + reloaded = ApplicationStateManager(path) + + assert reloaded.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL) == {"row.a", "row.b"} + assert reloaded.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL) == set() assert reloaded.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL) is False @pytest.mark.parametrize("exception_type", [PermissionError, IsADirectoryError, OSError]) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index c0fe1658b..916653720 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -208,6 +208,53 @@ def test_a_pass_over_the_whole_tree_forgets_the_rows_the_model_dropped( assert render_view(panel) == WHOLE_TREE_WITHOUT_THE_ARCHIVE assert panel._expanded_rows == set() + def test_a_browser_opens_with_the_rows_a_session_left_it(self, corpus: BrowserCorpus) -> None: + """The shape outlives the run it was made in, so a browser is handed it as it is built.""" + panel = build_browser_panel(corpus, set(), favorites_only=False) + archive_tag = panel._generate_node_tag(row_named(corpus, "archive")) + + opened = build_browser_panel( + corpus, + set(), + favorites_only=False, + expanded_rows={archive_tag}, + ) + + assert "v archive" in render_view(opened) + + def test_a_pass_in_the_favorites_mode_forgets_the_rows_the_model_dropped( + self, + corpus: BrowserCorpus, + ) -> None: + """The model states which rows exist whatever the mode narrows to, so a lost row is dropped.""" + panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) + archive = row_named(corpus, "archive") + set_row_expanded(panel, archive, expanded=True) + render_view(panel) + + archive.parent = None + set_filter(panel, favorites_only=True) + render_view(panel) + + assert panel._expanded_rows == set() + + def test_the_shape_a_save_writes_is_the_rows_standing_open(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel(corpus, set(), favorites_only=False) + archive_tag = panel._generate_node_tag(row_named(corpus, "archive")) + set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + + assert panel.expanded_rows == {archive_tag} + + def test_the_shape_a_save_reads_is_taken_apart_from_the_browser(self, corpus: BrowserCorpus) -> None: + """The browser keeps writing its own memory, so what a save carries is a reading of it.""" + panel = build_browser_panel(corpus, set(), favorites_only=False) + set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) + written = panel.expanded_rows + + set_row_expanded(panel, row_named(corpus, "archive"), expanded=False) + + assert written != panel.expanded_rows + def test_two_browsers_over_one_tree_remember_their_own_shape(self, corpus: BrowserCorpus) -> None: """A row is remembered under the tag of the browser showing it, so neither reaches the other.""" sequencer = build_browser_panel(corpus, set(), favorites_only=False, panel_tag="sequencer.browser") From eb7b1d3b44b5dd1b65e4ea69ba0193ce66bb28e2 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 12:33:54 +0200 Subject: [PATCH 142/152] Added: explorer folders standing open between application runs --- src/sampletones_application/application.py | 1 + .../config/managers/session.py | 7 + .../config/managers/state.py | 8 + .../config/session/state/state.py | 5 + .../coordinators/tabs/main.py | 5 + .../logic/main/explorer.py | 17 +- .../logic/main/explorer_manager.py | 122 +++++++--- .../ui/panels/main/explorer.py | 26 ++- .../config/managers/test_state.py | 22 ++ .../logic/main/test_explorer_manager.py | 220 ++++++++++++++++++ .../ui/panels/main/test_explorer_controls.py | 97 +++++++- 11 files changed, 481 insertions(+), 49 deletions(-) create mode 100644 tests/unit/sampletones_application/logic/main/test_explorer_manager.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 82b0666c8..87ca0cf33 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1375,6 +1375,7 @@ def _save_browser_shapes(self) -> None: The shape belongs to the browser showing it, and it is read the once here rather than followed row by row, a pass over the rows running on the tree worker. """ + self._main_tab.save_browser_shape() self._reconstructions_tab.save_browser_shape() self._sequencer_tab.save_browser_shape() self._instructions_tab.save_browser_shape() diff --git a/src/sampletones_application/config/managers/session.py b/src/sampletones_application/config/managers/session.py index 980ea540b..c5ee6fa1b 100644 --- a/src/sampletones_application/config/managers/session.py +++ b/src/sampletones_application/config/managers/session.py @@ -65,6 +65,13 @@ def expanded_rows(self, panel_tag: str) -> Set[str]: def set_expanded_rows(self, panel_tag: str, rows: Set[str]) -> None: self._state_manager.set_expanded_rows(panel_tag, rows) + @property + def expanded_directories(self) -> Set[Path]: + return self._state_manager.expanded_directories + + def set_expanded_directories(self, directories: Set[Path]) -> None: + self._state_manager.set_expanded_directories(directories) + def toggle_autoplay(self) -> bool: return self._config_manager.toggle_autoplay() diff --git a/src/sampletones_application/config/managers/state.py b/src/sampletones_application/config/managers/state.py index d69722430..97936b044 100644 --- a/src/sampletones_application/config/managers/state.py +++ b/src/sampletones_application/config/managers/state.py @@ -107,6 +107,14 @@ def set_expanded_rows(self, panel_tag: str, rows: Set[str]) -> None: """Writes the rows a browser stands open, in a settled order so the file reads the same twice.""" self.state.expanded_rows[panel_tag] = sorted(rows) + @property + def expanded_directories(self) -> Set[Path]: + return set(self.state.expanded_directories) + + def set_expanded_directories(self, directories: Set[Path]) -> None: + """Writes the folders the explorer stands open, in a settled order for a file read twice.""" + self.state.expanded_directories = sorted(directories) + def load_current_tab(self) -> Tab: return self.state.current.tab diff --git a/src/sampletones_application/config/session/state/state.py b/src/sampletones_application/config/session/state/state.py index 7747b9f59..2524a7972 100644 --- a/src/sampletones_application/config/session/state/state.py +++ b/src/sampletones_application/config/session/state/state.py @@ -1,3 +1,4 @@ +from pathlib import Path from typing import Dict, List from pydantic import BaseModel, Field @@ -28,6 +29,10 @@ class ApplicationState(BaseModel): default_factory=dict, description="The rows each browser stands open, keyed by the panel's tag.", ) + expanded_directories: List[Path] = Field( + default_factory=list, + description="The folders the Main tab's explorer stands open.", + ) current: Current = Field( default_factory=Current, description="The current state of application elements, e.g. selected tab.", diff --git a/src/sampletones_application/coordinators/tabs/main.py b/src/sampletones_application/coordinators/tabs/main.py index 6d665e50e..3cc17adab 100644 --- a/src/sampletones_application/coordinators/tabs/main.py +++ b/src/sampletones_application/coordinators/tabs/main.py @@ -130,6 +130,7 @@ def __init__( self._explorer_logic: ExplorerLogic = ExplorerLogic( config_manager, language_manager=language_manager, + open_directories=session_manager.expanded_directories, ) self._explorer_tree_logic: TreeLogic = TreeLogic( session_manager, @@ -493,6 +494,10 @@ def refresh_converter_view(self) -> None: def set_input_path(self, path: Path, convert: bool) -> None: self._converter_logic.set_input_path(path, convert=convert) + def save_browser_shape(self) -> None: + """Writes down the folders the explorer stands open, so a later run reads down to them.""" + self._session_manager.set_expanded_directories(self._explorer_logic.open_directories) + def refresh_browser(self) -> None: self._explorer_panel.refresh() diff --git a/src/sampletones_application/logic/main/explorer.py b/src/sampletones_application/logic/main/explorer.py index 5e665f2c3..40a1ec061 100644 --- a/src/sampletones_application/logic/main/explorer.py +++ b/src/sampletones_application/logic/main/explorer.py @@ -1,4 +1,5 @@ from pathlib import Path +from typing import AbstractSet, Set from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager @@ -12,10 +13,12 @@ def __init__( config_manager: ConfigManager, *, language_manager: LanguageManager, + open_directories: AbstractSet[Path], ) -> None: self._manager = ExplorerManager( config_manager, language_manager=language_manager, + open_directories=open_directories, ) @property @@ -25,8 +28,18 @@ def tree(self) -> Tree: def refresh_tree(self) -> None: self._manager.refresh_tree() - def is_directory_expanded(self, filepath: Path) -> bool: - return self._manager.is_directory_expanded(filepath) + def has_loaded_children(self, filepath: Path) -> bool: + return self._manager.has_loaded_children(filepath) + + def is_directory_open(self, filepath: Path) -> bool: + return self._manager.is_directory_open(filepath) + + def set_directory_open(self, filepath: Path, is_open: bool) -> None: + self._manager.set_directory_open(filepath, is_open) + + @property + def open_directories(self) -> Set[Path]: + return self._manager.open_directories def expand_directory(self, node: FileSystemNode) -> None: self._manager.expand_directory(node) diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py index 178d8456a..5fdaf755f 100644 --- a/src/sampletones_application/logic/main/explorer_manager.py +++ b/src/sampletones_application/logic/main/explorer_manager.py @@ -1,5 +1,5 @@ from pathlib import Path -from typing import Dict, List, Optional +from typing import AbstractSet, List, Optional, Set from sampletones_application.categories.manager import LanguageManager from sampletones_application.config.managers.config import ConfigManager @@ -20,21 +20,36 @@ class ExplorerManager: + """Reads the filesystem into the Main tab's tree, a folder at a time as the reader opens it. + + Two facts are held about a folder: whether its children have been read, and whether its row stands + open. They part company — a folder the reader read and then folded away is loaded and closed — and + the shape a session is left in is the open one, which is what a later run is handed back. + """ + def __init__( self, config_manager: ConfigManager, depth: int = 0, *, language_manager: LanguageManager, + open_directories: AbstractSet[Path], ) -> None: self._language_manager = language_manager self.tree = Tree() self.config_manager = config_manager - self._expanded_directories: Dict[Path, bool] = {} + self._loaded_directories: Set[Path] = set() + self._open_directories: Set[Path] = {path for path in open_directories if path.is_dir()} self.depth = depth def refresh_tree(self) -> None: + """Reads the filesystem afresh, down to every folder the tree has to show a row for. + + A refresh builds the tree from nothing, so each folder it needs is read once into it: reading a + folder twice would replace the rows below it, and with them the folders already read under it. + """ + self._loaded_directories.clear() container_root = TreeNode( name=self._language_manager["global.browser.label.root"], node_type=NodeType.ROOT, @@ -47,12 +62,23 @@ def refresh_tree(self) -> None: parent=container_root, ) - selected_path = self._get_ancestor_of_selected(filesystem_path) - if selected_path is not None: - self._expand_path_to_selected(filesystem_node, selected_path) + for path in self._paths_to_reveal(filesystem_path): + self._expand_path_to(filesystem_node, path) self.tree.set_root(container_root) + def _paths_to_reveal(self, filesystem_path: Path) -> List[Path]: + """The folders a refresh reads down to, among the ones this filesystem holds. + + A folder standing open is read again so it comes back open, and the directories the + application works in are revealed so the reader finds them without walking there. + """ + candidates = (*sorted(self._open_directories), *self.selected_directories) + return [path for path in candidates if self._holds(filesystem_path, path)] + + def _holds(self, filesystem_path: Path, path: Path) -> bool: + return path == filesystem_path or filesystem_path in path.parents + def _create_directory_node( self, directory_path: Path, @@ -66,6 +92,7 @@ def _create_directory_node( ) self._load_directory_children(node) + self._open_directories.add(node.filepath) return node def _load_directory_children( @@ -74,13 +101,10 @@ def _load_directory_children( level: int = 0, ) -> None: directory_path = directory_node.filepath - if not directory_path.is_dir(): + if not directory_path.is_dir() or directory_path in self._loaded_directories: return - self._expanded_directories[directory_path] = level == 0 - for existing_child in list(directory_node.children): - existing_child.parent = None - + self._loaded_directories.add(directory_path) try: entries = sorted( directory_path.iterdir(), @@ -145,7 +169,9 @@ def has_relevant_content(self, directory_path: Path) -> bool: return False def collapse_all(self) -> None: - self._expanded_directories.clear() + """Folds every folder away and drops what was read, so opening one lists it as it stands.""" + self._loaded_directories.clear() + self._open_directories.clear() root = self.tree.get_root() if not root: @@ -157,15 +183,32 @@ def collapse_all(self) -> None: child.parent = None def expand_directory(self, directory_node: FileSystemNode) -> None: + """Reads a folder's children the first time it is opened, which is what fills its row.""" if directory_node.node_type != NodeType.DIRECTORY: return - directory_path = directory_node.filepath - if not self.is_directory_expanded(directory_path): - self._load_directory_children(directory_node) + self._load_directory_children(directory_node) + + def has_loaded_children(self, directory_path: Path) -> bool: + """Whether the folder's children have been read, which is what a row below it needs.""" + return directory_path in self._loaded_directories - def is_directory_expanded(self, directory_path: Path) -> bool: - return self._expanded_directories.get(directory_path, False) + def is_directory_open(self, directory_path: Path) -> bool: + """Whether the folder's row stands open, which a refresh brings it back as.""" + return directory_path in self._open_directories + + def set_directory_open(self, directory_path: Path, is_open: bool) -> None: + """Takes what a click left the folder standing as, which is the shape a session writes down.""" + if is_open: + self._open_directories.add(directory_path) + return + + self._open_directories.discard(directory_path) + + @property + def open_directories(self) -> Set[Path]: + """The folders standing open, which is the shape a later run is handed back.""" + return set(self._open_directories) def _get_filesystems(self) -> List[Path]: system = System.current() @@ -184,23 +227,18 @@ def _get_windows_drives(self) -> List[Path]: return drives - def _get_ancestor_of_selected(self, path: Path) -> Optional[Path]: - for selected_path in self.selected_directories: - try: - selected_path.relative_to(path) - return selected_path - except ValueError: - continue - - return None - - def _expand_path_to_selected( + def _expand_path_to( self, filesystem_node: FileSystemNode, - selected_path: Path, + path: Path, ) -> None: + """Reads the folders down to a path, so a row stands for it and for every folder above it. + + Each folder walked through is opened, that being what shows the row below it. The folder at the + end is read as well where it stands open, so it comes back holding what it held. + """ try: - relative_parts = selected_path.relative_to(filesystem_node.filepath).parts + relative_parts = path.relative_to(filesystem_node.filepath).parts except ValueError: return @@ -210,13 +248,27 @@ def _expand_path_to_selected( for part in relative_parts: current_path = current_path / part self._load_directory_children(current_node) + self._open_directories.add(current_node.filepath) + + child = self._child_at(current_node, current_path) + if child is None: + return + + current_node = child + + if self.is_directory_open(current_node.filepath): + self._load_directory_children(current_node) - for child in current_node.children: - if isinstance(child, FileSystemNode) and child.filepath == current_path: - current_node = child - break - else: - break + def _child_at( + self, + directory_node: FileSystemNode, + path: Path, + ) -> Optional[FileSystemNode]: + for child in directory_node.children: + if isinstance(child, FileSystemNode) and child.filepath == path: + return child + + return None @property def selected_directories(self) -> List[Path]: diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 4adbe398a..1cbe05c89 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -55,7 +55,11 @@ def collapse_all(self) -> None: ... def expand_directory(self, node: FileSystemNode) -> None: ... - def is_directory_expanded(self, filepath: Path) -> bool: ... + def has_loaded_children(self, filepath: Path) -> bool: ... + + def is_directory_open(self, filepath: Path) -> bool: ... + + def set_directory_open(self, filepath: Path, is_open: bool) -> None: ... def has_relevant_content(self, filepath: Path) -> bool: ... @@ -165,7 +169,7 @@ def _collect_subtree_specs( node_tag: str, ) -> List[NodeSpec]: self._pending_specs = [] - if self._explorer_logic.is_directory_expanded(node.filepath): + if self._explorer_logic.has_loaded_children(node.filepath): for child in node.children: self._build_tree_node( child, @@ -194,16 +198,13 @@ def _build_tree_node( self._mark_favorite_ancestry(node, state) if node.node_type == NodeType.DIRECTORY: - should_expand = self._should_expand_node(node) or self._explorer_logic.is_directory_expanded(node.filepath) - is_directory_expanded = self._explorer_logic.is_directory_expanded(node.filepath) self._append_spec( node, node_tag, state.parent, open_on_double_click=True, - should_expand=should_expand, + should_expand=self._should_expand_node(node) or self._explorer_logic.is_directory_open(node.filepath), has_favorite_ancestor=state.has_favorite_ancestor, - is_node_expanded=is_directory_expanded, ) else: self._append_spec( @@ -361,19 +362,24 @@ def _toggle_directory_expansion( node: FileSystemNode, node_tag: str, ) -> None: + """Folds or unfolds a folder, reading its children the first time it is opened. + + The folder is told what it now stands as, which is the shape a refresh and a later run of the + application bring it back in. + """ if not isinstance(node, FileSystemNode) or node.node_type != NodeType.DIRECTORY: return if not dpg.does_item_exist(node_tag): return - is_directory_expanded = self._explorer_logic.is_directory_expanded(node.filepath) - state = dpg.get_value(node_tag) - if not is_directory_expanded: + is_open = not dpg.get_value(node_tag) + if not self._explorer_logic.has_loaded_children(node.filepath): self._explorer_logic.expand_directory(node) self._rebuild_node_subtree(node, node_tag) - dpg.set_value(node_tag, not state) + dpg.set_value(node_tag, is_open) + self._explorer_logic.set_directory_open(node.filepath, is_open) def _add_context_menu_file_actions(self, node: FileSystemNode) -> None: dpg.add_separator() diff --git a/tests/unit/sampletones_application/config/managers/test_state.py b/tests/unit/sampletones_application/config/managers/test_state.py index 43ee49554..a01acd0ab 100644 --- a/tests/unit/sampletones_application/config/managers/test_state.py +++ b/tests/unit/sampletones_application/config/managers/test_state.py @@ -132,6 +132,17 @@ def test_each_browser_keeps_the_rows_of_its_own_panel(self, manager: Application assert manager.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL) == set() + def test_an_explorer_no_run_has_touched_stands_open_nowhere(self, manager: ApplicationStateManager) -> None: + assert manager.expanded_directories == set() + + def test_the_explorer_reads_the_folders_it_was_given( + self, + manager: ApplicationStateManager, + tmp_path: Path, + ) -> None: + manager.set_expanded_directories({tmp_path}) + assert manager.expanded_directories == {tmp_path} + def test_the_rows_are_written_in_a_settled_order(self, manager: ApplicationStateManager) -> None: """The file reads the same twice, whichever order the browser answered its rows in.""" manager.set_expanded_rows(TAG_SEQUENCER_BROWSER_PANEL, {"row.b", "row.a"}) @@ -277,6 +288,17 @@ def test_save_and_reload_preserves_the_rows_each_browser_stands_open(self, tmp_p assert reloaded.expanded_rows(TAG_SEQUENCER_BROWSER_PANEL) == {"row.a", "row.b"} assert reloaded.expanded_rows(TAG_RECONSTRUCTIONS_BROWSER_PANEL) == set() + + def test_save_and_reload_preserves_the_folders_the_explorer_stands_open(self, tmp_path: Path) -> None: + """The folders the reader walked into return on the next launch, read down to as they were.""" + path = tmp_path / "state.yaml" + manager = ApplicationStateManager(path) + manager.set_expanded_directories({tmp_path / "music", tmp_path / "notes"}) + manager.save() + + reloaded = ApplicationStateManager(path) + + assert reloaded.expanded_directories == {tmp_path / "music", tmp_path / "notes"} assert reloaded.is_favorites_filter_active(TAG_RECONSTRUCTIONS_BROWSER_PANEL) is False @pytest.mark.parametrize("exception_type", [PermissionError, IsADirectoryError, OSError]) diff --git a/tests/unit/sampletones_application/logic/main/test_explorer_manager.py b/tests/unit/sampletones_application/logic/main/test_explorer_manager.py new file mode 100644 index 000000000..ae9d18205 --- /dev/null +++ b/tests/unit/sampletones_application/logic/main/test_explorer_manager.py @@ -0,0 +1,220 @@ +from pathlib import Path +from typing import AbstractSet, Dict, List, Optional, Set + +import pytest + +from sampletones_application.logic.main.explorer_manager import ExplorerManager +from sampletones_core.structures.tree import FileSystemNode, Tree +from tests.suite.language import FakeLanguageManager + +MUSIC = "music" +DRUMS = "drums" +NOTES = "notes" + + +class FakeConfigManager: + """Answers the directories the explorer reveals, which a test points at its own corpus.""" + + def __init__(self, directory: Path) -> None: + self._directory = directory + + def get_library_directory(self) -> Path: + return self._directory + + def get_reconstructions_directory(self) -> Path: + return self._directory + + +def write_corpus(root: Path) -> Dict[str, Path]: + """A folder holding a folder, beside a folder of its own, each carrying a file to be listed.""" + paths = { + MUSIC: root / MUSIC, + DRUMS: root / MUSIC / DRUMS, + NOTES: root / NOTES, + } + for path in paths.values(): + path.mkdir(parents=True) + (path / "song.wav").touch() + + return paths + + +def build_manager( + root: Path, + open_directories: AbstractSet[Path], + monkeypatch: pytest.MonkeyPatch, +) -> ExplorerManager: + """An explorer reading one directory as its whole filesystem, so a test states every folder.""" + manager = ExplorerManager( + FakeConfigManager(root), # type: ignore[arg-type] + language_manager=FakeLanguageManager(), + open_directories=open_directories, + ) + monkeypatch.setattr(manager, "_get_filesystems", lambda: [root], raising=False) + return manager + + +def row_at(tree: Tree, path: Path) -> Optional[FileSystemNode]: + rows = tree.find_nodes(FileSystemNode, lambda node: node.filepath == path) + return rows[0] if rows else None + + +def rows_below(tree: Tree, path: Path) -> List[str]: + row = row_at(tree, path) + assert row is not None + return sorted(str(child.name) for child in row.children) + + +class TestTheShapeASessionLeft: + """The folders standing open are handed back at startup, and read down to on the next refresh.""" + + def test_a_remembered_folder_comes_back_open( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, {paths[MUSIC]}, monkeypatch) + + manager.refresh_tree() + + assert manager.is_directory_open(paths[MUSIC]) + assert rows_below(manager.tree, paths[MUSIC]) == [DRUMS, "song.wav"] + + def test_a_folder_nested_in_a_remembered_one_is_read_down_to( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """A row stands for every folder above the remembered one, which is what shows it.""" + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, {paths[DRUMS]}, monkeypatch) + + manager.refresh_tree() + + assert manager.is_directory_open(paths[MUSIC]) + assert rows_below(manager.tree, paths[DRUMS]) == ["song.wav"] + + def test_a_folder_no_session_left_open_stays_folded( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, {paths[MUSIC]}, monkeypatch) + + manager.refresh_tree() + + assert not manager.is_directory_open(paths[NOTES]) + assert rows_below(manager.tree, paths[NOTES]) == [] + + def test_a_folder_the_disk_has_lost_is_dropped_at_startup( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + paths = write_corpus(tmp_path) + gone = tmp_path / "gone" + + manager = build_manager(tmp_path, {paths[MUSIC], gone}, monkeypatch) + + assert manager.open_directories == {paths[MUSIC]} + + def test_a_file_standing_where_a_folder_was_is_dropped_at_startup( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + write_corpus(tmp_path) + replaced = tmp_path / "replaced" + replaced.touch() + + manager = build_manager(tmp_path, {replaced}, monkeypatch) + + assert manager.open_directories == set() + + +class TestReadingApartFromStandingOpen: + """A folder read once and then folded away is loaded and closed, and comes back closed.""" + + def test_a_folded_folder_keeps_the_children_it_read( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, set(), monkeypatch) + manager.refresh_tree() + music = row_at(manager.tree, paths[MUSIC]) + assert music is not None + + manager.expand_directory(music) + manager.set_directory_open(paths[MUSIC], False) + + assert manager.has_loaded_children(paths[MUSIC]) + assert not manager.is_directory_open(paths[MUSIC]) + + def test_a_refresh_brings_a_folded_folder_back_folded( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, set(), monkeypatch) + manager.refresh_tree() + music = row_at(manager.tree, paths[MUSIC]) + assert music is not None + manager.expand_directory(music) + manager.set_directory_open(paths[MUSIC], True) + manager.set_directory_open(paths[MUSIC], False) + + manager.refresh_tree() + + assert not manager.is_directory_open(paths[MUSIC]) + + def test_the_filesystem_the_tree_opens_at_stands_open( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + write_corpus(tmp_path) + manager = build_manager(tmp_path, set(), monkeypatch) + + manager.refresh_tree() + + assert manager.is_directory_open(tmp_path) + + +class TestCollapseAll: + def test_every_folder_is_folded_and_what_was_read_is_dropped( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, {paths[DRUMS]}, monkeypatch) + manager.refresh_tree() + + manager.collapse_all() + + assert manager.open_directories == set() + assert not manager.has_loaded_children(paths[MUSIC]) + assert rows_below(manager.tree, tmp_path) == [] + + +class TestTheShapeASaveReads: + def test_the_folders_standing_open_are_answered_apart_from_the_explorer( + self, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + """The explorer keeps writing its own shape, so what a save carries is a reading of it.""" + paths = write_corpus(tmp_path) + manager = build_manager(tmp_path, {paths[MUSIC]}, monkeypatch) + manager.refresh_tree() + written: Set[Path] = manager.open_directories + + manager.set_directory_open(paths[MUSIC], False) + + assert paths[MUSIC] in written + assert paths[MUSIC] not in manager.open_directories diff --git a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py index 88c435e57..a18c1776b 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py @@ -1,9 +1,10 @@ from pathlib import Path -from typing import List, Tuple +from typing import List, Set, Tuple import pytest from sampletones_application.ui.elements.tree import tree as tree_module +from sampletones_application.ui.panels.main import explorer as explorer_module from sampletones_application.ui.panels.main.explorer import GUIExplorerPanel from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode @@ -13,11 +14,24 @@ class FakeExplorerLogic: - """Answers what the panel asks of its model, recording the folders it is told to drop.""" + """Answers what the panel asks of its model, recording what it is told about each folder.""" def __init__(self, tree: Tree) -> None: self.tree = tree self.cleared: List[Tuple[str, ...]] = [] + self.loaded: Set[Path] = set() + self.read: List[Path] = [] + self.standing: List[Tuple[Path, bool]] = [] + + def has_loaded_children(self, filepath: Path) -> bool: + return filepath in self.loaded + + def expand_directory(self, node: FileSystemNode) -> None: + self.read.append(node.filepath) + self.loaded.add(node.filepath) + + def set_directory_open(self, filepath: Path, is_open: bool) -> None: + self.standing.append((filepath, is_open)) def collapse_all(self) -> None: root = self.tree.get_root() @@ -73,6 +87,85 @@ def build_panel(tree: Tree) -> GUIExplorerPanel: return panel +@pytest.fixture +def toggled(monkeypatch: pytest.MonkeyPatch) -> List[Tuple[str, bool]]: + """Records the rows the panel folds through the framework, in place of the widgets.""" + calls: List[Tuple[str, bool]] = [] + monkeypatch.setattr(explorer_module.dpg, "does_item_exist", lambda tag: True) + monkeypatch.setattr(explorer_module.dpg, "get_value", lambda tag: False) + monkeypatch.setattr( + explorer_module.dpg, + "set_value", + lambda tag, value: calls.append((tag, value)), + ) + return calls + + +class TestFollowingAFold: + """A click on a folder is how it opens, and the explorer is told what it now stands as.""" + + def test_opening_a_folder_reads_it_and_records_it_open( + self, + toggled: List[Tuple[str, bool]], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = explorer_tree() + panel = build_panel(tree) + music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0] + monkeypatch.setattr(panel, "_rebuild_node_subtree", lambda node, node_tag: None, raising=False) + + panel._toggle_directory_expansion(music, "row.music") + + assert panel._explorer_logic.read == [MUSIC] + assert panel._explorer_logic.standing == [(MUSIC, True)] + assert toggled == [("row.music", True)] + + def test_a_folder_read_already_is_not_read_again( + self, + toggled: List[Tuple[str, bool]], + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = explorer_tree() + panel = build_panel(tree) + music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0] + panel._explorer_logic.loaded.add(MUSIC) + monkeypatch.setattr(panel, "_rebuild_node_subtree", lambda node, node_tag: None, raising=False) + + panel._toggle_directory_expansion(music, "row.music") + + assert panel._explorer_logic.read == [] + assert panel._explorer_logic.standing == [(MUSIC, True)] + + def test_folding_a_folder_records_it_closed( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = explorer_tree() + panel = build_panel(tree) + music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0] + panel._explorer_logic.loaded.add(MUSIC) + monkeypatch.setattr(explorer_module.dpg, "does_item_exist", lambda tag: True) + monkeypatch.setattr(explorer_module.dpg, "get_value", lambda tag: True) + monkeypatch.setattr(explorer_module.dpg, "set_value", lambda tag, value: None) + + panel._toggle_directory_expansion(music, "row.music") + + assert panel._explorer_logic.standing == [(MUSIC, False)] + + def test_a_row_that_left_the_tree_is_left_alone( + self, + monkeypatch: pytest.MonkeyPatch, + ) -> None: + tree = explorer_tree() + panel = build_panel(tree) + music = tree.find_nodes(FileSystemNode, lambda node: node.filepath == MUSIC)[0] + monkeypatch.setattr(explorer_module.dpg, "does_item_exist", lambda tag: False) + + panel._toggle_directory_expansion(music, "row.music") + + assert panel._explorer_logic.standing == [] + + class TestCollapseAll: def test_the_rows_fold_while_the_model_still_states_them( self, From 5a0396c32ed80d3eb888eb7a93ead94c9c73245b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 12:41:46 +0200 Subject: [PATCH 143/152] Added: application mark in the About dialog --- src/sampletones_application/application.py | 29 ++++++++----- .../categories/hierarchy.py | 1 + .../layout/general/dialogs/about.py | 15 +++++++ .../layout/general/dialogs/dialogs.py | 2 + src/sampletones_application/shell.py | 5 +++ src/sampletones_application/tags/general.py | 7 ++++ .../ui/elements/texture.py | 26 ++++++++++++ .../layout/general/dialogs.yaml | 5 +++ .../layout/test_about_dialog.py | 7 ++++ .../ui/elements/test_texture.py | 41 +++++++++++++++++++ 10 files changed, 127 insertions(+), 11 deletions(-) create mode 100644 src/sampletones_application/layout/general/dialogs/about.py create mode 100644 src/sampletones_application/ui/elements/texture.py create mode 100644 tests/unit/sampletones_application/layout/test_about_dialog.py create mode 100644 tests/unit/sampletones_application/ui/elements/test_texture.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 87ca0cf33..868523b7b 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -81,6 +81,7 @@ from sampletones_application.tags.general import ( TAG_GLOBAL_DIALOG_ABOUT, TAG_GLOBAL_DIALOG_EXIT_CONFIRMATION, + TAG_GLOBAL_TEXTURE_LOGO, TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_MENU_FPS, TAG_GLOBAL_THEME_PLAYER_BUTTON, @@ -1182,7 +1183,8 @@ def _open_audio_settings(self) -> None: ) def _open_about_dialog(self) -> None: - """Presents the application name, version, description, and authorship in a modal notice.""" + """Presents the application's mark beside its name, version, description, and authorship.""" + about = self.layout.general.dialogs.about description = self.language_manager["global.dialog.message.about_description"] author_line = self.language_manager["global.dialog.template.about_author"].format( author=SAMPLETONES_AUTHOR, @@ -1190,21 +1192,26 @@ def _open_about_dialog(self) -> None: ) def content(parent: str) -> None: - name_text = dpg.add_text(SAMPLETONES_NAME_VERSION, parent=parent) - dpg.add_separator(parent=parent) - FontRegistry.bind_to_item(name_text, Font.BOLD_LARGE) - dpg.add_text( - description, - parent=parent, - wrap=self.dialogs.default_wrap, - ) - author_text = dpg.add_text(author_line, parent=parent) - FontRegistry.bind_to_item(author_text, Font.ITALIC) + with dpg.group(horizontal=True, parent=parent): + dpg.add_image( + TAG_GLOBAL_TEXTURE_LOGO, + width=about.logo, + height=about.logo, + ) + with dpg.group(): + name_text = dpg.add_text(SAMPLETONES_NAME_VERSION) + FontRegistry.bind_to_item(name_text, Font.BOLD_LARGE) + dpg.add_separator() + dpg.add_text(description, wrap=about.text_wrap) + author_text = dpg.add_text(author_line) + FontRegistry.bind_to_item(author_text, Font.ITALIC) self.dialogs.show_modal( get_dialog_tag(TAG_GLOBAL_DIALOG_ABOUT), self.language_manager["global.dialog.title.about"], content, + width=about.width, + height=about.height, ) def _refresh_audio_devices(self) -> None: diff --git a/src/sampletones_application/categories/hierarchy.py b/src/sampletones_application/categories/hierarchy.py index 0a5fbfa43..8a2689e60 100644 --- a/src/sampletones_application/categories/hierarchy.py +++ b/src/sampletones_application/categories/hierarchy.py @@ -30,6 +30,7 @@ class Widget(StrEnum): TABLE = "table" TABS = "tabs" TEXT = "text" + TEXTURE = "texture" THEME = "theme" TOOLTIP = "tooltip" TREE = "tree" diff --git a/src/sampletones_application/layout/general/dialogs/about.py b/src/sampletones_application/layout/general/dialogs/about.py new file mode 100644 index 000000000..c7cf36dbd --- /dev/null +++ b/src/sampletones_application/layout/general/dialogs/about.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + + +class AboutDialogLayout(BaseModel, extra="forbid", frozen=True): + """The About dialog's size, the size its mark is drawn at, and the room left around the mark.""" + + width: int + height: int + logo: int + padding: int + + @property + def text_wrap(self) -> int: + """Width the text standing beside the mark wraps at.""" + return self.width - self.logo - self.padding diff --git a/src/sampletones_application/layout/general/dialogs/dialogs.py b/src/sampletones_application/layout/general/dialogs/dialogs.py index 700b5a763..3d1d8ebf8 100644 --- a/src/sampletones_application/layout/general/dialogs/dialogs.py +++ b/src/sampletones_application/layout/general/dialogs/dialogs.py @@ -1,5 +1,6 @@ from pydantic import BaseModel +from sampletones_application.layout.general.dialogs.about import AboutDialogLayout from sampletones_application.layout.general.dialogs.height import DialogSizeNoWidth from sampletones_application.layout.primitives import Dimensions @@ -11,3 +12,4 @@ class DialogsLayout(BaseModel, extra="forbid", frozen=True): confirmation: DialogSizeNoWidth text_input: DialogSizeNoWidth traceback: Dimensions + about: AboutDialogLayout diff --git a/src/sampletones_application/shell.py b/src/sampletones_application/shell.py index e3e21f06a..6477969c8 100644 --- a/src/sampletones_application/shell.py +++ b/src/sampletones_application/shell.py @@ -32,6 +32,7 @@ from sampletones_application.ui.elements.fonts.font import Font from sampletones_application.ui.elements.fonts.registry import FontRegistry from sampletones_application.ui.elements.status import GUIStatusBar +from sampletones_application.ui.elements.texture import TextureRegistry from sampletones_application.ui.menu import MenuBar from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.ui.themes.theme import Theme @@ -169,6 +170,7 @@ def setup( ) -> None: dpg.create_context() self._set_fonts() + self._set_textures() self._register_shortcuts(bindings) self._set_default_theme() self._viewport_manager.create_viewport() @@ -198,6 +200,9 @@ def _setup_dearpygui(self) -> None: def _set_fonts(self) -> None: FontRegistry.register_fonts(self._layout.fonts.scale) + def _set_textures(self) -> None: + TextureRegistry.register_textures() + def _set_default_theme(self) -> None: self._theme.bind() diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 99533da87..fed74055c 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -681,6 +681,13 @@ "sequencer", ) +TAG_GLOBAL_TEXTURE_LOGO = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.TEXTURE, + "logo", +) + SUF_BUTTON = "button" SUF_BUTTONS = "buttons" SUF_BUTTON_COPY = compose_tag(SUF_BUTTON, "copy") diff --git a/src/sampletones_application/ui/elements/texture.py b/src/sampletones_application/ui/elements/texture.py new file mode 100644 index 000000000..fe933c39c --- /dev/null +++ b/src/sampletones_application/ui/elements/texture.py @@ -0,0 +1,26 @@ +from typing import ClassVar, Dict + +import dearpygui.dearpygui as dpg + +from sampletones_application.tags.general import TAG_GLOBAL_TEXTURE_LOGO +from sampletones_application.ui.resources.items import IconResource +from sampletones_application.ui.resources.resources import get_icon_path + + +class TextureRegistry: + """Reads the images the interface draws into DearPyGui textures, the once at startup. + + A texture is created before any window asks for it and stands for the whole run, so whatever draws + the application's mark names it by the tag it was created under. + """ + + _IMAGES: ClassVar[Dict[str, IconResource]] = { + TAG_GLOBAL_TEXTURE_LOGO: IconResource.UNIX, + } + + @classmethod + def register_textures(cls) -> None: + with dpg.texture_registry(): + for tag, resource in cls._IMAGES.items(): + width, height, _channels, data = dpg.load_image(get_icon_path(resource)) + dpg.add_static_texture(width, height, data, tag=tag) diff --git a/src/sampletones_config/layout/general/dialogs.yaml b/src/sampletones_config/layout/general/dialogs.yaml index 7c57efcde..09eab64ba 100644 --- a/src/sampletones_config/layout/general/dialogs.yaml +++ b/src/sampletones_config/layout/general/dialogs.yaml @@ -14,3 +14,8 @@ text_input: traceback: width: 0 height: 400 +about: + width: 480 + height: 210 + logo: 72 + padding: 40 diff --git a/tests/unit/sampletones_application/layout/test_about_dialog.py b/tests/unit/sampletones_application/layout/test_about_dialog.py new file mode 100644 index 000000000..99296ccaa --- /dev/null +++ b/tests/unit/sampletones_application/layout/test_about_dialog.py @@ -0,0 +1,7 @@ +from sampletones_application.layout.general.dialogs.about import AboutDialogLayout + + +class TestTheRoomTheTextTakes: + def test_the_text_wraps_in_what_the_mark_leaves(self) -> None: + layout = AboutDialogLayout(width=480, height=210, logo=72, padding=40) + assert layout.text_wrap == 368 diff --git a/tests/unit/sampletones_application/ui/elements/test_texture.py b/tests/unit/sampletones_application/ui/elements/test_texture.py new file mode 100644 index 000000000..6d8c9efa5 --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/test_texture.py @@ -0,0 +1,41 @@ +from typing import Iterator + +import dearpygui.dearpygui as dpg +import pytest + +from sampletones_application.tags.general import TAG_GLOBAL_TEXTURE_LOGO +from sampletones_application.ui.elements.texture import TextureRegistry + +MARK_SIZE = 256 + + +@pytest.fixture +def context() -> Iterator[None]: + """A DearPyGui context, textures being framework items rather than plain data.""" + dpg.create_context() + try: + yield + finally: + dpg.destroy_context() + + +class TestTheImagesTheInterfaceDraws: + def test_the_mark_is_read_into_a_texture_named_by_its_tag(self, context: None) -> None: + TextureRegistry.register_textures() + + assert dpg.does_item_exist(TAG_GLOBAL_TEXTURE_LOGO) + + def test_the_texture_carries_the_shipped_image_at_its_own_size(self, context: None) -> None: + """The image is read as it ships, and whatever draws it states the size it wants.""" + TextureRegistry.register_textures() + + configuration = dpg.get_item_configuration(TAG_GLOBAL_TEXTURE_LOGO) + assert (configuration["width"], configuration["height"]) == (MARK_SIZE, MARK_SIZE) + + def test_the_texture_is_there_to_be_drawn(self, context: None) -> None: + TextureRegistry.register_textures() + + with dpg.window(): + image = dpg.add_image(TAG_GLOBAL_TEXTURE_LOGO, width=72, height=72) + + assert dpg.get_item_type(image) == "mvAppItemType::mvImage" From ad6144c140b1781b51896e582c11e1a5d4064923 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 12:54:01 +0200 Subject: [PATCH 144/152] Documented: the browser's open rule, its remembered shape and the sweep --- docs/development/browser.md | 62 ++++++++++++++----- docs/guide/interface.md | 13 +++- src/sampletones_application/tags/general.py | 6 -- .../ui/elements/tree/tree.py | 10 --- .../utils/gui/dialogs.py | 5 -- src/sampletones_config/palettes/dark.yaml | 1 - src/sampletones_config/palettes/light.yaml | 1 - src/sampletones_config/palettes/studio.yaml | 1 - .../nodes/files/not_expanded_directory.yaml | 9 --- src/sampletones_core/structures/tree/tree.py | 8 +-- .../structures/tree/test_tree.py | 16 ----- 11 files changed, 59 insertions(+), 73 deletions(-) delete mode 100644 src/sampletones_config/theme/nodes/files/not_expanded_directory.yaml diff --git a/docs/development/browser.md b/docs/development/browser.md index 67fd025d0..a31c0669a 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -34,9 +34,10 @@ complements `docs/development/architecture.md` (layering and ownership) and 7. **What a browser narrows to is its own.** Both tabs render one model, so which rows a browser shows is decided by the panel showing it: a search typed in one tab leaves the other reading as it was, and each browser opens in the mode a session left it in. -8. **The reader's shape survives a rebuild.** Which rows stand open is what the reader made of the +8. **The reader's shape is theirs to keep.** Which rows stand open is what the reader made of the tree, so a browser records it and brings it back: a refresh, a change of filter and a repaint leave - the tree standing as it was, and a filter adds the way down to what it names. + the tree standing as it was, and so does the next run of the application. What a filter unfolds on + top of that shape is the reader's to ask for. --- @@ -111,9 +112,9 @@ The browsers form one line of inheritance, each level owning what it shares: fonts per row, the detail tooltip, the status-bar messages, and the context-menu items every browser can offer. * `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the - refresh control, the tree window, the folder-and-file handler pair, and enabling the card as the tree - locks and unlocks. A subclass declares its widgets as a `FileBrowserTags` class attribute and states - what its card and refresh control read. + controls bringing the tree up to date and folding it away, the tree window, the folder-and-file + handler pair, and enabling the card as the tree locks and unlocks. A subclass declares its widgets as + a `FileBrowserTags` class attribute and states what its card and refresh control read. * `GUIReconstructionBrowserPanel` (`ui/panels/shared/browser.py`) — the reconstruction browser: the rows the two branches hold, the colour a group and a sample read in, and the context menus. The Reconstructions and Sequencer panels below it name their widgets, their refresh control, and what @@ -171,13 +172,25 @@ memory follows the size of what was found, and a row beneath a match is answered upwards. **What a criterion names and what it keeps are two sets.** A criterion points the reader at some rows -and brings others along with them, and only the first kind is worth unfolding to: a search names the -rows whose label matched, and the favorites mode names its **anchors** — a row the star sits on, and, -where no row stands for the starred path, the shallowest rows that path reaches. So a starred folder -comes up open showing what it holds, a folder inside it stays as it was, and a star nested deeper -opens the way down to itself, since a starred row anchors wherever it sits. In the sample branch the -headings carry no path, which makes the variants the rows the star arrives at, and the way down to -them opens. +and brings others along with them, and only the first kind is worth unfolding to. The rows a criterion +names are its **anchors**: for a search, the rows whose label matched; for the favorites mode, a row a +star sits on, and — where no row stands for the starred path — the shallowest rows that path reaches. +In the sample branch the headings carry no path, which is what makes the variants the rows a starred +folder arrives at. + +**A criterion is read the way that criterion means.** A search shows what a matching row gathers, so a +match opens along with the rows above it (`TreeVisibility.should_expand`). The favorites mode points +the reader at a star, so the rows above it open and the star's own row stands where the reader left it +(`TreeVisibility.leads_to`) — a starred folder is revealed rather than unfolded. A starred +reconstruction inside a starred folder anchors on its own, which is what opens the folder above it. + +**Which stars are followed is the reader's.** The mode decides what is drawn; whether it also unfolds +is a preference stated per kind of favorite, held in `ApplicationConfig.browser` and offered as +**View ▸ Auto-expand favorites**. A starred reconstruction reads the reconstructions answer; a starred +folder, and everything it brings in where no row stands for it, reads the directories answer. Both are +off by default, so turning the mode on narrows the tree and leaves every row standing as it was. The +panel reads the pair through `TreeLogicProtocol`, once per resolution, and a change of preference asks +each reconstruction browser for a redraw. **A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so @@ -187,12 +200,26 @@ declining a row declines its subtree, and one decision covers it while the trave the rows standing open, by the tag those rows are addressed under, and a later pass creates them open again: the filter adds the way down to what it names, and everything else comes back as it was left. A row is recorded as it is collected, so what the filter unfolded is part of that shape too; a click -is read a frame later, once the row has answered it, and the expansion items record what they set. A -pass over the whole tree states which rows exist, so the rows it left out leave the memory with them. +is read a frame later, once the row has answered it, and the expansion items record what they set. The +memory is held to the rows the model states, read afresh on every pass, so a row a moved +reconstructions directory left behind leaves the memory with it. + +The shape outlives the run as well. A browser is handed the rows it stands open as it is built +(`initial_expanded_rows`), and `_persist_application_state` asks each tab for its shape and writes it to +`ApplicationState.expanded_rows` under the panel's tag. Reading it the once at exit keeps the session +free of a write per row per pass, a pass running on the tree worker. + +**The Main tab's explorer remembers folders, not rows.** Its rows are the folders on disk, read a level +at a time as the reader opens one, so `ExplorerManager` holds two facts about a folder: whether its +children have been read, and whether its row stands open. They part company — a folder read and then +folded away is loaded and closed — and the open one is the shape a session writes to +`ApplicationState.expanded_directories`. A refresh reads down to each remembered folder through +`_expand_path_to`, reading every folder it needs once, and the folders that are no longer directories +are dropped as the manager is built. **What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing each row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents` — -and the anchors are read out of that one answer. What it materialises is the starred rows and the rows +and the anchors the preference follows are read out of that one answer. What it materialises is the starred rows and the rows above them, and what reaches DearPyGui is the drawn rows alone: on a directory holding hundreds of thousands of reconstructions, a favorites-only browser creates widgets for the starred ones and their headings. A keystroke resolves the query alone, the drawn rows being the mode's to state. A favorite @@ -213,3 +240,8 @@ the shade states whether the control is live. Each browser opens in the mode it was left in. The panel raises `on_favorites_filter_changed` with its own tag, and the tab coordinator writes it to `ApplicationState.favorites_filters` under that tag, which is how a collapsed card is remembered too. + +**Folding the whole tree away** is the other control every card carries. It reaches the rows through the +model rather than the widget tree, so one pass covers a branch however deep it runs, and it records what +it set — leaving the memory empty, which is the shape a later pass then draws. The explorer folds first +and drops the folders it had read afterwards, so opening one lists it as it stands on disk. diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 154147f84..7fe790caa 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -16,7 +16,8 @@ begin. Pick an audio file — or a whole folder — in the **Filesystem** browser on the left, set up how the reconstruction is done in the centre, and click **Convert -sample** (or **Convert directory** for a folder). The +sample** (or **Convert directory** for a folder). The browser opens the folders you +were last working in, and **Collapse all** folds them away again. The [instruction library](../concepts/instruction-library.md) for your settings is built automatically the first time it is needed, so you can convert straight away. While it runs, the panel names the file going in and where the result is going, and @@ -49,7 +50,15 @@ moved. To keep the reconstructions you return to within reach, right-click one — or a whole folder — and choose **Mark as favorite**, which highlights it in both views. Tick **Favorites only** under the search box to narrow the browser to your -favorites and everything inside them. +favorites and everything inside them. The browser keeps the folders you had open +while it narrows, so switching the tick on and off leaves the tree as you left it. +If you would rather it opened its way down to each favorite for you, turn that on +under **View ▸ Auto-expand favorites**, which answers for reconstructions and for +folders separately. + +**Collapse all**, beside the refresh button, folds the whole tree away in one +click. Whatever you leave open is remembered, so the tree comes back the way you +left it the next time you start the application. To get your results out, use the **Reconstruction** menu. **Export instruments ▸ FamiTracker instruments...** writes one `.fti` per channel, **Bitphase diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index fed74055c..4fcef78c6 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -302,12 +302,6 @@ Widget.THEME, "file_wave", ) -TAG_GLOBAL_THEME_FILE_NOT_EXPANDED_DIRECTORY = TagName( - Page.GLOBAL, - Panel.IMPLICIT, - Widget.THEME, - "file_not_expanded_directory", -) TAG_GLOBAL_THEME_INPUT_INVALID = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 412b6da1b..b1dc3405f 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -36,7 +36,6 @@ TAG_GLOBAL_THEME_FAVORITE_CHILD, TAG_GLOBAL_THEME_FILE_LIBRARY, TAG_GLOBAL_THEME_FILE_NO_CONTENT, - TAG_GLOBAL_THEME_FILE_NOT_EXPANDED_DIRECTORY, TAG_GLOBAL_THEME_FILE_RECONSTRUCTION, TAG_GLOBAL_THEME_FILE_WAVE, TAG_GLOBAL_THEME_TREE_WINDOW, @@ -380,7 +379,6 @@ def _append_spec( open_on_double_click: bool = False, should_expand: bool = False, has_favorite_ancestor: bool = False, - is_node_expanded: bool = False, ) -> None: """Resolve a node into a :class:`NodeSpec` and record it for emission. @@ -401,7 +399,6 @@ def _append_spec( theme_tag = self._resolve_node_theme_tag( node, has_favorite_ancestor=has_favorite_ancestor, - is_node_expanded=is_node_expanded, ) stands_open = self._stands_open( node, @@ -1113,13 +1110,11 @@ def _apply_node_theme( node_tag: str, node: TreeNode, has_favorite_ancestor: bool = False, - is_node_expanded: bool = False, ) -> None: FontRegistry.bind_to_item(node_tag, self._resolve_node_name_font(node)) theme_tag = self._resolve_node_theme_tag( node, has_favorite_ancestor=has_favorite_ancestor, - is_node_expanded=is_node_expanded, ) ThemeRegistry.get(theme_tag).bind_to_item(node_tag) @@ -1128,7 +1123,6 @@ def _resolve_node_theme_tag( node: TreeNode, *, has_favorite_ancestor: bool = False, - is_node_expanded: bool = False, ) -> str: """Select the theme tag for a node from its type, favorite state, and content. @@ -1146,7 +1140,6 @@ def _resolve_node_theme_tag( return self._resolve_file_theme_tag( node, has_favorite_ancestor=has_favorite_ancestor, - is_not_expanded=is_node_expanded, ) return self._resolve_other_theme_tag(node) @@ -1173,7 +1166,6 @@ def _resolve_file_theme_tag( node: FileSystemNode, *, has_favorite_ancestor: bool = False, - is_not_expanded: bool = False, ) -> str: if self._logic.is_node_favorite(node): return TAG_GLOBAL_THEME_FAVORITE @@ -1188,8 +1180,6 @@ def _resolve_file_theme_tag( case _: if has_favorite_ancestor: return TAG_GLOBAL_THEME_FAVORITE_CHILD - if is_not_expanded: - return TAG_GLOBAL_THEME_FILE_NOT_EXPANDED_DIRECTORY return TAG_GLOBAL_THEME_DEFAULT def _resolve_other_theme_tag(self, node: TreeNode) -> str: diff --git a/src/sampletones_application/utils/gui/dialogs.py b/src/sampletones_application/utils/gui/dialogs.py index 8bd9d58e2..9cc89a16f 100644 --- a/src/sampletones_application/utils/gui/dialogs.py +++ b/src/sampletones_application/utils/gui/dialogs.py @@ -167,11 +167,6 @@ def __init__( self._lbl_cancel = language_manager["global.dialog.label.cancel"] self._lbl_traceback_show = language_manager["global.traceback.label.show"] - @property - def default_wrap(self) -> int: - """Text wrap width matching the default dialog width, for caller-built content.""" - return self._default_wrap - def show_modal( self, tag: str, diff --git a/src/sampletones_config/palettes/dark.yaml b/src/sampletones_config/palettes/dark.yaml index 6d8d9580d..1bf4156f0 100644 --- a/src/sampletones_config/palettes/dark.yaml +++ b/src/sampletones_config/palettes/dark.yaml @@ -110,7 +110,6 @@ colors: file_wave: "#4fa6ff" file_library: "#89d185" file_reconstruction: "#dcdcaa" - file_muted: "#a8a8ae" favorite: "#ffd76e" favorite_child: "#ddd2ac" diff --git a/src/sampletones_config/palettes/light.yaml b/src/sampletones_config/palettes/light.yaml index e72dc7ff9..d8a97bca2 100644 --- a/src/sampletones_config/palettes/light.yaml +++ b/src/sampletones_config/palettes/light.yaml @@ -110,7 +110,6 @@ colors: file_wave: "#0a5aa8" file_library: "#146c2a" file_reconstruction: "#3a3a9c" - file_muted: "#6e7580" favorite: "#8a6000" favorite_child: "#75663c" diff --git a/src/sampletones_config/palettes/studio.yaml b/src/sampletones_config/palettes/studio.yaml index a92a7027f..371f5ee4e 100644 --- a/src/sampletones_config/palettes/studio.yaml +++ b/src/sampletones_config/palettes/studio.yaml @@ -110,7 +110,6 @@ colors: file_wave: "#64c8ff" file_library: "#96ff96" file_reconstruction: "#b4b4ff" - file_muted: "#b4b4b4" favorite: "#ffd76e" favorite_child: "#e7dbb7" diff --git a/src/sampletones_config/theme/nodes/files/not_expanded_directory.yaml b/src/sampletones_config/theme/nodes/files/not_expanded_directory.yaml deleted file mode 100644 index f09d7ae10..000000000 --- a/src/sampletones_config/theme/nodes/files/not_expanded_directory.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: node_file_not_expanded_directory -tag: global.theme.file_not_expanded_directory - -components: - - item_type: TreeNode - entries: - - type: color - key: Text - value: .file_muted diff --git a/src/sampletones_core/structures/tree/tree.py b/src/sampletones_core/structures/tree/tree.py index 143b6e522..a21897bc8 100644 --- a/src/sampletones_core/structures/tree/tree.py +++ b/src/sampletones_core/structures/tree/tree.py @@ -1,4 +1,4 @@ -from typing import Callable, Optional, Sequence, Tuple, Type, TypeVar +from typing import Callable, Optional, Tuple, Type, TypeVar from anytree import PreOrderIter @@ -48,9 +48,3 @@ def find_nodes( ) and predicate(node) ) - - def collect_leaves(self) -> Sequence[TreeNode]: - if not self.root: - return [] - - return [node for node in PreOrderIter(self.root) if node.is_leaf] diff --git a/tests/unit/sampletones_core/structures/tree/test_tree.py b/tests/unit/sampletones_core/structures/tree/test_tree.py index 0ec7dad6a..9ee140822 100644 --- a/tests/unit/sampletones_core/structures/tree/test_tree.py +++ b/tests/unit/sampletones_core/structures/tree/test_tree.py @@ -48,22 +48,6 @@ def test_set_root_replaces_the_shape(self, tree: Tree) -> None: assert tree.get_root() is replacement -class TestTreeCollectLeaves: - def test_returns_empty_for_empty_tree(self) -> None: - assert Tree().collect_leaves() == [] - - def test_singleton_root_is_its_own_leaf(self) -> None: - root = TreeNode("root", NodeType.ROOT) - t = Tree(root=root) - leaves = t.collect_leaves() - assert len(leaves) == 1 - assert leaves[0] is root - - def test_every_leaf_the_shape_holds_is_answered(self, tree: Tree) -> None: - leaf_names = {leaf.name for leaf in tree.collect_leaves()} - assert leaf_names == {"leaf_aa", "leaf_ab", "leaf_ba"} - - class TestTreeFindNodes: @staticmethod def _tree_with_twins() -> Tree: From 2e0c6fca7d814fd02b65b11d3bed95bc785b6c73 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 13:15:42 +0200 Subject: [PATCH 145/152] Changed: auto-expand favorites acting on the reader's switch alone --- docs/development/browser.md | 24 ++++-- docs/guide/interface.md | 3 +- src/sampletones_application/application.py | 12 +-- .../coordinators/tabs/reconstruction.py | 4 - .../coordinators/tabs/sequencer.py | 4 - .../ui/elements/tree/tree.py | 43 +++++++--- tests/suite/browser.py | 38 ++++++++- .../ui/elements/tree/test_expansion_memory.py | 69 +++++----------- .../ui/elements/tree/test_favorites_filter.py | 79 +++++++++++-------- 9 files changed, 153 insertions(+), 123 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index a31c0669a..ada186997 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -37,7 +37,8 @@ complements `docs/development/architecture.md` (layering and ownership) and 8. **The reader's shape is theirs to keep.** Which rows stand open is what the reader made of the tree, so a browser records it and brings it back: a refresh, a change of filter and a repaint leave the tree standing as it was, and so does the next run of the application. What a filter unfolds on - top of that shape is the reader's to ask for. + top of that shape is the reader's to ask for, and it is drawn for as long as the filter names the + row rather than recorded. --- @@ -189,8 +190,15 @@ is a preference stated per kind of favorite, held in `ApplicationConfig.browser` **View ▸ Auto-expand favorites**. A starred reconstruction reads the reconstructions answer; a starred folder, and everything it brings in where no row stands for it, reads the directories answer. Both are off by default, so turning the mode on narrows the tree and leaves every row standing as it was. The -panel reads the pair through `TreeLogicProtocol`, once per resolution, and a change of preference asks -each reconstruction browser for a redraw. +panel reads the pair through `TreeLogicProtocol`, once per resolution. + +**The way down opens on the pass the reader asked for.** Switching the mode on is the reader asking to +be shown their favorites, so the pass that switch starts is the one that opens the way down to them: +`_state_favorites_only` records the request and `_resolve_filter` spends it. A pass after that — a +refresh, a query, a star gained or lost — draws the rows standing where the reader has them, and +switching the mode off asks for nothing to be opened. A change of preference asks for nothing either; +it is answered the next time the reader asks for the mode, which keeps a menu click from moving the +tree the reader is working in. **A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so @@ -199,10 +207,12 @@ declining a row declines its subtree, and one decision covers it while the trave **The shape the reader built is theirs to keep.** A browser holding `_REMEMBERS_EXPANSION` records the rows standing open, by the tag those rows are addressed under, and a later pass creates them open again: the filter adds the way down to what it names, and everything else comes back as it was left. -A row is recorded as it is collected, so what the filter unfolded is part of that shape too; a click -is read a frame later, once the row has answered it, and the expansion items record what they set. The -memory is held to the rows the model states, read afresh on every pass, so a row a moved -reconstructions directory left behind leaves the memory with it. +What the reader did is what is recorded — a click, read a frame later once the row has answered it, and +the expansion items and the collapse control, which record what they set. A row the filter opened is +drawn open on top of that shape and folds back once the filter stops naming it, so a narrowed browser +hands the tree back the way the reader had it. The memory is held to the rows the model states, read +afresh on every pass, so a row a moved reconstructions directory left behind leaves the memory with +it. The shape outlives the run as well. A browser is handed the rows it stands open as it is built (`initial_expanded_rows`), and `_persist_application_state` asks each tab for its shape and writes it to diff --git a/docs/guide/interface.md b/docs/guide/interface.md index 7fe790caa..adc8ca927 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -54,7 +54,8 @@ favorites and everything inside them. The browser keeps the folders you had open while it narrows, so switching the tick on and off leaves the tree as you left it. If you would rather it opened its way down to each favorite for you, turn that on under **View ▸ Auto-expand favorites**, which answers for reconstructions and for -folders separately. +folders separately. It opens the way down each time you tick **Favorites only**, +and the rows it opened fold back as soon as you untick it. **Collapse all**, beside the refresh button, folds the whole tree away in one click. Whatever you leave open is remembered, so the tree comes back the way you diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 868523b7b..4ea29c0ff 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -817,23 +817,13 @@ def _toggle_auto_expand_favorite_reconstructions(self) -> None: self.session_manager.set_auto_expand_favorite_reconstructions( not self.session_manager.auto_expand_favorite_reconstructions ) - self._redraw_browsers() + self._update_menu() def _toggle_auto_expand_favorite_directories(self) -> None: self.session_manager.set_auto_expand_favorite_directories( not self.session_manager.auto_expand_favorite_directories ) - self._redraw_browsers() - - def _redraw_browsers(self) -> None: - """Marks the choice in the menu and draws both browsers again from the model each holds. - - What the favorites mode opens is decided as a rebuild collects the rows, so a change of the - preference is answered by collecting them again rather than by reaching into the tree. - """ self._update_menu() - self._reconstructions_tab.redraw_browser() - self._sequencer_tab.redraw_browser() def _reconstruct_file_dialog(self) -> None: if self._is_operation_active(): diff --git a/src/sampletones_application/coordinators/tabs/reconstruction.py b/src/sampletones_application/coordinators/tabs/reconstruction.py index 9846cc554..53a77dbca 100644 --- a/src/sampletones_application/coordinators/tabs/reconstruction.py +++ b/src/sampletones_application/coordinators/tabs/reconstruction.py @@ -565,10 +565,6 @@ def save_browser_shape(self) -> None: self._browser_panel.expanded_rows, ) - def redraw_browser(self) -> None: - """Draws the browser again from the model it holds, which a change of filter asks for.""" - self._browser_panel.redraw_tree() - def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: self._browser_panel.update_favorite_indicators(nodes) diff --git a/src/sampletones_application/coordinators/tabs/sequencer.py b/src/sampletones_application/coordinators/tabs/sequencer.py index faa630e09..5d16f7c12 100644 --- a/src/sampletones_application/coordinators/tabs/sequencer.py +++ b/src/sampletones_application/coordinators/tabs/sequencer.py @@ -964,10 +964,6 @@ def save_browser_shape(self) -> None: self._sequencer_browser_panel.expanded_rows, ) - def redraw_browser(self) -> None: - """Draws the browser again from the model it holds, which a change of filter asks for.""" - self._sequencer_browser_panel.redraw_tree() - def repaint_browser_favorites(self, nodes: Sequence[FileSystemNode]) -> None: self._sequencer_browser_panel.update_favorite_indicators(nodes) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index b1dc3405f..63765789f 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -149,6 +149,7 @@ def __init__( self._search_visibility: Optional[TreeVisibility] = None self._favorites_visibility: Optional[TreeVisibility] = None self._favorites_anchors: Optional[TreeVisibility] = None + self._auto_expand_pending: bool = False self._selected_node_tag: Optional[Union[str, int]] = None self._search_input_tag: Optional[str] = None @@ -332,7 +333,7 @@ def _on_favorites_only_changed( The rebuild resolves the filter against the model as it collects the rows, so the mode is stated here and answered there, and turning it on walks the model once. """ - self._filter = self._filter.with_favorites_only(favorites_only) + self._state_favorites_only(favorites_only) self._apply_favorites_glyph_color() self.call( self.on_favorites_filter_changed, @@ -362,8 +363,22 @@ def set_favorites_filter_enabled(self, enabled: bool) -> None: dpg_configure_item(self._favorites_checkbox_tag, enabled=enabled) + def _state_favorites_only(self, favorites_only: bool) -> None: + """Takes the mode the reader switched to, asking the pass it starts to follow the stars. + + Switching the mode on is the reader asking to be shown their favorites, so that pass opens the + way down to them; a pass after it — a refresh, a query, a star gained or lost — draws the rows + standing where the reader has them. Switching the mode off asks for nothing to be opened. + """ + self._filter = self._filter.with_favorites_only(favorites_only) + self._auto_expand_pending = favorites_only + def _restore_favorites_only(self, favorites_only: bool) -> None: - """Takes the mode a session left the browser in, which its first rebuild then draws by.""" + """Takes the mode a session left the browser in, which its first rebuild then draws by. + + The rows a session left standing open come back with it, so the mode a browser opens in points + the reader at their favorites without opening a row. + """ self._filter = self._filter.with_favorites_only(favorites_only) def _get_node_handler_tag(self, node_type: NodeType) -> str: @@ -401,7 +416,6 @@ def _append_spec( has_favorite_ancestor=has_favorite_ancestor, ) stands_open = self._stands_open( - node, node_tag, should_expand=should_expand, ) @@ -423,23 +437,21 @@ def _append_spec( def _stands_open( self, - node: TreeNode, node_tag: str, *, should_expand: bool, ) -> bool: """Whether the row is created standing open: the filter points at it, or the memory holds it. - The shape the reader built is theirs to keep, so a row they opened comes back open and the - filter adds the way down to what it names. Recording the answer here is what carries that - shape into the pass after this one. + The shape the reader built is theirs to keep and theirs alone to change, so the memory answers + with the rows they opened and the filter draws the way down to what it names on top of that. A + row the filter opened therefore folds back once the filter stops naming it, and the tree the + reader comes back to is the one they left. """ if not self._REMEMBERS_EXPANSION: return should_expand - stands_open = should_expand or node_tag in self._expanded_rows - self._set_row_expanded(node_tag, stands_open and bool(node.children)) - return stands_open + return should_expand or node_tag in self._expanded_rows @property def expanded_rows(self) -> Set[str]: @@ -969,6 +981,7 @@ def _resolve_filter(self) -> None: self._favorites_visibility, self._favorites_anchors, ) = self._resolve_favorites() + self._auto_expand_pending = False def _resolve_search_visibility(self) -> Optional[TreeVisibility]: """The rows the search query names, and nothing to narrow by while no query is typed.""" @@ -1008,10 +1021,14 @@ def _auto_expanded_anchors( ) -> List[TreeNode]: """The anchors whose star the reader asked the browser to open the way down to. - Which stars are followed is a preference stated per kind and read once per pass: a starred - reconstruction answers for itself, and a starred folder answers for itself together with the - rows it brings in where no row stands for the folder. + The pass the reader started by switching the mode on is the one that follows a star, so a pass + of its own accord points at nothing. Which stars are followed is a preference stated per kind + and read once per pass: a starred reconstruction answers for itself, and a starred folder + answers for itself together with the rows it brings in where no row stands for the folder. """ + if not self._auto_expand_pending: + return [] + reconstructions = self._logic.auto_expand_favorite_reconstructions directories = self._logic.auto_expand_favorite_directories return [ diff --git a/tests/suite/browser.py b/tests/suite/browser.py index e82be92ad..89139cfe7 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -299,7 +299,8 @@ def build_browser_panel( Resolving the filter reads the model alone, so the panel needs neither widgets nor a search box, and the control stands where a browser that has yet to build one leaves it. The pair of auto-expand answers states which stars the mode opens the way down to, as the reader's preference - does. + does, and the mode is stated the way a session restores it — so the way down opens once a test + asks for the mode through :func:`select_favorites`. """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = panel_tag @@ -318,6 +319,7 @@ def build_browser_panel( panel._favorites_glyph_tag = None panel.on_favorites_filter_changed = None panel._filter = TreeFilter(query=query, favorites_only=favorites_only) + panel._auto_expand_pending = False panel._resolve_filter() return panel @@ -427,6 +429,21 @@ def set_filter( panel._resolve_filter() +def select_favorites(panel: GUITreePanel) -> None: + """Switches the favorites mode on the way the reader's click does, and resolves the pass it starts. + + Asking to be shown the favorites is what asks the browser to follow a star, so a view showing an + opened row is read through this rather than through a mode stated any other way. + """ + panel._state_favorites_only(True) + panel._resolve_filter() + + +def resolve_pass(panel: GUITreePanel) -> None: + """Resolves the filter afresh, which every pass of a rebuild does before it collects the rows.""" + panel._resolve_filter() + + def view( corpus: BrowserCorpus, favorites: Set[Path], @@ -447,3 +464,22 @@ def view( auto_expand_directories=auto_expand_directories, ) ) + + +def view_on_selecting_favorites( + corpus: BrowserCorpus, + favorites: Set[Path], + *, + auto_expand_reconstructions: bool = False, + auto_expand_directories: bool = False, +) -> str: + """The view a browser leaves once the reader switches the favorites mode on.""" + panel = build_browser_panel( + corpus, + favorites, + favorites_only=False, + auto_expand_reconstructions=auto_expand_reconstructions, + auto_expand_directories=auto_expand_directories, + ) + select_favorites(panel) + return render_view(panel) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index 916653720..96002f861 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -13,6 +13,7 @@ nodes_at, render_view, row_named, + select_favorites, set_filter, set_row_expanded, ) @@ -43,53 +44,6 @@ - 44.1 kHz·30 Hz·FFT·γ0·PT - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT """) -WHOLE_TREE_AFTER_THE_MODE: Final[str] = as_view(""" - v By configuration - > 8 kHz·60 Hz·CQT·γ2·P - - sweep - v 44.1 kHz·30 Hz - > CQT·γ0·PTN - - beat - - solo - v FFT·γ0 - > PT - > takes - - alt - - beat - v PTN·#aaaaaaa - > drums - - kick - - snare - - beat - - melody - > PTN·#bbbbbbb - > drums - - kick - - beat - - melody - > archive - > 48 kHz·50 Hz·LogFFT·γ1·TN - - song - - stray - v By sample - v beat - - 44.1 kHz·30 Hz·CQT·γ0·PTN - - 44.1 kHz·30 Hz·FFT·γ0·PT - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - > drums - > kick - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - - snare·44.1 kHz·30 Hz·FFT·γ0·PTN - > melody - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa - - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb - - solo·44.1 kHz·30 Hz·CQT·γ0·PTN - - sweep·8 kHz·60 Hz·CQT·γ2·P - - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT - """) - WHOLE_TREE_WITHOUT_THE_ARCHIVE: Final[str] = as_view(""" > By configuration > 8 kHz·60 Hz·CQT·γ2·P @@ -155,19 +109,32 @@ def test_a_row_the_reader_closed_is_drawn_closed(self, corpus: BrowserCorpus) -> assert render_view(panel) == STARRED_CONFIGURATION - def test_the_rows_the_mode_opened_stand_open_once_it_goes_off(self, corpus: BrowserCorpus) -> None: - """What the browser unfolded to show a favorite is part of the shape the reader is left with.""" + def test_the_rows_the_mode_opened_fold_back_once_it_goes_off(self, corpus: BrowserCorpus) -> None: + """What the browser unfolded to show a favorite is the mode's, so the shape is left untouched.""" panel = build_browser_panel( corpus, {corpus.paths["A/beat"]}, - favorites_only=True, + favorites_only=False, auto_expand_reconstructions=True, ) + select_favorites(panel) render_view(panel) set_filter(panel, favorites_only=False) - assert render_view(panel) == WHOLE_TREE_AFTER_THE_MODE + assert render_view(panel) == WHOLE_TREE + + def test_the_rows_the_mode_opened_are_no_part_of_what_a_save_writes(self, corpus: BrowserCorpus) -> None: + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=False, + auto_expand_reconstructions=True, + ) + select_favorites(panel) + render_view(panel) + + assert panel.expanded_rows == set() def test_a_row_the_mode_never_drew_keeps_the_state_it_had(self, corpus: BrowserCorpus) -> None: panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index 66c0a654a..4e65f87e4 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -15,7 +15,11 @@ as_view, build_browser_panel, nodes_at, + render_view, + resolve_pass, + select_favorites, view, + view_on_selecting_favorites, ) CHECKBOX_TAG: Final[str] = "sequencer.browser.checkbox.favorites" @@ -344,8 +348,8 @@ def rows_of(rendered: str) -> List[str]: class TestDrawnRows: """Which rows the mode draws: what the star reaches, and the rows leading down to it. - What is drawn is the star's to state and nothing else, so every row stands folded here — which is - what a browser opening with the preference off comes back as. + What is drawn is the star's to state and nothing else, so every row stands folded here: the mode + is stated the way a session restores it, and a mode nobody asked for opens no row. """ def test_a_starred_reconstruction_is_drawn_in_both_views(self, corpus: BrowserCorpus) -> None: @@ -385,10 +389,9 @@ def test_the_rows_drawn_are_the_same_whichever_stars_are_followed(self, corpus: """Opening the way down to a star is a separate answer, so it moves no row in or out.""" favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} assert rows_of( - view( + view_on_selecting_favorites( corpus, favorites, - favorites_only=True, auto_expand_reconstructions=True, auto_expand_directories=True, ) @@ -399,14 +402,13 @@ class TestOpenRows: """Which rows stand open: the way down to a star the reader asked the browser to follow.""" def test_the_preference_off_opens_nothing(self, corpus: BrowserCorpus) -> None: - assert view(corpus, {corpus.paths["A/beat"]}, favorites_only=True) == STARRED_RECONSTRUCTION + assert view_on_selecting_favorites(corpus, {corpus.paths["A/beat"]}) == STARRED_RECONSTRUCTION def test_the_rows_above_a_starred_reconstruction_open(self, corpus: BrowserCorpus) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["A/beat"]}, - favorites_only=True, auto_expand_reconstructions=True, ) == STARRED_RECONSTRUCTION_OPENED @@ -417,10 +419,9 @@ def test_the_sample_row_above_a_starred_reconstruction_of_a_lone_audio_opens( corpus: BrowserCorpus, ) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["D/solo"]}, - favorites_only=True, auto_expand_reconstructions=True, ) == STARRED_LONE_AUDIO_OPENED @@ -428,10 +429,9 @@ def test_the_sample_row_above_a_starred_reconstruction_of_a_lone_audio_opens( def test_the_subfolder_above_a_starred_reconstruction_opens(self, corpus: BrowserCorpus) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["A/drums/kick"]}, - favorites_only=True, auto_expand_reconstructions=True, ) == STARRED_IN_SUBFOLDER_OPENED @@ -442,10 +442,9 @@ def test_the_branch_above_a_starred_reconstruction_outside_every_configuration_o corpus: BrowserCorpus, ) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["stray"]}, - favorites_only=True, auto_expand_reconstructions=True, ) == STARRED_STRAY_OPENED @@ -456,10 +455,9 @@ def test_a_starred_folder_is_left_folded_while_reconstructions_alone_are_followe corpus: BrowserCorpus, ) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["A"]}, - favorites_only=True, auto_expand_reconstructions=True, ) == STARRED_OF_TWO_ALIKE @@ -470,10 +468,9 @@ def test_a_starred_reconstruction_is_left_folded_while_directories_alone_are_fol corpus: BrowserCorpus, ) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["A/beat"]}, - favorites_only=True, auto_expand_directories=True, ) == STARRED_RECONSTRUCTION @@ -481,10 +478,9 @@ def test_a_starred_reconstruction_is_left_folded_while_directories_alone_are_fol def test_the_rows_above_a_starred_configuration_open_and_it_stays_folded(self, corpus: BrowserCorpus) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["C"]}, - favorites_only=True, auto_expand_directories=True, ) == STARRED_CONFIGURATION_OPENED @@ -492,10 +488,9 @@ def test_the_rows_above_a_starred_configuration_open_and_it_stays_folded(self, c def test_the_rows_above_a_starred_plain_folder_open_and_it_stays_folded(self, corpus: BrowserCorpus) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["archive"]}, - favorites_only=True, auto_expand_directories=True, ) == STARRED_PLAIN_FOLDER_OPENED @@ -508,10 +503,9 @@ def test_a_starred_folder_holding_a_starred_folder_opens_the_way_down_to_it( """The folder above stands on the way to the star below, which is what opens it.""" favorites = {corpus.paths["archive"], corpus.paths["archive/F"]} assert ( - view( + view_on_selecting_favorites( corpus, favorites, - favorites_only=True, auto_expand_directories=True, ) == STARRED_FOLDER_IN_STARRED_FOLDER_OPENED @@ -522,10 +516,9 @@ def test_a_starred_configuration_whose_chain_folded_keeps_the_folded_row_closed( corpus: BrowserCorpus, ) -> None: assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["E"]}, - favorites_only=True, auto_expand_directories=True, ) == STARRED_FOLDED_CONFIGURATION_OPENED @@ -537,10 +530,9 @@ def test_the_sample_branch_opens_the_way_to_the_variants_a_starred_folder_holds( ) -> None: """No row stands for the folder there, so the variants are where the star arrives.""" assert ( - view( + view_on_selecting_favorites( corpus, {corpus.paths["B"]}, - favorites_only=True, auto_expand_directories=True, ) == STARRED_CONFIGURATION_B_OPENED @@ -550,10 +542,9 @@ def test_a_star_inside_a_starred_folder_opens_that_folder(self, corpus: BrowserC """A reconstruction answers by its own preference, so following those opens the folder above.""" favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} assert ( - view( + view_on_selecting_favorites( corpus, favorites, - favorites_only=True, auto_expand_reconstructions=True, ) == STARRED_FOLDER_HOLDING_A_STAR_OPENED @@ -563,15 +554,41 @@ def test_a_star_inside_a_starred_folder_takes_its_own_preference(self, corpus: B """Following folders alone opens the way to the folder, leaving the star inside it folded away.""" favorites = {corpus.paths["B"], corpus.paths["B/drums/kick"]} assert ( - view( + view_on_selecting_favorites( corpus, favorites, - favorites_only=True, auto_expand_directories=True, ) == STARRED_FOLDER_HOLDING_A_STAR_BY_FOLDER ) + def test_a_mode_a_session_restored_opens_nothing(self, corpus: BrowserCorpus) -> None: + """A browser opens with the rows its reader left standing, whichever stars it would follow.""" + assert ( + view( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=True, + auto_expand_reconstructions=True, + ) + == STARRED_RECONSTRUCTION + ) + + def test_a_pass_after_the_one_the_reader_asked_for_opens_nothing(self, corpus: BrowserCorpus) -> None: + """The way down is opened the once, so a refresh leaves the rows standing as they now are.""" + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=False, + auto_expand_reconstructions=True, + ) + select_favorites(panel) + assert render_view(panel) == STARRED_RECONSTRUCTION_OPENED + + resolve_pass(panel) + + assert render_view(panel) == STARRED_RECONSTRUCTION + class TestSearchInsideTheMode: """The mode states which rows are drawn, and the query states which of them are shown.""" From b37fe9271e5c4dd87ebdbc7dd39641ed5705fe25 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 13:19:26 +0200 Subject: [PATCH 146/152] Changed: the About dialog balancing its mark against the name --- src/sampletones_application/application.py | 2 +- src/sampletones_application/layout/fonts.py | 10 +++++++--- src/sampletones_application/tags/general.py | 6 ++++++ .../ui/elements/fonts/font.py | 1 + .../ui/elements/fonts/registry.py | 2 ++ src/sampletones_config/layout/fonts.yaml | 3 +++ .../layout/general/dialogs.yaml | 2 +- .../layout/test_fonts.py | 20 +++++++++++++++++++ 8 files changed, 41 insertions(+), 5 deletions(-) create mode 100644 tests/unit/sampletones_application/layout/test_fonts.py diff --git a/src/sampletones_application/application.py b/src/sampletones_application/application.py index 4ea29c0ff..ecb423e3c 100644 --- a/src/sampletones_application/application.py +++ b/src/sampletones_application/application.py @@ -1190,7 +1190,7 @@ def content(parent: str) -> None: ) with dpg.group(): name_text = dpg.add_text(SAMPLETONES_NAME_VERSION) - FontRegistry.bind_to_item(name_text, Font.BOLD_LARGE) + FontRegistry.bind_to_item(name_text, Font.BOLD_TITLE) dpg.add_separator() dpg.add_text(description, wrap=about.text_wrap) author_text = dpg.add_text(author_line) diff --git a/src/sampletones_application/layout/fonts.py b/src/sampletones_application/layout/fonts.py index 14c904359..7c5f44108 100644 --- a/src/sampletones_application/layout/fonts.py +++ b/src/sampletones_application/layout/fonts.py @@ -13,27 +13,31 @@ class Step(Enum): SMALL = "small" MEDIUM = "medium" LARGE = "large" + TITLE = "title" class FontScale(BaseModel, extra="forbid", frozen=True): small: int medium: int large: int + title: int def step(self, step: Step) -> int: return { Step.SMALL: self.small, Step.MEDIUM: self.medium, Step.LARGE: self.large, + Step.TITLE: self.title, }[step] class FontsLayout(BaseModel, extra="forbid", frozen=True): """Per-typeface pixel-size scales for every rendered font. - Each typeface carries its own ``small``/``medium``/``large`` scale, so Sans and - Mono are tuned to the same apparent size independently. ``scale`` is the DearPyGui - global font multiplier applied on top. + Each typeface carries its own ``small``/``medium``/``large``/``title`` scale, so Sans + and Mono are tuned to the same apparent size independently, and a rung is drawn at + where a font asks for it. ``scale`` is the DearPyGui global font multiplier applied + on top. """ scale: int diff --git a/src/sampletones_application/tags/general.py b/src/sampletones_application/tags/general.py index 4fcef78c6..05eac7e15 100644 --- a/src/sampletones_application/tags/general.py +++ b/src/sampletones_application/tags/general.py @@ -32,6 +32,12 @@ Widget.FONT, "bold_large", ) +TAG_GLOBAL_FONT_BOLD_TITLE = TagName( + Page.GLOBAL, + Panel.IMPLICIT, + Widget.FONT, + "bold_title", +) TAG_GLOBAL_FONT_ITALIC = TagName( Page.GLOBAL, Panel.IMPLICIT, diff --git a/src/sampletones_application/ui/elements/fonts/font.py b/src/sampletones_application/ui/elements/fonts/font.py index dcb5bc461..319f55784 100644 --- a/src/sampletones_application/ui/elements/fonts/font.py +++ b/src/sampletones_application/ui/elements/fonts/font.py @@ -11,6 +11,7 @@ class Font(Enum): BOLD = "Bold" BOLD_SMALL = "BoldSmall" BOLD_LARGE = "BoldLarge" + BOLD_TITLE = "BoldTitle" MONO = "Mono" MONO_SMALL = "MonoSmall" MONO_BOLD = "MonoBold" diff --git a/src/sampletones_application/ui/elements/fonts/registry.py b/src/sampletones_application/ui/elements/fonts/registry.py index cea0c99f2..837e58b42 100644 --- a/src/sampletones_application/ui/elements/fonts/registry.py +++ b/src/sampletones_application/ui/elements/fonts/registry.py @@ -7,6 +7,7 @@ TAG_GLOBAL_FONT_BOLD, TAG_GLOBAL_FONT_BOLD_LARGE, TAG_GLOBAL_FONT_BOLD_SMALL, + TAG_GLOBAL_FONT_BOLD_TITLE, TAG_GLOBAL_FONT_ICON, TAG_GLOBAL_FONT_ITALIC, TAG_GLOBAL_FONT_ITALIC_LARGE, @@ -38,6 +39,7 @@ class FontRegistry: Font.BOLD: (TAG_GLOBAL_FONT_BOLD, FontResource.BOLD, Typeface.SANS, Step.MEDIUM), Font.BOLD_SMALL: (TAG_GLOBAL_FONT_BOLD_SMALL, FontResource.BOLD, Typeface.SANS, Step.SMALL), Font.BOLD_LARGE: (TAG_GLOBAL_FONT_BOLD_LARGE, FontResource.BOLD, Typeface.SANS, Step.LARGE), + Font.BOLD_TITLE: (TAG_GLOBAL_FONT_BOLD_TITLE, FontResource.BOLD, Typeface.SANS, Step.TITLE), Font.MONO: (TAG_GLOBAL_FONT_MONO, FontResource.MONO, Typeface.MONO, Step.MEDIUM), Font.MONO_SMALL: (TAG_GLOBAL_FONT_MONO_SMALL, FontResource.MONO, Typeface.MONO, Step.SMALL), Font.MONO_BOLD: (TAG_GLOBAL_FONT_MONO_BOLD, FontResource.MONO_BOLD, Typeface.MONO, Step.MEDIUM), diff --git a/src/sampletones_config/layout/fonts.yaml b/src/sampletones_config/layout/fonts.yaml index 38c32e67d..afc296a77 100644 --- a/src/sampletones_config/layout/fonts.yaml +++ b/src/sampletones_config/layout/fonts.yaml @@ -3,11 +3,14 @@ sans: small: 22 medium: 23 large: 25 + title: 34 mono: small: 19 medium: 22 large: 26 + title: 30 icon: small: 20 medium: 27 large: 33 + title: 45 diff --git a/src/sampletones_config/layout/general/dialogs.yaml b/src/sampletones_config/layout/general/dialogs.yaml index 09eab64ba..48e41eaba 100644 --- a/src/sampletones_config/layout/general/dialogs.yaml +++ b/src/sampletones_config/layout/general/dialogs.yaml @@ -17,5 +17,5 @@ traceback: about: width: 480 height: 210 - logo: 72 + logo: 56 padding: 40 diff --git a/tests/unit/sampletones_application/layout/test_fonts.py b/tests/unit/sampletones_application/layout/test_fonts.py new file mode 100644 index 000000000..451768a97 --- /dev/null +++ b/tests/unit/sampletones_application/layout/test_fonts.py @@ -0,0 +1,20 @@ +from typing import Final + +from sampletones_application.layout.fonts import FontScale, FontsLayout, Step, Typeface + +SANS: Final[FontScale] = FontScale(small=11, medium=12, large=13, title=14) +MONO: Final[FontScale] = FontScale(small=21, medium=22, large=23, title=24) +ICON: Final[FontScale] = FontScale(small=31, medium=32, large=33, title=34) + + +class TestTheSizeLadder: + """Every rung a font asks for answers with a size, on the typeface asking for it.""" + + def test_every_rung_answers_with_the_size_the_scale_states(self) -> None: + assert [SANS.step(step) for step in Step] == [11, 12, 13, 14] + + def test_a_typeface_answers_from_a_ladder_of_its_own(self) -> None: + layout = FontsLayout(scale=1, sans=SANS, mono=MONO, icon=ICON) + + assert layout.size_for(Typeface.MONO, Step.TITLE) == 24 + assert layout.size_for(Typeface.SANS, Step.TITLE) == 14 From 7992a516c782ac73346989a922e8e75f4f36f2bd Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 15:50:23 +0200 Subject: [PATCH 147/152] Fixed: the favorites mode folding the way down to rows the reader opened --- docs/development/browser.md | 49 +++++--- .../ui/elements/tree/tree.py | 98 ++++++++++++--- tests/suite/browser.py | 12 +- .../ui/elements/tree/test_expansion_memory.py | 118 +++++++++++++++++- .../ui/elements/tree/test_favorites.py | 2 +- .../ui/elements/tree/test_favorites_filter.py | 24 +++- .../ui/panels/main/test_explorer_controls.py | 2 +- .../shared/test_container_context_menu.py | 2 +- 8 files changed, 261 insertions(+), 46 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index ada186997..de65fdc1e 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -37,8 +37,9 @@ complements `docs/development/architecture.md` (layering and ownership) and 8. **The reader's shape is theirs to keep.** Which rows stand open is what the reader made of the tree, so a browser records it and brings it back: a refresh, a change of filter and a repaint leave the tree standing as it was, and so does the next run of the application. What a filter unfolds on - top of that shape is the reader's to ask for, and it is drawn for as long as the filter names the - row rather than recorded. + top of that shape is remembered as the filter's own, held for as long as the filter is, and handed + back when it goes — apart from a row the reader's own has come to stand on, which stays open so the + view they built stays on the screen. --- @@ -192,27 +193,39 @@ folder, and everything it brings in where no row stands for it, reads the direct off by default, so turning the mode on narrows the tree and leaves every row standing as it was. The panel reads the pair through `TreeLogicProtocol`, once per resolution. -**The way down opens on the pass the reader asked for.** Switching the mode on is the reader asking to -be shown their favorites, so the pass that switch starts is the one that opens the way down to them: -`_state_favorites_only` records the request and `_resolve_filter` spends it. A pass after that — a -refresh, a query, a star gained or lost — draws the rows standing where the reader has them, and -switching the mode off asks for nothing to be opened. A change of preference asks for nothing either; -it is answered the next time the reader asks for the mode, which keeps a menu click from moving the -tree the reader is working in. +**The way down opens on the pass the reader asked for, and stands for as long as the mode does.** +Switching the mode on is the reader asking to be shown their favorites, so the pass that switch starts +is the one that follows a star: `_state_favorites_only` records the request and `_resolve_filter` spends +it, and the rows it opens are noted in the mode's own memory. Later passes read that memory, so a +refresh, a query or a star gained meanwhile leaves the reader looking at their favorites, while the +stars followed stay the ones the switch asked about. Switching the mode off lets the memory go and those +rows fold back. A change of preference asks for nothing; it is answered the next time the reader asks +for the mode, which keeps a menu click from moving the tree the reader is working in. + +**The way down becomes the reader's once their own rows stand on it.** A reader looking at their +favorites opens rows of their own below the way the mode opened, and folding that way would take theirs +off the screen with it. `_release_the_rows_the_mode_opened` therefore reads the model as the mode goes +off and hands the reader every row of the mode's that holds one of theirs somewhere below it, which +writes the way down into the shape a session keeps. What is left held the mode's opening alone, and +folds with it. **A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so declining a row declines its subtree, and one decision covers it while the traversal walks on. -**The shape the reader built is theirs to keep.** A browser holding `_REMEMBERS_EXPANSION` records -the rows standing open, by the tag those rows are addressed under, and a later pass creates them open -again: the filter adds the way down to what it names, and everything else comes back as it was left. -What the reader did is what is recorded — a click, read a frame later once the row has answered it, and -the expansion items and the collapse control, which record what they set. A row the filter opened is -drawn open on top of that shape and folds back once the filter stops naming it, so a narrowed browser -hands the tree back the way the reader had it. The memory is held to the rows the model states, read -afresh on every pass, so a row a moved reconstructions directory left behind leaves the memory with -it. +**Two memories, each holding what one hand opened.** A browser holding `_REMEMBERS_EXPANSION` records +the rows standing open by the tag those rows are addressed under, and a later pass creates them open +again. The reader's memory holds what the reader did — a click, read a frame later once the row has +answered it, and the expansion items and the collapse control, which record what they set — and it is +what a session writes down. The mode's memory holds the way down it opened, and goes when the mode does, +so a narrowed browser hands the tree back the way the reader had it, keeping the rows theirs now stand +on. Folding a row is the reader's word +on it whichever hand opened it, so `_set_row_expanded` releases the mode's claim along with the reader's +and the row stays folded. Both memories are held to the rows the model states, read afresh on every +pass, so a row a moved reconstructions directory left behind leaves them with it. + +A search unfolds by the same rule from the other end: its matches and the rows above them open for as +long as the query stands, resolved afresh on each pass, and clearing the query folds them back. The shape outlives the run as well. A browser is handed the rows it stands open as it is built (`initial_expanded_rows`), and `_persist_application_state` asks each tab for its shape and writes it to diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 63765789f..a6b1887a8 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -142,7 +142,7 @@ def __init__( self.tree_tag = tree_tag self._pending_specs: List[NodeSpec] = [] - self._expanded_rows: Set[str] = set(initial_expanded_rows) + self._state_expansion_memory(initial_expanded_rows) self._emitter = TreeEmitter(scheduling=scheduling) self._filter: TreeFilter = NO_FILTER @@ -249,12 +249,21 @@ def _collect_specs(self, root_tag: str) -> List[NodeSpec]: self._forget_rows_the_model_dropped() return self._pending_specs + def _state_expansion_memory(self, initial_expanded_rows: AbstractSet[str]) -> None: + """Sets up the two memories of open rows: the reader's, and the favorites mode's. + + The reader's opens holding what a session left the browser standing as, and the mode's opening + empty, a mode being asked for afresh in each run. + """ + self._expanded_rows: Set[str] = set(initial_expanded_rows) + self._mode_expanded_rows: Set[str] = set() + def _forget_rows_the_model_dropped(self) -> None: - """Holds the memory of open rows to the rows the model states, read afresh on every pass. + """Holds both memories of open rows to the rows the model states, read afresh on every pass. - A row the memory holds that the model no longer states belongs to a folder the disk has lost, - so its place in the memory goes with it. Reading the model rather than the rows a pass drew is - what lets a browser opening in the favorites mode — or opening on a session written before the + A row a memory holds that the model no longer states belongs to a folder the disk has lost, so + its place goes with it. Reading the model rather than the rows a pass drew is what lets a + browser opening in the favorites mode — or opening on a session written before the reconstructions directory moved — drop what is gone. """ if not self._REMEMBERS_EXPANSION: @@ -264,7 +273,9 @@ def _forget_rows_the_model_dropped(self) -> None: if root is None: return - self._expanded_rows &= {self._generate_node_tag(node) for node in root.descendants if node.children} + rows_the_model_states = {self._generate_node_tag(node) for node in root.descendants if node.children} + self._expanded_rows &= rows_the_model_states + self._mode_expanded_rows &= rows_the_model_states def create_search(self, parent: str) -> None: self._search_input_tag = compose_tag(self.tag, SUF_INPUT_SEARCH) @@ -366,12 +377,47 @@ def set_favorites_filter_enabled(self, enabled: bool) -> None: def _state_favorites_only(self, favorites_only: bool) -> None: """Takes the mode the reader switched to, asking the pass it starts to follow the stars. - Switching the mode on is the reader asking to be shown their favorites, so that pass opens the - way down to them; a pass after it — a refresh, a query, a star gained or lost — draws the rows - standing where the reader has them. Switching the mode off asks for nothing to be opened. + Switching the mode on is the reader asking to be shown their favorites, so the pass it starts + opens the way down to them and notes which rows it opened. That way stands open for as long as + the mode does, so a refresh, a query or a star gained meanwhile leaves the reader looking at + their favorites. Switching the mode off hands those rows back. """ self._filter = self._filter.with_favorites_only(favorites_only) self._auto_expand_pending = favorites_only + self._release_the_rows_the_mode_opened() + + def _release_the_rows_the_mode_opened(self) -> None: + """Folds the rows the mode opened, leaving standing the way down to the reader's own rows. + + A row the reader opened below one the mode opened stands on that row, so the reader is looking + at a tree they built on the way the mode opened. Handing that way back would take their own + rows off the screen with it, and it therefore becomes theirs to keep. What is left held the + mode's opening alone, and folds with it. + """ + if not self._mode_expanded_rows: + return + + self._expanded_rows |= self._rows_the_readers_own_stand_on() + self._mode_expanded_rows = set() + + def _rows_the_readers_own_stand_on(self) -> Set[str]: + """The rows the mode opened that hold a row the reader opened somewhere below them. + + Each row the reader stands open is read off the model together with the way up to it, so a row + between one of theirs and the top of the tree answers however deep theirs stands. + """ + root = self.tree.get_root() + if root is None: + return set() + + ways_down: Set[str] = set() + for node in root.descendants: + if self._generate_node_tag(node) not in self._expanded_rows: + continue + + ways_down |= {self._generate_node_tag(ancestor) for ancestor in node.ancestors} + + return ways_down & self._mode_expanded_rows def _restore_favorites_only(self, favorites_only: bool) -> None: """Takes the mode a session left the browser in, which its first rebuild then draws by. @@ -416,6 +462,7 @@ def _append_spec( has_favorite_ancestor=has_favorite_ancestor, ) stands_open = self._stands_open( + node, node_tag, should_expand=should_expand, ) @@ -437,34 +484,51 @@ def _append_spec( def _stands_open( self, + node: TreeNode, node_tag: str, *, should_expand: bool, ) -> bool: - """Whether the row is created standing open: the filter points at it, or the memory holds it. + """Whether the row is created standing open: a filter points at it, or a memory holds it. - The shape the reader built is theirs to keep and theirs alone to change, so the memory answers - with the rows they opened and the filter draws the way down to what it names on top of that. A - row the filter opened therefore folds back once the filter stops naming it, and the tree the - reader comes back to is the one they left. + Two memories answer, each holding what one hand opened. The reader's holds the rows they opened + themselves and is what a session writes down. The mode's holds the way down it opened on the + pass the reader asked for, which stands for as long as the mode does and folds once it goes, + apart from the rows the reader's own have come to stand on. """ if not self._REMEMBERS_EXPANSION: return should_expand - return should_expand or node_tag in self._expanded_rows + if self._opened_by_the_mode(node): + self._mode_expanded_rows.add(node_tag) + + return should_expand or node_tag in self._expanded_rows or node_tag in self._mode_expanded_rows + + def _opened_by_the_mode(self, node: TreeNode) -> bool: + """Whether the favorites mode is opening the way down through this row on this pass. + + The anchors are resolved from the stars the reader asked to be pointed at, which is the pass + their switch started, so a pass of its own accord opens the way down through nothing. + """ + return self._favorites_anchors is not None and self._favorites_anchors.leads_to(node) @property def expanded_rows(self) -> Set[str]: - """The rows the browser stands open, which is the shape a session writes down.""" + """The rows the reader stands open, which is the shape a session writes down.""" return set(self._expanded_rows) def _set_row_expanded(self, node_tag: str, expanded: bool) -> None: - """Holds whether a row stands open, which is what a later pass brings it back by.""" + """Holds whether a row stands open, which is what a later pass brings it back by. + + A row the reader opens is theirs from then on. A row they fold is theirs to fold whichever hand + opened it, so folding it lets go of the mode's claim on it too and it stays folded. + """ if expanded: self._expanded_rows.add(node_tag) return self._expanded_rows.discard(node_tag) + self._mode_expanded_rows.discard(node_tag) def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: """Folds or unfolds the row together with every row below it holding something. diff --git a/tests/suite/browser.py b/tests/suite/browser.py index 89139cfe7..6d52a4367 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -304,7 +304,7 @@ def build_browser_panel( """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = panel_tag - panel._expanded_rows = set() if expanded_rows is None else set(expanded_rows) + panel._state_expansion_memory(set() if expanded_rows is None else expanded_rows) panel.tree_tag = TREE_TAG panel.tree = corpus.tree panel._logic = FakeTreeLogic( # type: ignore[assignment] @@ -439,6 +439,16 @@ def select_favorites(panel: GUITreePanel) -> None: panel._resolve_filter() +def deselect_favorites(panel: GUITreePanel) -> None: + """Switches the favorites mode off the way the reader's click does, and resolves the pass it starts. + + Switching the mode off is what hands back the rows it opened, so a view showing them folded is read + through this. + """ + panel._state_favorites_only(False) + panel._resolve_filter() + + def resolve_pass(panel: GUITreePanel) -> None: """Resolves the filter afresh, which every pass of a rebuild does before it collects the rows.""" panel._resolve_filter() diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index 96002f861..9604996f4 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -10,11 +10,12 @@ as_view, build_browser_panel, build_corpus, + deselect_favorites, nodes_at, render_view, + resolve_pass, row_named, select_favorites, - set_filter, set_row_expanded, ) @@ -44,6 +45,52 @@ - 44.1 kHz·30 Hz·FFT·γ0·PT - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT """) +THE_WAY_DOWN_TO_THE_READERS_ROW: Final[str] = as_view(""" + v By configuration + > 8 kHz·60 Hz·CQT·γ2·P + - sweep + v 44.1 kHz·30 Hz + > CQT·γ0·PTN + - beat + - solo + v FFT·γ0 + > PT + > takes + - alt + - beat + > PTN·#aaaaaaa + > drums + - kick + - snare + - beat + - melody + v PTN·#bbbbbbb + > drums + - kick + - beat + - melody + > archive + > 48 kHz·50 Hz·LogFFT·γ1·TN + - song + - stray + > By sample + > beat + - 44.1 kHz·30 Hz·CQT·γ0·PTN + - 44.1 kHz·30 Hz·FFT·γ0·PT + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + > drums + > kick + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - snare·44.1 kHz·30 Hz·FFT·γ0·PTN + > melody + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#aaaaaaa + - 44.1 kHz·30 Hz·FFT·γ0·PTN·#bbbbbbb + - solo·44.1 kHz·30 Hz·CQT·γ0·PTN + - sweep·8 kHz·60 Hz·CQT·γ2·P + - takes·alt·44.1 kHz·30 Hz·FFT·γ0·PT + """) WHOLE_TREE_WITHOUT_THE_ARCHIVE: Final[str] = as_view(""" > By configuration > 8 kHz·60 Hz·CQT·γ2·P @@ -120,10 +167,71 @@ def test_the_rows_the_mode_opened_fold_back_once_it_goes_off(self, corpus: Brows select_favorites(panel) render_view(panel) - set_filter(panel, favorites_only=False) + deselect_favorites(panel) assert render_view(panel) == WHOLE_TREE + def test_the_way_down_to_a_row_the_reader_opened_stands_once_the_mode_goes_off( + self, + corpus: BrowserCorpus, + ) -> None: + """A row of the reader's below one the mode opened makes that row part of the view they built. + + Handing back a row the reader's own stands on would take theirs off the screen with it, so the + way down to it stays open while the rows holding nothing of theirs fold. + """ + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=False, + auto_expand_reconstructions=True, + ) + for label in ("By configuration", "44.1 kHz·30 Hz", "FFT·γ0", "PTN·#bbbbbbb"): + set_row_expanded(panel, row_named(corpus, label), expanded=True) + + set_row_expanded(panel, row_named(corpus, "FFT·γ0"), expanded=False) + select_favorites(panel) + render_view(panel) + deselect_favorites(panel) + + assert render_view(panel) == THE_WAY_DOWN_TO_THE_READERS_ROW + + def test_the_way_down_the_mode_hands_over_is_written_down_with_the_readers_rows( + self, + corpus: BrowserCorpus, + ) -> None: + """A row the reader's own came to stand on is theirs from then on, so a session brings it back.""" + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=False, + auto_expand_reconstructions=True, + ) + heading = row_named(corpus, "FFT·γ0") + set_row_expanded(panel, row_named(corpus, "PTN·#bbbbbbb"), expanded=True) + select_favorites(panel) + render_view(panel) + + deselect_favorites(panel) + + assert panel._generate_node_tag(heading) in panel.expanded_rows + + def test_a_row_the_reader_folds_while_the_mode_is_on_stays_folded(self, corpus: BrowserCorpus) -> None: + """A row is the reader's to fold whichever hand opened it, so the mode lets go of its claim.""" + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=False, + auto_expand_reconstructions=True, + ) + select_favorites(panel) + render_view(panel) + + set_row_expanded(panel, row_named(corpus, "FFT·γ0"), expanded=False) + resolve_pass(panel) + + assert "> FFT·γ0" in render_view(panel) + def test_the_rows_the_mode_opened_are_no_part_of_what_a_save_writes(self, corpus: BrowserCorpus) -> None: panel = build_browser_panel( corpus, @@ -141,9 +249,9 @@ def test_a_row_the_mode_never_drew_keeps_the_state_it_had(self, corpus: BrowserC set_row_expanded(panel, row_named(corpus, "archive"), expanded=True) render_view(panel) - set_filter(panel, favorites_only=True) + select_favorites(panel) render_view(panel) - set_filter(panel, favorites_only=False) + deselect_favorites(panel) assert "v archive" in render_view(panel) @@ -200,7 +308,7 @@ def test_a_pass_in_the_favorites_mode_forgets_the_rows_the_model_dropped( render_view(panel) archive.parent = None - set_filter(panel, favorites_only=True) + select_favorites(panel) render_view(panel) assert panel._expanded_rows == set() diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index 476683570..cc6a108e0 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -82,7 +82,7 @@ def build_panel( panel._search_visibility = None panel._favorites_visibility = None panel._favorites_anchors = None - panel._expanded_rows = set() + panel._state_expansion_memory(set()) monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( panel, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index 4e65f87e4..7073cd2b9 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -12,6 +12,7 @@ TREE_COLORS, WHOLE_TREE, BrowserCorpus, + FakeTreeLogic, as_view, build_browser_panel, nodes_at, @@ -574,8 +575,8 @@ def test_a_mode_a_session_restored_opens_nothing(self, corpus: BrowserCorpus) -> == STARRED_RECONSTRUCTION ) - def test_a_pass_after_the_one_the_reader_asked_for_opens_nothing(self, corpus: BrowserCorpus) -> None: - """The way down is opened the once, so a refresh leaves the rows standing as they now are.""" + def test_the_way_down_stands_open_for_as_long_as_the_mode_does(self, corpus: BrowserCorpus) -> None: + """A refresh while the mode is on leaves the reader looking at the way down to their stars.""" panel = build_browser_panel( corpus, {corpus.paths["A/beat"]}, @@ -587,6 +588,25 @@ def test_a_pass_after_the_one_the_reader_asked_for_opens_nothing(self, corpus: B resolve_pass(panel) + assert render_view(panel) == STARRED_RECONSTRUCTION_OPENED + + def test_a_star_gained_while_the_mode_is_on_opens_no_way_of_its_own(self, corpus: BrowserCorpus) -> None: + """The reader asked to be pointed at the stars they had, so a star gained since points nowhere.""" + panel = build_browser_panel( + corpus, + set(), + favorites_only=False, + auto_expand_reconstructions=True, + ) + select_favorites(panel) + panel._logic = FakeTreeLogic( # type: ignore[assignment] + {corpus.paths["A/beat"]}, + auto_expand_reconstructions=True, + auto_expand_directories=False, + ) + + resolve_pass(panel) + assert render_view(panel) == STARRED_RECONSTRUCTION diff --git a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py index a18c1776b..fabf85869 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py @@ -82,7 +82,7 @@ def build_panel(tree: Tree) -> GUIExplorerPanel: panel = GUIExplorerPanel.__new__(GUIExplorerPanel) panel.tag = PANEL_TAG panel.tree = tree - panel._expanded_rows = set() + panel._state_expansion_memory(set()) panel._explorer_logic = FakeExplorerLogic(tree) # type: ignore[assignment] return panel diff --git a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py index 7807c7b60..c083003cd 100644 --- a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py @@ -48,7 +48,7 @@ def _panel() -> GUISequencerBrowserPanel: """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = PANEL_TAG - panel._expanded_rows = set() + panel._state_expansion_memory(set()) panel._language_manager = FakeLanguageManager(TEXTS) panel._colors = TreeColors( favorite=TEXT_COLOR, From 25386b156b4c26b70fb5b8643883fd82608a6b4b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 16:26:32 +0200 Subject: [PATCH 148/152] Refactored: the favorites mode's open rule into one answer per pass --- docs/development/browser.md | 4 +- .../ui/elements/tree/tree.py | 72 +++++++++---------- .../structures/tree/visibility.py | 8 --- .../ui/elements/tree/test_favorites.py | 1 - .../ui/elements/tree/test_filter.py | 2 +- .../structures/tree/test_visibility.py | 22 ------ 6 files changed, 39 insertions(+), 70 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index de65fdc1e..d70197f96 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -182,8 +182,8 @@ folder arrives at. **A criterion is read the way that criterion means.** A search shows what a matching row gathers, so a match opens along with the rows above it (`TreeVisibility.should_expand`). The favorites mode points -the reader at a star, so the rows above it open and the star's own row stands where the reader left it -(`TreeVisibility.leads_to`) — a starred folder is revealed rather than unfolded. A starred +the reader at a star, so what opens is the rows above it (`_way_down_to`, over the anchors' ancestors) +while the star's own row stands where the reader left it — a starred folder is revealed. A starred reconstruction inside a starred folder anchors on its own, which is what opens the folder above it. **Which stars are followed is the reader's.** The mode decides what is drawn; whether it also unfolds diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index a6b1887a8..5281d749d 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -148,7 +148,6 @@ def __init__( self._filter: TreeFilter = NO_FILTER self._search_visibility: Optional[TreeVisibility] = None self._favorites_visibility: Optional[TreeVisibility] = None - self._favorites_anchors: Optional[TreeVisibility] = None self._auto_expand_pending: bool = False self._selected_node_tag: Optional[Union[str, int]] = None @@ -462,7 +461,6 @@ def _append_spec( has_favorite_ancestor=has_favorite_ancestor, ) stands_open = self._stands_open( - node, node_tag, should_expand=should_expand, ) @@ -484,7 +482,6 @@ def _append_spec( def _stands_open( self, - node: TreeNode, node_tag: str, *, should_expand: bool, @@ -493,25 +490,15 @@ def _stands_open( Two memories answer, each holding what one hand opened. The reader's holds the rows they opened themselves and is what a session writes down. The mode's holds the way down it opened on the - pass the reader asked for, which stands for as long as the mode does and folds once it goes, - apart from the rows the reader's own have come to stand on. + pass the reader asked for, noted as that pass resolved the filter, which stands for as long as + the mode does and folds once it goes, apart from the rows the reader's own have come to stand + on. """ if not self._REMEMBERS_EXPANSION: return should_expand - if self._opened_by_the_mode(node): - self._mode_expanded_rows.add(node_tag) - return should_expand or node_tag in self._expanded_rows or node_tag in self._mode_expanded_rows - def _opened_by_the_mode(self, node: TreeNode) -> bool: - """Whether the favorites mode is opening the way down through this row on this pass. - - The anchors are resolved from the stars the reader asked to be pointed at, which is the pass - their switch started, so a pass of its own accord opens the way down through nothing. - """ - return self._favorites_anchors is not None and self._favorites_anchors.leads_to(node) - @property def expanded_rows(self) -> Set[str]: """The rows the reader stands open, which is the shape a session writes down.""" @@ -751,16 +738,13 @@ def _build_tree_node( def _has_relevant_content(self, node: TreeNode) -> bool: ... def _should_expand_node(self, node: TreeNode) -> bool: - """Whether the row is emitted standing open, which a row leading to a named row is. + """Whether the search points at the row, which a match and every row above one is. - A search names the rows whose label matched and shows what each of them gathers, so a folder - it named opens. The favorites mode points the reader at a star and opens the way down to it - alone, which leaves the starred row standing as the reader left it. + A search names the rows whose label matched and shows what each of them gathers, so a folder it + named opens, for as long as the query stands. What the favorites mode opens is its memory to + answer, read as each row is created. """ - if self._search_visibility is not None and self._search_visibility.should_expand(node): - return True - - return self._favorites_anchors is not None and self._favorites_anchors.leads_to(node) + return self._search_visibility is not None and self._search_visibility.should_expand(node) def _create_status_bar_message_function( self, @@ -1037,14 +1021,17 @@ def _set_query(self, query: str) -> None: def _resolve_filter(self) -> None: """Resolve the filter against the model as it stands, which a rebuild does once per pass. - Reading the model rather than the rows lets the resolution run on the rebuild worker, and - keeps a filter typed before a refresh answering for the rows that refresh brings. + The model is what the whole answer is read from, so the resolution runs on the rebuild worker + and a filter stated before a refresh answers for the rows that refresh brings. The way down + the favorites mode opens is read out of the anchors here as well, which notes it the once for + the pass. """ self._search_visibility = self._resolve_search_visibility() ( self._favorites_visibility, - self._favorites_anchors, + way_down, ) = self._resolve_favorites() + self._mode_expanded_rows |= way_down self._auto_expand_pending = False def _resolve_search_visibility(self) -> Optional[TreeVisibility]: @@ -1060,25 +1047,38 @@ def _resolve_search_visibility(self) -> Optional[TreeVisibility]: ) ) - def _resolve_favorites( - self, - ) -> Tuple[Optional[TreeVisibility], Optional[TreeVisibility]]: - """The rows the favorites mode keeps, and the rows it opens the way down to. + def _resolve_favorites(self) -> Tuple[Optional[TreeVisibility], Set[str]]: + """The rows the favorites mode keeps, and the rows it opens the way down through. The two answer different questions — which rows the browser draws, and which of them stand - open — so each is resolved from a set of its own, the second being a part of the first. One - walk of the model finds the rows the star reaches, and the anchors are read out of that - answer, so a corpus of any size resolves into a walk and a pair of sets. + open — so each is read out of one walk of the model: the rows a star reaches state what is + drawn, and the rows above the anchors among them state the way down. A corpus of any size + therefore resolves into a walk and a pair of sets. """ if not self._filter.favorites_only: - return None, None + return None, set() reached = self.tree.find_nodes(TreeNode, self._is_node_starred) return ( resolve_visibility(reached), - resolve_visibility(self._auto_expanded_anchors(reached)), + self._way_down_to(self._auto_expanded_anchors(reached)), ) + def _way_down_to(self, anchors: Sequence[TreeNode]) -> Set[str]: + """The tags of the rows standing above the anchors, which is the way down the mode opens. + + An anchor is a row the reader asked to be pointed at, so what opens is the rows above it while + the anchor's own row stands as the reader left it. The container both branches hang from is a + row on no screen, and stays out of the answer. + """ + root = self.tree.get_root() + return { + self._generate_node_tag(ancestor) + for anchor in anchors + for ancestor in anchor.ancestors + if ancestor is not root + } + def _auto_expanded_anchors( self, reached: Sequence[TreeNode], diff --git a/src/sampletones_core/structures/tree/visibility.py b/src/sampletones_core/structures/tree/visibility.py index 815de44cf..7c9fbbc1a 100644 --- a/src/sampletones_core/structures/tree/visibility.py +++ b/src/sampletones_core/structures/tree/visibility.py @@ -28,14 +28,6 @@ def should_expand(self, node: TreeNode) -> bool: """Whether the row stands open, which a named row does and so does every row above one.""" return node in self.matches or node in self.ancestors - def leads_to(self, node: TreeNode) -> bool: - """Whether the row stands on the way down to a named row, being none of the named rows itself. - - Answers the reader who is pointed at what was named rather than at what it holds, so opening - by this leaves a named row standing as it was while the rows above it show where it sits. - """ - return node in self.ancestors - def resolve_visibility(matches: Iterable[TreeNode]) -> TreeVisibility: """The visibility a set of named rows resolves to, read once per pass over the tree. diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index cc6a108e0..7b5cd4091 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -81,7 +81,6 @@ def build_panel( panel._filter = NO_FILTER panel._search_visibility = None panel._favorites_visibility = None - panel._favorites_anchors = None panel._state_expansion_memory(set()) monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py index d538bc63e..2bc23730d 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py @@ -51,7 +51,7 @@ def build_panel( panel._filter = NO_FILTER panel._search_visibility = None panel._favorites_visibility = None - panel._favorites_anchors = None + panel._state_expansion_memory(set()) return panel diff --git a/tests/unit/sampletones_core/structures/tree/test_visibility.py b/tests/unit/sampletones_core/structures/tree/test_visibility.py index 4f3ec4d6e..abd278307 100644 --- a/tests/unit/sampletones_core/structures/tree/test_visibility.py +++ b/tests/unit/sampletones_core/structures/tree/test_visibility.py @@ -107,28 +107,6 @@ def test_nothing_named_leaves_every_row_folded(self, nodes: Dict[str, TreeNode]) assert not any(visibility.should_expand(node) for node in nodes.values()) -class TestTheWayDownToARow: - """What ``leads_to`` answers: the rows above a named row, and none of the named rows.""" - - def test_every_row_above_a_match_leads_to_it(self, nodes: Dict[str, TreeNode]) -> None: - visibility = visibility_of(nodes, ["leaf_ba"]) - names = {name for name, node in nodes.items() if visibility.leads_to(node)} - assert names == {"root", "child_b"} - - def test_a_match_leads_to_nothing_of_its_own(self, nodes: Dict[str, TreeNode]) -> None: - """The reader is pointed at the match, so opening by this leaves it standing as it was.""" - visibility = visibility_of(nodes, ["child_a"]) - assert not visibility.leads_to(nodes["child_a"]) - - def test_a_match_above_another_leads_to_the_one_below_it(self, nodes: Dict[str, TreeNode]) -> None: - visibility = visibility_of(nodes, ["child_a", "leaf_aa"]) - assert visibility.leads_to(nodes["child_a"]) - - def test_nothing_named_leads_nowhere(self, nodes: Dict[str, TreeNode]) -> None: - visibility = visibility_of(nodes, []) - assert not any(visibility.leads_to(node) for node in nodes.values()) - - class TestResolvedSets: def test_the_named_rows_are_held_as_they_were_given(self, nodes: Dict[str, TreeNode]) -> None: visibility = visibility_of(nodes, ["leaf_aa", "leaf_ab"]) From 4f78db466175d4110ba99bbee09828ef2213be29 Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 16:52:04 +0200 Subject: [PATCH 149/152] Refactored: the favorites mode's release --- docs/development/browser.md | 17 ++++++------ .../ui/elements/tree/tree.py | 23 +++++++++------- tests/suite/browser.py | 13 +++++++-- .../ui/elements/tree/test_expansion_memory.py | 27 +++++++++++++++++++ 4 files changed, 61 insertions(+), 19 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index d70197f96..899c3f53b 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -198,16 +198,17 @@ Switching the mode on is the reader asking to be shown their favorites, so the p is the one that follows a star: `_state_favorites_only` records the request and `_resolve_filter` spends it, and the rows it opens are noted in the mode's own memory. Later passes read that memory, so a refresh, a query or a star gained meanwhile leaves the reader looking at their favorites, while the -stars followed stay the ones the switch asked about. Switching the mode off lets the memory go and those -rows fold back. A change of preference asks for nothing; it is answered the next time the reader asks -for the mode, which keeps a menu click from moving the tree the reader is working in. +stars followed stay the ones the switch asked about. Every turn of the mode is a pass's to answer: the +pass that reads the mode off lets the memory go and those rows fold back. A change of preference asks +for nothing; it is answered the next time the reader asks for the mode, which keeps a menu click from +moving the tree the reader is working in. **The way down becomes the reader's once their own rows stand on it.** A reader looking at their -favorites opens rows of their own below the way the mode opened, and folding that way would take theirs -off the screen with it. `_release_the_rows_the_mode_opened` therefore reads the model as the mode goes -off and hands the reader every row of the mode's that holds one of theirs somewhere below it, which -writes the way down into the shape a session keeps. What is left held the mode's opening alone, and -folds with it. +favorites opens rows of their own below the way the mode opened, so a row of the mode's holds theirs on +the screen. `_release_the_rows_the_mode_opened` therefore reads the model on the pass that finds the +mode off — on the tree worker, beside the other walks a pass makes — and hands the reader every row of +the mode's that holds one of theirs somewhere below it, which writes the way down into the shape a +session keeps. What is left held the mode's opening alone, and folds with it. **A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 5281d749d..70c5e9972 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -379,19 +379,19 @@ def _state_favorites_only(self, favorites_only: bool) -> None: Switching the mode on is the reader asking to be shown their favorites, so the pass it starts opens the way down to them and notes which rows it opened. That way stands open for as long as the mode does, so a refresh, a query or a star gained meanwhile leaves the reader looking at - their favorites. Switching the mode off hands those rows back. + their favorites. Switching the mode off is answered by the pass that follows it as well, which + hands those rows back. """ self._filter = self._filter.with_favorites_only(favorites_only) self._auto_expand_pending = favorites_only - self._release_the_rows_the_mode_opened() def _release_the_rows_the_mode_opened(self) -> None: """Folds the rows the mode opened, leaving standing the way down to the reader's own rows. A row the reader opened below one the mode opened stands on that row, so the reader is looking - at a tree they built on the way the mode opened. Handing that way back would take their own - rows off the screen with it, and it therefore becomes theirs to keep. What is left held the - mode's opening alone, and folds with it. + at a tree they built on the way the mode opened. That row holds their own on the screen, and + it therefore becomes theirs to keep. What is left held the mode's opening alone, and folds with + it. The rows are read off the model, so a pass on the tree worker is what runs this. """ if not self._mode_expanded_rows: return @@ -1022,16 +1022,21 @@ def _resolve_filter(self) -> None: """Resolve the filter against the model as it stands, which a rebuild does once per pass. The model is what the whole answer is read from, so the resolution runs on the rebuild worker - and a filter stated before a refresh answers for the rows that refresh brings. The way down - the favorites mode opens is read out of the anchors here as well, which notes it the once for - the pass. + and a filter stated before a refresh answers for the rows that refresh brings. Both of the + favorites mode's turns are answered here, each by the pass that follows it: the pass the reader + asked for notes the way down it opens, and the pass that reads the mode off hands those rows + back. """ self._search_visibility = self._resolve_search_visibility() ( self._favorites_visibility, way_down, ) = self._resolve_favorites() - self._mode_expanded_rows |= way_down + if self._filter.favorites_only: + self._mode_expanded_rows |= way_down + else: + self._release_the_rows_the_mode_opened() + self._auto_expand_pending = False def _resolve_search_visibility(self) -> Optional[TreeVisibility]: diff --git a/tests/suite/browser.py b/tests/suite/browser.py index 6d52a4367..afeaafdb6 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -442,13 +442,22 @@ def select_favorites(panel: GUITreePanel) -> None: def deselect_favorites(panel: GUITreePanel) -> None: """Switches the favorites mode off the way the reader's click does, and resolves the pass it starts. - Switching the mode off is what hands back the rows it opened, so a view showing them folded is read - through this. + The pass that reads the mode off is what hands back the rows it opened, so a view showing them + folded is read through this. """ panel._state_favorites_only(False) panel._resolve_filter() +def click_favorites(panel: GUITreePanel, *, favorites_only: bool) -> None: + """States the mode the reader's click leaves the control reading, with no pass following it. + + A rebuild the tree is locked against starts nothing, so the click stands as a request and the pass + that runs next is what answers it. + """ + panel._state_favorites_only(favorites_only) + + def resolve_pass(panel: GUITreePanel) -> None: """Resolves the filter afresh, which every pass of a rebuild does before it collects the rows.""" panel._resolve_filter() diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index 9604996f4..e026f93e6 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -10,6 +10,7 @@ as_view, build_browser_panel, build_corpus, + click_favorites, deselect_favorites, nodes_at, render_view, @@ -171,6 +172,32 @@ def test_the_rows_the_mode_opened_fold_back_once_it_goes_off(self, corpus: Brows assert render_view(panel) == WHOLE_TREE + def test_the_pass_that_follows_the_click_is_what_hands_the_modes_rows_back( + self, + corpus: BrowserCorpus, + ) -> None: + """The rows the mode opened are a pass's to hand back, which a click alone leaves standing. + + A click landing while the tree is locked starts no pass, so the rows stand as the mode left + them and whichever pass runs next folds them. + """ + panel = build_browser_panel( + corpus, + {corpus.paths["A/beat"]}, + favorites_only=False, + auto_expand_reconstructions=True, + ) + select_favorites(panel) + view_the_mode_left = render_view(panel) + + click_favorites(panel, favorites_only=False) + + assert render_view(panel) == view_the_mode_left + + resolve_pass(panel) + + assert render_view(panel) == WHOLE_TREE + def test_the_way_down_to_a_row_the_reader_opened_stands_once_the_mode_goes_off( self, corpus: BrowserCorpus, From 6c8aa855c9348ff68f52d712aefc2ffd40ffb76b Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 17:21:07 +0200 Subject: [PATCH 150/152] Refactored: the browser's open rows memory --- docs/development/browser.md | 36 ++--- .../ui/elements/tree/expansion.py | 75 ++++++++++ .../ui/elements/tree/tree.py | 135 +++++------------- tests/suite/browser.py | 5 +- .../ui/elements/tree/test_collapse_all.py | 2 +- .../ui/elements/tree/test_expansion.py | 116 +++++++++++++++ .../ui/elements/tree/test_expansion_memory.py | 8 +- .../ui/elements/tree/test_favorites.py | 3 +- .../ui/elements/tree/test_filter.py | 3 +- .../ui/panels/main/test_explorer_controls.py | 3 +- .../shared/test_container_context_menu.py | 17 ++- 11 files changed, 266 insertions(+), 137 deletions(-) create mode 100644 src/sampletones_application/ui/elements/tree/expansion.py create mode 100644 tests/unit/sampletones_application/ui/elements/tree/test_expansion.py diff --git a/docs/development/browser.md b/docs/development/browser.md index 899c3f53b..4c77c84c6 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -110,9 +110,9 @@ one row from the next. The browsers form one line of inheritance, each level owning what it shares: * `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the filter - they compose, the shape it holds across rebuilds, the rebuild handshake, spec collection, themes and - fonts per row, the detail tooltip, the status-bar messages, and the context-menu items every browser - can offer. + they compose, the shape it holds across rebuilds — kept for it by `RowExpansionMemory` + (`ui/elements/tree/expansion.py`) — the rebuild handshake, spec collection, themes and fonts per row, + the detail tooltip, the status-bar messages, and the context-menu items every browser can offer. * `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the controls bringing the tree up to date and folding it away, the tree window, the folder-and-file handler pair, and enabling the card as the tree locks and unlocks. A subclass declares its widgets as @@ -205,25 +205,27 @@ moving the tree the reader is working in. **The way down becomes the reader's once their own rows stand on it.** A reader looking at their favorites opens rows of their own below the way the mode opened, so a row of the mode's holds theirs on -the screen. `_release_the_rows_the_mode_opened` therefore reads the model on the pass that finds the -mode off — on the tree worker, beside the other walks a pass makes — and hands the reader every row of -the mode's that holds one of theirs somewhere below it, which writes the way down into the shape a -session keeps. What is left held the mode's opening alone, and folds with it. +the screen. `_release_mode_rows` therefore reads the model on the pass that finds the mode off — on the +tree worker, beside the other walks a pass makes — and hands the memory the ways down to the reader's +rows; `RowExpansionMemory.release` keeps the rows of the mode's among them, which writes the way down +into the shape a session keeps. What is left held the mode's opening alone, and folds with it. **A row the favorites mode holds back holds nothing it would show.** A row it shows either stands on the way to a starred row or sits beneath one, and each of those facts holds for every row above it — so declining a row declines its subtree, and one decision covers it while the traversal walks on. -**Two memories, each holding what one hand opened.** A browser holding `_REMEMBERS_EXPANSION` records -the rows standing open by the tag those rows are addressed under, and a later pass creates them open -again. The reader's memory holds what the reader did — a click, read a frame later once the row has -answered it, and the expansion items and the collapse control, which record what they set — and it is -what a session writes down. The mode's memory holds the way down it opened, and goes when the mode does, -so a narrowed browser hands the tree back the way the reader had it, keeping the rows theirs now stand -on. Folding a row is the reader's word -on it whichever hand opened it, so `_set_row_expanded` releases the mode's claim along with the reader's -and the row stays folded. Both memories are held to the rows the model states, read afresh on every -pass, so a row a moved reconstructions directory left behind leaves them with it. +**Two memories, each holding what one hand opened.** `RowExpansionMemory` owns both and the rules that +join them, holding each row by the tag it is addressed under so a later pass creates it open again. The +reader's rows hold what the reader did — a click, read a frame later once the row has answered it, and +the expansion items and the collapse control, which record what they set — and they are what a session +writes down. The mode's rows hold the way down it opened, and go when the mode does, so a narrowed +browser hands the tree back the way the reader had it, keeping the rows theirs now stand on. Folding a +row is the reader's word on it whichever hand opened it, so `remember` releases the mode's claim along +with the reader's and the row stays folded. A pass writes from the tree worker while a click writes from +the main thread, so one lock covers every answer the memory gives. Both sets are held to the rows the +model states, read afresh on every pass, so a row a moved reconstructions directory left behind leaves +them with it. Which browsers record a shape at all is `_REMEMBERS_EXPANSION`: it decides whether a click +is followed through to the memory, and a browser that keeps none leaves it empty. A search unfolds by the same rule from the other end: its matches and the rows above them open for as long as the query stands, resolved afresh on each pass, and clearing the query folds them back. diff --git a/src/sampletones_application/ui/elements/tree/expansion.py b/src/sampletones_application/ui/elements/tree/expansion.py new file mode 100644 index 000000000..3143afc04 --- /dev/null +++ b/src/sampletones_application/ui/elements/tree/expansion.py @@ -0,0 +1,75 @@ +from threading import RLock +from typing import AbstractSet, Set + + +class RowExpansionMemory: + """The rows a browser stands open, held apart by the hand that opened them. + + The reader's rows are the ones they opened themselves, and they are the shape a session writes down. + The mode's rows are the way down the favorites mode opened on the pass the reader asked for, which + stands for as long as the mode does. Each row is held by the tag it is addressed under, a pass + replacing every node the model states. + + A pass writes from the tree worker while a click writes from the main thread, so one lock covers + every answer the memory gives. + """ + + def __init__(self, reader_rows: AbstractSet[str]) -> None: + self._lock = RLock() + self._reader_rows: Set[str] = set(reader_rows) + self._mode_rows: Set[str] = set() + + def __bool__(self) -> bool: + with self._lock: + return bool(self._reader_rows or self._mode_rows) + + @property + def rows(self) -> Set[str]: + """The rows the reader stands open, which is the shape a session writes down.""" + with self._lock: + return set(self._reader_rows) + + @property + def follows_the_mode(self) -> bool: + """Whether the mode's way down stands open, which is what a release has rows to answer for.""" + with self._lock: + return bool(self._mode_rows) + + def stands_open(self, node_tag: str) -> bool: + with self._lock: + return node_tag in self._reader_rows or node_tag in self._mode_rows + + def remember(self, node_tag: str, *, expanded: bool) -> None: + """Holds what the reader left a row standing as, which is theirs from then on. + + A row they fold is theirs to fold whichever hand opened it, so folding it lets go of the mode's + claim on it as well and the row stays folded. + """ + with self._lock: + if expanded: + self._reader_rows.add(node_tag) + return + + self._reader_rows.discard(node_tag) + self._mode_rows.discard(node_tag) + + def follow(self, way_down: AbstractSet[str]) -> None: + """Notes the way down a pass opened, which stands open for as long as the mode does.""" + with self._lock: + self._mode_rows |= way_down + + def release(self, ways_down: AbstractSet[str]) -> None: + """Folds the rows the mode opened, keeping the ones a row of the reader's stands on. + + A row of the mode's holding one of theirs below it holds theirs on the screen, and it therefore + becomes the reader's to keep. What is left held the mode's opening alone, and folds with it. + """ + with self._lock: + self._reader_rows |= self._mode_rows & ways_down + self._mode_rows = set() + + def hold_to(self, rows: AbstractSet[str]) -> None: + """Holds both memories to the rows given, a row held beyond them having left the model.""" + with self._lock: + self._reader_rows &= rows + self._mode_rows &= rows diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 70c5e9972..f4c44f5a4 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -57,6 +57,7 @@ from sampletones_application.ui.elements.status import GUIStatusBar from sampletones_application.ui.elements.tree.colors import TreeColors from sampletones_application.ui.elements.tree.emitter import TreeEmitter +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory from sampletones_application.ui.elements.tree.filter import NO_FILTER, TreeFilter from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol @@ -142,7 +143,7 @@ def __init__( self.tree_tag = tree_tag self._pending_specs: List[NodeSpec] = [] - self._state_expansion_memory(initial_expanded_rows) + self._expansion = RowExpansionMemory(initial_expanded_rows) self._emitter = TreeEmitter(scheduling=scheduling) self._filter: TreeFilter = NO_FILTER @@ -248,33 +249,19 @@ def _collect_specs(self, root_tag: str) -> List[NodeSpec]: self._forget_rows_the_model_dropped() return self._pending_specs - def _state_expansion_memory(self, initial_expanded_rows: AbstractSet[str]) -> None: - """Sets up the two memories of open rows: the reader's, and the favorites mode's. - - The reader's opens holding what a session left the browser standing as, and the mode's opening - empty, a mode being asked for afresh in each run. - """ - self._expanded_rows: Set[str] = set(initial_expanded_rows) - self._mode_expanded_rows: Set[str] = set() - def _forget_rows_the_model_dropped(self) -> None: - """Holds both memories of open rows to the rows the model states, read afresh on every pass. + """Holds the memory of open rows to the rows the model states, read afresh on every pass. - A row a memory holds that the model no longer states belongs to a folder the disk has lost, so - its place goes with it. Reading the model rather than the rows a pass drew is what lets a - browser opening in the favorites mode — or opening on a session written before the - reconstructions directory moved — drop what is gone. + A row the memory holds that the model no longer states belongs to a folder the disk has lost, so + its place goes with it. The model is what the answer is read from, so a browser opening in the + favorites mode — or opening on a session written before the reconstructions directory moved — + drops what is gone. """ - if not self._REMEMBERS_EXPANSION: - return - root = self.tree.get_root() - if root is None: + if root is None or not self._expansion: return - rows_the_model_states = {self._generate_node_tag(node) for node in root.descendants if node.children} - self._expanded_rows &= rows_the_model_states - self._mode_expanded_rows &= rows_the_model_states + self._expansion.hold_to({self._generate_node_tag(node) for node in root.descendants if node.children}) def create_search(self, parent: str) -> None: self._search_input_tag = compose_tag(self.tag, SUF_INPUT_SEARCH) @@ -385,38 +372,21 @@ def _state_favorites_only(self, favorites_only: bool) -> None: self._filter = self._filter.with_favorites_only(favorites_only) self._auto_expand_pending = favorites_only - def _release_the_rows_the_mode_opened(self) -> None: - """Folds the rows the mode opened, leaving standing the way down to the reader's own rows. + def _release_mode_rows(self) -> None: + """Hands back the rows the favorites mode opened, which the memory holds the rule for. - A row the reader opened below one the mode opened stands on that row, so the reader is looking - at a tree they built on the way the mode opened. That row holds their own on the screen, and - it therefore becomes theirs to keep. What is left held the mode's opening alone, and folds with - it. The rows are read off the model, so a pass on the tree worker is what runs this. - """ - if not self._mode_expanded_rows: - return - - self._expanded_rows |= self._rows_the_readers_own_stand_on() - self._mode_expanded_rows = set() - - def _rows_the_readers_own_stand_on(self) -> Set[str]: - """The rows the mode opened that hold a row the reader opened somewhere below them. - - Each row the reader stands open is read off the model together with the way up to it, so a row - between one of theirs and the top of the tree answers however deep theirs stands. + The rows the reader stands open are read off the model, and the way down to each of them is what + the mode keeps standing however deep theirs stands. The model is read on the pass that finds the + mode off, on the tree worker. """ root = self.tree.get_root() - if root is None: - return set() - - ways_down: Set[str] = set() - for node in root.descendants: - if self._generate_node_tag(node) not in self._expanded_rows: - continue - - ways_down |= {self._generate_node_tag(ancestor) for ancestor in node.ancestors} + if root is None or not self._expansion.follows_the_mode: + return - return ways_down & self._mode_expanded_rows + reader_rows = self._expansion.rows + self._expansion.release( + self._way_down_to([node for node in root.descendants if self._generate_node_tag(node) in reader_rows]), + ) def _restore_favorites_only(self, favorites_only: bool) -> None: """Takes the mode a session left the browser in, which its first rebuild then draws by. @@ -460,10 +430,7 @@ def _append_spec( node, has_favorite_ancestor=has_favorite_ancestor, ) - stands_open = self._stands_open( - node_tag, - should_expand=should_expand, - ) + stands_open = should_expand or self._expansion.stands_open(node_tag) self._pending_specs.append( NodeSpec( node=node, @@ -480,42 +447,9 @@ def _append_spec( ) ) - def _stands_open( - self, - node_tag: str, - *, - should_expand: bool, - ) -> bool: - """Whether the row is created standing open: a filter points at it, or a memory holds it. - - Two memories answer, each holding what one hand opened. The reader's holds the rows they opened - themselves and is what a session writes down. The mode's holds the way down it opened on the - pass the reader asked for, noted as that pass resolved the filter, which stands for as long as - the mode does and folds once it goes, apart from the rows the reader's own have come to stand - on. - """ - if not self._REMEMBERS_EXPANSION: - return should_expand - - return should_expand or node_tag in self._expanded_rows or node_tag in self._mode_expanded_rows - @property def expanded_rows(self) -> Set[str]: - """The rows the reader stands open, which is the shape a session writes down.""" - return set(self._expanded_rows) - - def _set_row_expanded(self, node_tag: str, expanded: bool) -> None: - """Holds whether a row stands open, which is what a later pass brings it back by. - - A row the reader opens is theirs from then on. A row they fold is theirs to fold whichever hand - opened it, so folding it lets go of the mode's claim on it too and it stays folded. - """ - if expanded: - self._expanded_rows.add(node_tag) - return - - self._expanded_rows.discard(node_tag) - self._mode_expanded_rows.discard(node_tag) + return self._expansion.rows def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: """Folds or unfolds the row together with every row below it holding something. @@ -527,7 +461,7 @@ def _set_subtree_expanded(self, node: TreeNode, *, expanded: bool) -> None: if container.children: node_tag = self._generate_node_tag(container) dpg_set_value(node_tag, expanded) - self._set_row_expanded(node_tag, expanded) + self._expansion.remember(node_tag, expanded=expanded) def _finish_emit( self, @@ -696,7 +630,7 @@ def _read_row_expansion(self, node_tag: str) -> None: if not dpg.does_item_exist(node_tag): return - self._set_row_expanded(node_tag, bool(dpg_get_value(node_tag))) + self._expansion.remember(node_tag, expanded=bool(dpg_get_value(node_tag))) def _setup_handlers(self) -> None: for handler in self._node_handlers.values(): @@ -1033,9 +967,9 @@ def _resolve_filter(self) -> None: way_down, ) = self._resolve_favorites() if self._filter.favorites_only: - self._mode_expanded_rows |= way_down + self._expansion.follow(way_down) else: - self._release_the_rows_the_mode_opened() + self._release_mode_rows() self._auto_expand_pending = False @@ -1069,20 +1003,15 @@ def _resolve_favorites(self) -> Tuple[Optional[TreeVisibility], Set[str]]: self._way_down_to(self._auto_expanded_anchors(reached)), ) - def _way_down_to(self, anchors: Sequence[TreeNode]) -> Set[str]: - """The tags of the rows standing above the anchors, which is the way down the mode opens. + def _way_down_to(self, rows: Sequence[TreeNode]) -> Set[str]: + """The tags of the rows standing above these rows, which is a way down to them. - An anchor is a row the reader asked to be pointed at, so what opens is the rows above it while - the anchor's own row stands as the reader left it. The container both branches hang from is a - row on no screen, and stays out of the answer. + A row given here is one the browser is pointed at, so a way down opens the rows above it while + its own row stands as the reader left it. The container both branches hang from is a row on no + screen, and stays out of the answer. """ root = self.tree.get_root() - return { - self._generate_node_tag(ancestor) - for anchor in anchors - for ancestor in anchor.ancestors - if ancestor is not root - } + return {self._generate_node_tag(ancestor) for row in rows for ancestor in row.ancestors if ancestor is not root} def _auto_expanded_anchors( self, diff --git a/tests/suite/browser.py b/tests/suite/browser.py index afeaafdb6..d861995db 100644 --- a/tests/suite/browser.py +++ b/tests/suite/browser.py @@ -6,6 +6,7 @@ from sampletones_application.logic.reconstruction.browser.manager import BrowserManager from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory from sampletones_application.ui.elements.tree.filter import TreeFilter from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.spec import NodeSpec @@ -304,7 +305,7 @@ def build_browser_panel( """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = panel_tag - panel._state_expansion_memory(set() if expanded_rows is None else expanded_rows) + panel._expansion = RowExpansionMemory(set() if expanded_rows is None else expanded_rows) panel.tree_tag = TREE_TAG panel.tree = corpus.tree panel._logic = FakeTreeLogic( # type: ignore[assignment] @@ -415,7 +416,7 @@ def set_row_expanded( expanded: bool, ) -> None: """Leaves a row standing the way the reader would leave it, which the browser then remembers.""" - panel._set_row_expanded(panel._generate_node_tag(node), expanded) + panel._expansion.remember(panel._generate_node_tag(node), expanded=expanded) def set_filter( diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py b/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py index 6dc8aca81..31d88e95c 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_collapse_all.py @@ -70,5 +70,5 @@ def test_the_shape_the_control_left_is_what_the_next_pass_draws( panel._on_collapse_all_clicked() - assert panel._expanded_rows == set() + assert panel.expanded_rows == set() assert render_view(panel) == WHOLE_TREE diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion.py new file mode 100644 index 000000000..08eecd8af --- /dev/null +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion.py @@ -0,0 +1,116 @@ +from typing import Set + +import pytest + +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory + +READER_ROW: str = "panel.node_reader" +MODE_ROW: str = "panel.node_mode" +WAY_DOWN: str = "panel.node_way_down" + + +@pytest.fixture +def memory() -> RowExpansionMemory: + return RowExpansionMemory(set()) + + +class TestTheRowsAMemoryHolds: + def test_a_memory_opens_holding_the_rows_a_session_left(self) -> None: + memory = RowExpansionMemory({READER_ROW}) + + assert memory.stands_open(READER_ROW) + assert memory.rows == {READER_ROW} + + def test_a_row_no_hand_opened_stands_closed(self, memory: RowExpansionMemory) -> None: + assert not memory.stands_open(READER_ROW) + assert not memory + + def test_the_rows_a_save_writes_are_the_readers_alone(self, memory: RowExpansionMemory) -> None: + """A save writes the shape the reader built, the mode's way down being its own to hold.""" + memory.remember(READER_ROW, expanded=True) + memory.follow({MODE_ROW}) + + assert memory.rows == {READER_ROW} + assert memory.stands_open(MODE_ROW) + + def test_the_rows_a_save_reads_are_taken_apart_from_the_memory(self, memory: RowExpansionMemory) -> None: + memory.remember(READER_ROW, expanded=True) + rows: Set[str] = memory.rows + + rows.add(MODE_ROW) + + assert memory.rows == {READER_ROW} + + +class TestFollowingTheReader: + def test_a_row_the_reader_opens_is_theirs(self, memory: RowExpansionMemory) -> None: + memory.remember(READER_ROW, expanded=True) + + assert memory.rows == {READER_ROW} + + def test_a_row_the_reader_folds_lets_go_of_the_modes_claim(self, memory: RowExpansionMemory) -> None: + """A fold is the reader's word on a row whichever hand opened it, so the row stays folded.""" + memory.follow({MODE_ROW}) + + memory.remember(MODE_ROW, expanded=False) + + assert not memory.stands_open(MODE_ROW) + + def test_a_row_the_reader_folds_leaves_the_shape_a_save_writes(self, memory: RowExpansionMemory) -> None: + memory.remember(READER_ROW, expanded=True) + + memory.remember(READER_ROW, expanded=False) + + assert memory.rows == set() + + +class TestTheWayDownTheModeOpens: + def test_the_way_down_stands_open_while_the_mode_does(self, memory: RowExpansionMemory) -> None: + memory.follow({WAY_DOWN, MODE_ROW}) + + assert memory.follows_the_mode + assert memory.stands_open(WAY_DOWN) + + def test_a_release_folds_the_rows_the_mode_opened(self, memory: RowExpansionMemory) -> None: + memory.follow({WAY_DOWN, MODE_ROW}) + + memory.release(set()) + + assert not memory.follows_the_mode + assert not memory.stands_open(WAY_DOWN) + + def test_a_release_keeps_the_rows_the_readers_own_stand_on(self, memory: RowExpansionMemory) -> None: + """A row of the mode's holding one of the reader's below it becomes theirs to keep.""" + memory.follow({WAY_DOWN, MODE_ROW}) + + memory.release({WAY_DOWN}) + + assert memory.rows == {WAY_DOWN} + assert not memory.stands_open(MODE_ROW) + + def test_a_release_answers_for_the_rows_the_mode_opened_alone(self, memory: RowExpansionMemory) -> None: + """The ways down a release is handed are read off the model, and a row no hand opened stays shut.""" + memory.follow({MODE_ROW}) + + memory.release({WAY_DOWN}) + + assert memory.rows == set() + assert not memory.stands_open(WAY_DOWN) + + +class TestTheRowsTheModelStates: + def test_a_row_the_model_dropped_leaves_both_memories(self, memory: RowExpansionMemory) -> None: + memory.remember(READER_ROW, expanded=True) + memory.follow({MODE_ROW}) + + memory.hold_to({READER_ROW}) + + assert memory.rows == {READER_ROW} + assert not memory.stands_open(MODE_ROW) + + def test_holding_to_nothing_empties_the_memory(self, memory: RowExpansionMemory) -> None: + memory.remember(READER_ROW, expanded=True) + + memory.hold_to(set()) + + assert not memory diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py index e026f93e6..5b133df6f 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_expansion_memory.py @@ -308,7 +308,7 @@ def test_a_pass_over_the_whole_tree_forgets_the_rows_the_model_dropped( archive.parent = None assert render_view(panel) == WHOLE_TREE_WITHOUT_THE_ARCHIVE - assert panel._expanded_rows == set() + assert panel.expanded_rows == set() def test_a_browser_opens_with_the_rows_a_session_left_it(self, corpus: BrowserCorpus) -> None: """The shape outlives the run it was made in, so a browser is handed it as it is built.""" @@ -338,7 +338,7 @@ def test_a_pass_in_the_favorites_mode_forgets_the_rows_the_model_dropped( select_favorites(panel) render_view(panel) - assert panel._expanded_rows == set() + assert panel.expanded_rows == set() def test_the_shape_a_save_writes_is_the_rows_standing_open(self, corpus: BrowserCorpus) -> None: panel = build_browser_panel(corpus, set(), favorites_only=False) @@ -416,7 +416,7 @@ def test_the_reading_takes_the_state_the_row_stands_in( panel._read_row_expansion("row.tag") - assert panel._expanded_rows == {"row.tag"} + assert panel.expanded_rows == {"row.tag"} def test_a_row_that_left_the_tree_is_read_no_further( self, @@ -428,4 +428,4 @@ def test_a_row_that_left_the_tree_is_read_no_further( panel._read_row_expansion("row.tag") - assert panel._expanded_rows == set() + assert panel.expanded_rows == set() diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py index 7b5cd4091..86342b4d2 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites.py @@ -7,6 +7,7 @@ TAG_GLOBAL_THEME_DEFAULT, TAG_GLOBAL_THEME_FAVORITE_CHILD, ) +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory from sampletones_application.ui.elements.tree.filter import NO_FILTER from sampletones_application.ui.elements.tree.handler import NodeHandler from sampletones_application.ui.elements.tree.spec import NodeSpec @@ -81,7 +82,7 @@ def build_panel( panel._filter = NO_FILTER panel._search_visibility = None panel._favorites_visibility = None - panel._state_expansion_memory(set()) + panel._expansion = RowExpansionMemory(set()) monkeypatch.setattr(panel, "_logic", FakeTreeLogic(favorites), raising=False) monkeypatch.setattr( panel, diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py index 2bc23730d..c88768b10 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_filter.py @@ -1,5 +1,6 @@ from typing import Dict, List, Set, Type +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory from sampletones_application.ui.elements.tree.filter import NO_FILTER, TreeFilter from sampletones_application.ui.panels.reconstruction.browser import GUIReconstructionsBrowserPanel from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel @@ -51,7 +52,7 @@ def build_panel( panel._filter = NO_FILTER panel._search_visibility = None panel._favorites_visibility = None - panel._state_expansion_memory(set()) + panel._expansion = RowExpansionMemory(set()) return panel diff --git a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py index fabf85869..ddaf46487 100644 --- a/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py +++ b/tests/unit/sampletones_application/ui/panels/main/test_explorer_controls.py @@ -4,6 +4,7 @@ import pytest from sampletones_application.ui.elements.tree import tree as tree_module +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory from sampletones_application.ui.panels.main import explorer as explorer_module from sampletones_application.ui.panels.main.explorer import GUIExplorerPanel from sampletones_core.structures.tree import FileSystemNode, NodeType, Tree, TreeNode @@ -82,7 +83,7 @@ def build_panel(tree: Tree) -> GUIExplorerPanel: panel = GUIExplorerPanel.__new__(GUIExplorerPanel) panel.tag = PANEL_TAG panel.tree = tree - panel._state_expansion_memory(set()) + panel._expansion = RowExpansionMemory(set()) panel._explorer_logic = FakeExplorerLogic(tree) # type: ignore[assignment] return panel diff --git a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py index c083003cd..1eaf6f264 100644 --- a/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py +++ b/tests/unit/sampletones_application/ui/panels/shared/test_container_context_menu.py @@ -6,6 +6,7 @@ from sampletones_application.ui.elements.tree import tree as tree_module from sampletones_application.ui.elements.tree.colors import TreeColors +from sampletones_application.ui.elements.tree.expansion import RowExpansionMemory from sampletones_application.ui.elements.tree.tag import compose_node_tag from sampletones_application.ui.panels.sequencer.browser import GUISequencerBrowserPanel from sampletones_application.ui.panels.shared import browser as shared_browser_module @@ -48,7 +49,7 @@ def _panel() -> GUISequencerBrowserPanel: """ panel = GUISequencerBrowserPanel.__new__(GUISequencerBrowserPanel) panel.tag = PANEL_TAG - panel._state_expansion_memory(set()) + panel._expansion = RowExpansionMemory(set()) panel._language_manager = FakeLanguageManager(TEXTS) panel._colors = TreeColors( favorite=TEXT_COLOR, @@ -303,7 +304,7 @@ def test_the_browser_remembers_the_shape_the_item_left( panel._add_context_menu_expansion_items(group) recorder.item(EXPAND_LABEL)["callback"]() - assert panel._expanded_rows == rows + assert panel.expanded_rows == rows def test_the_browser_forgets_the_shape_the_item_folded( self, @@ -312,15 +313,17 @@ def test_the_browser_forgets_the_shape_the_item_folded( ) -> None: panel = _panel() group, sample, _ = _sample_tree() - panel._expanded_rows = { - compose_node_tag(group, panel_tag=PANEL_TAG), - compose_node_tag(sample, panel_tag=PANEL_TAG), - } + panel._expansion = RowExpansionMemory( + { + compose_node_tag(group, panel_tag=PANEL_TAG), + compose_node_tag(sample, panel_tag=PANEL_TAG), + } + ) panel._add_context_menu_expansion_items(group) recorder.item(COLLAPSE_LABEL)["callback"]() - assert panel._expanded_rows == set() + assert panel.expanded_rows == set() def test_leaf_rows_are_left_alone( self, From da6e992b9d7a3b4b5f112a2d3570df37f474772e Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 17:47:28 +0200 Subject: [PATCH 151/152] Refactored: the browser's opening mode and shape into constructor arguments --- .../ui/elements/tree/browser.py | 6 +++-- .../ui/elements/tree/tree.py | 25 ++++++------------- .../ui/panels/instruction/library.py | 1 + .../ui/panels/main/explorer.py | 3 +++ .../ui/panels/shared/browser.py | 3 +-- src/sampletones_core/configs/display.py | 8 +++++- .../ui/elements/tree/test_favorites_filter.py | 7 ------ .../sampletones_core/configs/test_display.py | 23 ++++++++++++++++- 8 files changed, 45 insertions(+), 31 deletions(-) diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index eae1cdab1..0a45e259a 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -20,7 +20,7 @@ from sampletones_application.ui.elements.tree.protocol import TreeLogicProtocol from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.elements.tree.tags import FileBrowserTags -from sampletones_application.ui.elements.tree.tree import NO_EXPANDED_ROWS, GUITreePanel +from sampletones_application.ui.elements.tree.tree import GUITreePanel from sampletones_application.ui.themes.registry import ThemeRegistry from sampletones_application.utils.gui.dpg import dpg_configure_item from sampletones_application.utils.parallelization.thread import concurrent @@ -55,7 +55,8 @@ def __init__( status_bar: GUIStatusBar, colors: TreeColors, initial_collapsed: bool, - initial_expanded_rows: AbstractSet[str] = NO_EXPANDED_ROWS, + initial_favorites_only: bool, + initial_expanded_rows: AbstractSet[str], ) -> None: self._lbl_collapse_all = language_manager["global.browser.label.collapse_all"] self._msg_collapse_all = language_manager["global.status.message.collapse_all"] @@ -70,6 +71,7 @@ def __init__( language_manager=language_manager, status_bar=status_bar, colors=colors, + initial_favorites_only=initial_favorites_only, initial_expanded_rows=initial_expanded_rows, ) diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index f4c44f5a4..2bb3f3975 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -83,6 +83,7 @@ SingleThreadExecutor, ) from sampletones_core.configs.display import ( + format_generators, format_nes_frequency, format_sample_rate, format_spectrum_method, @@ -125,15 +126,14 @@ def __init__( tag: str, tree_tag: str, tree_logic: TreeLogicProtocol, - width: int = -1, - height: int = -1, *, scheduling: SchedulingBehavior, search_label: str, language_manager: LanguageManager, status_bar: GUIStatusBar, colors: TreeColors, - initial_expanded_rows: AbstractSet[str] = NO_EXPANDED_ROWS, + initial_favorites_only: bool, + initial_expanded_rows: AbstractSet[str], ) -> None: self._language_manager = language_manager self._logic = tree_logic @@ -146,7 +146,7 @@ def __init__( self._expansion = RowExpansionMemory(initial_expanded_rows) self._emitter = TreeEmitter(scheduling=scheduling) - self._filter: TreeFilter = NO_FILTER + self._filter: TreeFilter = NO_FILTER.with_favorites_only(initial_favorites_only) self._search_visibility: Optional[TreeVisibility] = None self._favorites_visibility: Optional[TreeVisibility] = None self._auto_expand_pending: bool = False @@ -184,8 +184,8 @@ def __init__( super().__init__( tag=tag, - width=width, - height=height, + width=-1, + height=-1, ) def _launch_rebuild( @@ -388,14 +388,6 @@ def _release_mode_rows(self) -> None: self._way_down_to([node for node in root.descendants if self._generate_node_tag(node) in reader_rows]), ) - def _restore_favorites_only(self, favorites_only: bool) -> None: - """Takes the mode a session left the browser in, which its first rebuild then draws by. - - The rows a session left standing open come back with it, so the mode a browser opens in points - the reader at their favorites without opening a row. - """ - self._filter = self._filter.with_favorites_only(favorites_only) - def _get_node_handler_tag(self, node_type: NodeType) -> str: return compose_tag(self.tag, node_type.value, SUF_HANDLER_NODE) @@ -810,15 +802,12 @@ def _reconstruction_detail_items( self, fields: ConfigDirectoryFields, ) -> List[Tuple[str, str]]: - generators = ", ".join( - generator.capitalized for generator in fields.generators - ) # TODO: operation deserves a helper function return [ (self._lbl_detail_sample_rate, format_sample_rate(fields.sr)), (self._lbl_detail_nes_frequency, format_nes_frequency(fields.nf)), (self._lbl_detail_spectrum_method, format_spectrum_method(fields.sm)), (self._lbl_detail_transformation_gamma, str(fields.tg)), - (self._lbl_detail_generators, generators), + (self._lbl_detail_generators, format_generators(fields.generators)), (self._lbl_detail_configuration, short_hash(fields.ch)), ] diff --git a/src/sampletones_application/ui/panels/instruction/library.py b/src/sampletones_application/ui/panels/instruction/library.py index 4196ef4c1..5c98d0921 100644 --- a/src/sampletones_application/ui/panels/instruction/library.py +++ b/src/sampletones_application/ui/panels/instruction/library.py @@ -127,6 +127,7 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_favorites_only=False, initial_expanded_rows=initial_expanded_rows, ) diff --git a/src/sampletones_application/ui/panels/main/explorer.py b/src/sampletones_application/ui/panels/main/explorer.py index 1cbe05c89..45da542df 100644 --- a/src/sampletones_application/ui/panels/main/explorer.py +++ b/src/sampletones_application/ui/panels/main/explorer.py @@ -23,6 +23,7 @@ from sampletones_application.ui.elements.tree.spec import NodeSpec from sampletones_application.ui.elements.tree.state import TreeNodeState from sampletones_application.ui.elements.tree.tags import FileBrowserTags +from sampletones_application.ui.elements.tree.tree import NO_EXPANDED_ROWS from sampletones_application.utils.parallelization.thread import concurrent from sampletones_core.structures.tree import ( FileSystemNode, @@ -108,6 +109,8 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_favorites_only=False, + initial_expanded_rows=NO_EXPANDED_ROWS, ) @property diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 5e69b11d2..8f5ca4a13 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -72,11 +72,10 @@ def __init__( status_bar=status_bar, colors=colors, initial_collapsed=initial_collapsed, + initial_favorites_only=initial_favorites_only, initial_expanded_rows=initial_expanded_rows, ) - self._restore_favorites_only(initial_favorites_only) - @property def section_label(self) -> str: return self._language_manager["global.browser.label.browser"] diff --git a/src/sampletones_core/configs/display.py b/src/sampletones_core/configs/display.py index 80b92f032..04a1750db 100644 --- a/src/sampletones_core/configs/display.py +++ b/src/sampletones_core/configs/display.py @@ -1,11 +1,12 @@ from collections import Counter from typing import Dict, Final, Sequence, Tuple -from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.constants.enums import GeneratorName, SpectrumMethod from sampletones_shared.constants.symbols import HASH DISPLAY_SEPARATOR: Final[str] = "·" GAMMA_PREFIX: Final[str] = "γ" +GENERATOR_SEPARATOR: Final[str] = ", " DISPLAY_HASH_LENGTH: Final[int] = 7 HERTZ_UNIT: Final[str] = "Hz" @@ -44,6 +45,11 @@ def format_transformation_gamma(transformation_gamma: int) -> str: return f"{GAMMA_PREFIX}{transformation_gamma}" +def format_generators(generators: Sequence[GeneratorName]) -> str: + """Renders the generators a reconstruction was built with, in the order it names them (e.g. ``Pulse 1, Noise``).""" + return GENERATOR_SEPARATOR.join(generator.capitalized for generator in generators) + + def format_frequencies(sample_rate: int, nes_frequency: int) -> str: """Renders the rates a reconstruction runs at, audio before frame (e.g. ``44.1 kHz·30 Hz``).""" return DISPLAY_SEPARATOR.join( diff --git a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py index 7073cd2b9..c977916c2 100644 --- a/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py +++ b/tests/unit/sampletones_application/ui/elements/tree/test_favorites_filter.py @@ -706,13 +706,6 @@ def test_a_change_draws_the_rows_the_new_mode_names( assert redraws == [True] - def test_the_mode_a_session_left_on_stands_before_the_first_rebuild(self, corpus: BrowserCorpus) -> None: - panel = build_browser_panel(corpus, {corpus.paths["A/beat"]}, favorites_only=False) - - panel._restore_favorites_only(True) - - assert panel._filter.favorites_only - def test_a_query_typed_earlier_survives_a_change_of_mode( self, corpus: BrowserCorpus, diff --git a/tests/unit/sampletones_core/configs/test_display.py b/tests/unit/sampletones_core/configs/test_display.py index a5f3cee13..6d3d06174 100644 --- a/tests/unit/sampletones_core/configs/test_display.py +++ b/tests/unit/sampletones_core/configs/test_display.py @@ -7,6 +7,7 @@ DISPLAY_SEPARATOR, disambiguated_display_name, format_frequencies, + format_generators, format_nes_frequency, format_sample_rate, format_transformation, @@ -14,7 +15,7 @@ short_hash, unique_display_names, ) -from sampletones_core.constants.enums import SpectrumMethod +from sampletones_core.constants.enums import GeneratorName, SpectrumMethod class TestFormatSampleRate: @@ -42,6 +43,26 @@ def test_marks_the_gamma(self) -> None: assert format_transformation_gamma(0) == "γ0" +class TestFormatGenerators: + def test_reads_the_generators_in_the_order_they_are_given(self) -> None: + assert ( + format_generators( + [ + GeneratorName.PULSE1, + GeneratorName.TRIANGLE, + GeneratorName.NOISE, + ], + ) + == "Pulse 1, Triangle, Noise" + ) + + def test_a_lone_generator_reads_as_its_own_name(self) -> None: + assert format_generators([GeneratorName.PULSE2]) == "Pulse 2" + + def test_no_generator_reads_as_nothing(self) -> None: + assert format_generators([]) == "" + + class TestFormatFrequencies: def test_reads_audio_rate_then_frame_rate(self) -> None: assert format_frequencies(44100, 30) == "44.1 kHz·30 Hz" From fd4b942f2eee44ebd151f534b707c00fca01ddbf Mon Sep 17 00:00:00 2001 From: JakimPL Date: Tue, 18 Aug 2026 18:04:06 +0200 Subject: [PATCH 152/152] Documented: the favorites modes --- docs/development/browser.md | 30 ++++++++++--------- docs/guide/interface.md | 2 +- .../logic/main/explorer_manager.py | 5 ++-- .../ui/elements/tree/browser.py | 4 +-- .../ui/elements/tree/tree.py | 8 ++--- .../ui/panels/shared/browser.py | 7 ++--- 6 files changed, 29 insertions(+), 27 deletions(-) diff --git a/docs/development/browser.md b/docs/development/browser.md index 4c77c84c6..e764ab7bb 100644 --- a/docs/development/browser.md +++ b/docs/development/browser.md @@ -109,8 +109,8 @@ one row from the next. The browsers form one line of inheritance, each level owning what it shares: -* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the filter - they compose, the shape it holds across rebuilds — kept for it by `RowExpansionMemory` +* `GUITreePanel` (`ui/elements/tree/tree.py`) — a tree of rows: the controls it narrows by and the + filter they compose, the shape it holds across rebuilds — kept for it by `RowExpansionMemory` (`ui/elements/tree/expansion.py`) — the rebuild handshake, spec collection, themes and fonts per row, the detail tooltip, the status-bar messages, and the context-menu items every browser can offer. * `GUIFileBrowserPanel` (`ui/elements/tree/browser.py`) — a browser of files as a collapsible card: the @@ -230,10 +230,12 @@ is followed through to the memory, and a browser that keeps none leaves it empty A search unfolds by the same rule from the other end: its matches and the rows above them open for as long as the query stands, resolved afresh on each pass, and clearing the query folds them back. -The shape outlives the run as well. A browser is handed the rows it stands open as it is built -(`initial_expanded_rows`), and `_persist_application_state` asks each tab for its shape and writes it to -`ApplicationState.expanded_rows` under the panel's tag. Reading it the once at exit keeps the session -free of a write per row per pass, a pass running on the tree worker. +The shape outlives the run as well. A browser is handed the mode and the rows it opens with as it is +built (`initial_favorites_only`, `initial_expanded_rows`). A change of mode is written where it happens, +through `on_favorites_filter_changed`, and the shape is asked for the once, at exit: +`_persist_application_state` takes each tab's rows into `ApplicationState.expanded_rows` under the +panel's tag, so a pass holds what it opened in memory, on the tree worker, and the session file reads it +from there. **The Main tab's explorer remembers folders, not rows.** Its rows are the folders on disk, read a level at a time as the reader opens one, so `ExplorerManager` holds two facts about a folder: whether its @@ -243,14 +245,14 @@ folded away is loaded and closed — and the open one is the shape a session wri `_expand_path_to`, reading every folder it needs once, and the folders that are no longer directories are dropped as the manager is built. -**What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing -each row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents` — -and the anchors the preference follows are read out of that one answer. What it materialises is the starred rows and the rows -above them, and what reaches DearPyGui is the drawn rows alone: on a directory holding hundreds of -thousands of reconstructions, a favorites-only browser creates widgets for the starred ones and their -headings. A keystroke resolves the query alone, the drawn rows being the mode's to state. A favorite -toggled while the mode is on redraws the browser, so starring a row brings it in and unstarring one -takes it out along with what it held. +**What the mode costs.** Resolving it walks the model once per rebuild, on the tree worker, testing each +row with `is_node_favorite` and `has_favorite_ancestor` — set lookups over `filepath.parents` — and the +anchors the preference follows are read out of that one answer. What it materialises is the starred rows +and the rows above them, and what reaches DearPyGui is the drawn rows alone: on a directory holding +hundreds of thousands of reconstructions, a favorites-only browser creates widgets for the starred ones +and their headings. A keystroke resolves the query alone, the drawn rows being the mode's to state. A +favorite toggled while the mode is on redraws the browser, so starring a row brings it in and unstarring +one takes it out along with what it held. A rebuild that drew no row fills the cleared tree with the message naming the criterion that came back empty (`global.dialog.message.tree_no_favorites`, `global.dialog.message.tree_no_results`), so the diff --git a/docs/guide/interface.md b/docs/guide/interface.md index adc8ca927..6ff99732e 100644 --- a/docs/guide/interface.md +++ b/docs/guide/interface.md @@ -55,7 +55,7 @@ while it narrows, so switching the tick on and off leaves the tree as you left i If you would rather it opened its way down to each favorite for you, turn that on under **View ▸ Auto-expand favorites**, which answers for reconstructions and for folders separately. It opens the way down each time you tick **Favorites only**, -and the rows it opened fold back as soon as you untick it. +and unticking folds those rows back. **Collapse all**, beside the refresh button, folds the whole tree away in one click. Whatever you leave open is remembered, so the tree comes back the way you diff --git a/src/sampletones_application/logic/main/explorer_manager.py b/src/sampletones_application/logic/main/explorer_manager.py index 5fdaf755f..0458af984 100644 --- a/src/sampletones_application/logic/main/explorer_manager.py +++ b/src/sampletones_application/logic/main/explorer_manager.py @@ -46,8 +46,9 @@ def __init__( def refresh_tree(self) -> None: """Reads the filesystem afresh, down to every folder the tree has to show a row for. - A refresh builds the tree from nothing, so each folder it needs is read once into it: reading a - folder twice would replace the rows below it, and with them the folders already read under it. + A refresh builds the tree from nothing, so ``_loaded_directories`` starts empty and each folder + it needs is read into it once: the rows a read places under a folder stand as the walk carries + on deeper. """ self._loaded_directories.clear() container_root = TreeNode( diff --git a/src/sampletones_application/ui/elements/tree/browser.py b/src/sampletones_application/ui/elements/tree/browser.py index 0a45e259a..2428bd6eb 100644 --- a/src/sampletones_application/ui/elements/tree/browser.py +++ b/src/sampletones_application/ui/elements/tree/browser.py @@ -174,8 +174,8 @@ def _on_refresh_clicked(self) -> None: def _on_collapse_all_clicked(self) -> None: """Folds every row of the tree away, leaving the reader the level the tree opens at. - The rows are reached through the model rather than the widget tree, so one pass covers a - branch however deep it runs, and the browser is told what each row now stands as. + The rows are reached through the model, so one pass covers a branch however deep it runs, + and the browser is told what each row now stands as. """ root = self.tree.get_root() if root is None: diff --git a/src/sampletones_application/ui/elements/tree/tree.py b/src/sampletones_application/ui/elements/tree/tree.py index 2bb3f3975..f63f5db68 100644 --- a/src/sampletones_application/ui/elements/tree/tree.py +++ b/src/sampletones_application/ui/elements/tree/tree.py @@ -252,8 +252,8 @@ def _collect_specs(self, root_tag: str) -> List[NodeSpec]: def _forget_rows_the_model_dropped(self) -> None: """Holds the memory of open rows to the rows the model states, read afresh on every pass. - A row the memory holds that the model no longer states belongs to a folder the disk has lost, so - its place goes with it. The model is what the answer is read from, so a browser opening in the + A row the memory holds beyond the rows the model states belongs to a folder the disk has lost, + so its place goes with it. The model is what the answer is read from, so a browser opening in the favorites mode — or opening on a session written before the reconstructions directory moved — drops what is gone. """ @@ -1053,8 +1053,8 @@ def _is_node_anchored(self, node: TreeNode) -> bool: row above it is reached, which is how the sample branch answers: its headings carry no path, so the variants are where the star arrives. - Asked of the rows the star reaches, so a row it declines stands under a row it named, and - the reader is pointed at the folder rather than at everything inside it. + Asked of the rows the star reaches, so a row it declines stands under a row it named: the + reader is pointed at the folder, and the rows inside it stand as they were. """ if self._logic.is_node_favorite(node): return True diff --git a/src/sampletones_application/ui/panels/shared/browser.py b/src/sampletones_application/ui/panels/shared/browser.py index 8f5ca4a13..35652a6be 100644 --- a/src/sampletones_application/ui/panels/shared/browser.py +++ b/src/sampletones_application/ui/panels/shared/browser.py @@ -232,10 +232,9 @@ def _on_reconstruction_node_double_clicked( def _show_container_context_menu(self, node: TreeNode) -> None: """Offers what a row the browser invents can answer: what it gathers, and how it folds. - A group or a sample stands for a facet of the reconstructions below it rather than for a path - on disk, so its menu reads the subtree — how many reconstructions it gathers, the rows folding - under it, the label the tree shows it by, and for a sample the audio its reconstructions were - made from. + A group or a sample stands for a facet of the reconstructions below it, so its menu reads the + subtree — how many reconstructions it gathers, the rows folding under it, the label the tree + shows it by, and for a sample the audio its reconstructions were made from. """ if node.node_type not in (NodeType.GROUP, NodeType.SAMPLE): return